job-research/docs/superpowers/plans/2026-05-26-candidate-profile-ingestion.md
2026-05-27 16:09:28 +02:00

36 KiB

Candidate Profile Ingestion Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a CLI-first local workflow that ingests a PDF CV and structured markdown profile, surfaces factual conflicts, applies YAML overrides, and persists a trustworthy final Candidate Profile plus saved default Search Preferences.

Architecture: The implementation uses a layered state pipeline with deterministic extraction first. Machine-generated state is written to replaceable YAML, user corrections live in a separate override YAML, and downstream consumers read only the final merged profile and saved preferences.

Tech Stack: Python 3.13, Typer, Pydantic v2, PyYAML, pypdf, pytest, Rich


File Map

  • Create: src/job_research/__init__.py — package marker
  • Create: src/job_research/cli.py — Typer command surface for the first slice
  • Create: src/job_research/models.py — Pydantic models for profile state and preferences
  • Create: src/job_research/storage.py — path helpers and YAML/JSON persistence helpers
  • Create: src/job_research/preferences.py — validation and persistence of saved Search Preferences
  • Create: src/job_research/profile/__init__.py — profile package marker
  • Create: src/job_research/profile/cv_extractor.py — deterministic PDF text extraction and basic CV signal parsing
  • Create: src/job_research/profile/profile_parser.py — structured markdown profile parser
  • Create: src/job_research/profile/merge.py — generated profile assembly plus review-required conflict detection
  • Create: src/job_research/profile/materialize.py — override application and final profile materialization
  • Create: tests/conftest.py — shared temp data root fixture
  • Create: tests/test_cli.py — CLI integration tests
  • Create: tests/test_preferences.py — search preference tests
  • Create: tests/test_storage.py — persistence and path helper tests
  • Create: tests/profile/test_cv_extractor.py — CV extraction tests
  • Create: tests/profile/test_profile_parser.py — markdown parser tests
  • Create: tests/profile/test_merge.py — merge and conflict tests
  • Create: tests/profile/test_materialize.py — override application tests
  • Create: docs/profile-template.md — structured markdown template the user authors
  • Modify: pyproject.toml — dependencies and package metadata

Task 1: Project Skeleton and Dependencies

Files:

  • Modify: pyproject.toml

  • Create: src/job_research/__init__.py

  • Create: src/job_research/cli.py

  • Create: tests/test_cli.py

  • Step 1: Write the failing CLI smoke test

# tests/test_cli.py
from typer.testing import CliRunner

from job_research.cli import app


def test_cli_help_lists_first_slice_commands() -> None:
    result = CliRunner().invoke(app, ["--help"])

    assert result.exit_code == 0
    assert "build-profile" in result.stdout
    assert "apply-overrides" in result.stdout
    assert "set-preferences" in result.stdout
    assert "show-profile" in result.stdout
    assert "show-preferences" in result.stdout
  • Step 2: Run the smoke test to verify it fails

Run: uv run pytest tests/test_cli.py::test_cli_help_lists_first_slice_commands -v Expected: FAIL with ModuleNotFoundError: No module named 'job_research'

  • Step 3: Add package dependencies and a minimal Typer app
# pyproject.toml
[project]
name = "job-research"
version = "0.1.0"
description = "Local job discovery tooling"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
  "pydantic>=2.7,<3",
  "pypdf>=5.0,<6",
  "pyyaml>=6.0,<7",
  "rich>=13.7,<14",
  "typer>=0.12,<1",
]

[dependency-groups]
dev = [
  "pytest>=8.2,<9",
]

[project.scripts]
job-research = "job_research.cli:main"
# src/job_research/__init__.py
__all__ = ["__version__"]

__version__ = "0.1.0"
# src/job_research/cli.py
import typer

app = typer.Typer(help="Local candidate profile ingestion workflow")


@app.command("build-profile")
def build_profile() -> None:
    """Build generated candidate profile state."""


@app.command("apply-overrides")
def apply_overrides() -> None:
    """Apply override YAML to generated profile state."""


@app.command("set-preferences")
def set_preferences() -> None:
    """Persist saved default search preferences."""


@app.command("show-profile")
def show_profile() -> None:
    """Display the final candidate profile."""


@app.command("show-preferences")
def show_preferences() -> None:
    """Display saved search preferences."""


def main() -> None:
    app()


if __name__ == "__main__":
    main()
  • Step 4: Run the smoke test to verify it passes

Run: uv run pytest tests/test_cli.py::test_cli_help_lists_first_slice_commands -v Expected: PASS

  • Step 5: Commit the skeleton
git add pyproject.toml src/job_research/__init__.py src/job_research/cli.py tests/test_cli.py
git commit -m "feat: add candidate profile CLI skeleton"

Task 2: Models and Storage Helpers

Files:

  • Create: src/job_research/models.py

  • Create: src/job_research/storage.py

  • Create: tests/conftest.py

  • Create: tests/test_storage.py

  • Step 1: Write failing tests for storage paths and YAML round-trip

# tests/conftest.py
from pathlib import Path

import pytest


@pytest.fixture
def data_root(tmp_path: Path) -> Path:
    return tmp_path / "data"
# tests/test_storage.py
from job_research.models import CandidateProfile
from job_research.storage import final_profile_path, save_yaml, load_yaml, ensure_data_dirs


def test_ensure_data_dirs_creates_expected_layout(data_root) -> None:
    ensure_data_dirs(data_root)

    assert (data_root / "profiles" / "cv").is_dir()
    assert (data_root / "profiles" / "generated").is_dir()
    assert (data_root / "profiles" / "overrides").is_dir()
    assert (data_root / "profiles" / "final").is_dir()
    assert (data_root / "preferences").is_dir()


def test_save_yaml_round_trips_candidate_profile(data_root) -> None:
    ensure_data_dirs(data_root)
    profile = CandidateProfile(
        name="Tonio",
        summary="Junior data engineer profile",
        location="France",
        languages=["French", "English"],
        target_roles=["Data Engineer"],
        seniority="junior",
        skills=["Python", "SQL", "BigQuery"],
        industries=[],
        preferred_contract_types=["CDI"],
        years_of_experience=3,
        experience_entries=[],
        education_entries=[],
        exclusion_rules=[],
        profile_notes=[],
    )

    path = final_profile_path(data_root)
    save_yaml(path, profile.model_dump(mode="json"))

    loaded = load_yaml(path)

    assert loaded["name"] == "Tonio"
    assert loaded["preferred_contract_types"] == ["CDI"]
  • Step 2: Run the storage tests to verify they fail

Run: uv run pytest tests/test_storage.py -v Expected: FAIL with ImportError for missing job_research.models or job_research.storage

  • Step 3: Implement core models and storage helpers
# src/job_research/models.py
from typing import Literal

from pydantic import BaseModel, Field


class ExperienceEntry(BaseModel):
    company: str
    title: str
    start: str | None = None
    end: str | None = None
    highlights: list[str] = Field(default_factory=list)


class EducationEntry(BaseModel):
    institution: str
    credential: str
    start: str | None = None
    end: str | None = None


class CandidateProfile(BaseModel):
    name: str | None = None
    summary: str | None = None
    location: str | None = None
    languages: list[str] = Field(default_factory=list)
    profile_notes: list[str] = Field(default_factory=list)
    target_roles: list[str] = Field(default_factory=list)
    seniority: str | None = None
    skills: list[str] = Field(default_factory=list)
    industries: list[str] = Field(default_factory=list)
    preferred_contract_types: list[str] = Field(default_factory=list)
    years_of_experience: int | None = None
    experience_entries: list[ExperienceEntry] = Field(default_factory=list)
    education_entries: list[EducationEntry] = Field(default_factory=list)
    exclusion_rules: list[str] = Field(default_factory=list)


class ReviewItem(BaseModel):
    field: str
    cv_value: str
    profile_value: str
    reason: str


class GeneratedProfileState(BaseModel):
    candidate_profile: CandidateProfile
    review_required: list[ReviewItem] = Field(default_factory=list)
    ambiguities: list[str] = Field(default_factory=list)


class SearchPreferences(BaseModel):
    desired_titles: list[str] = Field(default_factory=list)
    required_keywords: list[str] = Field(default_factory=list)
    excluded_keywords: list[str] = Field(default_factory=list)
    target_locations: list[str] = Field(default_factory=list)
    remote_policy: Literal["any", "remote", "hybrid", "onsite"] = "any"
    company_types: list[str] = Field(default_factory=list)
    board_filters: list[str] = Field(default_factory=list)
    language_preference: Literal["french_preferred", "equal"] = "french_preferred"
    include_stretch_opportunities: bool = True
# src/job_research/storage.py
from __future__ import annotations

import json
from pathlib import Path
from typing import Any

import yaml


def ensure_data_dirs(data_root: Path) -> None:
    for path in [
        data_root / "profiles" / "cv",
        data_root / "profiles" / "authored",
        data_root / "profiles" / "generated",
        data_root / "profiles" / "overrides",
        data_root / "profiles" / "final",
        data_root / "preferences",
    ]:
        path.mkdir(parents=True, exist_ok=True)


def generated_profile_path(data_root: Path) -> Path:
    return data_root / "profiles" / "generated" / "candidate-profile.generated.yaml"


def cv_raw_text_path(data_root: Path) -> Path:
    return data_root / "profiles" / "cv" / "raw-text.txt"


def override_profile_path(data_root: Path) -> Path:
    return data_root / "profiles" / "overrides" / "candidate-profile.override.yaml"


def final_profile_path(data_root: Path) -> Path:
    return data_root / "profiles" / "final" / "candidate-profile.yaml"


def preferences_path(data_root: Path) -> Path:
    return data_root / "preferences" / "search-preferences.yaml"


def save_yaml(path: Path, payload: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(yaml.safe_dump(payload, sort_keys=False, allow_unicode=True), encoding="utf-8")


def load_yaml(path: Path) -> dict[str, Any]:
    return yaml.safe_load(path.read_text(encoding="utf-8")) or {}


def save_json(path: Path, payload: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
  • Step 4: Run the storage tests to verify they pass

Run: uv run pytest tests/test_storage.py -v Expected: PASS

  • Step 5: Commit models and storage
git add src/job_research/models.py src/job_research/storage.py tests/conftest.py tests/test_storage.py
git commit -m "feat: add profile state models and storage helpers"

Task 3: Structured Markdown Profile Parser

Files:

  • Create: src/job_research/profile/__init__.py

  • Create: src/job_research/profile/profile_parser.py

  • Create: tests/profile/test_profile_parser.py

  • Create: docs/profile-template.md

  • Step 1: Write the failing parser test with a concrete markdown template

# tests/profile/test_profile_parser.py
from textwrap import dedent

from job_research.profile.profile_parser import parse_profile_markdown


def test_parse_profile_markdown_reads_required_sections() -> None:
    markdown = dedent(
        """
        # Candidate Profile

        ## Summary
        Junior data engineer focused on Python and GCP.

        ## Strengths
        - Python
        - SQL
        - BigQuery

        ## Target Roles
        - Data Engineer
        - Analytics Engineer

        ## Skills To Emphasize
        - Terraform
        - GCP

        ## Factual Corrections
        - years_of_experience: 3

        ## Notes
        Prefer roles that mention warehousing and ELT.
        """
    ).strip()

    authored = parse_profile_markdown(markdown)

    assert authored.summary == "Junior data engineer focused on Python and GCP."
    assert authored.strengths == ["Python", "SQL", "BigQuery"]
    assert authored.target_roles == ["Data Engineer", "Analytics Engineer"]
    assert authored.skills_to_emphasize == ["Terraform", "GCP"]
    assert authored.factual_corrections == {"years_of_experience": "3"}
    assert authored.notes == ["Prefer roles that mention warehousing and ELT."]
  • Step 2: Run the parser test to verify it fails

Run: uv run pytest tests/profile/test_profile_parser.py -v Expected: FAIL with ImportError for missing job_research.profile.profile_parser

  • Step 3: Implement the authored profile model, parser, and template
# src/job_research/profile/__init__.py
__all__ = ["profile_parser"]
# src/job_research/profile/profile_parser.py
from __future__ import annotations

from collections import defaultdict
from dataclasses import dataclass, field


@dataclass
class AuthoredProfile:
    summary: str | None = None
    strengths: list[str] = field(default_factory=list)
    target_roles: list[str] = field(default_factory=list)
    skills_to_emphasize: list[str] = field(default_factory=list)
    factual_corrections: dict[str, str] = field(default_factory=dict)
    notes: list[str] = field(default_factory=list)


def parse_profile_markdown(markdown: str) -> AuthoredProfile:
    sections: dict[str, list[str]] = defaultdict(list)
    current_section: str | None = None

    for raw_line in markdown.splitlines():
        line = raw_line.strip()
        if line.startswith("## "):
            current_section = line[3:].strip().lower()
            continue
        if not line or current_section is None:
            continue
        sections[current_section].append(line)

    profile = AuthoredProfile()
    profile.summary = " ".join(sections.get("summary", [])) or None
    profile.strengths = [line[2:].strip() for line in sections.get("strengths", []) if line.startswith("- ")]
    profile.target_roles = [line[2:].strip() for line in sections.get("target roles", []) if line.startswith("- ")]
    profile.skills_to_emphasize = [line[2:].strip() for line in sections.get("skills to emphasize", []) if line.startswith("- ")]
    profile.notes = [line[2:].strip() if line.startswith("- ") else line for line in sections.get("notes", [])]

    for line in sections.get("factual corrections", []):
        if not line.startswith("- ") or ":" not in line:
            continue
        key, value = line[2:].split(":", 1)
        profile.factual_corrections[key.strip()] = value.strip()

    return profile
# docs/profile-template.md
# Candidate Profile

## Summary
One short paragraph describing your target profile.

## Strengths
- Python
- SQL

## Target Roles
- Data Engineer

## Skills To Emphasize
- GCP
- BigQuery

## Factual Corrections
- years_of_experience: 3

## Notes
Anything the CV parser might miss but future ranking should understand.
  • Step 4: Run the parser test to verify it passes

Run: uv run pytest tests/profile/test_profile_parser.py -v Expected: PASS

  • Step 5: Commit the markdown parser
git add src/job_research/profile/__init__.py src/job_research/profile/profile_parser.py tests/profile/test_profile_parser.py docs/profile-template.md
git commit -m "feat: add structured markdown profile parsing"

Task 4: Deterministic CV Extraction

Files:

  • Create: src/job_research/profile/cv_extractor.py

  • Create: tests/profile/test_cv_extractor.py

  • Step 1: Write the failing CV extraction tests

# tests/profile/test_cv_extractor.py
from job_research.profile.cv_extractor import extract_cv_signals


def test_extract_cv_signals_reads_basic_profile_fields() -> None:
    text = """
    Tonio
    Location: France
    Years of experience: 2
    Languages: French, English
    Skills: Python, SQL, Terraform, GCP, BigQuery
    Experience:
    Data Engineer at Company A
    Analytics Engineer at Company B
    """

    extracted = extract_cv_signals(text)

    assert extracted.name == "Tonio"
    assert extracted.location == "France"
    assert extracted.years_of_experience == 2
    assert extracted.languages == ["French", "English"]
    assert "Python" in extracted.skills
    assert len(extracted.experience_entries) == 2
  • Step 2: Run the CV extraction tests to verify they fail

Run: uv run pytest tests/profile/test_cv_extractor.py -v Expected: FAIL with ImportError for missing job_research.profile.cv_extractor

  • Step 3: Implement PDF text extraction and simple signal parsing
# src/job_research/profile/cv_extractor.py
from __future__ import annotations

from pathlib import Path

from pypdf import PdfReader

from job_research.models import CandidateProfile, ExperienceEntry


def extract_pdf_text(path: Path) -> str:
    reader = PdfReader(str(path))
    return "\n".join(page.extract_text() or "" for page in reader.pages)


def extract_cv_signals(text: str) -> CandidateProfile:
    lines = [line.strip() for line in text.splitlines() if line.strip()]
    profile = CandidateProfile()
    if lines:
        profile.name = lines[0]

    for line in lines:
        lower = line.lower()
        if lower.startswith("location:"):
            profile.location = line.split(":", 1)[1].strip()
        elif lower.startswith("years of experience:"):
            profile.years_of_experience = int(line.split(":", 1)[1].strip())
        elif lower.startswith("languages:"):
            profile.languages = [part.strip() for part in line.split(":", 1)[1].split(",") if part.strip()]
        elif lower.startswith("skills:"):
            profile.skills = [part.strip() for part in line.split(":", 1)[1].split(",") if part.strip()]
        elif " at " in line and not lower.startswith("skills:"):
            title, company = line.split(" at ", 1)
            profile.experience_entries.append(ExperienceEntry(company=company.strip(), title=title.strip()))

    return profile
  • Step 4: Run the CV extraction tests to verify they pass

Run: uv run pytest tests/profile/test_cv_extractor.py -v Expected: PASS

  • Step 5: Commit the CV extractor
git add src/job_research/profile/cv_extractor.py tests/profile/test_cv_extractor.py
git commit -m "feat: add deterministic CV extraction"

Task 5: Generated Profile Merge and Conflict Detection

Files:

  • Create: src/job_research/profile/merge.py

  • Create: tests/profile/test_merge.py

  • Step 1: Write the failing merge test for factual conflict handling

# tests/profile/test_merge.py
from job_research.models import CandidateProfile, ExperienceEntry
from job_research.profile.merge import build_generated_profile
from job_research.profile.profile_parser import AuthoredProfile


def test_build_generated_profile_flags_factual_conflicts() -> None:
    cv_profile = CandidateProfile(
        name="Tonio",
        location="France",
        years_of_experience=2,
        skills=["Python", "SQL"],
        target_roles=[],
        languages=["French", "English"],
        seniority="junior",
        industries=[],
        preferred_contract_types=["CDI"],
        experience_entries=[ExperienceEntry(company="A", title="Data Engineer")],
        education_entries=[],
        exclusion_rules=[],
        profile_notes=[],
    )
    authored = AuthoredProfile(
        summary="Junior data engineer focused on GCP.",
        strengths=["Python"],
        target_roles=["Data Engineer"],
        skills_to_emphasize=["GCP", "BigQuery"],
        factual_corrections={"years_of_experience": "3"},
        notes=["Prefer warehousing roles."],
    )

    generated = build_generated_profile(cv_profile, authored)

    assert generated.candidate_profile.summary == "Junior data engineer focused on GCP."
    assert generated.review_required[0].field == "years_of_experience"
    assert generated.review_required[0].cv_value == "2"
    assert generated.review_required[0].profile_value == "3"
  • Step 2: Run the merge test to verify it fails

Run: uv run pytest tests/profile/test_merge.py -v Expected: FAIL with ImportError for missing job_research.profile.merge

  • Step 3: Implement generated profile assembly with review-required conflicts
# src/job_research/profile/merge.py
from __future__ import annotations

from job_research.models import CandidateProfile, GeneratedProfileState, ReviewItem
from job_research.profile.profile_parser import AuthoredProfile


def build_generated_profile(cv_profile: CandidateProfile, authored: AuthoredProfile) -> GeneratedProfileState:
    merged = cv_profile.model_copy(deep=True)
    merged.summary = authored.summary or merged.summary
    merged.target_roles = authored.target_roles or merged.target_roles
    merged.profile_notes = [*merged.profile_notes, *authored.notes]

    for skill in [*authored.strengths, *authored.skills_to_emphasize]:
        if skill not in merged.skills:
            merged.skills.append(skill)

    review_required: list[ReviewItem] = []
    if "years_of_experience" in authored.factual_corrections:
        profile_value = authored.factual_corrections["years_of_experience"]
        cv_value = "" if merged.years_of_experience is None else str(merged.years_of_experience)
        if cv_value and profile_value != cv_value:
            review_required.append(
                ReviewItem(
                    field="years_of_experience",
                    cv_value=cv_value,
                    profile_value=profile_value,
                    reason="Markdown profile disagrees with extracted CV fact.",
                )
            )

    return GeneratedProfileState(candidate_profile=merged, review_required=review_required, ambiguities=[])
  • Step 4: Run the merge test to verify it passes

Run: uv run pytest tests/profile/test_merge.py -v Expected: PASS

  • Step 5: Commit generated profile merging
git add src/job_research/profile/merge.py tests/profile/test_merge.py
git commit -m "feat: add generated profile merge and conflict detection"

Task 6: Override Application and Final Profile Materialization

Files:

  • Create: src/job_research/profile/materialize.py

  • Create: tests/profile/test_materialize.py

  • Step 1: Write the failing override application test

# tests/profile/test_materialize.py
from job_research.models import CandidateProfile, GeneratedProfileState, ReviewItem
from job_research.profile.materialize import apply_profile_overrides


def test_apply_profile_overrides_resolves_review_required_fields() -> None:
    generated = GeneratedProfileState(
        candidate_profile=CandidateProfile(
            name="Tonio",
            years_of_experience=2,
            skills=["Python"],
            languages=["French"],
            target_roles=["Data Engineer"],
            seniority="junior",
            industries=[],
            preferred_contract_types=["CDI"],
            experience_entries=[],
            education_entries=[],
            exclusion_rules=[],
            profile_notes=[],
        ),
        review_required=[
            ReviewItem(
                field="years_of_experience",
                cv_value="2",
                profile_value="3",
                reason="conflict",
            )
        ],
        ambiguities=[],
    )

    final = apply_profile_overrides(generated, {"years_of_experience": 3})

    assert final.years_of_experience == 3
  • Step 2: Run the override test to verify it fails

Run: uv run pytest tests/profile/test_materialize.py -v Expected: FAIL with ImportError for missing job_research.profile.materialize

  • Step 3: Implement override application
# src/job_research/profile/materialize.py
from __future__ import annotations

from typing import Any

from job_research.models import CandidateProfile, GeneratedProfileState


def apply_profile_overrides(generated: GeneratedProfileState, overrides: dict[str, Any]) -> CandidateProfile:
    payload = generated.candidate_profile.model_dump(mode="json")

    for key, value in overrides.items():
        if key in payload:
            payload[key] = value

    return CandidateProfile.model_validate(payload)
  • Step 4: Run the override test to verify it passes

Run: uv run pytest tests/profile/test_materialize.py -v Expected: PASS

  • Step 5: Commit final profile materialization
git add src/job_research/profile/materialize.py tests/profile/test_materialize.py
git commit -m "feat: add profile override materialization"

Task 7: Search Preferences Validation and Persistence

Files:

  • Create: src/job_research/preferences.py

  • Create: tests/test_preferences.py

  • Step 1: Write the failing search preferences persistence test

# tests/test_preferences.py
from job_research.preferences import save_search_preferences, load_search_preferences


def test_save_search_preferences_persists_yaml(data_root) -> None:
    prefs = {
        "desired_titles": ["Data Engineer", "Analytics Engineer"],
        "required_keywords": ["Python", "SQL"],
        "excluded_keywords": ["Freelance"],
        "target_locations": ["France"],
        "remote_policy": "any",
        "company_types": [],
        "board_filters": ["apec"],
        "language_preference": "french_preferred",
        "include_stretch_opportunities": True,
    }

    save_search_preferences(data_root, prefs)
    loaded = load_search_preferences(data_root)

    assert loaded.desired_titles == ["Data Engineer", "Analytics Engineer"]
    assert loaded.language_preference == "french_preferred"
  • Step 2: Run the search preferences test to verify it fails

Run: uv run pytest tests/test_preferences.py -v Expected: FAIL with ImportError for missing job_research.preferences

  • Step 3: Implement search preference persistence
# src/job_research/preferences.py
from __future__ import annotations

from pathlib import Path

from job_research.models import SearchPreferences
from job_research.storage import preferences_path, save_yaml, load_yaml, ensure_data_dirs


def save_search_preferences(data_root: Path, payload: dict) -> SearchPreferences:
    ensure_data_dirs(data_root)
    preferences = SearchPreferences.model_validate(payload)
    save_yaml(preferences_path(data_root), preferences.model_dump(mode="json"))
    return preferences


def load_search_preferences(data_root: Path) -> SearchPreferences:
    payload = load_yaml(preferences_path(data_root))
    return SearchPreferences.model_validate(payload)
  • Step 4: Run the search preferences test to verify it passes

Run: uv run pytest tests/test_preferences.py -v Expected: PASS

  • Step 5: Commit search preference persistence
git add src/job_research/preferences.py tests/test_preferences.py
git commit -m "feat: add saved search preferences persistence"

Task 8: End-to-End CLI Workflow for Profile Generation

Files:

  • Modify: src/job_research/cli.py

  • Modify: tests/test_cli.py

  • Step 1: Write the failing CLI workflow tests

# tests/test_cli.py
from pathlib import Path

from typer.testing import CliRunner

from job_research.cli import app


def test_build_profile_writes_generated_yaml(tmp_path: Path) -> None:
    cv_path = tmp_path / "cv.txt"
    profile_path = tmp_path / "profile.md"
    cv_path.write_text(
        "Tonio\nLocation: France\nYears of experience: 2\nLanguages: French, English\nSkills: Python, SQL\n",
        encoding="utf-8",
    )
    profile_path.write_text(
        "# Candidate Profile\n\n## Summary\nJunior data engineer.\n\n## Strengths\n- Python\n",
        encoding="utf-8",
    )

    result = CliRunner().invoke(
        app,
        [
            "build-profile",
            "--cv",
            str(cv_path),
            "--profile",
            str(profile_path),
            "--data-root",
            str(tmp_path / "data"),
        ],
    )

    assert result.exit_code == 0
    assert (tmp_path / "data" / "profiles" / "generated" / "candidate-profile.generated.yaml").exists()
    assert (tmp_path / "data" / "profiles" / "cv" / "raw-text.txt").exists()


def test_build_profile_returns_review_required_exit_code(tmp_path: Path) -> None:
    cv_path = tmp_path / "cv.txt"
    profile_path = tmp_path / "profile.md"
    cv_path.write_text(
        "Tonio\nLocation: France\nYears of experience: 2\nLanguages: French, English\nSkills: Python, SQL\n",
        encoding="utf-8",
    )
    profile_path.write_text(
        "# Candidate Profile\n\n## Summary\nJunior data engineer.\n\n## Factual Corrections\n- years_of_experience: 3\n",
        encoding="utf-8",
    )

    result = CliRunner().invoke(
        app,
        [
            "build-profile",
            "--cv",
            str(cv_path),
            "--profile",
            str(profile_path),
            "--data-root",
            str(tmp_path / "data"),
        ],
    )

    assert result.exit_code == 10
    assert "review required" in result.stdout.lower()


def test_show_profile_prints_final_yaml(tmp_path: Path) -> None:
    final_path = tmp_path / "data" / "profiles" / "final"
    final_path.mkdir(parents=True)
    (final_path / "candidate-profile.yaml").write_text("name: Tonio\n", encoding="utf-8")

    result = CliRunner().invoke(app, ["show-profile", "--data-root", str(tmp_path / "data")])

    assert result.exit_code == 0
    assert "name: Tonio" in result.stdout


def test_show_preferences_prints_saved_preferences(tmp_path: Path) -> None:
    preferences_dir = tmp_path / "data" / "preferences"
    preferences_dir.mkdir(parents=True)
    (preferences_dir / "search-preferences.yaml").write_text("desired_titles:\n  - Data Engineer\n", encoding="utf-8")

    result = CliRunner().invoke(app, ["show-preferences", "--data-root", str(tmp_path / "data")])

    assert result.exit_code == 0
    assert "Data Engineer" in result.stdout
  • Step 2: Run the CLI workflow tests to verify they fail

Run: uv run pytest tests/test_cli.py -v Expected: FAIL because build-profile does not accept options or write files yet

  • Step 3: Implement the real CLI workflow
# src/job_research/cli.py
from pathlib import Path

import typer
from rich.console import Console

from job_research.models import GeneratedProfileState
from job_research.preferences import save_search_preferences
from job_research.profile.cv_extractor import extract_cv_signals
from job_research.profile.materialize import apply_profile_overrides
from job_research.profile.merge import build_generated_profile
from job_research.profile.profile_parser import parse_profile_markdown
from job_research.storage import (
    cv_raw_text_path,
    ensure_data_dirs,
    final_profile_path,
    generated_profile_path,
    load_yaml,
    override_profile_path,
    save_yaml,
)

app = typer.Typer(help="Local candidate profile ingestion workflow")
console = Console()
REVIEW_REQUIRED_EXIT_CODE = 10


def _read_cv_text(path: Path) -> str:
    if path.suffix.lower() == ".pdf":
        from job_research.profile.cv_extractor import extract_pdf_text

        return extract_pdf_text(path)
    return path.read_text(encoding="utf-8")


@app.command("build-profile")
def build_profile(
    cv: Path = typer.Option(..., exists=True, readable=True),
    profile: Path = typer.Option(..., exists=True, readable=True),
    data_root: Path = typer.Option(Path("data")),
) -> None:
    ensure_data_dirs(data_root)
    cv_text = _read_cv_text(cv)
    cv_raw_text_path(data_root).write_text(cv_text, encoding="utf-8")
    cv_profile = extract_cv_signals(cv_text)
    authored = parse_profile_markdown(profile.read_text(encoding="utf-8"))
    generated = build_generated_profile(cv_profile, authored)
    save_yaml(generated_profile_path(data_root), generated.model_dump(mode="json"))

    if generated.review_required:
        console.print("Review required before final profile can be trusted.")
        raise typer.Exit(REVIEW_REQUIRED_EXIT_CODE)

    console.print("Generated profile written.")


@app.command("apply-overrides")
def apply_overrides(
    data_root: Path = typer.Option(Path("data")),
    override: Path | None = typer.Option(None),
) -> None:
    ensure_data_dirs(data_root)
    generated = GeneratedProfileState.model_validate(load_yaml(generated_profile_path(data_root)))
    override_payload = load_yaml(override or override_profile_path(data_root))
    final_profile = apply_profile_overrides(generated, override_payload)
    save_yaml(final_profile_path(data_root), final_profile.model_dump(mode="json"))
    console.print("Final profile written.")


@app.command("set-preferences")
def set_preferences(
    preferences_file: Path = typer.Argument(..., exists=True, readable=True),
    data_root: Path = typer.Option(Path("data")),
) -> None:
    payload = load_yaml(preferences_file)
    save_search_preferences(data_root, payload)
    console.print("Search preferences saved.")


@app.command("show-profile")
def show_profile(
    data_root: Path = typer.Option(Path("data")),
) -> None:
    console.print(final_profile_path(data_root).read_text(encoding="utf-8"))


@app.command("show-preferences")
def show_preferences(
    data_root: Path = typer.Option(Path("data")),
) -> None:
    from job_research.storage import preferences_path

    console.print(preferences_path(data_root).read_text(encoding="utf-8"))


def main() -> None:
    app()


if __name__ == "__main__":
    main()
  • Step 4: Run the CLI workflow tests to verify they pass

Run: uv run pytest tests/test_cli.py -v Expected: PASS

  • Step 5: Commit the CLI workflow
git add src/job_research/cli.py tests/test_cli.py
git commit -m "feat: add candidate profile generation CLI workflow"

Task 9: Full Regression Run and Spec Alignment Check

Files:

  • Modify: none

  • Test: tests/test_cli.py

  • Test: tests/test_storage.py

  • Test: tests/test_preferences.py

  • Test: tests/profile/test_cv_extractor.py

  • Test: tests/profile/test_profile_parser.py

  • Test: tests/profile/test_merge.py

  • Test: tests/profile/test_materialize.py

  • Step 1: Run the full test suite

Run: uv run pytest tests -v Expected: PASS with all first-slice tests green

  • Step 2: Verify the generated file contract manually
mkdir -p /tmp/job-research-demo && \
printf 'Tonio\nLocation: France\nYears of experience: 2\nLanguages: French, English\nSkills: Python, SQL\n' > /tmp/job-research-demo/cv.txt && \
printf '# Candidate Profile\n\n## Summary\nJunior data engineer.\n\n## Strengths\n- Python\n\n## Target Roles\n- Data Engineer\n\n## Skills To Emphasize\n- GCP\n\n## Notes\nPrefer warehousing roles.\n' > /tmp/job-research-demo/profile.md && \
uv run python -m job_research.cli build-profile --cv /tmp/job-research-demo/cv.txt --profile /tmp/job-research-demo/profile.md --data-root /tmp/job-research-demo/data

Expected: generated YAML written to /tmp/job-research-demo/data/profiles/generated/candidate-profile.generated.yaml

  • Step 3: Verify review-required exit behavior manually
mkdir -p /tmp/job-research-conflict && \
printf 'Tonio\nLocation: France\nYears of experience: 2\nLanguages: French, English\nSkills: Python, SQL\n' > /tmp/job-research-conflict/cv.txt && \
printf '# Candidate Profile\n\n## Summary\nJunior data engineer.\n\n## Factual Corrections\n- years_of_experience: 3\n' > /tmp/job-research-conflict/profile.md && \
uv run python -m job_research.cli build-profile --cv /tmp/job-research-conflict/cv.txt --profile /tmp/job-research-conflict/profile.md --data-root /tmp/job-research-conflict/data; printf "%s\n" $?

Expected: stdout contains Review required and shell prints 10

  • Step 4: Commit the validated slice
git add pyproject.toml src/job_research tests docs/profile-template.md
git commit -m "feat: complete candidate profile ingestion slice"

Spec Coverage Check

  • Scope: covered by Tasks 1-8
  • Layered generated/override/final state: covered by Tasks 2, 5, and 6
  • Raw extracted CV text artifact: covered by Task 8
  • Structured markdown profile: covered by Task 3
  • Deterministic-first CV extraction: covered by Task 4
  • Explicit factual conflict handling: covered by Task 5
  • Saved default Search Preferences separate from profile: covered by Task 7
  • CLI-first workflow, including show-profile and show-preferences: covered by Task 8
  • Trust-oriented validation and repeatability: covered by Task 9