fix: fail on empty cv text and strengthen warnings
This commit is contained in:
parent
c4b9fc13ce
commit
0f6e641a99
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user