fix: extract years of experience and accept French notes

This commit is contained in:
Antoine 2026-05-28 18:18:02 +02:00
parent 3c331ef687
commit 1879de68a8
4 changed files with 95 additions and 4 deletions

View File

@ -145,6 +145,11 @@ EDUCATION_ENTRY_SEPARATORS = (" at ", ", ", " - ", " ", " — ")
LEADING_BULLET_MARKERS = {"-", "*", "+", "", "", "", "", "", "", ""}
YEARS_OF_EXPERIENCE_PATTERNS = (
re.compile(r"^years of experience\s*:\s*(\d+)\s*$", re.IGNORECASE),
re.compile(r"^ann[ée]es d[']exp[ée]rience\s*:\s*(\d+)\s*$", re.IGNORECASE),
)
def extract_pdf_text(path: Path) -> str:
reader = PdfReader(str(path))
@ -166,12 +171,18 @@ def extract_cv_signals(text: str) -> dict[str, object]:
skills: list[str] = []
experience_entries: list[dict[str, str]] = []
education_entries: list[dict[str, str]] = []
years_of_experience: int | None = None
in_education_section = False
pending_education_credential: str | None = None
for line in non_empty_lines[1:]:
lowered = line.lower()
years_of_experience_line = _parse_years_of_experience(line)
if years_of_experience_line is not None:
years_of_experience = years_of_experience_line
continue
if lowered.startswith(("education:", "formation:")):
in_education_section = True
pending_education_credential = None
@ -225,7 +236,7 @@ def extract_cv_signals(text: str) -> dict[str, object]:
if experience_entry:
experience_entries.append(experience_entry)
return {
payload = {
"name": name,
"location": location,
"languages": languages,
@ -234,6 +245,11 @@ def extract_cv_signals(text: str) -> dict[str, object]:
"education_entries": education_entries,
}
if years_of_experience is not None:
payload["years_of_experience"] = years_of_experience
return payload
def _parse_csv_field(line: str) -> list[str]:
_, value = line.split(":", 1)
@ -251,6 +267,17 @@ def _normalize_line(line: str) -> str:
return stripped
def _parse_years_of_experience(line: str) -> int | None:
normalized = line.replace("", "'")
for pattern in YEARS_OF_EXPERIENCE_PATTERNS:
match = pattern.match(normalized)
if match:
return int(match.group(1))
return None
def _parse_education_entry(line: str) -> dict[str, str] | None:
for separator in EDUCATION_ENTRY_SEPARATORS:
if separator not in line:

View File

@ -9,14 +9,20 @@ from job_research.models import (
from job_research.profile.profile_parser import AuthoredProfile
EXPERIENCE_NOTE_MARKERS = (
"experience",
"expérience",
"années d'expérience",
"annees d'experience",
)
def build_candidate_profile_output(
cv_signals: dict[str, object], authored: AuthoredProfile
) -> CandidateProfileOutput:
warnings: list[WarningItem] = []
if "years_of_experience" in cv_signals and any(
"experience" in note.lower() for note in authored.notes
):
if "years_of_experience" in cv_signals and _notes_mention_experience(authored.notes):
warnings.append(
WarningItem(
field="years_of_experience",
@ -54,3 +60,12 @@ def build_candidate_profile_output(
education_entries=[EducationEntry.model_validate(item) for item in education_entries],
warnings=warnings,
)
def _notes_mention_experience(notes: list[str]) -> bool:
for note in notes:
normalized = note.casefold().replace("", "'")
if any(marker in normalized for marker in EXPERIENCE_NOTE_MARKERS):
return True
return False

View File

@ -1,6 +1,8 @@
from textwrap import dedent
from pathlib import Path
import pytest
from job_research.profile.cv_extractor import extract_cv_signals
from job_research.profile.cv_extractor import extract_pdf_text
@ -27,6 +29,29 @@ def test_extract_cv_signals_reads_basic_fields_from_text() -> None:
assert len(extracted["experience_entries"]) == 2
@pytest.mark.parametrize(
("line", "expected"),
[
("Years of experience: 2", 2),
("Années d'expérience : 3", 3),
],
)
def test_extract_cv_signals_extracts_years_of_experience_from_explicit_line(
line: str, expected: int
) -> None:
text = dedent(
f"""
Tonio
Location: France
{line}
"""
).strip()
extracted = extract_cv_signals(text)
assert extracted["years_of_experience"] == expected
def test_extract_cv_signals_allows_single_word_titles() -> None:
text = dedent(
"""

View File

@ -26,3 +26,27 @@ def test_build_candidate_profile_output_writes_warning_when_facts_conflict() ->
assert output.summary == "Junior data engineer focused on GCP."
assert output.constraints == ["CDI only"]
assert output.warnings[0].field == "years_of_experience"
def test_build_candidate_profile_output_writes_warning_for_french_experience_note() -> None:
cv_signals = {
"name": "Tonio",
"location": "France",
"languages": ["French", "English"],
"skills": ["Python", "SQL"],
"experience_entries": [{"title": "Data Engineer", "company": "A"}],
"education_entries": [],
"years_of_experience": 2,
}
authored = AuthoredProfile(
summary="Junior data engineer focused on GCP.",
target_roles=["Data Engineer"],
strengths=["Python"],
skills_to_emphasize=["BigQuery", "GCP"],
constraints=["CDI only"],
notes=["Années d'expérience semble plus proche de 3."],
)
output = build_candidate_profile_output(cv_signals, authored)
assert output.warnings[0].field == "years_of_experience"