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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:39:36 +08:00
commit 5bdf4cc89a
423 changed files with 88197 additions and 0 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,53 @@
# Resume Tailor Verifier Loop Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make full resume tailoring add JD-aligned skills and improve work/project wording through smaller structured LLM passes guarded by local verification.
**Architecture:** Keep the existing diff-based safety model, but split planning from editing. A first LLM call produces skill targets, a local verifier filters and classifies them, then the existing diff call receives the verified target plan and can append allowed JD skills while rewriting summary, work, and project bullets around those targets.
**Tech Stack:** Python 3.13, FastAPI, Pydantic v2, LiteLLM via `complete_json()`, pytest
---
## File Map
| File | Responsibility | Change |
|------|----------------|--------|
| `apps/backend/app/schemas/models.py` | Diff action model | Add `add_skill` action support |
| `apps/backend/app/prompts/templates.py` | LLM instructions | Add skill planning prompt and expand diff prompt with verified targets |
| `apps/backend/app/prompts/__init__.py` | Prompt exports | Export the planning prompt |
| `apps/backend/app/services/improver.py` | Tailoring harness | Add skill-plan generation, verifier, prompt wiring, `add_skill` applier |
| `apps/backend/tests/unit/test_apply_diffs.py` | Diff applier coverage | Add add-skill pass/reject tests |
| `apps/backend/tests/service/test_improver.py` | LLM prompt harness coverage | Add skill-plan parser/verifier and prompt wiring tests |
## Tasks
### Task 1: Add Verified Skill Additions
- [ ] Write failing tests proving `apply_diffs()` can append a new skill through `action="add_skill"` and reject duplicates/empty values.
- [ ] Update `ResumeChange.action` to include `add_skill`.
- [ ] Implement `add_skill` in `apply_diffs()` for `additional.technicalSkills` only.
- [ ] Run the focused unit tests.
### Task 2: Build and Verify a Skill Target Plan
- [ ] Write failing service tests for parsing LLM skill-plan output.
- [ ] Add `SKILL_TARGET_PLAN_PROMPT`.
- [ ] Add `generate_skill_target_plan()` using `complete_json(schema_type="skill_plan")`.
- [ ] Add `verify_skill_target_plan()` that keeps existing skills, allows JD-added skills, and rejects unsupported non-JD items.
- [ ] Run the focused service tests.
### Task 3: Wire Verified Targets into Diff Generation
- [ ] Write a failing test proving `generate_resume_diffs()` includes verified skill targets in the prompt and advertises `add_skill`.
- [ ] Pass `skill_targets` into `generate_resume_diffs()`.
- [ ] Expand `DIFF_IMPROVE_PROMPT` so full tailor can add verified JD skills and improve work/project bullets around them.
- [ ] In `_improve_preview_flow()`, run the plan before diff generation and feed verified targets into the diff pass.
- [ ] Run backend unit/service tests for improver, diff applier, and refiner.
### Task 4: Verify the Preview Path
- [ ] Run backend focused tests.
- [ ] Run frontend lint if frontend files changed; otherwise skip and state why.
- [ ] Report the exact commands and outcomes.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,553 @@
# LaTeX, Clean, Vivid Resume Templates — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add three new resume templates — `latex` (single-column serif), `clean` (single-column minimal sans), `vivid` (two-column colorful Awesome-CV style) — each faithful to a maintainer-provided reference image, wired through the existing template selection / preview / PDF pipeline.
**Architecture:** Each template is a React component reading `ResumeData` via `getSortedSections`, styled by a co-located CSS module using shared `_base.module.css` classes plus template-specific overrides. Registration mirrors how `modern` was added: extend `TemplateType` + `TEMPLATE_OPTIONS`, add a dispatcher branch in `resume-component.tsx`, a `TemplateThumbnail` case, label maps, the print-route allow-list, and i18n in 5 locales. `vivid` reuses the existing accent-color control + two-column grid; `latex`/`clean` are monochrome single-column.
**Tech Stack:** Next.js 16 / React 19, CSS modules, TypeScript, vitest + @testing-library/react (jsdom), i18n JSON (en/es/zh/ja/pt).
**Spec:** `docs/superpowers/specs/2026-06-03-resume-templates-latex-clean-vivid-design.md`
**Environment note:** Do NOT run `npm`/`npx` in this shell (nvm issues). After implementation, hand the maintainer: `cd apps/frontend && npm run test && npm run lint && npm run build`.
**Always-compiling rule:** Each task leaves the codebase type-valid. Task 1 registers all three IDs *and* their label/thumbnail/footer/accent wiring with placeholder behavior (default thumbnail, blank dispatcher body) so nothing breaks; Tasks 24 fill in each real component + thumbnail + dispatcher branch.
---
## Task 1: Register the three templates (types, options, allow-lists, i18n, controls)
**Files:**
- Modify: `apps/frontend/lib/types/template-settings.ts`
- Modify: `apps/frontend/components/builder/template-selector.tsx`
- Modify: `apps/frontend/components/builder/formatting-controls.tsx`
- Modify: `apps/frontend/components/builder/resume-builder.tsx`
- Modify: `apps/frontend/app/print/resumes/[id]/page.tsx`
- Modify: `apps/backend/app/routers/resumes.py`
- Modify: `apps/frontend/messages/{en,es,zh,ja,pt}.json`
- Test: `apps/frontend/tests/template-registration.test.ts`
- [ ] **Step 1: Write the failing test**`apps/frontend/tests/template-registration.test.ts`
```ts
import { describe, expect, it } from 'vitest';
import { TEMPLATE_OPTIONS, type TemplateType } from '@/lib/types/template-settings';
describe('template registration', () => {
it('includes all seven templates with non-empty metadata', () => {
const ids = TEMPLATE_OPTIONS.map((t) => t.id);
expect(ids).toEqual(
expect.arrayContaining<TemplateType>([
'swiss-single',
'swiss-two-column',
'modern',
'modern-two-column',
'latex',
'clean',
'vivid',
])
);
for (const opt of TEMPLATE_OPTIONS) {
expect(opt.name.length).toBeGreaterThan(0);
expect(opt.description.length).toBeGreaterThan(0);
}
});
});
```
- [ ] **Step 2: Run test, expect FAIL**`npm run test -- template-registration` → FAIL (ids missing).
- [ ] **Step 3: Extend the type + options** in `template-settings.ts`:
```ts
export type TemplateType =
| 'swiss-single'
| 'swiss-two-column'
| 'modern'
| 'modern-two-column'
| 'latex'
| 'clean'
| 'vivid';
```
Append to `TEMPLATE_OPTIONS`:
```ts
{
id: 'latex',
name: 'LaTeX',
description: 'Classic serif academic layout with ruled section headers',
},
{
id: 'clean',
name: 'Clean',
description: 'Minimal sans layout with large understated section headers',
},
{
id: 'vivid',
name: 'Vivid',
description: 'Colorful two-column layout with accent headers and arrow bullets',
},
```
- [ ] **Step 4: Add label entries** to the `templateLabels` object in BOTH `template-selector.tsx` and `formatting-controls.tsx` (append inside the object literal):
```ts
latex: {
name: t('builder.formatting.templates.latex.name'),
description: t('builder.formatting.templates.latex.description'),
},
clean: {
name: t('builder.formatting.templates.clean.name'),
description: t('builder.formatting.templates.clean.description'),
},
vivid: {
name: t('builder.formatting.templates.vivid.name'),
description: t('builder.formatting.templates.vivid.description'),
},
```
- [ ] **Step 5: Footer single/two-column logic**`resume-builder.tsx` (~line 946). Change the single-column condition to include the new single-column IDs:
```tsx
{templateSettings.template === 'swiss-single' ||
templateSettings.template === 'modern' ||
templateSettings.template === 'latex' ||
templateSettings.template === 'clean'
? t('builder.footer.singleColumn')
: t('builder.footer.twoColumn')}
```
- [ ] **Step 6: Accent-color visibility**`formatting-controls.tsx` (~line 207). Add `vivid` so the accent control shows for it:
```tsx
{(settings.template === 'modern' ||
settings.template === 'modern-two-column' ||
settings.template === 'vivid') && (
```
- [ ] **Step 7: Print-route allow-list**`app/print/resumes/[id]/page.tsx` `parseTemplate`:
```ts
if (
value === 'swiss-single' ||
value === 'swiss-two-column' ||
value === 'modern' ||
value === 'modern-two-column' ||
value === 'latex' ||
value === 'clean' ||
value === 'vivid'
) {
return value;
}
return 'swiss-single';
```
- [ ] **Step 8: Backend docstring**`apps/backend/app/routers/resumes.py` line ~1433 comment, update to:
```python
- template: swiss-single, swiss-two-column, modern, modern-two-column, latex, clean, or vivid
```
- [ ] **Step 9: i18n** — in each of `messages/{en,es,zh,ja,pt}.json`, add under `builder.formatting.templates` three keys `latex`, `clean`, `vivid`, each `{ "name", "description" }`. English values match Step 3; other locales use a translated name + description (keep `LaTeX` as a proper noun untranslated; translate `Clean`/`Vivid` descriptively where natural, else transliterate). Preserve existing key ordering and JSON validity.
- [ ] **Step 10: Run the registration test, expect PASS**`npm run test -- template-registration` (maintainer runs). Code still compiles; selecting a new template currently shows the default thumbnail + blank preview body (filled in Tasks 24).
- [ ] **Step 11: Commit**
```bash
git add apps/frontend/lib/types/template-settings.ts apps/frontend/components/builder/template-selector.tsx apps/frontend/components/builder/formatting-controls.tsx apps/frontend/components/builder/resume-builder.tsx "apps/frontend/app/print/resumes/[id]/page.tsx" apps/backend/app/routers/resumes.py apps/frontend/messages/en.json apps/frontend/messages/es.json apps/frontend/messages/zh.json apps/frontend/messages/ja.json apps/frontend/messages/pt.json apps/frontend/tests/template-registration.test.ts
git commit -m "feat(templates): register latex, clean, vivid template IDs + controls/i18n"
```
---
## Task 2: LaTeX template (single-column serif)
**Files:**
- Create: `apps/frontend/components/resume/resume-latex.tsx`
- Create: `apps/frontend/components/resume/styles/latex.module.css`
- Modify: `apps/frontend/components/resume/index.ts`
- Modify: `apps/frontend/components/dashboard/resume-component.tsx`
- Modify: `apps/frontend/components/builder/template-selector.tsx` (thumbnail case)
- Test: `apps/frontend/tests/resume-latex.test.tsx`
**Component spec (adapt from `resume-modern.tsx`, single-column flow):**
- Reads `getSortedSections`, renders `personalInfo` header + sections in order; uses `SafeHtml` for bullet HTML; `formatDateRange` for dates; honors `showContactIcons`.
- Header centered: `<h1>` name with class `styles.name` (serif, `font-variant: small-caps`, size `calc(var(--font-size-base) * var(--header-scale))`). Optional `personalInfo.title` → centered italic `styles.tagline`. `personalInfo.location` → centered `styles.locationLine`. Contact row centered using the same `renderContactDetail` helper as `resume-modern.tsx` but separated by a middle dot, icons gated on `showContactIcons`.
- Section header: `<h3 className={styles.sectionTitle}>` (Title-Case, serif, bold, full-width 1px rule).
- Experience/itemList entries (company-first):
- Row 1: `flex justify-between``<span styles.entryPrimary>{company}</span>` (bold) · `<span styles.entryDates>{formatDateRange(years)}</span>` (bold).
- Row 2: `flex justify-between``<span styles.entrySecondary>{title}</span>` (italic) · `<span styles.entrySecondary>{location}</span>` (italic).
- Bullets: `<ul className={baseStyles['resume-list']}>` with `•&nbsp;` markers (reuse modern's list markup).
- Projects: name (bold) + tech/links; dates right; bullets. Education: institution (bold) · dates right; degree italic. Additional: bold `Category:` labels + comma-joined items (reuse modern's `AdditionalSection`, swapping the title class for `styles.sectionTitle`). Custom sections via a `DynamicResumeSectionLatex` wrapper (copy modern's pattern, use `styles.sectionTitle`).
**CSS spec (`latex.module.css`):**
```css
@import './_tokens.css';
/* LaTeX template — single-typeface serif, driven by --header-font */
.container { width: 100%; }
.container,
.container :global(p),
.container :global(li),
.container :global(span) {
font-family: var(--header-font);
}
.name {
font-family: var(--header-font);
font-size: calc(var(--font-size-base) * var(--header-scale));
font-weight: 700;
font-variant: small-caps;
letter-spacing: 0.02em;
color: var(--resume-text-primary);
}
.tagline {
font-family: var(--header-font);
font-style: italic;
font-size: calc(var(--font-size-base) * 1.05);
color: var(--resume-text-body);
}
.locationLine {
font-family: var(--header-font);
font-size: var(--font-size-base);
color: var(--resume-text-body);
}
.sectionTitle {
font-family: var(--header-font);
font-size: calc(var(--font-size-base) * var(--section-header-scale));
font-weight: 700;
text-transform: none; /* Title-Case, override base uppercase */
letter-spacing: 0;
color: var(--resume-text-primary);
border-bottom: 1px solid var(--resume-text-primary);
padding-bottom: 0.1rem;
margin-bottom: var(--item-gap);
break-after: avoid;
page-break-after: avoid;
orphans: 3;
widows: 3;
}
.entryPrimary { font-weight: 700; color: var(--resume-text-primary); }
.entryDates { font-weight: 700; color: var(--resume-text-primary); white-space: nowrap; }
.entrySecondary { font-style: italic; color: var(--resume-text-body); }
@media print {
.sectionTitle { break-after: avoid !important; page-break-after: avoid !important; }
}
```
- [ ] **Step 1: Write the failing test**`apps/frontend/tests/resume-latex.test.tsx`
```tsx
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ResumeLatex } from '@/components/resume/resume-latex';
import type { ResumeData } from '@/components/dashboard/resume-component';
vi.mock('@/lib/i18n', () => ({ useTranslations: () => ({ t: (k: string) => k }) }));
const data: ResumeData = {
personalInfo: { name: 'Saurabh Rai', location: 'Delhi, India', email: 'a@b.com' },
workExperience: [
{ id: 1, title: 'DevRel Engineer', company: 'Apideck', location: 'Remote', years: '2025-Present', description: ['Lead client demos.'] },
],
additional: { technicalSkills: ['Python', 'TypeScript'] },
} as ResumeData;
describe('ResumeLatex', () => {
it('renders name, company, title and a bullet', () => {
render(<ResumeLatex data={data} />);
expect(screen.getByText('Saurabh Rai')).toBeInTheDocument();
expect(screen.getByText('Apideck')).toBeInTheDocument();
expect(screen.getByText('DevRel Engineer')).toBeInTheDocument();
expect(screen.getByText('Lead client demos.')).toBeInTheDocument();
});
});
```
- [ ] **Step 2: Run test, expect FAIL**`npm run test -- resume-latex` → FAIL (module not found).
- [ ] **Step 3: Create `resume-latex.tsx` + `latex.module.css`** per the specs above.
- [ ] **Step 4: Export** — add to `components/resume/index.ts`: `export { ResumeLatex } from './resume-latex';`
- [ ] **Step 5: Dispatcher branch** — in `resume-component.tsx` import `ResumeLatex` and add:
```tsx
{mergedSettings.template === 'latex' && (
<ResumeLatex
data={resumeData}
showContactIcons={mergedSettings.showContactIcons}
additionalSectionLabels={additionalSectionLabels}
/>
)}
```
- [ ] **Step 6: Thumbnail** — in `template-selector.tsx` add a `if (type === 'latex')` branch returning a single-column thumbnail with a centered name bar, a serif-feel ruled header line, and stacked content lines (reuse swiss-single thumbnail markup; add a centered top bar + an underline rule under each section line via `border-b`).
- [ ] **Step 7: Run test, expect PASS**`npm run test -- resume-latex`.
- [ ] **Step 8: Commit**
```bash
git add apps/frontend/components/resume/resume-latex.tsx apps/frontend/components/resume/styles/latex.module.css apps/frontend/components/resume/index.ts apps/frontend/components/dashboard/resume-component.tsx apps/frontend/components/builder/template-selector.tsx apps/frontend/tests/resume-latex.test.tsx
git commit -m "feat(templates): add LaTeX single-column serif template"
```
---
## Task 3: Clean template (single-column minimal sans)
**Files:**
- Create: `apps/frontend/components/resume/resume-clean.tsx`
- Create: `apps/frontend/components/resume/styles/clean.module.css`
- Modify: `apps/frontend/components/resume/index.ts`
- Modify: `apps/frontend/components/dashboard/resume-component.tsx`
- Modify: `apps/frontend/components/builder/template-selector.tsx`
- Test: `apps/frontend/tests/resume-clean.test.tsx`
**Component spec (adapt from `resume-latex.tsx`, single-typeface sans):**
- Header centered: `<h1 className={styles.name}>` (light weight, sans). Contact rendered as one `|`-separated line via a helper that joins the present `renderContactDetail` spans with `<span className={styles.sep}> | </span>`; icons gated on `showContactIcons`.
- Section header `styles.sectionTitle` (large, UPPERCASE, gray, letter-spaced, thin rule).
- Entries single-line: `<div className="flex justify-between">` → left: `<span styles.entryCompany>{company}</span>` (bold) + `<span styles.sep> | </span>` + `<span styles.entryRole>{title}</span>` (small-caps gray); right: `<span styles.entryMeta>{location} | {formatDateRange(years)}</span>`. Bullets below.
- Education/Projects analogous. Additional: `**Label:** items`.
**CSS spec (`clean.module.css`):**
```css
@import './_tokens.css';
/* Clean template — single-typeface sans, driven by --body-font */
.container { width: 100%; }
.container,
.container :global(p),
.container :global(li),
.container :global(span),
.container :global(h1),
.container :global(h3) {
font-family: var(--body-font);
}
.name {
font-size: calc(var(--font-size-base) * var(--header-scale));
font-weight: 400;
letter-spacing: 0.01em;
color: var(--resume-text-primary);
}
.sep { color: var(--resume-text-tertiary); padding: 0 0.4em; }
.sectionTitle {
font-size: calc(var(--font-size-base) * var(--section-header-scale) * 1.15);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--resume-text-tertiary);
border-bottom: 1px solid var(--resume-border-secondary);
padding-bottom: 0.15rem;
margin-bottom: var(--item-gap);
break-after: avoid;
page-break-after: avoid;
orphans: 3;
widows: 3;
}
.entryCompany { font-weight: 700; color: var(--resume-text-primary); }
.entryRole { font-variant: small-caps; color: var(--resume-text-tertiary); }
.entryMeta { color: var(--resume-text-tertiary); white-space: nowrap; }
@media print {
.sectionTitle { break-after: avoid !important; page-break-after: avoid !important; }
}
```
- [ ] **Step 1: Write the failing test**`apps/frontend/tests/resume-clean.test.tsx` (same shape as Task 2 test, importing `ResumeClean`, asserting name/company/title/bullet render).
- [ ] **Step 2: Run test, expect FAIL.**
- [ ] **Step 3: Create `resume-clean.tsx` + `clean.module.css`.**
- [ ] **Step 4: Export** from `index.ts`: `export { ResumeClean } from './resume-clean';`
- [ ] **Step 5: Dispatcher branch** for `'clean'` in `resume-component.tsx` (same shape as Task 2 Step 5).
- [ ] **Step 6: Thumbnail**`if (type === 'clean')` branch: centered light name bar + large gray uppercase header lines (use `opacity-60` gray bars + thin rule).
- [ ] **Step 7: Run test, expect PASS.**
- [ ] **Step 8: Commit**
```bash
git add apps/frontend/components/resume/resume-clean.tsx apps/frontend/components/resume/styles/clean.module.css apps/frontend/components/resume/index.ts apps/frontend/components/dashboard/resume-component.tsx apps/frontend/components/builder/template-selector.tsx apps/frontend/tests/resume-clean.test.tsx
git commit -m "feat(templates): add Clean single-column minimal template"
```
---
## Task 4: Vivid template (two-column colorful)
**Files:**
- Create: `apps/frontend/components/resume/resume-vivid.tsx`
- Create: `apps/frontend/components/resume/styles/vivid.module.css`
- Modify: `apps/frontend/components/resume/index.ts`
- Modify: `apps/frontend/components/dashboard/resume-component.tsx`
- Modify: `apps/frontend/components/builder/template-selector.tsx`
- Test: `apps/frontend/tests/resume-vivid.test.tsx`
**Component spec (adapt from `resume-modern-two-column.tsx`):**
- Reuse the column-split logic, `getSortedSections`/`getSectionMeta`, `sectionHeadings`, `fallbackLabels`, and grid (`styles.grid` / `styles.mainColumn` / `styles.sidebarColumn`).
- Header (full width, left-aligned, above grid): two-tone name — split `personalInfo.name` on first space: `<span className={styles.nameFirst}>{first}</span>` + `<span className={styles.nameRest}>{rest}</span>`. `personalInfo.title``<div className={styles.titleLine}>` (monospace). Contact row: monospace, each item wrapped `<span className={styles.contactChip}>` with a circular-icon wrapper `<span className={styles.iconCircle}>{icon}</span>` shown when `showContactIcons`, else text only.
- Section titles use `styles.sectionTitle` (main) and `styles.sectionTitleSm` (sidebar): accent color, `font-variant: small-caps`, bold.
- Bullets: replace `•&nbsp;` markers with `<span className={styles.arrow}>➜&nbsp;</span>` (accent color). Apply in main-column experience/projects and any bulleted lists.
- Sidebar skills: render each group as bold label + `•`-joined wrapping list (reuse modern-two-column sidebar markup; recolor label via default text).
**CSS spec (`vivid.module.css`):** copy the grid + responsive widths from `modern-two-column.module.css`, then:
```css
@import './_tokens.css';
.container { width: 100%; }
.grid { display: grid; grid-template-columns: 63% 37%; gap: calc(var(--section-gap) * 1.25); }
.mainColumn { min-width: 0; }
.sidebarColumn { min-width: 0; }
.nameFirst {
font-size: calc(var(--font-size-base) * var(--header-scale) * 1.2);
font-weight: 800;
color: var(--resume-accent-primary);
font-family: var(--body-font);
}
.nameRest {
font-size: calc(var(--font-size-base) * var(--header-scale) * 1.2);
font-weight: 400;
color: var(--resume-accent-primary);
opacity: 0.6;
font-family: var(--body-font);
}
.titleLine {
font-family: var(--resume-font-mono);
font-size: calc(var(--font-size-base) * 1.05);
color: var(--resume-text-tertiary);
margin-top: 0.1rem;
}
.contactChip {
font-family: var(--resume-font-mono);
font-size: calc(var(--font-size-base) * 0.8);
color: var(--resume-text-body);
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.iconCircle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border: 1px solid var(--resume-border-primary);
border-radius: 9999px;
color: var(--resume-text-tertiary);
}
.sectionTitle {
font-family: var(--header-font);
font-size: calc(var(--font-size-base) * var(--section-header-scale) * 1.1);
font-weight: 700;
font-variant: small-caps;
letter-spacing: 0.03em;
color: var(--resume-accent-primary);
margin-bottom: var(--item-gap);
break-after: avoid;
page-break-after: avoid;
}
.sectionTitleSm { font-family: var(--header-font); font-size: calc(var(--font-size-base) * var(--section-header-scale) * 0.95); font-weight: 700; font-variant: small-caps; color: var(--resume-accent-primary); margin-bottom: var(--item-gap); break-after: avoid; page-break-after: avoid; }
.arrow { color: var(--resume-accent-primary); font-weight: 700; flex-shrink: 0; }
@media print {
.nameFirst, .nameRest, .sectionTitle, .sectionTitleSm, .arrow {
color: var(--resume-accent-primary) !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
.sectionTitle, .sectionTitleSm { break-after: avoid !important; page-break-after: avoid !important; }
}
```
- [ ] **Step 1: Write the failing test**`apps/frontend/tests/resume-vivid.test.tsx`
```tsx
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ResumeVivid } from '@/components/resume/resume-vivid';
import type { ResumeData } from '@/components/dashboard/resume-component';
vi.mock('@/lib/i18n', () => ({ useTranslations: () => ({ t: (k: string) => k }) }));
const data: ResumeData = {
personalInfo: { name: 'Saurabh Rai', title: 'Solutions Architect', email: 'a@b.com' },
workExperience: [
{ id: 1, title: 'DevRel Engineer', company: 'Apideck', years: '2025-Present', description: ['Lead client demos.'] },
],
additional: { technicalSkills: ['Python'] },
} as ResumeData;
describe('ResumeVivid', () => {
it('renders a two-tone name, company and bullet', () => {
render(<ResumeVivid data={data} />);
expect(screen.getByText('Saurabh')).toBeInTheDocument(); // first token only
expect(screen.getByText('Rai')).toBeInTheDocument(); // remaining tokens
expect(screen.getByText('Apideck')).toBeInTheDocument();
expect(screen.getByText('Lead client demos.')).toBeInTheDocument();
});
});
```
- [ ] **Step 2: Run test, expect FAIL.**
- [ ] **Step 3: Create `resume-vivid.tsx` + `vivid.module.css`.**
- [ ] **Step 4: Export** from `index.ts`: `export { ResumeVivid } from './resume-vivid';`
- [ ] **Step 5: Dispatcher branch** for `'vivid'` in `resume-component.tsx`, passing `sectionHeadings` and `fallbackLabels` like `modern-two-column`.
- [ ] **Step 6: Thumbnail**`if (type === 'vivid')` branch: two-column thumbnail with an accent top bar (two-tone), accent section header bars, and accent arrow-tick marks on left column lines (reuse `modern-two-column` thumbnail markup, recolor to accent).
- [ ] **Step 7: Run test, expect PASS.**
- [ ] **Step 8: Commit**
```bash
git add apps/frontend/components/resume/resume-vivid.tsx apps/frontend/components/resume/styles/vivid.module.css apps/frontend/components/resume/index.ts apps/frontend/components/dashboard/resume-component.tsx apps/frontend/components/builder/template-selector.tsx apps/frontend/tests/resume-vivid.test.tsx
git commit -m "feat(templates): add Vivid two-column colorful template"
```
---
## Task 5: parseTemplate test + docs
**Files:**
- Test: `apps/frontend/tests/print-route-parse.test.ts` (only if `parseTemplate` is exported; otherwise fold the allow-list assertion into `template-registration.test.ts` by re-testing `TEMPLATE_OPTIONS` ids — see Step 1)
- Modify: `docs/agent/design/template-system.md`
- Modify: `docs/agent/features/resume-templates.md`
- [ ] **Step 1: parseTemplate coverage.** `parseTemplate` is a module-local function in `page.tsx` (not exported). Do NOT export server-route internals just for a test. Instead assert the allow-list intent at the type/options layer: confirm `template-registration.test.ts` (Task 1) already pins the seven IDs, which is the source of truth `parseTemplate` mirrors. Add a comment in `parseTemplate` referencing `TEMPLATE_OPTIONS` so the two stay in sync. No new test file.
- [ ] **Step 2: Update docs tables.** In `docs/agent/design/template-system.md` and `docs/agent/features/resume-templates.md`, add `latex`, `clean`, `vivid` rows to the template tables with one-line descriptions matching `TEMPLATE_OPTIONS`. Note `vivid` supports the accent-color control.
- [ ] **Step 3: Commit**
```bash
git add "apps/frontend/app/print/resumes/[id]/page.tsx" docs/agent/design/template-system.md docs/agent/features/resume-templates.md
git commit -m "docs(templates): document latex, clean, vivid templates"
```
---
## Final Verification (maintainer runs — npm avoided in agent shell)
```bash
cd apps/frontend
npm run test # all suites incl. template-registration + 3 component smoke tests
npm run lint
npm run build
```
Then visually confirm in the builder: select LaTeX / Clean / Vivid, verify live preview matches the reference images, toggle Contact Icons, change accent color (Vivid), and export a PDF via the print route for each.
## Self-Review
- **Spec coverage:** types/options (T1), 3 components+CSS (T2T4), dispatcher/thumbnail/labels/footer/accent/print-allow-list (T1+T2T4), i18n×5 (T1), backend docstring (T1), tests (T1T4), docs (T5). All spec sections mapped. ✓
- **Placeholders:** wiring/test/CSS code is inline and complete; component bodies are specified as precise deltas from named existing files (`resume-modern.tsx`, `resume-modern-two-column.tsx`) — acceptable given they are direct structural analogues. ✓
- **Type consistency:** IDs `latex`/`clean`/`vivid`, components `ResumeLatex`/`ResumeClean`/`ResumeVivid`, classes `styles.sectionTitle`/`styles.name`/`styles.arrow`/`styles.nameFirst`/`styles.nameRest` referenced consistently across tasks. ✓
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,527 @@
# Diff-Based Resume Improvement — Design Spec
> **Status**: Design
> **Date**: 2026-03-23
> **Scope**: Backend improvement pipeline — `improver.py`, `refiner.py`, `resumes.py`, `templates.py`
---
## 1. Problem Statement
The current `improve_resume()` function sends the entire resume + job description + keywords + truthfulness rules + schema example to the LLM in a single prompt and asks for the **entire resume back as JSON**. This is ~40007500 input tokens and ~10002000 output tokens.
This single-prompt, full-output approach is the root cause of hallucination in the pipeline. The LLM must faithfully reproduce every field it doesn't want to change — company names, dates, bullet points, skills — and every reproduced field is a chance to hallucinate.
### 1.1 Hallucination vectors found in codebase
| # | Vector | Current mitigation | Gap |
|---|--------|-------------------|-----|
| 1 | **Dropped work/education/project entries** | None | **Undetected** — silently lost |
| 2 | **Fabricated skills** | `validate_master_alignment()` in refiner removes them *after* | Reactive, not preventive |
| 3 | **Renamed companies/institutions** | `validate_master_alignment()` checks new companies | Only catches *new* companies, not renames of existing ones |
| 4 | **Invented metrics** ("improved by 40%") | Nothing | **Undetected** — passes all checks |
| 5 | **Dates truncated** ("Jan 2023 - Mar 2024" → "2023 - 2024") | 3 safety nets restore them | Works, but wastes LLM output tokens reproducing dates |
| 6 | **personalInfo in output** | `_preserve_personal_info()` overwrites it | Works, but wastes LLM tokens outputting content it shouldn't |
| 7 | **Over-elaboration** (word count doubles) | Nothing | **Undetected** |
| 8 | **AI phrasing** ("spearheaded", "leveraged") | `remove_ai_phrases()` replaces ~50 blacklisted terms *after* (~47 with mapped replacements) | Reactive — LLM generates them, then they're stripped |
| 9 | **Custom section fabrication** | `_protect_custom_sections()` trims *after* | Reactive |
| 10 | **Context bleed** (JD company names appearing in resume) | Nothing | **Undetected** |
### 1.2 Current safety nets (resumes.py lines 750763)
Six post-processing functions patch the LLM output:
1. `_preserve_personal_info()` — overwrites personalInfo from original
2. `_restore_original_dates()` — restores month-precision dates
3. `restore_dates_from_markdown()` — fallback date restoration from original PDF markdown
4. `_preserve_original_skills()` — appends any skills the LLM dropped
5. `_protect_custom_sections()` — trims hallucinated items, reverts fabricated descriptions
6. `refine_resume()` — 3-pass refinement (keyword injection, AI phrase removal, alignment validation)
All six exist because the LLM is asked to reproduce content it shouldn't touch. A diff-based approach eliminates that class of problem by construction.
### 1.3 Why not section-by-section parallel calls
We evaluated splitting into parallel per-section LLM calls (summary, experience, projects, skills). This was rejected because:
- **Ollama users**: Ollama serves one request at a time by default (`num_parallel=1`). Parallel calls queue sequentially, making it *slower* than one call (5 prompt ingestions vs 1).
- **Still full-output**: Each section call still regenerates full section text — hallucination risk within each section is unchanged.
- **Half-measure**: Reduces blast radius but doesn't change the fundamental approach.
---
## 2. Solution: Diff-Based Output
Instead of asking the LLM to output the entire resume, ask it to output only what it wants to **change**. The original resume is preserved programmatically, and changes are applied as targeted diffs.
### 2.1 Architecture
```
extract_keywords ──→ generate_diffs ──→ apply_diffs ──→ verify ──→ refiner ──→ aux content
LLM #1 LLM #2 (local) (local) LLM #3 LLM #4-6
```
- **`generate_diffs()`** — new LLM call that returns a list of targeted changes
- **`apply_diffs()`** — local function that applies verified changes to the original resume
- **`verify_diff_result()`** — local quality checks on the result
- **Refiner** — unchanged, receives the full dict produced by `apply_diffs()`
- **Frontend response** — unchanged (`resume_preview`, `diff_summary`, `detailed_changes`)
### 2.2 What each hallucination vector looks like after this change
| # | Vector | How diff-based prevents it |
|---|--------|--------------------------|
| 1 | Dropped entries | **Eliminated** — original structure is the base, entries can't be dropped |
| 2 | Fabricated skills | **Catchable** — skill changes are explicit diffs, verified against JD keywords |
| 3 | Renamed companies | **Eliminated** — company/institution fields are blocked in applier |
| 4 | Invented metrics | **Catchable** — verifier flags new numbers in diffs that weren't in original |
| 5 | Dates truncated | **Eliminated** — date fields are blocked in applier |
| 6 | personalInfo leak | **Eliminated** — personalInfo is blocked in applier |
| 7 | Over-elaboration | **Reduced** — only changed text can grow; unchanged text is preserved exactly |
| 8 | AI phrasing | **Reduced** — smaller output surface area; AI phrase removal still runs after |
| 9 | Custom section fabrication | **Eliminated** — custom sections are blocked in applier |
| 10 | Context bleed | **Catchable** — each diff is inspectable; verifier can check for JD company names |
---
## 3. Diff Schema
### 3.1 `ResumeChange` — a single targeted change
```python
class ResumeChange(BaseModel):
"""A single change the LLM wants to make to the resume."""
path: str
action: Literal["replace", "append", "reorder"]
original: str | None = None
value: str | list[str]
reason: str
```
| Field | Purpose |
|-------|---------|
| `path` | Dot + bracket path to the field: `"workExperience[0].description[1]"` |
| `action` | `"replace"` (swap content), `"append"` (add to list), `"reorder"` (reorder list) |
| `original` | The current text at that path — LLM echoes it so the applier can verify |
| `value` | The new content (string or list of strings for reorder) |
| `reason` | Why this change helps match the JD — forces LLM self-justification |
### 3.2 `ImproveDiffResult` — full LLM output
```python
class ImproveDiffResult(BaseModel):
"""What the LLM returns instead of a full resume."""
changes: list[ResumeChange]
strategy_notes: str
```
### 3.3 Allowed paths
The applier enforces a whitelist of paths the LLM can target:
| Path pattern | What it modifies | Action types |
|---|---|---|
| `summary` | The summary text | replace |
| `workExperience[i].description[j]` | A specific bullet point | replace |
| `workExperience[i].description` | The bullet list (append new bullet) | append |
| `personalProjects[i].description[j]` | A specific project bullet | replace |
| `personalProjects[i].description` | The bullet list (append new bullet) | append |
| `additional.technicalSkills` | Skills list ordering | reorder |
### 3.4 Blocked paths
These paths are rejected by the applier regardless of what the LLM outputs:
| Path pattern | Why blocked |
|---|---|
| `personalInfo.*` | Always preserved from original |
| `*.years` | Dates are immutable |
| `*.company`, `*.institution` | Identity fields |
| `*.title` (workExperience), `*.degree` | Identity fields |
| `*.name` (personalProjects) | Identity fields |
| `*.role`, `*.github`, `*.website` (personalProjects) | Identity/metadata fields — not content to tailor |
| `*.location` | Identity fields |
| `customSections.*` | Protected separately |
| `education[*].*` | Rarely relevant to tailoring. Note: `education[*].description` could benefit from coursework rephrasing in some cases — this is a deliberate design choice to keep education immutable for now. Can be revisited if users request it. |
---
## 4. Diff Applier
### 4.1 Function signature
```python
def apply_diffs(
original: dict[str, Any],
changes: list[ResumeChange],
) -> tuple[dict[str, Any], list[ResumeChange], list[ResumeChange]]:
"""Apply verified diffs to original resume.
Args:
original: The original resume data (ResumeData-compatible dict)
changes: List of changes from the LLM
Returns:
(result_dict, applied_changes, rejected_changes)
"""
```
### 4.2 Verification before applying each change
Each change goes through 4 gates before being applied:
1. **Path exists**`workExperience[0].description[1]` must resolve to a real value in the original dict. If the LLM references an index that doesn't exist, the change is rejected.
2. **Path is allowed** — checked against the whitelist (Section 3.3). If the path matches a blocked pattern (Section 3.4), rejected.
3. **Original text matches** — the `original` field in the diff is compared (case-insensitive, stripped) against the actual value at that path. If they don't match, the LLM hallucinated the original content → rejected.
4. **No identity mutation** — even if the path is technically allowed, the applier checks that company names, titles, institutions, and degrees are unchanged in the result.
### 4.3 Change application
Changes that pass all 4 gates are applied to a `copy.deepcopy()` of the original:
- **`replace`**: Set the value at the resolved path
- **`append`**: Append the value to the list at the resolved path
- **`reorder`**: Validate that the reordered list contains exactly the same items (case-insensitive), then replace
### 4.4 Rejected changes
Changes that fail any gate are **rejected individually** (not all-or-nothing). The applier returns both the applied and rejected lists. Rejected changes generate warnings that flow through to the frontend via `response_warnings`.
---
## 5. Diff Verifier
After `apply_diffs()` produces the result, a local verifier checks for quality issues.
### 5.1 Function signature
```python
def verify_diff_result(
original: dict[str, Any],
result: dict[str, Any],
applied_changes: list[ResumeChange],
job_keywords: dict[str, Any],
) -> list[str]:
"""Local quality checks on the diff result. Returns list of warnings."""
```
### 5.2 Checks
| # | Check | What it catches | Severity |
|---|-------|-----------------|----------|
| 1 | Section counts preserved | Same number of work entries, education, projects in original vs result | Warning |
| 2 | Identity fields unchanged | Company names, titles, institutions, degrees match original exactly | Warning |
| 3 | Word count ratio | Total description word count didn't exceed 1.8x original | Warning |
| 4 | Skills only from JD or original | Any new skill must exist in `job_keywords` or original resume | Warning |
| 5 | No invented metrics | If a `replace` diff adds a number pattern (`\d+%`, `\d+x`, `\$\d+`) that wasn't in the original bullet at that path | Warning |
| 6 | No empty result | If zero changes were applied, warn (may indicate prompt failure) | Warning |
All checks are local (zero LLM cost). Warnings are informational — they don't block the response. They flow through to `response_warnings` in the API response.
---
## 6. Prompt Template
### 6.1 New prompt: `DIFF_IMPROVE_PROMPT`
```
Given this resume and job description, output a JSON object with targeted changes to better align the resume with the job.
RULES:
1. Only modify content — never change names, companies, dates, institutions, or degrees
2. Do not invent skills, metrics, or achievements not supported by the original resume text
3. Do not add new work entries, education entries, or project entries
4. You may: rephrase existing bullets, add new bullets to existing entries, adjust summary, reorder skills
5. Each change MUST include the original text (copied exactly) so it can be verified
6. For each change, explain WHY it helps match the job description
7. Generate all new text in {output_language}
8. Do not use em dash characters
9. Keep changes minimal and targeted — do not rewrite content that already aligns well
Keywords to emphasize (only if already supported by resume content):
{job_keywords}
Job Description:
{job_description}
Original Resume:
{original_resume}
Output this exact JSON format, nothing else:
{{
"changes": [
{{
"path": "workExperience[0].description[1]",
"action": "replace",
"original": "the exact original text at this path",
"value": "the improved text",
"reason": "why this change helps"
}},
{{
"path": "summary",
"action": "replace",
"original": "the current summary text",
"value": "the improved summary",
"reason": "why this change helps"
}},
{{
"path": "additional.technicalSkills",
"action": "reorder",
"original": null,
"value": ["most relevant skill first", "then next", "..."],
"reason": "reordered to prioritize JD-relevant skills"
}}
],
"strategy_notes": "brief summary of the tailoring approach"
}}
```
### 6.2 Token budget comparison
| Component | Current prompt | Diff prompt | Savings |
|-----------|---------------|-------------|---------|
| Instructions + rules | ~400 tokens | ~250 tokens | ~150 |
| Truthfulness rules (9 rules) | ~400 tokens | 0 (rules are structural) | ~400 |
| Schema example (IMPROVE_SCHEMA_EXAMPLE) | ~450 tokens | ~150 (diff example) | ~300 |
| Job description | same | same | 0 |
| Keywords | same | same | 0 |
| Original resume | same | same | 0 |
| **Output tokens** | ~10002000 (full resume) | ~300800 (changes only) | **~7001200** |
| **Total savings** | — | — | **~15502050 tokens** |
### 6.3 Prompt selection: strategy-aware
The diff prompt replaces all 3 current prompts (nudge, keywords, full). Strategy is controlled by instruction intensity within the same prompt:
```python
DIFF_STRATEGY_INSTRUCTIONS = {
"nudge": "Make minimal edits. Only rephrase where there is a clear match. Do not add new bullet points.",
"keywords": "Weave in relevant keywords where evidence already exists. You may rephrase bullets but do not add new ones.",
"full": "Make targeted adjustments. You may rephrase bullets and add new ones that elaborate on existing work, but do not invent new responsibilities.",
}
```
This replaces the 3 separate prompt templates (`IMPROVE_RESUME_PROMPT_NUDGE`, `_KEYWORDS`, `_FULL`) and the 3 separate truthfulness rule variants (`CRITICAL_TRUTHFULNESS_RULES`).
---
## 7. Integration with Existing Pipeline
### 7.1 What stays the same
| Component | File | Change? |
|-----------|------|---------|
| `extract_job_keywords()` | `improver.py` | No change |
| `refine_resume()` | `refiner.py` | No change — receives full dict from `apply_diffs()` |
| `analyze_keyword_gaps()` | `refiner.py` | No change |
| `inject_keywords()` | `refiner.py` | No change |
| `remove_ai_phrases()` | `refiner.py` | No change |
| `validate_master_alignment()` | `refiner.py` | No change |
| `calculate_resume_diff()` | `improver.py` | No change — compares original vs final |
| `generate_improvements()` | `improver.py` | No change |
| Auxiliary content generation | `cover_letter.py` | No change |
| Frontend response format | `schemas/models.py` | No change — `resume_preview`, `diff_summary`, `detailed_changes` populated as before |
| Preview → confirm hash validation | `resumes.py` | No change |
| `complete_json()` | `llm.py` | No change — diff output is valid JSON |
### 7.2 What changes
| Component | File | Change |
|-----------|------|--------|
| `improve_resume()` | `improver.py` | Replaced by `generate_resume_diffs()` |
| New: `apply_diffs()` | `improver.py` | New function — applies verified diffs to original |
| New: `verify_diff_result()` | `improver.py` | New function — local quality checks |
| New: `ResumeChange`, `ImproveDiffResult` | `schemas/models.py` | New Pydantic models for diff schema |
| New: `DIFF_IMPROVE_PROMPT` | `prompts/templates.py` | New prompt template |
| New: `DIFF_STRATEGY_INSTRUCTIONS` | `prompts/templates.py` | Per-strategy instruction variants |
| `_improve_preview_flow()` | `routers/resumes.py` | Orchestrates: generate_diffs → apply → verify → refine |
### 7.3 Safety nets after the change
| Safety net | Still needed? | Why |
|-----------|--------------|-----|
| `_preserve_personal_info()` | **Keep as fallback** — should never activate (applier blocks personalInfo) |
| `_restore_original_dates()` | **Keep as fallback** — should never activate (applier blocks dates) |
| `restore_dates_from_markdown()` | **Keep as fallback** — should never activate |
| `_preserve_original_skills()` | **Keep as fallback**`reorder` action preserves all items |
| `_protect_custom_sections()` | **Keep as fallback** — applier blocks customSections |
| `refine_resume()` | **Keep, unchanged** — alignment validation, keyword injection, AI phrase removal still valuable |
Defense in depth: the applier is the primary guard, safety nets are the secondary guard. If a bug in the applier lets something through, the safety nets catch it.
### 7.4 Updated flow in `_improve_preview_flow()`
```python
# Current (lines 739-746 in resumes.py):
improved_data = await improve_resume(
original_resume=resume["content"],
job_description=job["content"],
job_keywords=job_keywords,
language=language,
prompt_id=prompt_id,
original_resume_data=original_resume_data,
)
# New:
diff_result = await generate_resume_diffs(
original_resume=resume["content"],
job_description=job["content"],
job_keywords=job_keywords,
language=language,
prompt_id=prompt_id,
original_resume_data=original_resume_data,
)
improved_data, applied, rejected = apply_diffs(
original=original_resume_data,
changes=diff_result.changes,
)
warnings = verify_diff_result(
original=original_resume_data,
result=improved_data,
applied_changes=applied,
job_keywords=job_keywords,
)
response_warnings.extend(warnings)
if rejected:
response_warnings.append(
f"{len(rejected)} change(s) rejected during verification"
)
```
Everything downstream (`_preserve_personal_info`, `_restore_original_dates`, `refine_resume`, `calculate_resume_diff`, etc.) remains unchanged — it receives `improved_data` as a full dict, same as today.
---
## 8. Retry Mechanism
### 8.1 Transport-level retries (unchanged)
`complete_json()` in `llm.py` handles:
- Malformed JSON → retry with hint
- Truncated output → retry with hint
- Empty response → retry
- Temperature escalation per retry (0.1 → 0.3 → 0.5 → 0.7)
This stays as-is. The diff JSON output works with all existing retry logic.
### 8.2 Content-level retries (implicit via rejection)
The diff approach makes explicit retry loops unnecessary:
- **Bad diff** (wrong path, mismatched original text) → **rejected by applier** → original content preserved
- **All diffs rejected** → result is the original resume unchanged → warning generated
- **Some diffs rejected** → partial improvement applied → warning lists what was rejected
This is safer than retrying with feedback, which risks compounding hallucination. The worst case is "no changes applied" rather than "wrong changes applied."
### 8.3 `_appears_truncated()` compatibility
The existing truncation detector in `llm.py` checks for empty `workExperience`, `education`, or `skills` arrays. Note: `skills` is a legacy key name — the actual schema uses `additional.technicalSkills`, so this check already doesn't catch all truncation cases in the current flow either.
A diff-based output won't contain these arrays at all — it contains a `changes` list. This means:
- `_appears_truncated()` won't trigger false positives (no empty resume arrays to check)
- Custom truncation detection: if `changes` is an empty list, the LLM may have failed to generate diffs. Log a warning but proceed (returns original resume unchanged).
---
## 9. Latency & Cost Impact
### 9.1 Per-call comparison
| Metric | Current (full output) | Diff-based | Change |
|--------|----------------------|------------|--------|
| Input tokens | ~40007500 | ~35006500 | -5001000 |
| Output tokens | ~10002000 | ~300800 | -7001200 |
| Total tokens | ~50009500 | ~38007300 | **-2530%** |
| LLM call count | 1 | 1 | Same |
| Wall-clock time | ~812s | ~48s | **~3040% faster** |
### 9.2 Ollama impact
Ollama's generation speed is the bottleneck (token/s on local hardware). Reducing output tokens by 5070% directly reduces generation time. This is the biggest win for local users.
### 9.3 Pipeline total
| Pipeline step | Current | After | Change |
|---|---|---|---|
| Extract keywords | ~3s | ~3s | Same |
| Improve/diffs | ~812s | ~48s | **Faster** |
| Apply diffs | — | <1ms | New (local) |
| Verify diffs | — | <1ms | New (local) |
| Safety nets | <1ms | <1ms | Same |
| Refine | ~38s | ~38s | Same |
| Aux content | ~510s | ~510s | Same |
| **Total** | **~1933s** | **~1529s** | **~1520% faster** |
---
## 10. File Change Summary
| # | File | Type | Description |
|---|------|------|-------------|
| 1 | `app/schemas/models.py` | Add | `ResumeChange` and `ImproveDiffResult` Pydantic models |
| 2 | `app/prompts/templates.py` | Add | `DIFF_IMPROVE_PROMPT` and `DIFF_STRATEGY_INSTRUCTIONS` |
| 3 | `app/prompts/__init__.py` | Modify | Export `DIFF_IMPROVE_PROMPT` and `DIFF_STRATEGY_INSTRUCTIONS` |
| 4 | `app/services/improver.py` | Add | `generate_resume_diffs()`, `apply_diffs()`, `verify_diff_result()` |
| 5 | `app/services/improver.py` | Keep | `improve_resume()` kept for backward compatibility / fallback |
| 6 | `app/routers/resumes.py` | Modify | `_improve_preview_flow()` calls new diff functions |
| 7 | `app/llm.py` | No change | `complete_json()` works as-is with diff JSON |
| 8 | `app/services/refiner.py` | No change | Receives full dict from `apply_diffs()` |
| 9 | `app/services/cover_letter.py` | No change | Receives full dict |
| 10 | `app/routers/enrichment.py` | No change | Separate feature |
| 11 | Frontend | No change | `resume_preview`, `diff_summary`, `detailed_changes` populated as before |
### 10.1 What we're NOT changing
- `llm.py` — transport-level retry and JSON extraction stay as-is
- `refiner.py` — all 3 passes (keyword injection, AI phrase removal, alignment validation) stay as-is
- Safety nets in `resumes.py` — kept as defense-in-depth fallback
- Frontend response format — `ImproveResumeResponse` unchanged
- Preview → confirm hash validation — unchanged
- No new dependencies (no LangChain, no workflow engine)
- `improve_resume()` preserved as fallback (not deleted)
---
## 11. Implementation Order
1. **Add schemas**`ResumeChange`, `ImproveDiffResult` in `schemas/models.py`
2. **Add prompt**`DIFF_IMPROVE_PROMPT`, `DIFF_STRATEGY_INSTRUCTIONS` in `prompts/templates.py`
3. **Add `generate_resume_diffs()`** — new LLM call function in `improver.py`
4. **Add `apply_diffs()`** — diff applier with path resolution and verification gates in `improver.py`
5. **Add `verify_diff_result()`** — local quality checks in `improver.py`
6. **Wire into `_improve_preview_flow()`** — update orchestration in `resumes.py`
7. **Test with existing frontend** — no frontend changes needed
8. **Keep `improve_resume()` as fallback** — can be selected via config flag during rollout
---
## 12. Risks & Mitigations
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| LLM outputs wrong path indices | Medium | Low | Applier verifies `original` text matches — wrong index = mismatch = rejected |
| LLM outputs empty changes list | Low | Low | Warning generated, original resume returned unchanged |
| LLM ignores diff format, outputs full resume | Low | Medium | `complete_json()` parses whatever JSON it returns; if no `changes` key, treat as 0 changes + warning |
| Smaller models struggle with diff format | Medium | Medium | Keep `improve_resume()` as fallback for models that can't follow diff instructions |
| `apply_diffs()` has a bug that corrupts data | Low | High | Safety nets still run after (defense in depth); `improve_resume()` fallback available |
| Diff verification rejects too aggressively | Medium | Low | Individual rejection (not all-or-nothing); user sees partial improvement rather than nothing |
---
## 13. Success Criteria
- [ ] Zero structural deviations (dropped entries, renamed companies) in diff-based output
- [ ] Invented metrics flagged by verifier in >90% of cases
- [ ] Token usage reduced by 25%+ vs current approach
- [ ] All existing frontend flows work without changes
- [ ] Safety nets rarely activate (tracked via logging)
- [ ] `npm run lint` passes
- [ ] Python functions have type hints
@@ -0,0 +1,36 @@
# Health Check Fix — Stop LLM Calls on Docker Liveness Probe
**Date:** 2026-04-10
**Issue:** [#746](https://github.com/srbhr/Resume-Matcher/issues/746)
**Approach:** A (Minimal)
## Problem
The Docker `HEALTHCHECK` pings `GET /api/v1/health` every 10 seconds. That endpoint calls `check_llm_health()`, which fires a real `litellm.acompletion()` request to the configured LLM provider. This burns ~8,640 billable API calls per day, costing ~$1.50/day on GPT-5.4 via OpenRouter.
## Fix
Make `/health` a zero-cost liveness check. The LLM connectivity check remains available via `/status` (user-initiated only).
### Changes
**`apps/backend/app/routers/health.py`**
- Remove `check_llm_health` import (keep `get_llm_config` import for `/status`)
- `/health` handler returns `HealthResponse(status="healthy")` with no LLM call
- `/status` handler unchanged — it still calls `check_llm_health()` for the settings UI
**`apps/backend/app/schemas/models.py`**
- `HealthResponse`: remove the `llm: dict[str, Any]` field, keep only `status: str`
### What stays the same
- Dockerfile `HEALTHCHECK` command and interval — no changes needed
- `/status` endpoint — still calls `check_llm_health()`, used by frontend settings page
- Frontend `StatusCacheProvider` and all `useStatusCache` consumers — unaffected
- `check_llm_health()` function in `llm.py` — untouched
## Verification
- `GET /health` returns `{"status": "healthy"}` without making any external calls
- `GET /status` still returns `llm_healthy: true/false` with a real LLM check
- Settings page still shows LLM health status correctly
@@ -0,0 +1,279 @@
# Custom prompts for cover letter & cold outreach
**Date:** 2026-04-17
**Issue:** #749
**Branch:** `dev` (bundle into PR with #754 and #751)
## Problem
`COVER_LETTER_PROMPT` and `OUTREACH_MESSAGE_PROMPT` are module constants in `apps/backend/app/prompts/templates.py`. Users want to customize length, style, and content (e.g., "150 words", "include company research", "bold specific keywords"). Currently the only way is to fork the repo.
The resume-generation prompt already supports customization — users pick between three built-in variants ("nudge", "keywords", "full") via the Settings "Prompt Profile" section. That pattern doesn't extend to cover letter / outreach, where users want free-form text override rather than a fixed menu.
## Goals
- User can override the default prompt text for cover letter and cold outreach, per feature.
- Empty / absent override = use default prompt (unchanged behavior).
- Custom prompts must inject the same placeholders as the defaults (`{job_description}`, `{resume_data}`, `{output_language}`); missing placeholders are rejected at save time with a 422.
- UI surfaces the default prompt as placeholder text and offers a "Reset to default" button.
- Resume-generation prompt (which already has variants) is NOT touched in this bucket — deferred.
## Non-goals
- Per-resume prompt overrides (the custom prompt is global).
- Custom prompts for enrichment, refinement, JD matching, or resume title generation.
- Prompt versioning or history.
- Prompt templating beyond the existing `{placeholder}` substitution.
## Design
### Backend
**1. Stored config fields (`config.json`)**
Two new optional top-level string fields:
```json
{
"cover_letter_prompt": "",
"outreach_message_prompt": ""
}
```
Empty string = "use default". Absent key = same as empty string.
**2. `Settings` class — intentionally NOT extended**
These overrides are per-deployment user data, not environment config. Leave `apps/backend/app/config.py` alone; load via `_load_stored_config()` at the usage site.
**3. Placeholder validation**
The defaults require `{job_description}` and `{resume_data}` (both are substituted by the services using `.format()`). `{output_language}` is also required. Add a helper in `apps/backend/app/prompts/__init__.py`:
```python
REQUIRED_PLACEHOLDERS = ("{job_description}", "{resume_data}", "{output_language}")
def validate_prompt_placeholders(prompt: str) -> list[str]:
"""Return the list of required placeholders missing from the prompt.
Empty list means the prompt is valid. Empty-string prompt returns [] (valid
as "use default" sentinel; validation only runs on non-empty strings in
the router).
"""
if not prompt:
return []
return [p for p in REQUIRED_PLACEHOLDERS if p not in prompt]
```
The `.format()` call at usage will also fail on missing placeholders, but catching earlier produces a clean 422 with a list of missing names instead of a 500.
**4. Service changes**
`apps/backend/app/services/cover_letter.py`:
```python
from app.llm import _load_stored_config # or a new public export
async def generate_cover_letter(...):
...
stored = _load_stored_config()
custom = (stored.get("cover_letter_prompt") or "").strip()
template = custom or COVER_LETTER_PROMPT
try:
prompt = template.format(
job_description=job_description,
resume_data=json.dumps(resume_data),
output_language=output_language,
)
except KeyError as e:
# Defensive: placeholder missing despite save-time validation.
logging.warning("Custom cover letter prompt missing %s, falling back", e)
prompt = COVER_LETTER_PROMPT.format(...)
...
```
Same shape for `generate_outreach_message`.
`_load_stored_config` in `llm.py` is currently a private helper. Either promote it to a non-underscored export (`load_stored_config`) or move it to `app/config.py` alongside `load_config_file` — the second is cleaner. This spec picks the move.
**5. New API endpoints**
Extend `apps/backend/app/routers/config.py`. The existing router has `/config/prompts` for resume-generation prompt selection. Add two new endpoints scoped to features to keep concerns isolated:
```
GET /api/v1/config/feature-prompts
→ { cover_letter_prompt, outreach_message_prompt,
cover_letter_default, outreach_message_default }
PUT /api/v1/config/feature-prompts
body: { cover_letter_prompt?, outreach_message_prompt? }
→ same schema as GET
```
The `_default` fields in the GET response let the frontend show the default as placeholder text without duplicating the string across languages.
Validation in the PUT:
```python
if request.cover_letter_prompt is not None:
prompt = request.cover_letter_prompt.strip()
if prompt:
missing = validate_prompt_placeholders(prompt)
if missing:
raise HTTPException(
status_code=422,
detail={
"code": "missing_placeholders",
"field": "cover_letter_prompt",
"missing": missing,
},
)
stored["cover_letter_prompt"] = prompt # "" clears
# identical block for outreach_message_prompt
```
**6. Schema models (`schemas/models.py`)**
```python
class FeaturePromptsRequest(BaseModel):
cover_letter_prompt: str | None = None
outreach_message_prompt: str | None = None
class FeaturePromptsResponse(BaseModel):
cover_letter_prompt: str
outreach_message_prompt: str
cover_letter_default: str
outreach_message_default: str
```
Note: `str | None = None` on the request distinguishes "no change" from "clear to empty".
### Frontend
**7. API client (`apps/frontend/lib/api/config.ts`)**
```ts
export interface FeaturePrompts {
cover_letter_prompt: string;
outreach_message_prompt: string;
cover_letter_default: string;
outreach_message_default: string;
}
export interface FeaturePromptsUpdate {
cover_letter_prompt?: string;
outreach_message_prompt?: string;
}
export async function fetchFeaturePrompts(): Promise<FeaturePrompts> {...}
export async function updateFeaturePrompts(update: FeaturePromptsUpdate): Promise<FeaturePrompts> {...}
```
**8. Settings UI**
New sub-section under the existing "Content Generation" block. For each of cover letter and outreach, only render the prompt textarea when the feature's toggle is ON (matches existing UX).
For each feature:
- `<label>` "Custom prompt (optional)"
- `<textarea rows={8} className="... font-mono text-xs ...">` — placeholder = default prompt
- Helper line below: "Must include: `{job_description}`, `{resume_data}`, `{output_language}`. Leave blank to use default."
- `<button>` "Reset to default" — clears the textarea AND calls PUT with empty string
- Inline 422 error message below textarea on failed save (lists missing placeholders)
**9. i18n**
New keys in all 5 locales:
```json
{
"settings": {
"contentGeneration": {
"customPromptLabel": "Custom prompt (optional)",
"customPromptHelp": "Must include: {job_description}, {resume_data}, {output_language}. Leave blank to use default.",
"customPromptResetButton": "Reset to default",
"customPromptErrorMissing": "Custom prompt is missing required placeholders: {missing}"
}
}
}
```
### Docs
**10. Feature doc**
Short addition to `docs/agent/features/enrichment.md` or a new `docs/agent/features/custom-prompts.md`: explain what can be customized, placeholder requirements, reset behavior.
## Data flow
```
User opens Settings → enables Cover Letter toggle
Settings renders textarea with default prompt as placeholder
User pastes custom prompt, clicks Save
PUT /api/v1/config/feature-prompts { cover_letter_prompt: "..." }
Router validates placeholders → 422 on missing OR 200 + persist
stored.cover_letter_prompt = "<user text>" OR "" (on clear)
Later: user runs Tailor → generates cover letter
generate_cover_letter() reads stored config → uses custom or default
.format() substitutes placeholders → LLM call → returns text
```
## Error handling
| Failure | Behavior |
|---|---|
| Empty prompt on save | Treated as "clear to default". 200 OK. |
| Prompt missing required placeholder | 422 with `code=missing_placeholders`, lists missing names. No state change. |
| Prompt with extra unknown placeholder | Passes save-time validation. At runtime `.format()` treats unknown braces as literals — harmless if single braces, crashes on `{foo}` style. Defensive try/except in service falls back to default + logs. |
| Stored prompt corrupted (disk edit) | Same defensive try/except. Falls back to default. |
## Files touched
| File | Change |
|---|---|
| `apps/backend/app/config.py` | Move `_load_stored_config` from `llm.py` (rename to public `load_stored_config`), add `save_stored_config` helper |
| `apps/backend/app/llm.py` | Replace private `_load_stored_config` with import of public helper |
| `apps/backend/app/prompts/__init__.py` | New `validate_prompt_placeholders` helper |
| `apps/backend/app/services/cover_letter.py` | Load custom prompt, fall back on error |
| `apps/backend/app/routers/config.py` | Two new endpoints |
| `apps/backend/app/schemas/models.py` | Two new schema classes |
| `apps/frontend/lib/api/config.ts` | New types + fetch/update functions |
| `apps/frontend/app/(default)/settings/page.tsx` | Two new textareas in Content Generation section |
| `apps/frontend/messages/{en,es,ja,zh,pt-BR}.json` | i18n strings |
| `docs/agent/features/custom-prompts.md` (new) | One-page feature doc |
Estimated ~12 files, ~300-400 LOC.
## Risks
- **`_load_stored_config` move breaks existing imports.** There's exactly one external caller inside `apps/backend/app/` — the new service changes. Search + update in the commit.
- **User pastes a very long custom prompt** that inflates token count dramatically. Out of scope to enforce token limits; the LLM-side `max_tokens` and provider limits are the backstop.
- **`.format()` treats `{` as a special character.** If users want a literal brace in their prompt (e.g., documenting JSON schema in-prompt), they need `{{` / `}}`. Noted in helper copy.
## Rollback
Pure additive. Revert:
- Removes the UI textareas (users see feature toggles only, as before).
- Removes the endpoints (404 on next frontend call — would need frontend revert too).
- Stored `cover_letter_prompt` / `outreach_message_prompt` fields in `config.json` become inert — ignored by the reverted service code.
Data is not deleted. Re-enabling the feature restores the stored custom prompts.
## Verification
Manual:
1. Enable Cover Letter toggle in Settings.
2. Leave custom prompt blank; run Tailor; confirm generated cover letter uses default length/style.
3. Paste a valid prompt including all three placeholders with an altered tone ("Write in Shakespearean English, 200 words"); save; run Tailor; confirm output follows custom instructions.
4. Paste a prompt missing `{resume_data}`; save fails with 422 and UI shows "missing placeholders: {resume_data}".
5. Click "Reset to default"; textarea clears; run Tailor; output matches default behavior.
6. Same flow for cold outreach.
@@ -0,0 +1,196 @@
# LiteLLM reasoning hardening + Settings reasoning UX
**Date:** 2026-04-17
**Issues:** #747 (primary), partial #751 (api_base stripping half)
**Branch:** `dev` (PR dev → main to follow)
## Problem
Three entangled pain points in the current LLM layer:
1. **gpt-5 connection tests fail.** `check_llm_health()` hardcodes `reasoning_effort="minimal"` for any model name containing `"gpt-5"` and uses `max_tokens=16`. Some gpt-5 variants reject `reasoning_effort=minimal`; others need a larger token budget. Both failure modes return HTTP 200 with `healthy=false`, which is confusing.
2. **Thinking models return "empty" content.** Models like DeepSeek-R1 and OpenAI o1 place their answer in `message.reasoning_content` or `message.thinking` rather than `message.content`. Current code treats this as an empty response and fails.
3. **OpenAI-compatible endpoints are broken.** `_normalize_api_base()` strips trailing `/v1` even when the user wants to target a custom OpenAI-compatible server (e.g. llama.cpp at `http://localhost:8080/v1`). The user-supplied URL becomes `http://localhost:8080` and the request 404s.
## Goals
- `check_llm_health()` succeeds for both reasoning and non-reasoning models out of the box.
- Reasoning-only responses are surfaced as valid content.
- Users can target any OpenAI-compatible endpoint by pasting the full base URL.
- `reasoning_effort` becomes a user-controllable setting, not a hardcoded policy.
- Existing gpt-5 users keep their current behavior without touching config.
## Non-goals
- Adding a new `"openai_compatible"` entry to the provider dropdown (tracked in PR #751 follow-up).
- Rewriting prompt templates for cover letter / cold email (tracked in PR #749 follow-up).
- Settings page error-card overflow styling (tracked in PR #754 follow-up).
- Per-model capability allowlists (obsoleted by `drop_params=True`).
## Design
### Backend
**1. Global LiteLLM params policy (`apps/backend/app/llm.py` module init)**
```python
litellm.drop_params = True
litellm.modify_params = True
```
- `drop_params` lets LiteLLM silently discard params the selected provider rejects (`reasoning_effort`, non-default `temperature`, etc.). Replaces every hardcoded compatibility branch we wrote.
- `modify_params` lets LiteLLM auto-drop `thinking_blocks` when tool-call assistant messages are missing them. Applied defensively; no current tool-call path uses thinking, but this future-proofs the Router.
**2. Remove hardcoded compatibility branches**
Delete `_get_reasoning_effort()` and `_supports_temperature()`. Their call sites (`complete`, `complete_json`, `check_llm_health`) stop invoking them. `temperature` is always passed; `reasoning_effort` is passed only when the user has configured it.
**3. New `reasoning_effort` setting**
`apps/backend/app/config.py`:
```python
reasoning_effort: Literal["minimal", "low", "medium", "high"] | None = None
```
Read from `REASONING_EFFORT` env var and from `config.json`. `None` means "do not send the param". `LLMConfig` gains the same field so callers pass it through.
**4. Health-check token bump**
`check_llm_health()`: `max_tokens=16``max_tokens=64`. Matches the issue author's proposed minimum.
**5. Thinking-model content fallback in `_extract_choice_text()`**
Extraction order:
1. `message.content` (existing)
2. `message.reasoning_content` (new — DeepSeek, OpenAI o1)
3. `message.thinking` (new — Anthropic extended thinking)
4. `<think>...</think>` tags inside `message.content` (existing, via `_strip_thinking_tags`)
Return the first non-empty. Unchanged if none match.
`complete()` and `check_llm_health()` stop treating "reasoning present but content empty" as unhealthy — if extraction returns text, it is content.
**6. `_normalize_api_base` — preserve `/v1` for OpenAI**
Current code strips `/v1` for `anthropic`, `gemini`, `openrouter`, `ollama` because LiteLLM's provider handlers for those re-append path segments. For `openai` the OpenAI client handles `/v1` correctly, so **stop stripping when `provider == "openai"`**. The llama.cpp-style case `http://localhost:8080/v1` now round-trips intact.
Keep stripping for the other four providers — that behavior is correct and covered by existing users.
**7. Expose `reasoning_effort` through config API**
`apps/backend/app/routers/config.py`: GET `/api/v1/config/llm` includes the field; PUT accepts it. `schemas/models.py` adds the field to the request/response models.
**8. Health-check response carries `reasoning_content`**
`check_llm_health(include_details=True)` adds a `reasoning_content` key (code-block-wrapped string, empty when absent) alongside `model_output`. Schema update in `schemas/models.py`.
### Frontend
**9. Reasoning-effort dropdown (`apps/frontend/app/(default)/settings/page.tsx` + `components/settings/api-key-menu.tsx`)**
New select labeled "Reasoning effort" under the model field. Options: **Auto** (sends nothing, default), Minimal, Low, Medium, High. Swiss-styled: `rounded-none`, 1px black border, hard shadow on focus, monospace for the options. Shown for all providers — with `drop_params=True` the backend will safely drop the param where unsupported, so restricting by provider is unnecessary and misleading.
**10. Model-thinking block in Test Connection result**
When `reasoning_content` is non-empty in the health-check response, render a second code block under the main output, labeled "Model thinking" in a smaller monospace caption. Collapsible by default (closed).
### Regression mitigation (auto-migration)
**11. One-shot silent migration in `get_llm_config()`**
Pseudocode:
```python
stored = _load_stored_config()
provider = stored.get("provider", settings.llm_provider)
model = stored.get("model", settings.llm_model)
if (
provider == "openai"
and "gpt-5" in model.lower()
and "reasoning_effort" not in stored # absent, not empty string
):
stored["reasoning_effort"] = "minimal"
save_config_file(stored)
logging.info(
"Migrated gpt-5 config to preserve reasoning_effort=minimal "
"(set REASONING_EFFORT= or clear in Settings to disable)"
)
```
Condition-gated on **absent key** rather than "empty or missing". Once a user explicitly clears the field (empty string persisted), the migration does not re-apply.
The migration runs lazily: first `get_llm_config()` call after process start on an affected config. No blocking startup hook.
### PR description callout
```markdown
### Behavior change for gpt-5 users
Previously, any model containing "gpt-5" silently received reasoning_effort=minimal.
That default has been removed. Existing gpt-5 configs are auto-migrated on first
load to preserve the "minimal" setting — you will see a one-line INFO log. To
disable, open Settings → LLM → Reasoning effort and pick "Auto", or set
REASONING_EFFORT= (empty) in .env.
```
## Data flow
```
.env / config.json
Settings (reasoning_effort=None|minimal|low|medium|high)
LLMConfig (forward through)
get_router() / check_llm_health()
kwargs["reasoning_effort"] only if set
litellm.acompletion ── drop_params=True ──► provider API
response.choices[0].message
_extract_choice_text: content → reasoning_content → thinking → <think>-tags
caller (complete/complete_json/check_llm_health)
```
## Error handling
| Failure | Before | After |
|---|---|---|
| `UnsupportedParamsError: reasoning_effort=minimal not supported` | Health-check fails | `drop_params` drops it, call succeeds |
| `max_tokens or model output limit reached` at 16 tokens | Health-check fails | Budget is 64, succeeds for reasoning-capable models |
| Reasoning-only response (empty content, populated reasoning_content) | `empty_content` unhealthy | Extracted as content, healthy |
| `api_base` with `/v1` for openai → llama.cpp | 404 (stripped) | 200 (preserved) |
| `api_base` with `/v1` for anthropic/gemini/openrouter/ollama | Works (stripped) | Works (still stripped) |
| Provider rejects `temperature != 1` | Raised before | Dropped by LiteLLM, call succeeds |
Unchanged: Router retry policy, timeout logic, JSON extraction, config file write semantics.
## Files touched
| File | Change |
|---|---|
| `apps/backend/app/llm.py` | Module init flags; delete 2 helpers; fallback extraction; api_base branch; health-check max_tokens |
| `apps/backend/app/config.py` | Add `reasoning_effort` setting |
| `apps/backend/app/routers/config.py` | Expose `reasoning_effort` in LLM config GET/PUT |
| `apps/backend/app/schemas/models.py` | Add `reasoning_effort` field + `reasoning_content` in health response |
| `apps/backend/.env.example` | Document `REASONING_EFFORT` |
| `apps/frontend/app/(default)/settings/page.tsx` | Reasoning-effort dropdown + Model thinking block |
| `apps/frontend/components/settings/api-key-menu.tsx` | Thread the new field through |
| `apps/frontend/messages/{en,es,ja,zh,pt-BR}.json` | i18n strings for new UI elements |
Estimated ~250 LOC net across ~8-9 files.
## Risks
- **`drop_params=True` is process-global.** Any future code path that relied on a provider surfacing "unsupported param" as an error will now fail silently. Acceptable — we don't have such a path today.
- **Thinking-model fallback can change output shape.** A deepseek-r1 response that previously returned "empty" now returns the reasoning content. Downstream JSON extraction (`complete_json`) is unaffected because `_strip_thinking_tags` already handles inline `<think>` and the fallback returns clean text.
- **Auto-migration writes user config on first call.** Single idempotent write. If the write fails (disk full, permission), we log-and-continue — the next call will retry.
## Rollback
Pure backward-compatible at the data level. Reverting the commit restores old behavior. Migrated `reasoning_effort="minimal"` in config.json is now honored by new code; if we revert, the hardcoded branch is restored and the stored value is harmlessly redundant.
@@ -0,0 +1,211 @@
# OpenAI-compatible provider entry
**Date:** 2026-04-17
**Issue:** #751 (completes the dropdown half; `/v1` preservation shipped with #747)
**Branch:** `dev` (bundle into PR with #754 and #749)
## Problem
Local OpenAI-compatible servers (llama.cpp, vLLM, LM Studio, Ollama's OpenAI endpoint, etc.) already work when the user selects `provider=openai` and sets `api_base` to their local URL — after the `_normalize_api_base` fix in #747. But that path is discoverable only by reading code: the Settings UI exposes six providers (openai, anthropic, openrouter, gemini, deepseek, ollama) and none is labeled "OpenAI-compatible". New users trying to connect llama.cpp pick "Ollama" (because it's local), misconfigure the base URL, and hit 404s.
## Goals
- Add an explicit `"openai_compatible"` provider option to the Settings UI with clear labeling for local servers.
- Route requests via LiteLLM's `openai/` prefix (the documented way to hit OpenAI-compatible endpoints).
- Keep the existing `provider=openai + custom api_base` path working — no silent migration, no forced switch.
- API keys for `openai_compatible` are stored under their own namespace so they don't leak into real OpenAI (and vice versa).
## Non-goals
- Auto-detection of the backend's capabilities (streaming, tools, etc.).
- A Settings "provider profile" abstraction — one provider, one config.
- Migrating existing users from `openai + api_base`.
## Design
### Backend
**1. Provider enum (`apps/backend/app/config.py`)**
```python
llm_provider: Literal[
"openai",
"openai_compatible",
"anthropic",
"openrouter",
"gemini",
"deepseek",
"ollama",
] = "openai"
```
**2. Provider key map (`apps/backend/app/llm.py`)**
Add an entry so stored keys are scoped separately:
```python
_PROVIDER_KEY_MAP: dict[str, str] = {
"openai": "openai",
"openai_compatible": "openai_compatible",
"anthropic": "anthropic",
"gemini": "google",
"openrouter": "openrouter",
"deepseek": "deepseek",
"ollama": "ollama",
}
```
**3. Model routing (`get_model_name` in `apps/backend/app/llm.py`)**
LiteLLM's documented way to hit an OpenAI-compatible endpoint is `model="openai/<model_name>"` + `api_base=<URL>`. So `openai_compatible` uses the same prefix as `openai`:
```python
provider_prefixes = {
"openai": "",
"openai_compatible": "openai/", # explicit — user's model names usually lack the prefix
"anthropic": "anthropic/",
...
}
```
And in the OpenRouter-style special-case block, extend the "already prefixed" check to recognize `openai/`:
```python
known_prefixes = [
"openrouter/", "anthropic/", "gemini/", "deepseek/",
"ollama/", "ollama_chat/", "openai/",
]
```
**4. URL normalization (`_normalize_api_base`)**
Preserve the URL just like `openai` does — no stripping:
```python
if provider in ("openai", "openai_compatible"):
return base or None
```
**5. API-key requirement**
Real `openai` requires a key. `openai_compatible` often does not (llama.cpp, LM Studio). But OpenAI's python client [requires](https://github.com/openai/openai-python/blob/main/README.md) some key to be set (it validates the string is non-empty). The health-check currently gates API-key presence with:
```python
if config.provider != "ollama" and not config.api_key:
return {... "error_code": "api_key_missing" ...}
```
Change to:
```python
if config.provider not in ("ollama", "openai_compatible") and not config.api_key:
...
```
And in the actual completion call, if `config.api_key` is empty for `openai_compatible`, pass a sentinel `"sk-no-key"` string to satisfy the OpenAI client's empty-string check without leaking a real credential.
### Frontend
**6. Provider list (`apps/frontend/lib/api/config.ts`)**
```ts
export type LLMProvider =
| 'openai'
| 'openai_compatible'
| 'anthropic'
| 'openrouter'
| 'gemini'
| 'deepseek'
| 'ollama';
```
**7. `PROVIDERS` array + `PROVIDER_INFO` dict (`config.ts`)**
```ts
openai_compatible: {
name: 'OpenAI-Compatible',
description: 'llama.cpp, vLLM, LM Studio, self-hosted OpenAI-API servers',
defaultModel: 'custom-model',
requiresKey: false,
},
```
**8. Segmented-button UI (`settings/page.tsx`)**
The button row auto-wraps as long as `PROVIDERS` grows. With 7 providers the row flows to 2 lines on narrow viewports — acceptable.
**9. API-base hint**
When the user selects `openai_compatible`, pre-populate `api_base` to `http://localhost:8080/v1` if the field is empty (matches llama.cpp default).
**10. i18n**
Add `settings.providers.openai_compatible` name + description string in all 5 locales (en/es/ja/zh/pt-BR).
### Docs
**11. `.env.example`**
Extend the `LLM_PROVIDER` comment to list `openai_compatible` as a valid value.
**12. SETUP.md + translations**
One-liner mention in the LLM configuration section: "Use OpenAI-Compatible for llama.cpp, vLLM, LM Studio."
## Data flow
```
User picks "OpenAI-Compatible" in Settings
└─ api_base pre-filled to http://localhost:8080/v1
└─ API key field optional (requiresKey: false)
└─ PUT /api/v1/config/llm-api-key {provider: "openai_compatible", model: "llama-3.1-8b", api_base: "...", api_key: ""}
└─ stored.api_keys["openai_compatible"] = "" (own namespace)
└─ get_llm_config → LLMConfig(provider="openai_compatible", ...)
└─ get_model_name → "openai/llama-3.1-8b"
└─ _normalize_api_base → "http://localhost:8080/v1" (preserved)
└─ check_llm_health → passes api_key="sk-no-key" sentinel if empty
└─ litellm.acompletion(model="openai/llama-3.1-8b", api_base="...", api_key="sk-no-key")
└─ LiteLLM routes via OpenAI client → http://localhost:8080/v1/chat/completions
```
## Error handling
| Failure | Behavior |
|---|---|
| Empty api_key for `openai_compatible` | Allowed. Sentinel passed to LiteLLM; server decides whether to auth. |
| Server returns 404 on `/v1/chat/completions` | Health check surfaces `not_found_404` error code. |
| `api_base` includes `/v1/v1` | Handled by normal LiteLLM behavior; no extra stripping. |
| Model name includes `openai/` already | Respected, not double-prefixed. |
## Files touched
| File | Change |
|---|---|
| `apps/backend/app/config.py` | Add `openai_compatible` to `llm_provider` Literal |
| `apps/backend/app/llm.py` | Provider map, prefix, normalize, health-check gate |
| `apps/backend/app/.env.example` | Document the option |
| `apps/frontend/lib/api/config.ts` | Add to `LLMProvider`, `PROVIDERS`, `PROVIDER_INFO` |
| `apps/frontend/app/(default)/settings/page.tsx` | Pre-fill api_base hint on provider change |
| `apps/frontend/messages/{en,es,ja,zh,pt-BR}.json` | i18n strings |
| `SETUP.md`, `SETUP.es.md`, `SETUP.ja.md`, `SETUP.zh-CN.md` | One-line mention |
Estimated ~8 files, ~80-120 LOC.
## Risks
- **Sentinel key `"sk-no-key"`**: cosmetic; the OpenAI client treats it as a literal string and passes it in the `Authorization` header. Local servers that don't check auth ignore it. Servers that DO check auth will reject it — but those users will set a real key, so no regression.
- **Key namespace overlap**: users who already have a stored key under `openai` will not see it auto-populated when switching to `openai_compatible` — they have to paste it (or leave blank). This is by design: they're logically different providers.
- **New failure code paths**: none added; reuses existing `not_found_404` / `duplicate_v1_path` / `html_response` heuristics.
## Rollback
Pure additive. Revert removes the option from the dropdown; users who configured `openai_compatible` revert to seeing "unknown provider" error on next config load (handled by existing `set_default_provider` validator in `config.py`, which falls back to `"openai"`). Their stored `api_base` survives, so switching to `openai + api_base=...` gives them back the working path.
## Verification
Manual (no test infrastructure):
1. Start llama.cpp locally on port 8080 with an OpenAI server enabled.
2. In Settings, pick OpenAI-Compatible. `api_base` pre-fills to `http://localhost:8080/v1`.
3. Leave API key blank. Pick model `llama-3.1-8b`. Test Connection → healthy, shows model output.
4. Switch back to `openai` with a real key. Test Connection still works for OpenAI's API.
5. Saved keys are separate: clearing the `openai` key does not blank the `openai_compatible` setting.
@@ -0,0 +1,83 @@
# Settings error-container overflow fix
**Date:** 2026-04-17
**Issue:** #754
**Branch:** `dev` (bundle into PR with #751 and #749)
## Problem
When the LLM health check returns a 404 with a long response body (error detail, stack trace, URL with query string), the `<pre>` element inside the health-check detail block and the `<p>` inside the top-level error banner overflow their containers horizontally. The page layout breaks and the Settings panel becomes unreadable on narrow viewports.
The bug pattern: `font-mono` + default `overflow-wrap: normal` keeps unbreakable strings on a single line and blows out the parent's width. The same pattern exists in multiple error containers on the Settings page.
## Goals
- Long error strings wrap inside their containers across all Settings error surfaces.
- No horizontal scroll on the Settings page regardless of error content.
- Design stays Swiss: hard borders, monospace text, no visual softening.
## Non-goals
- Redesigning the error-card visual treatment.
- Changing the error message content or codes.
- Refactoring unrelated Settings components.
## Design
### Where the bug appears
Five locations in `apps/frontend/app/(default)/settings/page.tsx` use the same pattern and share the bug:
1. **Top-level save/load error banner** (around line 882): `<div className="border border-red-300 bg-red-50 p-3"><p className="text-xs text-red-600 font-mono">...`
2. **Health-check result card** (around line 891): container with `healthy ? green : red` styling; renders error message, warning message, and detail items.
3. **Health-check inline error message** (around line 919): `<p className="font-mono text-xs text-red-600 mt-1">`
4. **Health-check inline warning message** (around line 923): same pattern
5. **Health-check detail items' `<pre>` blocks** (around lines 929-945): each detail item renders its value inside `<pre className="mt-1 whitespace-pre-wrap rounded-none border border-black bg-white p-3 text-xs text-ink-soft shadow-sw-sm">`. `whitespace-pre-wrap` handles newlines but does NOT break unbreakable tokens.
### Fix
Two Tailwind utilities are enough:
- `break-words` — equivalent to `overflow-wrap: break-word`. Breaks at word boundaries where possible, falls back to mid-word breaks for unbreakable tokens.
- `min-w-0` on any flex parent that contains wrapping text. Flex items default to `min-width: auto`, which prevents shrinking below content size; `min-w-0` re-enables shrinking so the text can wrap.
Apply across the five locations:
| Location | Class additions |
|---|---|
| Top-level error banner `<div>` | `break-words` on the `<p>` inside |
| Health-check result card outer `<div>` | `break-words` |
| Health-check inline error `<p>` | `break-words` |
| Health-check inline warning `<p>` | `break-words` |
| Health-check detail `<pre>` blocks | `break-words` (complements existing `whitespace-pre-wrap`) |
The existing `whitespace-pre-wrap` on `<pre>` preserves newlines. Adding `break-words` adds mid-token breaking for long unbroken strings (URLs, base64 blobs).
### Data flow
No change. Pure CSS.
## Files touched
| File | Change |
|---|---|
| `apps/frontend/app/(default)/settings/page.tsx` | Add `break-words` to 5 containers/elements |
Estimated ~5-8 line-touches.
## Risks
- `break-words` can produce ugly mid-word breaks on narrow viewports. Acceptable for error surfaces — legibility of the layout outweighs prettiness of a single word.
- `min-w-0` is NOT needed for these specific layouts because they're in a `<section>` with `space-y-*`, not flex children — verified against current markup.
## Rollback
Pure style change. Revert restores the current (buggy) behavior. No data migration.
## Verification
Manual, on a narrow viewport (~360px):
1. Configure invalid API base URL (e.g., `https://example.com/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/v1`).
2. Click Test Connection.
3. Error text, error detail, and all rendered strings stay within the container. No horizontal scroll on the page.
4. Swiss styling preserved: hard borders, monospace text, no rounded corners.
@@ -0,0 +1,262 @@
# Agentic End-to-End Monitor — Design Spec
> **Status:** Implemented — harness in `apps/backend/e2e_monitor/` (PR #823); first live sweep run + golden baseline committed. This document is the design record; current state lives in the harness `README.md`.
> **Date:** 2026-06-01
> **Author:** Saurabh Rai (with Claude Code)
> **Branch:** `docs/agentic-e2e-monitor-spec` (off `dev`)
> **Relationship:** Next phase of the testing initiative. The deterministic suites + local
> pre-push gate (PR #820) answer *"is the plumbing correct?"*. This answers the question they
> structurally **cannot**: *"is the running system actually producing good resumes — now and as it drifts?"*
---
## 1. Problem
Green tests do not mean we are making good resumes. The deterministic backend/frontend suites and the
pre-push gate verify *plumbing* — schemas validate, routes return, the i18n shape holds. They are blind to:
- **Output quality drift** — a prompt edit that still passes every structural scorer but quietly makes
tailored resumes *worse* (less relevant, blander, subtly less truthful). No fixed assertion catches "worse."
- **Flow / render breakage** — the pipeline returns `200 OK` but the PDF is blank or a stage silently
swallowed an error. This is the **livestream failure**: a high-visibility run where the resume didn't render.
- **Local-provider reality** — recurring user complaints that **Ollama doesn't work**. The mocked-LLM suite
can't see this; only a real run against a real provider can.
The user's framing: *"All are going in good, because our tests pass right now. But what happens in the
future?? The only way going forward would be to have an agent debug the whole thing — read from logs and
test-files being produced, understand each of the files."*
So we need a **non-deterministic, agentic** verification layer that drives the real app end to end, captures
a durable evidence trail, and has a Claude Code instance judge it — as a **report, never a gate**.
---
## 2. Goals & non-goals
### Goals
1. Drive the **real running app** through the full flow: start → create master resume → generate 34
variations → render PDFs.
2. Capture a **durable evidence bundle** (logs + every intermediate artifact + PDFs) — something that does
not exist today (the app logs only to the console; the pipeline persists no intermediates).
3. Have an **agent** read that bundle and render an evidence-cited verdict across three jobs:
**output quality**, **flow/render integrity**, **provider reality-check**.
4. Detect **regression over time** against a committed golden **baseline**, above an absolute floor.
5. Be **safe for an OSS repo**: ~90% maintainer / ~10% contributors / ~0% random users' agents. It must not
be auto-installed, auto-run, or auto-discovered by every cloner's coding agent.
### Non-goals (explicit YAGNI)
- **No code/standards audit** — reviews + the pre-push gate already cover code hygiene; this watches *runtime*.
- **No provider matrix** — runs against the single configured provider, not a cross-provider sweep.
- **No run-history store** — a committed baseline is the reference, not an accumulated trend log.
- **No CI / cron** — on-demand only. Never a PR gate, never a GitHub Action.
- **No browser automation** — the flow is HTTP-driven; PDFs come from the existing `/pdf` endpoint.
- **No auto-baseline-refresh** — refreshing the golden is a deliberate, reviewed human commit.
---
## 3. Decisions (the locked forks, with rationale)
| # | Decision | Rationale |
|---|----------|-----------|
| 1 | **Three jobs:** output quality + flow/render integrity + provider reality-check (NOT code/standards). | These three are exactly the runtime blind spots of the deterministic suite, and map 1:1 to the user's real pains (silent quality drift, the blank-render livestream, Ollama complaints). |
| 2 | **Two layers:** deterministic capture **harness** + agentic **judge**. The judge produces a **report, never a gate**. | A fuzzy judgment can't block a push the way the pre-push hook does. Separating capture (reproducible) from judgment (non-deterministic) keeps the evidence trustworthy and the cost bounded. |
| 3 | **Agent-in-the-loop** autonomy. The harness is a library of discrete re-runnable "moves"; the agent runs the default sweep, reads the bundle, and re-invokes specific moves to investigate — but can't go fully off-script. | More flexible than a fixed script (can chase anomalies), more trustworthy/reproducible than a fully autonomous agent driving the app (a flaky autonomous run could be the agent's fault, not the app's). |
| 4 | **Configured provider only** — run against whatever `config.json` points at; record which provider it was. | Simplest. To compare Ollama vs cloud, run twice with different configs; the in-loop agent diffs the two bundles. The dev stays in control of provider choice. |
| 5 | **Committed golden baseline** over an **absolute floor**. | Version-controlled, fits the git-centric workflow, no background store. The floor (every stage completes, PDF non-blank, identity preserved, quality ≥ 3/5) is the hard fail; the baseline catches *drift* above the floor. |
| 6 | **Form factor:** standalone harness + a **Claude Code skill**. | Realizes agent-in-the-loop exactly (agent orchestrates deterministic moves), and the harness doubles as a reusable plain E2E smoke test (runs fine with no agent). |
| 7 | **Skill distribution:** the runnable skill is **gitignored**; its source-of-truth is a **committed playbook**. | Gives the 90/10/0 split — maintainer + deliberate contributors get it, no random user's agent ever receives it, and it doesn't leak into the maintainer's other repos. |
---
## 4. Architecture
```
YOU ──► /monitor-e2e (Claude Code skill = the agent-in-the-loop)
│ 1. runs the default sweep ┌─────────────────────────┐
├──────────────────────────────────► HARNESS (moves) │
│ 4. re-invokes targeted moves │ boot · seed-master · │
│ to investigate │ tailor · render · │
│ │ collect · baseline-diff│
│ └────────────┬────────────┘
│ │ spawns + drives over HTTP
│ ┌────────────▼────────────┐
│ 2. reads bundle ◄────writes──────│ backend :8000 (uvicorn) │
│ 3. applies rubric + baseline diff │ frontend :3000 (Next) │
│ 5. writes report.md │ (real configured LLM) │
▼ └─────────────────────────┘
report.md + session summary
```
**The log-capture trick (zero app changes):** the harness *owns the subprocesses* — it spawns uvicorn and
Next.js itself and redirects their stdout/stderr into log files in the bundle. This produces a durable log
trail **without adding any `FileHandler` to `app/`**. If servers are already running, the harness can
*attach* (skip spawn) instead.
---
## 5. Components & locations
| Path | VC? | What |
|------|-----|------|
| `apps/backend/e2e_monitor/` | ✅ committed | the harness package — `cd apps/backend && uv run python -m e2e_monitor <move>`. Reuses `app.schemas` + the eval `scorers.py` and the backend `uv` env (httpx). |
| `apps/backend/e2e_monitor/fixtures/` | ✅ committed | one rich canonical **master resume** (all sections) + a fixed set of **34 JDs** across distinct roles. |
| `apps/backend/e2e_monitor/baseline/baseline.json` | ✅ committed | the accepted golden — scores + flow expectations + content digests. |
| `apps/backend/e2e_monitor/AGENT_PLAYBOOK.md` | ✅ committed | the **source of truth** for the skill body; contributors copy it to opt in. |
| `artifacts/e2e-monitor/<run-id>/` | 🚫 gitignored | per-run evidence bundle. |
| `.claude/skills/monitor-e2e/SKILL.md` | 🚫 gitignored | the live, runnable skill (installed from the playbook). |
| `docs/agent/testing-strategy.md` §10 + harness `README.md` | ✅ committed | the curated, durable record. |
**Optional dependency:** anything beyond the existing backend deps (e.g. a `pypdf` text-probe for the
non-blank PDF check) goes behind `[project.optional-dependencies] e2e-monitor`**never** pulled by
`uv sync` or `uv sync --extra dev`. Only the maintainer runs `uv sync --extra e2e-monitor`.
---
## 6. The moves (harness CLI)
Each move is deterministic and appends to the bundle.
- **`boot`** — spawn backend + frontend (or attach), redirect their logs to the bundle, wait for `/health`
on both, write `manifest.json` (run-id, **provider + model from config**, git SHA, timestamps,
secret-scrubbed config snapshot).
- **`seed-master`** — upload the canonical master via `/resumes/upload`, await processing, save
`processed_data.json`, record `resume_id`.
- **`tailor --jd <key>`** — `/jobs/upload``/resumes/improve/preview``/improve/confirm`; save keywords
+ `tailored.json`; run the 5 structural scorers → `scores.json`.
- **`render --variation <key>`** — `/resumes/{id}/pdf`; save the PDF; **non-blank check** (size + page count
+ extractable-text probe) + timing → `render.json`.
- **`judge --variation <key>`** — reuse the eval rubric (`complete_json`, relevance/truthfulness/formatting
→ 15) and **record the number** to the bundle for like-for-like baseline comparison.
- **`collect`** — flush logs, finalize `flow-trace.json` (per-stage status/timing/HTTP errors), write
`summary.json`.
- **`baseline-diff`** — diff this run's scores + flow-trace against `baseline.json``baseline-diff.json`.
- **`sweep`** — convenience chain (`boot → seed → tailor×N → render×N → judge×N → collect → baseline-diff`);
the agent's default first call. **`teardown`** stops spawned servers.
**Opt-in gate (every expensive move):** refuses unless **both** a key is configured (the eval
`_needs_key()` gate) **and** `RM_E2E_MONITOR=1` (or `--yes-spend-tokens`) is set — printing a clear
"monitor disabled by default; this makes real billed LLM calls" message otherwise.
---
## 7. Bundle layout (what the agent reads)
```
artifacts/e2e-monitor/<run-id>/
manifest.json summary.json baseline-diff.json
flow-trace.json report.md ◄── written by the agent
logs/{backend,frontend}.log
master/{upload_response,processed_data}.json
variations/<jd-key>/
job_description.txt keywords.json tailored.json
scores.json judge.json resume.pdf render.json
```
---
## 8. The agent's judgment (skill / playbook)
After `sweep`, the agent reads `summary.json` + `baseline-diff.json` first, then works the three jobs —
each grounded in a specific artifact so the verdict is **evidence-cited**, not vibes:
- **Output quality** — the 5 structural scorers in `scores.json` are the deterministic floor (no dropped
sections, no fabricated employers, identity byte-stable, JD-keyword coverage, valid schema). `judge.json`
adds the rubric score (recorded, for baseline diffing). The *agent* then reads `tailored.json` vs
`job_description.txt` directly to catch what a fixed rubric misses, and compares to baseline scores.
- **Flow + render integrity** — `flow-trace.json` (every stage status + timing) + `render.json` (non-blank)
+ a **grep of `logs/backend.log`** for tracebacks, swallowed exceptions, the generic-500 pattern, asyncio
timeouts. A `200` can hide a broken PDF; the log + text-probe won't.
- **Provider reality-check** — manifest records provider+model; the agent scans logs for the **fingerprints
of local-provider struggle** even when output squeaks through: JSON-mode fallbacks, truncation retries,
timeout escalation, retry exhaustion, the Ollama `/api/show` probe. Point `config.json` at Ollama, run,
and the baseline (captured on the known-good provider) makes the gap obvious.
The agent then **re-invokes targeted moves** to investigate anomalies (re-`tailor` a suspect JD, re-`render`
a blank PDF, `collect` fresh logs), writes `report.md` (verdict per job, regressions vs baseline with
evidence citations, reproduction steps, recommended fixes) + a short session summary. It **never** modifies
app code and **never** refreshes the baseline.
---
## 9. Baseline & refresh
`baseline/baseline.json` holds accepted structural scores, judge scores **with tolerance bands** (so model
jitter ≠ a regression — only a floor breach or a drop beyond band flags), flow expectations, and content
digests of the golden outputs. `baseline-diff` flags: any floor breach (hard), any score drop beyond
tolerance, any new log-error signature, any stage that regressed.
**Refresh** = an explicit `python -m e2e_monitor update-baseline` that the dev **reviews and commits** — like
updating a snapshot test. The agent never refreshes it.
---
## 10. Guardrails (leakage prevention — the OSS-safety answer)
Three leak vectors, each closed:
1. **Dependency install** — harness-only deps behind the `e2e-monitor` optional extra; never in
`uv sync` / `--extra dev`. *Closed by construction.*
2. **Accidental run** — inert code (no import side-effects; never imported by `app/*` or the test suite, so
`uv run pytest` never touches it; not in `.githooks/pre-push`) + behavioral opt-in (`RM_E2E_MONITOR=1`
**and** `_needs_key()`). Even a helpful stranger's agent that runs it gets a "disabled by default" no-op.
*Closed by construction + gate.*
3. **Agent auto-discovery** — the skill is **not committed** as a project skill (which every cloner's agent
would see). It is gitignored at `.claude/skills/monitor-e2e/`; the committed `AGENT_PLAYBOOK.md` is the
source of truth; install is one documented copy (or `make e2e-skill`). *Closed by not shipping it.*
Plus: **secrets** — captured logs + manifest run through the `_scrub_secrets` philosophy before hitting the
(gitignored) bundle. **Report, never a gate** — not in the pre-push hook, not in `.github/workflows/`.
---
## 11. Anti-theater (the harness checks itself)
The **pure, dependency-free** helpers — `baseline-diff`, the non-blank heuristic, the log-scrubber, the
flow-trace builder — get **keyless, offline unit tests in the normal suite**, so the harness logic is
version-protected and proven to fire:
- `baseline-diff` must flag a **seeded regression**.
- the non-blank check must **fail a blank PDF**.
- the scrubber must **redact a planted key**.
The side-effectful parts (subprocess spawn / HTTP / LLM) run only in the on-demand sweep — so `uv run
pytest` still never boots a server or spends a token. (The tested pure helpers must not require the optional
`e2e-monitor` extra; the deep `pypdf` text-probe is mocked or skipped when the extra is absent.)
---
## 12. Fixtures, cadence, workflow
- **Fixtures:** one rich canonical master (personalInfo, summary, 23 work entries, education, projects,
skills, a custom section) + **34 JDs across distinct roles** (e.g. backend / frontend / ML / PM) — the
user's "34 variations."
- **Cadence:** on-demand — run `/monitor-e2e` before tagging a release or after prompt edits. No cron.
- **Workflow:** new branch off `dev`, same as the rest of the testing initiative; report is informational.
---
## 13. Open questions / future (deliberately deferred)
- **Run-history trend** — an accumulated (gitignored) log of scores/timings for slow-drift detection, on top
of the baseline. Add later if the baseline alone proves too coarse.
- **Provider matrix** — automatic local-vs-cloud comparison in one run, if manual two-run diffing gets tedious.
- **Scheduled local run** — a local cron/launchd that runs the sweep nightly and pings the maintainer.
---
## 14. Implementation outline (for the eventual plan)
Rough phases, smallest-shippable-first, each independently testable:
1. **Harness skeleton + manifest + opt-in gate**`boot`/`teardown` (spawn+attach+log capture),
`manifest.json`, the `RM_E2E_MONITOR` + `_needs_key()` gate. Unit-test the scrubber.
2. **Flow moves + bundle**`seed-master`, `tailor`, `render` (with non-blank check), `collect`
(`flow-trace.json` + `summary.json`). Unit-test the non-blank heuristic + flow-trace builder offline.
3. **Scoring + baseline** — wire the structural scorers + `judge`; `baseline-diff` + `update-baseline`;
commit the first golden. Unit-test baseline-diff against a seeded regression.
4. **Fixtures** — author the canonical master + the 34 JDs.
5. **Agent layer** — write `AGENT_PLAYBOOK.md`, the gitignored skill install path, `docs` §10 + README,
`.gitignore` entries, the optional extra in `pyproject.toml`.
> Next step when resumed: invoke the **writing-plans** skill to turn §14 into a detailed implementation plan.
@@ -0,0 +1,153 @@
# Design: Three New Resume Templates — LaTeX, Clean, Vivid
> **Status:** Approved (design). **Date:** 2026-06-03. **Branch:** `feat/resume-templates-latex-clean-vivid`
## Goal
Add three new resume templates to Resume Matcher, each a faithful reproduction of a
reference image the maintainer provided:
| ID | Display | Layout | Reference |
|----|---------|--------|-----------|
| `latex` | LaTeX | Single column | Classic serif/LaTeX resume (centered small-caps name, ruled Title-Case section headers, company-first two-line entries) |
| `clean` | Clean | Single column | Minimal modern sans (centered light name, `\|`-separated contact line, large gray UPPERCASE section headers, single-line entries) |
| `vivid` | Vivid | Two column | Colorful Awesome-CV lineage (two-tone colored name, monospace title + circular-icon contact row, accent small-caps headers, accent ➜ bullet markers) |
Guiding principle (maintainer steer): **match the reference images by default; expose the
existing universal controls so users can tweak the rest the way they like.** Do not
over-engineer beyond faithful reproduction.
## Non-Goals
- No new global controls in `TemplateSettings` (reuse existing margins / spacing / size /
page-size / fonts / contact-icons / accent-color).
- No changes to the two-column column-assignment engine — `vivid` reuses the established
split (main 65%: summary, experience, projects, certifications, custom; sidebar 35%:
education, skills, languages, awards). Custom-section placement follows the existing
convention; reordering remains a user action.
- No backend business-logic change. The PDF endpoint accepts `template` as a free string;
only its docstring/allow-list comment and the frontend `parseTemplate` allow-list need the
new IDs.
- No accent-color wiring for `latex`/`clean` (both are monochrome by design).
## Typography Approach (the one real wrinkle)
The maintainer chose "respect the font controls" over "enforce + hide the dropdowns," and
"the reference look is the default." Global font defaults are `headerFont: serif`,
`bodyFont: sans-serif`. To make each reference look the **default** while keeping the
dropdowns live and **without mutating settings on template switch**, each template binds to
existing font CSS variables per its design:
- **LaTeX** — single-typeface serif. Bind all text to `var(--header-font)` (default serif ✓).
The **Header Font** dropdown drives the whole template; **Body Font** is inert for LaTeX.
- **Clean** — single-typeface sans. Bind all text to `var(--body-font)` (default sans ✓).
The **Body Font** dropdown drives the whole template; **Header Font** is inert for Clean.
- **Vivid** — multi-font by design. Name + bullet body use `var(--body-font)` (sans default,
Body Font drives body). Title line + contact row use a fixed **monospace** stack
(`var(--resume-font-mono)`) to match the reference. Section headers + small-caps subtitles
use `var(--header-font)` for the family with `font-variant: small-caps`.
Trade-off accepted: for the single-typeface templates one of the two font dropdowns is a
no-op. This is consistent with how accent-color is inert (and hidden) for non-color templates.
## Template Specifications
All three are pure DOM text (ATS-safe), follow the `resume-modern.tsx` /
`resume-modern-two-column.tsx` patterns, render sections in `sectionMeta` order via
`getSortedSections`, and reuse the shared `_base.module.css` spacing/typography classes so
margins/spacing/size controls apply.
### LaTeX (`latex`) — single column
- **Header (centered):** serif name with `font-variant: small-caps` at `--header-scale`;
optional `personalInfo.title` as a centered italic tagline; `personalInfo.location` as a
centered sub-name line; contact row centered, icons shown only when the global
`showContactIcons` toggle is on, links underlined.
- **Section header:** serif, bold, **Title-Case** (override base `text-transform: uppercase`
`none`), full-width 1px bottom rule; `break-after: avoid`.
- **Experience / item entries (company-first):**
- Line 1: **Company** bold (left) · **dates** bold (right).
- Line 2: *Title* italic (left) · *Location* italic (right).
- Bullets with `•` markers.
- **Projects:** name bold + tech/links; dates right; bullets.
- **Education:** institution bold (left) · dates right; degree italic line.
- **Skills/Additional:** `**Category**: comma-joined items` lines (bold category labels).
### Clean (`clean`) — single column
- **Header (centered):** light-weight sans name (font-weight ~400), larger; contact rendered
as a single `\|`-separated line, small, links underlined; icons honor `showContactIcons`.
- **Section header:** large gray (`--resume-text-tertiary`) **UPPERCASE** sans, letter-spaced,
thin bottom rule; `break-after: avoid`.
- **Entries (single line):** **COMPANY** bold ` \| ` Title (small-caps gray) on the left;
`Location \| Dates` gray on the right; bullets below.
- **Education / Projects:** analogous single-line headers.
- **Skills/Additional:** `**Label:** items` lines.
### Vivid (`vivid`) — two column
- **Accent wired:** reads `--resume-accent-primary` (default blue); appears in the
accent-color control alongside `modern`/`modern-two-column`.
- **Header (full width, left-aligned):** two-tone name — first token bold in accent-primary,
remaining tokens in a lighter accent tone; `personalInfo.title` on a monospace line; contact
row in monospace with **circular-outlined** icon chips (icons honor `showContactIcons`;
when off, show monospace text only).
- **Section header:** accent-colored, `font-variant: small-caps`, bold (used in both columns;
sidebar variant slightly smaller, matching the existing `-sm` convention).
- **Bullet markers:** accent ➜ arrow instead of `•`.
- **Columns:** reuse `modern-two-column` grid (main 65% / sidebar 35%) and section split.
Sidebar skills render as `•`-separated wrapping lists (matching the reference).
- **Print:** accent colors forced with `-webkit-print-color-adjust: exact` (mirror
`modern.module.css` / `modern-two-column.module.css`).
## Integration Points (mirrors how `modern` was added)
1. **`apps/frontend/lib/types/template-settings.ts`** — extend `TemplateType` union with
`'latex' | 'clean' | 'vivid'`; append three `TEMPLATE_OPTIONS` entries.
2. **`apps/frontend/components/resume/`** — new `resume-latex.tsx`, `resume-clean.tsx`,
`resume-vivid.tsx`; new `styles/latex.module.css`, `styles/clean.module.css`,
`styles/vivid.module.css`; export all three from `index.ts`.
3. **`apps/frontend/components/dashboard/resume-component.tsx`** — import + three conditional
render branches in the dispatcher.
4. **`apps/frontend/components/builder/template-selector.tsx`** — three new
`TemplateThumbnail` variants + `templateLabels` entries.
5. **`apps/frontend/components/builder/formatting-controls.tsx`** — `templateLabels` entries;
add `vivid` to the accent-color visibility condition.
6. **`apps/frontend/components/builder/resume-builder.tsx`** — footer single/two-column label
logic: add `latex`, `clean` to the single-column condition (`vivid` falls through to
two-column).
7. **`apps/frontend/app/print/resumes/[id]/page.tsx`** — add three IDs to `parseTemplate`.
8. **`apps/backend/app/routers/resumes.py`** — update the `/generate` docstring template
allow-list comment (no logic change).
9. **i18n** — add `builder.formatting.templates.{latex,clean,vivid}` `{name, description}` to
all five locales: `messages/{en,es,zh,ja,pt}.json`.
10. **Docs** — update `docs/agent/design/template-system.md` and
`docs/agent/features/resume-templates.md` template tables.
## Testing
Currently there are **no** frontend template tests, so this establishes the pattern. Add
deterministic vitest + Testing Library tests (jsdom) that fail if the templates break:
- **Component render/smoke tests** (`components/resume/__tests__/` or co-located, following
existing vitest conventions): render each of `ResumeLatex`, `ResumeClean`, `ResumeVivid`
with a representative `ResumeData` fixture and assert key text appears (name, a section
heading, a company, a bullet). For `vivid`, assert the two-tone name splits and an accent
marker/element is present.
- **`parseTemplate` test**: the three new IDs round-trip; an unknown value still falls back to
`swiss-single`.
- **`TEMPLATE_OPTIONS` test**: includes all seven IDs with non-empty name/description.
Verification commands are handed to the maintainer to run (shell `npm`/`npx` is avoided per
environment constraints): `cd apps/frontend && npm run test`, `npm run lint`, `npm run build`.
Locale parity is covered by the existing pre-push check.
## Definition of Done
- Three templates selectable in the builder, render in live preview and PDF print route.
- Each matches its reference image by default; universal controls (margins, spacing, size,
page size, applicable font dropdown, contact-icon toggle, and accent color for `vivid`)
function.
- Allow-lists (`parseTemplate`, backend docstring) and i18n (5 locales) updated.
- New deterministic tests pass; lint and build clean.
- Docs tables updated.
@@ -0,0 +1,175 @@
# Resume Wizard Design
## Goal
Add a general-master-resume creation pipeline for users who do not already have a PDF or DOCX resume. The existing upload path remains the fast path, while the new wizard helps users create a truthful structured master resume from scratch through a hybrid one-question-at-a-time Q&A.
## Current Context
The dashboard currently treats the missing master resume state as the entry point for setup. Uploading a PDF or DOCX calls `POST /api/v1/resumes/upload`, creates a normal resume record, marks the resume as master when no healthy master exists, stores `master_resume_id` in local storage, and routes users into the rest of the app.
The new wizard should produce the same downstream shape as an uploaded and parsed master resume: a persisted resume with `is_master=True`, `processing_status="ready"`, `content_type="json"`, and `processed_data` compatible with `ResumeData`.
## Product Flow
When no master resume exists, the dashboard setup tile opens a choice surface with two options:
- Upload an existing resume.
- Create one from scratch with the AI Wizard.
The wizard lives at `/resume-wizard`. It builds a general master resume first, without requiring a pasted target job description. Job-specific tailoring remains a separate flow through the existing tailor pipeline.
The wizard starts with a short AI-guided intro:
1. Ask who the user is and what general role area they are aiming for.
2. Extract the user's name even when the answer is conversational, such as "Hi, I'm James."
3. Personalize the next prompt, for example: "So James, where would you like to begin?"
4. Present section choices: Work Experience, Internships, Education, Projects, Skills, and Review.
After the intro, the user chooses which section to work on. Inside each section, the AI-led conversation asks focused follow-up questions, but the app still owns the section state, resume schema, and validation rules.
## Section Behavior
Work Experience and Internships share the same `workExperience` resume schema. Internship entries can use titles, companies, dates, and bullets just like jobs.
Education maps to `education`.
Projects map to `personalProjects`.
Skills map to `additional.technicalSkills`, with optional user-confirmed languages, certifications, and awards stored in the existing `additional` fields.
The baseline output is:
- Three bullets per work experience or internship entry.
- Two bullets per side project.
- Skills inferred continuously from the user's answers and visible in the skills section before finalization.
The wizard should allow users to skip sections and return to them before finalization. The review step should identify missing but useful information, such as no contact method, no education, no dates, no project impact, or thin bullet details.
## AI Harness
The backend exposes a new `resume-wizard` API namespace. The harness accepts current wizard state plus the latest user action or answer. It returns a structured response, never only free-form prose.
Each turn returns:
- Updated `resume_data`.
- Updated wizard progress and current section.
- Inferred skills and skill suggestions.
- The next assistant message.
- Optional selectable options.
- Optional clarifying questions.
- Validation warnings.
- A completion status for the current section and for the whole resume.
The app controls the schema and allowed section actions. AI can write bullets, extract facts, infer skills, reformat phrasing, and ask clarifying questions, but every returned resume draft is validated through `ResumeData` before the client receives it.
If the model returns invalid JSON, the backend retries using the existing `complete_json` behavior and prompt-only JSON repair instructions. If the response is still invalid, the backend logs detailed errors server-side and returns a generic client error.
## Prompting
Add resume-wizard prompt templates that make the model behave as a structured resume-writing assistant.
The prompts should enforce these rules:
- Build a general master resume, not a job-specific tailored resume.
- Do not invent companies, dates, metrics, tools, degrees, awards, or skills.
- Ask clarifying questions when answers are vague or missing important facts.
- Prefer concise action-oriented bullets grounded in user-provided facts.
- Use the configured content language.
- Output only the requested JSON object.
- Preserve existing draft data unless the user explicitly changes it.
The first prompts should guide the intro and section handoff:
- "Hi, I'll help you create your master resume. What is your name, and what kind of role are you aiming for?"
- "So {name}, where would you like to begin?"
- "Would you like to start with work experience, internships, education, projects, or skills?"
Section prompts should ask for practical facts before drafting. For example, work experience should ask for title, company, dates, responsibilities, tools, scale, and impact. Projects should ask what was built, why it mattered, technologies used, user or usage context, and links when available.
## Backend API
Create `apps/backend/app/routers/resume_wizard.py` and mount it under `/api/v1`.
Planned endpoints:
- `POST /api/v1/resume-wizard/turn`: accepts a wizard state and the latest user answer or section selection, returns the next structured wizard state.
- `POST /api/v1/resume-wizard/finalize`: validates the final draft and creates the master resume.
The finalize endpoint should create a regular resume record through the existing database facade. The created resume should behave like an uploaded parsed master resume:
- `content` is canonical JSON.
- `content_type` is `"json"`.
- `filename` is a generated name such as `"AI Resume Wizard - James.json"`.
- `processed_data` is the validated `ResumeData`.
- `processing_status` is `"ready"`.
- `is_master` is true when there is no current healthy master resume.
If a master resume already exists, the finalize endpoint should reject creation with a clear non-destructive error. An explicit replacement flow is outside this implementation scope.
## Frontend UI
Create a new `/resume-wizard` route under the default app group. The page is a client component.
The layout follows the existing Swiss International Style:
- Canvas background `#F0F0E8`.
- Square corners.
- Black borders.
- Hard offset shadows.
- Serif headers, sans body, monospace metadata.
- No decorative gradients, rounded cards, or marketing hero layout.
The screen should feel like a focused tool, not a landing page. The first viewport should be the wizard itself:
- Left or top progress rail showing Intro, selected sections, and Review.
- Main Q&A panel with assistant prompt, answer textarea, and selectable section buttons.
- Live structured preview or compact summary panel showing collected facts, bullets, and inferred skills.
- Footer actions for Back, Skip, Continue, and Finalize.
The dashboard missing-master tile should open a Swiss-style dialog with two clear choices: upload or AI wizard. Upload keeps using `ResumeUploadDialog`; wizard routes to `/resume-wizard`.
## Frontend Data Contract
Add frontend API helpers in `apps/frontend/lib/api/resume-wizard.ts`.
The wizard page should keep local draft state while calling the backend each turn. It should persist draft state to local storage for refresh recovery. On finalize, it stores the returned `resume_id` in `master_resume_id`, updates the status cache, and routes to `/builder?id=<resume_id>` for review and final manual edits.
The final builder review is intentional. The AI wizard creates a strong first draft, but the user should still be able to inspect and edit before using the resume for tailoring.
## Error Handling
Backend errors should log detailed context and return generic messages. Client errors should appear as Swiss-style alerts with recovery actions:
- Retry AI turn.
- Continue editing the current answer.
- Return to dashboard.
- Open upload path instead.
No model exception details, provider secrets, or raw stack traces should reach the browser.
## Testing
Backend tests should cover:
- Wizard schema validation and coercion through `ResumeData`.
- Intro answers extracting a name and producing section choices.
- Section updates creating baseline bullet counts.
- Skill inference only using user-provided facts.
- Finalize creating a ready master resume when no master exists.
- Finalize rejecting when a master resume already exists.
Frontend tests should cover:
- Dashboard no-master choice between upload and wizard.
- `/resume-wizard` initial render.
- Section picker transition.
- API helper request and response shapes.
- Finalize storing `master_resume_id` and routing to the builder.
- Locale parity for all new translation keys.
Before completion, frontend changes must run `npm run lint` and `npm run format` from `apps/frontend`. Backend changes should run targeted pytest tests for the new router and service.
## Out of Scope
This design does not add job-description-specific tailoring to the wizard. It does not replace the existing upload parser, tailor flow, enrichment flow, or builder. It does not modify CI, Docker, or GitHub workflow files.
@@ -0,0 +1,245 @@
# Resume Wizard Redesign — Design Spec
> **Status:** Approved design (2026-06-04). Supersedes the section-picker UX in
> `docs/superpowers/plans/2026-06-04-resume-wizard-implementation.md`. The backend
> skeleton (router mount, finalize→master-resume, schema/service/prompt file layout,
> localStorage draft, dashboard entry + choice dialog) is **reused**; the turn
> protocol, prompt, and the entire wizard page UX are **replaced**.
## Goal
Turn `/resume-wizard` from a manual, multi-zone form (intro box, a 6-button section
grid, a free-text answer area, and a stats/warnings side panel) into an **AI-led,
one-question-at-a-time** flow: one focused question at a time, the AI writes the polished
(truthful) resume content, decides what to ask next, and the user watches their real
resume build in a quiet live preview. Finalizing creates the master resume and drops
the user into the existing `/builder`.
## Why (problems with the shipped version)
- The layout puts the question on top, a wall of 6 section buttons in the middle, and
the answer box at the bottom — the opposite of a focused flow.
- The right "Live Draft" panel shows stat counts (`Experience 1`, `Skills 0`) and an
always-on orange warnings box — noisy and confusing, not insightful.
- It is **manual**: the user picks sections and types structured answers; the AI only
reacts within a chosen section. Worse, answering during the post-intro picker state
runs an AI turn against the `review` sentinel and is silently discarded.
## Locked decisions
1. **Format — one-question-at-a-time cards.** One big question per screen, single input, thin
segmented progress bar, advance on `Enter`. No section-button grid.
2. **Right side — live resume preview.** A quiet, real resume page fills in as you
answer. No stat counts, no always-on warnings.
3. **AI behavior — writes + adapts.** The AI rewrites casual answers into polished,
truthful resume content AND chooses the next question from what's still thin,
signalling when there's enough to finish.
4. **Turn architecture — one adaptive `complete_json` call per answer** (vs. a
two-call write-then-plan split), for snappy answer→next-question latency.
## UX / Layout
Two-pane layout inside the existing Swiss shell (`bg-background`, 2px black borders,
hard offset shadows, serif headers, mono labels):
- **Left — question card** (`lg:grid-cols-[minmax(0,1fr)_360px]`, card is the `1fr`):
- Thin **segmented progress bar** (server-computed; see Guardrails).
- **Mono kicker** naming the current topic (e.g. `ABOUT YOUR ROLE AT ACME`).
- **Big serif question** (the AI's `next_question.text`).
- **One input** — `Textarea` (multi-line answers; keeps the repo's Enter-key
`stopPropagation` pattern; `⌘/Ctrl+Enter` or the Continue button submits).
- **Footer actions, shown per step:** `intro``Continue` + `Back to Dashboard`;
`question``Continue` (primary) + `Skip` + `← Back` (hidden when `history` is
empty) + `Review & finish` + `Back to Dashboard`; `review``Create master resume`
(primary, success) + `Keep adding` + `Back to Dashboard`. At most one primary per
region (Swiss rule).
- **Right — live preview** (`360px`): a real, scaled resume layout that renders
`resume_data` (name/title, Experience, Education, Projects, Skills as chips). Empty
state: "Your resume appears here as you answer." Newly inferred skills get a brief
green-bordered `✓` accent. **No** counts, **no** orange warning box.
- **Mobile (`< lg`):** preview collapses to a `Peek ▸` toggle so the question stays
full-focus; expanding slides the preview over.
The dashboard entry tile and `MasterResumeChoiceDialog` are **unchanged**.
## Flow & State Machine
`step`: `intro → question → review → complete`.
- **intro** — one card asks name + target role. Submitting runs the adaptive turn with
`section: "intro"`: deterministically extract the name (existing `extract_intro_name`
as a fallback), let the AI capture the target role/summary direction and produce the
first real `next_question`.
- **question** — the adaptive loop. Each answer → one AI turn → updated `resume_data` +
next `current_question` + `inferred_skills` + `is_complete`. Repeats until the user
chooses review, or the server forces review at the question cap.
- **review** — deterministic (no AI call). Shows the assembled preview, gentle
**optional** notes (the relocated warnings), and `Create master resume` /
`Keep adding`.
- **complete** — finalize succeeded; route to `/builder?id=…`.
### Round-tripped state (`ResumeWizardState`)
The frontend holds state in React + `localStorage` ("resume_wizard_draft") and posts it
back each turn (stateless backend, as today). New shape:
| Field | Type | Notes |
|-------|------|-------|
| `step` | `'intro'\|'question'\|'review'\|'complete'` | |
| `resume_data` | `ResumeData` | unchanged shared shape |
| `current_question` | `{ text: str, section: str }` | `section` ∈ intro, workExperience, internships, education, personalProjects, skills, summary, contact, review |
| `history` | `list[{ question, answer, section, resume_data_before }]` | `resume_data_before` snapshot enables deterministic **Back** |
| `asked_count` | `int` | drives the cap + progress |
| `inferred_skills` | `list[str]` | last turn's detected skills (for the green accent) |
| `is_complete` | `bool` | AI *suggests* done; never auto-finalizes |
| `progress` | `{ current: int, total: int }` | **server-computed**, not from the model |
| `warnings` | `list[str]` | populated only at `review` |
**Removed from the old state:** `options` (section picker), `completed_sections`,
`current_section`-as-picker, `pending_questions`.
### `/turn` actions
- `start` — returns `build_initial_wizard_state()` (intro question). (Frontend may also
build this locally; endpoint kept for parity.)
- `answer` — core adaptive AI turn (covers intro + every section).
- `skip` — one AI turn flagged skip: the AI returns the next question **without**
modifying `resume_data`.
- `back`**deterministic**, no AI call: pop `history`, restore the previous
`current_question` + `resume_data_before` + `inferred_skills`.
- `review`**deterministic**, no AI call: `step='review'`, compute `warnings`.
## Backend
### `complete_json` turn contract
One call per `answer`/`skip`, `schema_type="resume"`, in the user's **content
language** (`get_language_name(get_content_language())`) so questions *and* content
localize. The model returns:
```json
{
"resume_data": { full ResumeData envelope },
"next_question": { "text": "…", "section": "workExperience" },
"inferred_skills": ["SQL"],
"is_complete": false
}
```
- `resume_data` is normalized through `normalize_resume_data` + `ResumeData.model_validate`
(same as today). The **section-scoped merge** guard is kept and **extended** so a turn
only writes the fields for `current_question.section`, never clobbering the rest:
`intro`/`contact``personalInfo` (name/title/contact); `summary``summary`;
`workExperience`/`internships``workExperience`; `education``education`;
`personalProjects``personalProjects`; `skills``additional.*`. Unknown sections
no-op on `resume_data` (defensive).
- Skills merge via the existing case-insensitive `merge_unique_skills`.
### Truthfulness (hard rule in the prompt)
Reuse the project policy: aggressively turn the user's own facts into strong bullets,
but **never fabricate** employers, titles, dates, degrees, metrics, tools, or skills.
If a fact is missing, the model must put the ask in `next_question` rather than invent.
(Consistent with the existing `CRITICAL_TRUTHFULNESS_RULES` and the maintainer policy.)
### Server-side guardrails (don't trust the model blindly)
- **Question cap** `RESUME_WIZARD_MAX_QUESTIONS = 15`: once `asked_count >= cap`, the
server overrides `is_complete = true`; the next `answer`/`skip` routes to review.
- **Progress computed server-side**: `total = min(cap, max(8, asked_count + (0 if is_complete else 2)))`,
`current = asked_count`. The bar reflects this, not a model number.
- **`is_complete` only suggests** review (surfaces "Review & finish"); the user can
always "Keep adding". Finalize is always user-initiated.
- **Fallbacks:** missing/blank `next_question` → deterministic per-section prompt
(`_section_prompt`, retained); invalid `resume_data` → keep prior draft, return the
same question, surface a retry (turn raises → 422/500 per the existing handler).
### Finalize — unchanged
`POST /resume-wizard/finalize` keeps current behavior: validate name present
(`ResumeWizardFinalizeRequest`), normalize, `db.create_resume_atomic_master(...)`,
reject with 409 if a ready master already exists, set the title, return
`{resume_id, processing_status:"ready", is_master}`. `build_review_warnings` is kept and
used both at the `review` step and for the gentle notes.
### Files
- `apps/backend/app/schemas/resume_wizard.py` — new state/turn/finalize models.
- `apps/backend/app/prompts/resume_wizard.py` — single adaptive writer/planner prompt.
- `apps/backend/app/services/resume_wizard.py` — adaptive turn, guards, deterministic
back/review/skip, progress, name extraction, skill merge, warnings.
- `apps/backend/app/routers/resume_wizard.py``/turn` (answer/skip/back/review/start)
+ `/finalize` (unchanged). Mount unchanged.
## Frontend
- `components/resume-wizard/resume-wizard-page.tsx` — rebuilt orchestrator: holds
state, renders `QuestionCard` + `LivePreview`, handles answer/skip/back/review/
finalize, localStorage persistence + the existing safe-draft normalizer (kept and
adapted to the new shape).
- `components/resume-wizard/question-card.tsx` — progress bar + kicker + question +
textarea + footer actions; "thinking" state while a turn is in flight.
- `components/resume-wizard/live-preview.tsx`**replaces** `draft-preview.tsx`:
renders `resume_data` as a real resume layout (no counts/warnings); empty state;
green accent for newly inferred skills.
- **Delete** `components/resume-wizard/section-picker.tsx`.
- `lib/api/resume-wizard.ts` — update types to the new state; helpers
`postResumeWizardTurn`, `finalizeResumeWizard`, `createInitialResumeWizardState`
retained with new shapes.
- `app/(default)/resume-wizard/page.tsx` — unchanged (thin wrapper).
- `messages/*.json` (all 5) — refresh `resumeWizard.*`: `kicker`, `title`, intro
question fallback, `actions {continue, skip, back, review, keepAdding, create,
backToDashboard}`, `preview {label, empty, unnamed}`, `review {readyTitle, note*}`,
`errors {turnFailed, finalizeFailed}`. Dynamic question/section text comes from the
backend (localized via content language); static chrome via these keys. Must satisfy
the build-breaking locale-parity rule.
## Error handling
- Turn failure → inline error on the card, **state preserved**, retry the same answer
(matches current behavior). Backend logs detail, returns generic message.
- Finalize `409` (master already exists) → explain + offer "Back to Dashboard".
- Network/timeout → the `apiFetch` 240s default + friendly "timed out" message.
## i18n
The static chrome is translated via `resumeWizard.*` in all five locales. The AI's
question text and the written resume content are produced in the **content language**
(the turn prompt takes `output_language`) — a net improvement over today's English-only
dynamic text. (Section *kickers* derived from `current_question.section` map through a
small i18n label table so they localize too.)
## Testing (anti-theater; must fail when the target breaks)
- **Backend unit** (`tests/unit/test_resume_wizard_service.py`): intro name extraction;
adaptive turn merges only the target section (mocked `complete_json`); skill merge
dedupe; **question cap forces `is_complete`**; **progress computed server-side**;
deterministic `back` restores the prior snapshot; `review` builds warnings without an
AI call; missing `next_question` falls back to the section prompt.
- **Backend integration** (`tests/integration/test_resume_wizard_api.py`): `/turn`
`answer` with mocked LLM returns next question + updated data; `/turn` `back`/`review`
need no LLM; `/finalize` creates a ready master; `/finalize` 409 when a master exists.
- **Frontend** (`tests/resume-wizard-api.test.ts`, `tests/resume-wizard-page.test.tsx`,
`tests/dashboard-master-choice.test.tsx`): initial state shape; posts turns;
full page flow (answer → next question + preview updates → Skip → Back → Review →
Create → routes to `/builder`, sets status cache, clears draft); live-preview renders
`resume_data`; choice dialog unchanged. Plus `i18n-locale-parity.test.ts`.
## Non-goals / out of scope
- No changes to `/builder`, the upload-parse flow, the entry dialog, or PDF/templates.
- No multi-master support; the single-master invariant stands.
- The live preview is purpose-built for the wizard; reusing the real resume render
templates (`components/resume/*`) at small scale is explicitly **deferred** — the full
template experience happens in `/builder` after finalize.
## Risks & mitigations
| Risk | Mitigation |
|------|-----------|
| One prompt does write + plan + completeness | strict JSON `schema_type="resume"`, validation, deterministic fallbacks for `next_question`/`resume_data` |
| Adaptive loop never ends / loops | question cap (15) forces review; manual "Review & finish" always available |
| Per-question latency | single call; subtle "thinking" state; no second round-trip |
| Model clobbers unrelated sections | section-scoped merge guard retained |
| `localStorage` growth from `resume_data_before` snapshots | small JSON; ~15 entries max; well under quota |
| Locale drift breaks `next build` | parity test + mirror every `resumeWizard.*` key across 5 files |