fix: parse cv bullets and education entries

Normalize leading bullet markers before parsing and extract simple education entries from heading-led or keyword-plus-institution lines.
This commit is contained in:
Antoine 2026-05-28 16:43:11 +02:00
parent fe201831ba
commit 0eac9856da
2 changed files with 139 additions and 2 deletions

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import re
from pathlib import Path
from pypdf import PdfReader
@ -50,6 +51,40 @@ EXPERIENCE_PROSE_MARKERS = {
"working",
}
EDUCATION_TITLE_KEYWORDS = {
"bachelor",
"degree",
"diploma",
"engineering school",
"licence",
"master",
}
EDUCATION_INSTITUTION_MARKERS = {
"academy",
"college",
"centrale",
"ecole",
"école",
"ens",
"epita",
"epitech",
"hec",
"imt",
"institute",
"institut",
"insa",
"polytech",
"school",
"university",
"universite",
"université",
}
EDUCATION_ENTRY_SEPARATORS = (" at ", ", ", " - ", " ", "")
LEADING_BULLET_MARKERS = {"-", "*", "+", "", "", "", "", ""}
def extract_pdf_text(path: Path) -> str:
reader = PdfReader(str(path))
@ -62,7 +97,7 @@ def extract_pdf_text(path: Path) -> str:
def extract_cv_signals(text: str) -> dict[str, object]:
lines = [line.strip() for line in text.splitlines()]
lines = [_normalize_line(line) for line in text.splitlines()]
non_empty_lines = [line for line in lines if line]
name = non_empty_lines[0] if non_empty_lines else None
@ -70,9 +105,28 @@ def extract_cv_signals(text: str) -> dict[str, object]:
languages: list[str] = []
skills: list[str] = []
experience_entries: list[dict[str, str]] = []
education_entries: list[dict[str, str]] = []
in_education_section = False
for line in non_empty_lines[1:]:
lowered = line.lower()
if lowered.startswith("education:"):
in_education_section = True
remainder = line.split(":", 1)[1].strip()
if remainder:
education_entry = _parse_education_entry(remainder)
if education_entry:
education_entries.append(education_entry)
continue
if in_education_section:
education_entry = _parse_education_entry(line)
if education_entry:
education_entries.append(education_entry)
continue
in_education_section = False
if lowered.startswith("location:"):
location = line.split(":", 1)[1].strip() or None
continue
@ -82,6 +136,10 @@ def extract_cv_signals(text: str) -> dict[str, object]:
if lowered.startswith("skills:"):
skills = _parse_csv_field(line)
continue
education_entry = _parse_education_entry(line)
if education_entry:
education_entries.append(education_entry)
continue
if _looks_like_experience_line(line):
title, company = line.split(" at ", 1)
experience_entries.append(
@ -97,7 +155,7 @@ def extract_cv_signals(text: str) -> dict[str, object]:
"languages": languages,
"skills": skills,
"experience_entries": experience_entries,
"education_entries": [],
"education_entries": education_entries,
}
@ -106,6 +164,48 @@ def _parse_csv_field(line: str) -> list[str]:
return [item.strip() for item in value.split(",") if item.strip()]
def _normalize_line(line: str) -> str:
stripped = line.strip()
if not stripped:
return ""
if stripped[0] in LEADING_BULLET_MARKERS:
return stripped[1:].lstrip()
return stripped
def _parse_education_entry(line: str) -> dict[str, str] | None:
for separator in EDUCATION_ENTRY_SEPARATORS:
if separator not in line:
continue
left, right = (part.strip() for part in line.split(separator, 1))
if not left or not right:
continue
if _looks_like_education_credential(left) and _looks_like_institution(right):
return {"credential": left, "institution": right}
if _looks_like_education_credential(right) and _looks_like_institution(left):
return {"credential": right, "institution": left}
return None
def _looks_like_education_credential(text: str) -> bool:
lowered = text.lower()
return any(
re.search(rf"\b{re.escape(keyword)}\b", lowered)
for keyword in EDUCATION_TITLE_KEYWORDS
)
def _looks_like_institution(text: str) -> bool:
lowered = text.lower()
return any(marker in lowered for marker in EDUCATION_INSTITUTION_MARKERS)
def _looks_like_experience_line(line: str) -> bool:
if line.count(" at ") != 1:
return False

View File

@ -73,6 +73,23 @@ def test_extract_cv_signals_ignores_prose_after_company_name() -> None:
assert extracted["experience_entries"] == []
def test_extract_cv_signals_normalizes_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_ignores_in_paris_prose_tail() -> None:
text = dedent(
"""
@ -87,6 +104,26 @@ def test_extract_cv_signals_ignores_in_paris_prose_tail() -> None:
assert extracted["experience_entries"] == []
def test_extract_cv_signals_extracts_education_entries_after_heading() -> None:
text = dedent(
"""
Tonio
Location: France
Education:
- Master of Science in Data Science at University of Paris
"""
).strip()
extracted = extract_cv_signals(text)
assert extracted["education_entries"] == [
{
"credential": "Master of Science in Data Science",
"institution": "University of Paris",
}
]
def test_extract_cv_signals_ignores_before_moving_prose_tail() -> None:
text = dedent(
"""