Files
Jyotisha/TASK-rectification-adopt-flow-fix-20260903.md
T

18 KiB
Raw Blame History

任务书 · 采用流程修复单:采集期出口兜底、「改选」可点、开场题不占 education、核对题年份持久化、「用这个时间看盘」真正接到看盘(2026-09-0309-03 增补 §4b

基线:origin/staging 35e5781eTASK-rectification-adopt-flow-20260902.md 的实现,BUG-497~500)。本任务书是对该实现 review 后的修复单,不改 35e5781e 已定的形态(公开 can_adopt 按 session_outcome 收紧、accept 409、核对题沿用持久化 questionId、卡归出卡消息、状态条承接),只修 1 个 P1 和 4 个 P2(§4b 为 09-03 用户实测后增补)。

0. Review 结论摘要

35e5781e 门禁全绿(tsc / lint 0 错 / 1109 测试 fail=0),两个 P0 的修法与任务书一致。但收紧公开 can_adopt 之后,非终止轮出口检查还在看内部 canAdopt,会把一种此前靠"漏出的采用卡"兜住的状态变成真正的死角;另外三处是形态没做完或副作用。

09-03 增补来源:用户在真实环境走完一轮(采用 04:53,不可分区间约 29 分钟)后反馈"下面那个 rectification-consult-handoff「用这个时间看盘」按钮没用吧"。核对结论:按钮不是死的,但只做了"开一个空会话 + 往输入框塞一句服务端根本不读的话",而且没接住采用时间同步与"回到原来的问题"两条承诺——见 §4b。用户同一份记录里其余现象已被 35e5781e 覆盖、本单不重复处理,实现者不要再改:

用户看到的 归属 状态
题干出现两遍(正文一遍、选项卡上方一遍) 旧构建底部问题槽 + 消息内题干并存 35e5781e 已删底部槽,嵌入卡 legend 为 sr-only,题干只剩消息内一处;待部署
核对题卡片底部仍有「先这样,先看当前范围」 reverse_verify 卡应为「这题跳过」 35e5781e §3.4 已做;待部署
收口报告之后又问一道 2024 年 5 月感情核对题 采用后最多核两件前事,是 Skill 既定流程 不是缺陷,不改
「用这个时间看盘」是列表末尾裸按钮 rectification-consult-handoff 35e5781e 已移进状态条并删裸按钮;但 onClick 的行为没改,本单 §4b 修

1. P1 · 采集期"无题、无卡、无出口"

位置frontend/src/lib/rectification-agentic/v9/answer-choice.ts inspectNonTerminalTurnExit(行号线索 ~992–1013,按符号名定位)。

现象(静态推演)satisfied 的最后一项是 decision.canAdopt(内部能力位)。当 session_outcome = collect_evidence、内部 canAdopt = truebuildMethodFollowupPlannext_followup / deferred_followup 都为 null(题库耗尽)时:

  1. persistNextInterviewIfIdle 走到 if (!followup) return { persisted:false },不建题;
  2. ensureNonTerminalTurnExitdecision.canAdoptsatisfied = true不调用 persistExhaustionCollect 兜底
  3. 公开 can_adopt 现在为 false → 前端不出采用卡;showCollectStop 依赖活跃采集题 → 「先这样」也不出;
  4. 用户只剩空 composer,随便发一句话再跑一轮回到同一状态。

35e5781e 之前这个状态会漏出采用卡(就是 BUG-497 的现象),所以没人碰到过死角;现在门关了,出口必须补上。

修法

  1. inspectNonTerminalTurnExitsatisfieddecision.canAdopt 改成 publicCanAdopt(decision)(从 core/rectification-decision 导入)。语义:只有用户在界面上真的能采用时,"没有下一问"才算满足;否则走 persistExhaustionCollect(它建的题自带「先这样」,answerChoice 的 stop 路径会把 outcome 推到 provisional_range_user_stopped,公开 can_adopt 随之打开)。
  2. persistNextInterviewIfIdleshouldSkipFollowupPersist({ canAdopt: decision.canAdopt, ... }) 同步改成 publicCanAdopt(decision)。现有 nextAction 白名单已保证采集/区分/holdout 不会跳过持久化,这一改只是让"跳过持久化、改出 adopt 旁白"与公开门一致,不得出现"旁白说可以采用、界面却没卡"的分叉。
  3. 确认 persistExhaustionCollectcollect_evidence + 内部 canAdopt = true 下能返回一道题(exhaustionSpokenCollectFollowup 若也为 null,则至少给 occupation 兜底题——现有逻辑已有,验证即可)。

测试tests/rectification-adopt-flow-fix-20260903.test.ts 新建):

  • 构造 dossiersessionOutcome = collect_evidence、内部 canAdopt = true、无 active focus、题库耗尽(evidence 覆盖所有 collect 域或 declined 全部)。断言 ensureNonTerminalTurnExit 返回 persisted = true,写入的 focus intent = collect_method_evidenceprojectCurrentQuestion(focus).kind === "collect_spoken",并有 rectification_nonterminal_exit_repaired warn。用 fake accounting,不需要真实环境。
  • 对照:sessionOutcome = adopt_representativepublicCanAdopt = true)同样条件下 satisfied = true、不建题(保留既有语义)。

2. P2-a · 状态条「改选」是死的

位置rectification-agentic-chat.tsx 状态条 <span className="rectification-adopt-status__link">改选</span>~1501);offerSectionRef~439、~1438)赋了 ref 但没有任何 scrollIntoView 调用。

修法

  1. 改成 <button type="button" className="rectification-adopt-status__link">改选</button>onClickofferSectionRef.current?.scrollIntoView({ block: "center", behavior: "smooth" }),并给该消息一次 rectification-message-flashglobals.css 加一个 600ms 的背景闪烁 keyframe,复用现有 .rectification-message-entry 的色板)。
  2. showSelectionCards 为 false(出卡消息还没重建,或 readonly)时不渲染「改选」。
  3. globals.css .rectification-adopt-status__link 补 button reset(去边框/背景、光标 pointer、焦点环沿用 .rectification-candidate 的样式)。
  4. 测试:源码锁 rectification-adopt-status__link 出现在 <button 上、文件内存在 offerSectionRef.current?.scrollIntoView;不做 DOM 测试。

3. P2-b · 开场题把 education 记成"已问/已拒"

位置method-followup.ts OPENING_COLLECT_DOMAIN = "education"~1068)、collectQuestionDomain、开场 followup domain: OPENING_COLLECT_DOMAIN~1800);server-focus.ts persistCollectFocustargetDomain 兜底;declinedDomains / domainCollectFocusAsked~351/~366)。

问题:开场题是"随便一件带大概时间的经历",不是教育题。现在它以 target_domain = educationquestionId = collect:education:collect_method_evidence 持久化:

  • 用户对开场题点「先这样」或不答 → declinedSkippedTopics 里出现 educationdeclinedDomains 含 education → exhaustionSpokenCollectFollowup 跳过教育、D5 refine 的 !declined.has("education") 分支全部关闭;
  • 用户答完开场题(说的是搬家)→ domainCollectFocusAsked(rows, "education") 为真 → 之后真正的教育采集题被打上 collect_retry,文案变成"再补一件"。

修法(不改 DB check 约束,target_domain 只能是 education/career/relationship/relocation/finance/health/family/other):

  1. OPENING_COLLECT_DOMAIN 改为 "other"collectQuestionDomain(null | "unknown" | "active_focus") 返回 "other"。stable id 变为 collect:other:collect_method_evidencepersistableFocusDomain("other") 本来就合法。
  2. declinedDomainsdomainCollectFocusAsked 忽略 target_domain === "other"questionIdcollect:other: 开头的行(occupation 走的是 collect:occupation: 前缀,不受影响;occupationCollectFocusClosed 已按 questionId 前缀判断,确认不回归)。
  3. tests/rectification-adopt-flow-20260902.test.ts 里 "opening collect domain is education rather than unknown" 改为断言 "other"——三栏说明:旧 education → 新 other → 保留语义"不得为 unknown、stable id 不含 unknown"。
  4. 新测试:closedCollectFocuses 里只有开场题(collect:other:…status skipped)时,exhaustionSpokenCollectFollowup 仍会给出 education 题;buildMethodFollowupPlan 的教育采集 followup 不带 collect_retry

4. P2-c · 核对题年份靠正则从题干文本抠

位置method-followup.ts keepAcceptedFocus 分支 promptYear~1592)——text.match(/19\d{2}|20\d{2}/)

问题schema.probe_year 缺失时才走到它,但它是"从已展示文案反推数据",与"不得用字符串反推状态"的红线同一个方向;一旦题干写了两个年份("2016 年上大学、2020 年毕业")就取错。

修法

  1. 数据源头补齐:server-focus.ts expectedAnswerSchemaFor / persistServerOwnedFocusrectification-v9-tools.ts set-focus 建 reverse_verify / out_of_sample_check focus 时,schema 必写 probe_year(取 followup.probe_year,来自匹配的 verify 探针;没有就不写,不得从题干抠)。
  2. keepAcceptedFocuskeepProbeYear = matchingVerify?.year || schemaYear,删除 promptYear 兜底。年份仍缺时不给 probe_yearvalidateSpokenPromptyear_missing 只在 probe_year 非空时触发(现有语义),不会误伤。
  3. 测试:rectification-question-ownership.test.ts 加一条:采用后 set-focus 建的 reverse_verify focusexpectedAnswerSchema.probe_year 等于探针年份;keepAcceptedFocus 在 schema 无 probe_year、无匹配探针时 followup 不带 probe_year,且 method-followup.ts 源码不再含 /19\\d{2}|20\\d{2}/

4b. P2-d · 「用这个时间看盘」只是开空会话 + 塞一句废话(09-03 增补)

位置frontend/src/hooks/use-session-management.ts startConsultationAfterRectification~315);frontend/src/app/page.tsx onStartConsultation / onSaved~1951);rectification-agentic-chat.tsx 状态条 用这个时间看盘 按钮(~1503);frontend/src/hooks/use-consultation-run.ts resolveBirthTimeConsultationRoute(profile, …)~517)。

现状(静态核对,不是猜)

async function startConsultationAfterRectification() {
  await startNewChat();
  setDraft("请用刚才采用的代表性出生时间看盘。");
  setComposerNotice("已用刚才采用的时间作为当前排盘。这还不是唯一分钟确认。");
}
  1. 那句话是废话:普通咨询的排盘时间来自服务端 profiles.active_birth_timeaccept RPC 已写 active_birth_time + birth_time_status='accepted'/api/consultprepareConsultationRouteverified_chartactive_birth_time),服务端不读这句话;用户还得自己按发送,发出去的是一个没有问题的"看盘"。
  2. 静默失败startNewChat()!account || !modelCatalog || creatingSessionpersistSession 失败时返回 null,但草稿和提示照样设置,用户留在校正面上什么也没发生。
  3. 模式由客户端缓存决定,采用后没有等同步use-consultation-run.ts 用客户端 profile.birthTimeStatus / profile.timeresolveBirthTimeConsultationRoute;采用只触发 onSaved={() => void refreshAccount()}(不等待、失败无感)。缓存未更新时新会话会带 consultationMode: "unverified_birth_time"(或 declared_birth_window / general_no_birth_time)发请求;服务端资料已是 acceptedresolveServerChartConsultationmode_changed,首问直接报错——或更糟,走无分钟模式排了别的盘。
  4. 食言:校正面顶部写着"先陪你核对出生时间范围,之后会回到你原来的问题:“…”"(pendingConsultationQuestion),但按钮不带这个问题,塞的是那句废话。

修法(不自动发送——产品拍板:没有待回问题时自动发一句空泛的"看盘"是浪费额度;有待回问题也让用户过目一眼再发):

  1. 抽纯函数 frontend/src/lib/rectification-consult-handoff.ts
    export function planRectificationConsultHandoff(input: {
      savedTime: string;                       // 状态条上的已采用时间
      profile: { birthTimeStatus: string; time: string | null };  // refreshAccount 之后的资料
      pendingQuestion: string | null;
    }): { ok: false; reason: "profile_stale" } | { ok: true; draft: string; notice: string }
    
    birthTimeStatus ∉ {accepted, confirmed}profile.time !== savedTimeprofile_stale;否则 draft = pendingQuestion?.trim() ?? ""notice 为「当前排盘按已采用的 04:53(代表性候选,不是唯一分钟确认)。直接输入你想问的事。」(04:53 处用 savedTime)。
  2. startConsultationAfterRectification(input: { savedTime: string; pendingQuestion: string | null }): Promise<boolean>
    • await refreshAccount(),再从刷新后的资料(用 ref 或让 refreshAccount 返回资料,不得读闭包里的旧 profile)调 planRectificationConsultHandoffprofile_stalesetRectificationError("采用的时间还没同步到资料,请稍后再点。"),返回 false,不建会话。
    • const session = await startNewChat(); if (!session) { setRectificationError("新对话未能创建,请重试。"); return false; }
    • setDraft(plan.draft)setComposerNotice(plan.notice);删除 "请用刚才采用的代表性出生时间看盘。" 字面量。返回 true。
  3. RectificationAgenticChatonStartConsultation 签名改为 (savedTime: string) => void | Promise<unknown>,状态条按钮传 savedTime,按钮在 busycreatingSession(新增 prop 或复用 pending)时 disabledpage.tsx(savedTime) => void startConsultationAfterRectification({ savedTime, pendingQuestion: rectificationPendingQuestion })onSaved 里的 void refreshAccount() 保留作预热。
  4. 不改 resolveBirthTimeConsultationRoute 的客户端判定,也不改服务端 mode_changed 语义(它是防串盘的)。本单只保证"点按钮时客户端资料已是 accepted"。

测试(并入 tests/rectification-adopt-flow-fix-20260903.test.ts):

  • planRectificationConsultHandoffstatus=accepted, time=04:53, savedTime=04:53, pendingQuestion="我明年适合换工作吗?"okdraft 等于该问题;pendingQuestion=nulldraft=""notice04:53status=rectifyingtime=04:50profile_stale
  • 源码锁:use-session-management.ts 不再含 请用刚才采用的代表性出生时间看盘startConsultationAfterRectification 函数体内 refreshAccount 出现在 startNewChat 之前;rectification-agentic-chat.tsx用这个时间看盘 所在 <Buttondisabled=
  • 既有 rectification-adopt-flow-20260902.test.ts:402 / rectification-agentic-entry.test.ts:686/用这个时间看盘/ 断言保持通过。

5. 硬红线

  1. 公开 can_adopt 只能收紧;collect_evidence / discriminate_candidates / validate_holdout 不得放行。确认门恒 fail-closed。
  2. 选项、answer_class、计分服务端所有,Agent 只写 spokenPrompt;不恢复问题槽、不改 asked_turn_id 契约。
  3. 不得用正文/题干字符串反推状态或数据(本单 §4 就是在删一处;§4b 同理——不得靠往草稿塞话来"传递"排盘时间)。
  4. 不改 DB check 约束、不加迁移;Python 引擎不动;不 bump Skill 10.0.14。
  5. ./node_modules/.bin/tsc --noEmit 通过(不用 npx tsc);npm run lint --prefix frontend 0 错误;rectification-* / consultation-* / consult-* / chat-* 测试 fail=0;改动的既有断言逐条三栏说明(旧 → 新 → 保留语义)。
  6. 无凭据不得声称已真实环境验证。

6. 开工前置

git fetch origin --prune
git worktree add -b codex/rectification-adopt-flow-fix-20260903 \
  ../.worktrees/rectification-adopt-flow-fix-20260903 origin/staging

TASK-rectification-adopt-flow-20260902.mddocs/BUG_HISTORY.mdBUG-497500,以及 BUG-313/323 链)。BUG 编号从 BUG-501 起(先 grep 确认最大号):§1 记 BUG-501(复发自:无;相关 BUG-497),§4b 记 BUG-502(复发自:无;相关 BUG-499——35e5781e 把裸按钮移进状态条但没改行为,用户 09-03 实测反馈);§2/§3/§4 各一条或并入 BUG-499/498 的"最近更新",由实现者判断,但 §1 与 §4b 必须独立成条。行号是线索,按符号名定位。

7. 验收标准

  1. collect_evidence + 内部 canAdopt = true + 题库耗尽 → ensureNonTerminalTurnExit 建出 collect_spoken 兜底题(含 warn);adopt_representative 同条件不建题。
  2. 「改选」是 <button>,点击滚到出卡消息;showSelectionCards 为 false 时不渲染。
  3. 开场题 target_domain = other、id collect:other:collect_method_evidence;跳过开场题后 education 不进 declined、教育采集题无 collect_retry
  4. reverse_verify focus schema 带 probe_yearmethod-followup.ts 无年份正则。
  5. 「用这个时间看盘」:use-session-management.ts 无那句废话字面量;点击先刷新资料再建会话,资料未同步时报错不建会话,startNewChat 返回 null 时报错;有待回问题时草稿就是该问题,没有时草稿为空、提示含已采用时间;planRectificationConsultHandoff 三个用例通过。
  6. tsc + lint + 四组测试 fail=0BUG_HISTORY 追加条目(含 BUG-502)。
  7. 真实环境人工清单(部署后,沿用上一单 §9.7):采集阶段把题答到没题 → 出现"先这样"入口而不是空 composer;采用后点「改选」能跳回卡片;从首页带问题进校正 → 采用 → 点「用这个时间看盘」→ 新会话输入框里是原问题、提示写着已采用的时间 → 发送后回答按该时间排盘(回复不出现"使用未校正填报时间"提示)。