From a4c1ff3c0bae1e7f17d1dc297d33ebfa6f3196dd Mon Sep 17 00:00:00 2001 From: Antoine Date: Thu, 28 May 2026 16:56:31 +0200 Subject: [PATCH] fix: broaden cv heuristics for french cvs Handle French experience connectors, common degree forms, and en/em dash bullets while keeping the extraction deterministic and narrow. --- src/job_research/profile/cv_extractor.py | 84 ++++++++++++++++++------ tests/profile/test_cv_extractor.py | 65 ++++++++++++++++++ 2 files changed, 128 insertions(+), 21 deletions(-) diff --git a/src/job_research/profile/cv_extractor.py b/src/job_research/profile/cv_extractor.py index 8f1da56..089ce65 100644 --- a/src/job_research/profile/cv_extractor.py +++ b/src/job_research/profile/cv_extractor.py @@ -11,10 +11,14 @@ EXPERIENCE_TITLE_ENDINGS = { "analyst", "architect", "consultant", + "développeur", + "developpeur", "data engineer", "data scientist", "developer", "director", + "ingénieur", + "ingenieur", "engineer", "lead", "manager", @@ -23,6 +27,8 @@ EXPERIENCE_TITLE_ENDINGS = { "specialist", } +EXPERIENCE_LINE_CONNECTORS = (" at ", " chez ", " au ") + EXPERIENCE_PROSE_MARKERS = { "after", "before", @@ -55,9 +61,16 @@ EDUCATION_TITLE_KEYWORDS = { "bachelor", "degree", "diploma", + "diplome d'ingenieur", + "diplome d'ingénieur", + "diplôme d'ingénieur", + "diplôme d'ingenieur", "engineering school", + "bac+5", "licence", + "bsc", "master", + "msc", } EDUCATION_INSTITUTION_MARKERS = { @@ -83,7 +96,7 @@ EDUCATION_INSTITUTION_MARKERS = { EDUCATION_ENTRY_SEPARATORS = (" at ", ", ", " - ", " – ", " — ") -LEADING_BULLET_MARKERS = {"-", "*", "+", "•", "‣", "∙", "●", "◦"} +LEADING_BULLET_MARKERS = {"-", "*", "+", "•", "‣", "∙", "●", "◦", "–", "—"} def extract_pdf_text(path: Path) -> str: @@ -107,24 +120,45 @@ def extract_cv_signals(text: str) -> dict[str, object]: experience_entries: list[dict[str, str]] = [] education_entries: list[dict[str, str]] = [] in_education_section = False + pending_education_credential: str | None = None for line in non_empty_lines[1:]: lowered = line.lower() if lowered.startswith("education:"): in_education_section = True + pending_education_credential = None remainder = line.split(":", 1)[1].strip() if remainder: education_entry = _parse_education_entry(remainder) if education_entry: education_entries.append(education_entry) + elif _looks_like_education_credential(remainder): + pending_education_credential = remainder continue if in_education_section: + if pending_education_credential and _looks_like_institution(line): + education_entries.append( + { + "credential": pending_education_credential, + "institution": line, + } + ) + pending_education_credential = None + continue + education_entry = _parse_education_entry(line) if education_entry: education_entries.append(education_entry) + pending_education_credential = None continue + + if _looks_like_education_credential(line): + pending_education_credential = line + continue + + pending_education_credential = None in_education_section = False if lowered.startswith("location:"): @@ -140,14 +174,9 @@ def extract_cv_signals(text: str) -> dict[str, object]: if education_entry: education_entries.append(education_entry) continue - if _looks_like_experience_line(line): - title, company = line.split(" at ", 1) - experience_entries.append( - { - "title": title.strip(), - "company": company.strip(), - } - ) + experience_entry = _parse_experience_entry(line) + if experience_entry: + experience_entries.append(experience_entry) return { "name": name, @@ -206,19 +235,32 @@ def _looks_like_institution(text: str) -> bool: return any(marker in lowered for marker in EDUCATION_INSTITUTION_MARKERS) +def _parse_experience_entry(line: str) -> dict[str, str] | None: + for connector in EXPERIENCE_LINE_CONNECTORS: + if line.count(connector) != 1: + continue + + title, company = (part.strip() for part in line.split(connector, 1)) + if not title or not company: + continue + + title_words = title.split() + if title_words[-1].lower() not in EXPERIENCE_TITLE_ENDINGS: + continue + + if _looks_like_prose_company(company): + continue + + return { + "title": title, + "company": company, + } + + return None + + def _looks_like_experience_line(line: str) -> bool: - if line.count(" at ") != 1: - return False - - title, company = (part.strip() for part in line.split(" at ", 1)) - if not title or not company: - return False - - title_words = title.split() - if title_words[-1].lower() not in EXPERIENCE_TITLE_ENDINGS: - return False - - return not _looks_like_prose_company(company) + return _parse_experience_entry(line) is not None def _looks_like_prose_company(company: str) -> bool: diff --git a/tests/profile/test_cv_extractor.py b/tests/profile/test_cv_extractor.py index f926e57..279e4d1 100644 --- a/tests/profile/test_cv_extractor.py +++ b/tests/profile/test_cv_extractor.py @@ -90,6 +90,41 @@ def test_extract_cv_signals_normalizes_bullet_prefixed_fields_and_experience() - ] +def test_extract_cv_signals_normalizes_en_dash_bullet_prefixed_fields_and_experience() -> None: + text = dedent( + """ + Tonio + – Location: France + — Data Engineer at Company A + """ + ).strip() + + extracted = extract_cv_signals(text) + + assert extracted["location"] == "France" + assert extracted["experience_entries"] == [ + {"title": "Data Engineer", "company": "Company A"} + ] + + +def test_extract_cv_signals_parses_french_experience_connectors() -> None: + text = dedent( + """ + Tonio + Location: France + Ingénieur chez Dassault Systèmes + Développeur au CNRS + """ + ).strip() + + extracted = extract_cv_signals(text) + + assert extracted["experience_entries"] == [ + {"title": "Ingénieur", "company": "Dassault Systèmes"}, + {"title": "Développeur", "company": "CNRS"}, + ] + + def test_extract_cv_signals_ignores_in_paris_prose_tail() -> None: text = dedent( """ @@ -124,6 +159,36 @@ def test_extract_cv_signals_extracts_education_entries_after_heading() -> None: ] +def test_extract_cv_signals_extracts_common_french_education_entries() -> None: + text = dedent( + """ + Tonio + Location: France + Education: + Diplôme d'ingénieur + CentraleSupélec + MSc + University of Paris + BSc + University of Oxford + Bac+5 + École Polytechnique + """ + ).strip() + + extracted = extract_cv_signals(text) + + assert extracted["education_entries"] == [ + { + "credential": "Diplôme d'ingénieur", + "institution": "CentraleSupélec", + }, + {"credential": "MSc", "institution": "University of Paris"}, + {"credential": "BSc", "institution": "University of Oxford"}, + {"credential": "Bac+5", "institution": "École Polytechnique"}, + ] + + def test_extract_cv_signals_ignores_before_moving_prose_tail() -> None: text = dedent( """