From d27c58014d4d9f8cf2630bdd40ccef55ec16935f Mon Sep 17 00:00:00 2001 From: Antoine Date: Thu, 28 May 2026 18:26:09 +0200 Subject: [PATCH] feat: add one-command candidate profile build flow --- src/job_research/cli.py | 46 +++++++++++++- tests/test_cli.py | 131 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 1 deletion(-) diff --git a/src/job_research/cli.py b/src/job_research/cli.py index 7509326..77e4434 100644 --- a/src/job_research/cli.py +++ b/src/job_research/cli.py @@ -1,5 +1,12 @@ +from pathlib import Path + import typer +from job_research.profile.cv_extractor import extract_cv_signals, extract_pdf_text +from job_research.profile.merge import build_candidate_profile_output +from job_research.profile.profile_parser import parse_profile_markdown +from job_research.storage import save_candidate_profile_yaml + app = typer.Typer(help="Build one canonical candidate profile YAML") @@ -8,9 +15,46 @@ def main_command() -> None: pass @app.command("build-profile") -def build_profile() -> None: +def build_profile( + cv: Path = typer.Option( + ..., + "--cv", + exists=True, + dir_okay=False, + readable=True, + help="Path to the CV PDF or UTF-8 text file.", + ), + profile: Path = typer.Option( + ..., + "--profile", + exists=True, + dir_okay=False, + readable=True, + help="Path to the light-template markdown profile.", + ), + out: Path = typer.Option( + Path("data/candidate-profile.yaml"), + "--out", + dir_okay=False, + help="Path to write the canonical YAML profile.", + ), +) -> None: """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") + 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) + + save_candidate_profile_yaml(out, candidate_profile) + + typer.echo(f"candidate-profile.yaml written to {out}") + warning_count = len(candidate_profile.warnings) + if warning_count: + typer.echo(f"Warnings included: {warning_count}") + else: + typer.echo("No warnings included.") + def main() -> None: app() diff --git a/tests/test_cli.py b/tests/test_cli.py index 02f2c54..f7474e2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,7 @@ from subprocess import run +from textwrap import dedent + +from job_research.storage import load_yaml def test_installed_cli_help_exposes_build_profile_subcommand() -> None: @@ -14,3 +17,131 @@ def test_installed_cli_subcommand_help_works() -> None: assert result.returncode == 0 assert "Usage: job-research build-profile" in result.stdout + + +def test_build_profile_writes_yaml_from_cv_and_profile(tmp_path) -> None: + cv = tmp_path / "cv.txt" + cv.write_text( + dedent( + """ + Tonio Example + Location: France + Languages: French, English + Skills: Python, SQL, Terraform + Years of experience: 3 + Data Engineer at Acme + Education: Master of Science at Example University + """ + ).strip(), + encoding="utf-8", + ) + profile = tmp_path / "profile.md" + profile.write_text( + dedent( + """ + # Candidate Profile + + ## Summary + Junior data engineer focused on Python and GCP. + + ## Target Roles + - Data Engineer + + ## Strengths + - Python + - SQL + + ## Skills To Emphasize + - GCP + - BigQuery + + ## Constraints + - CDI only + - France only + + ## Notes + - Slight preference for French listings. + - Experience should stay visible. + """ + ).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 f"candidate-profile.yaml written to {out}" in result.stdout + assert "Warnings included: 1" in result.stdout + + payload = load_yaml(out) + + assert payload["name"] == "Tonio Example" + assert payload["summary"] == "Junior data engineer focused on Python and GCP." + assert payload["target_roles"] == ["Data Engineer"] + assert payload["skills"] == ["Python", "SQL", "Terraform", "GCP", "BigQuery"] + assert payload["warnings"][0]["field"] == "years_of_experience" + + +def test_build_profile_reports_when_no_warnings_are_included(tmp_path) -> None: + cv = tmp_path / "cv.txt" + cv.write_text( + dedent( + """ + Tonio Example + Location: France + Languages: French, English + Skills: Python, SQL + Data Engineer at Acme + Education: Master of Science at Example University + """ + ).strip(), + encoding="utf-8", + ) + profile = tmp_path / "profile.md" + profile.write_text( + dedent( + """ + # Candidate Profile + + ## Summary + Junior data engineer focused on Python and GCP. + + ## Target Roles + - Data Engineer + + ## Strengths + - Python + - SQL + + ## Skills To Emphasize + - GCP + - BigQuery + + ## Constraints + - CDI only + - France 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 f"candidate-profile.yaml written to {out}" in result.stdout + assert "No warnings included." in result.stdout