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 savedSearch 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 Preferencesseparate from profile: covered by Task 7 - CLI-first workflow, including
show-profileandshow-preferences: covered by Task 8 - Trust-oriented validation and repeatability: covered by Task 9