chore: import upstream snapshot with attribution
Publish Docker Image / publish (push) Has been cancelled
Publish Docker Image / publish (push) Has been cancelled
This commit is contained in:
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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.")
|
||||
```
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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!
|
||||
@@ -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
|
||||
@@ -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 1–6) **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 1–7
|
||||
**192 → ~444 deterministic tests** (+ 1 opt-in LLM-judge eval), **0 failures**. Phases 1–5 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 → 3–4 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.
|
||||
@@ -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
|
||||
@@ -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 200–800ms 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 200–800ms cold start |
|
||||
| `images.formats` | Serves AVIF/WebP when the browser supports it — typically 30–60% 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 | 1–2px 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 1–2px 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 1–2px black, never grey
|
||||
- Don't animate panel transitions — they snap
|
||||
@@ -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 | 3xl–5xl | bold | Set the tone of the page |
|
||||
| Section headers | serif | xl–2xl | 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 2–4 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 2–4).
|
||||
|
||||
- [ ] **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 `• ` 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 `• ` markers with `<span className={styles.arrow}>➜ </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 (T2–T4), dispatcher/thumbnail/labels/footer/accent/print-allow-list (T1+T2–T4), i18n×5 (T1), backend docstring (T1), tests (T1–T4), 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 ~4000–7500 input tokens and ~1000–2000 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 750–763)
|
||||
|
||||
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** | ~1000–2000 (full resume) | ~300–800 (changes only) | **~700–1200** |
|
||||
| **Total savings** | — | — | **~1550–2050 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 | ~4000–7500 | ~3500–6500 | -500–1000 |
|
||||
| Output tokens | ~1000–2000 | ~300–800 | -700–1200 |
|
||||
| Total tokens | ~5000–9500 | ~3800–7300 | **-25–30%** |
|
||||
| LLM call count | 1 | 1 | Same |
|
||||
| Wall-clock time | ~8–12s | ~4–8s | **~30–40% faster** |
|
||||
|
||||
### 9.2 Ollama impact
|
||||
|
||||
Ollama's generation speed is the bottleneck (token/s on local hardware). Reducing output tokens by 50–70% 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 | ~8–12s | ~4–8s | **Faster** |
|
||||
| Apply diffs | — | <1ms | New (local) |
|
||||
| Verify diffs | — | <1ms | New (local) |
|
||||
| Safety nets | <1ms | <1ms | Same |
|
||||
| Refine | ~3–8s | ~3–8s | Same |
|
||||
| Aux content | ~5–10s | ~5–10s | Same |
|
||||
| **Total** | **~19–33s** | **~15–29s** | **~15–20% 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 3–4
|
||||
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 **3–4 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
|
||||
→ 1–5) 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, 2–3 work entries, education, projects,
|
||||
skills, a custom section) + **3–4 JDs across distinct roles** (e.g. backend / frontend / ML / PM) — the
|
||||
user's "3–4 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 3–4 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 |
|
||||
Reference in New Issue
Block a user