From 0f6e641a99d78edcd3d765c4bc752e3b88cb19b6 Mon Sep 17 00:00:00 2001 From: Antoine Date: Thu, 28 May 2026 19:11:27 +0200 Subject: [PATCH] fix: fail on empty cv text and strengthen warnings --- src/job_research/cli.py | 3 + src/job_research/profile/cv_extractor.py | 4 ++ src/job_research/profile/merge.py | 72 ++++++++++++++++++------ tests/profile/test_cv_extractor.py | 19 +++++++ tests/profile/test_merge.py | 26 ++++++++- tests/test_cli.py | 46 ++++++++++++++- 6 files changed, 151 insertions(+), 19 deletions(-) diff --git a/src/job_research/cli.py b/src/job_research/cli.py index 50b6b3a..7de8e05 100644 --- a/src/job_research/cli.py +++ b/src/job_research/cli.py @@ -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) diff --git a/src/job_research/profile/cv_extractor.py b/src/job_research/profile/cv_extractor.py index 3675a57..1ccf55b 100644 --- a/src/job_research/profile/cv_extractor.py +++ b/src/job_research/profile/cv_extractor.py @@ -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) diff --git a/src/job_research/profile/merge.py b/src/job_research/profile/merge.py index 97af3f9..263fbac 100644 --- a/src/job_research/profile/merge.py +++ b/src/job_research/profile/merge.py @@ -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]) diff --git a/tests/profile/test_cv_extractor.py b/tests/profile/test_cv_extractor.py index a7a915f..7a9bc44 100644 --- a/tests/profile/test_cv_extractor.py +++ b/tests/profile/test_cv_extractor.py @@ -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")) diff --git a/tests/profile/test_merge.py b/tests/profile/test_merge.py index f3cf74d..1aeecbc 100644 --- a/tests/profile/test_merge.py +++ b/tests/profile/test_merge.py @@ -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", + ] diff --git a/tests/test_cli.py b/tests/test_cli.py index 822999f..d5a2388 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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