Resume Matcher 简历向导(Resume Wizard)重构设计解析:从手动表单到 AI 逐问式引导的完整实现
【免费下载链接】Resume-MatcherThe #1 AI Harness for Building Resumes, PDFs, Cover Letters & more, locally with 100+ LLMs support.项目地址: https://gitcode.com/GitHub_Trending/re/Resume-Matcher
本文基于仓库设计文档 2026-06-04-resume-wizard-redesign-design.md 展开,结合后端服务、提示词模板、路由、前端组件与测试代码,逐层剖析这套"AI 每次只问一个问题、边答边生成简历草稿"的交互架构,涵盖状态机设计、单次
complete_json轮次契约、节域合并守卫、服务端护栏、确定性回退与测试策略。
Resume Matcher 的/resume-wizard页面曾是一个手动多区域表单:顶部问题、中部六个分区按钮、底部自由文本答案区,右侧还有一个显示统计数字与常驻橙色警告的"Live Draft"面板。这套旧交互的问题在于:用户先选分区再打字,AI 只在所选分区内被动响应,且部分状态下的回答会被静默丢弃。本文要解析的重构方案,把该页面彻底改造成AI 主导、一次一问的流程:每次只呈现一个聚焦的问题,AI 将用户的随意回答重写为精炼、真实的简历内容,同时自主决定下一个该问什么,用户则在一旁的安静实时预览中看着简历逐渐成形,最终一键落定为主简历(Master Resume)并跳转到/builder继续编辑。读完本文,你将掌握该向导的完整状态机、轮次协议、防模型幻觉的护栏体系,以及前后端对应的实现落点。
背景:旧版向导为什么被推翻
设计文档开篇即点明了已发布版本(shipped version)的三个核心问题:
- 布局违背专注原则:问题在最上方、六个分区按钮组成的"墙"在中部、答案框在底部——信息密度过高,与"一次只做一件事"的向导直觉相悖。
- 右侧 Live Draft 面板噪音过大:展示
Experience 1、Skills 0之类的统计数字,以及常亮的橙色警告框,既吵闹又缺乏洞察。 - 流程本质是手动的:用户自行选分区、填写结构化答案,AI 只在已选分区内做出反应;更糟的是,在 intro 之后的分区选择状态下回答,会以
review哨兵值触发一轮 AI 调用,结果被静默丢弃。
此外,该设计文档明确说明它取代了旧的分区选择器(section-picker)方案,而旧方案中的一部分基础设施被复用:后端路由挂载、finalize→master-resume、schema/service/prompt 文件布局、localStorage 草稿、dashboard 入口与选择对话框均保持不变;被替换的是轮次协议、提示词与整个向导页面 UX。
四项锁定决策
重构的核心决策被文档固化为四条不可动摇的原则:
| 决策 | 内容 |
|---|---|
| 1. 逐问卡片格式 | 每屏一个大问题、单一输入框、纤细的分段进度条,按Enter前进;彻底移除分区按钮网格 |
| 2. 右侧实时简历预览 | 一面安静的真实简历页随回答逐步填充;无统计数字、无常驻警告 |
| 3. AI 行为 = 写作 + 自适应 | AI 把随意回答重写为精炼且真实的简历内容,并从"仍然薄弱"的部分中挑选下一个问题,在内容足够时给出完成信号 |
4. 轮次架构 = 每次回答一次自适应complete_json调用 | 取代旧的两段式"先写再规划"拆分,换取回答→下一个问题的低延迟 |
第 4 点直接决定了后端轮次协议的形态,后面会看到它如何在 服务层 中落地为run_ai_turn单函数。
UX 与布局:Swiss 风格双栏结构
新界面沿用项目的 Swiss 设计语言(bg-background背景、2px 黑色描边、硬偏移阴影、衬线标题、等宽标签),整体是双栏布局:
- 左侧 — 问题卡片(网格列定义为
lg:grid-cols-[minmax(0,1fr)_360px],卡片占据1fr):- 顶部的分段进度条(分段数完全由服务端计算,见"护栏"一节);
- 等宽 kicker,标出当前话题,例如
ABOUT YOUR ROLE AT ACME(在实际 i18n 中会本地化为对应语言,如中文的"工作经历"); - 大号衬线问题文本(取自 AI 返回的
next_question.text); - 单一输入框
Textarea:支持多行回答,保留仓库既有的 Enter 键stopPropagation模式,⌘/Ctrl+Enter或 Continue 按钮提交; - 底部动作区按步骤变化:
intro步骤显示Continue+Back to Dashboard;question步骤显示Continue(主按钮)+Skip+← Back(当history为空时隐藏)+Review & finish+Back to Dashboard;review步骤显示Create master resume(主按钮,success 变体)+Keep adding+Back to Dashboard。每个区域最多一个主按钮(Swiss 规则)。
- 右侧 — 实时预览(固定
360px):渲染resume_data的真实缩略简历布局(姓名/头衔、经历、教育、项目、技能 chip)。空状态显示文案"Your resume appears here as you answer."(i18n 键resumeWizard.preview.empty,中文为"当你回答问题时,简历将在此处显示。")。新推断出的技能会获得短暂的绿色描边✓强调。没有计数、没有橙色警告框。 - 移动端(
< lg):预览折叠为Peek ▸切换开关,保证问题保持全焦点;展开时预览以滑动方式覆盖。
在 question-card.tsx 中可以看到这套布局的逐行实现:进度条用role="progressbar"+aria-valuenow实现无障碍语义,每个分段是一个flex-1的黑色/白色小条;Enter 键处理在handleKeyDown中先stopPropagation再preventDefault并触发提交,Shift+Enter则换行;底部动作区依据isReview/isQuestion分支渲染不同按钮组。isComplete时在问题步骤下还会显示一个绿色方块 +resumeWizard.readyHint(中文:"你已经有足够的内容来生成一份出色的简历——随时可以查看并完成。")作为"内容已足够"的弱信号。
实时预览实现在 live-preview.tsx:它按"有无任何内容"判断空状态,有内容时渲染姓名/头衔、经历(标题·公司、年份、要点列表)、项目、教育、技能 chip;技能去重用与后端casefold语义对齐的toLowerCase(注释明确说明不用toLocaleLowerCase以避免土耳其语等 locale 的大小写差异);inferredSkills中本轮新推断的技能以inferredKeys集合比对,命中则套用绿色边框并追加✓。
/builder的入口瓷贴与MasterResumeChoiceDialog(主简历选择对话框)保持不变,重构不触碰这两处。
流程与状态机:intro → question → review → complete
整个向导是四步状态机,由ResumeWizardStep = Literal["intro", "question", "review", "complete"]定义(见 schemas/resume_wizard.py):
- intro— 一张卡片同时询问姓名与目标岗位。提交后以
section: "intro"运行自适应轮次:确定性提取姓名(以现有extract_intro_name兜底),让 AI 捕捉目标岗位/摘要方向,并产出第一个真正的next_question。 - question— 自适应主循环。每个回答 → 一次 AI 轮次 → 更新
resume_data+ 下一个current_question+inferred_skills+is_complete。持续迭代,直到用户主动选择 review,或服务端在问题数达到上限时强制转入 review。 - review—确定性步骤(不调用 AI)。展示组装好的预览、温和的可选提示(即被迁移过来的旧警告,以更轻的方式呈现),以及
Create master resume/Keep adding两个动作。 - complete— finalize 成功;前端路由跳转
/builder?id=…。
值得注意的设计细节是:is_complete只是 AI 的建议,向导永远不会自动 finalize;用户始终保有"Keep adding"的控制权。这一点在服务端run_ai_turn的注释中被明确强调:is_complete仅用于浮现 "Review & finish",步骤仍停留在question。
往返状态(ResumeWizardState)
前端把状态保存在 React +localStorage(键resume_wizard_draft)中,并在每轮把它 POST 回后端(后端保持无状态,与现状一致)。新的状态形状:
| 字段 | 类型 | 说明 |
|---|---|---|
step | 'intro'\|'question'\|'review'\|'complete' | 状态机当前步 |
resume_data | ResumeData | 复用的共享简历数据形状 |
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快照使Back具备确定性 |
asked_count | int | 驱动问题上限与进度条 |
inferred_skills | list[str] | 上一轮检测到的技能(用于绿色强调) |
is_complete | bool | AI建议完成;绝不自动 finalize |
progress | { current: int, total: int } | 服务端计算,不信任模型 |
warnings | list[str] | 仅在review步骤填充 |
从旧状态中移除的字段:options(分区选择器)、completed_sections、作为选择器的current_section、pending_questions。
Pydantic 模型在 schemas/resume_wizard.py 中完整定义了这套结构,并附带校验规则:ResumeWizardAnswer.text长度 1~6000、拒绝纯空白;ResumeWizardTurnRequest要求answer动作必须携带answer;ResumeWizardFinalizeRequest要求personalInfo.name非空(缺失即 422)。前端侧的 TypeScript 等价物在 lib/api/resume-wizard.ts 中,createInitialResumeWizardState()给出初始状态:step: 'intro'、空resume_data、intro 问题、progress: { current: 0, total: 8 }。
/turn的五个动作
start— 返回build_initial_wizard_state()(intro 问题)。前端也可以本地构建初始状态;该端点仅为对等性保留。answer— 核心自适应 AI 轮次(覆盖 intro 与每个分区)。skip— 一次带 skip 标志的 AI 轮次:AI 返回下一个问题但不改写resume_data。back—确定性操作,不调用 AI:弹出history,恢复上一个current_question+resume_data_before+inferred_skills。review—确定性操作,不调用 AI:step='review',计算warnings。
路由层的分派逻辑在 routers/resume_wizard.py 中一目了然:start/back/review直接返回确定性结果;asked_count >= RESUME_WIZARD_MAX_QUESTIONS时对 answer/skip 做成本守卫,直接转入 review 而不触发 LLM 调用;skip调用run_ai_turn(state, "", skip=True);其余走run_ai_turn(state, answer_text, skip=False)。异常处理统一收敛为 422(校验/值错误)或 500(未知错误),日志记录细节、返回给用户的只是通用消息。
后端:complete_json轮次契约
每次answer/skip只有一次LLM 调用,使用schema_type="resume",并以用户的内容语言生成(get_language_name(get_content_language())),因此问题与简历内容都会本地化。模型返回的 JSON 结构为:
{ "resume_data": { …完整 ResumeData 信封… }, "next_question": { "text": "…", "section": "workExperience" }, "inferred_skills": ["SQL"], "is_complete": false }处理管线(见 services/resume_wizard.py 的run_ai_turn):
- 序列化当前草稿:
json.dumps(state.resume_data.model_dump(mode="json"), ensure_ascii=False),连同output_language、current_section、answer_text一起格式化进提示词模板 prompts/resume_wizard.py。 - 输入清洗:回答先过
_sanitize_user_input(来自 improver 服务)剥离提示注入模式,再过_scrub_secrets脱敏sk-…/AIza…/Bearer …这类凭据型 token——这是对 LLM 的纵深防御,测试test_ai_turn_sanitizes_user_answer_before_prompting专门验证注入语句会被[REDACTED]替换。 - 模型输出:
await complete_json(prompt, max_tokens=8192, schema_type="resume");非 dict 结果直接抛ValueError。 - 校验与归一化:
resume_data经normalize_resume_data+ResumeData.model_validate(与全仓库其他路径一致)。 - 节域合并:见下文。
- 状态推进:
asked_count + 1;is_complete = bool(result["is_complete"]) or asked_count >= 15;追加history条目;用_next_question决定下一个问题;最后compute_progress计算进度。
提示词模板 prompts/resume_wizard.py 中值得注意的约束:所有人类可读文本(下一个问题、标题、要点、摘要)都用{output_language}书写,但结构性值保持原样——next_question.section必须是精确的英文枚举值,日期保持用户给出的格式,不翻译分区键与日期。内容形状方面,工作/实习条目在事实充足时目标写 3 个要点,项目条目目标 2 个要点,技能只能来自用户给出的事实或已有草稿。
节域合并守卫:绝不覆盖无关分区
设计文档明确要求把旧的节域合并守卫保留并扩展:一轮只能写入current_question.section对应字段,绝不能动其他部分。映射关系为:
intro/contact→personalInfo(name/title/contact)summary→summaryworkExperience/internships→workExperienceeducation→educationpersonalProjects→personalProjectsskills→additional.*- 未知分区对
resume_datano-op(防御性)
该逻辑实现在_merge_section中:先existing.model_copy(deep=True),然后只针对当前分区做字段级写入。技能通过现有的大小写不敏感merge_unique_skills合并(保留首次出现的写法与顺序)。
列表类分区(经历、教育、项目)还额外依赖_merge_entries的内容签名合并:模型若只回显用户刚描述的这条记录而漏掉其他条目,已有条目不会被抹除——按签名(例如 title+company+years 的 casefold 元组)做"缺失保留、回显替换、新增追加"。注释解释得很清楚:签名基于内容而非id,因为向导提示词并不要求模型输出id,条目默认id=0。随后_assign_entry_ids会按稳定顺序把三条列表(workExperience、education、personalProjects)的条目重编号为唯一的 1 起id——否则实时预览的 Reactkey={item.id}与/builder的Math.max(...ids)+1新增逻辑都会失效。
单元测试test_ai_turn_does_not_let_other_sections_be_clobbered精确验证了这一点:模型在skills轮次故意返回空workExperience,结果已有经历被保留、技能被合并为["SQL", "Python"];test_ai_turn_full_echo_keeps_all_experience_in_order与test_ai_turn_partial_echo_does_not_drop_prior_experience则分别验证"全量回显保序"与"部分回显不丢旧条目"两种情形。
真实性规则:提示词中的硬约束
复用项目既有策略:激进地把用户自己的事实转化为有力的要点,但绝不捏造雇主、职位、日期、学位、指标、工具或技能。若某个事实缺失,模型必须把它放进next_question去追问,而不是自行编造。这与现有CRITICAL_TRUTHFULNESS_RULES及维护者策略保持一致。提示词中该规则被列为 non-negotiable 的五条:
- 绝不虚构雇主、职位、日期、学位、认证、奖项、指标、工具或技能;
- 只把用户自己给出的事实改写为有力内容,不添加其未提供的事实;
- 需要的事实缺失或模糊时,不要猜测——放入
next_question追问; - 保留已有草稿数据,除非用户明确修改;
- 构建通用主简历,而非针对某个岗位的定制简历(后者的定制发生在
/tailor)。
服务端护栏:不盲信模型
- 问题上限
RESUME_WIZARD_MAX_QUESTIONS = 15:一旦asked_count >= cap,服务端把is_complete覆写为true;下一次answer/skip直接路由到 review。 - 进度条服务端计算:
total = min(cap, max(8, asked_count + (0 if is_complete else 2))),current = asked_count。进度条反映的是这个确定性数字,而非模型输出。初始基线_PROGRESS_BASELINE = 8。 is_complete只建议:仅浮现 "Review & finish",用户随时可以 "Keep adding"。Finalize 永远由用户发起。- 回退机制:
next_question缺失/空白时,用确定性的分区提示词_section_prompt(对应_SECTION_PROMPTS字典,内含 intro/contact/summary/workExperience/internships/education/personalProjects/skills/review 九个分区的默认问题文案)回退;resume_data非法时保留先前草稿、返回同一问题并提示重试(轮次抛错 → 按既有 handler 返回 422/500)。此外valid_section会把模型给出的未知分区钳制为review,_next_gap_section按"workExperience → education → personalProjects → skills → review"的空白优先序兜底选下一个问题。
这些护栏都有测试背书:test_compute_progress_grows_with_questions_and_caps验证进度增长与封顶;test_ai_turn_question_cap_forces_completion验证模型返回is_complete=false时上限仍会强制完成;集成测试test_turn_answer_past_cap_routes_to_review_without_llm断言达到上限后complete_json根本没有被调用(成本守卫);test_ai_turn_missing_next_question_falls_back_to_gap验证缺失next_question时自动落到下一个空白分区。
Finalize:保持不变
POST /resume-wizard/finalize保持既有行为:校验姓名存在(ResumeWizardFinalizeRequest)、归一化、db.create_resume_atomic_master(...)、若已存在 ready 状态的主简历则返回 409、设置标题、返回{resume_id, processing_status:"ready", is_master}。build_review_warnings保留,既用于review步骤也用于温和提示。
从 routers/resume_wizard.py 的实现看,finalize 还有两个工程细节:其一,主简历标题f"{name} Master Resume"在原子创建时一并设置(而非创建后再更新),避免"已提交但无标题的主简历"导致重试时永久 409;其二,若创建出的记录意外不是 master(竞态),会删除该记录并同样返回 409。build_review_warnings的五条温和提示(见 services/resume_wizard.py)覆盖:缺姓名(finalize 的硬性 422 门槛,review 阶段提前暴露)、无任何联系方式、无经历/实习/项目、教育为空、技能为空。
文件清单:改动落在哪里
| 文件 | 角色 |
|---|---|
| schemas/resume_wizard.py | 新的 state/turn/finalize 模型 |
| prompts/resume_wizard.py | 单一的自适应写手/规划提示词 |
| services/resume_wizard.py | 自适应轮次、护栏、确定性 back/review/skip、进度、姓名提取、技能合并、警告 |
| routers/resume_wizard.py | /turn(answer/skip/back/review/start)+/finalize(不变),挂载不变 |
| resume-wizard-page.tsx | 重建的编排器:持有状态、渲染 QuestionCard + LivePreview、处理五类动作、localStorage 持久化与安全的草稿归一化器(保留并适配新形状) |
| question-card.tsx | 进度条 + kicker + 问题 + textarea + 底部动作;轮次进行中的 "thinking" 状态 |
| live-preview.tsx | 取代draft-preview.tsx:把resume_data渲染为真实简历布局(无计数/警告)、空状态、新推断技能的绿色强调 |
| 删除 | |
| lib/api/resume-wizard.ts | 更新为新形状的类型;postResumeWizardTurn、finalizeResumeWizard、createInitialResumeWizardState以新形状保留 |
| app/(default)/resume-wizard/page.tsx/resume-wizard/page.tsx) | 不变(薄封装) |
| messages/*.json(全部 5 种语言) | 刷新resumeWizard.*:kicker、title、intro 问题回退、actions {continue, skip, back, review, keepAdding, create, backToDashboard}、preview {label, empty, unnamed}、review {readyTitle, note*}、errors {turnFailed, finalizeFailed};必须满足破坏构建的 locale 对齐规则 |
前端编排器:状态、草稿恢复与动作分派
resume-wizard-page.tsx 是前端的神经中枢。几个值得展开的实现要点:
- 草稿持久化:
useEffect在每次状态变化时写localStorage['resume_wizard_draft'],但把写入包在 try/catch 里——持久化是"尽力而为"的锦上添花,配额/序列化错误绝不能崩溃向导;step === 'complete'时停止写入。 - 防崩溃草稿归一化:
readSavedDraft会把可能损坏的持久化草稿逐字段洗成安全形状——normalizeDraftResumeData把三个列表字段强制转数组并给每条补 1 起id(asEntriesWithIds),normalizeDraftPersonalInfo把所有 personalInfo 字段强制转字符串,避免数字型姓名让后续.trim()抛错把用户困在重载循环里。测试recovers from a corrupt saved draft without crashing the render与recovers from a draft with non-string personalInfo fields专门覆盖这两类事故。 - Keep Adding 的本地逻辑:
handleKeepAdding不调用 API,直接把步骤切回question并把current_question.section指向firstGapSection计算出的下一个空白分区——因为review分区在后端合并层是 no-op,若沿用 review 分区回答会被静默丢弃(这正是旧版 bug 的翻版,前端显式规避)。 - Finalize 收尾:成功后将
master_resume_id写入localStorage、清除草稿、incrementResumes()、setHasMasterResume(true),最后router.push('/builder?id=…')。
错误处理策略
- 轮次失败→ 卡片内联错误提示,状态完整保留,可重试同一回答(与现有行为一致);后端记录细节、返回通用消息(
resumeWizard.errors.turnFailed,中文"向导无法保存该回答。请重试。")。 - Finalize 409(主简历已存在)→ 解释原因并提供 "Back to Dashboard"。
- 网络/超时→ 沿用
apiFetch的 240s 默认超时,配友好的 "timed out" 提示。
i18n:静态外壳本地化 + 动态内容随内容语言
静态外壳通过五个 locale 文件中的resumeWizard.*键翻译(中文版见 messages/zh.json 的resumeWizard段,包含actions、preview、errors、sections九分区标签、readyHint、keepAddingPrompt等);AI 生成的问题文本与简历内容则使用内容语言(轮次提示词接收output_language)——这比旧版英文-only 的动态文本是一个净改进。分区 kicker 由current_question.section经一张小型 i18n 标签表映射(t('resumeWizard.sections.' + section)),因此同样被本地化。设计文档特别提醒:动态问题/分区文本来自后端,静态外壳走键值翻译,两者必须满足会破坏构建的 locale 对齐规则(由i18n-locale-parity.test.ts守护,仓库根目录还有 scripts/check_locale_parity.py 可复跑校验)。
测试策略:反形式主义,目标破坏时必须失败
测试被设计为"anti-theater"——当实现回归时测试必须红:
- 后端单元(tests/unit/test_resume_wizard_service.py):intro 姓名提取(
"I'm James"→James、"My name is Priya Sharma"→Priya Sharma);自适应轮次只合并目标分区(mockcomplete_json);技能去重保序;问题上限强制is_complete;进度服务端计算;确定性back恢复先前快照;review不调用 AI 直接构建警告;next_question缺失回退到分区提示词;skip 不改resume_data;答案清洗注入尝试;_assign_entry_ids重编号。 - 后端集成(tests/integration/test_resume_wizard_api.py):
/turnanswer带 mock LLM 返回下一个问题与更新数据;/turnback/review无需 LLM;/finalize创建 ready 主简历;/finalize已有主简历时 409;达到上限后 answer 直接转 review 且LLM 未被调用。 - 前端(tests/resume-wizard-api.test.ts、tests/resume-wizard-page.test.tsx、tests/dashboard-master-choice.test.tsx):初始状态形状;发起轮次;完整页面流(回答 → 下一个问题 + 预览更新 → Skip → Back → Review → Create → 路由到
/builder、设置状态缓存、清除草稿);live-preview 渲染resume_data;选择对话框不变。另有i18n-locale-parity.test.ts守护五语对齐。resume-wizard-page.test.tsx的用例清单包括:渲染 intro 问题与答案框、提交 intro 答案显示下一个问题、Review 进入审查、finalize 后路由并清草稿、轮次失败保留问题、损坏草稿恢复、skip/back 分派、keep-adding 纯本地返回 question 步骤。
非目标与边界
- 不改动
/builder、上传解析流程、入口对话框、PDF/模板。 - 不支持多主简历;单主简历不变量保持不变。
- 实时预览是为向导专门构建的;复用真实简历渲染模板(
components/resume/*)做小尺寸渲染被明确推迟——完整模板体验在 finalize 后的/builder中进行。
风险与缓解
| 风险 | 缓解 |
|---|---|
| 单一提示词同时承担写作 + 规划 + 完整性判断 | 严格 JSONschema_type="resume"、校验、next_question/resume_data的确定性回退 |
| 自适应循环永不结束/死循环 | 问题上限(15)强制进入 review;手动 "Review & finish" 始终可用 |
| 每问题延迟 | 单次调用;subtle "thinking" 状态;无第二次往返 |
| 模型覆盖无关分区 | 保留节域合并守卫 |
localStorage因resume_data_before快照膨胀 | JSON 很小;最多约 15 条;远低于配额 |
locale 漂移破坏next build | 对齐测试 + 五个文件镜像每一个resumeWizard.*键 |
小结
这套重构的本质,是把"工具"变成"对话":一次回答只触发一次complete_json调用,模型同时完成内容写作、下一问规划与完整性判断;服务端用节域合并守卫、15 问上限、服务端进度计算与确定性回退兜住模型的不确定性;前端用history快照实现零 AI 成本的 Back、用 localStorage 草稿实现刷新恢复,并在 finalize 后无缝衔接/builder。如果你想深入某个环节,建议从 services/resume_wizard.py 的run_ai_turn读起,再对照 prompts/resume_wizard.py 的提示词约束和 tests/unit/test_resume_wizard_service.py 的边界用例,就能完整还原这套"AI 引导逐问式简历构建"从设计到落地的全过程。
【免费下载链接】Resume-MatcherThe #1 AI Harness for Building Resumes, PDFs, Cover Letters & more, locally with 100+ LLMs support.项目地址: https://gitcode.com/GitHub_Trending/re/Resume-Matcher
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考