freehire 定制简历确认流设计:冲突时升级 provenance、按钮式确认与 follow-ups 下线
freehire 定制简历确认流设计:冲突时升级 provenance、按钮式确认与 follow-ups 下线
本文围绕 freehire 仓库中的设计文档 2026-08-03-tailor-confirmation-flow-design.md 展开,拆解 CV 定制(tailor)助手曾经陷入"确认死循环"的根因,以及三个已达成共识的修复:经验库原子(experience atom)在唯一键冲突时对 provenance 的条件升级、把"请候选人确认"从自由文本改为 request_confirmation 按钮交互、以及彻底删除 follow-ups 追问芯片功能。读完后你可以完整理解这条链路的数据库语义、工具注册机制与前后端协作方式,并在当前仓库中逐一核对每处实现的落点。
1. 问题起源:一次真实的定制会话死循环
设计文档从一个真实生产会话讲起:assistant_sessions 表中 id 为 bf58de6a-a83b-4056-aa7e-16e3a908a70a 的定制会话,模型反复要求候选人"confirm in your own words(用自己的原话确认)"多达 16 次,始终无法恢复——转录里最后一条消息仍是同一句拒绝。根因沿 internal/candidate/experience/store.go 和 internal/platform/db/queries/experience.sql 追溯如下:
- 原实现中
InsertAtomIfNew对(user_id, claim_key)唯一键使用ON CONFLICT DO NOTHING; - 一条 claim 一旦先以
agent_inferredprovenance(模型转述、没有候选人原话支撑)入库,之后所有针对同一条 claim 的experience_add调用——哪怕said字段这次是真正的候选人逐字引用——都会命中冲突、插入失败; Store.AddAtom把pgx.ErrNoRows直接映射为ErrAlreadyBanked,新调用中正确计算出的 provenance 被静默丢弃;- 系统中没有任何其他工具能在原子创建后重新盖章 provenance(
experience_update的 schema 里没有said/provenance字段)。
结果是:一条一次没通过逐字检查的 claim,会永远卡在 can_write_cv: false——不仅卡死当次会话,此后所有会话都一样。当前仓库中 store.go 的 insertAtom 仍然保留这条映射(pgx.ErrNoRows → ErrAlreadyBanked),但它的前提已经变了:冲突子句只在"确实无事可做"时才返回空行。
文档同时指出两个独立问题:候选人在纯聊天文本里被反复要求手打/粘贴确认,摩擦过高;转录里几乎每轮对话后都出现的 /followups 调用则是用户希望直接删除的功能。文档覆盖三个相互独立的修复,并明确把第四个更大的想法(借鉴 JD-reframing 分类学做更激进的关键词覆盖改写)排除在外。
2. 修复 A:冲突时条件升级 provenance
2.1 目标 SQL 的语义变化
设计文档给出的改法是把插入的冲突子句从 DO NOTHING 换成条件升级。文档中的目标语句:
INSERT INTO experience_atoms (user_id, employment_id, claim, claim_key, context, metrics, skills, provenance, source_ref)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (user_id, claim_key) DO UPDATE
SET provenance = EXCLUDED.provenance,
source_ref = EXCLUDED.source_ref,
context = EXCLUDED.context
WHERE experience_atoms.provenance = 'agent_inferred'
AND EXCLUDED.provenance != 'agent_inferred'
RETURNING id, user_id, employment_id, claim, claim_key, context, metrics, skills, provenance, source_ref, created_at, updated_at;
WHERE 子句同时挡住了两种"不该升级"的情况:已确认的原子绝不会被降级;两次 agent_inferred 的尝试则维持原样。当前仓库中该查询已按此实现(见 experience.sql 第 123–143 行),并多写了一个 updated_at = now(),同时注释里写清了不变式:"a claim already banked with a stronger provenance than agent_inferred is never touched"。
三种行为的对应关系:
| 场景 | WHERE 结果 | 返回 | AddAtom 行为 |
|---|---|---|---|
| 行不存在 | — | 新行 | 正常入库 |
行已是 stated_in_chat/manual 等,或新调用仍是 agent_inferred |
false | 无行(pgx.ErrNoRows) |
返回 ErrAlreadyBanked,与旧行为一致 |
行是 agent_inferred,新调用算出 stated_in_chat/manual/cv_import |
true | 升级后的行 | 返回升级后的原子(can_write_cv: true) |
两个关键设计点值得强调:
- 原子 id 不变。升级是原地更新,因此一个已经持有该
evidence_id的cv_edit调用在重试时可以直接成功,无需任何额外改动。 claim/skills/metrics/employment_id刻意不覆盖。升级只是为既有 claim 文本补上 provenance,不允许第二次调用悄悄改写 claim 本身说了什么。
工具签名没有变化:experience_add 原本就有的"带 said 重试"行为(转录里已经能看到模型在这样做)从此真正生效,而不是无限循环。
2.2 与防"洗白"(anti-laundering)体系的关系
这套 provenance 语义不是孤立的 SQL 改动,而是 internal/candidate/experience/author.go 中整套作者身份规则的落库侧收口。该文件定义了三类作者及标签推导:
func ProvenanceFor(author Author, existing Provenance) Provenance {
switch author {
case AuthorCandidate:
return ProvenanceManual
case AuthorQuoted:
return ProvenanceStatedInChat
case AuthorRewrite:
if existing.Valid() {
return existing
}
return ProvenanceAgentInferred
default:
return ProvenanceAgentInferred
}
}
(见 author.go 第 51–68 行。)其中 AuthorRewrite 的存在正是为了防止"把一条 agent_inferred 推理编辑一遍再读回来当 manual 引用进 CV"的洗白路径——重写措辞不等于重新断言。修复 A 在数据库层补齐了最后一块拼图:只有 AuthorQuoted(逐字引用、经转录核验)这一条路能提升一条 claim 的地位,而提升动作现在可以直接发生在插入冲突点上。
2.3 文档要求的测试
设计文档要求两个 store 级测试:其一,先以无 said 的调用让 claim 落为 agent_inferred,再用 said 与转录消息逐字匹配的第二次 AddAtom,断言第二次调用返回 can_write_cv: true 且原子 id 不变;其二,确认一条已是 stated_in_chat 的原子不会被后续 agent_inferred 调用降级。当前仓库的集成测试 experience_integration_test.go 即为该查询的验证入口。
3. 修复 B:用按钮确认,而不是自由文本
3.1 request_confirmation 工具
修复 B 新增一个仅限 tailor 预设的工具 request_confirmation,与 assistantCVTools 一起注册。其定义(assistant_cv_tools.go 第 112–156 行):
Name: "request_confirmation"
Args: { claim: string, question: string } // 两者均 required,additionalProperties: false
Run: 无副作用 —— 返回 {"status": "awaiting_candidate_response"}
注册点在 assistant_cv_tools.go 第 89–101 行 的 assistantCVTools 列表中,该列表只在 preset == assistant.PresetTailor 且会话持有 CV 与职位 id 时才被组装(见 assistant_tools.go 第 391 行 的门控)。预设常量 PresetTailor = "tailor" 定义在 internal/ai/assistant/store.go。
实现比设计文档多做了一层防御:若模型传来的 claim 为空串(schema 的 required 并不强制,且 healToolArguments 可能把损坏的参数串修复成 {}),工具直接报错 claim is required: pass the exact claim text the candidate must confirm, verbatim。注释解释了为什么这必须拦住:空 claim 意味着前端什么都不渲染,模型却会收到成功回执、把沉默误当成确认,下一次 experience_add 因找不到逐字引用而被盖上 agent_inferred。
3.2 提示词侧的改动
tailorPrompt(internal/ai/assistant/prompt.go)的第 2 步从"ASK them(…)"的自由文本改成:
evidenceempty → the bank holds nothing on that point. Callrequest_confirmationwith the exact claim text and a short question — do NOT write the ask as free-text prose, the candidate sees it as a claim with Yes/No buttons and Yes replays your claim text verbatim as their next message. If they confirm, record it withexperience_add— putting their own words insaid— and then write the bullet citing the id it returns. If they decline, leave it out.
模型仍然自己撰写 claim 文本(未变),变的是把问题"递"给候选人的方式。
3.3 前端渲染与"不绕过诚实门"的关键设计
web/src/lib/assistant/ToolGroupList.svelte 对 request_confirmation 增加了一个按工具名条件判断的分支:渲染 claim 文本加一对按钮。点击行为(配合 tool-formatters.ts 第 120–142 行):
- Yes(确认):把 claim 文本逐字、未修改地作为普通聊天消息发出。该消息以真正的
RoleUser身份落进转录,于是下一次experience_add的provenanceFor逐字检查(对转录消息做真实的子串匹配,本设计没有改动它)真正通过。 - No(拒绝):发送一条固定的短消息。设计文档举例为俄文 "Нет, это не так — не добавляй.",当前实现中该常量是英文的
CONFIRMATION_DECLINE_TEXT = "No, that's not right — don't add it."(tool-formatters.ts 第 126 行),按钮文案则通过 i18n 本地化。模型按既有提示词处理拒绝("If they say no, leave it out")。
组件测试要求:ToolGroupList.svelte 的分支对 request_confirmation 渲染按钮、对其他所有工具名回退到通用渲染,并分别验证两个按钮点击后以正确载荷触发 submitText。后端侧无需新增测试——既有的 TestPromptOnlyNamesToolsThePresetHas 类检查已保证提示词只点名预设实际注册的工具。
为什么 Yes 的"回放"不算绕过诚实门?核验逻辑在 internal/ai/assistant/message.go 的 UserSaid:引用必须至少 3 个词(minQuoteWords),逐字(大小写不敏感、空白折叠)出现在某条用户消息中,且所在句子在引用起点之前不含 negationCues(如 "not"、"never")这类否定词——否则 "I have NOT worked with Kubernetes" 会字面包含 "worked with Kubernetes" 而被误判为断言。按钮流做的只是用 UI 快捷方式生产出一条能满足该检查的真实用户消息,检查本身原封未动:没有新的 SSE 事件类型(沿用既有 tool_use 事件),没有新的确认端点,也没有新的 provenance 值。
3.4 实现记录
该设计的落地过程在 openspec 变更目录中留有完整档案:proposal.md、design.md、tasks.md 及规格 tailor-claim-confirmation/spec.md,可作为从设计到验收的追溯路径。
4. 修复 C:彻底删除 Follow-ups
设计文档对 follow-ups 的处置是"删除,而不是加开关关"。清单如下:
internal/ai/assistant/followups.go与followups_test.gointernal/api/handler/assistant_followups.go及其单元测试、集成测试internal/api/handler/assistant.go中的路由注册(文档标注第 148 行)internal/api/handler/assistant_integration_test.go中的路由列表断言internal/api/handler/user_llm.go中的计费标签tagFollowUps(删除前需确认无其他引用)- 前端:
web/src/lib/assistant/followups.ts与测试、web/src/lib/assistant/api.ts中的suggestFollowUps调用,以及AssistantChat.svelte中所有相关引用(followUps状态、askForFollowUps、重置点、芯片渲染块)
文档特别点出一个易踩的坑:internal/api/handler/followup.go / internal/application/followup 是同名但无关的功能(申请跟进邮件草稿),绝不能误删。当前仓库中上述 follow-ups 源码文件确实已不存在(全库搜索 followUps/suggestFollowUps 仅命中文档与 openspec 存档),说明该项修复已落地;测试预期也随之收敛——功能被删后套件里自然少了对应测试,无需替代覆盖。
5. 明确不做的部分
文档在结尾划清了范围:让 tailoring 改写更贴近 JD 实际用语(reframing 分类学、系统性的逐关键词覆盖、可能的评审/批评步骤)是另一个更大的设计议题,等这三个修复上线后再单独开一轮设计。这条边界声明的价值在于防止后续读者把"更激进的 CV 改写"误认为本设计的隐含目标。
6. 核对清单:从设计到仓库的落点索引
| 设计要点 | 仓库落点 |
|---|---|
| 冲突条件升级 SQL | internal/platform/db/queries/experience.sql#L123-L143 |
ErrNoRows → ErrAlreadyBanked 映射 |
internal/candidate/experience/store.go#L245-L256 |
| provenance 作者身份推导 | internal/candidate/experience/author.go#L51-L68 |
逐字引用核验(UserSaid、否定词、词数下限) |
internal/ai/assistant/message.go#L161-L239 |
request_confirmation 工具实现 |
internal/api/handler/assistant_cv_tools.go#L112-L156 |
| tailor 预设工具门控 | internal/api/handler/assistant_tools.go#L391 |
tailorPrompt 第 2 步指令 |
internal/ai/assistant/prompt.go#L188 |
| 按钮渲染分支 | web/src/lib/assistant/ToolGroupList.svelte#L57-L86 |
| 参数解析与拒绝文案常量 | web/src/lib/assistant/tool-formatters.ts#L120-L142 |
| 变更档案 | openspec/changes/archive/2026-08-04-tailor-confirmation-flow/ |
三个修复的共同点是"最小侵入":A 只改一条 SQL 的冲突子句、B 只加一个无副作用工具加一个前端分支、C 是纯删除。整个方案的诚实约束始终由 UserSaid 的逐字核验兜底——按钮、升级子句都只是让候选人的确认更省力、让被误伤的一次性 agent_inferred 标签有机会被纠正,而不是放宽任何一条"模型不许替候选人说话"的既有规则。