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.
This commit is contained in:
Antoine 2026-05-28 16:56:31 +02:00
parent 0eac9856da
commit a4c1ff3c0b
2 changed files with 128 additions and 21 deletions

View File

@ -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:

View File

@ -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(
"""