Resume Tailoring Phase 2: LLM Pipeline Implementation Plan
Goal: Build the 4-step LLM tailoring pipeline (Analyze → Retrieve → Generate → Evaluate) that takes a job description and produces a tailored resume using Claude API, with a single POST endpoint.
Architecture: Three Anthropic API calls (Haiku for analysis + evaluation, Sonnet for generation) orchestrated by a pipeline service. The retrieval step uses the existing Voyage AI embedding + Qdrant vector search from Phase 1. The pipeline reads the user’s current resume from MongoDB as structural scaffolding, retrieves relevant content chunks from Qdrant, and assembles a tailored resume JSON. A retry loop re-generates if evaluation score is below threshold.
Tech Stack: Python, FastAPI, anthropic SDK, Voyage AI (existing), Qdrant (existing), MongoDB (existing), pytest
Task 1: Add anthropic dependency
Files:
- Modify:
backend/requirements.in - Regenerate:
backend/requirements.txt
Step 1: Add anthropic to requirements.in
Add anthropic to the end of backend/requirements.in.
Step 2: Regenerate requirements.txt
cd backend && pip-compile requirements.in -o requirements.txtStep 3: Install in venv
source ~/Documents/venvs/field-notes/bin/activate && pip install anthropicStep 4: Commit
git add backend/requirements.in backend/requirements.txt
git commit -m "chore: add anthropic SDK dependency"Task 2: Job analysis service (Haiku)
Files:
- Create:
backend/services/job_analyzer.py - Create:
backend/tests/test_job_analyzer.py
Step 1: Write the failing test
backend/tests/test_job_analyzer.py:
"""Tests for job analysis service."""
import json
from unittest.mock import MagicMock, patch
import pytest
from services.job_analyzer import analyze_job_description
class TestJobAnalyzer:
def test_returns_structured_analysis(self):
"""Test that analyze_job_description returns structured extraction."""
mock_response = MagicMock()
mock_response.content = [
MagicMock(
text=json.dumps(
{
"required_skills": ["Python", "distributed systems"],
"preferred_skills": ["Kubernetes"],
"seniority": "staff",
"domain": "backend",
"culture_signals": "startup, remote",
"key_requirements": [
"led architecture of distributed systems",
],
}
)
)
]
mock_client = MagicMock()
mock_client.messages.create.return_value = mock_response
with patch("services.job_analyzer._get_client", return_value=mock_client):
result = analyze_job_description("We need a staff backend engineer...")
assert result["required_skills"] == ["Python", "distributed systems"]
assert result["seniority"] == "staff"
mock_client.messages.create.assert_called_once()
call_kwargs = mock_client.messages.create.call_args.kwargs
assert call_kwargs["model"] == "claude-haiku-4-5-20251001"
def test_raises_without_api_key(self):
"""Test that missing ANTHROPIC_API_KEY raises ValueError."""
from services import job_analyzer
job_analyzer._client = None
with patch.dict("os.environ", {}, clear=True):
with pytest.raises(ValueError, match="ANTHROPIC_API_KEY"):
job_analyzer._get_client()
def test_handles_malformed_json_response(self):
"""Test that malformed JSON from LLM raises ValueError."""
mock_response = MagicMock()
mock_response.content = [MagicMock(text="not valid json")]
mock_client = MagicMock()
mock_client.messages.create.return_value = mock_response
with patch("services.job_analyzer._get_client", return_value=mock_client):
with pytest.raises(ValueError, match="Failed to parse"):
analyze_job_description("some job description")Step 2: Run test to verify it fails
cd backend && python -m pytest tests/test_job_analyzer.py -vExpected: FAIL — ModuleNotFoundError: No module named 'services.job_analyzer'
Step 3: Write the implementation
backend/services/job_analyzer.py:
"""Job description analysis using Claude Haiku."""
import json
import os
import threading
from typing import Any, Dict
import anthropic
_client = None
_client_lock = threading.Lock()
ANALYSIS_SYSTEM_PROMPT = """You are a job description analyst. Extract structured information from the job description.
Return ONLY a JSON object with these exact keys:
- required_skills: list of strings — hard requirements explicitly stated
- preferred_skills: list of strings — nice-to-haves or "bonus" items
- seniority: string — one of "junior", "mid", "senior", "staff", "principal", "director"
- domain: string — short label like "backend", "frontend", "fullstack", "data", "ml", "devops", "ai_backend"
- culture_signals: string — comma-separated culture indicators (e.g. "startup, remote, fast-paced")
- key_requirements: list of strings — the 3-7 most important qualifications, rephrased as what the ideal candidate has done
Do not include any text outside the JSON object. No markdown fences."""
def _get_client() -> anthropic.Anthropic:
global _client
if _client is None:
with _client_lock:
if _client is None:
api_key = os.getenv("ANTHROPIC_API_KEY")
if not api_key:
raise ValueError(
"ANTHROPIC_API_KEY environment variable is required"
)
_client = anthropic.Anthropic(api_key=api_key)
return _client
def analyze_job_description(job_description: str) -> Dict[str, Any]:
"""Analyze a job description and extract structured requirements.
Args:
job_description: Raw job description text.
Returns:
Dict with required_skills, preferred_skills, seniority, domain,
culture_signals, and key_requirements.
Raises:
ValueError: If LLM response cannot be parsed as JSON.
"""
client = _get_client()
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=1024,
system=ANALYSIS_SYSTEM_PROMPT,
messages=[
{"role": "user", "content": job_description},
],
)
raw_text = response.content[0].text.strip()
# Strip markdown fences if present
if raw_text.startswith("```"):
lines = raw_text.split("\n")
raw_text = "\n".join(lines[1:-1])
try:
return json.loads(raw_text)
except json.JSONDecodeError as e:
raise ValueError(f"Failed to parse job analysis response: {e}\nRaw: {raw_text}")Step 4: Run tests
cd backend && python -m pytest tests/test_job_analyzer.py -vExpected: 3 passed
Step 5: Commit
git add backend/services/job_analyzer.py backend/tests/test_job_analyzer.py
git commit -m "feat: add job description analyzer using Claude Haiku"Task 3: Resume generator service (Sonnet)
Files:
- Create:
backend/services/resume_generator.py - Create:
backend/tests/test_resume_generator.py
Step 1: Write the failing test
backend/tests/test_resume_generator.py:
"""Tests for resume generator service."""
import json
from unittest.mock import MagicMock, patch
import pytest
from services.resume_generator import generate_tailored_resume
SAMPLE_RESUME = {
"contact": {"full_name": "Test User", "email": "test@example.com"},
"summary": "Experienced engineer.",
"work_experience": [
{
"company": "Acme",
"title": "Staff Engineer",
"start_date": "2020",
"end_date": "2024",
"current": False,
"description": "- Built distributed systems.\n- Led platform team.",
"technologies": ["Python", "Go"],
}
],
"education": [],
"skills": ["Python", "Go", "Docker"],
"achievements": [],
}
SAMPLE_ANALYSIS = {
"required_skills": ["Python", "distributed systems"],
"preferred_skills": ["Kubernetes"],
"seniority": "staff",
"domain": "backend",
"culture_signals": "startup, remote",
"key_requirements": ["led architecture of distributed systems"],
}
SAMPLE_CHUNKS = [
{"text": "Staff Engineer at Acme: Built distributed systems.", "score": 0.95},
{"text": "Python: used at Acme as Staff Engineer", "score": 0.90},
]
class TestResumeGenerator:
def test_returns_tailored_resume_json(self):
"""Test that generate_tailored_resume returns a resume dict."""
tailored = {**SAMPLE_RESUME, "summary": "Tailored summary for backend role."}
mock_response = MagicMock()
mock_response.content = [MagicMock(text=json.dumps(tailored))]
mock_client = MagicMock()
mock_client.messages.create.return_value = mock_response
with patch("services.resume_generator._get_client", return_value=mock_client):
result = generate_tailored_resume(
resume=SAMPLE_RESUME,
analysis=SAMPLE_ANALYSIS,
chunks=SAMPLE_CHUNKS,
)
assert result["summary"] == "Tailored summary for backend role."
call_kwargs = mock_client.messages.create.call_args.kwargs
assert call_kwargs["model"] == "claude-sonnet-4-6"
def test_passes_resume_and_chunks_in_prompt(self):
"""Test that the prompt contains resume data and retrieved chunks."""
mock_response = MagicMock()
mock_response.content = [MagicMock(text=json.dumps(SAMPLE_RESUME))]
mock_client = MagicMock()
mock_client.messages.create.return_value = mock_response
with patch("services.resume_generator._get_client", return_value=mock_client):
generate_tailored_resume(
resume=SAMPLE_RESUME,
analysis=SAMPLE_ANALYSIS,
chunks=SAMPLE_CHUNKS,
)
call_kwargs = mock_client.messages.create.call_args.kwargs
user_msg = call_kwargs["messages"][0]["content"]
assert "Acme" in user_msg
assert "distributed systems" in user_msg
def test_includes_evaluator_feedback_when_provided(self):
"""Test that evaluator issues are appended to the prompt on retry."""
mock_response = MagicMock()
mock_response.content = [MagicMock(text=json.dumps(SAMPLE_RESUME))]
mock_client = MagicMock()
mock_client.messages.create.return_value = mock_response
with patch("services.resume_generator._get_client", return_value=mock_client):
generate_tailored_resume(
resume=SAMPLE_RESUME,
analysis=SAMPLE_ANALYSIS,
chunks=SAMPLE_CHUNKS,
evaluator_feedback=["missing 'Kubernetes' keyword", "summary too generic"],
)
call_kwargs = mock_client.messages.create.call_args.kwargs
user_msg = call_kwargs["messages"][0]["content"]
assert "missing 'Kubernetes' keyword" in user_msg
assert "summary too generic" in user_msg
def test_handles_malformed_json(self):
"""Test that malformed JSON from LLM raises ValueError."""
mock_response = MagicMock()
mock_response.content = [MagicMock(text="not json")]
mock_client = MagicMock()
mock_client.messages.create.return_value = mock_response
with patch("services.resume_generator._get_client", return_value=mock_client):
with pytest.raises(ValueError, match="Failed to parse"):
generate_tailored_resume(
resume=SAMPLE_RESUME,
analysis=SAMPLE_ANALYSIS,
chunks=SAMPLE_CHUNKS,
)Step 2: Run test to verify it fails
cd backend && python -m pytest tests/test_resume_generator.py -vExpected: FAIL — ModuleNotFoundError
Step 3: Write the implementation
backend/services/resume_generator.py:
"""Resume generation using Claude Sonnet."""
import json
import os
import threading
from typing import Any, Dict, List, Optional
import anthropic
_client = None
_client_lock = threading.Lock()
STRATEGY_PROMPT = """You are a resume optimization expert. Follow these rules:
- Mirror keywords from the job description in bullet points (ATS optimization)
- Lead bullets with action verbs, not passive constructions
- Quantify impact where possible (numbers, percentages, scale)
- Front-load the most relevant experience in each section
- Match the job description's language register (startup vs enterprise)
- Ensure the top 5-7 keywords from the job description appear naturally
- Never fabricate experience, companies, titles, or dates
- Reorder and prioritize content by relevance to the job description
- Same facts, different framing — rewrite bullets to emphasize applicable skills
- Keep the summary to 2-3 sentences, specific to this role
- Do not use inflated language like "spearheaded", "synergized", "leveraged"
- Write in direct, active voice
Return ONLY a valid JSON object matching the exact Resume schema provided. No markdown fences, no commentary."""
def _get_client() -> anthropic.Anthropic:
global _client
if _client is None:
with _client_lock:
if _client is None:
api_key = os.getenv("ANTHROPIC_API_KEY")
if not api_key:
raise ValueError(
"ANTHROPIC_API_KEY environment variable is required"
)
_client = anthropic.Anthropic(api_key=api_key)
return _client
def generate_tailored_resume(
resume: Dict[str, Any],
analysis: Dict[str, Any],
chunks: List[Dict[str, Any]],
evaluator_feedback: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Generate a tailored resume using Claude Sonnet.
Args:
resume: Current resume JSON (structural scaffolding — companies, titles, dates are fixed).
analysis: Structured job analysis from analyze_job_description().
chunks: Retrieved content chunks from Qdrant with relevance scores.
evaluator_feedback: Optional list of issues from a previous evaluation (retry loop).
Returns:
Tailored resume as a dict matching the Resume schema.
Raises:
ValueError: If LLM response cannot be parsed as JSON.
"""
client = _get_client()
# Build the user prompt with all context
chunks_text = "\n".join(
f"- [{c.get('score', 0):.2f}] {c['text']}" for c in chunks
)
user_content = f"""Job Description Analysis:
{json.dumps(analysis, indent=2)}
Available Content (ranked by relevance to this job):
{chunks_text}
Current Resume (dates, companies, titles are fixed — do not change these):
{json.dumps(resume, indent=2)}
Return a complete Resume JSON with:
- Tailored summary specific to this role
- Rewritten bullet points emphasizing relevant experience
- Skills reordered by relevance to the job
- Same structure, same companies/titles/dates — different framing"""
if evaluator_feedback:
feedback_text = "\n".join(f"- {issue}" for issue in evaluator_feedback)
user_content += f"""
IMPORTANT — Previous evaluation found these issues. Fix them:
{feedback_text}"""
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
system=STRATEGY_PROMPT,
messages=[
{"role": "user", "content": user_content},
],
)
raw_text = response.content[0].text.strip()
if raw_text.startswith("```"):
lines = raw_text.split("\n")
raw_text = "\n".join(lines[1:-1])
try:
return json.loads(raw_text)
except json.JSONDecodeError as e:
raise ValueError(f"Failed to parse generated resume: {e}\nRaw: {raw_text[:500]}")Step 4: Run tests
cd backend && python -m pytest tests/test_resume_generator.py -vExpected: 4 passed
Step 5: Commit
git add backend/services/resume_generator.py backend/tests/test_resume_generator.py
git commit -m "feat: add resume generator using Claude Sonnet"Task 4: Resume evaluator service (Haiku)
Files:
- Create:
backend/services/resume_evaluator.py - Create:
backend/tests/test_resume_evaluator.py
Step 1: Write the failing test
backend/tests/test_resume_evaluator.py:
"""Tests for resume evaluator service."""
import json
from unittest.mock import MagicMock, patch
import pytest
from services.resume_evaluator import evaluate_resume
SAMPLE_ANALYSIS = {
"required_skills": ["Python", "distributed systems"],
"preferred_skills": ["Kubernetes"],
"seniority": "staff",
"domain": "backend",
"culture_signals": "startup, remote",
"key_requirements": ["led architecture of distributed systems"],
}
SAMPLE_RESUME = {
"contact": {"full_name": "Test User"},
"summary": "Staff backend engineer with distributed systems experience.",
"work_experience": [],
"skills": ["Python", "distributed systems"],
}
class TestResumeEvaluator:
def test_returns_score_breakdown(self):
"""Test that evaluate_resume returns scores and issues."""
mock_response = MagicMock()
mock_response.content = [
MagicMock(
text=json.dumps(
{
"keyword_coverage": 0.92,
"relevance_ranking": 0.88,
"ats_compatibility": 0.90,
"overall": 0.90,
"issues": [],
}
)
)
]
mock_client = MagicMock()
mock_client.messages.create.return_value = mock_response
with patch("services.resume_evaluator._get_client", return_value=mock_client):
result = evaluate_resume(
tailored_resume=SAMPLE_RESUME,
analysis=SAMPLE_ANALYSIS,
)
assert result["overall"] == 0.90
assert result["keyword_coverage"] == 0.92
assert isinstance(result["issues"], list)
call_kwargs = mock_client.messages.create.call_args.kwargs
assert call_kwargs["model"] == "claude-haiku-4-5-20251001"
def test_returns_issues_when_score_low(self):
"""Test that low-scoring evaluations include issue descriptions."""
mock_response = MagicMock()
mock_response.content = [
MagicMock(
text=json.dumps(
{
"keyword_coverage": 0.60,
"relevance_ranking": 0.70,
"ats_compatibility": 0.80,
"overall": 0.70,
"issues": [
"missing 'Kubernetes' keyword",
"summary too generic for staff role",
],
}
)
)
]
mock_client = MagicMock()
mock_client.messages.create.return_value = mock_response
with patch("services.resume_evaluator._get_client", return_value=mock_client):
result = evaluate_resume(
tailored_resume=SAMPLE_RESUME,
analysis=SAMPLE_ANALYSIS,
)
assert result["overall"] == 0.70
assert len(result["issues"]) == 2
def test_handles_malformed_json(self):
"""Test that malformed JSON from LLM raises ValueError."""
mock_response = MagicMock()
mock_response.content = [MagicMock(text="not json")]
mock_client = MagicMock()
mock_client.messages.create.return_value = mock_response
with patch("services.resume_evaluator._get_client", return_value=mock_client):
with pytest.raises(ValueError, match="Failed to parse"):
evaluate_resume(
tailored_resume=SAMPLE_RESUME,
analysis=SAMPLE_ANALYSIS,
)Step 2: Run test to verify it fails
cd backend && python -m pytest tests/test_resume_evaluator.py -vExpected: FAIL — ModuleNotFoundError
Step 3: Write the implementation
backend/services/resume_evaluator.py:
"""Resume evaluation using Claude Haiku."""
import json
import os
import threading
from typing import Any, Dict
import anthropic
_client = None
_client_lock = threading.Lock()
EVALUATION_SYSTEM_PROMPT = """You are a resume evaluation expert. Score a tailored resume against the original job requirements.
Return ONLY a JSON object with these exact keys:
- keyword_coverage: float 0-1 — what fraction of required_skills appear in the resume
- relevance_ranking: float 0-1 — are the most relevant experiences listed first
- ats_compatibility: float 0-1 — will a standard ATS parser extract this correctly
- overall: float 0-1 — weighted average (keyword_coverage 0.4, relevance_ranking 0.3, ats_compatibility 0.3)
- issues: list of strings — specific, actionable problems to fix (empty if overall >= 0.80)
Be strict. A generic summary that doesn't mention the specific role or domain scores low on relevance.
A resume missing 2+ required skills scores below 0.80 on keyword_coverage.
No markdown fences, no commentary outside the JSON."""
def _get_client() -> anthropic.Anthropic:
global _client
if _client is None:
with _client_lock:
if _client is None:
api_key = os.getenv("ANTHROPIC_API_KEY")
if not api_key:
raise ValueError(
"ANTHROPIC_API_KEY environment variable is required"
)
_client = anthropic.Anthropic(api_key=api_key)
return _client
def evaluate_resume(
tailored_resume: Dict[str, Any],
analysis: Dict[str, Any],
) -> Dict[str, Any]:
"""Evaluate a tailored resume against job requirements.
Args:
tailored_resume: The generated resume JSON.
analysis: Structured job analysis from analyze_job_description().
Returns:
Dict with keyword_coverage, relevance_ranking, ats_compatibility,
overall, and issues.
Raises:
ValueError: If LLM response cannot be parsed as JSON.
"""
client = _get_client()
user_content = f"""Job Requirements:
{json.dumps(analysis, indent=2)}
Tailored Resume:
{json.dumps(tailored_resume, indent=2)}
Evaluate this resume against the job requirements."""
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=1024,
system=EVALUATION_SYSTEM_PROMPT,
messages=[
{"role": "user", "content": user_content},
],
)
raw_text = response.content[0].text.strip()
if raw_text.startswith("```"):
lines = raw_text.split("\n")
raw_text = "\n".join(lines[1:-1])
try:
return json.loads(raw_text)
except json.JSONDecodeError as e:
raise ValueError(f"Failed to parse evaluation response: {e}\nRaw: {raw_text}")Step 4: Run tests
cd backend && python -m pytest tests/test_resume_evaluator.py -vExpected: 3 passed
Step 5: Commit
git add backend/services/resume_evaluator.py backend/tests/test_resume_evaluator.py
git commit -m "feat: add resume evaluator using Claude Haiku"Task 5: Tailoring pipeline orchestrator
Files:
- Create:
backend/services/tailoring_pipeline.py - Create:
backend/tests/test_tailoring_pipeline.py
This is the orchestrator that wires Analyze → Retrieve → Generate → Evaluate into a single flow with retry logic.
Step 1: Write the failing test
backend/tests/test_tailoring_pipeline.py:
"""Tests for the tailoring pipeline orchestrator."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from services.tailoring_pipeline import run_tailoring_pipeline
SAMPLE_RESUME_DOC = {
"_id": "abc123",
"contact": {"full_name": "Test User", "email": "test@example.com"},
"summary": "Experienced engineer.",
"work_experience": [
{
"company": "Acme",
"title": "Staff Engineer",
"start_date": "2020",
"end_date": "2024",
"current": False,
"description": "- Built distributed systems.",
"technologies": ["Python"],
}
],
"education": [],
"skills": ["Python"],
"achievements": [],
"user_id": "user-1",
}
SAMPLE_ANALYSIS = {
"required_skills": ["Python"],
"preferred_skills": [],
"seniority": "staff",
"domain": "backend",
"culture_signals": "startup",
"key_requirements": ["distributed systems"],
}
SAMPLE_CHUNKS = [
{
"id": "pt-1",
"score": 0.95,
"payload": {
"text": "Built distributed systems.",
"chunk_type": "achievement",
"source": "resume",
"company": "Acme",
},
}
]
SAMPLE_TAILORED = {
"contact": {"full_name": "Test User", "email": "test@example.com"},
"summary": "Tailored summary.",
"work_experience": SAMPLE_RESUME_DOC["work_experience"],
"education": [],
"skills": ["Python"],
"achievements": [],
}
GOOD_EVALUATION = {
"keyword_coverage": 0.95,
"relevance_ranking": 0.90,
"ats_compatibility": 0.92,
"overall": 0.92,
"issues": [],
}
class TestTailoringPipeline:
@pytest.mark.asyncio
async def test_full_pipeline_success(self):
"""Test the full pipeline returns tailored resume and scores."""
mock_collection = MagicMock()
mock_collection.find_one = AsyncMock(return_value=SAMPLE_RESUME_DOC)
with (
patch(
"services.tailoring_pipeline.analyze_job_description",
return_value=SAMPLE_ANALYSIS,
),
patch(
"services.tailoring_pipeline.embed_query",
return_value=[0.1, 0.2],
),
patch(
"services.tailoring_pipeline.search",
return_value=SAMPLE_CHUNKS,
),
patch(
"services.tailoring_pipeline.generate_tailored_resume",
return_value=SAMPLE_TAILORED,
),
patch(
"services.tailoring_pipeline.evaluate_resume",
return_value=GOOD_EVALUATION,
),
):
result = await run_tailoring_pipeline(
job_description="Staff backend engineer needed...",
user_id="user-1",
resumes_collection=mock_collection,
)
assert result["tailored_resume"]["summary"] == "Tailored summary."
assert result["evaluation"]["overall"] == 0.92
assert result["analysis"]["seniority"] == "staff"
@pytest.mark.asyncio
async def test_retries_when_score_below_threshold(self):
"""Test that pipeline retries generation when evaluation score < 0.80."""
mock_collection = MagicMock()
mock_collection.find_one = AsyncMock(return_value=SAMPLE_RESUME_DOC)
low_eval = {
"keyword_coverage": 0.60,
"relevance_ranking": 0.65,
"ats_compatibility": 0.70,
"overall": 0.65,
"issues": ["missing keyword X"],
}
generate_mock = MagicMock(return_value=SAMPLE_TAILORED)
evaluate_mock = MagicMock(side_effect=[low_eval, GOOD_EVALUATION])
with (
patch(
"services.tailoring_pipeline.analyze_job_description",
return_value=SAMPLE_ANALYSIS,
),
patch(
"services.tailoring_pipeline.embed_query",
return_value=[0.1, 0.2],
),
patch(
"services.tailoring_pipeline.search",
return_value=SAMPLE_CHUNKS,
),
patch(
"services.tailoring_pipeline.generate_tailored_resume",
generate_mock,
),
patch(
"services.tailoring_pipeline.evaluate_resume",
evaluate_mock,
),
):
result = await run_tailoring_pipeline(
job_description="job desc",
user_id="user-1",
resumes_collection=mock_collection,
)
assert generate_mock.call_count == 2
assert evaluate_mock.call_count == 2
# Second generate call should include feedback
second_call = generate_mock.call_args_list[1]
assert second_call.kwargs["evaluator_feedback"] == ["missing keyword X"]
@pytest.mark.asyncio
async def test_stops_retrying_after_max_attempts(self):
"""Test that pipeline stops after max_retries even if score stays low."""
mock_collection = MagicMock()
mock_collection.find_one = AsyncMock(return_value=SAMPLE_RESUME_DOC)
low_eval = {
"keyword_coverage": 0.50,
"relevance_ranking": 0.50,
"ats_compatibility": 0.50,
"overall": 0.50,
"issues": ["everything is wrong"],
}
with (
patch(
"services.tailoring_pipeline.analyze_job_description",
return_value=SAMPLE_ANALYSIS,
),
patch(
"services.tailoring_pipeline.embed_query",
return_value=[0.1, 0.2],
),
patch(
"services.tailoring_pipeline.search",
return_value=SAMPLE_CHUNKS,
),
patch(
"services.tailoring_pipeline.generate_tailored_resume",
return_value=SAMPLE_TAILORED,
),
patch(
"services.tailoring_pipeline.evaluate_resume",
return_value=low_eval,
),
):
result = await run_tailoring_pipeline(
job_description="job desc",
user_id="user-1",
resumes_collection=mock_collection,
)
# Returns best attempt even if below threshold
assert result["evaluation"]["overall"] == 0.50
@pytest.mark.asyncio
async def test_raises_when_no_resume_found(self):
"""Test that pipeline raises 404-style error when user has no resume."""
mock_collection = MagicMock()
mock_collection.find_one = AsyncMock(return_value=None)
with pytest.raises(ValueError, match="No resume found"):
await run_tailoring_pipeline(
job_description="job desc",
user_id="user-1",
resumes_collection=mock_collection,
)Step 2: Run test to verify it fails
cd backend && python -m pytest tests/test_tailoring_pipeline.py -vExpected: FAIL — ModuleNotFoundError
Step 3: Write the implementation
backend/services/tailoring_pipeline.py:
"""Orchestrates the full resume tailoring pipeline."""
import asyncio
from typing import Any, Dict
from motor.motor_asyncio import AsyncIOMotorCollection
from services.embedding import embed_query
from services.job_analyzer import analyze_job_description
from services.resume_evaluator import evaluate_resume
from services.resume_generator import generate_tailored_resume
from services.vector_store import search
SCORE_THRESHOLD = 0.80
MAX_RETRIES = 2
async def run_tailoring_pipeline(
job_description: str,
user_id: str,
resumes_collection: AsyncIOMotorCollection,
) -> Dict[str, Any]:
"""Run the full tailoring pipeline: Analyze → Retrieve → Generate → Evaluate.
Args:
job_description: Raw job description text.
user_id: The authenticated user's ID.
resumes_collection: MongoDB resumes collection.
Returns:
Dict with keys: analysis, tailored_resume, evaluation, attempts.
Raises:
ValueError: If user has no resume.
"""
# Fetch current resume
resume_doc = await resumes_collection.find_one(
{"user_id": user_id, "deleted": {"$ne": True}}
)
if not resume_doc:
raise ValueError("No resume found for this user")
# Strip MongoDB internal fields for the LLM
resume = {
k: v
for k, v in resume_doc.items()
if k not in ("_id", "user_id", "createdDate", "updatedDate", "deleted")
}
# Step 1: Analyze job description (sync call, run in thread)
analysis = await asyncio.to_thread(analyze_job_description, job_description)
# Step 2: Retrieve relevant chunks
query_embedding = await asyncio.to_thread(embed_query, job_description)
raw_results = await asyncio.to_thread(
search,
query_vector=query_embedding,
limit=25,
source_filter="resume",
)
chunks = [
{"text": r["payload"].get("text", ""), "score": r["score"]}
for r in raw_results
]
# Step 3 + 4: Generate and Evaluate with retry loop
evaluator_feedback = None
best_result = None
for attempt in range(1 + MAX_RETRIES):
tailored = await asyncio.to_thread(
generate_tailored_resume,
resume=resume,
analysis=analysis,
chunks=chunks,
evaluator_feedback=evaluator_feedback,
)
evaluation = await asyncio.to_thread(
evaluate_resume,
tailored_resume=tailored,
analysis=analysis,
)
best_result = {
"analysis": analysis,
"tailored_resume": tailored,
"evaluation": evaluation,
"attempts": attempt + 1,
}
if evaluation.get("overall", 0) >= SCORE_THRESHOLD:
break
evaluator_feedback = evaluation.get("issues", [])
return best_resultStep 4: Run tests
cd backend && python -m pytest tests/test_tailoring_pipeline.py -vExpected: 4 passed
Step 5: Commit
git add backend/services/tailoring_pipeline.py backend/tests/test_tailoring_pipeline.py
git commit -m "feat: add tailoring pipeline orchestrator with retry loop"Task 6: POST /tailor endpoint
Files:
- Create:
backend/handlers/tailor.py - Modify:
backend/app.py— register router - Modify:
backend/tests/conftest.py— register router in test_app - Create:
backend/tests/test_tailor_api.py
Step 1: Write the failing test
backend/tests/test_tailor_api.py:
"""Tests for the tailor endpoint."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
SAMPLE_PIPELINE_RESULT = {
"analysis": {
"required_skills": ["Python"],
"preferred_skills": [],
"seniority": "staff",
"domain": "backend",
"culture_signals": "startup",
"key_requirements": ["distributed systems"],
},
"tailored_resume": {
"contact": {"full_name": "Test User"},
"summary": "Tailored summary.",
"work_experience": [],
"education": [],
"skills": ["Python"],
"achievements": [],
},
"evaluation": {
"keyword_coverage": 0.95,
"relevance_ranking": 0.90,
"ats_compatibility": 0.92,
"overall": 0.92,
"issues": [],
},
"attempts": 1,
}
class TestTailorEndpoint:
@pytest.mark.integration
@pytest.mark.asyncio
async def test_tailor_requires_auth(self, async_client):
"""Test POST /tailor without auth returns 401."""
response = await async_client.post(
"/tailor", json={"job_description": "test"}
)
assert response.status_code == 401
@pytest.mark.integration
@pytest.mark.asyncio
async def test_tailor_returns_tailored_resume(
self, async_client, override_database, mock_auth, auth_headers
):
"""Test POST /tailor returns tailored resume with scores."""
with patch(
"handlers.tailor.run_tailoring_pipeline",
new_callable=AsyncMock,
return_value=SAMPLE_PIPELINE_RESULT,
):
response = await async_client.post(
"/tailor",
json={"job_description": "We need a staff backend engineer..."},
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["tailored_resume"]["summary"] == "Tailored summary."
assert data["evaluation"]["overall"] == 0.92
assert data["attempts"] == 1
@pytest.mark.integration
@pytest.mark.asyncio
async def test_tailor_validates_empty_job_description(
self, async_client, override_database, mock_auth, auth_headers
):
"""Test POST /tailor rejects empty job description."""
response = await async_client.post(
"/tailor",
json={"job_description": ""},
headers=auth_headers,
)
assert response.status_code == 422
@pytest.mark.integration
@pytest.mark.asyncio
async def test_tailor_returns_503_without_api_keys(
self, async_client, override_database, mock_auth, auth_headers
):
"""Test POST /tailor returns 503 when API keys not configured."""
with patch.dict("os.environ", {"ANTHROPIC_API_KEY": "", "QDRANT_URL": ""}, clear=False):
with patch(
"handlers.tailor.run_tailoring_pipeline",
new_callable=AsyncMock,
side_effect=ValueError("ANTHROPIC_API_KEY environment variable is required"),
):
response = await async_client.post(
"/tailor",
json={"job_description": "some job"},
headers=auth_headers,
)
assert response.status_code == 503
@pytest.mark.integration
@pytest.mark.asyncio
async def test_tailor_returns_404_when_no_resume(
self, async_client, override_database, mock_auth, auth_headers
):
"""Test POST /tailor returns 404 when user has no resume."""
with patch(
"handlers.tailor.run_tailoring_pipeline",
new_callable=AsyncMock,
side_effect=ValueError("No resume found for this user"),
):
response = await async_client.post(
"/tailor",
json={"job_description": "some job"},
headers=auth_headers,
)
assert response.status_code == 404Step 2: Run test to verify it fails
cd backend && python -m pytest tests/test_tailor_api.py -vExpected: FAIL — ModuleNotFoundError
Step 3: Write the handler
backend/handlers/tailor.py:
"""API handler for resume tailoring."""
from database import get_resumes_collection
from decorators.auth import requires_auth
from fastapi import APIRouter, Depends, HTTPException, Request
from glogger import logger
from middleware.rate_limit import limiter
from models.user import UserInfo
from motor.motor_asyncio import AsyncIOMotorCollection
from pydantic import BaseModel, Field
from services.tailoring_pipeline import run_tailoring_pipeline
router = APIRouter()
class TailorRequest(BaseModel):
job_description: str = Field(..., min_length=1, max_length=50000)
@router.post("/tailor")
@limiter.limit("5/minute")
@requires_auth
async def tailor_resume(
request: Request,
body: TailorRequest,
collection: AsyncIOMotorCollection = Depends(get_resumes_collection),
):
"""Tailor a resume to a job description using the LLM pipeline."""
user: UserInfo = request.state.user
try:
logger.info_with_context(
"Starting resume tailoring",
{"user_id": user.id, "job_desc_length": len(body.job_description)},
)
result = await run_tailoring_pipeline(
job_description=body.job_description,
user_id=user.id,
resumes_collection=collection,
)
logger.info_with_context(
"Resume tailoring complete",
{
"user_id": user.id,
"overall_score": result["evaluation"].get("overall"),
"attempts": result["attempts"],
},
)
return result
except ValueError as e:
error_msg = str(e)
if "No resume found" in error_msg:
raise HTTPException(status_code=404, detail="No resume found. Create a resume first.")
if "environment variable is required" in error_msg:
raise HTTPException(status_code=503, detail="Tailoring service is not configured")
raise HTTPException(status_code=500, detail="Tailoring failed")
except HTTPException:
raise
except Exception as e:
logger.exception_with_context(
"Error during resume tailoring",
{
"user_id": user.id,
"error_type": type(e).__name__,
"error_details": str(e),
},
)
raise HTTPException(status_code=500, detail="Tailoring failed")Step 4: Register router in app.py
Add to backend/app.py imports:
from handlers.tailor import router as tailor_routerAdd to router includes (after content_router):
app.include_router(tailor_router)Step 5: Register router in test conftest
Add to backend/tests/conftest.py imports:
from handlers.tailor import router as tailor_routerAdd to test_app includes:
test_app.include_router(tailor_router)Step 6: Run tests
cd backend && python -m pytest tests/test_tailor_api.py -vExpected: 5 passed
Step 7: Run full test suite
cd backend && python -m pytest -vExpected: all existing tests still pass
Step 8: Format and commit
make format
git add backend/handlers/tailor.py backend/app.py backend/tests/conftest.py backend/tests/test_tailor_api.py
git commit -m "feat: add POST /tailor endpoint for resume tailoring"Task 7: Add ANTHROPIC_API_KEY to deploy config
Files:
- Modify:
.github/workflows/deploy.yml
Step 1: Add the secret to deploy.yml
Find the backend --set-env-vars line and add ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }}.
Step 2: Commit
git add .github/workflows/deploy.yml
git commit -m "chore: add ANTHROPIC_API_KEY to Cloud Run deploy config"Task 8: Add GitHub contribution graph plan + update _meta.ts
Files:
- Already present:
docs-site/pages/plans/2026-03-15-github-contribution-graph.md - Modify:
docs-site/pages/plans/_meta.ts
Step 1: Update _meta.ts
Add entry for the contribution graph plan and this phase 2 plan:
'2026-03-21-resume-tailoring-phase2': 'Resume Tailoring Phase 2',
'2026-03-15-github-contribution-graph': 'GitHub Contribution Graph',Step 2: Commit
git add docs-site/pages/plans/2026-03-15-github-contribution-graph.md docs-site/pages/plans/2026-03-21-resume-tailoring-phase2.md docs-site/pages/plans/_meta.ts
git commit -m "docs: add contribution graph plan and tailoring phase 2 plan"Task 9: Format check and full verification
Step 1: Format
make formatStep 2: Run all backend tests
make testExpected: all tests pass (existing + new)
Step 3: Run frontend tests
make test-frontend-unitExpected: all pass (no frontend changes in this phase)
Step 4: Final commit if format made changes
git add -A && git commit -m "chore: format"(Skip if format made no changes.)
What’s Next
The full system design — including voice/feedback loop, job application tracker, content management CRUD, frontend admin UI, and personality LLM direction — is defined in 2026-03-20-resume-tailoring-design. Phase 2 implements the core pipeline. Remaining work follows that design doc.
Design Consideration: Multi-dimensional tagging
The current metadata has source (resume, blog) and chunk_type (achievement, role_summary, skill_context, meta). A third dimension is needed: purpose/lens — how the content should be used (personality, facts, strategy).
“Built distributed systems at Ro” is a fact. ATS keyword density is strategy (resume generation only). How Nicholas describes work in a blog is personality (conversation only). Same facts, different framing depending on the lens. The content management UI needs to support browsing and curating by all three dimensions.