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
+45
View File
@@ -0,0 +1,45 @@
# Portable Documentation Packs
Self-contained documentation packs that can be lifted out of this repository and dropped into any project, knowledge base, or skill bundle without modification.
Each pack lives in its own folder. Cross-references inside a pack are relative and stay valid wherever the folder lands. Nothing here links back to the rest of this repository.
---
## Available packs
| Pack | What it covers |
|------|----------------|
| [swiss-design-system/](swiss-design-system/) | Swiss International Style design system — tokens, components, layouts, AI prompt template, anti-patterns |
| [nextjs-performance/](nextjs-performance/) | Next.js 15 performance optimizations — waterfalls, bundle size, Server Action security, server-side perf, pre-PR checklist |
---
## How to extract a pack
Each subfolder is independent. To move one to another location:
```bash
# Copy a pack into another project
cp -R docs/portable/swiss-design-system /path/to/other-project/docs/
# Or into a standalone skills repo
cp -R docs/portable/nextjs-performance /path/to/skills-repo/
```
No find-and-replace needed. The internal links use relative paths that resolve correctly regardless of where the folder lives.
---
## Why "portable"?
These docs originated as project-specific guides inside a larger codebase. They were generic enough to be useful anywhere — but the original versions had references to specific files, features, and conventions that made them awkward to reuse.
The versions in this folder have been rewritten so that:
1. **No project-specific references** — generic examples only
2. **No outbound links** — every link points to a sibling within the same pack
3. **Standalone reading** — each file makes sense in isolation
4. **Heavy structure** — split by topic, with explicit prerequisites and "when to apply" guidance
If you find a file that violates any of these rules, that's a bug — please fix it before distributing.
@@ -0,0 +1,176 @@
# Eliminating Waterfalls (CRITICAL)
> Sibling docs: [bundle size](02-bundle-size.md) · [server actions security](03-server-actions-security.md) · [server-side perf](04-server-side-perf.md) · [checklist](checklist.md)
---
## What's a waterfall?
A waterfall is a sequence of `await`s where each one blocks the next, even though they could run in parallel. Each sequential `await` adds the **full network latency** of the slowest dependency before anything else can start.
Waterfalls are the #1 performance killer in App Router code, and they're nearly invisible until you profile.
```text
Sequential (waterfall): [user 200ms][posts 200ms][comments 200ms] → 600ms
Parallel: [user 200ms] → 200ms
[posts 200ms]
[comments 200ms]
```
---
## 1.1 Parallel fetching with `Promise.all()`
The single most common waterfall: independent data fetches stacked sequentially.
```tsx
// ❌ BAD: Sequential — 600ms total
async function getPageData() {
const user = await fetchUser();
const posts = await fetchPosts();
const comments = await fetchComments();
return { user, posts, comments };
}
// ✅ GOOD: Parallel — 200ms total
async function getPageData() {
const [user, posts, comments] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments(),
]);
return { user, posts, comments };
}
```
**Rule of thumb**: if two `await`s don't depend on each other's results, they should be inside a single `Promise.all()`.
### Dependent fetches
When fetch B genuinely needs the result of fetch A, you can't parallelize them — but you can still start fetch C in parallel with A.
```tsx
// ✅ GOOD: A and C run in parallel; B waits on A
const userPromise = fetchUser(userId);
const settingsPromise = fetchSettings(); // independent — start it now
const user = await userPromise;
const posts = await fetchUserPosts(user.id); // depends on user
const settings = await settingsPromise;
```
---
## 1.2 Defer `await` to where it's actually needed
Don't await things you might not use.
```tsx
// ❌ BAD: Always waits for analytics, even when validation fails
async function handleSubmit(data: FormData) {
const analytics = await getAnalytics();
if (!data.get('email')) {
return { error: 'Email required' };
}
analytics.track('submit');
}
// ✅ GOOD: Validate first, only await analytics on the success path
async function handleSubmit(data: FormData) {
if (!data.get('email')) {
return { error: 'Email required' };
}
const analytics = await getAnalytics();
analytics.track('submit');
}
```
**Rule of thumb**: cheap synchronous checks (validation, auth) come before expensive async work.
---
## 1.3 Start promises early, await late
If you can't parallelize but still know in advance what you'll need, kick off the fetch as early as possible and only await right before you use it.
```tsx
// ❌ BAD: 200ms wasted between body parse and user fetch
export async function POST(req: Request) {
const body = await req.json(); // 50ms
const user = await getUser(body.userId); // 100ms (waits for body)
const permissions = await getPermissions(user.id); // 100ms (waits for user)
return Response.json({ user, permissions });
}
// ✅ GOOD: Chain promises immediately, await all at once
export async function POST(req: Request) {
const body = await req.json();
// Start both immediately — permissions chains off user without blocking
const userPromise = getUser(body.userId);
const permissionsPromise = userPromise.then(u => getPermissions(u.id));
const [user, permissions] = await Promise.all([userPromise, permissionsPromise]);
return Response.json({ user, permissions });
}
```
This pattern (start early, await late) is the second-most-common waterfall fix after `Promise.all`.
---
## 1.4 Use Suspense boundaries for streaming
If part of your page is fast and part is slow, don't make the fast part wait. Stream the slow part in with `<Suspense>`.
```tsx
// ❌ BAD: Whole page waits for the slow analysis fetch
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id); // 50ms — fast
const reviews = await getReviews(params.id); // 500ms — slow
return (
<div>
<ProductDetails product={product} />
<ReviewsList reviews={reviews} />
</div>
);
}
// ✅ GOOD: Render product immediately, stream reviews in
import { Suspense } from 'react';
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
return (
<div>
<ProductDetails product={product} />
<Suspense fallback={<ReviewsSkeleton />}>
<ReviewsPanel productId={params.id} />
</Suspense>
</div>
);
}
// Slow data lives in its own async component
async function ReviewsPanel({ productId }: { productId: string }) {
const reviews = await getReviews(productId);
return <ReviewsList reviews={reviews} />;
}
```
The user sees the product immediately. The reviews stream in when ready. Time-to-first-byte and time-to-interactive both improve dramatically.
---
## When to apply
- **Always** when fetching multiple independent resources
- **Always** when one fetch is significantly slower than another and the user can act on the fast one
- **Whenever** you spot a function with two consecutive `await`s — pause and ask "do these depend on each other?"
Next: [02-bundle-size.md](02-bundle-size.md) covers the second-biggest source of waste — JS bundles full of unused code.
@@ -0,0 +1,182 @@
# Bundle Size Optimization (CRITICAL)
> Sibling docs: [waterfalls](01-waterfalls.md) · [server actions security](03-server-actions-security.md) · [server-side perf](04-server-side-perf.md) · [checklist](checklist.md)
---
## Why bundles bloat silently
Modern JS libraries ship as **barrel files** — single index.js entry points that re-export everything. When you `import { Foo } from 'big-lib'`, the bundler often pulls in the *entire* library, even though you only use one symbol. This adds 200800ms to cold starts and balloons your client bundle.
The fixes are simple, mechanical, and high-impact.
---
## 2.1 Avoid barrel imports
The single most expensive line of code in many projects is a one-line icon import.
```tsx
// ❌ BAD: Loads ~1,583 modules from lucide-react
import { FileText, Upload, Check } from 'lucide-react';
// ✅ GOOD: Direct deep imports — loads only 3 modules
import FileText from 'lucide-react/dist/esm/icons/file-text';
import Upload from 'lucide-react/dist/esm/icons/upload';
import Check from 'lucide-react/dist/esm/icons/check';
```
The deep-import path is library-specific. For lucide-react it's `lucide-react/dist/esm/icons/<kebab-name>`. Check your library's source for the actual path.
### Or: use `optimizePackageImports`
Next.js 15 has a built-in fix that does the deep-import rewrite at build time:
```js
// next.config.js
module.exports = {
experimental: {
optimizePackageImports: [
'lucide-react',
'@radix-ui/react-icons',
'date-fns',
'lodash',
],
},
};
```
This is the lower-effort path. Use it if you can — fall back to manual deep imports only for libraries Next.js doesn't recognize.
### Commonly affected libraries
- `lucide-react`
- `@radix-ui/react-*`
- `react-icons`
- `date-fns`
- `lodash` (use `lodash-es` or per-method imports)
- `@mui/material`
- `@chakra-ui/react`
If you're importing from any of these, audit it.
---
## 2.2 Dynamic imports for heavy components
Some components are huge (editors, charts, video players, PDF renderers) and only used in specific flows. Don't ship them on the initial page load.
```tsx
// ❌ BAD: Monaco editor loads on every page (2MB+)
import { MonacoEditor } from '@/components/monaco-editor';
export default function EditorPage() {
const [showEditor, setShowEditor] = useState(false);
return showEditor ? <MonacoEditor /> : <Preview />;
}
// ✅ GOOD: Load Monaco only when the user actually needs it
import dynamic from 'next/dynamic';
const MonacoEditor = dynamic(
() => import('@/components/monaco-editor'),
{
loading: () => <EditorSkeleton />,
ssr: false,
}
);
```
### When to use `ssr: false`
- The component touches `window`, `document`, or other browser-only APIs
- The component is purely interactive (no SEO value in pre-rendering it)
- The component is gated behind a button click or modal
When in doubt: if it's a click-to-open editor or modal, set `ssr: false`.
### Heavy candidates worth checking
- Code editors (Monaco, CodeMirror, Ace)
- Rich text editors (TipTap, Quill, Slate)
- Charts (Recharts, Chart.js, D3)
- PDF viewers / editors
- Video players
- 3D libraries (Three.js, Babylon)
- Markdown editors with preview
---
## 2.3 Defer third-party scripts
Analytics, error tracking, A/B testing — none of these need to block hydration.
```tsx
// ❌ BAD: Analytics blocks hydration
import { Analytics } from '@vercel/analytics/react';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
</body>
</html>
);
}
// ✅ GOOD: Lazy-load after hydration
import dynamic from 'next/dynamic';
const Analytics = dynamic(
() => import('@vercel/analytics/react').then(m => m.Analytics),
{ ssr: false }
);
```
### Or: use `next/script` with the right strategy
```tsx
import Script from 'next/script';
<Script
src="https://example.com/analytics.js"
strategy="lazyOnload" // or "afterInteractive"
/>
```
| Strategy | When it loads | Use for |
|----------|---------------|---------|
| `beforeInteractive` | Before page is interactive | Polyfills only — almost never |
| `afterInteractive` | Right after hydration | Tag managers, A/B test SDKs |
| `lazyOnload` | During browser idle time | Analytics, chat widgets, social embeds |
| `worker` (experimental) | In a web worker | Heavy tracking scripts |
**Default**: `lazyOnload` for anything that doesn't need to fire before user interaction.
---
## How to measure
You don't have to guess what's bloated. Two free tools:
```bash
# Analyze your build output
ANALYZE=true next build
# Or for a quick check
next build # look at the "First Load JS" column
```
If any route shows >300KB First Load JS, you almost certainly have a barrel import or a heavy component that should be dynamic.
---
## When to apply
- **Always** for icon libraries — there is never a reason to barrel-import 1,500 icons to use 3
- **Always** for analytics, error tracking, chat widgets, social embed scripts
- **When a route exceeds ~250KB First Load JS** — start hunting for heavy components to lazy-load
Next: [03-server-actions-security.md](03-server-actions-security.md) covers a smaller but critical category — auth boundaries inside Server Actions.
@@ -0,0 +1,162 @@
# Server Actions Security (CRITICAL)
> Sibling docs: [waterfalls](01-waterfalls.md) · [bundle size](02-bundle-size.md) · [server-side perf](04-server-side-perf.md) · [checklist](checklist.md)
---
## The core fact
**Server Actions are public HTTP endpoints.** Even though they're called like functions from your client components, Next.js exposes them as POST routes that anyone with a fetch client can hit directly.
This means: **never trust the call site**. The fact that "the button is only shown to admins" does not protect the action. An attacker can call the action with any payload they want, regardless of UI state.
Every Server Action must verify auth and authorization **inside the action body**.
---
## The vulnerable pattern
```tsx
// ❌ BAD: No auth check — anyone can delete any record
'use server';
export async function deleteResource(resourceId: string) {
await db.resource.delete({ where: { id: resourceId } });
return { success: true };
}
```
This looks safe at a glance. The button that calls it might only be shown to logged-in users. But the action itself accepts a bare `resourceId` from anyone. An attacker can:
1. Open the network tab, find the action endpoint
2. Call it with `resourceId: "any-id-they-want"`
3. Delete records they don't own
There is no client-side guard you can add that fixes this.
---
## The safe pattern
```tsx
// ✅ GOOD: Verify auth, then ownership, then act
'use server';
import { auth } from '@/lib/auth';
import { revalidatePath } from 'next/cache';
export async function deleteResource(resourceId: string) {
// 1. Authentication — is anyone logged in?
const session = await auth();
if (!session?.user) {
throw new Error('Unauthorized');
}
// 2. Authorization — does this user own this resource?
const resource = await db.resource.findUnique({ where: { id: resourceId } });
if (!resource || resource.userId !== session.user.id) {
throw new Error('Forbidden');
}
// 3. Now it's safe to perform the action
await db.resource.delete({ where: { id: resourceId } });
revalidatePath('/dashboard');
return { success: true };
}
```
The order matters: **authenticate, then authorize, then act**.
---
## Three layers of checks
| Layer | Question | Failure response |
|-------|----------|------------------|
| **Authentication** | Is there a logged-in user at all? | `Unauthorized` (401-equivalent) |
| **Authorization** | Does this user have permission to act on this resource? | `Forbidden` (403-equivalent) |
| **Validation** | Is the input shape and value sane? | `Invalid input` (400-equivalent) |
Skipping any layer is a security hole. The most common mistake is checking auth but skipping authorization — "logged in" is not the same as "allowed to touch this record".
---
## Validating input
Server Actions accept whatever you pass them. Use a schema validator (Zod, Valibot, etc.) at the top of every action that takes structured input.
```tsx
'use server';
import { z } from 'zod';
import { auth } from '@/lib/auth';
const UpdateProfileSchema = z.object({
name: z.string().min(1).max(100),
bio: z.string().max(500),
});
export async function updateProfile(input: unknown) {
const session = await auth();
if (!session?.user) throw new Error('Unauthorized');
// Validate before touching the DB
const data = UpdateProfileSchema.parse(input);
await db.user.update({
where: { id: session.user.id },
data,
});
}
```
Without validation, an attacker could pass `{ name: <huge string>, role: 'admin' }` and your DB write might silently overwrite fields you didn't intend to expose.
---
## A reusable wrapper
If you have many actions that all need the same checks, factor them into a higher-order helper:
```tsx
// lib/action.ts
import { auth } from '@/lib/auth';
export function authedAction<TInput, TOutput>(
fn: (input: TInput, userId: string) => Promise<TOutput>
) {
return async (input: TInput): Promise<TOutput> => {
const session = await auth();
if (!session?.user) throw new Error('Unauthorized');
return fn(input, session.user.id);
};
}
```
Then your actions become:
```tsx
'use server';
import { authedAction } from '@/lib/action';
export const deleteResource = authedAction(async (resourceId: string, userId) => {
const resource = await db.resource.findUnique({ where: { id: resourceId } });
if (!resource || resource.userId !== userId) {
throw new Error('Forbidden');
}
await db.resource.delete({ where: { id: resourceId } });
});
```
The wrapper guarantees auth happens. You still have to write the per-resource authorization check yourself — there's no shortcut for that.
---
## The mental model
> Treat every Server Action as if it's an unauthenticated HTTP endpoint that an attacker is reading the source for.
If that mental model produces panic about a particular action, fix it. If it doesn't, you're probably missing a check.
Next: [04-server-side-perf.md](04-server-side-perf.md) covers higher-effort, higher-payoff server-side optimizations.
@@ -0,0 +1,176 @@
# Server-Side Performance (HIGH)
> Sibling docs: [waterfalls](01-waterfalls.md) · [bundle size](02-bundle-size.md) · [server actions security](03-server-actions-security.md) · [checklist](checklist.md)
---
## Scope
Once you've fixed waterfalls (file 01) and bundle bloat (file 02), the next tier of wins comes from three patterns: deduplicating server-side data fetches, trimming what you send to the client, and pushing non-critical work off the response path.
These are HIGH severity, not CRITICAL — they matter, but the gains are smaller and require more refactoring than the earlier fixes.
---
## 4.1 Use `React.cache()` for request deduplication
In App Router, the same data-fetching function can be called from multiple places during a single request — `layout.tsx`, `page.tsx`, nested components, metadata generation. Without dedup, each call hits your database again.
```tsx
// ❌ BAD: Same user fetched 3+ times per request
// layout.tsx
const user = await getUser(userId);
// page.tsx
const user = await getUser(userId); // duplicate query
// generateMetadata()
const user = await getUser(userId); // another duplicate
```
```tsx
// ✅ GOOD: One fetch per request, shared everywhere
// lib/data.ts
import { cache } from 'react';
export const getUser = cache(async (userId: string) => {
return await db.user.findUnique({ where: { id: userId } });
});
```
`cache()` creates a per-request memoization layer. The first call hits the database; subsequent calls (within the same request) return the cached result. The cache resets between requests, so you don't have to worry about stale data.
### When to apply
- Any data-fetcher called from more than one place during a single request
- Especially: user lookups, current-tenant lookups, feature flag fetches, permission checks
- Wrap them at the source (in `lib/data.ts`), not at every call site
### What `cache()` is NOT
- Not a cross-request cache — use `unstable_cache` or external caching for that
- Not a client-side cache — only works in Server Components
- Not magic — if your fetcher takes different arguments at each call site, it won't dedup
---
## 4.2 Minimize client component data
When you pass props from a Server Component into a Client Component, those props get **serialized into the HTML** and shipped to the browser. Sending the entire user object means shipping the user's email, password hash (if present), internal IDs, etc. — every page load.
```tsx
// ❌ BAD: Sends the whole user object to the browser
const user = await getUser(id);
return <ClientProfile user={user} />;
// What gets serialized: { id, name, email, passwordHash, role, internalNotes, ... }
```
```tsx
// ✅ GOOD: Pick only the fields the client genuinely needs
const user = await getUser(id);
return (
<ClientProfile
user={{
name: user.name,
avatar: user.avatar,
}}
/>
);
```
This has two benefits:
1. **Smaller HTML payload** — faster time-to-first-byte
2. **Less leakage** — sensitive fields never reach the browser, even if the client component never renders them
### Rule of thumb
Treat the Server→Client boundary as a public API. Pick fields explicitly. Never pass full ORM objects.
---
## 4.3 Use `after()` for non-blocking work
Some work has to happen as a result of a request, but the user doesn't need to wait for it: analytics tracking, webhook fanout, audit logs, cache warming, email queuing.
In Next.js 15, the `after()` API lets you defer this work until **after the response has been sent**.
```tsx
// ❌ BAD: User waits for analytics + webhook before getting their response
export async function POST(req: Request) {
const data = await processRequest(req);
await logToAnalytics(data); // user waits
await sendWebhook(data); // user waits
return Response.json(data);
}
```
```tsx
// ✅ GOOD: Respond first, do background work after
import { after } from 'next/server';
export async function POST(req: Request) {
const data = await processRequest(req);
after(async () => {
await logToAnalytics(data);
await sendWebhook(data);
});
return Response.json(data); // returns immediately
}
```
The user sees the response in N ms instead of N + analytics_latency + webhook_latency ms.
### What's safe to put in `after()`
- ✅ Analytics events
- ✅ Audit logging
- ✅ Cache warming / invalidation
- ✅ Webhook dispatch (fire-and-forget)
- ✅ Email queueing (queueing, not sending)
### What's NOT safe to put in `after()`
- ❌ Anything the response payload depends on
- ❌ Anything the user expects to be durable before they see success (e.g., the actual database write)
- ❌ Anything that needs to fail loudly to the user
If a failure in `after()` should block the user, it doesn't belong there.
### `after()` and Server Actions
`after()` works in Server Actions too:
```tsx
'use server';
import { after } from 'next/server';
export async function createPost(formData: FormData) {
const post = await db.post.create({ data: { /* ... */ } });
after(async () => {
await indexInSearch(post);
await notifySubscribers(post);
});
return { id: post.id };
}
```
---
## When to apply
| Pattern | When |
|---------|------|
| `cache()` | Any data fetcher called from multiple places per request |
| Minimize client props | Always — make picking fields a habit |
| `after()` | Any post-response work that doesn't gate user feedback |
These are not as urgent as fixing waterfalls and barrels, but they compound. Apply them once and they keep paying off as the codebase grows.
Next: [checklist.md](checklist.md) — a pre-merge checklist plus a `next.config.js` template.
@@ -0,0 +1,62 @@
# Next.js 15 Performance Pack
A focused, opinionated guide to the highest-impact performance optimizations for Next.js 15 applications. Adapted from Vercel's react-best-practices, restructured for portability and self-contained reading.
This pack is **self-contained**: every file in this directory links only to siblings here. Drop the whole folder into any project and the cross-references keep working.
---
## What you get
| File | Severity | Topic |
|------|----------|-------|
| [01-waterfalls.md](01-waterfalls.md) | CRITICAL | Eliminating sequential awaits, parallel fetching, Suspense streaming |
| [02-bundle-size.md](02-bundle-size.md) | CRITICAL | Barrel imports, dynamic imports, third-party scripts |
| [03-server-actions-security.md](03-server-actions-security.md) | CRITICAL | Auth checks inside Server Actions |
| [04-server-side-perf.md](04-server-side-perf.md) | HIGH | `React.cache()`, minimizing client data, `after()` for non-blocking work |
| [checklist.md](checklist.md) | — | Pre-PR checklist + `next.config.js` template |
---
## Audience
You should read this if you're:
- Writing or reviewing Next.js 15+ code (App Router)
- Hitting unexplained slowness on data-heavy pages
- Looking at large client bundles and not sure what's bloating them
- Building Server Actions and unsure about auth boundaries
---
## Prerequisites
Comfort with:
- React 18+ Server Components and Client Components (`'use client'`)
- async/await
- Next.js App Router (not Pages Router — most patterns here don't apply)
- TypeScript syntax in examples (the patterns work in JS too)
---
## How to read
1. Start with [01-waterfalls.md](01-waterfalls.md) — sequential awaits are the single biggest cause of unexplained slowness, and the fixes are nearly free.
2. Then [02-bundle-size.md](02-bundle-size.md) — barrel imports are quietly murdering cold-start times in most projects.
3. [03-server-actions-security.md](03-server-actions-security.md) is short but non-negotiable.
4. [04-server-side-perf.md](04-server-side-perf.md) is for when you've handled the basics and want to squeeze more.
5. Use [checklist.md](checklist.md) before opening every PR.
---
## A note on severity
The four issues marked CRITICAL are the ones that, in production code reviews, cause the biggest measurable wins. If you have limited time, fix waterfalls and barrel imports first — those alone typically cut load times in half.
---
## References
- [Vercel React Best Practices](https://github.com/vercel-labs/agent-skills/tree/main/skills/react-best-practices)
- [Next.js 15 Documentation](https://nextjs.org/docs)
@@ -0,0 +1,93 @@
# Pre-PR Checklist & Config Template
> Sibling docs: [waterfalls](01-waterfalls.md) · [bundle size](02-bundle-size.md) · [server actions security](03-server-actions-security.md) · [server-side perf](04-server-side-perf.md)
---
## Pre-merge checklist
Walk through this before opening or merging any PR that touches Next.js application code.
### Data fetching ([01-waterfalls.md](01-waterfalls.md))
- [ ] No two consecutive `await`s on independent data — wrap them in `Promise.all()`
- [ ] Validation/auth checks happen *before* expensive async work
- [ ] Slow data is wrapped in `<Suspense>` so the rest of the page can render
- [ ] Independent fetches in API routes start as early as possible
### Bundle size ([02-bundle-size.md](02-bundle-size.md))
- [ ] No barrel imports from icon libraries (`lucide-react`, `@radix-ui/react-icons`, etc.) without `optimizePackageImports`
- [ ] Heavy components (editors, charts, PDF, video, 3D) use `next/dynamic`
- [ ] `ssr: false` on dynamic components that touch `window` or are gated behind interaction
- [ ] Analytics, chat widgets, and tracking scripts use `next/script` with `lazyOnload`, or are dynamically imported
- [ ] First Load JS for new routes is under ~250KB
### Server Actions ([03-server-actions-security.md](03-server-actions-security.md))
- [ ] Every Server Action calls `auth()` (or equivalent) at the top
- [ ] Every action that takes a resource ID also verifies **ownership** of that resource
- [ ] Input is validated with a schema (Zod, Valibot, etc.) — never trust the shape
- [ ] Errors are thrown, not silently logged
### Server-side performance ([04-server-side-perf.md](04-server-side-perf.md))
- [ ] Multi-call data fetchers (`getCurrentUser`, `getCurrentTenant`, etc.) are wrapped in `React.cache()`
- [ ] Server→Client component props are explicit picks, not full ORM objects
- [ ] Analytics, webhooks, and audit logs use `after()` instead of blocking the response
### Quick sanity pass
- [ ] `next build` succeeds with no warnings about large bundles
- [ ] No `console.log` left in production code paths
- [ ] No `'use client'` at the top of files that don't need it (Server Components are the default for a reason)
---
## `next.config.js` template
A baseline config that turns on the most important optimizations for Next.js 15. Drop this in and adjust the package list to match your dependencies.
```js
/** @type {import('next').NextConfig} */
module.exports = {
experimental: {
// Tree-shake barrel imports automatically
optimizePackageImports: [
'lucide-react',
'@radix-ui/react-icons',
'@radix-ui/react-*',
'date-fns',
'lodash-es',
],
},
// If you serve images, prefer modern formats
images: {
formats: ['image/avif', 'image/webp'],
},
// Strict mode catches a lot of subtle bugs
reactStrictMode: true,
};
```
### What this turns on
| Setting | Effect |
|---------|--------|
| `optimizePackageImports` | Per-symbol tree-shaking for the listed libraries — typically saves 200800ms cold start |
| `images.formats` | Serves AVIF/WebP when the browser supports it — typically 3060% smaller than JPEG |
| `reactStrictMode` | Surfaces unsafe lifecycles, double-renders effects in dev to catch bugs |
---
## When the checklist starts feeling automatic
Once you've gone through it on a dozen PRs, the patterns become reflexive. At that point:
- Add a CI check for First Load JS budgets per route
- Add an ESLint rule (or custom regex check) for barrel imports from your most-abused libraries
- Add a code-review template that includes the auth-and-ownership verification step for Server Actions
The goal is to make these checks happen automatically so you can focus on the next tier of optimizations.
@@ -0,0 +1,54 @@
# Swiss International Style — Design System
A portable design system pack inspired by Swiss International Style (also called International Typographic Style or Brutalism). Hard edges, mathematical grids, objective typography, no ornamentation.
This pack is **self-contained**: every file in this directory links only to siblings here. Drop the whole folder into any project and the cross-references keep working.
---
## What you get
| File | Purpose |
|------|---------|
| [tokens.md](tokens.md) | Colors, typography, spacing, shadows — the raw design tokens |
| [components.md](components.md) | Buttons, inputs, cards, alerts, status indicators |
| [layouts.md](layouts.md) | Grid systems, panel patterns, page dimensions |
| [ai-prompt.md](ai-prompt.md) | System prompt for asking an LLM to generate Swiss-style UI |
| [anti-patterns.md](anti-patterns.md) | What NOT to do, plus a pre-merge checklist |
---
## Core principles
1. **Grid-based layouts** — Mathematical precision over visual intuition
2. **Asymmetric balance** — Strategic whitespace, not symmetric centering
3. **Objective typography** — Serif headers, monospace metadata, sans-serif body
4. **Minimal ornamentation** — Hard edges, no gradients, no rounded corners, no decorative icons
If you remember nothing else: **square corners, hard shadows, pure colors, three-font hierarchy**.
---
## Who this is for
- Designers and engineers who want a strict, opinionated visual language
- Projects that benefit from looking distinct from generic SaaS aesthetics
- Teams that want a small, memorizable rule set rather than a sprawling component library
This pack is **prescriptive, not flexible**. It works because the rules are absolute. If you need a softer, more decorative system, this is the wrong starting point.
---
## How to use
1. Read [tokens.md](tokens.md) first — every other file references these values.
2. Then [components.md](components.md) for the building blocks.
3. [layouts.md](layouts.md) when composing pages.
4. Use [ai-prompt.md](ai-prompt.md) when delegating UI generation to an LLM.
5. Review [anti-patterns.md](anti-patterns.md) before shipping.
---
## Stack assumptions
The code samples use **Tailwind CSS** utility classes and **React/JSX**. The token values themselves are framework-agnostic — translate the colors and shadows into your own CSS, vanilla, Vue, Svelte, or whatever. The principles don't change.
@@ -0,0 +1,111 @@
# AI System Prompt — Swiss International Style
A drop-in system prompt for delegating UI generation to an LLM (Claude, GPT, Gemini, etc.). Paste it into your assistant's system message or prepend it to a generation request.
> Sibling docs: [tokens](tokens.md) · [components](components.md) · [layouts](layouts.md) · [anti-patterns](anti-patterns.md)
---
## The prompt
```text
You are a UI designer and developer following Swiss International Style
(also called International Typographic Style or Brutalism).
ABSOLUTE RULES — never violate these:
1. NO rounded corners anywhere (no rounded-*, no border-radius)
2. NO gradients, no drop shadows, no blur effects
3. NO decorative icons (only functional icons, mono-colored)
4. Hard black borders (1px or 2px solid #000000)
5. Hard shadows that translate on hover (never blurred)
6. Grid-based layouts with mathematical precision
7. Asymmetric balance — left-aligned by default, never centered
COLOR PALETTE (use only these):
- Canvas: #F0F0E8 (page background — never pure white)
- Ink: #000000 (text, borders)
- Hyper Blue: #1D4ED8 (links, primary actions, focus rings)
- Signal Green: #15803D (success, downloads)
- Alert Orange: #F97316 (warnings)
- Alert Red: #DC2626 (errors, destructive)
- Steel Grey: #4B5563 (secondary text only)
TYPOGRAPHY (three fonts only):
- Headers: serif (Georgia, Times)
- Body: sans-serif (Inter, Helvetica)
- Labels: monospace, UPPERCASE, tracked-wider (SF Mono, Consolas)
BUTTONS:
- rounded-none, border-2 border-black
- shadow-[2px_2px_0px_0px_#000000]
- hover: translate-y-[1px] translate-x-[1px] shadow-none
- font-mono uppercase text-sm
- One primary button per region; demote others to outline
INPUTS:
- rounded-none, border border-black (1px)
- bg-white, focus ring-1 ring-blue-700
- Paired with monospace uppercase labels
CARDS:
- rounded-none, border-2 border-black
- shadow-[4px_4px_0px_0px_#000000]
- bg-white over the canvas
LAYOUT:
- CSS Grid for collections (3, 4, or 5 columns — never 2)
- Hard black dividers between panels
- Asymmetric padding (more right than left, more bottom than top)
- Section headers sit close to their content (mt-12 mb-2)
STATUS INDICATORS:
- 12px square (w-3 h-3) in the status color
- Followed by monospace uppercase label
- Never circles, never animated dots, never spinners
STACK ASSUMPTION (unless told otherwise):
- React + Tailwind CSS utility classes
- TypeScript
If the user asks for something that violates these rules (e.g., "make it
more friendly with rounded corners"), explain that the style is intentionally
strict and offer a Swiss-compliant alternative instead.
```
---
## Usage tips
### When generating a single component
Send the prompt above as the system message, then ask for one component at a time. LLMs handle one focused request better than "build me a whole page".
### When generating a full page
After the system prompt, give the model a content outline:
```
Generate a Swiss-style settings page with:
- Page header "Settings" (serif, 4xl)
- Two columns (1/3 nav sidebar, 2/3 form)
- Form sections: Profile, Notifications, Danger Zone
- Each section is a card with a 2px border
- Save button at bottom (primary, blue)
- Delete account button at bottom of Danger Zone (red, destructive)
```
Specify the **layout grid** explicitly. LLMs default to centered layouts; you have to push them off that.
### When iterating
If the model produces something with rounded corners, gradients, or pastel colors, don't ask "can you fix that". Restate the violated rule:
> "Remove all rounded corners — `rounded-none` is non-negotiable in this style."
Direct correction is faster than soft requests.
---
## Why this prompt is strict
LLMs are trained on millions of generic SaaS designs. Their default aesthetic is rounded corners, soft shadows, pastel colors, and centered layouts — the exact opposite of Swiss style. The only way to get clean output is **absolute, non-negotiable rules** stated up front. Soft suggestions ("try to avoid gradients") get ignored.
@@ -0,0 +1,93 @@
# Anti-Patterns & Pre-Merge Checklist
What NOT to do, and how to catch it before code ships. Read this before opening a PR that touches UI.
> Sibling docs: [tokens](tokens.md) · [components](components.md) · [layouts](layouts.md) · [ai-prompt](ai-prompt.md)
---
## Forbidden things
| Anti-pattern | Why it breaks the style | Use instead |
|--------------|-------------------------|-------------|
| `rounded-*` (any value) | Rounds soften the binary geometry | `rounded-none` |
| Gradients (`bg-gradient-*`) | Decorative, not structural | Solid color from the palette |
| Blurred shadows (`shadow-md`, `shadow-lg`) | Implies depth illusion | Hard offset shadow `shadow-[Npx_Npx_0px_0px_#000]` |
| Decorative icons (heart, star, sparkles) | Ornamental | Functional icons only, mono color |
| Pastel colors | Off-palette | Use Hyper Blue, Signal Green, Alert Orange/Red |
| Pure white (`#FFFFFF`) as page bg | Too clinical, fights the borders | Canvas `#F0F0E8` |
| Animated transitions (`transition-all`) | Swiss style is binary | No transition, or instant |
| Centered layouts | Symmetric = generic | Left-aligned, asymmetric |
| 2-column collections | Too symmetric | 3, 4, or 5 columns |
| Card carousels | Hides content | Show the grid |
| Soft grey dividers | Weakens structure | 12px solid black |
| Circle status dots | Decorative | 12px squares |
| Multiple primary buttons per region | No focal point | One primary, rest outline |
| Decorative borders (dashed/dotted) | Ornamental | Solid only — exception: "hidden/draft" state |
| New colors invented for new states | Palette explosion | Reuse existing colors with intent |
| Custom paddings off the 4px scale | Breaks rhythm | Stick to xs/sm/md/lg/xl/2xl |
---
## Common mistakes that look "almost right"
These are the ones that pass casual review but fail the style:
### Using `border-gray-300` instead of `border-black`
Grey borders look "softer" and feel safer, which is exactly the wrong instinct. Swiss style commits to its borders. If a border looks too heavy, the answer is to **remove it**, not soften it.
### Adding `shadow-sm` "for a little depth"
A soft shadow is a depth illusion. The whole pack rejects depth illusions. If something needs to feel elevated, give it a hard offset shadow or a heavier border — never `shadow-sm`.
### Centering the page content with `mx-auto max-w-4xl`
Centering is the default reflex from generic web design. In Swiss style, content should sit asymmetrically — typically pulled to the left third or two-thirds, with whitespace on the right. Use a grid, not `mx-auto`.
### Using `text-gray-500` for everything secondary
There's exactly one secondary text color: Steel Grey `#4B5563`. Don't introduce tints or alternates. If you need more hierarchy, use weight or size, not color.
### Importing decorative icon sets
`lucide-react`, `heroicons`, etc. ship with thousands of decorative glyphs. Use them only for functional icons (close, expand, navigate). Never for emotional decoration (sparkles, hearts, lightning bolts).
---
## Pre-merge checklist
Before merging UI changes, walk through this list:
### Tokens
- [ ] All colors are from the palette in [tokens.md](tokens.md)
- [ ] No `rounded-*` classes anywhere
- [ ] No `bg-gradient-*` anywhere
- [ ] No `shadow-sm`, `shadow-md`, `shadow-lg` (only hard offset shadows)
- [ ] All paddings are on the 4px scale (`p-1`, `p-2`, `p-4`, `p-6`, `p-8`, `p-12`, `p-16`)
### Typography
- [ ] Headers use `font-serif`
- [ ] Body uses `font-sans`
- [ ] Labels and metadata use `font-mono uppercase tracking-wider`
- [ ] No more than three font families on the page
### Components
- [ ] Buttons have `border-2 border-black` and a hard offset shadow
- [ ] Inputs have `border border-black` (1px) and `rounded-none`
- [ ] Cards have `border-2 border-black` and `shadow-[4px_4px_0px_0px_#000000]`
- [ ] Status indicators are 12px squares, not circles or dots
- [ ] At most one primary button per logical region
### Layout
- [ ] Page background is Canvas, not white
- [ ] Content is left-aligned by default
- [ ] Padding is asymmetric (not equal on all sides for major blocks)
- [ ] Dividers between panels are 12px solid black
- [ ] No animated transitions (or, if interactivity demands it, instant snap)
### Final pass
- [ ] Squint at the design — does it look distinctly Swiss, or could it be any SaaS app?
- [ ] If you removed all colors except black and one accent, would the layout still read?
If you can answer **yes** to the squint test, you're done. If it looks like a generic dashboard, go back to [tokens.md](tokens.md) and start over.
@@ -0,0 +1,221 @@
# Components
Concrete recipes for the building blocks of a Swiss-style interface. Every component here uses tokens defined in [tokens.md](tokens.md).
> Sibling docs: [tokens](tokens.md) · [layouts](layouts.md) · [anti-patterns](anti-patterns.md)
---
## Buttons
Square corners, 2px black border, hard shadow, press-in hover.
```jsx
<button className="
rounded-none
border-2 border-black
bg-blue-700 text-white
px-4 py-2
font-mono uppercase tracking-wider text-sm
shadow-[2px_2px_0px_0px_#000000]
hover:translate-y-[1px] hover:translate-x-[1px] hover:shadow-none
transition-none
">
Submit
</button>
```
### Variants
| Variant | Background | Text | When |
|---------|------------|------|------|
| Primary | `bg-blue-700` | white | Default action |
| Success | `bg-green-700` | white | Confirm, save, download |
| Destructive | `bg-red-600` | white | Delete, cancel-with-loss |
| Warning | `bg-orange-500` | white | Risky but reversible |
| Outline | transparent | black | Secondary actions |
**Rule**: only one Primary button per logical screen region. If you find yourself adding a second, demote it to Outline.
### Don't
- Don't add `transition` curves — Swiss style is binary, not animated
- Don't use icons inside buttons unless absolutely necessary; if you do, use a single mono-colored icon, never decorative
---
## Inputs
```jsx
<input
type="text"
className="
rounded-none
border border-black
bg-white
px-3 py-2
font-sans text-base
focus:outline-none focus:ring-1 focus:ring-blue-700
"
/>
```
- 1px black border (not 2px — inputs are denser)
- Focus state: 1px Hyper Blue ring, no glow
- White background only (so they read as elevated against the canvas)
### Labels
Always paired with monospace uppercase labels:
```jsx
<label className="font-mono text-sm uppercase tracking-wider mb-1 block">
Email Address
</label>
```
### Textareas
Same as inputs. If you're embedding textareas inside another keyboard-handled component (modals, command palettes, draggable cards), make sure Enter doesn't bubble up:
```tsx
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter') e.stopPropagation();
};
```
---
## Cards
```jsx
<div className="
bg-white
border-2 border-black
rounded-none
shadow-[4px_4px_0px_0px_#000000]
p-6
">
<h2 className="font-serif text-2xl font-bold mb-4">Card Title</h2>
<p className="font-sans text-base">Card body content.</p>
</div>
```
- 2px border (heavier than inputs because cards are anchors)
- 4px shadow (heavier than buttons because they're stationary)
- White background to lift off the canvas
### Featured / hero cards
For the one or two most important cards on a page:
```jsx
<div className="
bg-white
border-2 border-black
shadow-[8px_8px_0px_0px_#000000]
p-8
">
```
8px shadow signals "this is the headline element". Use sparingly.
---
## Dialogs / Modals
```jsx
<div className="
fixed inset-0 bg-black/30 flex items-center justify-center
">
<div className="
max-w-md w-full
bg-white
border-2 border-black
shadow-[4px_4px_0px_0px_#000000]
p-6
">
<h2 className="font-serif text-2xl font-bold mb-4">Confirm</h2>
{/* content */}
</div>
</div>
```
- Centered, `max-w-md` by default (wider only if the form genuinely demands it)
- Backdrop is `bg-black/30` — never blurred
- The dialog itself is just a card with extra emphasis
---
## Alerts
Status alerts use the matching status color family. Border is always 2px in the status hue.
```jsx
// Danger
<div className="bg-red-100 border-2 border-red-600 p-4">
<p className="font-mono uppercase text-sm font-bold text-red-600 mb-1">Error</p>
<p className="font-sans">Something went wrong.</p>
</div>
// Warning
<div className="bg-orange-100 border-2 border-orange-600 p-4">
// Success
<div className="bg-green-100 border-2 border-green-700 p-4">
// Info
<div className="bg-blue-100 border-2 border-blue-700 p-4">
```
The pattern is always: pale-100 background, 600/700 border and label.
---
## Status Indicators
A 12px square + a monospace uppercase label. No icons, no spinners, no animated dots.
```jsx
// Ready
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-green-700" />
<span className="font-mono uppercase font-bold text-green-700">STATUS: READY</span>
</div>
// Setup Required
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-orange-500" />
<span className="font-mono uppercase font-bold text-orange-500">STATUS: SETUP REQUIRED</span>
</div>
// Error
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-red-600" />
<span className="font-mono uppercase font-bold text-red-600">STATUS: ERROR</span>
</div>
```
### Why squares, not circles?
Circles are decorative. Squares are structural. The whole pack is built on rejecting decorative geometry.
---
## Quick reference snippets
```jsx
// Swiss button
<button className="rounded-none border-2 border-black bg-blue-700 text-white px-4 py-2 font-mono uppercase text-sm shadow-[2px_2px_0px_0px_#000000] hover:translate-y-[1px] hover:translate-x-[1px] hover:shadow-none">
// Swiss card
<div className="bg-white border-2 border-black rounded-none shadow-[4px_4px_0px_0px_#000000] p-6">
// Swiss label
<label className="font-mono text-sm uppercase tracking-wider">
// Swiss section header
<h2 className="font-serif text-2xl font-bold">
```
For composing these into pages, see [layouts.md](layouts.md).
@@ -0,0 +1,144 @@
# Layouts
How to compose Swiss-style components into full pages. The system rewards mathematical grids and asymmetric balance over centered, decorative arrangements.
> Sibling docs: [tokens](tokens.md) · [components](components.md) · [anti-patterns](anti-patterns.md)
---
## Grid systems
Swiss design is grid-first. Pick a column count up front and stick to it.
### Dashboard / index grid
```jsx
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
{items.map(item => <Card key={item.id} {...item} />)}
</div>
```
5-column on large screens is unusual on purpose — it creates the asymmetric rhythm the style is known for. 3- or 4-column also works; avoid 2-column for collections (too symmetric).
### Editor + preview split
```jsx
<div className="flex h-full">
<div className="w-1/2 border-r-2 border-black">
{/* editor */}
</div>
<div className="w-1/2">
{/* preview */}
</div>
</div>
```
The hard black divider is what makes this Swiss instead of generic. Don't use a thin grey divider — it weakens the structure.
### Sidebar + content
```jsx
<div className="flex h-full">
<aside className="w-64 border-r-2 border-black p-6">
{/* nav */}
</aside>
<main className="flex-1 p-8">
{/* content */}
</main>
</div>
```
Fixed sidebar width (256px / `w-64`), fluid content. Resist the urge to make the sidebar collapsible with smooth animations — if it collapses, it snaps.
---
## Panel headers
Each major panel gets a labeled header with a status square + monospace caption. This is a defining Swiss-style flourish.
```jsx
// Editor panel
<div className="flex items-center gap-2 mb-4">
<div className="w-3 h-3 bg-blue-700" />
<span className="font-mono text-xs uppercase tracking-wider">Editor Panel</span>
</div>
// Preview panel
<div className="flex items-center gap-2 mb-4">
<div className="w-3 h-3 bg-green-700" />
<span className="font-mono text-xs uppercase tracking-wider">Live Preview</span>
</div>
```
The color of the square encodes the panel's role (input, output, status, etc.). Pick once per project and stay consistent.
---
## Whitespace
Asymmetric balance comes from **uneven** padding around content blocks.
```jsx
// Symmetric — feels generic
<div className="p-8">
<h1>Title</h1>
<p>Body</p>
</div>
// Asymmetric — feels Swiss
<div className="pt-6 pb-12 pl-8 pr-16">
<h1>Title</h1>
<p>Body</p>
</div>
```
A common trick: **more whitespace on the right** than the left, **more on the bottom** than the top. It creates a directional weight that pulls the eye through the page.
---
## Page dimensions (for print/PDF layouts)
If you're targeting print, anchor on standard page sizes:
```typescript
const PAGE_SIZES = {
A4: { width: 210, height: 297 }, // mm — international standard
LETTER: { width: 215.9, height: 279.4 }, // mm — US standard
};
// Convert mm to px at 96 DPI
const mmToPx = (mm: number) => mm * 3.7795275591;
```
For browser-based PDF rendering (e.g., headless Chromium), set the page size on the print stylesheet:
```css
@page {
size: A4;
margin: 0;
}
```
---
## Typography rhythm
Headers should sit **closer** to the content they introduce than to the content above them. The default browser margins do the opposite — fix this.
```jsx
<h2 className="font-serif text-2xl font-bold mt-12 mb-2">Section Title</h2>
<p className="font-sans">Content directly under the header.</p>
```
`mt-12 mb-2` (asymmetric vertical) is the default; reach for it instinctively.
---
## Anti-patterns to avoid
See [anti-patterns.md](anti-patterns.md) for the full list. The layout-specific ones:
- Don't center everything — Swiss style is left-aligned by default
- Don't use card carousels — show the grid
- Don't soften dividers — borders are 12px black, never grey
- Don't animate panel transitions — they snap
+133
View File
@@ -0,0 +1,133 @@
# Design Tokens
The atomic values every other file in this pack builds on. Memorize the colors and the three-font hierarchy — those two things define 80% of the visual identity.
> Sibling docs: [components](components.md) · [layouts](layouts.md) · [anti-patterns](anti-patterns.md)
---
## Color Palette
A small, intentional palette. Each color has one job. Don't introduce new colors casually — if a new state shows up, ask whether an existing color already covers it.
| Name | Hex | Usage |
|------|-----|-------|
| Canvas | `#F0F0E8` | Page background — warm off-white, never pure white |
| Ink | `#000000` | Body text, borders, primary structural lines |
| Hyper Blue | `#1D4ED8` | Links, primary actions, focus rings |
| Signal Green | `#15803D` | Success, confirmation, downloads |
| Alert Orange | `#F97316` | Warnings, "needs attention" |
| Alert Red | `#DC2626` | Errors, destructive actions, delete |
| Steel Grey | `#4B5563` | Secondary text, captions, disabled states |
### Why no pure white?
Canvas (`#F0F0E8`) is the default surface. Pure white is jarring against the hard black borders and feels clinical. Use white only for elevated card surfaces sitting on top of the canvas.
### Color rules
- One primary action per screen (Hyper Blue)
- Status colors are loud — they stop you, so use them sparingly
- Steel Grey is your only "soft" color; never invent additional greys
---
## Typography
Three fonts. That's the whole hierarchy.
```css
font-serif /* Headers — Georgia, Times, "Times New Roman" */
font-sans /* Body text — Inter, Helvetica, system-ui */
font-mono /* Metadata, labels — SF Mono, Consolas, "Courier New" */
```
### Role mapping
| Use | Font | Size | Weight | Notes |
|-----|------|------|--------|-------|
| Page headers | serif | 3xl5xl | bold | Set the tone of the page |
| Section headers | serif | xl2xl | bold | Anchor major sections |
| Body | sans | base | normal | Default for paragraphs |
| Labels | mono | sm | medium, **uppercase** | Form labels, table headers |
| Metadata | mono | xs | light | Timestamps, IDs, captions |
### Type Scale
```
xs: 12px / 1.4 Captions, metadata
sm: 14px / 1.5 Labels, secondary text
base: 16px / 1.6 Body
lg: 18px / 1.55 Lead paragraphs
xl: 20px / 1.5 Subsection headers
2xl: 24px / 1.4 Section headers
3xl: 30px / 1.3 Page headers
4xl: 36px / 1.2 Hero headers
5xl: 48px / 1.1 Display headers
```
### Why monospace for labels?
Monospace + uppercase signals "this is metadata, not content". It creates instant visual hierarchy without relying on color or size. It's the cheapest way to organize a dense interface.
---
## Spacing Scale
A 4px-based scale. Stick to it. Custom paddings break the rhythm.
```
xs: 4px (p-1)
sm: 8px (p-2)
md: 16px (p-4) ← default for most cases
lg: 24px (p-6)
xl: 32px (p-8)
2xl: 48px (p-12)
3xl: 64px (p-16)
```
**Default rule**: when in doubt, use `md` (16px). Tighten to `sm` for dense lists, expand to `lg` or `xl` for breathing room around major sections.
---
## Shadows
Hard shadows only. Never blurred. Never soft. The shadow is a graphic element, not a depth illusion.
```css
/* Buttons */
shadow-[2px_2px_0px_0px_#000000]
/* Cards */
shadow-[4px_4px_0px_0px_#000000]
/* Hero / featured elements */
shadow-[8px_8px_0px_0px_#000000]
```
### Hover behavior
Hard-shadowed elements should "press in" on hover by translating into the shadow:
```css
hover:translate-y-[1px] hover:translate-x-[1px] hover:shadow-none
```
This produces a tactile click affordance without animation curves or easing.
---
## Borders
- **Default**: 1px solid black (`border border-black`)
- **Emphasized**: 2px solid black (`border-2 border-black`)
- **Never**: rounded corners (`rounded-none` is the default)
- **Never**: dashed or dotted borders, except for "hidden" / "draft" states
---
## Putting it together
A minimal Swiss-style element uses **canvas background + ink border + hard shadow + serif/mono type**. If your component has those four things and no extras, you're already on style.
See [components.md](components.md) for concrete examples.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,53 @@
# Resume Tailor Verifier Loop Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make full resume tailoring add JD-aligned skills and improve work/project wording through smaller structured LLM passes guarded by local verification.
**Architecture:** Keep the existing diff-based safety model, but split planning from editing. A first LLM call produces skill targets, a local verifier filters and classifies them, then the existing diff call receives the verified target plan and can append allowed JD skills while rewriting summary, work, and project bullets around those targets.
**Tech Stack:** Python 3.13, FastAPI, Pydantic v2, LiteLLM via `complete_json()`, pytest
---
## File Map
| File | Responsibility | Change |
|------|----------------|--------|
| `apps/backend/app/schemas/models.py` | Diff action model | Add `add_skill` action support |
| `apps/backend/app/prompts/templates.py` | LLM instructions | Add skill planning prompt and expand diff prompt with verified targets |
| `apps/backend/app/prompts/__init__.py` | Prompt exports | Export the planning prompt |
| `apps/backend/app/services/improver.py` | Tailoring harness | Add skill-plan generation, verifier, prompt wiring, `add_skill` applier |
| `apps/backend/tests/unit/test_apply_diffs.py` | Diff applier coverage | Add add-skill pass/reject tests |
| `apps/backend/tests/service/test_improver.py` | LLM prompt harness coverage | Add skill-plan parser/verifier and prompt wiring tests |
## Tasks
### Task 1: Add Verified Skill Additions
- [ ] Write failing tests proving `apply_diffs()` can append a new skill through `action="add_skill"` and reject duplicates/empty values.
- [ ] Update `ResumeChange.action` to include `add_skill`.
- [ ] Implement `add_skill` in `apply_diffs()` for `additional.technicalSkills` only.
- [ ] Run the focused unit tests.
### Task 2: Build and Verify a Skill Target Plan
- [ ] Write failing service tests for parsing LLM skill-plan output.
- [ ] Add `SKILL_TARGET_PLAN_PROMPT`.
- [ ] Add `generate_skill_target_plan()` using `complete_json(schema_type="skill_plan")`.
- [ ] Add `verify_skill_target_plan()` that keeps existing skills, allows JD-added skills, and rejects unsupported non-JD items.
- [ ] Run the focused service tests.
### Task 3: Wire Verified Targets into Diff Generation
- [ ] Write a failing test proving `generate_resume_diffs()` includes verified skill targets in the prompt and advertises `add_skill`.
- [ ] Pass `skill_targets` into `generate_resume_diffs()`.
- [ ] Expand `DIFF_IMPROVE_PROMPT` so full tailor can add verified JD skills and improve work/project bullets around them.
- [ ] In `_improve_preview_flow()`, run the plan before diff generation and feed verified targets into the diff pass.
- [ ] Run backend unit/service tests for improver, diff applier, and refiner.
### Task 4: Verify the Preview Path
- [ ] Run backend focused tests.
- [ ] Run frontend lint if frontend files changed; otherwise skip and state why.
- [ ] Report the exact commands and outcomes.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,553 @@
# LaTeX, Clean, Vivid Resume Templates — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add three new resume templates — `latex` (single-column serif), `clean` (single-column minimal sans), `vivid` (two-column colorful Awesome-CV style) — each faithful to a maintainer-provided reference image, wired through the existing template selection / preview / PDF pipeline.
**Architecture:** Each template is a React component reading `ResumeData` via `getSortedSections`, styled by a co-located CSS module using shared `_base.module.css` classes plus template-specific overrides. Registration mirrors how `modern` was added: extend `TemplateType` + `TEMPLATE_OPTIONS`, add a dispatcher branch in `resume-component.tsx`, a `TemplateThumbnail` case, label maps, the print-route allow-list, and i18n in 5 locales. `vivid` reuses the existing accent-color control + two-column grid; `latex`/`clean` are monochrome single-column.
**Tech Stack:** Next.js 16 / React 19, CSS modules, TypeScript, vitest + @testing-library/react (jsdom), i18n JSON (en/es/zh/ja/pt).
**Spec:** `docs/superpowers/specs/2026-06-03-resume-templates-latex-clean-vivid-design.md`
**Environment note:** Do NOT run `npm`/`npx` in this shell (nvm issues). After implementation, hand the maintainer: `cd apps/frontend && npm run test && npm run lint && npm run build`.
**Always-compiling rule:** Each task leaves the codebase type-valid. Task 1 registers all three IDs *and* their label/thumbnail/footer/accent wiring with placeholder behavior (default thumbnail, blank dispatcher body) so nothing breaks; Tasks 24 fill in each real component + thumbnail + dispatcher branch.
---
## Task 1: Register the three templates (types, options, allow-lists, i18n, controls)
**Files:**
- Modify: `apps/frontend/lib/types/template-settings.ts`
- Modify: `apps/frontend/components/builder/template-selector.tsx`
- Modify: `apps/frontend/components/builder/formatting-controls.tsx`
- Modify: `apps/frontend/components/builder/resume-builder.tsx`
- Modify: `apps/frontend/app/print/resumes/[id]/page.tsx`
- Modify: `apps/backend/app/routers/resumes.py`
- Modify: `apps/frontend/messages/{en,es,zh,ja,pt}.json`
- Test: `apps/frontend/tests/template-registration.test.ts`
- [ ] **Step 1: Write the failing test**`apps/frontend/tests/template-registration.test.ts`
```ts
import { describe, expect, it } from 'vitest';
import { TEMPLATE_OPTIONS, type TemplateType } from '@/lib/types/template-settings';
describe('template registration', () => {
it('includes all seven templates with non-empty metadata', () => {
const ids = TEMPLATE_OPTIONS.map((t) => t.id);
expect(ids).toEqual(
expect.arrayContaining<TemplateType>([
'swiss-single',
'swiss-two-column',
'modern',
'modern-two-column',
'latex',
'clean',
'vivid',
])
);
for (const opt of TEMPLATE_OPTIONS) {
expect(opt.name.length).toBeGreaterThan(0);
expect(opt.description.length).toBeGreaterThan(0);
}
});
});
```
- [ ] **Step 2: Run test, expect FAIL**`npm run test -- template-registration` → FAIL (ids missing).
- [ ] **Step 3: Extend the type + options** in `template-settings.ts`:
```ts
export type TemplateType =
| 'swiss-single'
| 'swiss-two-column'
| 'modern'
| 'modern-two-column'
| 'latex'
| 'clean'
| 'vivid';
```
Append to `TEMPLATE_OPTIONS`:
```ts
{
id: 'latex',
name: 'LaTeX',
description: 'Classic serif academic layout with ruled section headers',
},
{
id: 'clean',
name: 'Clean',
description: 'Minimal sans layout with large understated section headers',
},
{
id: 'vivid',
name: 'Vivid',
description: 'Colorful two-column layout with accent headers and arrow bullets',
},
```
- [ ] **Step 4: Add label entries** to the `templateLabels` object in BOTH `template-selector.tsx` and `formatting-controls.tsx` (append inside the object literal):
```ts
latex: {
name: t('builder.formatting.templates.latex.name'),
description: t('builder.formatting.templates.latex.description'),
},
clean: {
name: t('builder.formatting.templates.clean.name'),
description: t('builder.formatting.templates.clean.description'),
},
vivid: {
name: t('builder.formatting.templates.vivid.name'),
description: t('builder.formatting.templates.vivid.description'),
},
```
- [ ] **Step 5: Footer single/two-column logic**`resume-builder.tsx` (~line 946). Change the single-column condition to include the new single-column IDs:
```tsx
{templateSettings.template === 'swiss-single' ||
templateSettings.template === 'modern' ||
templateSettings.template === 'latex' ||
templateSettings.template === 'clean'
? t('builder.footer.singleColumn')
: t('builder.footer.twoColumn')}
```
- [ ] **Step 6: Accent-color visibility**`formatting-controls.tsx` (~line 207). Add `vivid` so the accent control shows for it:
```tsx
{(settings.template === 'modern' ||
settings.template === 'modern-two-column' ||
settings.template === 'vivid') && (
```
- [ ] **Step 7: Print-route allow-list**`app/print/resumes/[id]/page.tsx` `parseTemplate`:
```ts
if (
value === 'swiss-single' ||
value === 'swiss-two-column' ||
value === 'modern' ||
value === 'modern-two-column' ||
value === 'latex' ||
value === 'clean' ||
value === 'vivid'
) {
return value;
}
return 'swiss-single';
```
- [ ] **Step 8: Backend docstring**`apps/backend/app/routers/resumes.py` line ~1433 comment, update to:
```python
- template: swiss-single, swiss-two-column, modern, modern-two-column, latex, clean, or vivid
```
- [ ] **Step 9: i18n** — in each of `messages/{en,es,zh,ja,pt}.json`, add under `builder.formatting.templates` three keys `latex`, `clean`, `vivid`, each `{ "name", "description" }`. English values match Step 3; other locales use a translated name + description (keep `LaTeX` as a proper noun untranslated; translate `Clean`/`Vivid` descriptively where natural, else transliterate). Preserve existing key ordering and JSON validity.
- [ ] **Step 10: Run the registration test, expect PASS**`npm run test -- template-registration` (maintainer runs). Code still compiles; selecting a new template currently shows the default thumbnail + blank preview body (filled in Tasks 24).
- [ ] **Step 11: Commit**
```bash
git add apps/frontend/lib/types/template-settings.ts apps/frontend/components/builder/template-selector.tsx apps/frontend/components/builder/formatting-controls.tsx apps/frontend/components/builder/resume-builder.tsx "apps/frontend/app/print/resumes/[id]/page.tsx" apps/backend/app/routers/resumes.py apps/frontend/messages/en.json apps/frontend/messages/es.json apps/frontend/messages/zh.json apps/frontend/messages/ja.json apps/frontend/messages/pt.json apps/frontend/tests/template-registration.test.ts
git commit -m "feat(templates): register latex, clean, vivid template IDs + controls/i18n"
```
---
## Task 2: LaTeX template (single-column serif)
**Files:**
- Create: `apps/frontend/components/resume/resume-latex.tsx`
- Create: `apps/frontend/components/resume/styles/latex.module.css`
- Modify: `apps/frontend/components/resume/index.ts`
- Modify: `apps/frontend/components/dashboard/resume-component.tsx`
- Modify: `apps/frontend/components/builder/template-selector.tsx` (thumbnail case)
- Test: `apps/frontend/tests/resume-latex.test.tsx`
**Component spec (adapt from `resume-modern.tsx`, single-column flow):**
- Reads `getSortedSections`, renders `personalInfo` header + sections in order; uses `SafeHtml` for bullet HTML; `formatDateRange` for dates; honors `showContactIcons`.
- Header centered: `<h1>` name with class `styles.name` (serif, `font-variant: small-caps`, size `calc(var(--font-size-base) * var(--header-scale))`). Optional `personalInfo.title` → centered italic `styles.tagline`. `personalInfo.location` → centered `styles.locationLine`. Contact row centered using the same `renderContactDetail` helper as `resume-modern.tsx` but separated by a middle dot, icons gated on `showContactIcons`.
- Section header: `<h3 className={styles.sectionTitle}>` (Title-Case, serif, bold, full-width 1px rule).
- Experience/itemList entries (company-first):
- Row 1: `flex justify-between``<span styles.entryPrimary>{company}</span>` (bold) · `<span styles.entryDates>{formatDateRange(years)}</span>` (bold).
- Row 2: `flex justify-between``<span styles.entrySecondary>{title}</span>` (italic) · `<span styles.entrySecondary>{location}</span>` (italic).
- Bullets: `<ul className={baseStyles['resume-list']}>` with `•&nbsp;` markers (reuse modern's list markup).
- Projects: name (bold) + tech/links; dates right; bullets. Education: institution (bold) · dates right; degree italic. Additional: bold `Category:` labels + comma-joined items (reuse modern's `AdditionalSection`, swapping the title class for `styles.sectionTitle`). Custom sections via a `DynamicResumeSectionLatex` wrapper (copy modern's pattern, use `styles.sectionTitle`).
**CSS spec (`latex.module.css`):**
```css
@import './_tokens.css';
/* LaTeX template — single-typeface serif, driven by --header-font */
.container { width: 100%; }
.container,
.container :global(p),
.container :global(li),
.container :global(span) {
font-family: var(--header-font);
}
.name {
font-family: var(--header-font);
font-size: calc(var(--font-size-base) * var(--header-scale));
font-weight: 700;
font-variant: small-caps;
letter-spacing: 0.02em;
color: var(--resume-text-primary);
}
.tagline {
font-family: var(--header-font);
font-style: italic;
font-size: calc(var(--font-size-base) * 1.05);
color: var(--resume-text-body);
}
.locationLine {
font-family: var(--header-font);
font-size: var(--font-size-base);
color: var(--resume-text-body);
}
.sectionTitle {
font-family: var(--header-font);
font-size: calc(var(--font-size-base) * var(--section-header-scale));
font-weight: 700;
text-transform: none; /* Title-Case, override base uppercase */
letter-spacing: 0;
color: var(--resume-text-primary);
border-bottom: 1px solid var(--resume-text-primary);
padding-bottom: 0.1rem;
margin-bottom: var(--item-gap);
break-after: avoid;
page-break-after: avoid;
orphans: 3;
widows: 3;
}
.entryPrimary { font-weight: 700; color: var(--resume-text-primary); }
.entryDates { font-weight: 700; color: var(--resume-text-primary); white-space: nowrap; }
.entrySecondary { font-style: italic; color: var(--resume-text-body); }
@media print {
.sectionTitle { break-after: avoid !important; page-break-after: avoid !important; }
}
```
- [ ] **Step 1: Write the failing test**`apps/frontend/tests/resume-latex.test.tsx`
```tsx
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ResumeLatex } from '@/components/resume/resume-latex';
import type { ResumeData } from '@/components/dashboard/resume-component';
vi.mock('@/lib/i18n', () => ({ useTranslations: () => ({ t: (k: string) => k }) }));
const data: ResumeData = {
personalInfo: { name: 'Saurabh Rai', location: 'Delhi, India', email: 'a@b.com' },
workExperience: [
{ id: 1, title: 'DevRel Engineer', company: 'Apideck', location: 'Remote', years: '2025-Present', description: ['Lead client demos.'] },
],
additional: { technicalSkills: ['Python', 'TypeScript'] },
} as ResumeData;
describe('ResumeLatex', () => {
it('renders name, company, title and a bullet', () => {
render(<ResumeLatex data={data} />);
expect(screen.getByText('Saurabh Rai')).toBeInTheDocument();
expect(screen.getByText('Apideck')).toBeInTheDocument();
expect(screen.getByText('DevRel Engineer')).toBeInTheDocument();
expect(screen.getByText('Lead client demos.')).toBeInTheDocument();
});
});
```
- [ ] **Step 2: Run test, expect FAIL**`npm run test -- resume-latex` → FAIL (module not found).
- [ ] **Step 3: Create `resume-latex.tsx` + `latex.module.css`** per the specs above.
- [ ] **Step 4: Export** — add to `components/resume/index.ts`: `export { ResumeLatex } from './resume-latex';`
- [ ] **Step 5: Dispatcher branch** — in `resume-component.tsx` import `ResumeLatex` and add:
```tsx
{mergedSettings.template === 'latex' && (
<ResumeLatex
data={resumeData}
showContactIcons={mergedSettings.showContactIcons}
additionalSectionLabels={additionalSectionLabels}
/>
)}
```
- [ ] **Step 6: Thumbnail** — in `template-selector.tsx` add a `if (type === 'latex')` branch returning a single-column thumbnail with a centered name bar, a serif-feel ruled header line, and stacked content lines (reuse swiss-single thumbnail markup; add a centered top bar + an underline rule under each section line via `border-b`).
- [ ] **Step 7: Run test, expect PASS**`npm run test -- resume-latex`.
- [ ] **Step 8: Commit**
```bash
git add apps/frontend/components/resume/resume-latex.tsx apps/frontend/components/resume/styles/latex.module.css apps/frontend/components/resume/index.ts apps/frontend/components/dashboard/resume-component.tsx apps/frontend/components/builder/template-selector.tsx apps/frontend/tests/resume-latex.test.tsx
git commit -m "feat(templates): add LaTeX single-column serif template"
```
---
## Task 3: Clean template (single-column minimal sans)
**Files:**
- Create: `apps/frontend/components/resume/resume-clean.tsx`
- Create: `apps/frontend/components/resume/styles/clean.module.css`
- Modify: `apps/frontend/components/resume/index.ts`
- Modify: `apps/frontend/components/dashboard/resume-component.tsx`
- Modify: `apps/frontend/components/builder/template-selector.tsx`
- Test: `apps/frontend/tests/resume-clean.test.tsx`
**Component spec (adapt from `resume-latex.tsx`, single-typeface sans):**
- Header centered: `<h1 className={styles.name}>` (light weight, sans). Contact rendered as one `|`-separated line via a helper that joins the present `renderContactDetail` spans with `<span className={styles.sep}> | </span>`; icons gated on `showContactIcons`.
- Section header `styles.sectionTitle` (large, UPPERCASE, gray, letter-spaced, thin rule).
- Entries single-line: `<div className="flex justify-between">` → left: `<span styles.entryCompany>{company}</span>` (bold) + `<span styles.sep> | </span>` + `<span styles.entryRole>{title}</span>` (small-caps gray); right: `<span styles.entryMeta>{location} | {formatDateRange(years)}</span>`. Bullets below.
- Education/Projects analogous. Additional: `**Label:** items`.
**CSS spec (`clean.module.css`):**
```css
@import './_tokens.css';
/* Clean template — single-typeface sans, driven by --body-font */
.container { width: 100%; }
.container,
.container :global(p),
.container :global(li),
.container :global(span),
.container :global(h1),
.container :global(h3) {
font-family: var(--body-font);
}
.name {
font-size: calc(var(--font-size-base) * var(--header-scale));
font-weight: 400;
letter-spacing: 0.01em;
color: var(--resume-text-primary);
}
.sep { color: var(--resume-text-tertiary); padding: 0 0.4em; }
.sectionTitle {
font-size: calc(var(--font-size-base) * var(--section-header-scale) * 1.15);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--resume-text-tertiary);
border-bottom: 1px solid var(--resume-border-secondary);
padding-bottom: 0.15rem;
margin-bottom: var(--item-gap);
break-after: avoid;
page-break-after: avoid;
orphans: 3;
widows: 3;
}
.entryCompany { font-weight: 700; color: var(--resume-text-primary); }
.entryRole { font-variant: small-caps; color: var(--resume-text-tertiary); }
.entryMeta { color: var(--resume-text-tertiary); white-space: nowrap; }
@media print {
.sectionTitle { break-after: avoid !important; page-break-after: avoid !important; }
}
```
- [ ] **Step 1: Write the failing test**`apps/frontend/tests/resume-clean.test.tsx` (same shape as Task 2 test, importing `ResumeClean`, asserting name/company/title/bullet render).
- [ ] **Step 2: Run test, expect FAIL.**
- [ ] **Step 3: Create `resume-clean.tsx` + `clean.module.css`.**
- [ ] **Step 4: Export** from `index.ts`: `export { ResumeClean } from './resume-clean';`
- [ ] **Step 5: Dispatcher branch** for `'clean'` in `resume-component.tsx` (same shape as Task 2 Step 5).
- [ ] **Step 6: Thumbnail**`if (type === 'clean')` branch: centered light name bar + large gray uppercase header lines (use `opacity-60` gray bars + thin rule).
- [ ] **Step 7: Run test, expect PASS.**
- [ ] **Step 8: Commit**
```bash
git add apps/frontend/components/resume/resume-clean.tsx apps/frontend/components/resume/styles/clean.module.css apps/frontend/components/resume/index.ts apps/frontend/components/dashboard/resume-component.tsx apps/frontend/components/builder/template-selector.tsx apps/frontend/tests/resume-clean.test.tsx
git commit -m "feat(templates): add Clean single-column minimal template"
```
---
## Task 4: Vivid template (two-column colorful)
**Files:**
- Create: `apps/frontend/components/resume/resume-vivid.tsx`
- Create: `apps/frontend/components/resume/styles/vivid.module.css`
- Modify: `apps/frontend/components/resume/index.ts`
- Modify: `apps/frontend/components/dashboard/resume-component.tsx`
- Modify: `apps/frontend/components/builder/template-selector.tsx`
- Test: `apps/frontend/tests/resume-vivid.test.tsx`
**Component spec (adapt from `resume-modern-two-column.tsx`):**
- Reuse the column-split logic, `getSortedSections`/`getSectionMeta`, `sectionHeadings`, `fallbackLabels`, and grid (`styles.grid` / `styles.mainColumn` / `styles.sidebarColumn`).
- Header (full width, left-aligned, above grid): two-tone name — split `personalInfo.name` on first space: `<span className={styles.nameFirst}>{first}</span>` + `<span className={styles.nameRest}>{rest}</span>`. `personalInfo.title``<div className={styles.titleLine}>` (monospace). Contact row: monospace, each item wrapped `<span className={styles.contactChip}>` with a circular-icon wrapper `<span className={styles.iconCircle}>{icon}</span>` shown when `showContactIcons`, else text only.
- Section titles use `styles.sectionTitle` (main) and `styles.sectionTitleSm` (sidebar): accent color, `font-variant: small-caps`, bold.
- Bullets: replace `•&nbsp;` markers with `<span className={styles.arrow}>➜&nbsp;</span>` (accent color). Apply in main-column experience/projects and any bulleted lists.
- Sidebar skills: render each group as bold label + `•`-joined wrapping list (reuse modern-two-column sidebar markup; recolor label via default text).
**CSS spec (`vivid.module.css`):** copy the grid + responsive widths from `modern-two-column.module.css`, then:
```css
@import './_tokens.css';
.container { width: 100%; }
.grid { display: grid; grid-template-columns: 63% 37%; gap: calc(var(--section-gap) * 1.25); }
.mainColumn { min-width: 0; }
.sidebarColumn { min-width: 0; }
.nameFirst {
font-size: calc(var(--font-size-base) * var(--header-scale) * 1.2);
font-weight: 800;
color: var(--resume-accent-primary);
font-family: var(--body-font);
}
.nameRest {
font-size: calc(var(--font-size-base) * var(--header-scale) * 1.2);
font-weight: 400;
color: var(--resume-accent-primary);
opacity: 0.6;
font-family: var(--body-font);
}
.titleLine {
font-family: var(--resume-font-mono);
font-size: calc(var(--font-size-base) * 1.05);
color: var(--resume-text-tertiary);
margin-top: 0.1rem;
}
.contactChip {
font-family: var(--resume-font-mono);
font-size: calc(var(--font-size-base) * 0.8);
color: var(--resume-text-body);
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.iconCircle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border: 1px solid var(--resume-border-primary);
border-radius: 9999px;
color: var(--resume-text-tertiary);
}
.sectionTitle {
font-family: var(--header-font);
font-size: calc(var(--font-size-base) * var(--section-header-scale) * 1.1);
font-weight: 700;
font-variant: small-caps;
letter-spacing: 0.03em;
color: var(--resume-accent-primary);
margin-bottom: var(--item-gap);
break-after: avoid;
page-break-after: avoid;
}
.sectionTitleSm { font-family: var(--header-font); font-size: calc(var(--font-size-base) * var(--section-header-scale) * 0.95); font-weight: 700; font-variant: small-caps; color: var(--resume-accent-primary); margin-bottom: var(--item-gap); break-after: avoid; page-break-after: avoid; }
.arrow { color: var(--resume-accent-primary); font-weight: 700; flex-shrink: 0; }
@media print {
.nameFirst, .nameRest, .sectionTitle, .sectionTitleSm, .arrow {
color: var(--resume-accent-primary) !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
.sectionTitle, .sectionTitleSm { break-after: avoid !important; page-break-after: avoid !important; }
}
```
- [ ] **Step 1: Write the failing test**`apps/frontend/tests/resume-vivid.test.tsx`
```tsx
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ResumeVivid } from '@/components/resume/resume-vivid';
import type { ResumeData } from '@/components/dashboard/resume-component';
vi.mock('@/lib/i18n', () => ({ useTranslations: () => ({ t: (k: string) => k }) }));
const data: ResumeData = {
personalInfo: { name: 'Saurabh Rai', title: 'Solutions Architect', email: 'a@b.com' },
workExperience: [
{ id: 1, title: 'DevRel Engineer', company: 'Apideck', years: '2025-Present', description: ['Lead client demos.'] },
],
additional: { technicalSkills: ['Python'] },
} as ResumeData;
describe('ResumeVivid', () => {
it('renders a two-tone name, company and bullet', () => {
render(<ResumeVivid data={data} />);
expect(screen.getByText('Saurabh')).toBeInTheDocument(); // first token only
expect(screen.getByText('Rai')).toBeInTheDocument(); // remaining tokens
expect(screen.getByText('Apideck')).toBeInTheDocument();
expect(screen.getByText('Lead client demos.')).toBeInTheDocument();
});
});
```
- [ ] **Step 2: Run test, expect FAIL.**
- [ ] **Step 3: Create `resume-vivid.tsx` + `vivid.module.css`.**
- [ ] **Step 4: Export** from `index.ts`: `export { ResumeVivid } from './resume-vivid';`
- [ ] **Step 5: Dispatcher branch** for `'vivid'` in `resume-component.tsx`, passing `sectionHeadings` and `fallbackLabels` like `modern-two-column`.
- [ ] **Step 6: Thumbnail**`if (type === 'vivid')` branch: two-column thumbnail with an accent top bar (two-tone), accent section header bars, and accent arrow-tick marks on left column lines (reuse `modern-two-column` thumbnail markup, recolor to accent).
- [ ] **Step 7: Run test, expect PASS.**
- [ ] **Step 8: Commit**
```bash
git add apps/frontend/components/resume/resume-vivid.tsx apps/frontend/components/resume/styles/vivid.module.css apps/frontend/components/resume/index.ts apps/frontend/components/dashboard/resume-component.tsx apps/frontend/components/builder/template-selector.tsx apps/frontend/tests/resume-vivid.test.tsx
git commit -m "feat(templates): add Vivid two-column colorful template"
```
---
## Task 5: parseTemplate test + docs
**Files:**
- Test: `apps/frontend/tests/print-route-parse.test.ts` (only if `parseTemplate` is exported; otherwise fold the allow-list assertion into `template-registration.test.ts` by re-testing `TEMPLATE_OPTIONS` ids — see Step 1)
- Modify: `docs/agent/design/template-system.md`
- Modify: `docs/agent/features/resume-templates.md`
- [ ] **Step 1: parseTemplate coverage.** `parseTemplate` is a module-local function in `page.tsx` (not exported). Do NOT export server-route internals just for a test. Instead assert the allow-list intent at the type/options layer: confirm `template-registration.test.ts` (Task 1) already pins the seven IDs, which is the source of truth `parseTemplate` mirrors. Add a comment in `parseTemplate` referencing `TEMPLATE_OPTIONS` so the two stay in sync. No new test file.
- [ ] **Step 2: Update docs tables.** In `docs/agent/design/template-system.md` and `docs/agent/features/resume-templates.md`, add `latex`, `clean`, `vivid` rows to the template tables with one-line descriptions matching `TEMPLATE_OPTIONS`. Note `vivid` supports the accent-color control.
- [ ] **Step 3: Commit**
```bash
git add "apps/frontend/app/print/resumes/[id]/page.tsx" docs/agent/design/template-system.md docs/agent/features/resume-templates.md
git commit -m "docs(templates): document latex, clean, vivid templates"
```
---
## Final Verification (maintainer runs — npm avoided in agent shell)
```bash
cd apps/frontend
npm run test # all suites incl. template-registration + 3 component smoke tests
npm run lint
npm run build
```
Then visually confirm in the builder: select LaTeX / Clean / Vivid, verify live preview matches the reference images, toggle Contact Icons, change accent color (Vivid), and export a PDF via the print route for each.
## Self-Review
- **Spec coverage:** types/options (T1), 3 components+CSS (T2T4), dispatcher/thumbnail/labels/footer/accent/print-allow-list (T1+T2T4), i18n×5 (T1), backend docstring (T1), tests (T1T4), docs (T5). All spec sections mapped. ✓
- **Placeholders:** wiring/test/CSS code is inline and complete; component bodies are specified as precise deltas from named existing files (`resume-modern.tsx`, `resume-modern-two-column.tsx`) — acceptable given they are direct structural analogues. ✓
- **Type consistency:** IDs `latex`/`clean`/`vivid`, components `ResumeLatex`/`ResumeClean`/`ResumeVivid`, classes `styles.sectionTitle`/`styles.name`/`styles.arrow`/`styles.nameFirst`/`styles.nameRest` referenced consistently across tasks. ✓
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,527 @@
# Diff-Based Resume Improvement — Design Spec
> **Status**: Design
> **Date**: 2026-03-23
> **Scope**: Backend improvement pipeline — `improver.py`, `refiner.py`, `resumes.py`, `templates.py`
---
## 1. Problem Statement
The current `improve_resume()` function sends the entire resume + job description + keywords + truthfulness rules + schema example to the LLM in a single prompt and asks for the **entire resume back as JSON**. This is ~40007500 input tokens and ~10002000 output tokens.
This single-prompt, full-output approach is the root cause of hallucination in the pipeline. The LLM must faithfully reproduce every field it doesn't want to change — company names, dates, bullet points, skills — and every reproduced field is a chance to hallucinate.
### 1.1 Hallucination vectors found in codebase
| # | Vector | Current mitigation | Gap |
|---|--------|-------------------|-----|
| 1 | **Dropped work/education/project entries** | None | **Undetected** — silently lost |
| 2 | **Fabricated skills** | `validate_master_alignment()` in refiner removes them *after* | Reactive, not preventive |
| 3 | **Renamed companies/institutions** | `validate_master_alignment()` checks new companies | Only catches *new* companies, not renames of existing ones |
| 4 | **Invented metrics** ("improved by 40%") | Nothing | **Undetected** — passes all checks |
| 5 | **Dates truncated** ("Jan 2023 - Mar 2024" → "2023 - 2024") | 3 safety nets restore them | Works, but wastes LLM output tokens reproducing dates |
| 6 | **personalInfo in output** | `_preserve_personal_info()` overwrites it | Works, but wastes LLM tokens outputting content it shouldn't |
| 7 | **Over-elaboration** (word count doubles) | Nothing | **Undetected** |
| 8 | **AI phrasing** ("spearheaded", "leveraged") | `remove_ai_phrases()` replaces ~50 blacklisted terms *after* (~47 with mapped replacements) | Reactive — LLM generates them, then they're stripped |
| 9 | **Custom section fabrication** | `_protect_custom_sections()` trims *after* | Reactive |
| 10 | **Context bleed** (JD company names appearing in resume) | Nothing | **Undetected** |
### 1.2 Current safety nets (resumes.py lines 750763)
Six post-processing functions patch the LLM output:
1. `_preserve_personal_info()` — overwrites personalInfo from original
2. `_restore_original_dates()` — restores month-precision dates
3. `restore_dates_from_markdown()` — fallback date restoration from original PDF markdown
4. `_preserve_original_skills()` — appends any skills the LLM dropped
5. `_protect_custom_sections()` — trims hallucinated items, reverts fabricated descriptions
6. `refine_resume()` — 3-pass refinement (keyword injection, AI phrase removal, alignment validation)
All six exist because the LLM is asked to reproduce content it shouldn't touch. A diff-based approach eliminates that class of problem by construction.
### 1.3 Why not section-by-section parallel calls
We evaluated splitting into parallel per-section LLM calls (summary, experience, projects, skills). This was rejected because:
- **Ollama users**: Ollama serves one request at a time by default (`num_parallel=1`). Parallel calls queue sequentially, making it *slower* than one call (5 prompt ingestions vs 1).
- **Still full-output**: Each section call still regenerates full section text — hallucination risk within each section is unchanged.
- **Half-measure**: Reduces blast radius but doesn't change the fundamental approach.
---
## 2. Solution: Diff-Based Output
Instead of asking the LLM to output the entire resume, ask it to output only what it wants to **change**. The original resume is preserved programmatically, and changes are applied as targeted diffs.
### 2.1 Architecture
```
extract_keywords ──→ generate_diffs ──→ apply_diffs ──→ verify ──→ refiner ──→ aux content
LLM #1 LLM #2 (local) (local) LLM #3 LLM #4-6
```
- **`generate_diffs()`** — new LLM call that returns a list of targeted changes
- **`apply_diffs()`** — local function that applies verified changes to the original resume
- **`verify_diff_result()`** — local quality checks on the result
- **Refiner** — unchanged, receives the full dict produced by `apply_diffs()`
- **Frontend response** — unchanged (`resume_preview`, `diff_summary`, `detailed_changes`)
### 2.2 What each hallucination vector looks like after this change
| # | Vector | How diff-based prevents it |
|---|--------|--------------------------|
| 1 | Dropped entries | **Eliminated** — original structure is the base, entries can't be dropped |
| 2 | Fabricated skills | **Catchable** — skill changes are explicit diffs, verified against JD keywords |
| 3 | Renamed companies | **Eliminated** — company/institution fields are blocked in applier |
| 4 | Invented metrics | **Catchable** — verifier flags new numbers in diffs that weren't in original |
| 5 | Dates truncated | **Eliminated** — date fields are blocked in applier |
| 6 | personalInfo leak | **Eliminated** — personalInfo is blocked in applier |
| 7 | Over-elaboration | **Reduced** — only changed text can grow; unchanged text is preserved exactly |
| 8 | AI phrasing | **Reduced** — smaller output surface area; AI phrase removal still runs after |
| 9 | Custom section fabrication | **Eliminated** — custom sections are blocked in applier |
| 10 | Context bleed | **Catchable** — each diff is inspectable; verifier can check for JD company names |
---
## 3. Diff Schema
### 3.1 `ResumeChange` — a single targeted change
```python
class ResumeChange(BaseModel):
"""A single change the LLM wants to make to the resume."""
path: str
action: Literal["replace", "append", "reorder"]
original: str | None = None
value: str | list[str]
reason: str
```
| Field | Purpose |
|-------|---------|
| `path` | Dot + bracket path to the field: `"workExperience[0].description[1]"` |
| `action` | `"replace"` (swap content), `"append"` (add to list), `"reorder"` (reorder list) |
| `original` | The current text at that path — LLM echoes it so the applier can verify |
| `value` | The new content (string or list of strings for reorder) |
| `reason` | Why this change helps match the JD — forces LLM self-justification |
### 3.2 `ImproveDiffResult` — full LLM output
```python
class ImproveDiffResult(BaseModel):
"""What the LLM returns instead of a full resume."""
changes: list[ResumeChange]
strategy_notes: str
```
### 3.3 Allowed paths
The applier enforces a whitelist of paths the LLM can target:
| Path pattern | What it modifies | Action types |
|---|---|---|
| `summary` | The summary text | replace |
| `workExperience[i].description[j]` | A specific bullet point | replace |
| `workExperience[i].description` | The bullet list (append new bullet) | append |
| `personalProjects[i].description[j]` | A specific project bullet | replace |
| `personalProjects[i].description` | The bullet list (append new bullet) | append |
| `additional.technicalSkills` | Skills list ordering | reorder |
### 3.4 Blocked paths
These paths are rejected by the applier regardless of what the LLM outputs:
| Path pattern | Why blocked |
|---|---|
| `personalInfo.*` | Always preserved from original |
| `*.years` | Dates are immutable |
| `*.company`, `*.institution` | Identity fields |
| `*.title` (workExperience), `*.degree` | Identity fields |
| `*.name` (personalProjects) | Identity fields |
| `*.role`, `*.github`, `*.website` (personalProjects) | Identity/metadata fields — not content to tailor |
| `*.location` | Identity fields |
| `customSections.*` | Protected separately |
| `education[*].*` | Rarely relevant to tailoring. Note: `education[*].description` could benefit from coursework rephrasing in some cases — this is a deliberate design choice to keep education immutable for now. Can be revisited if users request it. |
---
## 4. Diff Applier
### 4.1 Function signature
```python
def apply_diffs(
original: dict[str, Any],
changes: list[ResumeChange],
) -> tuple[dict[str, Any], list[ResumeChange], list[ResumeChange]]:
"""Apply verified diffs to original resume.
Args:
original: The original resume data (ResumeData-compatible dict)
changes: List of changes from the LLM
Returns:
(result_dict, applied_changes, rejected_changes)
"""
```
### 4.2 Verification before applying each change
Each change goes through 4 gates before being applied:
1. **Path exists**`workExperience[0].description[1]` must resolve to a real value in the original dict. If the LLM references an index that doesn't exist, the change is rejected.
2. **Path is allowed** — checked against the whitelist (Section 3.3). If the path matches a blocked pattern (Section 3.4), rejected.
3. **Original text matches** — the `original` field in the diff is compared (case-insensitive, stripped) against the actual value at that path. If they don't match, the LLM hallucinated the original content → rejected.
4. **No identity mutation** — even if the path is technically allowed, the applier checks that company names, titles, institutions, and degrees are unchanged in the result.
### 4.3 Change application
Changes that pass all 4 gates are applied to a `copy.deepcopy()` of the original:
- **`replace`**: Set the value at the resolved path
- **`append`**: Append the value to the list at the resolved path
- **`reorder`**: Validate that the reordered list contains exactly the same items (case-insensitive), then replace
### 4.4 Rejected changes
Changes that fail any gate are **rejected individually** (not all-or-nothing). The applier returns both the applied and rejected lists. Rejected changes generate warnings that flow through to the frontend via `response_warnings`.
---
## 5. Diff Verifier
After `apply_diffs()` produces the result, a local verifier checks for quality issues.
### 5.1 Function signature
```python
def verify_diff_result(
original: dict[str, Any],
result: dict[str, Any],
applied_changes: list[ResumeChange],
job_keywords: dict[str, Any],
) -> list[str]:
"""Local quality checks on the diff result. Returns list of warnings."""
```
### 5.2 Checks
| # | Check | What it catches | Severity |
|---|-------|-----------------|----------|
| 1 | Section counts preserved | Same number of work entries, education, projects in original vs result | Warning |
| 2 | Identity fields unchanged | Company names, titles, institutions, degrees match original exactly | Warning |
| 3 | Word count ratio | Total description word count didn't exceed 1.8x original | Warning |
| 4 | Skills only from JD or original | Any new skill must exist in `job_keywords` or original resume | Warning |
| 5 | No invented metrics | If a `replace` diff adds a number pattern (`\d+%`, `\d+x`, `\$\d+`) that wasn't in the original bullet at that path | Warning |
| 6 | No empty result | If zero changes were applied, warn (may indicate prompt failure) | Warning |
All checks are local (zero LLM cost). Warnings are informational — they don't block the response. They flow through to `response_warnings` in the API response.
---
## 6. Prompt Template
### 6.1 New prompt: `DIFF_IMPROVE_PROMPT`
```
Given this resume and job description, output a JSON object with targeted changes to better align the resume with the job.
RULES:
1. Only modify content — never change names, companies, dates, institutions, or degrees
2. Do not invent skills, metrics, or achievements not supported by the original resume text
3. Do not add new work entries, education entries, or project entries
4. You may: rephrase existing bullets, add new bullets to existing entries, adjust summary, reorder skills
5. Each change MUST include the original text (copied exactly) so it can be verified
6. For each change, explain WHY it helps match the job description
7. Generate all new text in {output_language}
8. Do not use em dash characters
9. Keep changes minimal and targeted — do not rewrite content that already aligns well
Keywords to emphasize (only if already supported by resume content):
{job_keywords}
Job Description:
{job_description}
Original Resume:
{original_resume}
Output this exact JSON format, nothing else:
{{
"changes": [
{{
"path": "workExperience[0].description[1]",
"action": "replace",
"original": "the exact original text at this path",
"value": "the improved text",
"reason": "why this change helps"
}},
{{
"path": "summary",
"action": "replace",
"original": "the current summary text",
"value": "the improved summary",
"reason": "why this change helps"
}},
{{
"path": "additional.technicalSkills",
"action": "reorder",
"original": null,
"value": ["most relevant skill first", "then next", "..."],
"reason": "reordered to prioritize JD-relevant skills"
}}
],
"strategy_notes": "brief summary of the tailoring approach"
}}
```
### 6.2 Token budget comparison
| Component | Current prompt | Diff prompt | Savings |
|-----------|---------------|-------------|---------|
| Instructions + rules | ~400 tokens | ~250 tokens | ~150 |
| Truthfulness rules (9 rules) | ~400 tokens | 0 (rules are structural) | ~400 |
| Schema example (IMPROVE_SCHEMA_EXAMPLE) | ~450 tokens | ~150 (diff example) | ~300 |
| Job description | same | same | 0 |
| Keywords | same | same | 0 |
| Original resume | same | same | 0 |
| **Output tokens** | ~10002000 (full resume) | ~300800 (changes only) | **~7001200** |
| **Total savings** | — | — | **~15502050 tokens** |
### 6.3 Prompt selection: strategy-aware
The diff prompt replaces all 3 current prompts (nudge, keywords, full). Strategy is controlled by instruction intensity within the same prompt:
```python
DIFF_STRATEGY_INSTRUCTIONS = {
"nudge": "Make minimal edits. Only rephrase where there is a clear match. Do not add new bullet points.",
"keywords": "Weave in relevant keywords where evidence already exists. You may rephrase bullets but do not add new ones.",
"full": "Make targeted adjustments. You may rephrase bullets and add new ones that elaborate on existing work, but do not invent new responsibilities.",
}
```
This replaces the 3 separate prompt templates (`IMPROVE_RESUME_PROMPT_NUDGE`, `_KEYWORDS`, `_FULL`) and the 3 separate truthfulness rule variants (`CRITICAL_TRUTHFULNESS_RULES`).
---
## 7. Integration with Existing Pipeline
### 7.1 What stays the same
| Component | File | Change? |
|-----------|------|---------|
| `extract_job_keywords()` | `improver.py` | No change |
| `refine_resume()` | `refiner.py` | No change — receives full dict from `apply_diffs()` |
| `analyze_keyword_gaps()` | `refiner.py` | No change |
| `inject_keywords()` | `refiner.py` | No change |
| `remove_ai_phrases()` | `refiner.py` | No change |
| `validate_master_alignment()` | `refiner.py` | No change |
| `calculate_resume_diff()` | `improver.py` | No change — compares original vs final |
| `generate_improvements()` | `improver.py` | No change |
| Auxiliary content generation | `cover_letter.py` | No change |
| Frontend response format | `schemas/models.py` | No change — `resume_preview`, `diff_summary`, `detailed_changes` populated as before |
| Preview → confirm hash validation | `resumes.py` | No change |
| `complete_json()` | `llm.py` | No change — diff output is valid JSON |
### 7.2 What changes
| Component | File | Change |
|-----------|------|--------|
| `improve_resume()` | `improver.py` | Replaced by `generate_resume_diffs()` |
| New: `apply_diffs()` | `improver.py` | New function — applies verified diffs to original |
| New: `verify_diff_result()` | `improver.py` | New function — local quality checks |
| New: `ResumeChange`, `ImproveDiffResult` | `schemas/models.py` | New Pydantic models for diff schema |
| New: `DIFF_IMPROVE_PROMPT` | `prompts/templates.py` | New prompt template |
| New: `DIFF_STRATEGY_INSTRUCTIONS` | `prompts/templates.py` | Per-strategy instruction variants |
| `_improve_preview_flow()` | `routers/resumes.py` | Orchestrates: generate_diffs → apply → verify → refine |
### 7.3 Safety nets after the change
| Safety net | Still needed? | Why |
|-----------|--------------|-----|
| `_preserve_personal_info()` | **Keep as fallback** — should never activate (applier blocks personalInfo) |
| `_restore_original_dates()` | **Keep as fallback** — should never activate (applier blocks dates) |
| `restore_dates_from_markdown()` | **Keep as fallback** — should never activate |
| `_preserve_original_skills()` | **Keep as fallback**`reorder` action preserves all items |
| `_protect_custom_sections()` | **Keep as fallback** — applier blocks customSections |
| `refine_resume()` | **Keep, unchanged** — alignment validation, keyword injection, AI phrase removal still valuable |
Defense in depth: the applier is the primary guard, safety nets are the secondary guard. If a bug in the applier lets something through, the safety nets catch it.
### 7.4 Updated flow in `_improve_preview_flow()`
```python
# Current (lines 739-746 in resumes.py):
improved_data = await improve_resume(
original_resume=resume["content"],
job_description=job["content"],
job_keywords=job_keywords,
language=language,
prompt_id=prompt_id,
original_resume_data=original_resume_data,
)
# New:
diff_result = await generate_resume_diffs(
original_resume=resume["content"],
job_description=job["content"],
job_keywords=job_keywords,
language=language,
prompt_id=prompt_id,
original_resume_data=original_resume_data,
)
improved_data, applied, rejected = apply_diffs(
original=original_resume_data,
changes=diff_result.changes,
)
warnings = verify_diff_result(
original=original_resume_data,
result=improved_data,
applied_changes=applied,
job_keywords=job_keywords,
)
response_warnings.extend(warnings)
if rejected:
response_warnings.append(
f"{len(rejected)} change(s) rejected during verification"
)
```
Everything downstream (`_preserve_personal_info`, `_restore_original_dates`, `refine_resume`, `calculate_resume_diff`, etc.) remains unchanged — it receives `improved_data` as a full dict, same as today.
---
## 8. Retry Mechanism
### 8.1 Transport-level retries (unchanged)
`complete_json()` in `llm.py` handles:
- Malformed JSON → retry with hint
- Truncated output → retry with hint
- Empty response → retry
- Temperature escalation per retry (0.1 → 0.3 → 0.5 → 0.7)
This stays as-is. The diff JSON output works with all existing retry logic.
### 8.2 Content-level retries (implicit via rejection)
The diff approach makes explicit retry loops unnecessary:
- **Bad diff** (wrong path, mismatched original text) → **rejected by applier** → original content preserved
- **All diffs rejected** → result is the original resume unchanged → warning generated
- **Some diffs rejected** → partial improvement applied → warning lists what was rejected
This is safer than retrying with feedback, which risks compounding hallucination. The worst case is "no changes applied" rather than "wrong changes applied."
### 8.3 `_appears_truncated()` compatibility
The existing truncation detector in `llm.py` checks for empty `workExperience`, `education`, or `skills` arrays. Note: `skills` is a legacy key name — the actual schema uses `additional.technicalSkills`, so this check already doesn't catch all truncation cases in the current flow either.
A diff-based output won't contain these arrays at all — it contains a `changes` list. This means:
- `_appears_truncated()` won't trigger false positives (no empty resume arrays to check)
- Custom truncation detection: if `changes` is an empty list, the LLM may have failed to generate diffs. Log a warning but proceed (returns original resume unchanged).
---
## 9. Latency & Cost Impact
### 9.1 Per-call comparison
| Metric | Current (full output) | Diff-based | Change |
|--------|----------------------|------------|--------|
| Input tokens | ~40007500 | ~35006500 | -5001000 |
| Output tokens | ~10002000 | ~300800 | -7001200 |
| Total tokens | ~50009500 | ~38007300 | **-2530%** |
| LLM call count | 1 | 1 | Same |
| Wall-clock time | ~812s | ~48s | **~3040% faster** |
### 9.2 Ollama impact
Ollama's generation speed is the bottleneck (token/s on local hardware). Reducing output tokens by 5070% directly reduces generation time. This is the biggest win for local users.
### 9.3 Pipeline total
| Pipeline step | Current | After | Change |
|---|---|---|---|
| Extract keywords | ~3s | ~3s | Same |
| Improve/diffs | ~812s | ~48s | **Faster** |
| Apply diffs | — | <1ms | New (local) |
| Verify diffs | — | <1ms | New (local) |
| Safety nets | <1ms | <1ms | Same |
| Refine | ~38s | ~38s | Same |
| Aux content | ~510s | ~510s | Same |
| **Total** | **~1933s** | **~1529s** | **~1520% faster** |
---
## 10. File Change Summary
| # | File | Type | Description |
|---|------|------|-------------|
| 1 | `app/schemas/models.py` | Add | `ResumeChange` and `ImproveDiffResult` Pydantic models |
| 2 | `app/prompts/templates.py` | Add | `DIFF_IMPROVE_PROMPT` and `DIFF_STRATEGY_INSTRUCTIONS` |
| 3 | `app/prompts/__init__.py` | Modify | Export `DIFF_IMPROVE_PROMPT` and `DIFF_STRATEGY_INSTRUCTIONS` |
| 4 | `app/services/improver.py` | Add | `generate_resume_diffs()`, `apply_diffs()`, `verify_diff_result()` |
| 5 | `app/services/improver.py` | Keep | `improve_resume()` kept for backward compatibility / fallback |
| 6 | `app/routers/resumes.py` | Modify | `_improve_preview_flow()` calls new diff functions |
| 7 | `app/llm.py` | No change | `complete_json()` works as-is with diff JSON |
| 8 | `app/services/refiner.py` | No change | Receives full dict from `apply_diffs()` |
| 9 | `app/services/cover_letter.py` | No change | Receives full dict |
| 10 | `app/routers/enrichment.py` | No change | Separate feature |
| 11 | Frontend | No change | `resume_preview`, `diff_summary`, `detailed_changes` populated as before |
### 10.1 What we're NOT changing
- `llm.py` — transport-level retry and JSON extraction stay as-is
- `refiner.py` — all 3 passes (keyword injection, AI phrase removal, alignment validation) stay as-is
- Safety nets in `resumes.py` — kept as defense-in-depth fallback
- Frontend response format — `ImproveResumeResponse` unchanged
- Preview → confirm hash validation — unchanged
- No new dependencies (no LangChain, no workflow engine)
- `improve_resume()` preserved as fallback (not deleted)
---
## 11. Implementation Order
1. **Add schemas**`ResumeChange`, `ImproveDiffResult` in `schemas/models.py`
2. **Add prompt**`DIFF_IMPROVE_PROMPT`, `DIFF_STRATEGY_INSTRUCTIONS` in `prompts/templates.py`
3. **Add `generate_resume_diffs()`** — new LLM call function in `improver.py`
4. **Add `apply_diffs()`** — diff applier with path resolution and verification gates in `improver.py`
5. **Add `verify_diff_result()`** — local quality checks in `improver.py`
6. **Wire into `_improve_preview_flow()`** — update orchestration in `resumes.py`
7. **Test with existing frontend** — no frontend changes needed
8. **Keep `improve_resume()` as fallback** — can be selected via config flag during rollout
---
## 12. Risks & Mitigations
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| LLM outputs wrong path indices | Medium | Low | Applier verifies `original` text matches — wrong index = mismatch = rejected |
| LLM outputs empty changes list | Low | Low | Warning generated, original resume returned unchanged |
| LLM ignores diff format, outputs full resume | Low | Medium | `complete_json()` parses whatever JSON it returns; if no `changes` key, treat as 0 changes + warning |
| Smaller models struggle with diff format | Medium | Medium | Keep `improve_resume()` as fallback for models that can't follow diff instructions |
| `apply_diffs()` has a bug that corrupts data | Low | High | Safety nets still run after (defense in depth); `improve_resume()` fallback available |
| Diff verification rejects too aggressively | Medium | Low | Individual rejection (not all-or-nothing); user sees partial improvement rather than nothing |
---
## 13. Success Criteria
- [ ] Zero structural deviations (dropped entries, renamed companies) in diff-based output
- [ ] Invented metrics flagged by verifier in >90% of cases
- [ ] Token usage reduced by 25%+ vs current approach
- [ ] All existing frontend flows work without changes
- [ ] Safety nets rarely activate (tracked via logging)
- [ ] `npm run lint` passes
- [ ] Python functions have type hints
@@ -0,0 +1,36 @@
# Health Check Fix — Stop LLM Calls on Docker Liveness Probe
**Date:** 2026-04-10
**Issue:** [#746](https://github.com/srbhr/Resume-Matcher/issues/746)
**Approach:** A (Minimal)
## Problem
The Docker `HEALTHCHECK` pings `GET /api/v1/health` every 10 seconds. That endpoint calls `check_llm_health()`, which fires a real `litellm.acompletion()` request to the configured LLM provider. This burns ~8,640 billable API calls per day, costing ~$1.50/day on GPT-5.4 via OpenRouter.
## Fix
Make `/health` a zero-cost liveness check. The LLM connectivity check remains available via `/status` (user-initiated only).
### Changes
**`apps/backend/app/routers/health.py`**
- Remove `check_llm_health` import (keep `get_llm_config` import for `/status`)
- `/health` handler returns `HealthResponse(status="healthy")` with no LLM call
- `/status` handler unchanged — it still calls `check_llm_health()` for the settings UI
**`apps/backend/app/schemas/models.py`**
- `HealthResponse`: remove the `llm: dict[str, Any]` field, keep only `status: str`
### What stays the same
- Dockerfile `HEALTHCHECK` command and interval — no changes needed
- `/status` endpoint — still calls `check_llm_health()`, used by frontend settings page
- Frontend `StatusCacheProvider` and all `useStatusCache` consumers — unaffected
- `check_llm_health()` function in `llm.py` — untouched
## Verification
- `GET /health` returns `{"status": "healthy"}` without making any external calls
- `GET /status` still returns `llm_healthy: true/false` with a real LLM check
- Settings page still shows LLM health status correctly
@@ -0,0 +1,279 @@
# Custom prompts for cover letter & cold outreach
**Date:** 2026-04-17
**Issue:** #749
**Branch:** `dev` (bundle into PR with #754 and #751)
## Problem
`COVER_LETTER_PROMPT` and `OUTREACH_MESSAGE_PROMPT` are module constants in `apps/backend/app/prompts/templates.py`. Users want to customize length, style, and content (e.g., "150 words", "include company research", "bold specific keywords"). Currently the only way is to fork the repo.
The resume-generation prompt already supports customization — users pick between three built-in variants ("nudge", "keywords", "full") via the Settings "Prompt Profile" section. That pattern doesn't extend to cover letter / outreach, where users want free-form text override rather than a fixed menu.
## Goals
- User can override the default prompt text for cover letter and cold outreach, per feature.
- Empty / absent override = use default prompt (unchanged behavior).
- Custom prompts must inject the same placeholders as the defaults (`{job_description}`, `{resume_data}`, `{output_language}`); missing placeholders are rejected at save time with a 422.
- UI surfaces the default prompt as placeholder text and offers a "Reset to default" button.
- Resume-generation prompt (which already has variants) is NOT touched in this bucket — deferred.
## Non-goals
- Per-resume prompt overrides (the custom prompt is global).
- Custom prompts for enrichment, refinement, JD matching, or resume title generation.
- Prompt versioning or history.
- Prompt templating beyond the existing `{placeholder}` substitution.
## Design
### Backend
**1. Stored config fields (`config.json`)**
Two new optional top-level string fields:
```json
{
"cover_letter_prompt": "",
"outreach_message_prompt": ""
}
```
Empty string = "use default". Absent key = same as empty string.
**2. `Settings` class — intentionally NOT extended**
These overrides are per-deployment user data, not environment config. Leave `apps/backend/app/config.py` alone; load via `_load_stored_config()` at the usage site.
**3. Placeholder validation**
The defaults require `{job_description}` and `{resume_data}` (both are substituted by the services using `.format()`). `{output_language}` is also required. Add a helper in `apps/backend/app/prompts/__init__.py`:
```python
REQUIRED_PLACEHOLDERS = ("{job_description}", "{resume_data}", "{output_language}")
def validate_prompt_placeholders(prompt: str) -> list[str]:
"""Return the list of required placeholders missing from the prompt.
Empty list means the prompt is valid. Empty-string prompt returns [] (valid
as "use default" sentinel; validation only runs on non-empty strings in
the router).
"""
if not prompt:
return []
return [p for p in REQUIRED_PLACEHOLDERS if p not in prompt]
```
The `.format()` call at usage will also fail on missing placeholders, but catching earlier produces a clean 422 with a list of missing names instead of a 500.
**4. Service changes**
`apps/backend/app/services/cover_letter.py`:
```python
from app.llm import _load_stored_config # or a new public export
async def generate_cover_letter(...):
...
stored = _load_stored_config()
custom = (stored.get("cover_letter_prompt") or "").strip()
template = custom or COVER_LETTER_PROMPT
try:
prompt = template.format(
job_description=job_description,
resume_data=json.dumps(resume_data),
output_language=output_language,
)
except KeyError as e:
# Defensive: placeholder missing despite save-time validation.
logging.warning("Custom cover letter prompt missing %s, falling back", e)
prompt = COVER_LETTER_PROMPT.format(...)
...
```
Same shape for `generate_outreach_message`.
`_load_stored_config` in `llm.py` is currently a private helper. Either promote it to a non-underscored export (`load_stored_config`) or move it to `app/config.py` alongside `load_config_file` — the second is cleaner. This spec picks the move.
**5. New API endpoints**
Extend `apps/backend/app/routers/config.py`. The existing router has `/config/prompts` for resume-generation prompt selection. Add two new endpoints scoped to features to keep concerns isolated:
```
GET /api/v1/config/feature-prompts
→ { cover_letter_prompt, outreach_message_prompt,
cover_letter_default, outreach_message_default }
PUT /api/v1/config/feature-prompts
body: { cover_letter_prompt?, outreach_message_prompt? }
→ same schema as GET
```
The `_default` fields in the GET response let the frontend show the default as placeholder text without duplicating the string across languages.
Validation in the PUT:
```python
if request.cover_letter_prompt is not None:
prompt = request.cover_letter_prompt.strip()
if prompt:
missing = validate_prompt_placeholders(prompt)
if missing:
raise HTTPException(
status_code=422,
detail={
"code": "missing_placeholders",
"field": "cover_letter_prompt",
"missing": missing,
},
)
stored["cover_letter_prompt"] = prompt # "" clears
# identical block for outreach_message_prompt
```
**6. Schema models (`schemas/models.py`)**
```python
class FeaturePromptsRequest(BaseModel):
cover_letter_prompt: str | None = None
outreach_message_prompt: str | None = None
class FeaturePromptsResponse(BaseModel):
cover_letter_prompt: str
outreach_message_prompt: str
cover_letter_default: str
outreach_message_default: str
```
Note: `str | None = None` on the request distinguishes "no change" from "clear to empty".
### Frontend
**7. API client (`apps/frontend/lib/api/config.ts`)**
```ts
export interface FeaturePrompts {
cover_letter_prompt: string;
outreach_message_prompt: string;
cover_letter_default: string;
outreach_message_default: string;
}
export interface FeaturePromptsUpdate {
cover_letter_prompt?: string;
outreach_message_prompt?: string;
}
export async function fetchFeaturePrompts(): Promise<FeaturePrompts> {...}
export async function updateFeaturePrompts(update: FeaturePromptsUpdate): Promise<FeaturePrompts> {...}
```
**8. Settings UI**
New sub-section under the existing "Content Generation" block. For each of cover letter and outreach, only render the prompt textarea when the feature's toggle is ON (matches existing UX).
For each feature:
- `<label>` "Custom prompt (optional)"
- `<textarea rows={8} className="... font-mono text-xs ...">` — placeholder = default prompt
- Helper line below: "Must include: `{job_description}`, `{resume_data}`, `{output_language}`. Leave blank to use default."
- `<button>` "Reset to default" — clears the textarea AND calls PUT with empty string
- Inline 422 error message below textarea on failed save (lists missing placeholders)
**9. i18n**
New keys in all 5 locales:
```json
{
"settings": {
"contentGeneration": {
"customPromptLabel": "Custom prompt (optional)",
"customPromptHelp": "Must include: {job_description}, {resume_data}, {output_language}. Leave blank to use default.",
"customPromptResetButton": "Reset to default",
"customPromptErrorMissing": "Custom prompt is missing required placeholders: {missing}"
}
}
}
```
### Docs
**10. Feature doc**
Short addition to `docs/agent/features/enrichment.md` or a new `docs/agent/features/custom-prompts.md`: explain what can be customized, placeholder requirements, reset behavior.
## Data flow
```
User opens Settings → enables Cover Letter toggle
Settings renders textarea with default prompt as placeholder
User pastes custom prompt, clicks Save
PUT /api/v1/config/feature-prompts { cover_letter_prompt: "..." }
Router validates placeholders → 422 on missing OR 200 + persist
stored.cover_letter_prompt = "<user text>" OR "" (on clear)
Later: user runs Tailor → generates cover letter
generate_cover_letter() reads stored config → uses custom or default
.format() substitutes placeholders → LLM call → returns text
```
## Error handling
| Failure | Behavior |
|---|---|
| Empty prompt on save | Treated as "clear to default". 200 OK. |
| Prompt missing required placeholder | 422 with `code=missing_placeholders`, lists missing names. No state change. |
| Prompt with extra unknown placeholder | Passes save-time validation. At runtime `.format()` treats unknown braces as literals — harmless if single braces, crashes on `{foo}` style. Defensive try/except in service falls back to default + logs. |
| Stored prompt corrupted (disk edit) | Same defensive try/except. Falls back to default. |
## Files touched
| File | Change |
|---|---|
| `apps/backend/app/config.py` | Move `_load_stored_config` from `llm.py` (rename to public `load_stored_config`), add `save_stored_config` helper |
| `apps/backend/app/llm.py` | Replace private `_load_stored_config` with import of public helper |
| `apps/backend/app/prompts/__init__.py` | New `validate_prompt_placeholders` helper |
| `apps/backend/app/services/cover_letter.py` | Load custom prompt, fall back on error |
| `apps/backend/app/routers/config.py` | Two new endpoints |
| `apps/backend/app/schemas/models.py` | Two new schema classes |
| `apps/frontend/lib/api/config.ts` | New types + fetch/update functions |
| `apps/frontend/app/(default)/settings/page.tsx` | Two new textareas in Content Generation section |
| `apps/frontend/messages/{en,es,ja,zh,pt-BR}.json` | i18n strings |
| `docs/agent/features/custom-prompts.md` (new) | One-page feature doc |
Estimated ~12 files, ~300-400 LOC.
## Risks
- **`_load_stored_config` move breaks existing imports.** There's exactly one external caller inside `apps/backend/app/` — the new service changes. Search + update in the commit.
- **User pastes a very long custom prompt** that inflates token count dramatically. Out of scope to enforce token limits; the LLM-side `max_tokens` and provider limits are the backstop.
- **`.format()` treats `{` as a special character.** If users want a literal brace in their prompt (e.g., documenting JSON schema in-prompt), they need `{{` / `}}`. Noted in helper copy.
## Rollback
Pure additive. Revert:
- Removes the UI textareas (users see feature toggles only, as before).
- Removes the endpoints (404 on next frontend call — would need frontend revert too).
- Stored `cover_letter_prompt` / `outreach_message_prompt` fields in `config.json` become inert — ignored by the reverted service code.
Data is not deleted. Re-enabling the feature restores the stored custom prompts.
## Verification
Manual:
1. Enable Cover Letter toggle in Settings.
2. Leave custom prompt blank; run Tailor; confirm generated cover letter uses default length/style.
3. Paste a valid prompt including all three placeholders with an altered tone ("Write in Shakespearean English, 200 words"); save; run Tailor; confirm output follows custom instructions.
4. Paste a prompt missing `{resume_data}`; save fails with 422 and UI shows "missing placeholders: {resume_data}".
5. Click "Reset to default"; textarea clears; run Tailor; output matches default behavior.
6. Same flow for cold outreach.
@@ -0,0 +1,196 @@
# LiteLLM reasoning hardening + Settings reasoning UX
**Date:** 2026-04-17
**Issues:** #747 (primary), partial #751 (api_base stripping half)
**Branch:** `dev` (PR dev → main to follow)
## Problem
Three entangled pain points in the current LLM layer:
1. **gpt-5 connection tests fail.** `check_llm_health()` hardcodes `reasoning_effort="minimal"` for any model name containing `"gpt-5"` and uses `max_tokens=16`. Some gpt-5 variants reject `reasoning_effort=minimal`; others need a larger token budget. Both failure modes return HTTP 200 with `healthy=false`, which is confusing.
2. **Thinking models return "empty" content.** Models like DeepSeek-R1 and OpenAI o1 place their answer in `message.reasoning_content` or `message.thinking` rather than `message.content`. Current code treats this as an empty response and fails.
3. **OpenAI-compatible endpoints are broken.** `_normalize_api_base()` strips trailing `/v1` even when the user wants to target a custom OpenAI-compatible server (e.g. llama.cpp at `http://localhost:8080/v1`). The user-supplied URL becomes `http://localhost:8080` and the request 404s.
## Goals
- `check_llm_health()` succeeds for both reasoning and non-reasoning models out of the box.
- Reasoning-only responses are surfaced as valid content.
- Users can target any OpenAI-compatible endpoint by pasting the full base URL.
- `reasoning_effort` becomes a user-controllable setting, not a hardcoded policy.
- Existing gpt-5 users keep their current behavior without touching config.
## Non-goals
- Adding a new `"openai_compatible"` entry to the provider dropdown (tracked in PR #751 follow-up).
- Rewriting prompt templates for cover letter / cold email (tracked in PR #749 follow-up).
- Settings page error-card overflow styling (tracked in PR #754 follow-up).
- Per-model capability allowlists (obsoleted by `drop_params=True`).
## Design
### Backend
**1. Global LiteLLM params policy (`apps/backend/app/llm.py` module init)**
```python
litellm.drop_params = True
litellm.modify_params = True
```
- `drop_params` lets LiteLLM silently discard params the selected provider rejects (`reasoning_effort`, non-default `temperature`, etc.). Replaces every hardcoded compatibility branch we wrote.
- `modify_params` lets LiteLLM auto-drop `thinking_blocks` when tool-call assistant messages are missing them. Applied defensively; no current tool-call path uses thinking, but this future-proofs the Router.
**2. Remove hardcoded compatibility branches**
Delete `_get_reasoning_effort()` and `_supports_temperature()`. Their call sites (`complete`, `complete_json`, `check_llm_health`) stop invoking them. `temperature` is always passed; `reasoning_effort` is passed only when the user has configured it.
**3. New `reasoning_effort` setting**
`apps/backend/app/config.py`:
```python
reasoning_effort: Literal["minimal", "low", "medium", "high"] | None = None
```
Read from `REASONING_EFFORT` env var and from `config.json`. `None` means "do not send the param". `LLMConfig` gains the same field so callers pass it through.
**4. Health-check token bump**
`check_llm_health()`: `max_tokens=16``max_tokens=64`. Matches the issue author's proposed minimum.
**5. Thinking-model content fallback in `_extract_choice_text()`**
Extraction order:
1. `message.content` (existing)
2. `message.reasoning_content` (new — DeepSeek, OpenAI o1)
3. `message.thinking` (new — Anthropic extended thinking)
4. `<think>...</think>` tags inside `message.content` (existing, via `_strip_thinking_tags`)
Return the first non-empty. Unchanged if none match.
`complete()` and `check_llm_health()` stop treating "reasoning present but content empty" as unhealthy — if extraction returns text, it is content.
**6. `_normalize_api_base` — preserve `/v1` for OpenAI**
Current code strips `/v1` for `anthropic`, `gemini`, `openrouter`, `ollama` because LiteLLM's provider handlers for those re-append path segments. For `openai` the OpenAI client handles `/v1` correctly, so **stop stripping when `provider == "openai"`**. The llama.cpp-style case `http://localhost:8080/v1` now round-trips intact.
Keep stripping for the other four providers — that behavior is correct and covered by existing users.
**7. Expose `reasoning_effort` through config API**
`apps/backend/app/routers/config.py`: GET `/api/v1/config/llm` includes the field; PUT accepts it. `schemas/models.py` adds the field to the request/response models.
**8. Health-check response carries `reasoning_content`**
`check_llm_health(include_details=True)` adds a `reasoning_content` key (code-block-wrapped string, empty when absent) alongside `model_output`. Schema update in `schemas/models.py`.
### Frontend
**9. Reasoning-effort dropdown (`apps/frontend/app/(default)/settings/page.tsx` + `components/settings/api-key-menu.tsx`)**
New select labeled "Reasoning effort" under the model field. Options: **Auto** (sends nothing, default), Minimal, Low, Medium, High. Swiss-styled: `rounded-none`, 1px black border, hard shadow on focus, monospace for the options. Shown for all providers — with `drop_params=True` the backend will safely drop the param where unsupported, so restricting by provider is unnecessary and misleading.
**10. Model-thinking block in Test Connection result**
When `reasoning_content` is non-empty in the health-check response, render a second code block under the main output, labeled "Model thinking" in a smaller monospace caption. Collapsible by default (closed).
### Regression mitigation (auto-migration)
**11. One-shot silent migration in `get_llm_config()`**
Pseudocode:
```python
stored = _load_stored_config()
provider = stored.get("provider", settings.llm_provider)
model = stored.get("model", settings.llm_model)
if (
provider == "openai"
and "gpt-5" in model.lower()
and "reasoning_effort" not in stored # absent, not empty string
):
stored["reasoning_effort"] = "minimal"
save_config_file(stored)
logging.info(
"Migrated gpt-5 config to preserve reasoning_effort=minimal "
"(set REASONING_EFFORT= or clear in Settings to disable)"
)
```
Condition-gated on **absent key** rather than "empty or missing". Once a user explicitly clears the field (empty string persisted), the migration does not re-apply.
The migration runs lazily: first `get_llm_config()` call after process start on an affected config. No blocking startup hook.
### PR description callout
```markdown
### Behavior change for gpt-5 users
Previously, any model containing "gpt-5" silently received reasoning_effort=minimal.
That default has been removed. Existing gpt-5 configs are auto-migrated on first
load to preserve the "minimal" setting — you will see a one-line INFO log. To
disable, open Settings → LLM → Reasoning effort and pick "Auto", or set
REASONING_EFFORT= (empty) in .env.
```
## Data flow
```
.env / config.json
Settings (reasoning_effort=None|minimal|low|medium|high)
LLMConfig (forward through)
get_router() / check_llm_health()
kwargs["reasoning_effort"] only if set
litellm.acompletion ── drop_params=True ──► provider API
response.choices[0].message
_extract_choice_text: content → reasoning_content → thinking → <think>-tags
caller (complete/complete_json/check_llm_health)
```
## Error handling
| Failure | Before | After |
|---|---|---|
| `UnsupportedParamsError: reasoning_effort=minimal not supported` | Health-check fails | `drop_params` drops it, call succeeds |
| `max_tokens or model output limit reached` at 16 tokens | Health-check fails | Budget is 64, succeeds for reasoning-capable models |
| Reasoning-only response (empty content, populated reasoning_content) | `empty_content` unhealthy | Extracted as content, healthy |
| `api_base` with `/v1` for openai → llama.cpp | 404 (stripped) | 200 (preserved) |
| `api_base` with `/v1` for anthropic/gemini/openrouter/ollama | Works (stripped) | Works (still stripped) |
| Provider rejects `temperature != 1` | Raised before | Dropped by LiteLLM, call succeeds |
Unchanged: Router retry policy, timeout logic, JSON extraction, config file write semantics.
## Files touched
| File | Change |
|---|---|
| `apps/backend/app/llm.py` | Module init flags; delete 2 helpers; fallback extraction; api_base branch; health-check max_tokens |
| `apps/backend/app/config.py` | Add `reasoning_effort` setting |
| `apps/backend/app/routers/config.py` | Expose `reasoning_effort` in LLM config GET/PUT |
| `apps/backend/app/schemas/models.py` | Add `reasoning_effort` field + `reasoning_content` in health response |
| `apps/backend/.env.example` | Document `REASONING_EFFORT` |
| `apps/frontend/app/(default)/settings/page.tsx` | Reasoning-effort dropdown + Model thinking block |
| `apps/frontend/components/settings/api-key-menu.tsx` | Thread the new field through |
| `apps/frontend/messages/{en,es,ja,zh,pt-BR}.json` | i18n strings for new UI elements |
Estimated ~250 LOC net across ~8-9 files.
## Risks
- **`drop_params=True` is process-global.** Any future code path that relied on a provider surfacing "unsupported param" as an error will now fail silently. Acceptable — we don't have such a path today.
- **Thinking-model fallback can change output shape.** A deepseek-r1 response that previously returned "empty" now returns the reasoning content. Downstream JSON extraction (`complete_json`) is unaffected because `_strip_thinking_tags` already handles inline `<think>` and the fallback returns clean text.
- **Auto-migration writes user config on first call.** Single idempotent write. If the write fails (disk full, permission), we log-and-continue — the next call will retry.
## Rollback
Pure backward-compatible at the data level. Reverting the commit restores old behavior. Migrated `reasoning_effort="minimal"` in config.json is now honored by new code; if we revert, the hardcoded branch is restored and the stored value is harmlessly redundant.
@@ -0,0 +1,211 @@
# OpenAI-compatible provider entry
**Date:** 2026-04-17
**Issue:** #751 (completes the dropdown half; `/v1` preservation shipped with #747)
**Branch:** `dev` (bundle into PR with #754 and #749)
## Problem
Local OpenAI-compatible servers (llama.cpp, vLLM, LM Studio, Ollama's OpenAI endpoint, etc.) already work when the user selects `provider=openai` and sets `api_base` to their local URL — after the `_normalize_api_base` fix in #747. But that path is discoverable only by reading code: the Settings UI exposes six providers (openai, anthropic, openrouter, gemini, deepseek, ollama) and none is labeled "OpenAI-compatible". New users trying to connect llama.cpp pick "Ollama" (because it's local), misconfigure the base URL, and hit 404s.
## Goals
- Add an explicit `"openai_compatible"` provider option to the Settings UI with clear labeling for local servers.
- Route requests via LiteLLM's `openai/` prefix (the documented way to hit OpenAI-compatible endpoints).
- Keep the existing `provider=openai + custom api_base` path working — no silent migration, no forced switch.
- API keys for `openai_compatible` are stored under their own namespace so they don't leak into real OpenAI (and vice versa).
## Non-goals
- Auto-detection of the backend's capabilities (streaming, tools, etc.).
- A Settings "provider profile" abstraction — one provider, one config.
- Migrating existing users from `openai + api_base`.
## Design
### Backend
**1. Provider enum (`apps/backend/app/config.py`)**
```python
llm_provider: Literal[
"openai",
"openai_compatible",
"anthropic",
"openrouter",
"gemini",
"deepseek",
"ollama",
] = "openai"
```
**2. Provider key map (`apps/backend/app/llm.py`)**
Add an entry so stored keys are scoped separately:
```python
_PROVIDER_KEY_MAP: dict[str, str] = {
"openai": "openai",
"openai_compatible": "openai_compatible",
"anthropic": "anthropic",
"gemini": "google",
"openrouter": "openrouter",
"deepseek": "deepseek",
"ollama": "ollama",
}
```
**3. Model routing (`get_model_name` in `apps/backend/app/llm.py`)**
LiteLLM's documented way to hit an OpenAI-compatible endpoint is `model="openai/<model_name>"` + `api_base=<URL>`. So `openai_compatible` uses the same prefix as `openai`:
```python
provider_prefixes = {
"openai": "",
"openai_compatible": "openai/", # explicit — user's model names usually lack the prefix
"anthropic": "anthropic/",
...
}
```
And in the OpenRouter-style special-case block, extend the "already prefixed" check to recognize `openai/`:
```python
known_prefixes = [
"openrouter/", "anthropic/", "gemini/", "deepseek/",
"ollama/", "ollama_chat/", "openai/",
]
```
**4. URL normalization (`_normalize_api_base`)**
Preserve the URL just like `openai` does — no stripping:
```python
if provider in ("openai", "openai_compatible"):
return base or None
```
**5. API-key requirement**
Real `openai` requires a key. `openai_compatible` often does not (llama.cpp, LM Studio). But OpenAI's python client [requires](https://github.com/openai/openai-python/blob/main/README.md) some key to be set (it validates the string is non-empty). The health-check currently gates API-key presence with:
```python
if config.provider != "ollama" and not config.api_key:
return {... "error_code": "api_key_missing" ...}
```
Change to:
```python
if config.provider not in ("ollama", "openai_compatible") and not config.api_key:
...
```
And in the actual completion call, if `config.api_key` is empty for `openai_compatible`, pass a sentinel `"sk-no-key"` string to satisfy the OpenAI client's empty-string check without leaking a real credential.
### Frontend
**6. Provider list (`apps/frontend/lib/api/config.ts`)**
```ts
export type LLMProvider =
| 'openai'
| 'openai_compatible'
| 'anthropic'
| 'openrouter'
| 'gemini'
| 'deepseek'
| 'ollama';
```
**7. `PROVIDERS` array + `PROVIDER_INFO` dict (`config.ts`)**
```ts
openai_compatible: {
name: 'OpenAI-Compatible',
description: 'llama.cpp, vLLM, LM Studio, self-hosted OpenAI-API servers',
defaultModel: 'custom-model',
requiresKey: false,
},
```
**8. Segmented-button UI (`settings/page.tsx`)**
The button row auto-wraps as long as `PROVIDERS` grows. With 7 providers the row flows to 2 lines on narrow viewports — acceptable.
**9. API-base hint**
When the user selects `openai_compatible`, pre-populate `api_base` to `http://localhost:8080/v1` if the field is empty (matches llama.cpp default).
**10. i18n**
Add `settings.providers.openai_compatible` name + description string in all 5 locales (en/es/ja/zh/pt-BR).
### Docs
**11. `.env.example`**
Extend the `LLM_PROVIDER` comment to list `openai_compatible` as a valid value.
**12. SETUP.md + translations**
One-liner mention in the LLM configuration section: "Use OpenAI-Compatible for llama.cpp, vLLM, LM Studio."
## Data flow
```
User picks "OpenAI-Compatible" in Settings
└─ api_base pre-filled to http://localhost:8080/v1
└─ API key field optional (requiresKey: false)
└─ PUT /api/v1/config/llm-api-key {provider: "openai_compatible", model: "llama-3.1-8b", api_base: "...", api_key: ""}
└─ stored.api_keys["openai_compatible"] = "" (own namespace)
└─ get_llm_config → LLMConfig(provider="openai_compatible", ...)
└─ get_model_name → "openai/llama-3.1-8b"
└─ _normalize_api_base → "http://localhost:8080/v1" (preserved)
└─ check_llm_health → passes api_key="sk-no-key" sentinel if empty
└─ litellm.acompletion(model="openai/llama-3.1-8b", api_base="...", api_key="sk-no-key")
└─ LiteLLM routes via OpenAI client → http://localhost:8080/v1/chat/completions
```
## Error handling
| Failure | Behavior |
|---|---|
| Empty api_key for `openai_compatible` | Allowed. Sentinel passed to LiteLLM; server decides whether to auth. |
| Server returns 404 on `/v1/chat/completions` | Health check surfaces `not_found_404` error code. |
| `api_base` includes `/v1/v1` | Handled by normal LiteLLM behavior; no extra stripping. |
| Model name includes `openai/` already | Respected, not double-prefixed. |
## Files touched
| File | Change |
|---|---|
| `apps/backend/app/config.py` | Add `openai_compatible` to `llm_provider` Literal |
| `apps/backend/app/llm.py` | Provider map, prefix, normalize, health-check gate |
| `apps/backend/app/.env.example` | Document the option |
| `apps/frontend/lib/api/config.ts` | Add to `LLMProvider`, `PROVIDERS`, `PROVIDER_INFO` |
| `apps/frontend/app/(default)/settings/page.tsx` | Pre-fill api_base hint on provider change |
| `apps/frontend/messages/{en,es,ja,zh,pt-BR}.json` | i18n strings |
| `SETUP.md`, `SETUP.es.md`, `SETUP.ja.md`, `SETUP.zh-CN.md` | One-line mention |
Estimated ~8 files, ~80-120 LOC.
## Risks
- **Sentinel key `"sk-no-key"`**: cosmetic; the OpenAI client treats it as a literal string and passes it in the `Authorization` header. Local servers that don't check auth ignore it. Servers that DO check auth will reject it — but those users will set a real key, so no regression.
- **Key namespace overlap**: users who already have a stored key under `openai` will not see it auto-populated when switching to `openai_compatible` — they have to paste it (or leave blank). This is by design: they're logically different providers.
- **New failure code paths**: none added; reuses existing `not_found_404` / `duplicate_v1_path` / `html_response` heuristics.
## Rollback
Pure additive. Revert removes the option from the dropdown; users who configured `openai_compatible` revert to seeing "unknown provider" error on next config load (handled by existing `set_default_provider` validator in `config.py`, which falls back to `"openai"`). Their stored `api_base` survives, so switching to `openai + api_base=...` gives them back the working path.
## Verification
Manual (no test infrastructure):
1. Start llama.cpp locally on port 8080 with an OpenAI server enabled.
2. In Settings, pick OpenAI-Compatible. `api_base` pre-fills to `http://localhost:8080/v1`.
3. Leave API key blank. Pick model `llama-3.1-8b`. Test Connection → healthy, shows model output.
4. Switch back to `openai` with a real key. Test Connection still works for OpenAI's API.
5. Saved keys are separate: clearing the `openai` key does not blank the `openai_compatible` setting.
@@ -0,0 +1,83 @@
# Settings error-container overflow fix
**Date:** 2026-04-17
**Issue:** #754
**Branch:** `dev` (bundle into PR with #751 and #749)
## Problem
When the LLM health check returns a 404 with a long response body (error detail, stack trace, URL with query string), the `<pre>` element inside the health-check detail block and the `<p>` inside the top-level error banner overflow their containers horizontally. The page layout breaks and the Settings panel becomes unreadable on narrow viewports.
The bug pattern: `font-mono` + default `overflow-wrap: normal` keeps unbreakable strings on a single line and blows out the parent's width. The same pattern exists in multiple error containers on the Settings page.
## Goals
- Long error strings wrap inside their containers across all Settings error surfaces.
- No horizontal scroll on the Settings page regardless of error content.
- Design stays Swiss: hard borders, monospace text, no visual softening.
## Non-goals
- Redesigning the error-card visual treatment.
- Changing the error message content or codes.
- Refactoring unrelated Settings components.
## Design
### Where the bug appears
Five locations in `apps/frontend/app/(default)/settings/page.tsx` use the same pattern and share the bug:
1. **Top-level save/load error banner** (around line 882): `<div className="border border-red-300 bg-red-50 p-3"><p className="text-xs text-red-600 font-mono">...`
2. **Health-check result card** (around line 891): container with `healthy ? green : red` styling; renders error message, warning message, and detail items.
3. **Health-check inline error message** (around line 919): `<p className="font-mono text-xs text-red-600 mt-1">`
4. **Health-check inline warning message** (around line 923): same pattern
5. **Health-check detail items' `<pre>` blocks** (around lines 929-945): each detail item renders its value inside `<pre className="mt-1 whitespace-pre-wrap rounded-none border border-black bg-white p-3 text-xs text-ink-soft shadow-sw-sm">`. `whitespace-pre-wrap` handles newlines but does NOT break unbreakable tokens.
### Fix
Two Tailwind utilities are enough:
- `break-words` — equivalent to `overflow-wrap: break-word`. Breaks at word boundaries where possible, falls back to mid-word breaks for unbreakable tokens.
- `min-w-0` on any flex parent that contains wrapping text. Flex items default to `min-width: auto`, which prevents shrinking below content size; `min-w-0` re-enables shrinking so the text can wrap.
Apply across the five locations:
| Location | Class additions |
|---|---|
| Top-level error banner `<div>` | `break-words` on the `<p>` inside |
| Health-check result card outer `<div>` | `break-words` |
| Health-check inline error `<p>` | `break-words` |
| Health-check inline warning `<p>` | `break-words` |
| Health-check detail `<pre>` blocks | `break-words` (complements existing `whitespace-pre-wrap`) |
The existing `whitespace-pre-wrap` on `<pre>` preserves newlines. Adding `break-words` adds mid-token breaking for long unbroken strings (URLs, base64 blobs).
### Data flow
No change. Pure CSS.
## Files touched
| File | Change |
|---|---|
| `apps/frontend/app/(default)/settings/page.tsx` | Add `break-words` to 5 containers/elements |
Estimated ~5-8 line-touches.
## Risks
- `break-words` can produce ugly mid-word breaks on narrow viewports. Acceptable for error surfaces — legibility of the layout outweighs prettiness of a single word.
- `min-w-0` is NOT needed for these specific layouts because they're in a `<section>` with `space-y-*`, not flex children — verified against current markup.
## Rollback
Pure style change. Revert restores the current (buggy) behavior. No data migration.
## Verification
Manual, on a narrow viewport (~360px):
1. Configure invalid API base URL (e.g., `https://example.com/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/v1`).
2. Click Test Connection.
3. Error text, error detail, and all rendered strings stay within the container. No horizontal scroll on the page.
4. Swiss styling preserved: hard borders, monospace text, no rounded corners.
@@ -0,0 +1,262 @@
# Agentic End-to-End Monitor — Design Spec
> **Status:** Implemented — harness in `apps/backend/e2e_monitor/` (PR #823); first live sweep run + golden baseline committed. This document is the design record; current state lives in the harness `README.md`.
> **Date:** 2026-06-01
> **Author:** Saurabh Rai (with Claude Code)
> **Branch:** `docs/agentic-e2e-monitor-spec` (off `dev`)
> **Relationship:** Next phase of the testing initiative. The deterministic suites + local
> pre-push gate (PR #820) answer *"is the plumbing correct?"*. This answers the question they
> structurally **cannot**: *"is the running system actually producing good resumes — now and as it drifts?"*
---
## 1. Problem
Green tests do not mean we are making good resumes. The deterministic backend/frontend suites and the
pre-push gate verify *plumbing* — schemas validate, routes return, the i18n shape holds. They are blind to:
- **Output quality drift** — a prompt edit that still passes every structural scorer but quietly makes
tailored resumes *worse* (less relevant, blander, subtly less truthful). No fixed assertion catches "worse."
- **Flow / render breakage** — the pipeline returns `200 OK` but the PDF is blank or a stage silently
swallowed an error. This is the **livestream failure**: a high-visibility run where the resume didn't render.
- **Local-provider reality** — recurring user complaints that **Ollama doesn't work**. The mocked-LLM suite
can't see this; only a real run against a real provider can.
The user's framing: *"All are going in good, because our tests pass right now. But what happens in the
future?? The only way going forward would be to have an agent debug the whole thing — read from logs and
test-files being produced, understand each of the files."*
So we need a **non-deterministic, agentic** verification layer that drives the real app end to end, captures
a durable evidence trail, and has a Claude Code instance judge it — as a **report, never a gate**.
---
## 2. Goals & non-goals
### Goals
1. Drive the **real running app** through the full flow: start → create master resume → generate 34
variations → render PDFs.
2. Capture a **durable evidence bundle** (logs + every intermediate artifact + PDFs) — something that does
not exist today (the app logs only to the console; the pipeline persists no intermediates).
3. Have an **agent** read that bundle and render an evidence-cited verdict across three jobs:
**output quality**, **flow/render integrity**, **provider reality-check**.
4. Detect **regression over time** against a committed golden **baseline**, above an absolute floor.
5. Be **safe for an OSS repo**: ~90% maintainer / ~10% contributors / ~0% random users' agents. It must not
be auto-installed, auto-run, or auto-discovered by every cloner's coding agent.
### Non-goals (explicit YAGNI)
- **No code/standards audit** — reviews + the pre-push gate already cover code hygiene; this watches *runtime*.
- **No provider matrix** — runs against the single configured provider, not a cross-provider sweep.
- **No run-history store** — a committed baseline is the reference, not an accumulated trend log.
- **No CI / cron** — on-demand only. Never a PR gate, never a GitHub Action.
- **No browser automation** — the flow is HTTP-driven; PDFs come from the existing `/pdf` endpoint.
- **No auto-baseline-refresh** — refreshing the golden is a deliberate, reviewed human commit.
---
## 3. Decisions (the locked forks, with rationale)
| # | Decision | Rationale |
|---|----------|-----------|
| 1 | **Three jobs:** output quality + flow/render integrity + provider reality-check (NOT code/standards). | These three are exactly the runtime blind spots of the deterministic suite, and map 1:1 to the user's real pains (silent quality drift, the blank-render livestream, Ollama complaints). |
| 2 | **Two layers:** deterministic capture **harness** + agentic **judge**. The judge produces a **report, never a gate**. | A fuzzy judgment can't block a push the way the pre-push hook does. Separating capture (reproducible) from judgment (non-deterministic) keeps the evidence trustworthy and the cost bounded. |
| 3 | **Agent-in-the-loop** autonomy. The harness is a library of discrete re-runnable "moves"; the agent runs the default sweep, reads the bundle, and re-invokes specific moves to investigate — but can't go fully off-script. | More flexible than a fixed script (can chase anomalies), more trustworthy/reproducible than a fully autonomous agent driving the app (a flaky autonomous run could be the agent's fault, not the app's). |
| 4 | **Configured provider only** — run against whatever `config.json` points at; record which provider it was. | Simplest. To compare Ollama vs cloud, run twice with different configs; the in-loop agent diffs the two bundles. The dev stays in control of provider choice. |
| 5 | **Committed golden baseline** over an **absolute floor**. | Version-controlled, fits the git-centric workflow, no background store. The floor (every stage completes, PDF non-blank, identity preserved, quality ≥ 3/5) is the hard fail; the baseline catches *drift* above the floor. |
| 6 | **Form factor:** standalone harness + a **Claude Code skill**. | Realizes agent-in-the-loop exactly (agent orchestrates deterministic moves), and the harness doubles as a reusable plain E2E smoke test (runs fine with no agent). |
| 7 | **Skill distribution:** the runnable skill is **gitignored**; its source-of-truth is a **committed playbook**. | Gives the 90/10/0 split — maintainer + deliberate contributors get it, no random user's agent ever receives it, and it doesn't leak into the maintainer's other repos. |
---
## 4. Architecture
```
YOU ──► /monitor-e2e (Claude Code skill = the agent-in-the-loop)
│ 1. runs the default sweep ┌─────────────────────────┐
├──────────────────────────────────► HARNESS (moves) │
│ 4. re-invokes targeted moves │ boot · seed-master · │
│ to investigate │ tailor · render · │
│ │ collect · baseline-diff│
│ └────────────┬────────────┘
│ │ spawns + drives over HTTP
│ ┌────────────▼────────────┐
│ 2. reads bundle ◄────writes──────│ backend :8000 (uvicorn) │
│ 3. applies rubric + baseline diff │ frontend :3000 (Next) │
│ 5. writes report.md │ (real configured LLM) │
▼ └─────────────────────────┘
report.md + session summary
```
**The log-capture trick (zero app changes):** the harness *owns the subprocesses* — it spawns uvicorn and
Next.js itself and redirects their stdout/stderr into log files in the bundle. This produces a durable log
trail **without adding any `FileHandler` to `app/`**. If servers are already running, the harness can
*attach* (skip spawn) instead.
---
## 5. Components & locations
| Path | VC? | What |
|------|-----|------|
| `apps/backend/e2e_monitor/` | ✅ committed | the harness package — `cd apps/backend && uv run python -m e2e_monitor <move>`. Reuses `app.schemas` + the eval `scorers.py` and the backend `uv` env (httpx). |
| `apps/backend/e2e_monitor/fixtures/` | ✅ committed | one rich canonical **master resume** (all sections) + a fixed set of **34 JDs** across distinct roles. |
| `apps/backend/e2e_monitor/baseline/baseline.json` | ✅ committed | the accepted golden — scores + flow expectations + content digests. |
| `apps/backend/e2e_monitor/AGENT_PLAYBOOK.md` | ✅ committed | the **source of truth** for the skill body; contributors copy it to opt in. |
| `artifacts/e2e-monitor/<run-id>/` | 🚫 gitignored | per-run evidence bundle. |
| `.claude/skills/monitor-e2e/SKILL.md` | 🚫 gitignored | the live, runnable skill (installed from the playbook). |
| `docs/agent/testing-strategy.md` §10 + harness `README.md` | ✅ committed | the curated, durable record. |
**Optional dependency:** anything beyond the existing backend deps (e.g. a `pypdf` text-probe for the
non-blank PDF check) goes behind `[project.optional-dependencies] e2e-monitor`**never** pulled by
`uv sync` or `uv sync --extra dev`. Only the maintainer runs `uv sync --extra e2e-monitor`.
---
## 6. The moves (harness CLI)
Each move is deterministic and appends to the bundle.
- **`boot`** — spawn backend + frontend (or attach), redirect their logs to the bundle, wait for `/health`
on both, write `manifest.json` (run-id, **provider + model from config**, git SHA, timestamps,
secret-scrubbed config snapshot).
- **`seed-master`** — upload the canonical master via `/resumes/upload`, await processing, save
`processed_data.json`, record `resume_id`.
- **`tailor --jd <key>`** — `/jobs/upload``/resumes/improve/preview``/improve/confirm`; save keywords
+ `tailored.json`; run the 5 structural scorers → `scores.json`.
- **`render --variation <key>`** — `/resumes/{id}/pdf`; save the PDF; **non-blank check** (size + page count
+ extractable-text probe) + timing → `render.json`.
- **`judge --variation <key>`** — reuse the eval rubric (`complete_json`, relevance/truthfulness/formatting
→ 15) and **record the number** to the bundle for like-for-like baseline comparison.
- **`collect`** — flush logs, finalize `flow-trace.json` (per-stage status/timing/HTTP errors), write
`summary.json`.
- **`baseline-diff`** — diff this run's scores + flow-trace against `baseline.json``baseline-diff.json`.
- **`sweep`** — convenience chain (`boot → seed → tailor×N → render×N → judge×N → collect → baseline-diff`);
the agent's default first call. **`teardown`** stops spawned servers.
**Opt-in gate (every expensive move):** refuses unless **both** a key is configured (the eval
`_needs_key()` gate) **and** `RM_E2E_MONITOR=1` (or `--yes-spend-tokens`) is set — printing a clear
"monitor disabled by default; this makes real billed LLM calls" message otherwise.
---
## 7. Bundle layout (what the agent reads)
```
artifacts/e2e-monitor/<run-id>/
manifest.json summary.json baseline-diff.json
flow-trace.json report.md ◄── written by the agent
logs/{backend,frontend}.log
master/{upload_response,processed_data}.json
variations/<jd-key>/
job_description.txt keywords.json tailored.json
scores.json judge.json resume.pdf render.json
```
---
## 8. The agent's judgment (skill / playbook)
After `sweep`, the agent reads `summary.json` + `baseline-diff.json` first, then works the three jobs —
each grounded in a specific artifact so the verdict is **evidence-cited**, not vibes:
- **Output quality** — the 5 structural scorers in `scores.json` are the deterministic floor (no dropped
sections, no fabricated employers, identity byte-stable, JD-keyword coverage, valid schema). `judge.json`
adds the rubric score (recorded, for baseline diffing). The *agent* then reads `tailored.json` vs
`job_description.txt` directly to catch what a fixed rubric misses, and compares to baseline scores.
- **Flow + render integrity** — `flow-trace.json` (every stage status + timing) + `render.json` (non-blank)
+ a **grep of `logs/backend.log`** for tracebacks, swallowed exceptions, the generic-500 pattern, asyncio
timeouts. A `200` can hide a broken PDF; the log + text-probe won't.
- **Provider reality-check** — manifest records provider+model; the agent scans logs for the **fingerprints
of local-provider struggle** even when output squeaks through: JSON-mode fallbacks, truncation retries,
timeout escalation, retry exhaustion, the Ollama `/api/show` probe. Point `config.json` at Ollama, run,
and the baseline (captured on the known-good provider) makes the gap obvious.
The agent then **re-invokes targeted moves** to investigate anomalies (re-`tailor` a suspect JD, re-`render`
a blank PDF, `collect` fresh logs), writes `report.md` (verdict per job, regressions vs baseline with
evidence citations, reproduction steps, recommended fixes) + a short session summary. It **never** modifies
app code and **never** refreshes the baseline.
---
## 9. Baseline & refresh
`baseline/baseline.json` holds accepted structural scores, judge scores **with tolerance bands** (so model
jitter ≠ a regression — only a floor breach or a drop beyond band flags), flow expectations, and content
digests of the golden outputs. `baseline-diff` flags: any floor breach (hard), any score drop beyond
tolerance, any new log-error signature, any stage that regressed.
**Refresh** = an explicit `python -m e2e_monitor update-baseline` that the dev **reviews and commits** — like
updating a snapshot test. The agent never refreshes it.
---
## 10. Guardrails (leakage prevention — the OSS-safety answer)
Three leak vectors, each closed:
1. **Dependency install** — harness-only deps behind the `e2e-monitor` optional extra; never in
`uv sync` / `--extra dev`. *Closed by construction.*
2. **Accidental run** — inert code (no import side-effects; never imported by `app/*` or the test suite, so
`uv run pytest` never touches it; not in `.githooks/pre-push`) + behavioral opt-in (`RM_E2E_MONITOR=1`
**and** `_needs_key()`). Even a helpful stranger's agent that runs it gets a "disabled by default" no-op.
*Closed by construction + gate.*
3. **Agent auto-discovery** — the skill is **not committed** as a project skill (which every cloner's agent
would see). It is gitignored at `.claude/skills/monitor-e2e/`; the committed `AGENT_PLAYBOOK.md` is the
source of truth; install is one documented copy (or `make e2e-skill`). *Closed by not shipping it.*
Plus: **secrets** — captured logs + manifest run through the `_scrub_secrets` philosophy before hitting the
(gitignored) bundle. **Report, never a gate** — not in the pre-push hook, not in `.github/workflows/`.
---
## 11. Anti-theater (the harness checks itself)
The **pure, dependency-free** helpers — `baseline-diff`, the non-blank heuristic, the log-scrubber, the
flow-trace builder — get **keyless, offline unit tests in the normal suite**, so the harness logic is
version-protected and proven to fire:
- `baseline-diff` must flag a **seeded regression**.
- the non-blank check must **fail a blank PDF**.
- the scrubber must **redact a planted key**.
The side-effectful parts (subprocess spawn / HTTP / LLM) run only in the on-demand sweep — so `uv run
pytest` still never boots a server or spends a token. (The tested pure helpers must not require the optional
`e2e-monitor` extra; the deep `pypdf` text-probe is mocked or skipped when the extra is absent.)
---
## 12. Fixtures, cadence, workflow
- **Fixtures:** one rich canonical master (personalInfo, summary, 23 work entries, education, projects,
skills, a custom section) + **34 JDs across distinct roles** (e.g. backend / frontend / ML / PM) — the
user's "34 variations."
- **Cadence:** on-demand — run `/monitor-e2e` before tagging a release or after prompt edits. No cron.
- **Workflow:** new branch off `dev`, same as the rest of the testing initiative; report is informational.
---
## 13. Open questions / future (deliberately deferred)
- **Run-history trend** — an accumulated (gitignored) log of scores/timings for slow-drift detection, on top
of the baseline. Add later if the baseline alone proves too coarse.
- **Provider matrix** — automatic local-vs-cloud comparison in one run, if manual two-run diffing gets tedious.
- **Scheduled local run** — a local cron/launchd that runs the sweep nightly and pings the maintainer.
---
## 14. Implementation outline (for the eventual plan)
Rough phases, smallest-shippable-first, each independently testable:
1. **Harness skeleton + manifest + opt-in gate**`boot`/`teardown` (spawn+attach+log capture),
`manifest.json`, the `RM_E2E_MONITOR` + `_needs_key()` gate. Unit-test the scrubber.
2. **Flow moves + bundle**`seed-master`, `tailor`, `render` (with non-blank check), `collect`
(`flow-trace.json` + `summary.json`). Unit-test the non-blank heuristic + flow-trace builder offline.
3. **Scoring + baseline** — wire the structural scorers + `judge`; `baseline-diff` + `update-baseline`;
commit the first golden. Unit-test baseline-diff against a seeded regression.
4. **Fixtures** — author the canonical master + the 34 JDs.
5. **Agent layer** — write `AGENT_PLAYBOOK.md`, the gitignored skill install path, `docs` §10 + README,
`.gitignore` entries, the optional extra in `pyproject.toml`.
> Next step when resumed: invoke the **writing-plans** skill to turn §14 into a detailed implementation plan.
@@ -0,0 +1,153 @@
# Design: Three New Resume Templates — LaTeX, Clean, Vivid
> **Status:** Approved (design). **Date:** 2026-06-03. **Branch:** `feat/resume-templates-latex-clean-vivid`
## Goal
Add three new resume templates to Resume Matcher, each a faithful reproduction of a
reference image the maintainer provided:
| ID | Display | Layout | Reference |
|----|---------|--------|-----------|
| `latex` | LaTeX | Single column | Classic serif/LaTeX resume (centered small-caps name, ruled Title-Case section headers, company-first two-line entries) |
| `clean` | Clean | Single column | Minimal modern sans (centered light name, `\|`-separated contact line, large gray UPPERCASE section headers, single-line entries) |
| `vivid` | Vivid | Two column | Colorful Awesome-CV lineage (two-tone colored name, monospace title + circular-icon contact row, accent small-caps headers, accent ➜ bullet markers) |
Guiding principle (maintainer steer): **match the reference images by default; expose the
existing universal controls so users can tweak the rest the way they like.** Do not
over-engineer beyond faithful reproduction.
## Non-Goals
- No new global controls in `TemplateSettings` (reuse existing margins / spacing / size /
page-size / fonts / contact-icons / accent-color).
- No changes to the two-column column-assignment engine — `vivid` reuses the established
split (main 65%: summary, experience, projects, certifications, custom; sidebar 35%:
education, skills, languages, awards). Custom-section placement follows the existing
convention; reordering remains a user action.
- No backend business-logic change. The PDF endpoint accepts `template` as a free string;
only its docstring/allow-list comment and the frontend `parseTemplate` allow-list need the
new IDs.
- No accent-color wiring for `latex`/`clean` (both are monochrome by design).
## Typography Approach (the one real wrinkle)
The maintainer chose "respect the font controls" over "enforce + hide the dropdowns," and
"the reference look is the default." Global font defaults are `headerFont: serif`,
`bodyFont: sans-serif`. To make each reference look the **default** while keeping the
dropdowns live and **without mutating settings on template switch**, each template binds to
existing font CSS variables per its design:
- **LaTeX** — single-typeface serif. Bind all text to `var(--header-font)` (default serif ✓).
The **Header Font** dropdown drives the whole template; **Body Font** is inert for LaTeX.
- **Clean** — single-typeface sans. Bind all text to `var(--body-font)` (default sans ✓).
The **Body Font** dropdown drives the whole template; **Header Font** is inert for Clean.
- **Vivid** — multi-font by design. Name + bullet body use `var(--body-font)` (sans default,
Body Font drives body). Title line + contact row use a fixed **monospace** stack
(`var(--resume-font-mono)`) to match the reference. Section headers + small-caps subtitles
use `var(--header-font)` for the family with `font-variant: small-caps`.
Trade-off accepted: for the single-typeface templates one of the two font dropdowns is a
no-op. This is consistent with how accent-color is inert (and hidden) for non-color templates.
## Template Specifications
All three are pure DOM text (ATS-safe), follow the `resume-modern.tsx` /
`resume-modern-two-column.tsx` patterns, render sections in `sectionMeta` order via
`getSortedSections`, and reuse the shared `_base.module.css` spacing/typography classes so
margins/spacing/size controls apply.
### LaTeX (`latex`) — single column
- **Header (centered):** serif name with `font-variant: small-caps` at `--header-scale`;
optional `personalInfo.title` as a centered italic tagline; `personalInfo.location` as a
centered sub-name line; contact row centered, icons shown only when the global
`showContactIcons` toggle is on, links underlined.
- **Section header:** serif, bold, **Title-Case** (override base `text-transform: uppercase`
`none`), full-width 1px bottom rule; `break-after: avoid`.
- **Experience / item entries (company-first):**
- Line 1: **Company** bold (left) · **dates** bold (right).
- Line 2: *Title* italic (left) · *Location* italic (right).
- Bullets with `•` markers.
- **Projects:** name bold + tech/links; dates right; bullets.
- **Education:** institution bold (left) · dates right; degree italic line.
- **Skills/Additional:** `**Category**: comma-joined items` lines (bold category labels).
### Clean (`clean`) — single column
- **Header (centered):** light-weight sans name (font-weight ~400), larger; contact rendered
as a single `\|`-separated line, small, links underlined; icons honor `showContactIcons`.
- **Section header:** large gray (`--resume-text-tertiary`) **UPPERCASE** sans, letter-spaced,
thin bottom rule; `break-after: avoid`.
- **Entries (single line):** **COMPANY** bold ` \| ` Title (small-caps gray) on the left;
`Location \| Dates` gray on the right; bullets below.
- **Education / Projects:** analogous single-line headers.
- **Skills/Additional:** `**Label:** items` lines.
### Vivid (`vivid`) — two column
- **Accent wired:** reads `--resume-accent-primary` (default blue); appears in the
accent-color control alongside `modern`/`modern-two-column`.
- **Header (full width, left-aligned):** two-tone name — first token bold in accent-primary,
remaining tokens in a lighter accent tone; `personalInfo.title` on a monospace line; contact
row in monospace with **circular-outlined** icon chips (icons honor `showContactIcons`;
when off, show monospace text only).
- **Section header:** accent-colored, `font-variant: small-caps`, bold (used in both columns;
sidebar variant slightly smaller, matching the existing `-sm` convention).
- **Bullet markers:** accent ➜ arrow instead of `•`.
- **Columns:** reuse `modern-two-column` grid (main 65% / sidebar 35%) and section split.
Sidebar skills render as `•`-separated wrapping lists (matching the reference).
- **Print:** accent colors forced with `-webkit-print-color-adjust: exact` (mirror
`modern.module.css` / `modern-two-column.module.css`).
## Integration Points (mirrors how `modern` was added)
1. **`apps/frontend/lib/types/template-settings.ts`** — extend `TemplateType` union with
`'latex' | 'clean' | 'vivid'`; append three `TEMPLATE_OPTIONS` entries.
2. **`apps/frontend/components/resume/`** — new `resume-latex.tsx`, `resume-clean.tsx`,
`resume-vivid.tsx`; new `styles/latex.module.css`, `styles/clean.module.css`,
`styles/vivid.module.css`; export all three from `index.ts`.
3. **`apps/frontend/components/dashboard/resume-component.tsx`** — import + three conditional
render branches in the dispatcher.
4. **`apps/frontend/components/builder/template-selector.tsx`** — three new
`TemplateThumbnail` variants + `templateLabels` entries.
5. **`apps/frontend/components/builder/formatting-controls.tsx`** — `templateLabels` entries;
add `vivid` to the accent-color visibility condition.
6. **`apps/frontend/components/builder/resume-builder.tsx`** — footer single/two-column label
logic: add `latex`, `clean` to the single-column condition (`vivid` falls through to
two-column).
7. **`apps/frontend/app/print/resumes/[id]/page.tsx`** — add three IDs to `parseTemplate`.
8. **`apps/backend/app/routers/resumes.py`** — update the `/generate` docstring template
allow-list comment (no logic change).
9. **i18n** — add `builder.formatting.templates.{latex,clean,vivid}` `{name, description}` to
all five locales: `messages/{en,es,zh,ja,pt}.json`.
10. **Docs** — update `docs/agent/design/template-system.md` and
`docs/agent/features/resume-templates.md` template tables.
## Testing
Currently there are **no** frontend template tests, so this establishes the pattern. Add
deterministic vitest + Testing Library tests (jsdom) that fail if the templates break:
- **Component render/smoke tests** (`components/resume/__tests__/` or co-located, following
existing vitest conventions): render each of `ResumeLatex`, `ResumeClean`, `ResumeVivid`
with a representative `ResumeData` fixture and assert key text appears (name, a section
heading, a company, a bullet). For `vivid`, assert the two-tone name splits and an accent
marker/element is present.
- **`parseTemplate` test**: the three new IDs round-trip; an unknown value still falls back to
`swiss-single`.
- **`TEMPLATE_OPTIONS` test**: includes all seven IDs with non-empty name/description.
Verification commands are handed to the maintainer to run (shell `npm`/`npx` is avoided per
environment constraints): `cd apps/frontend && npm run test`, `npm run lint`, `npm run build`.
Locale parity is covered by the existing pre-push check.
## Definition of Done
- Three templates selectable in the builder, render in live preview and PDF print route.
- Each matches its reference image by default; universal controls (margins, spacing, size,
page size, applicable font dropdown, contact-icon toggle, and accent color for `vivid`)
function.
- Allow-lists (`parseTemplate`, backend docstring) and i18n (5 locales) updated.
- New deterministic tests pass; lint and build clean.
- Docs tables updated.
@@ -0,0 +1,175 @@
# Resume Wizard Design
## Goal
Add a general-master-resume creation pipeline for users who do not already have a PDF or DOCX resume. The existing upload path remains the fast path, while the new wizard helps users create a truthful structured master resume from scratch through a hybrid one-question-at-a-time Q&A.
## Current Context
The dashboard currently treats the missing master resume state as the entry point for setup. Uploading a PDF or DOCX calls `POST /api/v1/resumes/upload`, creates a normal resume record, marks the resume as master when no healthy master exists, stores `master_resume_id` in local storage, and routes users into the rest of the app.
The new wizard should produce the same downstream shape as an uploaded and parsed master resume: a persisted resume with `is_master=True`, `processing_status="ready"`, `content_type="json"`, and `processed_data` compatible with `ResumeData`.
## Product Flow
When no master resume exists, the dashboard setup tile opens a choice surface with two options:
- Upload an existing resume.
- Create one from scratch with the AI Wizard.
The wizard lives at `/resume-wizard`. It builds a general master resume first, without requiring a pasted target job description. Job-specific tailoring remains a separate flow through the existing tailor pipeline.
The wizard starts with a short AI-guided intro:
1. Ask who the user is and what general role area they are aiming for.
2. Extract the user's name even when the answer is conversational, such as "Hi, I'm James."
3. Personalize the next prompt, for example: "So James, where would you like to begin?"
4. Present section choices: Work Experience, Internships, Education, Projects, Skills, and Review.
After the intro, the user chooses which section to work on. Inside each section, the AI-led conversation asks focused follow-up questions, but the app still owns the section state, resume schema, and validation rules.
## Section Behavior
Work Experience and Internships share the same `workExperience` resume schema. Internship entries can use titles, companies, dates, and bullets just like jobs.
Education maps to `education`.
Projects map to `personalProjects`.
Skills map to `additional.technicalSkills`, with optional user-confirmed languages, certifications, and awards stored in the existing `additional` fields.
The baseline output is:
- Three bullets per work experience or internship entry.
- Two bullets per side project.
- Skills inferred continuously from the user's answers and visible in the skills section before finalization.
The wizard should allow users to skip sections and return to them before finalization. The review step should identify missing but useful information, such as no contact method, no education, no dates, no project impact, or thin bullet details.
## AI Harness
The backend exposes a new `resume-wizard` API namespace. The harness accepts current wizard state plus the latest user action or answer. It returns a structured response, never only free-form prose.
Each turn returns:
- Updated `resume_data`.
- Updated wizard progress and current section.
- Inferred skills and skill suggestions.
- The next assistant message.
- Optional selectable options.
- Optional clarifying questions.
- Validation warnings.
- A completion status for the current section and for the whole resume.
The app controls the schema and allowed section actions. AI can write bullets, extract facts, infer skills, reformat phrasing, and ask clarifying questions, but every returned resume draft is validated through `ResumeData` before the client receives it.
If the model returns invalid JSON, the backend retries using the existing `complete_json` behavior and prompt-only JSON repair instructions. If the response is still invalid, the backend logs detailed errors server-side and returns a generic client error.
## Prompting
Add resume-wizard prompt templates that make the model behave as a structured resume-writing assistant.
The prompts should enforce these rules:
- Build a general master resume, not a job-specific tailored resume.
- Do not invent companies, dates, metrics, tools, degrees, awards, or skills.
- Ask clarifying questions when answers are vague or missing important facts.
- Prefer concise action-oriented bullets grounded in user-provided facts.
- Use the configured content language.
- Output only the requested JSON object.
- Preserve existing draft data unless the user explicitly changes it.
The first prompts should guide the intro and section handoff:
- "Hi, I'll help you create your master resume. What is your name, and what kind of role are you aiming for?"
- "So {name}, where would you like to begin?"
- "Would you like to start with work experience, internships, education, projects, or skills?"
Section prompts should ask for practical facts before drafting. For example, work experience should ask for title, company, dates, responsibilities, tools, scale, and impact. Projects should ask what was built, why it mattered, technologies used, user or usage context, and links when available.
## Backend API
Create `apps/backend/app/routers/resume_wizard.py` and mount it under `/api/v1`.
Planned endpoints:
- `POST /api/v1/resume-wizard/turn`: accepts a wizard state and the latest user answer or section selection, returns the next structured wizard state.
- `POST /api/v1/resume-wizard/finalize`: validates the final draft and creates the master resume.
The finalize endpoint should create a regular resume record through the existing database facade. The created resume should behave like an uploaded parsed master resume:
- `content` is canonical JSON.
- `content_type` is `"json"`.
- `filename` is a generated name such as `"AI Resume Wizard - James.json"`.
- `processed_data` is the validated `ResumeData`.
- `processing_status` is `"ready"`.
- `is_master` is true when there is no current healthy master resume.
If a master resume already exists, the finalize endpoint should reject creation with a clear non-destructive error. An explicit replacement flow is outside this implementation scope.
## Frontend UI
Create a new `/resume-wizard` route under the default app group. The page is a client component.
The layout follows the existing Swiss International Style:
- Canvas background `#F0F0E8`.
- Square corners.
- Black borders.
- Hard offset shadows.
- Serif headers, sans body, monospace metadata.
- No decorative gradients, rounded cards, or marketing hero layout.
The screen should feel like a focused tool, not a landing page. The first viewport should be the wizard itself:
- Left or top progress rail showing Intro, selected sections, and Review.
- Main Q&A panel with assistant prompt, answer textarea, and selectable section buttons.
- Live structured preview or compact summary panel showing collected facts, bullets, and inferred skills.
- Footer actions for Back, Skip, Continue, and Finalize.
The dashboard missing-master tile should open a Swiss-style dialog with two clear choices: upload or AI wizard. Upload keeps using `ResumeUploadDialog`; wizard routes to `/resume-wizard`.
## Frontend Data Contract
Add frontend API helpers in `apps/frontend/lib/api/resume-wizard.ts`.
The wizard page should keep local draft state while calling the backend each turn. It should persist draft state to local storage for refresh recovery. On finalize, it stores the returned `resume_id` in `master_resume_id`, updates the status cache, and routes to `/builder?id=<resume_id>` for review and final manual edits.
The final builder review is intentional. The AI wizard creates a strong first draft, but the user should still be able to inspect and edit before using the resume for tailoring.
## Error Handling
Backend errors should log detailed context and return generic messages. Client errors should appear as Swiss-style alerts with recovery actions:
- Retry AI turn.
- Continue editing the current answer.
- Return to dashboard.
- Open upload path instead.
No model exception details, provider secrets, or raw stack traces should reach the browser.
## Testing
Backend tests should cover:
- Wizard schema validation and coercion through `ResumeData`.
- Intro answers extracting a name and producing section choices.
- Section updates creating baseline bullet counts.
- Skill inference only using user-provided facts.
- Finalize creating a ready master resume when no master exists.
- Finalize rejecting when a master resume already exists.
Frontend tests should cover:
- Dashboard no-master choice between upload and wizard.
- `/resume-wizard` initial render.
- Section picker transition.
- API helper request and response shapes.
- Finalize storing `master_resume_id` and routing to the builder.
- Locale parity for all new translation keys.
Before completion, frontend changes must run `npm run lint` and `npm run format` from `apps/frontend`. Backend changes should run targeted pytest tests for the new router and service.
## Out of Scope
This design does not add job-description-specific tailoring to the wizard. It does not replace the existing upload parser, tailor flow, enrichment flow, or builder. It does not modify CI, Docker, or GitHub workflow files.
@@ -0,0 +1,245 @@
# Resume Wizard Redesign — Design Spec
> **Status:** Approved design (2026-06-04). Supersedes the section-picker UX in
> `docs/superpowers/plans/2026-06-04-resume-wizard-implementation.md`. The backend
> skeleton (router mount, finalize→master-resume, schema/service/prompt file layout,
> localStorage draft, dashboard entry + choice dialog) is **reused**; the turn
> protocol, prompt, and the entire wizard page UX are **replaced**.
## Goal
Turn `/resume-wizard` from a manual, multi-zone form (intro box, a 6-button section
grid, a free-text answer area, and a stats/warnings side panel) into an **AI-led,
one-question-at-a-time** flow: one focused question at a time, the AI writes the polished
(truthful) resume content, decides what to ask next, and the user watches their real
resume build in a quiet live preview. Finalizing creates the master resume and drops
the user into the existing `/builder`.
## Why (problems with the shipped version)
- The layout puts the question on top, a wall of 6 section buttons in the middle, and
the answer box at the bottom — the opposite of a focused flow.
- The right "Live Draft" panel shows stat counts (`Experience 1`, `Skills 0`) and an
always-on orange warnings box — noisy and confusing, not insightful.
- It is **manual**: the user picks sections and types structured answers; the AI only
reacts within a chosen section. Worse, answering during the post-intro picker state
runs an AI turn against the `review` sentinel and is silently discarded.
## Locked decisions
1. **Format — one-question-at-a-time cards.** One big question per screen, single input, thin
segmented progress bar, advance on `Enter`. No section-button grid.
2. **Right side — live resume preview.** A quiet, real resume page fills in as you
answer. No stat counts, no always-on warnings.
3. **AI behavior — writes + adapts.** The AI rewrites casual answers into polished,
truthful resume content AND chooses the next question from what's still thin,
signalling when there's enough to finish.
4. **Turn architecture — one adaptive `complete_json` call per answer** (vs. a
two-call write-then-plan split), for snappy answer→next-question latency.
## UX / Layout
Two-pane layout inside the existing Swiss shell (`bg-background`, 2px black borders,
hard offset shadows, serif headers, mono labels):
- **Left — question card** (`lg:grid-cols-[minmax(0,1fr)_360px]`, card is the `1fr`):
- Thin **segmented progress bar** (server-computed; see Guardrails).
- **Mono kicker** naming the current topic (e.g. `ABOUT YOUR ROLE AT ACME`).
- **Big serif question** (the AI's `next_question.text`).
- **One input** — `Textarea` (multi-line answers; keeps the repo's Enter-key
`stopPropagation` pattern; `⌘/Ctrl+Enter` or the Continue button submits).
- **Footer actions, shown per step:** `intro``Continue` + `Back to Dashboard`;
`question``Continue` (primary) + `Skip` + `← Back` (hidden when `history` is
empty) + `Review & finish` + `Back to Dashboard`; `review``Create master resume`
(primary, success) + `Keep adding` + `Back to Dashboard`. At most one primary per
region (Swiss rule).
- **Right — live preview** (`360px`): a real, scaled resume layout that renders
`resume_data` (name/title, Experience, Education, Projects, Skills as chips). Empty
state: "Your resume appears here as you answer." Newly inferred skills get a brief
green-bordered `✓` accent. **No** counts, **no** orange warning box.
- **Mobile (`< lg`):** preview collapses to a `Peek ▸` toggle so the question stays
full-focus; expanding slides the preview over.
The dashboard entry tile and `MasterResumeChoiceDialog` are **unchanged**.
## Flow & State Machine
`step`: `intro → question → review → complete`.
- **intro** — one card asks name + target role. Submitting runs the adaptive turn with
`section: "intro"`: deterministically extract the name (existing `extract_intro_name`
as a fallback), let the AI capture the target role/summary direction and produce the
first real `next_question`.
- **question** — the adaptive loop. Each answer → one AI turn → updated `resume_data` +
next `current_question` + `inferred_skills` + `is_complete`. Repeats until the user
chooses review, or the server forces review at the question cap.
- **review** — deterministic (no AI call). Shows the assembled preview, gentle
**optional** notes (the relocated warnings), and `Create master resume` /
`Keep adding`.
- **complete** — finalize succeeded; route to `/builder?id=…`.
### Round-tripped state (`ResumeWizardState`)
The frontend holds state in React + `localStorage` ("resume_wizard_draft") and posts it
back each turn (stateless backend, as today). New shape:
| Field | Type | Notes |
|-------|------|-------|
| `step` | `'intro'\|'question'\|'review'\|'complete'` | |
| `resume_data` | `ResumeData` | unchanged shared shape |
| `current_question` | `{ text: str, section: str }` | `section` ∈ intro, workExperience, internships, education, personalProjects, skills, summary, contact, review |
| `history` | `list[{ question, answer, section, resume_data_before }]` | `resume_data_before` snapshot enables deterministic **Back** |
| `asked_count` | `int` | drives the cap + progress |
| `inferred_skills` | `list[str]` | last turn's detected skills (for the green accent) |
| `is_complete` | `bool` | AI *suggests* done; never auto-finalizes |
| `progress` | `{ current: int, total: int }` | **server-computed**, not from the model |
| `warnings` | `list[str]` | populated only at `review` |
**Removed from the old state:** `options` (section picker), `completed_sections`,
`current_section`-as-picker, `pending_questions`.
### `/turn` actions
- `start` — returns `build_initial_wizard_state()` (intro question). (Frontend may also
build this locally; endpoint kept for parity.)
- `answer` — core adaptive AI turn (covers intro + every section).
- `skip` — one AI turn flagged skip: the AI returns the next question **without**
modifying `resume_data`.
- `back`**deterministic**, no AI call: pop `history`, restore the previous
`current_question` + `resume_data_before` + `inferred_skills`.
- `review`**deterministic**, no AI call: `step='review'`, compute `warnings`.
## Backend
### `complete_json` turn contract
One call per `answer`/`skip`, `schema_type="resume"`, in the user's **content
language** (`get_language_name(get_content_language())`) so questions *and* content
localize. The model returns:
```json
{
"resume_data": { full ResumeData envelope },
"next_question": { "text": "…", "section": "workExperience" },
"inferred_skills": ["SQL"],
"is_complete": false
}
```
- `resume_data` is normalized through `normalize_resume_data` + `ResumeData.model_validate`
(same as today). The **section-scoped merge** guard is kept and **extended** so a turn
only writes the fields for `current_question.section`, never clobbering the rest:
`intro`/`contact``personalInfo` (name/title/contact); `summary``summary`;
`workExperience`/`internships``workExperience`; `education``education`;
`personalProjects``personalProjects`; `skills``additional.*`. Unknown sections
no-op on `resume_data` (defensive).
- Skills merge via the existing case-insensitive `merge_unique_skills`.
### Truthfulness (hard rule in the prompt)
Reuse the project policy: aggressively turn the user's own facts into strong bullets,
but **never fabricate** employers, titles, dates, degrees, metrics, tools, or skills.
If a fact is missing, the model must put the ask in `next_question` rather than invent.
(Consistent with the existing `CRITICAL_TRUTHFULNESS_RULES` and the maintainer policy.)
### Server-side guardrails (don't trust the model blindly)
- **Question cap** `RESUME_WIZARD_MAX_QUESTIONS = 15`: once `asked_count >= cap`, the
server overrides `is_complete = true`; the next `answer`/`skip` routes to review.
- **Progress computed server-side**: `total = min(cap, max(8, asked_count + (0 if is_complete else 2)))`,
`current = asked_count`. The bar reflects this, not a model number.
- **`is_complete` only suggests** review (surfaces "Review & finish"); the user can
always "Keep adding". Finalize is always user-initiated.
- **Fallbacks:** missing/blank `next_question` → deterministic per-section prompt
(`_section_prompt`, retained); invalid `resume_data` → keep prior draft, return the
same question, surface a retry (turn raises → 422/500 per the existing handler).
### Finalize — unchanged
`POST /resume-wizard/finalize` keeps current behavior: validate name present
(`ResumeWizardFinalizeRequest`), normalize, `db.create_resume_atomic_master(...)`,
reject with 409 if a ready master already exists, set the title, return
`{resume_id, processing_status:"ready", is_master}`. `build_review_warnings` is kept and
used both at the `review` step and for the gentle notes.
### Files
- `apps/backend/app/schemas/resume_wizard.py` — new state/turn/finalize models.
- `apps/backend/app/prompts/resume_wizard.py` — single adaptive writer/planner prompt.
- `apps/backend/app/services/resume_wizard.py` — adaptive turn, guards, deterministic
back/review/skip, progress, name extraction, skill merge, warnings.
- `apps/backend/app/routers/resume_wizard.py``/turn` (answer/skip/back/review/start)
+ `/finalize` (unchanged). Mount unchanged.
## Frontend
- `components/resume-wizard/resume-wizard-page.tsx` — rebuilt orchestrator: holds
state, renders `QuestionCard` + `LivePreview`, handles answer/skip/back/review/
finalize, localStorage persistence + the existing safe-draft normalizer (kept and
adapted to the new shape).
- `components/resume-wizard/question-card.tsx` — progress bar + kicker + question +
textarea + footer actions; "thinking" state while a turn is in flight.
- `components/resume-wizard/live-preview.tsx`**replaces** `draft-preview.tsx`:
renders `resume_data` as a real resume layout (no counts/warnings); empty state;
green accent for newly inferred skills.
- **Delete** `components/resume-wizard/section-picker.tsx`.
- `lib/api/resume-wizard.ts` — update types to the new state; helpers
`postResumeWizardTurn`, `finalizeResumeWizard`, `createInitialResumeWizardState`
retained with new shapes.
- `app/(default)/resume-wizard/page.tsx` — unchanged (thin wrapper).
- `messages/*.json` (all 5) — refresh `resumeWizard.*`: `kicker`, `title`, intro
question fallback, `actions {continue, skip, back, review, keepAdding, create,
backToDashboard}`, `preview {label, empty, unnamed}`, `review {readyTitle, note*}`,
`errors {turnFailed, finalizeFailed}`. Dynamic question/section text comes from the
backend (localized via content language); static chrome via these keys. Must satisfy
the build-breaking locale-parity rule.
## Error handling
- Turn failure → inline error on the card, **state preserved**, retry the same answer
(matches current behavior). Backend logs detail, returns generic message.
- Finalize `409` (master already exists) → explain + offer "Back to Dashboard".
- Network/timeout → the `apiFetch` 240s default + friendly "timed out" message.
## i18n
The static chrome is translated via `resumeWizard.*` in all five locales. The AI's
question text and the written resume content are produced in the **content language**
(the turn prompt takes `output_language`) — a net improvement over today's English-only
dynamic text. (Section *kickers* derived from `current_question.section` map through a
small i18n label table so they localize too.)
## Testing (anti-theater; must fail when the target breaks)
- **Backend unit** (`tests/unit/test_resume_wizard_service.py`): intro name extraction;
adaptive turn merges only the target section (mocked `complete_json`); skill merge
dedupe; **question cap forces `is_complete`**; **progress computed server-side**;
deterministic `back` restores the prior snapshot; `review` builds warnings without an
AI call; missing `next_question` falls back to the section prompt.
- **Backend integration** (`tests/integration/test_resume_wizard_api.py`): `/turn`
`answer` with mocked LLM returns next question + updated data; `/turn` `back`/`review`
need no LLM; `/finalize` creates a ready master; `/finalize` 409 when a master exists.
- **Frontend** (`tests/resume-wizard-api.test.ts`, `tests/resume-wizard-page.test.tsx`,
`tests/dashboard-master-choice.test.tsx`): initial state shape; posts turns;
full page flow (answer → next question + preview updates → Skip → Back → Review →
Create → routes to `/builder`, sets status cache, clears draft); live-preview renders
`resume_data`; choice dialog unchanged. Plus `i18n-locale-parity.test.ts`.
## Non-goals / out of scope
- No changes to `/builder`, the upload-parse flow, the entry dialog, or PDF/templates.
- No multi-master support; the single-master invariant stands.
- The live preview is purpose-built for the wizard; reusing the real resume render
templates (`components/resume/*`) at small scale is explicitly **deferred** — the full
template experience happens in `/builder` after finalize.
## Risks & mitigations
| Risk | Mitigation |
|------|-----------|
| One prompt does write + plan + completeness | strict JSON `schema_type="resume"`, validation, deterministic fallbacks for `next_question`/`resume_data` |
| Adaptive loop never ends / loops | question cap (15) forces review; manual "Review & finish" always available |
| Per-question latency | single call; subtle "thinking" state; no second round-trip |
| Model clobbers unrelated sections | section-scoped merge guard retained |
| `localStorage` growth from `resume_data_before` snapshots | small JSON; ~15 entries max; well under quota |
| Locale drift breaks `next build` | parity test + mirror every `resumeWizard.*` key across 5 files |