From 1879de68a8b0be6f9db88cdfda9a4603e0e2d898 Mon Sep 17 00:00:00 2001 From: Antoine Date: Thu, 28 May 2026 18:18:02 +0200 Subject: [PATCH] fix: extract years of experience and accept French notes --- src/job_research/profile/cv_extractor.py | 29 +++++++++++++++++++++++- src/job_research/profile/merge.py | 21 ++++++++++++++--- tests/profile/test_cv_extractor.py | 25 ++++++++++++++++++++ tests/profile/test_merge.py | 24 ++++++++++++++++++++ 4 files changed, 95 insertions(+), 4 deletions(-) diff --git a/src/job_research/profile/cv_extractor.py b/src/job_research/profile/cv_extractor.py index 25c5b76..3675a57 100644 --- a/src/job_research/profile/cv_extractor.py +++ b/src/job_research/profile/cv_extractor.py @@ -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: diff --git a/src/job_research/profile/merge.py b/src/job_research/profile/merge.py index 8718e23..97af3f9 100644 --- a/src/job_research/profile/merge.py +++ b/src/job_research/profile/merge.py @@ -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 diff --git a/tests/profile/test_cv_extractor.py b/tests/profile/test_cv_extractor.py index ede5758..a7a915f 100644 --- a/tests/profile/test_cv_extractor.py +++ b/tests/profile/test_cv_extractor.py @@ -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( """ diff --git a/tests/profile/test_merge.py b/tests/profile/test_merge.py index 0892a9a..f3cf74d 100644 --- a/tests/profile/test_merge.py +++ b/tests/profile/test_merge.py @@ -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"