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:
parent
0eac9856da
commit
a4c1ff3c0b
@ -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:
|
||||
|
||||
@ -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(
|
||||
"""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user