chore: import upstream snapshot with attribution
Publish Docker Image / publish (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:39:36 +08:00
commit 5bdf4cc89a
423 changed files with 88197 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
# Resume Matcher — Agent Documentation Index
> Project-specific reference for agents working in the Resume Matcher codebase.
Generic, reusable guides (Swiss design system, Next.js performance) live in [`../portable/`](../portable/README.md) as standalone packs that can be lifted out of this repo and dropped into any project. This index covers only the docs that are tied to Resume Matcher itself.
## Quick Navigation
### Core docs
| Doc | Purpose |
|-----|---------|
| [scope-and-principles](scope-and-principles.md) | Rules, what's in/out of scope |
| [quickstart](quickstart.md) | Install, run, test commands |
| [workflow](workflow.md) | Git, PRs, testing |
| [coding-standards](coding-standards.md) | Frontend/backend conventions |
### Architecture
| Doc | Purpose |
|-----|---------|
| [backend-architecture](architecture/backend-architecture.md) | Backend modules, API, services |
| [backend-guide](architecture/backend-guide.md) | Module-by-module backend tour |
| [frontend-architecture](architecture/frontend-architecture.md) | Components, pages, state |
| [frontend-workflow](architecture/frontend-workflow.md) | User flows in the frontend |
| [prompt-workflow-design](architecture/prompt-workflow-design.md) | Improver pipeline design (superseded — historical) |
### APIs
| Doc | Purpose |
|-----|---------|
| [front-end-apis](apis/front-end-apis.md) | API contract |
| [api-flow-maps](apis/api-flow-maps.md) | Request/response flows |
| [backend-requirements](apis/backend-requirements.md) | Backend behavioral requirements |
### Design (Resume Matcher specifics)
| Doc | Purpose |
|-----|---------|
| [template-system](design/template-system.md) | Resume template architecture |
| [pdf-template-guide](design/pdf-template-guide.md) | PDF rendering pipeline |
| [print-pdf-design-spec](design/print-pdf-design-spec.md) | Print/PDF design spec |
| [resume-template-design-spec](design/resume-template-design-spec.md) | Resume template design spec |
| [templates/swiss-single-spec](design/templates/swiss-single-spec.md) | Single-column Swiss template spec |
| [templates/swiss-two-column-spec](design/templates/swiss-two-column-spec.md) | Two-column Swiss template spec |
> **For the design system itself** (colors, components, anti-patterns), see the portable pack: [`../portable/swiss-design-system/`](../portable/swiss-design-system/README.md)
### Features
| Doc | Purpose |
|-----|---------|
| [custom-sections](features/custom-sections.md) | Dynamic sections |
| [resume-templates](features/resume-templates.md) | Template types and controls |
| [adding-resume-templates](features/adding-resume-templates.md) | How to add a new template |
| [enrichment](features/enrichment.md) | AI enrichment flow |
| [jd-match](features/jd-match.md) | Job description matching |
| [i18n](features/i18n.md) | Internationalization |
| [i18n-preparation](features/i18n-preparation.md) | i18n setup notes |
### LLM Integration
| Doc | Purpose |
|-----|---------|
| [llm-integration](llm-integration.md) | Multi-provider AI via LiteLLM |
### Portable packs (live outside this folder)
| Pack | Purpose |
|------|---------|
| [swiss-design-system](../portable/swiss-design-system/README.md) | Full Swiss style design system — required reading for frontend work |
| [nextjs-performance](../portable/nextjs-performance/README.md) | Next.js 15 performance optimizations — required reading for frontend work |
## Project Structure
```
apps/
├── backend/ # FastAPI + Python
│ ├── app/
│ │ ├── main.py # Entry point
│ │ ├── routers/ # API endpoints
│ │ ├── services/ # Business logic
│ │ └── prompts/ # LLM templates
│ └── data/ # Database storage
└── frontend/ # Next.js + React
├── app/ # Pages
├── components/ # UI components
└── lib/ # Utilities, API client
```
## How to Use
**New tasks:** Read `scope-and-principles``quickstart``workflow`
**Backend changes:** `backend-architecture``front-end-apis``llm-integration`
**Frontend changes:** `frontend-architecture` → portable [`swiss-design-system`](../portable/swiss-design-system/README.md) → portable [`nextjs-performance`](../portable/nextjs-performance/README.md) → `coding-standards`
**Template/PDF changes:** `pdf-template-guide``template-system`
+144
View File
@@ -0,0 +1,144 @@
# API Flow Maps
> Request/response flows for all Resume Matcher endpoints.
## Resume Upload
```
POST /api/v1/resumes/upload
├── Validate file (PDF/DOCX, ≤4MB)
├── parse_document() → Markdown
├── db.create_resume(status="processing")
├── parse_resume_to_json() → LLM
│ ├── Success: status="ready"
│ └── Failure: status="failed"
└── Return {resume_id}
```
## Resume Improvement
```
POST /api/v1/resumes/improve
├── Fetch resume + job from DB
├── extract_job_keywords() → LLM
├── improve_resume() → LLM
├── [If enabled] generate_cover_letter() → LLM
├── [If enabled] generate_outreach_message() → LLM
├── [If enabled] generate_interview_prep() → LLM
├── db.create_resume(improved)
├── db.create_improvement()
└── Return {data, cover_letter, outreach_message, interview_prep}
```
## Interview Prep Generation
```
POST /api/v1/resumes/{id}/generate-interview-prep
├── Require tailored resume (parent_id)
├── Fetch improvement record and associated job description
├── Require processed resume data
├── generate_interview_prep() → LLM JSON
├── Validate InterviewPrepData
├── Save resumes.interview_prep as serialized JSON TEXT
└── Return {interview_prep, message}
```
## PDF Generation
```
GET /api/v1/resumes/{id}/pdf
├── Fetch resume from DB
├── Build URL: {frontend}/print/resumes/{id}?{params}
├── Playwright render (wait for .resume-print)
└── Return PDF bytes
```
## Health Check
```
GET /api/v1/health
└── Return {status: "healthy"} # pure liveness — does NOT call the LLM
```
## System Status
```
GET /api/v1/status # each check isolated → 200 (partial/degraded), never 500
├── try: get_llm_config()
│ ├── llm_configured = api_key set OR provider ∈ {ollama, openai_compatible}
│ └── check_llm_health() → llm_healthy # failure here degrades only this field
├── try: db.get_stats() # failure → empty stats, still 200
└── Return {status, llm_configured, llm_healthy, has_master_resume, database_stats}
```
## Configuration Update
```
PUT /api/v1/config/llm-api-key
├── _load_config()
├── Merge new NON-SECRET values (provider/model/base/...)
├── (no longer persists any key — keys go through /config/api-keys)
├── _save_config()
└── Return masked config
```
## API Keys (per-provider, encrypted)
```
GET /api/v1/config/api-keys
└── Return {providers: [{provider, configured, masked_key}]} # always masked
POST /api/v1/config/api-keys
├── For each provided provider key:
│ └── Fernet-encrypt → upsert into SQLite `api_keys` table # other providers' keys untouched
└── Return {message, updated_providers}
DELETE /api/v1/config/api-keys/{provider} # remove one provider's key
DELETE /api/v1/config/api-keys?confirm=... # clear all keys
```
## Job Upload
```
POST /api/v1/jobs/upload
├── For each description:
│ └── db.create_job()
└── Return {job_id[]}
```
## Resume Operations
| Endpoint | Flow |
|----------|------|
| `GET /resumes?id=` | db.get_resume() |
| `GET /resumes/list` | db.list_resumes() |
| `PATCH /resumes/{id}` | db.update_resume() |
| `DELETE /resumes/{id}` | db.delete_resume() |
## Application Tracker
```
GET /api/v1/applications
├── db.list_applications()
└── Return {columns} # grouped by the 7 status keys (all present):
# saved/applied/no_response/response/interview/accepted/rejected
POST /api/v1/applications # manual add from a pasted JD
├── db.create_job(jd)
├── [If company/role missing] extract_job_keywords() → LLM # one best-effort call
├── db.create_application(status default "applied") # dedupes on (job_id, resume_id)
└── Return Application
GET /api/v1/applications/{id}
├── db.get_application() + embed job_content + applied resume
└── Return {..., job_content, resume} # resume: null if it was deleted
```
| Endpoint | Flow |
|----------|------|
| `PATCH /applications/{id}` | db.update_application() — status/position/notes/company/role/applied_at; server renumbers `position` |
| `PATCH /applications/bulk` | db.bulk_update_status() — move many cards to one column |
| `DELETE /applications/{id}` | db.delete_application() |
| `POST /applications/bulk-delete` | db.bulk_delete_applications() |
> **Auto-create:** `POST /resumes/improve/confirm` (and legacy `POST /resumes/improve`) create an `applied` card after persisting the tailored resume — best-effort (a tracker failure never breaks tailoring); company/role reuse the cached keyword extraction, so no extra LLM call.
+113
View File
@@ -0,0 +1,113 @@
# Backend API Requirements
> API contract specifications for Resume Matcher.
## Base URL
```
http://localhost:8000/api/v1
```
## Authentication
Currently none (local-first design).
## Endpoints
### Health & Status
```
GET /health → {healthy, provider, model, error?}
GET /status → {status, llm_configured, llm_healthy, database_stats}
```
### Configuration
```
GET /config/llm-api-key → {provider, model, api_key(masked), api_base?}
PUT /config/llm-api-key ← {provider, model, api_key, api_base?}
POST /config/llm-test → {healthy, provider, model}
GET /config/features → {enable_cover_letter, enable_outreach_message, enable_interview_prep}
PUT /config/features ← {enable_cover_letter?, enable_outreach_message?, enable_interview_prep?}
```
### Resumes
```
POST /resumes/upload ← multipart/form-data {file}
→ {resume_id}
GET /resumes?resume_id= → Resume object
GET /resumes/list → [{resume_id, filename, is_master, created_at}]
PATCH /resumes/{id} ← ResumeData
DELETE /resumes/{id} → {message}
GET /resumes/{id}/pdf → application/pdf
POST /resumes/improve ← {resume_id, job_id}
→ {data, cover_letter?, outreach_message?, interview_prep?}
POST /resumes/{id}/generate-interview-prep
→ {interview_prep, message}
```
### Jobs
```
POST /jobs/upload ← {job_descriptions: [], resume_id?}
→ {job_id: []}
GET /jobs/{id} → {job_id, content, created_at}
```
## Request/Response Formats
### Resume Object
```json
{
"resume_id": "uuid",
"content": "markdown or json",
"processed_data": {
"personalInfo": {...},
"summary": "...",
"workExperience": [...],
"education": [...],
"additional": {...}
},
"is_master": true,
"processing_status": "ready|processing|failed",
"cover_letter": "text or null",
"outreach_message": "text or null",
"interview_prep": "structured object or null"
}
```
### ResumeData
`PATCH /resumes/{id}` accepts the structured resume payload stored in
`processed_data`, for example:
```json
{
"personalInfo": {...},
"summary": "...",
"workExperience": [...],
"education": [...],
"personalProjects": [...],
"additional": {...},
"customSections": {...},
"sectionMeta": [...]
}
```
### Error Response
```json
{
"detail": "Error message"
}
```
## Status Codes
| Code | Meaning |
|------|---------|
| 400 | Bad request |
| 404 | Not found |
| 413 | File too large (>4MB) |
| 422 | Parsing failed |
| 500 | Server error |
| 503 | PDF rendering failed |
+120
View File
@@ -0,0 +1,120 @@
# Frontend API Client
> API client layer for Resume Matcher frontend.
## Base Client (`lib/api/client.ts`)
```typescript
export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
export const API_BASE = `${API_URL}/api/v1`;
export async function apiFetch(endpoint: string, options?: RequestInit);
export async function apiPost<T>(endpoint: string, body: T);
export async function apiPatch<T>(endpoint: string, body: T);
export async function apiPut<T>(endpoint: string, body: T);
export async function apiDelete(endpoint: string);
export function getUploadUrl(): string;
```
## Resume Operations (`lib/api/resume.ts`)
```typescript
// Job descriptions
uploadJobDescriptions(descriptions: string[], resumeId: string) job_id
// Resume improvement
improveResume(resumeId: string, jobId: string) ImprovedResult
// CRUD
fetchResume(resumeId: string) ResumeResponse['data']
fetchResumeList(includeMaster?: boolean) ResumeListItem[]
updateResume(resumeId: string, data: ResumeData) ResumeResponse['data']
deleteResume(resumeId: string) void
// PDF
downloadResumePdf(resumeId: string, settings?: TemplateSettings) Blob
downloadCoverLetterPdf(resumeId: string, pageSize?: string) Blob
// Content updates
updateCoverLetter(resumeId: string, content: string) void
updateOutreachMessage(resumeId: string, content: string) void
// On-demand generated content
generateInterviewPrep(resumeId: string) InterviewPrepData
```
## Resume Wizard (`lib/api/resume-wizard.ts`)
```typescript
postResumeWizardTurn(payload: ResumeWizardTurnRequest) ResumeWizardTurnResponse
finalizeResumeWizard(state: ResumeWizardState) ResumeWizardFinalizeResponse
createInitialResumeWizardState() ResumeWizardState
```
Backend endpoints:
- `POST /api/v1/resume-wizard/turn` — one adaptive turn. `action` is `start | answer | skip | back | review`. `answer`/`skip` run one AI call that updates `resume_data`, returns the next `current_question`, `inferred_skills`, and an `is_complete` flag; `back`/`review`/`start` are deterministic (no LLM). The full `ResumeWizardState` round-trips in the request and response.
- `POST /api/v1/resume-wizard/finalize` — creates the single master resume from the draft (`processing_status: "ready"`), or `409` if a master already exists.
The wizard is an AI-led, one-question-at-a-time flow that builds a general master resume; it does not require a job description and does not replace the upload parser. Question and content text are produced in the configured **content language**; static UI chrome uses the `resumeWizard.*` i18n keys.
## Application Tracker (`lib/api/tracker.ts`)
```typescript
// Kanban board (7 status columns: saved | applied | no_response |
// response | interview | accepted | rejected)
listApplications() ApplicationListResponse // { columns: Record<status, Application[]> }
createApplication(payload: ManualApplicationCreate) Application // manual add from a pasted JD
getApplicationDetail(id: string) ApplicationDetail // embedded JD + applied resume (resume null if deleted)
updateApplication(id: string, payload: ApplicationUpdate) Application // status/position/notes/company/role/applied_at
// Bulk
bulkUpdateStatus(applicationIds: string[], status: ApplicationStatus) ApplicationActionResponse
deleteApplication(id: string) void
bulkDeleteApplications(applicationIds: string[]) ApplicationActionResponse
```
## Config Operations (`lib/api/config.ts`)
```typescript
fetchLlmConfig() LLMConfig
updateLlmConfig(config: LLMConfigUpdate) LLMConfig
testLlmConnection() LLMHealthCheck
fetchSystemStatus() SystemStatus
// Per-provider API keys (encrypted server-side; switching the active
// provider no longer wipes another provider's key — responses always masked)
fetchApiKeyStatus() ApiKeyStatusResponse // { providers: [{ provider, configured, masked_key }] }
updateApiKeys(keys: ApiKeysUpdateRequest) ApiKeysUpdateResponse
deleteApiKey(provider: ApiKeyProvider) void
clearAllApiKeys() void
// Feature flags
fetchFeatureConfig() FeatureConfig
updateFeatureConfig(config: FeatureConfigUpdate) FeatureConfig
// Language
fetchLanguageConfig() LanguageConfig
updateLanguageConfig(language: string) LanguageConfig
```
> `updateLlmApiKey` (`PUT /config/llm-api-key`) no longer persists a key — keys are managed per-provider via the encrypted `/config/api-keys` endpoints above.
## Provider Info
```typescript
export const PROVIDER_INFO = {
openai: { name: 'OpenAI', defaultModel: 'gpt-5-nano-2025-08-07', requiresKey: true },
anthropic: { name: 'Anthropic', defaultModel: 'claude-haiku-4-5-20251001', requiresKey: true },
openrouter: { name: 'OpenRouter', defaultModel: 'deepseek/deepseek-chat', requiresKey: true },
gemini: { name: 'Google Gemini', defaultModel: 'gemini-3-flash-preview', requiresKey: true },
deepseek: { name: 'DeepSeek', defaultModel: 'deepseek-chat', requiresKey: true },
ollama: { name: 'Ollama (Local)', defaultModel: 'gemma3:4b', requiresKey: false },
};
```
## Usage
```typescript
import { fetchResume, API_BASE, PROVIDER_INFO } from '@/lib/api';
```
@@ -0,0 +1,188 @@
# Backend Architecture
> FastAPI + Python 3.13+ | SQLite (SQLAlchemy 2.0 async + aiosqlite) | LiteLLM multi-provider
## Directory Structure
```
apps/backend/app/
├── main.py # FastAPI entry point (lifespan: TinyDB→SQLite import, legacy-key fold-in)
├── config.py # Pydantic settings; encrypted API-key read/write
├── crypto.py # Fernet encrypt/decrypt for API keys at rest
├── database.py # Async SQLAlchemy/SQLite facade (returns plain dicts)
├── models.py # SQLAlchemy declarative Base + ORM models
├── db_engine.py # SQLite engine/session factories (async + sync) + PRAGMAs
├── llm.py # LiteLLM multi-provider
├── pdf.py # Playwright PDF rendering
├── routers/ # API endpoints (health, config, resumes, jobs, applications, enrichment)
├── services/ # parser.py, improver.py, cover_letter.py
├── schemas/ # Pydantic models (models.py, applications.py)
├── scripts/ # migrate_tinydb_to_sqlite.py (one-time importer)
└── prompts/templates.py # LLM prompts
```
## API Endpoints
### Health & Status
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/health` | Liveness probe (no LLM call) |
| GET | `/api/v1/status` | Full system status (LLM probe + DB stats, each isolated → 200 with degraded state on partial failure) |
### Configuration
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET/PUT | `/api/v1/config/llm-api-key` | LLM config (no longer persists a key) |
| POST | `/api/v1/config/llm-test` | Test connection |
| GET/POST/DELETE | `/api/v1/config/api-keys` | Per-provider encrypted API keys |
### Resumes
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/resumes/upload` | Upload PDF/DOCX |
| GET | `/resumes?resume_id=` | Fetch resume |
| GET | `/resumes/list` | List all |
| POST | `/resumes/improve` | Tailor for job (LLM) |
| PATCH | `/resumes/{id}` | Update |
| GET | `/resumes/{id}/pdf` | Download PDF |
| DELETE | `/resumes/{id}` | Delete |
### Jobs
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/jobs/upload` | Store job description |
| GET | `/jobs/{id}` | Fetch job |
### Applications (Kanban tracker)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/applications` | All cards grouped by column (7 status keys) |
| POST | `/applications` | Manual add (creates job + card) |
| GET | `/applications/{id}` | Card + JD + resume (resume null if deleted) |
| PATCH | `/applications/{id}` | Update status/position/notes/company/role |
| PATCH | `/applications/bulk` | Move many cards to one column |
| DELETE | `/applications/{id}` | Delete one card |
| POST | `/applications/bulk-delete` | Delete many cards |
## Database (`database.py`, `models.py`, `db_engine.py`)
**SQLite** via SQLAlchemy 2.0 async (`aiosqlite`). DB file: `data/resume_matcher.db`.
`database.py` is an async `Database` facade (global `db` singleton) — same method
names/signatures as before, but it returns **plain dicts**, never ORM rows.
ORM models live in `models.py` (declarative `Base` + `Resume`/`Job`/`Improvement`/`Application`/`ApiKey`);
engine/session plumbing lives in `db_engine.py`.
Tables: `resumes`, `jobs`, `improvements`, `applications`, `api_keys` (encrypted).
```python
await db.create_resume(content, content_type, filename, is_master, processed_data)
await db.get_resume(resume_id) dict | None
await db.update_resume(resume_id, updates) dict
await db.delete_resume(resume_id) bool
await db.set_master_resume(resume_id) # Exactly one master allowed
await db.create_application(...) / list_applications / update_application / bulk_*
await db.get_stats() {total_resumes, total_jobs, total_improvements, total_applications}
get_api_key_ciphertexts() / replace_api_keys(...) # sync; encrypted api_keys table
```
**Two engines, one file (`db_engine.py`):** a module-level **async** engine serves
the document tables + `applications`; a **sync** engine serves the encrypted
`api_keys` table, which is read on the synchronous LLM hot path
(`get_llm_config``load_config_file``resolve_api_key`) so async isn't threaded
through `llm.py`. Both apply PRAGMAs `journal_mode=WAL`, `foreign_keys=ON`,
`busy_timeout` on connect.
**Single-master invariant** is preserved by an `asyncio.Lock`
(`create_resume_atomic_master`) plus a partial unique index on `is_master`.
**Jobs' dynamic pipeline fields** (`preview_hash`/`preview_hashes`, `job_keywords`,
`company`/`role`) are stored in a `metadata_json` JSON column and flattened on read.
`Application` dedupes on `(job_id, resume_id)` via a `UniqueConstraint`.
### Migration & encrypted keys
- **One-time importer** (`app/scripts/migrate_tinydb_to_sqlite.py`): runs on lifespan
startup. If a legacy `data/database.json` (TinyDB) exists and SQLite is empty, it
imports the rows, then renames the file `database.json.migrated` (rollback artifact).
Idempotent: skips if SQLite already has rows.
- **Encrypted API keys** (`app/crypto.py`): Fernet symmetric encrypt/decrypt. The
secret lives at `data/.secret_key` (auto-generated, `chmod 600`, gitignored, atomic
write); plaintext exists only in memory. Per-provider ciphertexts live in the
`api_keys` table. `app/config.py` reads/writes them (atomic `replace_api_keys`);
`migrate_legacy_keys()` folds any legacy plaintext keys into the encrypted store on
startup (idempotent, non-clobbering).
## LLM Integration (`llm.py`)
**Providers:** OpenAI, Anthropic, Gemini, DeepSeek, OpenRouter, Ollama
```python
await check_llm_health(config) # 30s timeout
await complete(prompt, ...) # 120s timeout
await complete_json(prompt, ...) # 180s timeout, JSON mode + retries
```
**Key Features:**
- API keys passed directly (avoids os.environ race conditions)
- Auto JSON mode for supported providers
- 2 retries with lower temperature
- Bracket-matching JSON extraction
## Services
### Parser (`services/parser.py`)
```python
await parse_document(content, filename) str # PDF/DOCX → Markdown
await parse_resume_to_json(markdown) dict # LLM call
```
### Improver (`services/improver.py`)
```python
await extract_job_keywords(job_desc) dict # LLM call
await improve_resume(original, job, keywords) # LLM call
```
### Cover Letter (`services/cover_letter.py`)
```python
await generate_cover_letter(resume, job) str # LLM call
await generate_outreach_message(resume, job) str # LLM call
```
## PDF Rendering (`pdf.py`)
Uses Playwright headless Chromium:
```python
await render_resume_pdf(url, page_size, selector=".resume-print")
```
**Critical:** CSS must whitelist print classes in `globals.css`:
```css
@media print {
body * { visibility: hidden !important; }
.resume-print, .resume-print * { visibility: visible !important; }
}
```
## Configuration
```bash
LLM_PROVIDER=openai|anthropic|gemini|deepseek|openrouter|ollama
LLM_MODEL=gpt-5-nano-2025-08-07
LLM_API_KEY=sk-...
FRONTEND_BASE_URL=http://localhost:3000
```
Non-secret config (provider/model/base/features) stored in `data/config.json`, takes
precedence over env vars. **API keys are never written to `config.json`** — they live
encrypted in the SQLite `api_keys` table (per-provider) and are injected into the config
dict only at read time. Set them via `POST /config/api-keys`; `PUT /config/llm-api-key` no
longer persists a key.
## Error Handling
Log detailed errors server-side, return generic messages to clients:
```python
except Exception as e:
logger.error(f"Failed: {e}")
raise HTTPException(500, "Operation failed. Please try again.")
```
+132
View File
@@ -0,0 +1,132 @@
# Backend Guide
> Lean, local-first FastAPI app for resume tailoring.
## Tech Stack
| Component | Technology |
|-----------|------------|
| Framework | FastAPI |
| Database | SQLite (SQLAlchemy 2.0 async + aiosqlite) |
| AI | LiteLLM (100+ providers) |
| Doc Parsing | markitdown |
| Validation | Pydantic |
| Key encryption | Fernet (`cryptography`) |
## Directory Structure
```
apps/backend/app/
├── main.py # Entry point (lifespan: TinyDB→SQLite import, legacy-key fold-in)
├── config.py # Settings from env/file; encrypted API-key read/write
├── crypto.py # Fernet encrypt/decrypt for API keys at rest
├── database.py # Async SQLAlchemy/SQLite facade (returns plain dicts)
├── models.py # SQLAlchemy declarative Base + ORM models
├── db_engine.py # SQLite engine/session factories (async + sync) + PRAGMAs
├── llm.py # Multi-provider LLM
├── routers/ # health, config, resumes, jobs, applications, enrichment
├── services/ # parser, improver, cover_letter
├── schemas/ # Pydantic models (models.py, applications.py)
├── scripts/ # migrate_tinydb_to_sqlite.py (one-time importer)
└── prompts/ # templates.py
```
## Database Operations
`database.py` is an async `Database` facade (global `db` singleton). Methods keep the
same names/signatures as the old TinyDB wrapper but return **plain dicts** (never ORM
rows). ORM models are in `models.py`; engine plumbing is in `db_engine.py`.
```python
await db.create_resume(content, content_type, filename, is_master, processed_data)
await db.get_resume(resume_id) dict | None
await db.list_resumes() list[dict]
await db.update_resume(resume_id, updates)
await db.delete_resume(resume_id) bool
await db.set_master_resume(resume_id) # Exactly one master allowed
await db.create_job(content, resume_id)
await db.create_application(...) / list_applications / bulk_update_applications
get_api_key_ciphertexts() / replace_api_keys(...) # sync; encrypted api_keys table
```
**Tables:** `resumes`, `jobs`, `improvements`, `applications`, `api_keys` (encrypted).
DB file: `data/resume_matcher.db`.
**Two engines, one file:** a module-level **async** engine serves the document tables +
`applications`; a **sync** engine serves the encrypted `api_keys` table (read on the
synchronous LLM hot path). Both apply PRAGMAs `journal_mode=WAL`, `foreign_keys=ON`,
`busy_timeout`. The single-master invariant is held by an `asyncio.Lock` plus a partial
unique index. Jobs' dynamic pipeline fields (`preview_hash(es)`, `job_keywords`,
`company`/`role`) live in a `metadata_json` JSON column, flattened on read.
### Encrypted API keys & migration
- **Keys** (`crypto.py`): Fernet-encrypted, per-provider, in the `api_keys` table. Secret
at `data/.secret_key` (`chmod 600`, gitignored, atomic write; plaintext only in memory).
`config.py` injects decrypted keys at read time and strips them on save, so secrets
never reach `config.json`. Set via `POST /config/api-keys`; `PUT /config/llm-api-key` no
longer persists a key.
- **Migration** (`scripts/migrate_tinydb_to_sqlite.py`): runs on lifespan startup. Imports
a legacy `data/database.json` (TinyDB) into SQLite if present, then renames it
`database.json.migrated`. Idempotent. `migrate_legacy_keys()` likewise folds legacy
plaintext keys into the encrypted store.
## LLM Features
| Feature | Description |
|---------|-------------|
| API Key Passing | Direct to litellm (avoids race conditions) |
| JSON Mode | Auto-enabled for supported providers |
| Retry Logic | 2 retries, temperature 0.1→0.0 |
| Timeouts | 30s (health), 120s (completion), 180s (JSON) |
## Prompt Guidelines
1. Use `{variable}` for substitution (single braces)
2. Include JSON schema examples
3. End with "Output ONLY the JSON object"
## API Endpoints Quick Ref
```
GET /api/v1/health # liveness probe (no LLM call)
GET /api/v1/status # Full status (LLM + DB isolated; 200 on partial failure)
GET/PUT /api/v1/config/llm-api-key # no longer persists a key
GET/POST/DELETE /api/v1/config/api-keys # per-provider encrypted keys
POST /api/v1/resumes/upload # PDF/DOCX
POST /api/v1/resumes/improve # Tailor (LLM)
GET /api/v1/resumes/{id}/pdf
DELETE /api/v1/resumes/{id}
GET /api/v1/applications # Kanban tracker: grouped list (+ POST/PATCH/DELETE/bulk)
```
## Data Flow
**Upload:** File → markitdown → Markdown → LLM parse → JSON → SQLite (via `db`)
**Improve:** Resume + Job → Extract keywords (LLM) → Tailor (LLM) → Store. Routers call
services; services call `app/llm.py`; persistence goes through the async `db` facade.
`/improve/confirm` also best-effort auto-creates an `applied` card in the tracker.
## Error Handling
Log details server-side, generic messages to clients:
```python
except Exception as e:
logger.error(f"Failed: {e}")
raise HTTPException(500, "Operation failed.")
```
## Running
```bash
cd apps/backend
cp .env.example .env
uv run uvicorn app.main:app --reload --port 8000
```
## Adding New Endpoints
1. Create router in `app/routers/`
2. Add Pydantic models to `app/schemas/models.py`
3. Register router in `app/main.py`
@@ -0,0 +1,119 @@
# Frontend Architecture
> Next.js 15 + React 19 | TypeScript | Tailwind | Swiss International Style
## Directory Structure
```
apps/frontend/
├── app/
│ ├── (default)/ # Main app routes
│ │ ├── page.tsx # Landing (/)
│ │ ├── dashboard/ # /dashboard
│ │ ├── builder/ # /builder
│ │ ├── tailor/ # /tailor
│ │ ├── settings/ # /settings
│ │ └── resumes/[id]/ # /resumes/[id]
│ └── print/ # Print routes for PDF
├── components/
│ ├── ui/ # Button, Input, Dialog, etc.
│ ├── builder/ # ResumeBuilder, forms/
│ ├── preview/ # PaginatedPreview, usePagination
│ └── resume/ # Templates (single, two-column)
├── lib/
│ ├── api/ # client.ts, resume.ts, config.ts
│ ├── context/ # status-cache.tsx, language-context.tsx
│ └── constants/ # page-dimensions.ts
└── messages/ # i18n translations
```
## Pages
### Dashboard (`/dashboard`)
- Master resume card + tailored resume tiles
- States: `loading | pending | processing | ready | failed`
- Auto-refreshes on window focus
- localStorage: `master_resume_id`
### Builder (`/builder`)
- Left: Editor Panel (forms + formatting controls)
- Right: WYSIWYG PaginatedPreview
- Tabs: Resume | Cover Letter | Outreach
- Auto-saves to localStorage
### Tailor (`/tailor`)
- Job description textarea
- Calls: `POST /jobs/upload``POST /resumes/improve`
- Redirects to `/resumes/[new_id]`
### Settings (`/settings`)
- Provider selection (6 providers)
- API key input
- System status (cached, 30-min refresh)
### Print Routes (`/print/resumes/[id]`, `/print/cover-letter/[id]`)
- Headless Chrome renders these for PDF
- Query params: template, pageSize, margins, spacing
## UI Components
**Button variants:** default (blue), destructive (red), success (green), warning (orange), outline, secondary
**Styling:** `rounded-none`, hard shadows, `font-mono` for labels
## Context Providers
### StatusCacheProvider
```typescript
const { status, refreshStatus, incrementResumes, decrementResumes } = useStatusCache();
```
- Caches system status, 30-min auto-refresh
- Optimistic counter updates on user actions
### LanguageProvider
```typescript
const { contentLanguage, setContentLanguage } = useLanguage();
```
- Content generation language (en, es, zh, ja)
## API Client (`lib/api/`)
```typescript
import { fetchResume, API_BASE } from '@/lib/api';
// client.ts exports
API_URL, API_BASE, apiFetch, apiPost, apiPatch, apiDelete
// resume.ts
uploadJobDescriptions, improveResume, fetchResume, fetchResumeList
updateResume, downloadResumePdf, deleteResume
// config.ts
fetchLlmConfig, updateLlmConfig, testLlmConnection, fetchSystemStatus
```
## localStorage Keys
| Key | Purpose |
|-----|---------|
| `master_resume_id` | Master resume UUID |
| `resume_builder_draft` | Auto-saved form data |
| `resume_builder_settings` | Template preferences |
## Pagination System
`usePagination` hook calculates page breaks:
- Respects `.resume-item` boundaries
- Prevents orphaned headers
- 150ms debounce for performance
## Critical CSS Rule
For PDF generation, `globals.css` must whitelist print classes:
```css
@media print {
body * { visibility: hidden !important; }
.resume-print, .resume-print * { visibility: visible !important; }
.cover-letter-print, .cover-letter-print * { visibility: visible !important; }
}
```
@@ -0,0 +1,88 @@
# Frontend Workflow
> User flows and state management for Resume Matcher.
## Core User Flow
```
Dashboard → Upload Master Resume → Tailor for Job → View/Edit → Download PDF
```
## Pages
### 1. Dashboard (`/dashboard`)
- **No master:** "Initialize Master Resume" card
- **Has master:** "Master Resume" card + tailored tiles
- **Create:** "+" card opens `/tailor`
- Auto-refreshes on window focus
### 2. Resume Viewer (`/resumes/[id]`)
- Read-only display at 250mm width
- Actions: Back, Edit, Download PDF, Delete
- Delete shows confirmation + success dialogs
### 3. Tailor (`/tailor`)
- Job description textarea (min 50 chars)
- Process: Upload JD → Improve → Redirect to viewer
### 4. Builder (`/builder`)
- **Left panel:** Editor (forms + formatting)
- **Right panel:** WYSIWYG preview
- **Tabs:** Resume | Cover Letter | Outreach
- Data priority: URL param → Context → localStorage → defaults
### 5. Settings (`/settings`)
- System status (cached)
- LLM configuration (6 providers)
- Last fetched indicator + manual refresh
## Pagination Rules
- Sections CAN span pages
- Individual items stay together
- Pages ≥50% full before break
- Headers never orphaned
## State Management
### localStorage
| Key | Purpose |
|-----|---------|
| `master_resume_id` | Master resume UUID |
| `resume_builder_draft` | Auto-saved form |
| `resume_builder_settings` | Template prefs |
### StatusCache Context
- Initial fetch on app start
- 30-min auto-refresh
- Optimistic counter updates
## Delete Flow
1. Click Delete → Confirmation dialog
2. API: `DELETE /resumes/{id}`
3. Clear localStorage if master
4. Success dialog → Redirect to dashboard
## Section Management
| Action | Result |
|--------|--------|
| Rename | Click pencil icon |
| Reorder | Up/down arrows |
| Hide | Eye icon (hidden sections still editable) |
| Delete | Hides default, removes custom |
| Add | "Add Section" button |
## API Client
```typescript
import { fetchResume, API_BASE } from '@/lib/api';
// Resume operations
fetchResume, fetchResumeList, updateResume, deleteResume
uploadJobDescriptions, improveResume, downloadResumePdf
// Config operations
fetchLlmConfig, updateLlmConfig, testLlmConnection
```
@@ -0,0 +1,503 @@
# Prompt Workflow Design: Deviation Detection & Retry
> **Status**: Superseded by [diff-based improvement design](../../superpowers/specs/2026-03-23-diff-based-improvement-design.md)
> **Scope**: Backend improvement pipeline — `improver.py`, `refiner.py`, `llm.py`, `enrichment.py`
---
## Problem Statement
The current resume improvement pipeline has **no content-quality retry**. The LLM can produce structurally valid JSON that deviates significantly from the original resume — dropping entries, inflating word count, fabricating skills — and it passes straight through to refinement.
### What exists today
```
extract_keywords() ─→ improve_resume() ─→ 6 safety nets ─→ refine_resume() ─→ diff + aux
LLM #1 LLM #2 (local) LLM #3 LLM #4-6
```
**Retries that exist (in `llm.py:complete_json`):**
- Malformed JSON → retry with hint "Output ONLY valid JSON"
- Truncated output (empty arrays) → retry with hint "Output COMPLETE JSON"
- Empty response → retry
- Temperature escalation per retry (0.1 → 0.3 → 0.5 → 0.7)
**What's missing:**
- No check that the LLM's output is faithful to the original resume
- No check that section counts are preserved (work entries, education, projects)
- No check that word count hasn't exploded (over-elaboration)
- No check that new skills come from the JD, not invented
- No retry mechanism for content deviation — only for structural failures
### Where deviation hurts
| Deviation type | Current detection | When caught | Cost |
|----------------|-------------------|-------------|------|
| Dropped work entry | None | User notices in preview | Trust loss |
| Fabricated skill | `validate_master_alignment` in refiner | After improve, during refine | Removed silently, wastes LLM call |
| Word count doubled | None | Never | Over-elaborated resume |
| personalInfo in output | `_preserve_personal_info` patches it | After improve | Patched, not prevented |
| Dates lost | `_restore_original_dates` patches it | After improve | Patched, not prevented |
The safety nets in `resumes.py` (lines 750-763) **patch** the output after the fact. They don't feed back to the LLM to produce a better result. The refiner catches fabrications but **removes** them instead of asking the LLM to try again.
---
## Design: Deviation Evaluator + Retry Loop
### Architecture
Insert a **local evaluator** between `improve_resume()` and the safety nets. If the evaluator detects deviation beyond thresholds, retry the LLM call with specific feedback. Max 1 retry (total 2 attempts) to avoid latency explosion.
```
┌─────────────────────┐
│ retry with feedback │
│ (max 1 retry) │
└────────┬────────────┘
extract_keywords ──→ improve_resume ──→ evaluate_deviation ──→ safety nets ──→ refine ──→ done
LLM #1 LLM #2 (local, free) (local) LLM #3
▲ │
│ fail │ pass
└────────────────┘
```
### Evaluator: local, zero LLM cost
The evaluator runs locally — no LLM call. It compares the improved output against the original resume data and returns a pass/fail with specific feedback.
### Where each piece lives
| Component | File | Function |
|-----------|------|----------|
| Deviation evaluator | `app/services/improver.py` | `evaluate_deviation()` |
| Retry loop | `app/services/improver.py` | Inside `improve_resume()` |
| Deviation feedback prompt | `app/prompts/templates.py` | `DEVIATION_FEEDBACK_SUFFIX` |
| Enrichment parallelization | `app/routers/enrichment.py` | `generate_enhancements()` |
| Auto strategy selection | `app/services/improver.py` | `auto_select_strategy()` |
---
## Change 1: Deviation Evaluator
**File: `app/services/improver.py`**
New function `evaluate_deviation()` that checks 5 criteria:
### Check 1: Section count preservation
The LLM must not drop or add work experience, education, or project entries.
```python
def _check_section_counts(
original: dict[str, Any],
improved: dict[str, Any],
) -> list[str]:
issues = []
for key, label in [
("workExperience", "work experience"),
("education", "education"),
("personalProjects", "project"),
]:
orig_count = len(original.get(key, []))
imp_count = len(improved.get(key, []))
if imp_count < orig_count:
issues.append(
f"Dropped {orig_count - imp_count} {label} "
f"entries ({orig_count}{imp_count}). "
f"Keep ALL original entries."
)
elif imp_count > orig_count:
issues.append(
f"Added {imp_count - orig_count} new {label} "
f"entries ({orig_count}{imp_count}). "
f"Do NOT invent new entries."
)
return issues
```
**Why this matters:** The most common LLM failure mode is silently dropping the oldest work experience entry when the output gets long. The user doesn't notice until they print the PDF.
### Check 2: Word count ratio
The improved resume should not be more than 1.8x the original word count. Over-elaboration makes resumes look AI-generated.
```python
def _check_word_count(
original: dict[str, Any],
improved: dict[str, Any],
max_ratio: float = 1.8,
) -> list[str]:
orig_words = _count_description_words(original)
imp_words = _count_description_words(improved)
if orig_words > 0 and imp_words > orig_words * max_ratio:
return [
f"Description word count increased {imp_words / orig_words:.1f}x "
f"({orig_words}{imp_words}). "
f"Keep descriptions concise — do not over-elaborate."
]
return []
```
**Why 1.8x:** The "full" strategy can legitimately expand bullets. But 2x+ is always over-elaboration. 1.8x gives headroom for the full strategy while catching runaway expansion.
### Check 3: Fabricated skills detection
New skills in the output must come from the JD keywords, not invented by the LLM.
```python
def _check_fabricated_skills(
original: dict[str, Any],
improved: dict[str, Any],
job_keywords: dict[str, Any],
) -> list[str]:
orig_skills = _extract_skills_set(original)
imp_skills = _extract_skills_set(improved)
jd_skills = _extract_jd_skills_set(job_keywords)
fabricated = imp_skills - orig_skills - jd_skills
if fabricated:
# Only flag if more than 1 — single-word variants are common
if len(fabricated) > 1:
sample = ", ".join(sorted(fabricated)[:5])
return [
f"Added skills not in original resume or job description: "
f"{sample}. Only use skills from the original resume or JD."
]
return []
```
**Why this catches what the refiner misses:** The refiner checks against the *master* resume (which may have more skills than the original tailored input). This check is tighter — it catches skills that aren't in the JD either.
### Check 4: Company/role preservation
The LLM must not rename companies or job titles.
```python
def _check_entry_identity(
original: dict[str, Any],
improved: dict[str, Any],
) -> list[str]:
issues = []
for key, title_field, subtitle_field, label in [
("workExperience", "title", "company", "work experience"),
("education", "degree", "institution", "education"),
]:
orig_entries = original.get(key, [])
imp_entries = improved.get(key, [])
for i, (orig, imp) in enumerate(zip(orig_entries, imp_entries)):
orig_id = (
orig.get(subtitle_field, "").strip().lower()
)
imp_id = (
imp.get(subtitle_field, "").strip().lower()
)
if orig_id and imp_id and orig_id != imp_id:
issues.append(
f"Changed {label}[{i}] {subtitle_field} from "
f"'{orig.get(subtitle_field)}' to "
f"'{imp.get(subtitle_field)}'. "
f"Never change company names, institutions, or degrees."
)
return issues
```
### Check 5: personalInfo leakage
The improve prompts tell the LLM to skip personalInfo. If it's in the output, that's a deviation.
```python
def _check_personal_info_leak(improved: dict[str, Any]) -> list[str]:
pi = improved.get("personalInfo")
if pi and isinstance(pi, dict) and any(pi.values()):
return [
"Output includes personalInfo — the improve prompt "
"excludes it. Do NOT include personalInfo in output."
]
return []
```
### Combined evaluator
```python
@dataclass
class DeviationResult:
passed: bool
issues: list[str]
severity: str # "none" | "soft" | "hard"
def evaluate_deviation(
original: dict[str, Any],
improved: dict[str, Any],
job_keywords: dict[str, Any],
) -> DeviationResult:
"""Local quality gate — zero LLM cost.
Returns pass/fail with specific feedback for retry prompt.
"""
issues: list[str] = []
# Hard failures — always retry
issues.extend(_check_section_counts(original, improved))
issues.extend(_check_entry_identity(original, improved))
hard_issues = len(issues)
# Soft failures — retry only on first attempt
issues.extend(_check_word_count(original, improved))
issues.extend(_check_fabricated_skills(original, improved, job_keywords))
issues.extend(_check_personal_info_leak(improved))
if not issues:
return DeviationResult(passed=True, issues=[], severity="none")
severity = "hard" if hard_issues > 0 else "soft"
return DeviationResult(passed=False, issues=issues, severity=severity)
```
---
## Change 2: Retry Loop in `improve_resume()`
**File: `app/services/improver.py`** — modify `improve_resume()` function
The current function makes one `complete_json` call and returns. Add a loop around it:
```python
async def improve_resume(
original_resume: str,
job_description: str,
job_keywords: dict[str, Any],
language: str = "en",
prompt_id: str | None = None,
original_resume_data: dict[str, Any] | None = None,
) -> dict[str, Any]:
# ... existing setup (lines 170-211) stays the same ...
max_attempts = 2
last_result = None
for attempt in range(max_attempts):
current_prompt = prompt
if attempt > 0 and last_result is not None:
# Append deviation feedback to prompt
evaluation = evaluate_deviation(
original_resume_data or {},
last_result,
job_keywords,
)
feedback = "\n".join(f"- {issue}" for issue in evaluation.issues)
current_prompt = (
prompt
+ f"\n\n--- CRITICAL CORRECTIONS (your previous output had these problems) ---\n"
+ feedback
+ "\n\nFix ALL issues listed above. Do not repeat these mistakes."
)
logger.info(
"Deviation retry (attempt %d/%d): %s",
attempt + 1, max_attempts, feedback,
)
result = await complete_json(
prompt=current_prompt,
system_prompt="You are an expert resume editor. Output only valid JSON.",
max_tokens=8192,
)
_check_for_truncation(result)
validated = ResumeData.model_validate(result)
result_dict = validated.model_dump()
# Evaluate deviation (skip on last attempt — take what we get)
if attempt < max_attempts - 1 and original_resume_data:
evaluation = evaluate_deviation(
original_resume_data, result_dict, job_keywords,
)
if evaluation.passed:
return result_dict
# Only retry on hard failures (dropped entries, renamed companies)
# Soft failures (word count, skills) proceed with warning
if evaluation.severity == "soft":
logger.warning(
"Soft deviation detected (not retrying): %s",
evaluation.issues,
)
return result_dict
# Hard failure — retry
logger.warning(
"Hard deviation detected (retrying): %s",
evaluation.issues,
)
last_result = result_dict
continue
return result_dict
# Should not reach here, but safety fallback
return result_dict
```
### Key design decisions
1. **Max 1 retry (2 total attempts)**: Adding more retries compounds latency. The improve call takes 8-12s. One retry adds 8-12s. Two retries would add 16-24s on top of a flow that already has a 240s timeout.
2. **Hard vs soft severity**: Dropped entries and renamed companies are always retried. Word count and skill issues only generate warnings — the refiner handles fabricated skills anyway.
3. **Feedback goes into the prompt**: The retry appends specific issues to the original prompt. The LLM sees exactly what it did wrong. This is more effective than generic "try again" hints.
4. **Evaluator runs before safety nets**: The safety nets in `resumes.py` (preserve_personal_info, restore_dates, etc.) patch the output. We want the evaluator to run on the raw LLM output, before patching. This means the evaluator lives inside `improve_resume()`, not in the router.
5. **No LLM cost for evaluation**: All 5 checks are local string/set comparisons. The only additional LLM cost is the retry call itself, and only when deviation is detected.
---
## Change 3: Deviation Feedback Prompt Suffix
**File: `app/prompts/templates.py`**
No new prompt template needed. The feedback is constructed dynamically from the evaluator's issues list and appended as a suffix to the existing prompt. This keeps the prompt system simple — no new templates to maintain.
The suffix format:
```
--- CRITICAL CORRECTIONS (your previous output had these problems) ---
- Dropped 1 work experience entry (4 → 3). Keep ALL original entries.
- Changed workExperience[0] company from 'Acme Corp' to 'ACME Corporation'. Never change company names.
Fix ALL issues listed above. Do not repeat these mistakes.
```
---
## Change 4: Parallelization in `generate_enhancements()`
**File: `app/routers/enrichment.py`** — modify `generate_enhancements()` endpoint
Current code (sequential):
```python
for item_id, answers in answers_by_item.items():
# ... build prompt ...
result = await complete_json(prompt) # blocks on each item
enhancements.append(...)
```
Changed to parallel:
```python
async def _enhance_single_item(item_id, item, answers, questions_by_id):
"""Generate enhanced descriptions for a single item."""
# ... build prompt (existing code) ...
result = await complete_json(prompt)
additional_bullets = result.get("additional_bullets", [])
if not additional_bullets:
additional_bullets = result.get("enhanced_description", [])
if not isinstance(additional_bullets, list):
additional_bullets = []
additional_bullets = [str(b) for b in additional_bullets if b]
return EnhancedDescription(
item_id=item_id,
item_type=item.get("item_type", "experience"),
title=item.get("title", ""),
original_description=item.get("current_description", []),
enhanced_description=additional_bullets,
)
# In generate_enhancements():
tasks = [
_enhance_single_item(item_id, item_details.get(item_id, {}), answers, questions_by_id)
for item_id, answers in answers_by_item.items()
if item_details.get(item_id)
]
results = await asyncio.gather(*tasks, return_exceptions=True)
enhancements = []
for result in results:
if isinstance(result, Exception):
logger.warning(f"Failed to enhance item: {result}")
else:
enhancements.append(result)
```
**Impact:** If a user has 4 items to enhance, latency drops from ~40s (4 sequential calls) to ~10s (1 parallel batch).
---
## Change 5: Auto Strategy Selection
**File: `app/services/improver.py`**
New function that selects `nudge`/`keywords`/`full` based on keyword overlap, used when `prompt_id="auto"`:
```python
def auto_select_strategy(
original_resume_data: dict[str, Any],
job_keywords: dict[str, Any],
) -> str:
"""Select tailoring strategy based on keyword match percentage.
Uses the same keyword matching logic as the refiner to determine
how much work the LLM needs to do.
"""
from app.services.refiner import calculate_keyword_match
match_pct = calculate_keyword_match(original_resume_data, job_keywords)
if match_pct >= 70:
return "nudge" # already close — light rephrasing
elif match_pct >= 35:
return "keywords" # relevant but missing key terms
else:
return "full" # needs significant restructuring
```
**Integration point** — in `improve_resume()`:
```python
selected_prompt_id = prompt_id or DEFAULT_IMPROVE_PROMPT_ID
if selected_prompt_id == "auto" and original_resume_data:
selected_prompt_id = auto_select_strategy(original_resume_data, job_keywords)
logger.info("Auto-selected strategy: %s", selected_prompt_id)
```
This requires adding `"auto"` to the `IMPROVE_PROMPT_OPTIONS` list in `app/prompts/templates.py` so the frontend can offer it.
---
## Summary: All Changes
| # | What | Where | Type | LLM cost |
|---|------|-------|------|----------|
| 1 | `evaluate_deviation()` — 5 local checks | `app/services/improver.py` | New function | 0 |
| 2 | Retry loop in `improve_resume()` | `app/services/improver.py` | Modify existing | +1 call on hard failure only |
| 3 | Deviation feedback suffix | Dynamic (in `improve_resume()`) | No new template | 0 |
| 4 | Parallelize `generate_enhancements()` | `app/routers/enrichment.py` | Modify existing | 0 |
| 5 | `auto_select_strategy()` | `app/services/improver.py` | New function | 0 |
### What we're NOT changing
- `llm.py` — the transport-level retry and JSON quality checks stay as-is
- `refiner.py` — alignment validation, phrase removal, keyword injection stay as-is
- `resumes.py` safety nets — `_preserve_personal_info`, `_restore_original_dates`, etc. stay as-is
- No new dependencies (no LangChain, no workflow engine)
- No new prompt templates — feedback is constructed dynamically
### Latency impact
| Scenario | Current | After change |
|----------|---------|-------------|
| Happy path (no deviation) | ~12s | ~12s (evaluator adds <1ms) |
| Hard deviation (dropped entry) | ~12s (bad output) | ~24s (retry produces correct output) |
| Enrichment (4 items) | ~40s | ~10s |
| Auto strategy | N/A | <1ms (local keyword match) |
---
## Implementation Order
1. **`evaluate_deviation()` + retry loop** — highest impact, catches the worst failures
2. **Enrichment parallelization** — straightforward, immediate latency win
3. **Auto strategy selection** — small addition, requires frontend support for "auto" option
+77
View File
@@ -0,0 +1,77 @@
# Coding Standards
> **Frontend and backend coding conventions.**
## Frontend (TypeScript/React)
### Design System
All UI changes MUST follow the **Swiss International Style**. The full design system is published as a portable pack at [`docs/portable/swiss-design-system/`](../portable/swiss-design-system/README.md). The non-negotiable basics:
- Use `font-serif` for headers, `font-mono` for metadata, `font-sans` for body text
- Color palette: `#F0F0E8` (Canvas), `#000000` (Ink), `#1D4ED8` (Hyper Blue), `#15803D` (Signal Green), `#F97316` (Alert Orange), `#DC2626` (Alert Red), `#4B5563` (Steel Grey)
- Components: `rounded-none` with 1px black borders and hard shadows
- See [`tokens.md`](../portable/swiss-design-system/tokens.md), [`components.md`](../portable/swiss-design-system/components.md), and [`anti-patterns.md`](../portable/swiss-design-system/anti-patterns.md) for the full rules
### Naming Conventions
- Use PascalCase for components
- Use camelCase for helpers
- Tailwind utility classes for styling
### Textarea Enter Key Fix
All textareas in forms should include `onKeyDown` with `e.stopPropagation()` for Enter key to ensure newlines work correctly:
```tsx
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter') e.stopPropagation();
};
```
### Before Committing
1. Run Prettier: `npm run format`
2. Run linter: `npm run lint`
## Backend (Python/FastAPI)
### General Rules
- Python 3.11+
- 4-space indents
- Type hints on ALL functions
- Async functions for I/O operations (database, LLM calls)
- Pydantic models for all request/response schemas
- Prompts go in `app/prompts/templates.py`
### Error Handling
Log detailed errors server-side, return generic messages to clients:
```python
except Exception as e:
logger.error(f"Operation failed: {e}")
raise HTTPException(status_code=500, detail="Operation failed. Please try again.")
```
### Race Conditions
Use `asyncio.Lock()` for shared resource initialization (see `app/pdf.py` for example).
### Mutable Defaults
Always use `copy.deepcopy()` when assigning mutable default values to avoid shared state bugs:
```python
# Correct
import copy
data = copy.deepcopy(DEFAULT_DATA)
# Incorrect - shared state bug
data = DEFAULT_DATA
```
### New Service Pattern
Mirror patterns in `app/services/improver.py` for new services.
+94
View File
@@ -0,0 +1,94 @@
# PDF Template Guide
> PDF rendering and template editing for resumes and cover letters.
## Rendering Flow
```
Backend: GET /resumes/{id}/pdf
├── Build URL: {frontend}/print/resumes/{id}?params
├── Playwright opens headless Chrome
├── Waits for .resume-print selector
├── Waits for document.fonts.ready
├── Generates PDF (zero margins, print_background=true)
└── Returns PDF bytes
```
## Print Routes
| Route | Selector | Output |
|-------|----------|--------|
| `/print/resumes/[id]` | `.resume-print` | Resume PDF |
| `/print/cover-letter/[id]` | `.cover-letter-print` | Cover letter PDF |
## Query Parameters
| Param | Default | Range |
|-------|---------|-------|
| template | swiss-single | swiss-single, swiss-two-column |
| pageSize | A4 | A4, LETTER |
| marginTop/Bottom/Left/Right | 10 | 5-25mm |
| sectionSpacing | 3 | 1-5 |
| itemSpacing | 2 | 1-5 |
| lineHeight | 3 | 1-5 |
| fontSize | 3 | 1-5 |
| headerScale | 3 | 1-5 |
## Critical CSS Rule
In `globals.css`, whitelist print classes or PDFs will be blank:
```css
@media print {
body * { visibility: hidden !important; }
.resume-print,
.resume-print * { visibility: visible !important; }
.cover-letter-print,
.cover-letter-print * { visibility: visible !important; }
}
```
## Template Structure
```
components/resume/
├── index.ts # Template exports
├── resume-single-column.tsx # Full-width vertical
└── resume-two-column.tsx # 65% main + 35% sidebar
```
## Adding New Templates
1. Create `components/resume/resume-{name}.tsx`
2. Export from `components/resume/index.ts`
3. Add to template selector in `formatting-controls.tsx`
4. Add thumbnail preview
## Template Props
```typescript
interface TemplateProps {
resumeData: ResumeData;
settings: TemplateSettings;
}
```
## CSS Classes
```css
.resume-section /* Section container */
.resume-section-title /* Section heading */
.resume-items /* Items container */
.resume-item /* Individual entry (won't split across pages) */
```
## Error Handling
If Playwright can't connect to frontend, returns HTTP 503 with:
```
Cannot connect to frontend for PDF generation.
Please ensure: 1) Frontend is running
2) FRONTEND_BASE_URL matches your frontend URL
```
@@ -0,0 +1,54 @@
# Print PDF Design Spec
> Specifications for PDF rendering in Resume Matcher.
## Page Sizes
| Size | Dimensions |
|------|------------|
| A4 | 210 × 297 mm |
| US Letter | 215.9 × 279.4 mm |
## Print Route
```
/print/resumes/[id]?template=swiss-single&pageSize=A4&marginTop=10...
```
## CSS Requirements
```css
@media print {
body * { visibility: hidden !important; }
.resume-print,
.resume-print * { visibility: visible !important; }
}
```
## Playwright Settings
```python
await page.pdf(
format="A4", # or "Letter"
print_background=True,
margin={"top": "0", "right": "0", "bottom": "0", "left": "0"}
)
```
Margins are applied via HTML padding, not PDF margins (WYSIWYG accuracy).
## Section Break Rules
- Individual `.resume-item` elements stay together
- Section titles never orphaned at page bottom
- Pages must be ≥50% full before breaking to next
## File Locations
| File | Purpose |
|------|---------|
| `app/print/resumes/[id]/page.tsx` | Print route |
| `components/preview/use-pagination.ts` | Page break logic |
| `lib/constants/page-dimensions.ts` | Size constants |
| `apps/backend/app/pdf.py` | Playwright renderer |
@@ -0,0 +1,70 @@
# Resume Template Design Spec
> Swiss International Style specifications for resume templates.
## Typography
| Element | Font | Size | Weight |
|---------|------|------|--------|
| Name | serif | 2xl | bold |
| Title | serif | lg | normal |
| Section heading | serif | lg | semibold |
| Job title | sans | base | semibold |
| Company | sans | sm | medium |
| Body text | sans | sm | normal |
| Metadata | mono | xs | normal |
## Spacing
| Element | Spacing |
|---------|---------|
| Between sections | 16-24px |
| Between items | 8-12px |
| Line height | 1.4-1.6 |
## Colors
```
Text: #000000 (Ink)
Links: #1D4ED8 (Hyper Blue)
Dividers: #E5E5E0
Background: #FFFFFF
```
## Section Order
1. Header (name, title, contact)
2. Summary
3. Work Experience
4. Projects
5. Education
6. Additional (skills, languages, certs, awards)
## Two-Column Layout
```
┌────────────────────┬──────────┐
│ Main (65%) │ Side 35% │
├────────────────────┼──────────┤
│ Experience │ Summary │
│ Projects │ Education│
│ Certifications │ Skills │
│ │ Languages│
│ │ Awards │
└────────────────────┴──────────┘
```
## CSS Classes
```css
.resume-section /* Section wrapper */
.resume-section-title /* Heading */
.resume-items /* Item container */
.resume-item /* Single entry */
```
## Print Considerations
- Never split `.resume-item` across pages
- Never orphan section headers
- Minimum 50% page fill before break
+125
View File
@@ -0,0 +1,125 @@
# Template System
> Resume template architecture and customization.
## Templates
| Template | Layout | Best For |
| ----------------- | ----------------------------------- | ---------------------------------------- |
| swiss-single | Full-width vertical | 1-2 page resumes |
| swiss-two-column | 65% main + 35% sidebar | Dense content |
| modern | Single column, accent headers | Colorful single-column |
| modern-two-column | 65% main + 35% sidebar, accent | Colorful dense content |
| latex | Single column, serif, ruled headers | Classic/academic résumés |
| clean | Single column, minimal sans | Understated modern résumés |
| vivid | 63% main + 37% sidebar, accent | Colorful Awesome-CV style (accent color) |
## File Structure
```
components/resume/
├── index.ts # Re-exports all templates
├── resume-single-column.tsx # swiss-single
├── resume-two-column.tsx # swiss-two-column
├── resume-modern.tsx # modern
├── resume-modern-two-column.tsx # modern-two-column
├── resume-latex.tsx # latex
├── resume-clean.tsx # clean
├── resume-vivid.tsx # vivid
├── dynamic-resume-section.tsx # shared custom-section renderer
├── safe-html.tsx # sanitized rich-text renderer
└── styles/ # *.module.css per template + _base/_tokens
```
## Template Settings
Authoritative definition: `apps/frontend/lib/types/template-settings.ts` (`TemplateSettings`,
`DEFAULT_TEMPLATE_SETTINGS`, and the CSS-variable maps). Current shape:
```typescript
interface TemplateSettings {
template:
| "swiss-single"
| "swiss-two-column"
| "modern"
| "modern-two-column"
| "latex"
| "clean"
| "vivid";
pageSize: "A4" | "LETTER";
margins: { top: number; bottom: number; left: number; right: number }; // 5-25mm each
spacing: {
section: 1 | 2 | 3 | 4 | 5;
item: 1 | 2 | 3 | 4 | 5;
lineHeight: 1 | 2 | 3 | 4 | 5;
};
fontSize: {
base: 1 | 2 | 3 | 4 | 5;
headerScale: 1 | 2 | 3 | 4 | 5;
headerFont: "serif" | "sans-serif" | "mono";
bodyFont: "serif" | "sans-serif" | "mono";
};
compactMode: boolean;
showContactIcons: boolean;
accentColor: "blue" | "green" | "orange" | "red"; // modern, modern-two-column, vivid
}
```
## Section Order
1. Personal Info (header)
2. Summary
3. Work Experience
4. Projects
5. Education
6. Additional (skills, languages, certs, awards)
## ResumeData Schema
```typescript
interface ResumeData {
personalInfo: PersonalInfo;
summary: string;
workExperience: Experience[];
education: Education[];
personalProjects: Project[];
additional: AdditionalInfo;
sectionMeta: SectionMeta[]; // Order, visibility
customSections: CustomSection[]; // User-added sections
}
```
## Custom Sections
Users can add custom sections via `AddSectionDialog`:
| Type | Component | Use Case |
| ---------- | ----------------- | ---------------------- |
| text | `GenericTextForm` | Objective, statement |
| itemList | `GenericItemForm` | Publications, research |
| stringList | `GenericListForm` | Hobbies, interests |
## CSS Classes
```css
.resume-section /* Section wrapper */
.resume-section-title /* Section heading (h3) */
.resume-items /* Items container */
.resume-item /* Single entry (won't page-break) */
```
## Spacing Variables
```css
--section-spacing: calc(4px * var(--spacing-level));
--item-spacing: calc(2px * var(--spacing-level));
--line-height: calc(1.4 + 0.1 * var(--line-height-level));
```
## Adding a Template
1. Create `components/resume/resume-{name}.tsx`
2. Implement `TemplateProps` interface
3. Export from `components/resume/index.ts`
4. Add to `FormattingControls` selector
5. Create thumbnail for preview
@@ -0,0 +1,35 @@
# Swiss Single Column Template Specification
**ID:** `swiss-single`
**Layout:** Single Column (Vertical Stack)
**Design System:** Swiss International Style
## Overview
The Swiss Single Column template is a traditional, clean, and highly readable layout. It emphasizes content hierarchy through bold typography and generous whitespace. It is the default template and is suitable for most professions, especially those requiring detailed descriptions of experience.
## Typography
Uses the system font stack defined in `_base.module.css`.
| Element | Class (Base) | Font Family | Size | Weight |
|---------|--------------|-------------|------|--------|
| Name | `.resume-name` | Serif | 2em (28px) | 700 |
| Title | `.resume-title` | Sans | 1.05em | 400 |
| Section Header | `.resume-section-title` | Serif | 1.2em | 700 |
| Item Title | `.resume-item-title` | Sans | 1em | 700 |
| Body Text | `.resume-text` | Sans | 1em (14px) | 400 |
| Metadata | `.resume-meta` | Mono | 0.82em | 400 |
## Layout Structure
- **Header:** Centered name, title, and contact info.
- **Body:** Vertical stack of sections.
- **Sections:** Full width.
- **Items:** Full width, with metadata (years, location) often floated or flex-aligned.
## CSS Modules
- **Base:** `components/resume/styles/_base.module.css` (Typography, Spacing)
- **Specific:** `components/resume/styles/swiss-single.module.css` (Container)
- **Tokens:** `components/resume/styles/_tokens.css` (Colors)
## Customization
- **Colors:** Edit `_tokens.css` to change global theme, or override CSS variables in inline styles.
- **Spacing:** Adjust `--section-gap` and `--item-gap` in resume settings.
@@ -0,0 +1,36 @@
# Swiss Two Column Template Specification
**ID:** `swiss-two-column`
**Layout:** Two Column Grid (65% / 35%)
**Design System:** Swiss International Style
## Overview
The Swiss Two Column template optimizes for space efficiency. It places the most critical information (experience, projects) in a main left column, and supporting details (education, skills, summary) in a right sidebar. Ideal for technical roles or one-page resumes.
## Typography
Uses the system font stack defined in `_base.module.css`.
| Element | Class (Base) | Font Family | Size | Weight |
|---------|--------------|-------------|------|--------|
| Name | `.resume-name` | Serif | 2em (28px) | 700 |
| Title | `.resume-title` | Sans | 1.05em | 400 |
| Section Header (Main) | `.resume-section-title` | Serif | 1.2em | 700 |
| Section Header (Side) | `.resume-section-title-sm` | Serif | 0.96em | 700 |
| Item Title | `.resume-item-title` | Sans | 1em | 700 |
| Body Text | `.resume-text` | Sans | 1em (14px) | 400 |
## Layout Structure
- **Header:** Full width, centered (outside grid).
- **Grid:** CSS Grid with `65% 35%` columns.
- **Main Column:** Left side. Contains Experience, Projects, Custom Sections.
- **Sidebar:** Right side. Contains Summary, Education, Skills, Languages, Awards, Links.
- **Separation:** Vertical border on the right of the main column.
## CSS Modules
- **Base:** `components/resume/styles/_base.module.css` (Typography, Spacing)
- **Specific:** `components/resume/styles/swiss-two-column.module.css` (Grid, Column Layout)
- **Tokens:** `components/resume/styles/_tokens.css` (Colors)
## Customization
- **Grid:** Defined in `swiss-two-column.module.css`.
- **Borders:** Main column has `border-right` using `--resume-border-tertiary`.
@@ -0,0 +1,75 @@
# Adding Resume Templates
> Guide for adding new resume template layouts.
## Quick Start
1. Create `components/resume/resume-{name}.tsx`
2. Export from `components/resume/index.ts`
3. Add to `FormattingControls` template selector
4. Create thumbnail image
## Template Component
```tsx
interface TemplateProps {
resumeData: ResumeData;
settings: TemplateSettings;
}
export function ResumeNewTemplate({ resumeData, settings }: TemplateProps) {
return (
<div className="resume-print">
{/* Header */}
<header className="resume-section">
<h1>{resumeData.personalInfo?.name}</h1>
</header>
{/* Sections */}
{getSortedSections(resumeData).map(section => (
<section key={section.id} className="resume-section">
<h3 className="resume-section-title">{section.displayName}</h3>
<div className="resume-items">
{/* Items */}
</div>
</section>
))}
</div>
);
}
```
## CSS Classes Required
```css
.resume-print /* Root container (Playwright waits for this) */
.resume-section /* Section wrapper */
.resume-section-title /* Section heading */
.resume-items /* Items container */
.resume-item /* Individual entry (won't page-break) */
```
## Export Template
```typescript
// components/resume/index.ts
export { ResumeNewTemplate } from './resume-new-template';
```
## Add to Selector
```typescript
// components/builder/formatting-controls.tsx
const TEMPLATES = [
{ id: 'swiss-single', name: 'Single Column' },
{ id: 'swiss-two-column', name: 'Two Column' },
{ id: 'new-template', name: 'New Template' }, // Add here
];
```
## Testing
1. Load builder with new template
2. Check all sections render
3. Verify PDF generation works
4. Test with 2+ page content
@@ -0,0 +1,77 @@
# Application Tracker Feature
> **A Kanban board for managing the job-application pipeline, auto-populated from the tailor flow.**
## Overview
The Application Tracker (`/tracker`) gives each tailored resume a place in a
seven-column Kanban pipeline. Tailoring a resume to a job auto-creates an
`applied` card; users can also add cards manually from a pasted job
description. Cards are drag-and-drop reorderable within and across columns.
## Columns (stable keys, decoupled from i18n labels)
`saved` · `applied` · `no_response` · `response` · `interview` · `accepted` · `rejected`
Auto-created cards from the tailor flow land in **`applied`**. Manual cards
default to `applied` but can be created as `saved`.
## How It Works
1. **Auto-create:** `POST /resumes/improve/confirm` (and the legacy
`POST /resumes/improve`) create an `applied` card after persisting the
tailored resume — best-effort (a tracker failure never breaks tailoring).
Company/role come from the cached keyword-extraction pass, so there is **no
extra LLM call** on this path.
2. **Manual add:** `POST /applications` creates the job from the pasted JD then
the card; when company/role aren't supplied it runs one best-effort
extraction call (falls back to blank/editable).
3. **Drag/drop:** cards reorder within a column or move across columns; the
board updates optimistically and reverts on a failed `PATCH`.
4. **Detail modal:** shows the JD + the applied resume; **Edit** opens
`/builder?id=<resume_id>`. Tolerates a deleted resume (`resume: null`).
5. **Bulk actions:** multi-select cards to move or delete in one request.
## Data Model
`Application` (SQLite, `apps/backend/app/models.py`): `application_id` (PK),
`job_id`, `resume_id` (the applied/tailored resume), `master_resume_id`
(optional base — powers the "shared resume" badge), `status` (7-key enum),
`company`, `role`, `applied_at`, `notes`, `position` (per-column order,
server-renumbered on PATCH), `created_at`, `updated_at`. `create_application`
dedupes on `(job_id, resume_id)` to survive double-submit.
## API (`prefix=/applications`, mounted under `/api/v1`)
| Method | Path | Purpose |
|--------|------|---------|
| GET | `/applications` | All cards grouped by column (all 7 keys present) |
| POST | `/applications` | Manual add (creates job + card; best-effort extraction) |
| GET | `/applications/{id}` | Card + embedded JD + resume (resume null if deleted) |
| PATCH | `/applications/{id}` | Update status/position/notes/company/role/applied_at |
| PATCH | `/applications/bulk` | Move many cards to one column |
| DELETE | `/applications/{id}` | Delete one card |
| POST | `/applications/bulk-delete` | Delete many cards |
## Key Files
| File | Purpose |
|------|---------|
| `apps/backend/app/models.py` | `Application` ORM model |
| `apps/backend/app/schemas/applications.py` | Pydantic request/response schemas + status enum |
| `apps/backend/app/routers/applications.py` | The tracker endpoints |
| `apps/backend/app/database.py` | Facade CRUD/bulk/reorder methods |
| `apps/backend/app/routers/resumes.py` | `_auto_create_tracker_application` hook (both confirm paths) |
| `apps/backend/app/services/improver.py` + `app/prompts/templates.py` | Company/role added to keyword extraction |
| `apps/frontend/app/(default)/tracker/page.tsx` | Route |
| `apps/frontend/components/tracker/*` | Board, column, card, detail modal, bulk bar, manual-add dialog |
| `apps/frontend/components/tracker/reorder.ts` | Pure drag-end resolution (`planMove`) |
| `apps/frontend/lib/api/tracker.ts` | Typed API client |
## Tests
- Backend: `tests/integration/test_applications_api.py` (CRUD, grouping, detail
tolerance, bulk), `tests/integration/test_tracker_autocreate.py` (confirm
auto-creates an `applied` card), `tests/unit/test_database.py::TestApplications`.
- Frontend: `tests/tracker-reorder.test.ts` (`planMove` within/cross-column +
empty-column drop), `tests/api-tracker.test.ts` (client payloads/URLs).
+68
View File
@@ -0,0 +1,68 @@
# Custom Sections System
> **Dynamic resume sections with full customization.**
## Section Types
| Type | Description | Example Uses |
|------|-------------|--------------|
| `personalInfo` | Special type for header (always first) | Name, contact details |
| `text` | Single text block | Summary, objective, statement |
| `itemList` | Array of items with title, subtitle, years, description | Experience, projects, publications |
| `stringList` | Simple array of strings | Skills, languages, hobbies |
## Section Features
- **Rename sections**: Change display names (e.g., "Education" → "Academic Background")
- **Reorder sections**: Up/down buttons to change section order
- **Hide sections**: Toggle visibility (hidden sections still editable, just not in PDF)
- **Delete sections**: Remove custom sections entirely
- **Add custom sections**: Create new sections with any name and type
## Section Controls (UI)
Each section (except Personal Info) has these controls in the header:
| Control | Icon | Function |
|---------|------|----------|
| Visibility | 👁 Eye / EyeOff | Toggle show/hide in PDF preview |
| Move Up | ⬆ ChevronUp | Move section earlier in order |
| Move Down | ⬇ ChevronDown | Move section later in order |
| Rename | ✏️ Pencil | Edit section display name |
| Delete | 🗑 Trash | Hide (default) or delete (custom) |
## Hidden Section Behavior
- Hidden sections appear in the form with:
- Dashed border and 60% opacity
- "Hidden from PDF" badge (amber)
- Hidden sections are still editable
- Only PDF/preview hides them (uses `getSortedSections` which filters by visibility)
- Form shows all sections (uses `getAllSections`)
## Key Files
| File | Purpose |
|------|---------|
| `apps/backend/app/schemas/models.py` | `SectionType`, `SectionMeta`, `CustomSection` models |
| `apps/frontend/lib/utils/section-helpers.ts` | Section management utilities |
| `apps/frontend/components/builder/section-header.tsx` | Section controls UI |
| `apps/frontend/components/builder/add-section-dialog.tsx` | Add custom section dialog |
| `apps/frontend/components/builder/resume-form.tsx` | Dynamic form rendering |
| `apps/frontend/components/resume/dynamic-resume-section.tsx` | Renders custom sections in templates |
## Data Structure
```typescript
interface ResumeData {
// ... existing fields (personalInfo, summary, etc.)
sectionMeta?: SectionMeta[]; // Section order, names, visibility
customSections?: Record<string, CustomSection>; // Custom section data
}
```
## Migration
Existing resumes are automatically migrated via lazy normalization - default section metadata is added when a resume is fetched if `sectionMeta` is missing.
> **Important**: The `normalize_resume_data()` function uses `copy.deepcopy(DEFAULT_SECTION_META)` to avoid shared mutable reference bugs. Always use deep copies when assigning default mutable values.
+42
View File
@@ -0,0 +1,42 @@
# Resume Enrichment Feature
> **AI-powered resume improvement with targeted questions.**
## Overview
The enrichment feature helps users improve their master resume with more detailed content by:
1. Analyzing the resume for weak/generic descriptions
2. Asking targeted questions about their experience
3. Generating additional bullet points based on answers
## How It Works
1. User clicks "Enhance Resume" on the master resume page
2. AI analyzes the resume and identifies weak/generic descriptions
3. User answers targeted questions (max 6 questions total) about their experience
4. AI generates additional bullet points based on user answers
5. New bullets are **added** to existing content (not replaced)
## Key Design Decisions
- **Maximum 6 questions**: To avoid overwhelming users, the AI generates at most 6 questions across all items
- **Additive enhancement**: Original bullet points are preserved; new enhanced bullets are appended after them
- **Question prioritization**: AI prioritizes the most impactful questions that will yield the best improvements
## Key Files
| File | Purpose |
|------|---------|
| `apps/backend/app/prompts/enrichment.py` | AI prompts for analysis and enhancement |
| `apps/backend/app/routers/enrichment.py` | API endpoints for enrichment workflow |
| `apps/frontend/hooks/use-enrichment-wizard.ts` | React state management for wizard flow |
| `apps/frontend/components/enrichment/*.tsx` | UI components for enrichment modal |
## API Endpoints
| Endpoint | Description |
|----------|-------------|
| `POST /enrichment/analyze/{resume_id}` | Analyze resume and generate questions |
| `POST /enrichment/enhance` | Generate enhanced descriptions from answers |
| `POST /enrichment/apply/{resume_id}` | Apply enhancements to resume |
+66
View File
@@ -0,0 +1,66 @@
# i18n Preparation Guide
> Plan for internationalizing Resume Matcher.
## Current State
- UI uses `next-intl` with locale files in `messages/`
- Content language preference stored via `LanguageProvider`
- Supported: en, es, zh, ja
## Translation File Location
```
apps/frontend/messages/
├── en.json
├── es.json
├── zh.json
└── ja.json
```
## Adding New Locale
1. Create `messages/{locale}.json`
2. Add locale to `i18n/config.ts`:
```typescript
export const locales = ['en', 'es', 'zh', 'ja', 'de'] as const;
```
3. Add to `SUPPORTED_LANGUAGES` in backend `config.py`
## Translation Keys
```json
{
"dashboard": {
"title": "Dashboard",
"masterResume": "Master Resume"
},
"builder": {
"save": "Save",
"download": "Download PDF"
}
}
```
## Usage in Components
```tsx
import { useTranslations } from 'next-intl';
export function MyComponent() {
const t = useTranslations('dashboard');
return <h1>{t('title')}</h1>;
}
```
## Content Language vs UI Language
- **UI Language:** Controlled by `next-intl`, affects interface text
- **Content Language:** Controlled by `LanguageProvider`, affects LLM-generated content (cover letters, tailored resumes)
## Backend i18n (Future)
Currently prompts are English-only. To support multiple languages:
1. Create `app/i18n/locales/{lang}.json`
2. Add language parameter to prompt templates
3. Pass `Accept-Language` header from frontend
+64
View File
@@ -0,0 +1,64 @@
# Internationalization (i18n)
> **Multi-language UI and content generation.**
## Supported Languages
| Code | Language | Native Name | Flag | Message File |
|------|----------|-------------|------|--------------|
| `en` | English | English | 🇺🇸 | `messages/en.json` |
| `es` | Spanish | Español | 🇪🇸 | `messages/es.json` |
| `zh` | Chinese (Simplified) | 中文 | 🇨🇳 | `messages/zh.json` |
| `ja` | Japanese | 日本語 | 🇯🇵 | `messages/ja.json` |
| `pt` | Portuguese (Brazilian) | Português | 🇧🇷 | `messages/pt-BR.json` |
> Source of truth: `apps/frontend/i18n/config.ts` (`locales`, `localeNames`, `localeFlags`). Note `pt` loads from `messages/pt-BR.json` (imported as `pt` in `apps/frontend/lib/i18n/messages.ts`).
## Two Language Settings
1. **UI Language** - Interface text (buttons, labels, navigation)
2. **Content Language** - LLM-generated content (resumes, cover letters)
Both are configured independently in the Settings page.
## How It Works
- **UI translations**: Simple JSON import approach, no external dependencies
- **Content generation**: Backend receives language, passes to LLM prompts via `{output_language}`
- **Existing content** in database remains in original language
## Key Files
| File | Purpose |
|------|---------|
| `apps/frontend/messages/*.json` | UI translation files (`en`, `es`, `zh`, `ja`, `pt-BR`) |
| `apps/frontend/lib/i18n/translations.ts` | `useTranslations` hook |
| `apps/frontend/lib/context/language-context.tsx` | LanguageProvider (UI + content) |
| `apps/backend/app/prompts/templates.py` | LLM prompts with `{output_language}` |
## Using Translations
```typescript
import { useTranslations } from '@/lib/i18n';
const { t } = useTranslations();
<button>{t('common.save')}</button>
```
## Storage
| Key | Purpose |
|-----|---------|
| `resume_matcher_ui_language` | UI language (localStorage only) |
| `resume_matcher_content_language` | Content language (localStorage + backend) |
## Adding a New Language
1. Create `apps/frontend/messages/{code}.json` with all translations
2. Add locale to `apps/frontend/i18n/config.ts`
3. Add language name to `apps/backend/app/prompts/templates.py`
4. Update `SUPPORTED_LANGUAGES` in backend config router
## Related Docs
- [i18n-preparation.md](i18n-preparation.md) - Detailed extraction plan
+39
View File
@@ -0,0 +1,39 @@
# JD Match Feature
> **Shows how well a tailored resume matches the original job description.**
## Overview
The Resume Builder includes a "JD Match" tab that shows how well a tailored resume matches the original job description.
## How It Works
1. User tailors a resume against a job description
2. Opens the tailored resume in the Builder
3. "JD MATCH" tab appears (only for tailored resumes)
4. Shows side-by-side comparison:
- **Left panel**: Original job description (read-only)
- **Right panel**: Resume with matching keywords highlighted in yellow
## Features
- **Keyword extraction**: Extracts significant keywords from JD (filters common stop words)
- **Case-insensitive matching**: Matches keywords regardless of case
- **Match statistics**: Shows total keywords, matches found, and match percentage
- **Color-coded percentage**: Green (≥50%), yellow (≥30%), red (<30%)
## Key Files
| File | Purpose |
|------|---------|
| `apps/frontend/lib/utils/keyword-matcher.ts` | Keyword extraction and matching utilities |
| `apps/frontend/components/builder/jd-comparison-view.tsx` | Main split-view component |
| `apps/frontend/components/builder/jd-display.tsx` | Read-only JD display |
| `apps/frontend/components/builder/highlighted-resume-view.tsx` | Resume with keyword highlighting |
| `apps/backend/app/routers/resumes.py` | `GET /{resume_id}/job-description` endpoint |
## API Endpoint
| Endpoint | Description |
|----------|-------------|
| `GET /resumes/{resume_id}/job-description` | Fetch JD used to tailor a resume |
+71
View File
@@ -0,0 +1,71 @@
# Resume Template Settings
> **Template types and extensive formatting controls.**
## Template Types
| Template | Description |
|----------|-------------|
| `swiss-single` | Traditional single-column layout with maximum content density |
| `swiss-two-column` | 65%/35% split with experience in main column, skills in sidebar |
| `modern` | Single-column with colorful accent headers and customizable theme colors |
| `modern-two-column` | Two-column layout combining modern accents with space-efficient design |
| `latex` | Classic serif single-column with Title-Case ruled headers and company-first entries (LaTeX-style). Single-typeface — driven by the Header Font control |
| `clean` | Minimal sans single-column with large understated gray UPPERCASE headers and single-line entries. Single-typeface — driven by the Body Font control |
| `vivid` | Colorful two-column (Awesome-CV lineage): two-tone accent name, monospace contact with circular icons, accent small-caps headers, accent arrow bullets. Supports the Accent Color control |
## Formatting Controls
| Control | Range | Default | Effect |
|---------|-------|---------|--------|
| Margins | 5-25mm | 8mm | Page margins |
| Section Spacing | 1-5 | 3 | Gap between major sections |
| Item Spacing | 1-5 | 2 | Gap between items within sections |
| Line Height | 1-5 | 3 | Text line height |
| Base Font Size | 1-5 | 3 | Overall text scale (11-16px) |
| Header Scale | 1-5 | 3 | Name/section header size multiplier |
| Header Font | serif/sans-serif/mono | serif | Font family for headers |
| Body Font | serif/sans-serif/mono | sans-serif | Font family for body text |
| Compact Mode | boolean | false | Apply 0.6x spacing multiplier (spacing only; margins unchanged) |
| Contact Icons | boolean | false | Show icons next to contact info |
| Accent Color | blue/green/orange/red | blue | Accent color for color templates (modern, modern-two-column, vivid) |
## Key Files
| File | Purpose |
|------|---------|
| `apps/frontend/lib/types/template-settings.ts` | Type definitions, defaults, CSS variable mapping |
| `apps/frontend/components/resume/styles/_tokens.css` | Global design tokens (colors) |
| `apps/frontend/components/resume/styles/_base.module.css` | Shared typography and layout styles |
| `apps/frontend/components/builder/formatting-controls.tsx` | UI controls for template settings |
| `apps/frontend/components/resume/resume-single-column.tsx` | Single column template |
| `apps/frontend/components/resume/resume-two-column.tsx` | Two column template |
| `apps/frontend/components/resume/resume-modern.tsx` | Modern single column template |
| `apps/frontend/components/resume/resume-modern-two-column.tsx` | Modern two column template |
| `apps/backend/app/routers/resumes.py` | PDF generation endpoint with accentColor support |
## CSS Variables
Templates use CSS custom properties for styling:
- `--section-gap`, `--item-gap`, `--line-height` - Spacing
- `--font-size-base`, `--header-scale`, `--section-header-scale` - Typography
- `--header-font` - Header font family
- `--body-font` - Body text font family
- `--margin-top/bottom/left/right` - Page margins
- `--accent-primary`, `--accent-light` - Accent colors for Modern templates
> **Note**: Templates should use the styles exported from `apps/frontend/components/resume/styles/_base.module.css` (e.g., `baseStyles['resume-section']`, `baseStyles['resume-item-subtitle']`) to ensure all spacing and typography respond to template settings.
### Typography Classes
The base stylesheet includes specialized classes for improved subtitle visibility:
| Class | Font Size | Weight | Usage |
|-------|-----------|--------|-------|
| `resume-item-subtitle` | 0.95× base | 600 | Company names, education degrees, project roles |
| `resume-item-subtitle-sm` | 0.88× base | 600 | Same fields in compact two-column layouts |
These classes provide **better visibility** than the generic `resume-meta` class (0.82× base, weight 400), making subtitles 13-16% larger and semi-bold.
Formatting controls include an "Effective Output" summary that reflects compact-mode adjustments for spacing/line-height.
+122
View File
@@ -0,0 +1,122 @@
# LLM Integration Guide
> **Multi-provider AI support, JSON handling, and prompt guidelines.**
## Multi-Provider Support
Backend uses LiteLLM to support multiple providers through a unified API:
| Provider | Type | Notes |
|----------|------|-------|
| **Ollama** | Local | Free, runs on your machine |
| **OpenAI** | Cloud | GPT-5 Nano, GPT-4o |
| **Anthropic** | Cloud | Claude Haiku 4.5 |
| **Google Gemini** | Cloud | Gemini 3 Flash |
| **OpenRouter** | Cloud | Access to multiple models |
| **DeepSeek** | Cloud | DeepSeek Chat |
## API Key Handling
API keys are passed directly to `litellm.acompletion()` via the `api_key` parameter (not via `os.environ`) to avoid race conditions in async contexts.
```python
# Correct
await litellm.acompletion(
model=model,
messages=messages,
api_key=api_key # Direct parameter
)
# Incorrect - don't use os.environ in async code
os.environ["OPENAI_API_KEY"] = key # Race condition risk
```
## JSON Mode
The `complete_json()` function automatically enables `response_format={"type": "json_object"}` for providers that support it:
- OpenAI
- Anthropic
- Gemini
- DeepSeek
- Major OpenRouter models
## Retry Logic
JSON completions include 2 automatic retries with progressively lower temperature:
- Attempt 1: temperature 0.1
- Attempt 2: temperature 0.0
## JSON Extraction
Robust bracket-matching algorithm in `_extract_json()` handles:
- Malformed responses
- Markdown code blocks
- Edge cases
- Infinite recursion protection when content starts with `{` but matching fails
## Error Handling Pattern
LLM functions log detailed errors server-side but return generic messages to clients:
```python
except Exception as e:
logger.error(f"LLM completion failed: {e}")
raise ValueError("LLM completion failed. Please check your API configuration.")
```
## Adding Prompts
Add new prompt templates to `apps/backend/app/prompts/templates.py`.
### Prompt Guidelines
1. Use `{variable}` for substitution (single braces)
2. Include example JSON schemas for structured outputs
3. Keep instructions concise: "Output ONLY the JSON object, no other text"
### Example
```python
IMPROVE_BULLET = """
Improve this resume bullet point for a {job_title} position.
Current: {current_bullet}
Output ONLY the improved bullet point, no explanations.
"""
```
## Provider Configuration
Users configure their preferred AI provider via:
- Settings page: `/settings`
- API: `PUT /api/v1/config/llm-api-key`
## Health Checks
The `/api/v1/health` endpoint validates LLM connectivity.
> **Note**: Docker health checks must use `/api/v1/health` (not `/health`).
## Timeouts
All LLM calls have configurable timeouts:
| Operation | Timeout |
|-----------|---------|
| Health checks | 30s |
| Completions | 120s |
| JSON operations | 180s |
## Key Files
| File | Purpose |
|------|---------|
| `apps/backend/app/llm.py` | LiteLLM wrapper with JSON mode |
| `apps/backend/app/prompts/templates.py` | Prompt templates |
| `apps/backend/app/prompts/enrichment.py` | Enrichment-specific prompts |
| `apps/backend/app/config.py` | Provider configuration |
+66
View File
@@ -0,0 +1,66 @@
# Quickstart Guide
> Essential commands to build, run, and test Resume Matcher.
## Prerequisites
- Node.js 22+
- Python 3.13+
- [uv](https://docs.astral.sh/uv/) (Python package manager)
## Installation
```bash
# Backend (from repo root)
cd apps/backend
uv sync
# Frontend (from repo root)
cd apps/frontend
npm install
```
## Development
```bash
# Backend (Terminal 1, from repo root)
cd apps/backend
uv run uvicorn app.main:app --reload --port 8000
# Frontend (Terminal 2, from repo root)
cd apps/frontend
npm run dev
```
## Quality Checks
```bash
# From apps/frontend
npm run lint # Lint frontend
npm run format # Prettier
```
## Backend Commands
```bash
cd apps/backend
uv run uvicorn app.main:app --reload --port 8000
uv run pytest
```
## Environment Setup
```bash
# Backend
cp apps/backend/.env.example apps/backend/.env
# Frontend
cp apps/frontend/.env.sample apps/frontend/.env.local
```
## First-Time Setup
1. Open http://localhost:3000/settings
2. Select AI provider + enter API key
3. Click "Test Connection"
4. Upload your first resume!
+46
View File
@@ -0,0 +1,46 @@
# Scope and Principles
> **Canonical source for agent behavior rules in Resume Matcher.**
## What This Repo Is
Resume Matcher is an AI-powered application that helps users tailor resumes to job descriptions. It consists of:
- **Backend**: FastAPI + Python 3.13+ with multi-provider LLM support via LiteLLM
- **Frontend**: Next.js 16 + React 19 with Swiss International Style design
- **Database**: SQLite via async SQLAlchemy (`aiosqlite`)
- **PDF Generation**: Headless Chromium via Playwright
## Non-Negotiable Rules
### Code Quality
1. **All frontend changes** MUST follow the Swiss International Style design system. The full system is published as a portable pack at [`docs/portable/swiss-design-system/`](../portable/swiss-design-system/README.md). Read [`tokens.md`](../portable/swiss-design-system/tokens.md) and [`components.md`](../portable/swiss-design-system/components.md) before touching UI.
2. **All backend functions** MUST have type hints
3. **Run `npm run lint`** before committing frontend changes
4. **Run Prettier** (`npm run format`) before committing
### Error Handling
- **Backend**: Log detailed errors server-side, return generic messages to clients
- **Frontend**: Use proper error boundaries and user-friendly error states
### Security
- Never expose API keys or sensitive data in client responses
- Use `asyncio.Lock()` for shared resource initialization
- Always use `copy.deepcopy()` for mutable default values
## Out of Scope
The agent should NOT:
- Modify GitHub workflow files (`.github/workflows/`)
- Change CI/CD configuration without explicit request
- Alter Docker build behavior without explicit request
- Remove or disable tests
## Related Docs
- [quickstart.md](quickstart.md) - Build, run, test commands
- [workflow.md](workflow.md) - Git workflow, PRs, commits
+217
View File
@@ -0,0 +1,217 @@
# Testing Strategy & Verification Plan
> **Status:** Living document. Started 2026-05-30 on branch `test/backend-coverage-foundation` (base: `dev`).
> **Scope:** Backend (`apps/backend`, Phases 16) **and** frontend (`apps/frontend`, vitest — §8). Gating is a local `pre-push` hook, not PR CI.
> **Why this exists:** We shipped a build break that no automation caught, users report "Ollama doesn't work" and "resume won't render," and we had no evidence-based read on whether our tests are real or theater. This doc is the resumable record of the assessment and the plan.
---
## 1. TL;DR
- A real backend test suite already exists: **192 tests**, `pytest` + `pytest-asyncio` + `httpx`. We do **not** need a new framework.
- When run: **191 pass, 1 fails, 54% line coverage.** The one failure (`test_health_returns_degraded`) is a **stale test**, not a product bug — and the fact it sat red proves the suite is **never run by automation**.
- Root cause of "the build broke and nobody caught it": **there is no PR-gating CI.** The only workflow is `docker-publish.yml` (build+push on merge to `main`). Nothing runs `pytest`, `tsc`, `next build`, or lint before merge.
- The tests we have cluster on the **deterministic algorithmic core** (diff engine, schemas) and **mock away the entire I/O surface** (DB, LLM, parser, Playwright). So coverage is real where it exists but absent exactly where users get hurt.
- Two test types are needed and are **different things**: deterministic tests (plumbing correctness, LLM mocked, run on every change) and **evals** (LLM output quality, real/recorded LLM, run on demand/nightly). We have the first kind only for the core, and zero of the second.
---
## 2. Current-state assessment (evidence)
Run command (no `pyproject.toml` change — ephemeral coverage plugin):
```bash
cd apps/backend
uv run --with pytest-cov pytest -q --cov=app --cov-report=term-missing
# 192 tests · 191 passed · 1 FAILED · 54% coverage · ~6s
```
### 2.1 The one failure is the canary
`tests/integration/test_health_api.py::test_health_returns_degraded` expects `GET /health` to report `"degraded"` when the LLM is unhealthy. But `app/routers/health.py` was refactored so `/health` is a **pure liveness probe** that never calls the LLM (`return HealthResponse(status="healthy")`); readiness now lives at `GET /status`. The test was never updated. Its sibling `test_health_returns_healthy` *passes for the wrong reason* — it mocks `check_llm_health`, which the endpoint no longer calls, so the mock is dead and the assertion is hollow.
Takeaway: a green checkmark here meant nothing, and a red one went unseen. That is the exact failure mode behind the production incidents.
### 2.2 Coverage map (what's real vs absent)
| Module | Cover | Read |
|---|---|---|
| `services/improver.py` (diff engine) | 84% | ✅ Genuinely strong — path resolution, apply/verify diffs, skill gating |
| `schemas/models.py` | 88% | ✅ Real |
| `services/refiner.py` | 72% | ✅ Decent |
| `routers/config.py` | 53% | 🟡 Contract-level only |
| `llm.py` | 47% | 🟡 Pure helpers tested; **real request + `check_llm_health` + Ollama paths NOT** |
| `routers/enrichment.py` | 38% | 🟡 Regenerate matching tested; rest mocked |
| `config_cache.py` | 38% | 🔴 |
| `database.py` | 34% | 🔴 Real DB not exercised *at this baseline* — integration tests mocked `db` (changed in Phases 4 & 7) |
| `services/cover_letter.py` | 26% | 🔴 Untested |
| `services/parser.py` | 20% | 🔴 Upload→markdown→JSON untested; even the pure date-restore is uncovered |
| `pdf.py` (render) | 20% | 🔴 The "resume won't render" class |
| `routers/resumes.py` | 18% | 🔴 Biggest file (1,796 LOC): tailor + PDF + CRUD — almost entirely untested |
### 2.3 The structural truth about the integration tests (at the 192-test baseline)
At the 192-test baseline, every `tests/integration/*` test patched `app.routers.<x>.db` and the LLM/parse calls. They verified **status codes, request validation, response shape, and router branch logic** (e.g. API-key masking, regenerate fallback matching) — valuable *contract* tests. They did **not** prove the database persists, Playwright renders, markitdown parses, or any provider (Ollama included) actually responds. *(Phases 4 & 7 below later added real-SQLite `isolated_db` persistence + pipeline/tracker integration tests.)*
---
## 3. The framework decision
**Keep `pytest` + `pytest-asyncio` + `httpx`.** It is correctly configured (`asyncio_mode=auto`, strict markers, `unit`/`service`/`integration` markers). Add, incrementally:
| Need | Tool | Why |
|---|---|---|
| Coverage as a tracked number | `pytest-cov` | Quantify gaps & deltas; stop guessing |
| Real `llm.py` against a fake provider (incl. Ollama) | `respx` (or `pytest-httpx`) | Mock at the HTTP transport so our actual routing/normalization code runs — the only way to regression-test "Ollama doesn't work" |
| PDF render proof | Playwright (already a dep) | One smoke test → real PDF bytes from the print route |
| Prompt/skill quality | In-repo **eval harness** | Golden fixtures + structural scorers + optional LLM-as-judge |
| Push gate (local, replaces PR CI) | `pre-push` git hook (`.githooks/`) | Runs backend suite + locale parity, blocks red pushes; no PR-triggered CI — see §5 Phase 6 |
### 3.1 Deterministic tests vs evals (the key distinction)
- **Deterministic test** — LLM mocked, asserts code behavior given a known response. Fast, runs on every change. Answers *"is the plumbing correct?"*
- **Eval** — real (or recorded) LLM call scored against a rubric. Non-deterministic, costs money/time, runs nightly/on-demand, **never in the PR gate**. Answers *"did this prompt change make the output better?"*
You cannot answer "does my prompt change help?" with a deterministic test. You need evals. Conversely you should never block a PR on a non-deterministic eval. Both layers, kept separate.
### 3.2 The prompt chain ("skills") and how each stage is tested
```
upload → parse_resume_to_json
→ extract_job_keywords
→ generate_skill_target_plan → verify_skill_target_plan (verify = pure, deterministic gate)
→ generate_resume_diffs [strategy: keywords | nudge | full]
→ apply_diffs (pure)
→ render_resume_pdf
```
For each stage:
- **Deterministic layer** — structural invariants that hold regardless of wording: valid JSON/schema, **no fabricated employers/dates** (truthfulness rules), every section preserved, JD keywords actually present in output, diff paths resolve. Most "the prompt change broke something" regressions are caught here, for free.
- **Eval layer** — golden `(resume, job_description)` fixtures → run the stage with a real model → score with a rubric (heuristic + LLM-as-judge). Tracks quality over time and across prompt edits.
---
## 4. What "verify our recent work" means here
Three concrete mechanisms, applied to every change in this initiative:
1. **Coverage delta.** Each batch reports before/after coverage for the modules it touches. Numbers, not vibes.
2. **Anti-theater check.** For new tests on critical logic, confirm the test *fails when the code is broken* (a quick manual mutation). This is the antidote to the `test_health_returns_healthy` "passes for the wrong reason" trap.
3. **Regression tests that pin recent PRs.** The first new coverage deliberately locks in behavior we recently shipped, so "our recent work" gains a safety net:
- `_normalize_api_base` — the `/v1/v1` duplicate-path dedup and OpenAI preserve-as-is (issue #751).
- `resolve_api_key` — the security rule that `ollama`/`openai_compatible` must **not** fall back to the env `LLM_API_KEY` (so a paid key can't leak to a local server).
- `get_model_name``ollama_chat/` prefix and OpenRouter nested prefixes.
- Empty-extracted-text rejection on upload (`resumes.py:546`, PR #794).
- `restore_dates_from_markdown` — months survive LLM parsing.
---
## 5. Phased roadmap
Legend: ✅ done · 🚧 in progress · ⬜ planned
**Phase 1 — Foundation + cheap deterministic coverage (PR #820 → `dev`) ✅ COMPLETE**
- ✅ Audit + this document
- ✅ Make the suite green (fixed stale `health` tests; liveness vs readiness)
-`llm.py` provider/Ollama pure-function regression tests (`tests/unit/test_llm_providers.py`)
-`parser.py` pure tests (date restoration) (`tests/unit/test_parser.py`) + empty-text rejection (`tests/integration/test_upload_api.py`)
- ✅ Real-SQLite `database.py` CRUD tests (`tests/unit/test_database.py`)
- ✅ Verify: **192 → 265 tests, 1 silent failure → 0, coverage 54% → 58%** (database 34→96%, parser 20→72%, llm 47→55%, health now meaningful). Anti-theater mutation check passed.
**Phase 2 — Transport contract tests (LLM/Ollama) ✅ COMPLETE** (`tests/integration/test_llm_contract.py`, 8 tests)
-`respx`-backed tests: real `complete` / `complete_json` / `check_llm_health` against a fake Ollama + OpenAI-compatible HTTP server (base-URL handling #751, JSON extraction over the wire, thinking-tag stripping, health error-code mapping + secret scrubbing). Findings: litellm 1.86 defaults to an aiohttp transport respx can't see → tests set `disable_aiohttp_transport`; Ollama makes two calls (`/api/show` probe + `/api/chat`). **`llm.py` 55% → 74%.**
**Phase 3 — Render safety net ✅ COMPLETE** (`tests/integration/test_pdf_render.py`, 11 tests)
- ✅ Real headless-Chromium render of a self-contained `data:` URL → asserts genuine `%PDF` bytes; pure-helper tests (format/margins); connection-refused → `PDFRenderError` mapping. Render tests skip cleanly without Chromium. **`pdf.py` 20% → 54%.**
**Phase 4 — End-to-end pipeline ✅ COMPLETE** (`tests/integration/test_pipeline_e2e.py`, 5 tests)
- ✅ Real routers + real temp DB (`isolated_db`), every LLM boundary mocked: upload → jobs → fetch **and** the preview→confirm tailoring handshake. Asserts real persisted state (master invariant, `parent_id` linkage, `improvements` record). **`resumes.py` 18% → 53%.**
**Phase 5 — Eval harness (structural + LLM-as-judge) ✅ COMPLETE** (`tests/evals/`, 31 scorer tests + 1 gated judge)
- ✅ Pure structural scorers (`sections_preserved`, `no_fabricated_employers`, `jd_keywords_present`, `is_valid_resume`, `personal_info_unchanged`) + golden fixtures, each proven on good AND bad inputs.
- ✅ LLM-as-judge marked `@pytest.mark.eval`, uses the developer's own configured key, **excluded from the default run** (`addopts -m "not eval"`); run on demand with `uv run pytest -m eval`. Skips cleanly with no key.
**Phase 6 — Local pre-push gate (replaces PR CI) ✅ COMPLETE** (`.githooks/pre-push`)
- ✅ A version-controlled `pre-push` hook runs the backend suite + a node-free locale-parity check and **blocks the push on red**. Activate per-clone with `git config core.hooksPath .githooks`; bypass with `git push --no-verify`. See `.githooks/README.md`.
- ✅ We **deliberately avoid a GitHub Actions PR gate** — the repo gets a high volume of external contributor PRs; PR-triggered CI would run on every one (and run untrusted code). The local hook keeps `dev`/`main` green for the maintainer's own pushes without that cost.
- ⬜ (Optional, future) a Node-based `tsc`/`next build` check — deferred due to nvm-in-hook fragility; the pure-Python locale-parity guard already covers the known i18n break.
**Phase 7 — SQLite persistence + tracker + encrypted-keys coverage (PRs #841 + #843 → `main`) ✅ COMPLETE**
- ✅ The persistence layer moved from TinyDB to **SQLite (async SQLAlchemy)**; the `conftest.py::isolated_db` fixture now swaps a disposable **temp-file SQLite** DB (not TinyDB) across all router modules, and `tests/unit/test_database.py` exercises **real SQLite CRUD** (incl. `TestApplications`).
- ✅ New tracker coverage: `tests/integration/test_applications_api.py` (CRUD, column grouping, detail tolerance for a deleted resume, bulk move/delete) and `tests/integration/test_tracker_autocreate.py` (confirming a tailoring auto-creates an `applied` card).
- ✅ Encrypted per-provider API keys: `tests/unit/test_crypto.py` (Fernet encrypt/decrypt round-trip + masking).
-`/status` graceful degradation (#843): `tests/integration/test_health_api.py` expanded — each check isolated, so a single failing probe yields 200 with partial/degraded state instead of 500.
- ✅ Verify: default `uv run pytest` count is now **~444** (was ~320). `respx` still mocks the HTTP transport for `llm.py`.
### Result after Phases 17
**192 → ~444 deterministic tests** (+ 1 opt-in LLM-judge eval), **0 failures**. Phases 15 were built via parallel subagents (one per phase, strict file ownership) using the `dispatching-parallel-agents` skill; Phase 7 followed the TinyDB→SQLite migration (PRs #841 + #843).
---
## 6. How to run
```bash
cd apps/backend
# Full deterministic suite (LLM-judge evals are auto-excluded via addopts -m "not eval")
uv run pytest
# Coverage (ephemeral plugin, no pyproject change)
uv run --with pytest-cov pytest -q --cov=app --cov-report=term-missing
# Prompt-quality evals on demand — structural scorers always run; the LLM-judge
# runs only when an LLM key is configured (uses the developer's own key), else skips
uv run pytest -m eval
# One module
uv run pytest tests/unit/test_parser.py -q
```
> `uv run pytest` is unaffected by the project's nvm/npm constraints — it's Python-only. Frontend `tsc`/`build`/lint are run separately and are out of scope for this backend phase.
---
## 7. Decisions log
| Date | Decision | Rationale |
|---|---|---|
| 2026-05-30 | Base this initiative on **`dev`**, not `main`. Sync `dev``main`, branch off `dev`, merge work back to `dev`. | User directive — batch this important work on `dev` before it reaches `main`. |
| 2026-05-30 | **No CI workflow yet** (tests only). | User directive. CI is the highest-ROI fix but is a separate, explicit decision (and `.github/workflows/` is change-controlled). |
| 2026-05-30 | Eval layer = **structural + LLM-as-judge**, judge uses the **developer-provided** LLM key, skipped when absent. | User directive — the developer (usually the maintainer) supplies the key, so real-LLM scoring is acceptable when configured. |
| 2026-05-30 | Keep `pytest`; add `respx`, `pytest-cov`, Playwright smoke, eval harness. | Existing framework is correct; fill gaps rather than replace. |
| 2026-05-30 | Gate pushes with a **local `pre-push` hook**, NOT GitHub Actions on PRs. | Maintainer gets a high volume of external PRs; PR-triggered CI would run on all of them (incl. untrusted code). A local hook keeps `dev`/`main` green for the maintainer's own pushes — backend suite + node-free locale parity — without that cost. `.githooks/` + `core.hooksPath`. |
---
## 8. Frontend test suite
`apps/frontend` uses **vitest + Testing Library (jsdom)** — run `npm run test` (or `./node_modules/.bin/vitest run`). The same rigor as the backend was applied: assess what existed (a green 65-test suite over `download-utils` + two components), then cover the highest-value untested logic.
Added (`apps/frontend/tests/`):
- `i18n-utils.test.ts` — the `t()` engine (`getNestedValue` dot-path + missing-key fallback, `applyParams` substitution).
- `i18n-locale-parity.test.ts`**in-suite guard for the build break**: every `messages/*.json` must structurally match `en.json` (mirrors `scripts/check_locale_parity.py`). Verified anti-theater (adding a key to `en.json` fails all four locales).
- `keyword-matcher.test.ts` — JD↔resume keyword extract/segment/match-stats.
- `section-helpers.test.ts` — section ordering, custom-section IDs, localize-only-untouched-defaults.
- `html-sanitizer.test.ts` — the DOMPurify XSS whitelist (`strong/em/u/a`).
- `api-client.test.ts``lib/api/client` URL resolution + timeout/AbortError (`fetch` stubbed).
Net: **65 → 117 frontend tests**, all green. The `pre-push` gate runs this suite when Node is available; a full `tsc`/`next build` gate remains future work (nvm-in-hook fragility).
---
## 9. Open questions / future
-~~Frontend locale-parity test~~ — done (`i18n-locale-parity.test.ts` + the hook's `scripts/check_locale_parity.py`).
- Decide coverage floors per module once the I/O surface is broadly covered (avoid a single global % that hides gaps).
- A Node-aware `tsc`/`next build` gate (catches TS errors beyond locale drift) — deferred; needs reliable node-in-hook.
- If GitHub Actions is ever reconsidered, run it on push to `dev`/`main` only (not on PRs).
---
## 10. Agentic end-to-end monitor (on-demand, report-only)
Above the deterministic suites and the local pre-push gate sits an **agentic E2E monitor** — an opt-in, on-demand harness that drives the *real* running app (master resume → 34 tailored variations → PDFs), captures a durable evidence bundle (logs + every intermediate JSON + PDFs), and has a Claude Code skill judge it across three runtime jobs: **output quality**, **flow/render integrity**, and **provider reality**. It is a **report, never a gate** — it informs, never blocks a push, and is never wired into CI.
- Design: `docs/superpowers/specs/2026-06-01-agentic-e2e-monitor-design.md`; plan: `docs/superpowers/plans/2026-06-01-agentic-e2e-monitor.md`; harness + how-to: `apps/backend/e2e_monitor/README.md`.
- OSS-safe: harness deps are an optional extra (`uv sync --extra dev --extra e2e-monitor`), every move is gated behind `RM_E2E_MONITOR=1` + a configured key, and the runnable skill is gitignored (its source is the committed `e2e_monitor/AGENT_PLAYBOOK.md`). The dev's real SQLite DB is never touched (isolated `DATA_DIR`).
- Run: `cd apps/backend && RM_E2E_MONITOR=1 uv run python -m e2e_monitor sweep`, then `bash e2e_monitor/install_skill.sh` and invoke the `monitor-e2e` skill for the report.
+73
View File
@@ -0,0 +1,73 @@
# Workflow: Commits, PRs, and Testing
> **Git workflow, testing guidelines, and PR conventions.**
## Commit Guidelines
- Use concise, sentence-style subjects (e.g., `Add custom funding link to FUNDING.yml`)
- Keep messages short
- If using prefixes, stick to `type: summary` in the imperative
- Reference issues with `Fixes #123`
### Examples
```
Add resume enrichment wizard component
Fix PDF generation timeout on large resumes
Refactor LLM provider configuration
```
## Pull Request Guidelines
1. **Reference issues**: `Fixes #123` in the description
2. **Call out schema or prompt changes** so reviewers can smoke-test downstream agents
3. **List local verification commands** you ran
4. **Attach screenshots** for UI or API changes
## Testing Guidelines
### Frontend Testing
- All contributions must pass `npm run lint`
- Add Jest or Playwright suites beneath `apps/frontend/__tests__/`
- Name test files `*.test.tsx`
```bash
# Run linter
npm run lint
# Format code
npm run format
```
### Backend Testing
- Tests belong in `apps/backend/tests/`
- Use `test_*.py` naming convention
- Seed anonymized resume/job fixtures
```bash
# Run tests
cd apps/backend
uv run pytest
```
## Definition of Done
Before marking a PR as ready:
- [ ] Code compiles without errors
- [ ] `npm run lint` passes
- [ ] New features have tests (or documented reason why not)
- [ ] UI changes follow the [Swiss International Style pack](../portable/swiss-design-system/README.md)
- [ ] Schema/prompt changes are called out in PR description
- [ ] Screenshots attached for UI changes
- [ ] Verified locally with commands listed in PR
## Code Review Checklist
- [ ] Type hints on all Python functions
- [ ] Proper error handling (generic to clients, detailed in logs)
- [ ] No exposed secrets or API keys
- [ ] Follows existing patterns in the codebase
- [ ] Documentation updated if needed