fix: extract years of experience and accept French notes
This commit is contained in:
parent
3c331ef687
commit
1879de68a8
@ -145,6 +145,11 @@ EDUCATION_ENTRY_SEPARATORS = (" at ", ", ", " - ", " – ", " — ")
|
||||
|
||||
LEADING_BULLET_MARKERS = {"-", "*", "+", "•", "‣", "∙", "●", "◦", "–", "—"}
|
||||
|
||||
YEARS_OF_EXPERIENCE_PATTERNS = (
|
||||
re.compile(r"^years of experience\s*:\s*(\d+)\s*$", re.IGNORECASE),
|
||||
re.compile(r"^ann[ée]es d['’]exp[ée]rience\s*:\s*(\d+)\s*$", re.IGNORECASE),
|
||||
)
|
||||
|
||||
|
||||
def extract_pdf_text(path: Path) -> str:
|
||||
reader = PdfReader(str(path))
|
||||
@ -166,12 +171,18 @@ def extract_cv_signals(text: str) -> dict[str, object]:
|
||||
skills: list[str] = []
|
||||
experience_entries: list[dict[str, str]] = []
|
||||
education_entries: list[dict[str, str]] = []
|
||||
years_of_experience: int | None = None
|
||||
in_education_section = False
|
||||
pending_education_credential: str | None = None
|
||||
|
||||
for line in non_empty_lines[1:]:
|
||||
lowered = line.lower()
|
||||
|
||||
years_of_experience_line = _parse_years_of_experience(line)
|
||||
if years_of_experience_line is not None:
|
||||
years_of_experience = years_of_experience_line
|
||||
continue
|
||||
|
||||
if lowered.startswith(("education:", "formation:")):
|
||||
in_education_section = True
|
||||
pending_education_credential = None
|
||||
@ -225,7 +236,7 @@ def extract_cv_signals(text: str) -> dict[str, object]:
|
||||
if experience_entry:
|
||||
experience_entries.append(experience_entry)
|
||||
|
||||
return {
|
||||
payload = {
|
||||
"name": name,
|
||||
"location": location,
|
||||
"languages": languages,
|
||||
@ -234,6 +245,11 @@ def extract_cv_signals(text: str) -> dict[str, object]:
|
||||
"education_entries": education_entries,
|
||||
}
|
||||
|
||||
if years_of_experience is not None:
|
||||
payload["years_of_experience"] = years_of_experience
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def _parse_csv_field(line: str) -> list[str]:
|
||||
_, value = line.split(":", 1)
|
||||
@ -251,6 +267,17 @@ def _normalize_line(line: str) -> str:
|
||||
return stripped
|
||||
|
||||
|
||||
def _parse_years_of_experience(line: str) -> int | None:
|
||||
normalized = line.replace("’", "'")
|
||||
|
||||
for pattern in YEARS_OF_EXPERIENCE_PATTERNS:
|
||||
match = pattern.match(normalized)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _parse_education_entry(line: str) -> dict[str, str] | None:
|
||||
for separator in EDUCATION_ENTRY_SEPARATORS:
|
||||
if separator not in line:
|
||||
|
||||
@ -9,14 +9,20 @@ from job_research.models import (
|
||||
from job_research.profile.profile_parser import AuthoredProfile
|
||||
|
||||
|
||||
EXPERIENCE_NOTE_MARKERS = (
|
||||
"experience",
|
||||
"expérience",
|
||||
"années d'expérience",
|
||||
"annees d'experience",
|
||||
)
|
||||
|
||||
|
||||
def build_candidate_profile_output(
|
||||
cv_signals: dict[str, object], authored: AuthoredProfile
|
||||
) -> CandidateProfileOutput:
|
||||
warnings: list[WarningItem] = []
|
||||
|
||||
if "years_of_experience" in cv_signals and any(
|
||||
"experience" in note.lower() for note in authored.notes
|
||||
):
|
||||
if "years_of_experience" in cv_signals and _notes_mention_experience(authored.notes):
|
||||
warnings.append(
|
||||
WarningItem(
|
||||
field="years_of_experience",
|
||||
@ -54,3 +60,12 @@ def build_candidate_profile_output(
|
||||
education_entries=[EducationEntry.model_validate(item) for item in education_entries],
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def _notes_mention_experience(notes: list[str]) -> bool:
|
||||
for note in notes:
|
||||
normalized = note.casefold().replace("’", "'")
|
||||
if any(marker in normalized for marker in EXPERIENCE_NOTE_MARKERS):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
from textwrap import dedent
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from job_research.profile.cv_extractor import extract_cv_signals
|
||||
from job_research.profile.cv_extractor import extract_pdf_text
|
||||
|
||||
@ -27,6 +29,29 @@ def test_extract_cv_signals_reads_basic_fields_from_text() -> None:
|
||||
assert len(extracted["experience_entries"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("line", "expected"),
|
||||
[
|
||||
("Years of experience: 2", 2),
|
||||
("Années d'expérience : 3", 3),
|
||||
],
|
||||
)
|
||||
def test_extract_cv_signals_extracts_years_of_experience_from_explicit_line(
|
||||
line: str, expected: int
|
||||
) -> None:
|
||||
text = dedent(
|
||||
f"""
|
||||
Tonio
|
||||
Location: France
|
||||
{line}
|
||||
"""
|
||||
).strip()
|
||||
|
||||
extracted = extract_cv_signals(text)
|
||||
|
||||
assert extracted["years_of_experience"] == expected
|
||||
|
||||
|
||||
def test_extract_cv_signals_allows_single_word_titles() -> None:
|
||||
text = dedent(
|
||||
"""
|
||||
|
||||
@ -26,3 +26,27 @@ def test_build_candidate_profile_output_writes_warning_when_facts_conflict() ->
|
||||
assert output.summary == "Junior data engineer focused on GCP."
|
||||
assert output.constraints == ["CDI only"]
|
||||
assert output.warnings[0].field == "years_of_experience"
|
||||
|
||||
|
||||
def test_build_candidate_profile_output_writes_warning_for_french_experience_note() -> None:
|
||||
cv_signals = {
|
||||
"name": "Tonio",
|
||||
"location": "France",
|
||||
"languages": ["French", "English"],
|
||||
"skills": ["Python", "SQL"],
|
||||
"experience_entries": [{"title": "Data Engineer", "company": "A"}],
|
||||
"education_entries": [],
|
||||
"years_of_experience": 2,
|
||||
}
|
||||
authored = AuthoredProfile(
|
||||
summary="Junior data engineer focused on GCP.",
|
||||
target_roles=["Data Engineer"],
|
||||
strengths=["Python"],
|
||||
skills_to_emphasize=["BigQuery", "GCP"],
|
||||
constraints=["CDI only"],
|
||||
notes=["Années d'expérience semble plus proche de 3."],
|
||||
)
|
||||
|
||||
output = build_candidate_profile_output(cv_signals, authored)
|
||||
|
||||
assert output.warnings[0].field == "years_of_experience"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user