从零生成主简历:Resume-Matcher 的 AI Resume Wizard 全流程设计解析
【免费下载链接】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
导读
Resume-Matcher 是一款在本地运行、支持 100+ LLM 的开源简历构建工具。对于尚未持有 PDF/DOCX 简历的用户,项目提供了一条全新的入口:AI Resume Wizard(AI 简历向导)——通过"一次一个问题"的自适应 AI 对话,从零构建一份结构化的通用主简历(master resume),并最终沉淀为与"上传+解析"路径完全一致的下游数据形态。本文基于 Resume Wizard 设计文档,结合仓库中已落地的后端路由、服务、Schema、Prompt 模板与前端页面源码,完整讲解该功能的架构设计、数据契约、交互流程、容错机制与测试策略,读者读完可以掌握如何在项目内实现并验证一个"AI 引导 + 应用管控"的问答式简历生成管线。
一、设计目标:为"没有简历的人"补上主简历创建路径
Resume-Matcher 的核心工作流围绕"主简历(master resume)+ 职位描述(JD)"的匹配与定制展开。此前,dashboard 将"缺少主简历"作为初始化入口:用户通过POST /api/v1/resumes/upload上传 PDF/DOCX,系统创建一条普通简历记录,并在不存在健康主简历时将其标记为 master,同时把master_resume_id写入本地存储,从而放行用户进入后续功能。
Wizard 设计文档的核心目标正是为"手上没有现成 PDF/DOCX"的用户提供第二条路径:通过一次一步的 AI 问答,从零构建一份真实(truthful)的结构化主简历,其下游产物与"上传并解析出的主简历"完全等价:
| 字段 | 上传解析路径 | Wizard 最终产物 |
|---|---|---|
is_master | true(当无健康主简历时) | true(当无健康主简历时) |
processing_status | "ready" | "ready" |
content_type | "json" | "json" |
processed_data | 兼容ResumeData | 兼容ResumeData(经校验) |
也就是说,Wizard 输出的不是一份游离的"草稿文件",而是一条已持久化、可用于后续 tailor(职位定制)流程的标准主简历记录。
二、产品流程:上传 or AI 向导,二选一
当不存在主简历时,dashboard 的设置磁贴(setup tile)会弹出一个Swiss 风格二选一对话框,对应前端组件 master-resume-choice-dialog.tsx:
- 上传已有简历:继续复用
ResumeUploadDialog,走原有上传解析快路径; - 用 AI Wizard 从零创建:跳转到
/resume-wizard路由。
// apps/frontend/components/dashboard/master-resume-choice-dialog.tsx <DialogContent className="max-w-2xl bg-background border-2 border-black shadow-[4px_4px_0px_0px_#000000] ..."> <DialogHeader>...</DialogHeader> <div className="grid gap-4 bg-background p-6 md:grid-cols-2"> <section>{/* Upload 选项:Upload 图标 + ResumeUploadDialog */}</section> <section>{/* Wizard 选项:Bot 图标 + 路由到 /resume-wizard */}</section> </div> </DialogContent>Wizard 页面位于 apps/frontend/app/(default)/resume-wizard/page.tsx/resume-wizard/page.tsx),是一个客户端组件('use client'),实际渲染逻辑在 resume-wizard-page.tsx 中。
Wizard 的引导式开场(Intro)
设计文档规划了一个简短的 AI 引导开场,源码中通过_INTRO_QUESTION常量(resume_wizard.py 服务)与前端INTRO_QUESTION(lib/api/resume-wizard.ts)保持一致:
"Hi — I'll help you build your master resume. What's your name, and what kind of role are you going for?"
- 询问用户身份与目标角色方向;
- 即使回答是口语化的(如 "Hi, I'm James."),也能提取出姓名;
- 用姓名个性化下一条问题("So James, where would you like to begin?");
- 展示章节选择:Work Experience、Internships、Education、Projects、Skills、Review。
姓名提取在服务端由extract_intro_name()实现(services/resume_wizard.py),它使用三条正则依次匹配:
_INTRO_NAME_PATTERNS = ( re.compile(r"\bI(?:'| a)m\s+([A-Z][A-Za-z]+(?:\s+[A-Z][A-Za-z]+)?)"), re.compile(r"\b[Mm]y name is\s+([A-Z][A-Za-z]+(?:\s+[A-Z][A-Za-z]+)?)"), re.compile(r"\b[Nn]ame(?:'s| is)?\s+([A-Z][A-Za-z]+(?:\s+[A-Z][A-Za-z]+)?)"), )注意一个细节:关键字("my name"/"name")允许大小写,但捕获的姓名必须以大写字母开头,因此用[Mm]/[Nn]显式限定而非re.IGNORECASE——否则会误把 "domain name facebook is" 这类句子中的小写词捕获成姓名。该提取作为"intro 回答后姓名仍为空"时的确定性兜底,防止 LLM 未能正确解析姓名(见run_ai_turn中的 fallback 逻辑,services/resume_wizard.py)。
章节行为与基线输出
Wizard 的章节与简历 Schema 的映射关系在设计中明确给出,服务端_VALID_SECTIONS与_SECTION_PROMPTS(services/resume_wizard.py)落地了这一映射:
| 用户可见章节 | ResumeData 字段 | 章节提示词(节选) |
|---|---|---|
| Work Experience | workExperience | title、company、dates、what you did、measurable impact |
| Internships | workExperience(合并层映射) | title、company、dates、what you worked on、what changed |
| Education | education | school、degree、dates、honors、standout coursework |
| Projects | personalProjects | what you built、why it mattered、tech、results |
| Skills | additional.technicalSkills(+ languages / certificationsTraining / awards) | tools、technologies、skills |
| Contact / Summary / Review | personalInfo/summary/ 审查 | 联系方式、职业描述、缺口检查 |
基线输出(Prompt 模板中同样强制,见 prompts/resume_wizard.py):
- 每段工作/实习经历 3 条 bullet;
- 每个项目 2 条 bullet;
- 技能从用户回答中持续推断,并在 finalize 前实时显示在技能章节。
用户可以在 finalize 之前跳过任意章节并随时返回;Review 步骤会识别缺失但有用的信息——源码中build_review_warnings()(services/resume_wizard.py)产生确定性提示:
def build_review_warnings(data: ResumeData) -> list[str]: if not info.name.strip(): warnings.append("Add your name — it's required to create your resume.") if not any(value.strip() for value in contact): warnings.append("Add at least one contact method (email, phone, or a link).") if not data.workExperience and not data.personalProjects: warnings.append("Add at least one experience, internship, or project.") if not data.education: warnings.append("Education is empty — skip only if that's intentional.") if not data.additional.technicalSkills: warnings.append("Skills are empty — add tools or technologies you've used.")其中"姓名"是 finalize 的唯一硬性要求(请求缺少姓名会直接 422),因此在 review 阶段就提前提示,避免用户在最终提交时遭遇笼统报错。
三、AI Harness:结构化回合制状态机
设计文档明确了 AI Harness 的核心原则:后端暴露resume-wizardAPI 命名空间,接收"当前 wizard 状态 + 最新用户动作/回答",永远返回结构化响应,而不是自由文本。应用自身掌控 Schema 与允许的章节动作——AI 可以写 bullet、提取事实、推断技能、改写措辞、追问澄清问题,但每一轮返回的简历草稿在交给客户端前都必须通过ResumeData校验。
回合状态模型
状态 Schema 定义在 schemas/resume_wizard.py,由以下核心模型组成:
ResumeWizardSection = Literal["intro", "contact", "summary", "workExperience", "internships", "education", "personalProjects", "skills", "review"] ResumeWizardStep = Literal["intro", "question", "review", "complete"] ResumeWizardAction = Literal["start", "answer", "skip", "back", "review"] class ResumeWizardState(BaseModel): step: ResumeWizardStep = "intro" resume_data: ResumeData = Field(default_factory=ResumeData) current_question: ResumeWizardQuestion = Field(default_factory=ResumeWizardQuestion) history: list[ResumeWizardHistoryEntry] = Field(default_factory=list) asked_count: int = 0 inferred_skills: list[str] = Field(default_factory=list) is_complete: bool = False progress: ResumeWizardProgress = Field(default_factory=ResumeWizardProgress) warnings: list[str] = Field(default_factory=list)其中history记录每个已答问题的回答前草稿快照(resume_data_before),这是back动作能够确定性还原上一问与草稿的基础。asked_count服务端自增,配合进度计算compute_progress()(services/resume_wizard.py)让进度条永远由服务端计算,绝不信任模型输出。
回合动作路由
POST /api/v1/resume-wizard/turn的动作分发在 routers/resume_wizard.py:
action = request.action if action == "start": return ResumeWizardTurnResponse(state=build_initial_wizard_state()) if action == "back": return ResumeWizardTurnResponse(state=apply_back(request.state)) if action == "review": return ResumeWizardTurnResponse(state=apply_review(request.state)) # 成本护栏:达到问题数上限后不再调用 LLM,直接引导进入 review if request.state.asked_count >= RESUME_WIZARD_MAX_QUESTIONS: return ResumeWizardTurnResponse(state=apply_review(request.state)) if action == "skip": state = await run_ai_turn(request.state, "", skip=True) ... state = await run_ai_turn(request.state, answer_text, skip=False)值得注意的工程细节:
RESUME_WIZARD_MAX_QUESTIONS = 15(services/resume_wizard.py)是一道成本护栏:一旦asked_count达到 15,后续 answer/skip 回合不再产生 LLM 调用,直接路由到 review,避免无上限消耗 token;back与review是纯确定性操作(无 LLM 调用),back通过弹栈 history 快照还原,review仅计算温柔提示;skip也调用 LLM,但 Prompt 中注入的是固定指令——"(The user skipped this question. Do NOT modify resume_data. Ask the next most useful question for a different section.)",让模型转向另一个章节提问。
AI 回合核心逻辑
run_ai_turn()(services/resume_wizard.py)是整条管线的枢纽,按序完成:
- 序列化当前草稿→
json.dumps(state.resume_data.model_dump(mode="json"), ensure_ascii=False); - 净化用户回答:非 skip 时对回答先做
_sanitize_user_input(Prompt 注入模式剥离)再_scrub_secrets(脱敏sk-…/AIza…/Bearer …等凭证样式 token),避免敏感信息进入 LLM; - 组装 Prompt并调用
complete_json(prompt, max_tokens=8192, schema_type="resume"); - 解析与校验:结果必须是 dict;
resume_data经过normalize_wizard_resume_data→ResumeData.model_validate严格校验; - 分区合并:
_merge_section()只把 LLM 输出合并进当前活动章节,绝不覆盖其他章节。
关键设计:分区合并与条目去重
_merge_section()(services/resume_wizard.py)按章节分发合并策略:
intro/contact:仅当新值非空时覆盖personalInfo各字段;summary:非空时替换摘要;workExperience/internships/education/personalProjects:调用_merge_entries()做签名去重合并;skills:merge_unique_skills()保留首次出现的拼写与顺序,大小写不敏感去重(casefold),同时合并languages、certificationsTraining、awards;- 未知/
review章节:绝不改动resume_data。
_merge_entries()的注释解释了动机:模型有时只回显用户刚描述的一条记录而非完整列表,若直接整体替换就会抹掉早前录入的条目。因此采用"内容签名"(而非id,因为 wizard 条目的 id 默认为 0)作为联合键:模型省略的旧条目保留、同签名条目原位替换、真正的新条目追加。
def _merge_entriesT -> list[T]: # 签名键:如 title+company+years 的 casefold 元组 # 省略的保留 / 同签名替换 / 新条目追加合并后还会执行_assign_entry_ids()(services/resume_wizard.py):LLM 省略id字段导致条目 id 全为 0,而下游的 live preview React key 与 builder 的Math.max(...ids)+1逻辑都依赖唯一 id,因此按位置确定性重编 1-based id。
下一问的选择
_next_question()(services/resume_wizard.py)优先采用模型返回的next_question(其section必须钳制到合法枚举,valid_section()兜底为review);若模型未提供,则回退到_next_gap_section()——按"工作经历 → 教育 → 项目 → 技能 → review"的顺序自动定位第一个明显为空的章节。
def _next_gap_section(data: ResumeData) -> str: if not data.workExperience: return "workExperience" if not data.education: return "education" if not data.personalProjects: return "personalProjects" if not data.additional.technicalSkills: return "skills" return "review"前端 resume-wizard-page.tsx 的firstGapSection()复刻了同一启发式,用于 review 后"继续补充"(Keep Adding)时定位下一个内容缺口——注释特别指出review章节在后端合并中是 no-op,若目标设为其会静默丢弃回答。
四、Prompting:结构化简历写作助手的约束体系
设计文档要求新增 resume-wizard Prompt 模板,让模型扮演"结构化简历写作助手",核心规则包括:
- 构建通用主简历,而非针对特定职位的定制简历;
- 不得虚构公司、日期、指标、工具、学位、奖项或技能;
- 回答含糊或缺关键事实时必须追问澄清;
- 优先产出基于用户事实的精炼行动导向 bullet;
- 使用配置的内容语言(
{output_language}); - 只输出请求的 JSON 对象;
- 保留既有草稿数据,除非用户明确修改。
这些规则完整落在 prompts/resume_wizard.py 的RESUME_WIZARD_TURN_PROMPT中。模板额外强调了语言边界:人类可读文本(下一问、标题、bullet、摘要)用{output_language}输出,但结构化值保持原样——next_question.section必须是精确的英文枚举值,日期保持给定格式,不翻译章节键与日期。
模板中的输出 JSON 骨架(每轮必须返回):
{ "resume_data": { "personalInfo": {"name": "", "title": "", "email": "", "phone": "", "location": "", "website": "", "linkedin": "", "github": ""}, "summary": "", "workExperience": [], "education": [], "personalProjects": [], "additional": {"technicalSkills": [], "languages": [], "certificationsTraining": [], "awards": []}, "sectionMeta": [], "customSections": {} }, "next_question": {"text": "Your next concise question", "section": "workExperience"}, "inferred_skills": ["Skill"], "is_complete": false }其中is_complete仅是建议信号(提示前端亮起 "Review & finish" 提示),step始终停留在question,绝不自动 finalize——是否进入 review 由客户端决定(见 services/resume_wizard.py 的注释)。
章节提示词同样落地:work experience 要求 title、company、dates、responsibilities、tools、scale、impact;projects 要求 what was built、why it mattered、technologies used、user/usage context、links(见_SECTION_PROMPTS)。
五、Backend API:turn 与 finalize 两个端点
设计文档规划的端点与源码一一对应(挂载于 apps/backend/app/main.py 的app.include_router(resume_wizard_router, prefix="/api/v1")):
POST /api/v1/resume-wizard/turn
- 请求体:
ResumeWizardTurnRequest——state(完整往返的状态)+action+ 可选的answer; answer的text约束为min_length=1, max_length=6000,且必须非空白(字段校验器拒绝纯空白);action == "answer"时必须携带answer,否则模型校验器抛错(422);- 响应体:
ResumeWizardTurnResponse——新的state(含更新后的resume_data、进度、当前章节、推断技能、下一问、警告、完成状态)。
POST /api/v1/resume-wizard/finalize
finalize 端点 执行"校验终稿 → 创建主简历":
- 幂等保护:先查当前主简历,若已存在
processing_status == "ready"的主简历,直接 409 拒绝("A master resume already exists. Delete it before creating a new one."); - Schema 校验:
normalize_resume_data(request.state.resume_data.model_dump(mode="json"))后再ResumeData.model_validate,保证落库的是严格合法的ResumeData; - 原子创建:
db.create_resume_atomic_master(...)——content为规范 JSON(ensure_ascii=False, sort_keys=True),content_type="json",filename=f"AI Resume Wizard - {name}.json",processing_status="ready",并同步设置title。注释指出:title 放在原子创建内,避免"已提交但无标题的主简历"在重试时触发 409 死锁; - 最终校验:若创建结果
is_master=False(竞态下主简历已被占用),删除刚创建的非 master 记录并返回 409,做到非破坏性拒绝。
最终响应:{message, request_id, resume_id, processing_status: "ready", is_master}。
若已存在主简历,finalize 拒绝创建——显式的替换(replacement)流程不在本次实现范围内(Out of Scope)。
错误处理:完整上下文留服务端,浏览器只见通用消息
后端所有异常统一处理(routers/resume_wizard.py):ValueError→ 422 "Could not update the resume draft.";其余异常 → 500 "Resume wizard failed. Please try again.",同时logger.error记录详细上下文。模型异常细节、provider 密钥、原始堆栈永不进入浏览器。
JSON 修复与重试
设计文档要求:模型返回非法 JSON 时,后端使用既有complete_json行为 + 仅基于 prompt 的 JSON 修复指令重试;仍失败则服务端记录详细错误并返回通用客户端错误。这正对应run_ai_turn中complete_json(prompt, max_tokens=8192, schema_type="resume")的调用——complete_json是项目 LLM 层的既有能力(app/llm.py)。
六、Frontend UI:聚焦工具的 Swiss 设计
设计文档明确了/resume-wizard页面的视觉规范,前端 resume-wizard-page.tsx 与 question-card.tsx 逐条落地:
| 设计规范 | 源码实现 |
|---|---|
Canvas 背景#F0F0E8 | 主题 tokenbg-background |
| 方形圆角、黑色边框 | border-2 border-black rounded-none |
| 硬偏移阴影 | shadow-[4px_4px_0px_0px_#000000](shadow-sw-lg) |
| 衬线标题、无衬线正文、等宽元数据 | font-serif/font-sans/font-mono |
| 无装饰渐变、无圆角卡片、无营销 hero | 网格布局lg:grid-cols-[minmax(0,1fr)_360px] |
首屏即向导工具本身,页面结构为两栏:
- 左栏:
QuestionCard——顶部服务端计算的进度条(role="progressbar",每格为黑/白方块)、章节标签(等宽蓝色小字)、衬线大字号问题、答案Textarea、操作按钮组; - 右栏:
LivePreview——实时结构化预览,展示已收集的姓名/头衔、经历(title · company、年份、bullet)、项目、教育、技能标签。
交互操作矩阵
QuestionCard根据step渲染不同的操作:
- question 步骤:Continue(Enter 提交、Shift+Enter 换行,见
handleKeyDown)、Skip、Review、Back(有 history 时显示); - review 步骤:Create(
canFinalize为 false 时禁用,即无姓名时不可创建)、Keep Adding(回到 question 并定位到下一个缺口章节); isComplete为 true 时在 question 步骤显示绿色 "ready" 提示(resumeWizard.readyHint)。
键盘交互遵循仓库惯例:Enter 永不冒泡到父级表单/对话框(event.stopPropagation()),Shift+Enter 插入换行。
Live Preview 的技能推断展示
live-preview.tsx 将technicalSkills与inferred_skills合并展示,dedupeSkills()使用toLowerCase()(与后端casefold对齐,注释专门指出toLocaleLowerCase会在土耳其语等 locale 上因点/无点 I 产生分歧),本轮新推断的技能用绿色边框 + ✓ 标记,用户可在 finalize 前实时核对 AI 推断是否越界。
本地草稿持久化与刷新恢复
resume-wizard-page.tsx将草稿持久化到localStorage的resume_wizard_draft键。读取时做深度容错归一化(readSavedDraft/normalizeDraftResumeData):step/section 钳制到合法枚举、personalInfo每字段强制字符串(防止数字型 name 让.trim()抛错陷入刷新死循环)、列表字段强制数组并重编 1-based id。写入是 best-effort——配额/序列化失败静默忽略,绝不让草稿保存问题崩溃向导。
Finalize 后的落点
handleFinalize()(resume-wizard-page.tsx):
localStorage.setItem(MASTER_RESUME_KEY, response.resume_id); // master_resume_id localStorage.removeItem(DRAFT_STORAGE_KEY); incrementResumes(); setHasMasterResume(true); setState((current) => ({ ...current, step: 'complete' })); router.push(`/builder?id=${response.resume_id}`);即:存入master_resume_id→ 更新状态缓存 → 路由到/builder?id=<resume_id>供用户最终人工审查与编辑。这是设计文档强调的刻意安排——"AI 向导产出强初稿,但用户在使用简历进行 tailor 之前仍应能检查与编辑"。
七、Frontend 数据契约与 API 助手
设计文档要求前端在 apps/frontend/lib/api/resume-wizard.ts 增加 API 助手,源码完全对应:
postResumeWizardTurn(payload)→POST /resume-wizard/turn;finalizeResumeWizard(state)→POST /resume-wizard/finalize;createInitialResumeWizardState()生成本地初始状态(与后端build_initial_wizard_state字段一致:step: 'intro'、空resume_data、intro 问题、progress: {current: 0, total: 8})。
前端类型ResumeWizardState/ResumeWizardSection/ResumeWizardAction与后端 Pydantic Schema 字段一一对应,构成可往返的契约。所有 i18n 文案走useTranslations()的resumeWizard.*键,并由 check_locale_parity.py 检查各 locale 键一致。
八、错误处理与恢复动作(前端侧)
设计文档要求客户端错误以 Swiss 风格警示呈现并附带恢复动作,前端落地为红框警示区(border-2 border-red-600 bg-red-100,role="alert")+ 错误翻译键(resumeWizard.errors.turnFailed/finalizeFailed),用户可执行的恢复路径包括:
- 重试 AI 回合(再次点击 Continue/Skip/Review);
- 继续编辑当前回答(
setErrorKey(null)后重新输入); - 返回 dashboard(页面右上角 Back to Dashboard 按钮);
- 转而走上传路径(dashboard 的 Upload 选项)。
九、测试策略与质量门禁
设计文档列出的测试点全部有落地文件:
后端(apps/backend/tests/integration/test_resume_wizard_api.py):
- Wizard Schema 校验与经
ResumeData的强制类型转换; - intro 回答提取姓名并产出章节选择;
- 章节更新产生基线 bullet 数量;
- 技能推断仅使用用户提供的事实;
- finalize 在无主简历时创建 ready 主简历;
- finalize 在主简历已存在时拒绝。
前端(apps/frontend/tests/):
- resume-wizard-api.test.ts:API 助手请求/响应形状;
- resume-wizard-page.test.tsx:初始渲染、章节切换、finalize 存储
master_resume_id并路由到 builder; - resume-wizard-question-card.test.tsx:question/review 步骤操作矩阵;
- resume-wizard-live-preview.test.tsx:技能去重与推断标记。
质量门禁:前端改动须在apps/frontend下运行npm run lint与npm run format;后端改动运行针对新 router/service 的定向 pytest。
十、Out of Scope:明确的边界
设计文档与实现保持一致,以下内容不在 Wizard 范围内:
- 不做职位定制:Wizard 构建的是通用主简历,JD 定制仍走既有 tailor 管线;
- 不替换既有上传解析器、tailor 流程、enrichment 流程或 builder;
- 不改动CI、Docker 或 GitHub workflow 文件;
- 不实现主简历的显式替换流程(finalize 遇到已存在主简历时以 409 非破坏性拒绝)。
结语:一份"AI 引导、应用管控"的可信简历管线
Resume-Matcher 的 AI Resume Wizard 展示了混合式问答生成的成熟工程范式:AI 负责对话与起草,应用负责 Schema、状态机与校验。从 intro 的姓名提取、分区合并防覆盖、内容签名去重、服务端进度计算与 15 问成本护栏,到 review 的确定性缺口提示与 finalize 的原子建库,再到前端 Swiss 风格工具化界面与 localStorage 刷新恢复——整条管线强调"诚实生成、可追溯、可回退、可最终人工编辑"。它没有把简历生成交给黑盒,而是把 LLM 的能力约束在结构化契约之内,这也正是设计文档中"truthful structured master resume"从原则走向实现的完整路径。
【免费下载链接】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),仅供参考