freehire 定制简历确认流设计:冲突时升级 provenance、按钮式确认与 follow-ups 下线

原创2026-10-22 16:07:031,701 阅读
文章标签:后端前端搜索引擎网页爬虫数据工程AI 应用可观测性

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 追溯如下:

  1. 原实现中 InsertAtomIfNew 对 (user_id, claim_key) 唯一键使用 ON CONFLICT DO NOTHING;
  2. 一条 claim 一旦先以 agent_inferred provenance(模型转述、没有候选人原话支撑)入库,之后所有针对同一条 claim 的 experience_add 调用——哪怕 said 字段这次是真正的候选人逐字引用——都会命中冲突、插入失败;
  3. Store.AddAtom 把 pgx.ErrNoRows 直接映射为 ErrAlreadyBanked,新调用中正确计算出的 provenance 被静默丢弃;
  4. 系统中没有任何其他工具能在原子创建后重新盖章 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(…)"的自由文本改成:

evidence empty → the bank holds nothing on that point. Call request_confirmation with 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 with experience_add — putting their own words in said — 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.go
  • internal/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 标签有机会被纠正,而不是放宽任何一条"模型不许替候选人说话"的既有规则。

登录后查看全文
freehire