fix: fail on empty cv text and strengthen warnings

This commit is contained in:
Antoine 2026-05-28 19:11:27 +02:00
parent c4b9fc13ce
commit 0f6e641a99
6 changed files with 151 additions and 19 deletions

View File

@ -42,6 +42,9 @@ def build_profile(
"""Build candidate-profile.yaml from CV and markdown profile."""
cv_text = extract_pdf_text(cv) if cv.suffix.lower() == ".pdf" else cv.read_text(encoding="utf-8")
if not cv_text.strip():
raise ValueError("No readable text found in CV input")
authored_profile = parse_profile_markdown(profile.read_text(encoding="utf-8"))
cv_signals = extract_cv_signals(cv_text)
candidate_profile = build_candidate_profile_output(cv_signals, authored_profile)

View File

@ -158,6 +158,10 @@ def extract_pdf_text(path: Path) -> str:
for text in (page.extract_text() or "" for page in reader.pages)
if text.strip()
]
if not page_texts:
raise ValueError("No extractable text found in PDF CV")
return "\n".join(page_texts)

View File

@ -1,5 +1,7 @@
from __future__ import annotations
import re
from job_research.models import (
CandidateProfileOutput,
EducationEntry,
@ -17,21 +19,21 @@ EXPERIENCE_NOTE_MARKERS = (
)
MISSING_CV_FACT_WARNINGS = (
("name", "No candidate name was extracted from the CV."),
("experience_entries", "No experience entries were extracted from the CV."),
("skills", "No skills were extracted from the CV."),
("education_entries", "No education entries were extracted from the CV."),
)
def build_candidate_profile_output(
cv_signals: dict[str, object], authored: AuthoredProfile
) -> CandidateProfileOutput:
warnings: list[WarningItem] = []
if "years_of_experience" in cv_signals and _notes_mention_experience(authored.notes):
warnings.append(
WarningItem(
field="years_of_experience",
message=(
"CV-derived experience may differ from markdown interpretation. "
"Review manually."
),
)
)
_append_years_of_experience_warning(cv_signals, authored.notes, warnings)
_append_missing_cv_fact_warnings(cv_signals, warnings)
merged_skills: list[str] = []
for skill in [
@ -62,10 +64,48 @@ def build_candidate_profile_output(
)
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
def _append_years_of_experience_warning(
cv_signals: dict[str, object], notes: list[str], warnings: list[WarningItem]
) -> None:
years_of_experience = cv_signals.get("years_of_experience")
if years_of_experience is None:
return
return False
for note in notes:
note_years_of_experience = _note_years_of_experience(note)
if note_years_of_experience is None or note_years_of_experience == years_of_experience:
continue
warnings.append(
WarningItem(
field="years_of_experience",
message=(
"CV-derived years of experience does not match a markdown note. "
"Review manually."
),
)
)
return
def _append_missing_cv_fact_warnings(
cv_signals: dict[str, object], warnings: list[WarningItem]
) -> None:
for field, message in MISSING_CV_FACT_WARNINGS:
if cv_signals.get(field):
continue
warnings.append(WarningItem(field=field, message=message))
def _note_years_of_experience(note: str) -> int | None:
normalized = note.casefold().replace("", "'")
if not any(marker in normalized for marker in EXPERIENCE_NOTE_MARKERS):
return None
matches = re.findall(r"\b(\d{1,2})\b", normalized)
if len(matches) != 1:
return None
return int(matches[0])

View File

@ -393,3 +393,22 @@ def test_extract_pdf_text_skips_blank_pages(monkeypatch) -> None:
assert extracted == "Tonio\nData Engineer"
assert seen_paths == ["cv.pdf"]
def test_extract_pdf_text_rejects_textless_pdfs(monkeypatch) -> None:
class FakePage:
def extract_text(self) -> str | None:
return " "
class FakePdfReader:
def __init__(self, path: str) -> None:
self.path = path
self.pages = [FakePage(), FakePage()]
def fake_pdf_reader(path: str) -> FakePdfReader:
return FakePdfReader(path)
monkeypatch.setattr("job_research.profile.cv_extractor.PdfReader", fake_pdf_reader)
with pytest.raises(ValueError, match="No extractable text"):
extract_pdf_text(Path("cv.pdf"))

View File

@ -25,7 +25,7 @@ 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"
assert any(item.field == "years_of_experience" for item in output.warnings)
def test_build_candidate_profile_output_writes_warning_for_french_experience_note() -> None:
@ -49,4 +49,26 @@ def test_build_candidate_profile_output_writes_warning_for_french_experience_not
output = build_candidate_profile_output(cv_signals, authored)
assert output.warnings[0].field == "years_of_experience"
assert any(item.field == "years_of_experience" for item in output.warnings)
def test_build_candidate_profile_output_warns_on_missing_core_cv_facts() -> None:
cv_signals = {
"location": "France",
"languages": ["French", "English"],
"skills": [],
"experience_entries": [],
"education_entries": [],
}
authored = AuthoredProfile(
summary="Junior data engineer focused on GCP."
)
output = build_candidate_profile_output(cv_signals, authored)
assert [item.field for item in output.warnings] == [
"name",
"experience_entries",
"skills",
"education_entries",
]

View File

@ -61,7 +61,7 @@ def test_build_profile_writes_yaml_from_cv_and_profile(tmp_path) -> None:
## Notes
- Slight preference for French listings.
- Experience should stay visible.
- Years of experience feels closer to 4.
"""
).strip(),
encoding="utf-8",
@ -145,3 +145,47 @@ def test_build_profile_reports_when_no_warnings_are_included(tmp_path) -> None:
assert result.returncode == 0
assert f"candidate profile written to {out}" in result.stdout
assert "No warnings included." in result.stdout
def test_build_profile_rejects_empty_cv_text_before_writing(tmp_path) -> None:
cv = tmp_path / "cv.txt"
cv.write_text(" \n", encoding="utf-8")
profile = tmp_path / "profile.md"
profile.write_text(
dedent(
"""
# Candidate Profile
## Summary
Junior data engineer.
## Target Roles
- Data Engineer
## Strengths
- Python
## Skills To Emphasize
- BigQuery
## Constraints
- CDI only
## Notes
- Slight preference for French listings.
"""
).strip(),
encoding="utf-8",
)
out = tmp_path / "candidate-profile.yaml"
result = run(
["uv", "run", "job-research", "build-profile", "--cv", str(cv), "--profile", str(profile), "--out", str(out)],
capture_output=True,
text=True,
check=False,
)
assert result.returncode != 0
assert not out.exists()
assert "No readable text found in CV input" in result.stderr