feat: conversational create-resume wizard (/create) - #845
Conversation
Approved brainstorming output for the /create Q&A wizard: guided-section engine with AI authoring, live responsive preview side panel, save-as-master then open in Builder. Stateless backend draft-section endpoints + a create-from-JSON endpoint; frontend-orchestrated fixed script. Anti-fabrication guardrails, deterministic test plan, and file map included.
…ext files
New feature doc (docs/agent/features/create-resume.md) plus context updates for
the /create route across .claude/CLAUDE.md, apps/{backend,frontend}/CLAUDE.md,
backend-guide/backend-architecture endpoint tables, and the front-end-apis
contract (draft-section + create-from-wizard).
resume_context carries user-entered name/title/contact and prior drafted text; it was embedded into the summary LLM call without the injection sanitizer that already guards answers/name/role. Sanitize the serialized JSON too.
- appendDraft('skills') now merges and dedupes (case-insensitive) instead of
overwriting, so re-picking Skills accumulates rather than replacing.
- After a section drafts, the chat now confirms the drafted entry
('Added: <summary>...') via a new summarizeFragment helper + create.added
string (5 locales), matching the spec's 'appears in chat and preview'.
…oard too The /create entry was only in the no-master branch. Per the spec it should also be reachable when a master exists (to make a normal non-master resume); add a grid card gated on masterResumeId.
There was a problem hiding this comment.
Pull request overview
Adds a new “Create Resume” conversational wizard at /create to generate a structured resume from guided Q&A, draft section content via LLM (with anti-fabrication + injection sanitization), show a live preview, and persist the final resume (master iff none exists) before opening it in the Builder.
Changes:
- Backend: added stateless creation endpoints (
POST /resumes/draft-section,POST /resumes) plus creation schemas/service/prompts and tests. - Frontend: added
/createroute with wizard orchestrator, pure state helpers, API client, live preview, and dashboard entry points. - Docs/i18n: updated backend/frontend architecture/API docs and added
create.*translations across 5 locales.
Reviewed changes
Copilot reviewed 39 out of 39 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/superpowers/specs/2026-06-03-create-resume-wizard-design.md | Design spec for the create wizard flow and constraints |
| docs/superpowers/plans/2026-06-03-create-resume-wizard.md | Implementation plan detailing backend/frontend workstreams |
| docs/agent/features/create-resume.md | Feature documentation for /create and related endpoints |
| docs/agent/architecture/backend-guide.md | Backend endpoint quick reference updated with wizard endpoints |
| docs/agent/architecture/backend-architecture.md | Backend endpoint table updated with wizard endpoints |
| docs/agent/apis/front-end-apis.md | Frontend API contract updated with lib/api/create.ts |
| apps/frontend/tests/wizard-script.test.ts | Unit tests for wizard pure state logic |
| apps/frontend/tests/api-create.test.ts | Unit tests for create-wizard API client |
| apps/frontend/messages/en.json | Added create.* i18n strings |
| apps/frontend/messages/es.json | Added create.* i18n strings |
| apps/frontend/messages/zh.json | Added create.* i18n strings |
| apps/frontend/messages/ja.json | Added create.* i18n strings |
| apps/frontend/messages/pt-BR.json | Added create.* i18n strings |
| apps/frontend/lib/api/resume.ts | Exported ProcessedResume type for reuse |
| apps/frontend/lib/api/create.ts | New typed API client for wizard draft + persist calls |
| apps/frontend/components/create/wizard-script.ts | Pure wizard data model + append/assemble helpers |
| apps/frontend/components/create/wizard-preview.tsx | Live preview wrapper reusing existing resume renderer |
| apps/frontend/components/create/section-picker.tsx | Section picker UI component |
| apps/frontend/components/create/creation-wizard.tsx | Wizard orchestrator (chat + preview + persistence) |
| apps/frontend/components/create/contact-fields.tsx | Typed contact details form step |
| apps/frontend/components/create/chat-message.tsx | Chat bubble component |
| apps/frontend/components/create/chat-input.tsx | Chat input component with Enter/Shift+Enter handling |
| apps/frontend/CLAUDE.md | Documented /create route and new API client |
| apps/frontend/app/(default)/dashboard/page.tsx | Added “Create from scratch” entry points (no-master + master states) |
| apps/frontend/app/(default)/create/page.tsx | Registered /create route wrapper |
| apps/backend/tests/unit/test_creation_schemas.py | Unit tests for creation schemas |
| apps/backend/tests/unit/test_creation_prompts.py | Unit tests ensuring prompts format correctly |
| apps/backend/tests/service/test_creation.py | Service tests for section drafting (mocked LLM + sanitization) |
| apps/backend/tests/integration/test_create_api.py | Integration tests for new wizard endpoints |
| apps/backend/tests/conftest.py | Ensured isolated DB fixture patches the new router module |
| apps/backend/CLAUDE.md | Documented the create-wizard router/service/prompts |
| apps/backend/app/services/creation.py | New service to draft validated ResumeData fragments per section |
| apps/backend/app/schemas/creation.py | New request/response schemas for wizard endpoints |
| apps/backend/app/schemas/init.py | Exported creation schemas |
| apps/backend/app/routers/creation.py | New router for draft-section + persist endpoints |
| apps/backend/app/routers/init.py | Exported creation_router |
| apps/backend/app/prompts/creation.py | New anti-fabrication prompts per section |
| apps/backend/app/main.py | Mounted the new creation router under /api/v1 |
| .claude/CLAUDE.md | Added create-resume wizard to feature documentation index |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if not _llm_configured(): | ||
| raise HTTPException( | ||
| status_code=400, detail="LLM not configured. Please set an API key in Settings." | ||
| ) |
| async def create_resume_from_wizard(request: WizardResumeCreate) -> ResumeUploadResponse: | ||
| """Persist the assembled resume; becomes master iff none exists.""" | ||
| try: | ||
| normalized = normalize_resume_data( | ||
| ResumeData.model_validate(request.processed_data).model_dump() | ||
| ) | ||
| created = await db.create_resume_atomic_master( |
| import { useCallback, useEffect, useMemo, useState } from 'react'; | ||
| import { useRouter } from 'next/navigation'; | ||
| import { useTranslations } from '@/lib/i18n'; | ||
| import { draftSection, createResumeFromWizard, type SectionKind } from '@/lib/api/create'; | ||
| import { |
| export function CreationWizard() { | ||
| const { t } = useTranslations(); | ||
| const router = useRouter(); | ||
| const [data, setData] = useState<WizardData>(emptyWizardData); | ||
| const [phase, setPhase] = useState<Phase>('name'); | ||
| const [section, setSection] = useState<Pickable | null>(null); | ||
| const [turns, setTurns] = useState<Turn[]>([]); | ||
| const [busy, setBusy] = useState(false); | ||
| const [showPreview, setShowPreview] = useState(false); |
| """Turn a user's plain answers into one validated ResumeData fragment.""" | ||
| if not _llm_configured(): | ||
| raise HTTPException( | ||
| status_code=400, detail="LLM not configured. Please set an API key in Settings." |
There was a problem hiding this comment.
SUGGESTION: Status code 400 for unconfigured LLM (design spec says 503)
The spec states "503 with a clear, generic message if not configured." While 400 works in practice (the frontend gates before reaching this), 503 (Service Unavailable) is semantically more accurate for a missing dependency. Consider aligning with the spec for consistency.
| """Persist the assembled resume; becomes master iff none exists.""" | ||
| try: | ||
| normalized = normalize_resume_data( | ||
| ResumeData.model_validate(request.processed_data).model_dump() |
There was a problem hiding this comment.
WARNING: ResumeData.model_validate ValidationError returns HTTP 500 instead of 400
If request.processed_data contains invalid resume data (missing required fields, wrong types), Pydantic raises ValidationError. This is caught by the blanket except Exception and re-raised as HTTP 500 ("Failed to save your resume").
A 500 suggests a server bug, but the error is actually client-side bad input. Should catch ValidationError separately and return HTTP 400 with a generic "Invalid resume data" message (per the repo's "log details, return generic" pattern).
| processing_status="ready", | ||
| ) | ||
| if request.title: | ||
| await db.update_resume(created["resume_id"], {"title": request.title}) |
There was a problem hiding this comment.
WARNING: Non-atomic title update after resume creation
update_resume is called after create_resume_atomic_master has already succeeded. If update_resume fails (DB error, connection lost), the resume is persisted without a title but the client receives a 500 error. The user sees a failure message, may retry, and creates a duplicate resume.
Consider passing the title into create_resume_atomic_master directly, or wrapping both operations in a single transaction.
| } | ||
| }} | ||
| /> | ||
| <Button onClick={send} disabled={disabled}> |
There was a problem hiding this comment.
SUGGESTION: Button missing type="button"
The Send button has no explicit type attribute. If this component is ever wrapped inside a <form>, the button will default to type="submit" and trigger form submission. Add type="button" to prevent accidental submissions.
| const next = { ...data, contact: c }; | ||
| setData(next); | ||
| setPhase('summary'); | ||
| void generateSummary(next); |
There was a problem hiding this comment.
CRITICAL: Race condition: double-click on ContactFieldsForm submit triggers duplicate summary LLM calls
ContactFieldsForm's submit button has no disabled guard. If the user double-clicks "Continue", submitContact fires twice, each calling void generateSummary(next) in parallel. Both make unnecessary LLM API calls.
The first setBusy(true) blocks the UI briefly, but the second fire-and-forget call still executes before busy state takes effect. Add a disabled prop to ContactFieldsForm or guard with a ref to prevent duplicate calls.
Code Review SummaryStatus: 5 Issues Found (1 CRITICAL, 2 WARNING, 2 SUGGESTION) | Recommendation: Address before merge Overview
Issue Details (click to expand)CRITICAL
WARNING
SUGGESTION
Other Observations (not in diff)
Files Reviewed (38 files)
Reviewed by qwen3.6-plus · 1,266,848 tokens |
There was a problem hiding this comment.
7 issues found across 39 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/backend/app/services/creation.py">
<violation number="1" location="apps/backend/app/services/creation.py:86">
P2: Summary section silently coerces non-string LLM output via str(), risking data corruption. Unlike work/education/project/skills which validate against Pydantic models, the summary branch falls back to `str(summary)` when the LLM returns a non-string value (e.g., a dict or list). This produces a stringified representation like `"{'text': '...'}"` instead of rejecting the malformed output or extracting the intended text.</violation>
</file>
<file name="apps/frontend/components/create/contact-fields.tsx">
<violation number="1" location="apps/frontend/components/create/contact-fields.tsx:28">
P2: Missing `<form>` element wrapper — pressing Enter in any input field won't trigger submission, and there is no form landmark for screen readers. Users filling out the contact form must reach for the mouse/tab to the button instead of submitting via keyboard.</violation>
</file>
<file name="apps/frontend/app/(default)/dashboard/page.tsx">
<violation number="1" location="apps/frontend/app/(default)/dashboard/page.tsx:381">
P3: "Create from scratch" card is identically duplicated across both the "no master" branch and the "master exists" branch, creating a maintainability issue where changes to one must be manually synced to the other.</violation>
<violation number="2" location="apps/frontend/app/(default)/dashboard/page.tsx:381">
P3: Filler count calculation doesn't account for the new "Create from scratch" card when a master resume exists, causing an off-by-one in the grid layout's filler cards.</violation>
</file>
<file name="apps/frontend/components/create/creation-wizard.tsx">
<violation number="1" location="apps/frontend/components/create/creation-wizard.tsx:46">
P2: Restored draft data is cast to `WizardData` without shape validation, so malformed/legacy localStorage can crash the wizard on `.length` access.</violation>
</file>
<file name="apps/backend/app/routers/creation.py">
<violation number="1" location="apps/backend/app/routers/creation.py:57">
P1: Invalid `processed_data` is returned as HTTP 500 because `ValidationError` is swallowed by a broad exception handler in the create endpoint.</violation>
<violation number="2" location="apps/backend/app/routers/creation.py:67">
P2: Title update failure turns a successful resume creation into HTTP 500, causing partial success and retry duplication risk.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| async def create_resume_from_wizard(request: WizardResumeCreate) -> ResumeUploadResponse: | ||
| """Persist the assembled resume; becomes master iff none exists.""" | ||
| try: | ||
| normalized = normalize_resume_data( |
There was a problem hiding this comment.
P1: Invalid processed_data is returned as HTTP 500 because ValidationError is swallowed by a broad exception handler in the create endpoint.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/app/routers/creation.py, line 57:
<comment>Invalid `processed_data` is returned as HTTP 500 because `ValidationError` is swallowed by a broad exception handler in the create endpoint.</comment>
<file context>
@@ -0,0 +1,79 @@
+async def create_resume_from_wizard(request: WizardResumeCreate) -> ResumeUploadResponse:
+ """Persist the assembled resume; becomes master iff none exists."""
+ try:
+ normalized = normalize_resume_data(
+ ResumeData.model_validate(request.processed_data).model_dump()
+ )
</file context>
| return {"technicalSkills": validated.technicalSkills} | ||
| # summary | ||
| summary = raw.get("summary", "") | ||
| return {"summary": summary if isinstance(summary, str) else str(summary)} |
There was a problem hiding this comment.
P2: Summary section silently coerces non-string LLM output via str(), risking data corruption. Unlike work/education/project/skills which validate against Pydantic models, the summary branch falls back to str(summary) when the LLM returns a non-string value (e.g., a dict or list). This produces a stringified representation like "{'text': '...'}" instead of rejecting the malformed output or extracting the intended text.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/app/services/creation.py, line 86:
<comment>Summary section silently coerces non-string LLM output via str(), risking data corruption. Unlike work/education/project/skills which validate against Pydantic models, the summary branch falls back to `str(summary)` when the LLM returns a non-string value (e.g., a dict or list). This produces a stringified representation like `"{'text': '...'}"` instead of rejecting the malformed output or extracting the intended text.</comment>
<file context>
@@ -0,0 +1,86 @@
+ return {"technicalSkills": validated.technicalSkills}
+ # summary
+ summary = raw.get("summary", "")
+ return {"summary": summary if isinstance(summary, str) else str(summary)}
</file context>
| const { t } = useTranslations(); | ||
| const [values, setValues] = useState<ContactFields>(initial); | ||
| return ( | ||
| <div className="border border-black bg-canvas p-4 shadow-sw-default"> |
There was a problem hiding this comment.
P2: Missing <form> element wrapper — pressing Enter in any input field won't trigger submission, and there is no form landmark for screen readers. Users filling out the contact form must reach for the mouse/tab to the button instead of submitting via keyboard.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/frontend/components/create/contact-fields.tsx, line 28:
<comment>Missing `<form>` element wrapper — pressing Enter in any input field won't trigger submission, and there is no form landmark for screen readers. Users filling out the contact form must reach for the mouse/tab to the button instead of submitting via keyboard.</comment>
<file context>
@@ -0,0 +1,47 @@
+ const { t } = useTranslations();
+ const [values, setValues] = useState<ContactFields>(initial);
+ return (
+ <div className="border border-black bg-canvas p-4 shadow-sw-default">
+ <p className="mb-3 font-serif text-lg">{t('create.contactTitle')}</p>
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
</file context>
| const saved = localStorage.getItem(DRAFT_KEY); | ||
| if (saved) { | ||
| try { | ||
| const parsed = JSON.parse(saved) as WizardData; |
There was a problem hiding this comment.
P2: Restored draft data is cast to WizardData without shape validation, so malformed/legacy localStorage can crash the wizard on .length access.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/frontend/components/create/creation-wizard.tsx, line 46:
<comment>Restored draft data is cast to `WizardData` without shape validation, so malformed/legacy localStorage can crash the wizard on `.length` access.</comment>
<file context>
@@ -0,0 +1,228 @@
+ const saved = localStorage.getItem(DRAFT_KEY);
+ if (saved) {
+ try {
+ const parsed = JSON.parse(saved) as WizardData;
+ if (parsed && parsed.name) {
+ setData(parsed);
</file context>
| processing_status="ready", | ||
| ) | ||
| if request.title: | ||
| await db.update_resume(created["resume_id"], {"title": request.title}) |
There was a problem hiding this comment.
P2: Title update failure turns a successful resume creation into HTTP 500, causing partial success and retry duplication risk.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/app/routers/creation.py, line 67:
<comment>Title update failure turns a successful resume creation into HTTP 500, causing partial success and retry duplication risk.</comment>
<file context>
@@ -0,0 +1,79 @@
+ processing_status="ready",
+ )
+ if request.title:
+ await db.update_resume(created["resume_id"], {"title": request.title})
+ except Exception as e:
+ logger.error("create_resume_from_wizard failed: %s", e)
</file context>
| @@ -17,6 +17,7 @@ import RefreshCw from 'lucide-react/dist/esm/icons/refresh-cw'; | |||
| import Plus from 'lucide-react/dist/esm/icons/plus'; | |||
There was a problem hiding this comment.
P3: "Create from scratch" card is identically duplicated across both the "no master" branch and the "master exists" branch, creating a maintainability issue where changes to one must be manually synced to the other.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/frontend/app/(default)/dashboard/page.tsx, line 381:
<comment>"Create from scratch" card is identically duplicated across both the "no master" branch and the "master exists" branch, creating a maintainability issue where changes to one must be manually synced to the other.</comment>
<file context>
@@ -350,32 +351,53 @@ export default function DashboardPage() {
+ </Card>
+ }
+ />
+ <Link href="/create" className="block h-full">
<Card
variant="interactive"
</file context>
| </Card> | ||
| } | ||
| /> | ||
| <Link href="/create" className="block h-full"> |
There was a problem hiding this comment.
P3: Filler count calculation doesn't account for the new "Create from scratch" card when a master resume exists, causing an off-by-one in the grid layout's filler cards.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/frontend/app/(default)/dashboard/page.tsx, line 381:
<comment>Filler count calculation doesn't account for the new "Create from scratch" card when a master resume exists, causing an off-by-one in the grid layout's filler cards.</comment>
<file context>
@@ -350,32 +351,53 @@ export default function DashboardPage() {
+ </Card>
+ }
+ />
+ <Link href="/create" className="block h-full">
<Card
variant="interactive"
</file context>
Summary
Adds a conversational Create Resume wizard at
/create— the missing on-ramp for users who don't have a resume to upload. A guided chat interviews the user, the LLM authors polished resume content from their plain answers, the resume builds live beside the chat, and the result is saved as the master resume, then opened in the Builder.This closes the "I have nothing → a tailored, downloadable resume" gap (previously the only way in was uploading a PDF/DOCX).
The flow
greet → name → "what do you do" → pick a section
[Work] [Education] [Projects] [Skills]→ you talk, the AI drafts that section (it appears in chat and the live preview) → contact details → AI summary (accept / rewrite) → Save → opens in the Builder as your master resume.What's included
Backend (stateless, mirrors the existing
enrichmentanalyze/enhance pattern):POST /api/v1/resumes/draft-section— author one validatedResumeDatafragment per section (LLM)POST /api/v1/resumes— persist the assembled resume (becomes master iff none exists, viacreate_resume_atomic_master)services/creation.py,prompts/creation.py(anti-fabrication),schemas/creation.py,routers/creation.pyFrontend (Swiss International Style):
/createroute +creation-wizard.tsxorchestrator (responsive split: chat ↔ live preview; preview becomes a drawer on mobile)wizard-script.tspure state machine (assemble / append / finish gate / summarize), 5 presentational components,lib/api/create.tslocalStoragecreate.*i18n across all 5 locales (en/es/zh/ja/pt-BR)Tests & verification
next buildgreen —/createis a registered route, locale parity holds,tsccleanDesign docs
Spec + bite-sized TDD plan committed under
docs/superpowers/(2026-06-03-create-resume-wizard-*). Feature doc atdocs/agent/features/create-resume.md; context files updated (root + backend + frontendCLAUDE.md, backend-guide/architecture endpoint tables, front-end-apis contract).Notes
npm run lint(eslint .) is red on pre-existingprettier/prettierviolations in unrelated files (components/resume/resume-*.tsx) — present onmain, not introduced here;next builddoes not gate on ESLint. Every file in this PR is eslint + prettier clean./create, talk through a section, confirm it lands in the Builder prefilled.Summary by cubic
Adds a conversational “Create Resume” wizard at /create that interviews the user, drafts clean sections from their answers with a live preview, and saves the result as the master resume for editing in the Builder. This gives new users a from‑scratch path without uploading a file.
New Features
Bug Fixes
Written for commit 85c7b6a. Summary will update on new commits.