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