From 2dc454c9747132de79ff79188dfe51e93dc3c10e Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 6 Sep 2026 11:33:41 +0000 Subject: [PATCH] docs(tasks): rectification convergence + exit brief (BUG-558~560) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P --- docs/tasks/README.md | 1 + ...rectification-convergence-exit-20260906.md | 212 ++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 docs/tasks/TASK-rectification-convergence-exit-20260906.md diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 1955b327..e5a0244d 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -61,6 +61,7 @@ | `TASK-consultation-context-and-cache-20260906.md` | `PROGRESS-consultation-context-and-cache-20260906.md` | 普通对话历史只取每条前 4,000 字(报告结论被砍、无标记)、历史窗口不看模型 `context_window`、无溢出识别;改为检查点式会话摘要 + append-only 尾巴 + 按模型预算 + 一次降级重试;Anthropic 历史断点;共享方法段进系统块;后台用量页显示缓存命中率 | 已验收 | `bf8ad0d1`(BUG-555/556);`test:db` 环境缺口;部署前先 Migrate Staging Database | | `TASK-session-title-guard-fix-20260906.md` | `PROGRESS-session-title-guard-fix-20260906.md` | BUG-553 修复单:`consult/route.ts` 标题守卫比较的是 RPC 前快照,`append_consultation_question` 已改写标题,守卫恒 0 行,模型标题只靠客户端 PATCH 落库 | 待验收 | `codex/session-title-guard-fix-20260906`(BUG-557) | | `TASK-rectification-collect-vs-offer-consistency-20260905.md` | `PROGRESS-rectification-collect-vs-offer-consistency-20260905.md` | 带年份采集没问完就出采用卡 + 报告,同一轮又被搬家采集题把卡挤掉:决策层判 `adopt_representative` 而计划层仍有 dated 采集(BUG-546 只修了一半);改为剩余采集未完保持 `collect_evidence`,出牌轮才出卡写报告 | 已验收通过 `ca4e2408`(2026-09-05;staging 部署仍停在 `afd14948`,`deploy-staging` 自 `c295b853` 起连续失败,先解决 `bab07187` 的待迁移) | `codex/rectification-collect-vs-offer-consistency-20260905`(BUG-550) | +| `TASK-rectification-convergence-exit-20260906.md` | `PROGRESS-rectification-convergence-exit-20260906.md` | 采集问完落到「也可以再说一件事」无出口(非收敛出牌/非终止修复兜底 `other`)、同年月多领域拆题与同域同年重复问、`relative_support` 正比例归一把引擎证据压平成 7–9 使 lead 8 不可达;改为穷尽即交付 + 口述态停止按钮、同年月合并为「发生了什么」四选卡 + 同域同年去重 + 性格题须锚点、prior 按 (分−窗口最低分) 归一并过 20 例公开 holdout 校准门 | 待领取 | `codex/rectification-convergence-exit-20260906`(BUG-558~560) | | `TASK-api-not-configured-mislabel-20260904.md` | `PROGRESS-api-not-configured-mislabel-20260904.md` | 16 处路由把数据库瞬断(部署切换窗口)兜底翻译成 503「服务尚未配置」;改为仅配置错误用该文案,其余 `service_unavailable`,收敛为共享 helper | 已验收 | `5483649b`(BUG-542);2 条子进程测试留 CI Node 22 复核 | | `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505~509) | diff --git a/docs/tasks/TASK-rectification-convergence-exit-20260906.md b/docs/tasks/TASK-rectification-convergence-exit-20260906.md new file mode 100644 index 00000000..ea95409d --- /dev/null +++ b/docs/tasks/TASK-rectification-convergence-exit-20260906.md @@ -0,0 +1,212 @@ +# TASK · 生时校正:采集完没有出口、反推题形态不对、候选收不敛(2026-09-06) + +- 基线:`origin/staging` @ `e2d6f203`(代码头 `e2f4b55c`,staging 已部署同一 SHA,事故就是这版跑出来的) +- 分支:`codex/rectification-convergence-exit-20260906` +- 执行方:coding agent;验收:Claude +- 涉及文件: + - TS:`frontend/src/lib/rectification-agentic/v9/answer-choice.ts`、`method-followup.ts`、`decision-from-dossier.ts`、`probe-question-contract.ts`、`core/build-state.ts`、`core/candidate-separation.ts`(只读)、`frontend/src/app/api/rectification/agent/route.ts`、`frontend/src/components/rectification-agentic-chat.tsx`、`frontend/src/lib/rectification-agentic/user-copy.ts` + - Python:`scripts/rectification/decision_policy.py`、`scripts/rectification/event_probes.py`、`scripts/rectification/probe_question_contract.py`、`contracts/probe-question-v1.json` + - 不改 `scripts/jyotish_api_server.py`(增长冻结),不改 `frontend/src/app/page.tsx` +- BUG 编号起点:**BUG-558**(BUG-557 已被 `TASK-session-title-guard-fix-20260906.md` 预留;开工时 `grep -o "^## BUG-5[0-9][0-9]" docs/BUG_HISTORY.md | tail -1` 复核) +- 与其他任务书的关系:与 `TASK-session-title-guard-fix-20260906.md`(consult 路由)无文件交集,可并行。本单三个工作流 A → B → C **串行**,同一分支分三次提交。 + +## 1. 事故实证(staging,2026-09-06,产品负责人实测) + +年份一律抽象(Y、Y+4 …),不写出生资料。用户一次走完整个采集,共说了 7 件带时间的经历,答了 6 张点选卡,最后停在一句"也可以再说一件你记得大概时间的事",界面只剩"目前范围 …–…,还在收窄"的只读小字,没有候选卡、没有停止按钮、没有下一步。 + +| 轮 | 系统问 | 用户答 | 备注 | +| --- | --- | --- | --- | +| 1 | 开场采集(`GENERIC_COLLECT_QUESTION`) | 上大学 Y 年 9 月、毕业 Y+4 年 6 月 | 2 件 month | +| 2 | 感情采集(`USER_COLLECT_QUESTION.relationship` 原文) | Y+8 年 5 月认识、8 月 8 日分手 | month + day | +| 3 | 点选卡「在亲密关系里更接近哪一种 A/B/C/D」 | 点选 | varga_style,有事件锚点 | +| 4 | 点选卡「平时做事更接近哪一种 A/B/C/D」 | 点选 | varga_style,**无任何时间锚点** | +| 5 | 点选卡「Y+7 年 5 月前后有没有入职/换工作 A/B/C/D」 | 点选 | existence,career | +| 6 | 点选卡「Y+8 年 4 月前后有没有入职/换工作」 | 点选 | existence,career | +| 7 | 点选卡「Y−1 年 5 月前后有没有搬家/长期住外地」 | 点选 | existence,relocation | +| 8 | 家人采集(原文) | "没有" | declined | +| 9 | 钱的采集(原文) | Y+10 年 1 月欠薪、开始欠债 | month;助手随后说"想先看看当前候选也直接说一声,我来汇总" | +| 10 | 搬家采集(原文) | Y+7 年 7 月搬到外地 | month | +| 11 | 点选卡「**Y+7 年前后**工作上有没有入职/换工作」 | 点选 | 与第 5 轮同域同年,只是去掉了月份 | +| 12 | 身体/压力采集(原文) | Y+10 年 1–8 月难熬 | range | +| 13 | 职业采集(原文) | 三段职业,无年份 | occupation_note | +| 14 | **「也可以再说一件你记得大概时间的事。」**(`USER_COLLECT_QUESTION.other` 原文) | —— | 死胡同 | + +只读小字显示范围仍有 27 分钟宽。每张点选卡答完只回"已记录你的选择,并更新了候选比较",看不出哪一题让范围动了。 + +产品负责人的三条判断,本单照单全收: + +1. 与 Agent 互动完之后没有下一步,用户不知道该做什么,卡在那里。 +2. 占星反推题与验证冲突题应当是"**在几年几月发生了什么事 → 你觉得更像 A/B/C/D**"这种形态;现在是一个领域一个领域地问"那时候有没有 X",同一个时间点被拆成几道题,还有没时间锚点的性格题。 +3. 时间根本收敛不了。 + +## 2. 根因(三条,互相独立,都已在代码里定位) + +### 2.1 出口:非收敛出牌 / 非终止修复都退化成"再说一件事",没有交付 + +- `answer-choice.ts::persistNextInterviewIfIdle` 在 `isNonConvergingRangeOffer(decision)`(`canOfferRange && !canAdopt && nextAction === "offer_provisional_range"`)时调用 `persistExhaustionCollect`;`ensureNonTerminalTurnExit` 在"没有问题也没有采用载体"时同样调用它;`route.ts` 无焦点分支也走同一条。 +- `persistExhaustionCollect` → `method-followup.ts::exhaustionSpokenCollectFollowup`:先 `nextDatedCollectFollowup`(七个领域都问过/拒过就为空),再职业,最后 **无条件落到 `otherCollectFollowup`**,即第 14 轮那句话。它的 `domain="other"` 不在 `REMAINING_EVIDENCE_COLLECT_DOMAINS`,所以既不算"剩余采集"也不挡出牌,但它作为 `collect:other:*` 焦点被持久化后,下一轮再答一件事又回到同一处——**没有终止条件**。 +- 出口所依赖的 `canAdopt=false` 从哪来,服务端没有留下任何日志(`persistExhaustionCollect` 不记录 `decision` 的输入)。可能来源有四个:引擎 `acceptance_allowed=false`(`decision_policy.py` 的 event_quality / domain_diversity / date_quality 门)、`trainingScoreableGate` 因证据 status 非 `confirmed` 而关闭、`candidateScoresFromDossier` 在 inference 存在但投影不一致时返回空数组(`ranked.length===0` → `keep_collecting`)、`engineCapabilityCeilingFromReceipt` 因 receipt 字段自相矛盾而返回全关。**本单不猜是哪一个**,先补日志与形状回归,再把"不管哪一个都不能落到 other"钉死。 +- 用户能看到的唯一状态是 `RectificationReadonlyRange`(`sessionOutcome ∈ {collect_evidence, discriminate_candidates}` 时渲染),它不可点。口述采集态没有"先这样,先看当前范围"按钮(`CHOICE_STOP_LABEL` 只挂在点选卡上)。`turn-intent-classifier.ts` 定义了 `ask_about_result`,`route.ts` 却只处理 `stop_rectification`,而且只在点选焦点分支里处理;助手那句"想先看看当前候选也直接说一声"是模型自由发挥,系统兑现不了。 + +### 2.2 题型:探针按"领域 × 大运边界月"一条条出,同一日期拆成多题,同域同年月份不同再问一遍 + +- `event_probes.py::_discriminating_event_probe_lists` 对每个领域独立扫描 `_union_boundary_dates`,同一个边界月会给 career、finance、relocation 各生成一条 existence 探针。用虚构出生资料(1998-03-15,北京,04:30–05:30)把本次事故的 7 件事喂进 `score_candidates`,引擎公开的 8 条探针是: + + | semantic_key | gain | 说明 | + | --- | --- | --- | + | `career.2022.06.dasha_boundary` | 1.11 | 同一月 | + | `finance.2022.06.dasha_boundary` | 1.06 | 同一月 | + | `career.2023.05.dasha_boundary` | 1.10 | 同一月 | + | `finance.2023.05.dasha_boundary` | 1.01 | 同一月 | + | `relocation.2018.05.dasha_boundary` | 0.99 | | + | `relationship.2019.02.dasha_boundary` | 0.96 | | + | `finance.2024.03.dasha_boundary` | 0.92 | | + | `career.2024.05.dasha_boundary` | 0.88 | | + + 8 条里 4 条是"同一个月、换个领域再问一次"。用户感受就是"同一个时间点被反复问有没有 X、有没有 Y"。 +- 去重键是 `(domain, year, month, source)`(`_partition_ranked_probes`)与 TS 侧 `askedKeys`(semantic_key / split hash 精确匹配)。`_try_activation_probe` 用年龄带中点年份生成年级探针(`career..dasha_activation`,无月份),`_existence_blocked_years` 只看**账本**里的年份,不看**已问过**的探针年份,于是第 5 轮问了 `career.Y+7.05`,第 11 轮又问 `career.Y+7`(BUG-540 修的是质量探针绑定,没覆盖这条)。 +- varga_style 卡(第 4 轮"平时做事")来自 `contrastPacket` 的 D10 类型对照,没有任何日期锚点;第 3 轮同类卡之所以可接受,只是因为模型自己把"回想你 Y+8 年的这段感情"写进了题干。 +- `_agent_brief` 给模型的提示是"写一句自然的是/否题",产品要的"那时候发生了什么 → 更像哪一种"在探针层根本没有这个题型。 + +### 2.3 收敛:相对支持度把引擎证据压平成 7–9,之后只有点选卡在推分 + +同一份虚构资料的量化结果(`references/` 里没有该用户任何资料,数字只说明机制): + +| 指标 | 4 件事 | 7 件事 | +| --- | --- | --- | +| 61 个分钟的原始分 min / max | 6.03 / 11.19 | 10.96 / 15.52 | +| 原始分极差 | 5.16 | 4.56 | +| 公开候选(12 个特征簇代表)的 `relative_support` | 9 9 9 9 9 8 8 8 8 8 8 7 | 9 9 9 9 9 8 8 8 8 8 8 7 | +| top-2 领先 | 0 | 0 | +| 诊断 `margin_percent` | 0.80 | 2.26 | +| `acceptance_allowed` / `propose_allowed` | true / true | true / true | + +- `decision_policy.py::_relative_support` 把 100 按**原始分正比例**分给最多 12 个簇代表。原始分是各事件正分之和,底座 11–15,事件带来的差异只有 4.5,正比例归一后每个簇 7–9 分,**任何证据组合都到不了 `MIN_SEPARATION_LEAD=8`**。 +- `core/build-state.ts::buildInferenceState` 把 `relative_support` 当 `prior_score`,之后每道点选卡按 `SCORE_DELTA` ±2 推分;`credible-range.ts::unionStillValidRange` 取"与峰值差 < 8"的簇并集。于是:7 件带年月的经历对可信区间几乎没有贡献,区间只靠点选卡一题题往下压,压 4 题才可能淘汰一个簇;点选卡又有 8 轮 / 10 答的熔断。这就是"收不敛"。 +- 同一份数据换成"支持度 ∝ (原始分 − 窗口最低分)",12 个簇代表变成 14/14/12/12/10/9/8/7/6/5/2/0;换成 softmax(T=0.5)是 29/26/14/13/6/4/3/1/1/1/0/0。差别来自引擎已经算出来的证据,不是新证据。 +- `TASK-rectification-provisional-adopt-20260901.md` §2 当时已经看到"lead 永远停在 5",决策是"只改门、四个常数不动",但没有追到 prior 被压平这一层。本单不动 `MIN_SEPARATION_LEAD=8` 的数值,改的是喂给它的尺度。 + +## 3. 决策记录(产品负责人 2026-09-06 授权"彻查并彻底解决",以下是 PM 代为落地的决策,可否决) + +1. **采集封顶必须交付。** 方法覆盖完成(`blockingMethodsCovered`)、七个带年月领域都问过或拒过、职业已覆盖之后,不得再问任何开放式采集题。`USER_COLLECT_QUESTION.other` / `USER_COLLECT_QUESTION_RETRY.other` 与 `otherCollectFollowup` 只保留给开场(`!dashaCovered`);`exhaustionSpokenCollectFollowup` 删除 `other` 兜底。这推翻 `TASK-rectification-nonterminal-exit-20260901.md` 里"每轮至少留一个问题"的实现方式:**每轮至少留一个问题或一个交付载体**的规则不变,但穷尽时的载体改为交付,不是"再说一件事"。 +2. **交付分两种。** 引擎 `acceptance_allowed=true` → 走既有 `adopt_representative` 出牌(候选卡 + 八法报告 + 停止原因句)。`acceptance_allowed=false` → 不出牌,但必须把 receipt 的 `acceptance_reasons` 翻成用户能照做的一句话(例:"还差 1 件带月份的经历,领域不限" / "两件事的日期还没对清"),并给"先这样,先看当前范围"按钮;范围小字改为可点。不得再出现"也可以再说一件事"。 +3. **口述采集态也要有停止按钮**,与点选卡共用 `CHOICE_STOP_LABEL`,走 `STOP_ACTION`(→ `paused` → `provisional_range_user_stopped`)。`ask_about_result` 意图等同于用户按了这个按钮。助手文案不得承诺系统做不到的事(Skill 文案另开单,本单只在 `user-copy.ts` 与提示词里删掉"说一声我来汇总"类措辞的来源——若来源在模型自由发挥,进度记录写明即可)。 +4. **题型改为"某年某月发生了什么"。** 同一 (year, month) 的多领域 existence 探针合并成一张卡(见 5.B2),选项是领域,不是"发生了 / 没发生";题干必须写出年月,若账本里有 ±2 个月内的其他领域事件,题干要提到它("Y 年 7 月你搬到外地前后,工作上……")。这是产品要的"反推 + 验证冲突"形态。四选项契约(BUG-390)不放宽:一张卡仍是 A/B/C/D 四项,只是每项映射到两条探针的答案。 +5. **同域同年只问一次。** 已问过 `career.Y.05.*`,则 `career.Y`、`career.Y.03`、`career.Y±1`(按 `EXISTENCE_NEARBY_YEARS`)全部视为已问。Python 与 TS 两侧都要生效。 +6. **无锚点的性格题不出。** varga_style 卡只在同域账本里有带年月事件时才可渲染,题干提示必须带那件事的年月;排序在带年月卡之后。 +7. **prior 换尺度。** `_relative_support` 改为按 (原始分 − 全窗口最低分) 归一(或温度可调的 softmax,由校准决定),`MIN_SEPARATION_LEAD=8`、3/2 采用门、4/3 确认门、`maxPlateauRounds` 等数值一律不动。这是对 `TASK-rectification-decision-authority-20260831.md` §4 "数值不动"的补充而非推翻:常数不动,改的是输入尺度,并且必须过 5.C2 的校准门。**合入前把校准表拿给产品负责人看一眼**,他点头再合。 +8. **先补日志再修。** `persistExhaustionCollect` 的每次进入都要写一条结构化日志(无 PII),本单合入后若真实环境再复现,靠日志定位 `canAdopt=false` 的来源,不靠猜。 + +## 4. 硬红线 + +- BUG-390 四选项契约、`contracts/probe-question-v1.json` 的 yes / weak_yes / no / unsure 语义不放宽;新增 choice_kind 必须同时进 Python `probe_question_contract.py` 与 TS `probe-question-contract.ts` 的契约测试。 +- `tests/test_rectification_engine_convergence.py::test_scripts_and_frontend_have_no_answer_key_literals` 必须继续通过:不得在代码里写任何真实案例的年份/分钟字面量。 +- 采用门 / 确认门 / 训练门 / 熔断的数值不动(`MIN_ACCEPTANCE_*`、`MIN_CONFIRMATION_*`、`MIN_SEPARATION_LEAD`、`DEFAULT_MAX_DISCRIMINATION_ROUNDS`、`EFFECTIVE_ANSWER_SAFETY_CAP`、`maxPlateauRounds`、`minUncertaintyAnswers`)。 +- BUG-503(一道 D 不停)、BUG-520(区分卡答"否"不是拒答)、BUG-540/541(质量探针绑定)、BUG-544/546/547/549/550 的用例原样通过;改既有断言写"原值 / 新值 / 原因"三栏。 +- 点选答案仍不写证据账本(BUG-389–393 分层)。 +- 前端:`tsc --noEmit` 0 错、`npm run lint` 0 error、测试总数不低于开工时 `origin/staging` 实测;`page.tsx` 不增长。Python:`run_quality_gate.py --profile quick` 通过,`scripts/jyotish_api_server.py` 不增长。 +- 任务书 / 进度 / Bug 历史 / 测试 fixture 只用抽象年份或公开名人(`references/real_case_calibration/`)。 + +## 5. 任务分解 + +### A · 出口(BUG-558) + +**A1 结构化日志。** `persistExhaustionCollect` 入口 `console.warn(JSON.stringify({ event: "rectification_exhaustion_collect", case_id, can_adopt, ceiling: {acceptance, selection, propose}, training_gate: {count, domains, open}, stop_class, ranked_count, probe_key, budget: {rounds, answers, plateau}, next_domain, next_source }))`。 +- 验收:单测断言字段齐全且不含 evidence summary / 年份。 + +**A2 穷尽即交付。** 改 `exhaustionSpokenCollectFollowup`:删 `other` 兜底,返回 `null` 时由调用方分流—— +- `engineCeiling.acceptanceAllowed && trainingGate.open` → 走 `adopt_representative` 交付(复用 `adoptHostNarration` / `deliveryAdoptNarration`,`stopReason` 用新增的 `"probe_pool_exhausted"`,文案:"能分开候选的问题已经问完,先按现有材料给你结果。")。`decideRectification` 需要一条新的 `completeWithRange(..., "exhausted")` 入口:`probe===null && !datedMethodCollectOpen && methodCoverageAll` 且 `capability.canAdopt` 时不再走 `offerRangeWithoutAdopt`,而是 `finish("adopt_representative")`。 +- 否则 → `hostNarration` = 范围句 + 门槛翻译句(`acceptance_reasons` → 用户文案映射表放 `user-copy.ts`:`insufficient_events`、`insufficient_domain_diversity`、`low_date_quality`、`no_candidates`;TS 侧 `keep_collecting` 的 `insufficient_dated_events` / `insufficient_domains` 同表);不持久化任何焦点;`ensureNonTerminalTurnExit` 的"满足"条件加上"本轮已写出门槛翻译句"(用 turn 上的 `question=null && terminal_note` 标记,具体载体由执行方定,进度记录写明)。 +- `route.ts` 无焦点分支的 `isNonConvergingRangeOffer` 同步改为上面两种出口。 +- 验收:形状回归见 A4;`grep -rn "USER_COLLECT_QUESTION.other\|collectQuestionRetryByDomain.other" frontend/src` 只剩开场路径;`listUserVisibleCopy` 与 `agent-voice-copy-contract` 测试更新。 + +**A3 停止入口。** `rectification-agentic-chat.tsx`:`currentQuestion.kind === "collect_spoken"` 时在输入框上方渲染与点选卡同款的 `CHOICE_STOP_LABEL` 按钮(复用 `submitStop` 路径,`STOP_ACTION`);`RectificationReadonlyRange` 改为按钮,同一动作。`route.ts`:`ask_about_result` 在点选与口述两个焦点分支都映射到 `STOP_ACTION`(口述分支现在只处理 decline,要补 stop 与 ask_about_result)。`frontend/DESIGN.md` 记录新按钮位置;文案对照 `frontend/docs/VOICE.md`。 +- 验收:`rectification-v9-entry-routing` 或新测试覆盖 `ask_about_result` → paused;组件测试覆盖口述态出现停止按钮。 + +**A4 事故形状回归(核心)。** 新文件 `frontend/tests/rectification-exhaustion-exit-20260906.test.ts`,dossier 形状:7 条 confirmed 证据(education×2 month、relationship month+day、finance month、relocation month、health_pressure range)、family declined、occupation_note、`answered_probes` 6 条(2 条 varga_style、4 条 existence,含同域同年一月一年的两条)、`discriminating_event_probes` 全部已问或 `no_split_among_active`、receipt `acceptance_allowed/selection_allowed/propose_allowed=true`、`confirmation_allowed=false`。断言: + 1. `decideFromDossier` 不是 `collect_evidence`;`persistNextInterviewIfIdle` 不写 `collect:other:*` 焦点;hostNarration 含停止原因句与范围句。 + 2. 同形状但 receipt `acceptance_allowed=false, acceptance_reasons=["insufficient_events"]` → hostNarration 含门槛翻译句,不写焦点,`ensureNonTerminalTurnExit` 不抛。 + 3. 同形状但 `candidateScoresFromDossier` 返回空(模拟投影不一致)→ 同 2,且日志行 `ranked_count=0`。 + +### B · 题型(BUG-559) + +**B1 同域同年去重(两侧)。** Python:`_discriminating_event_probe_lists` 接收 `asked_probe_keys`(由 API 请求体新增可选字段 `asked_probe_keys: string[]` 透传;`normalize_rectification_request` 允许该字段,不改 `jyotish_api_server.py` 主体),`_existence_blocked_years(domain, known_years ∪ asked_years)`;`_try_activation_probe` 同样受阻。TS:`remainingConflictProbes` / `renderableEventProbe` / `renderableContrastProbe` 的 `asked` 判断改为按 `domain.year` 前缀匹配(`career.2023.05.dasha_boundary` 覆盖 `career.2023*`),并按 `EXISTENCE_NEARBY_YEARS` 扩到相邻年。 +- 验收:`tests/test_rectification_event_probes.py` 新增:已问 `career.Y.05.dasha_boundary` 后不再产出 `career.Y.*`、`career.Y±1.*`;TS `rectification-choice-card.test.ts` 新增同样断言。 + +**B2 "某年某月发生了什么"合并卡。** 新 `choice_kind: "boundary_pair"`: +- Python:`_partition_ranked_probes` 之后,对同 (year, month) 且领域不同的两条 existence 探针(取 gain 最高的两条)生成一条合并探针:`semantic_key = boundary..`,`component_probes = [key1, key2]`,`style_options`:A ``(→ p1 yes, p2 no)、B ``(→ p1 no, p2 yes)、C `两件都有`(→ 都 yes)、D `都没有`(→ 都 no);`expected_outcomes` 按四个组合合并 supports/conflicts;`information_gain` 取两条的联合分组熵(`_group_entropy` 对四组)。原两条单领域探针从 public 列表移除,进 `dropped_probes(reason="merged_into_boundary_pair")`。选项标签长度守 `LABEL_MIN/MAX`,用 `DOMAIN_CATALOG` 里的 `event_family` 缩到 ≤ 12 字。 +- 契约:`contracts/probe-question-v1.json` 加 `boundary_pair` 的 `answer_class` 映射(A=`pair_first`、B=`pair_second`、C=`pair_both`、D=`pair_none`,"这题跳过" = 两条 unsure);`probe_question_contract.py` 与 `probe-question-contract.ts` 同步,契约测试比对字节。 +- TS:`applyRectificationChoice` 对 `boundary_pair` 写两条 `answered_probes`(各自 `semantic_key`、`answer_class` yes/no),`buildInferenceState` 不改;`askedKeys` 同时记 `boundary..` 与两条分量键(配合 B1)。 +- 题干:`_agent_brief` 新分支:"时间范围锁定 {Y 年 M 月前后};请问用户那段时间身上发生了什么变化,选项由服务端给出;若账本里 ±2 个月内有其他领域事件,题干先提那件事再问。" `method-followup.ts` 在 `user_prompt_hint` 里把邻近账本事件(只给领域与年月,不给 summary)拼进去。 +- 验收:Python 测试——两条同月探针合并成一条、四个 outcome 的 supports 互不重叠且并集覆盖两条分量、原探针进 dropped;TS 测试——一张 `boundary_pair` 卡答 A 后 `answered_probes` 两条、posterior 变化等于分别答 p1 yes + p2 no;契约测试;`agent-voice-copy-contract` 更新。 +- 若 B2 做不完(见 §6),至少 B1 + 题干锚点(邻近账本事件写进 `user_prompt_hint`)要落地。 + +**B3 varga_style 卡要锚点。** `rankRenderableDiscriminators` / `firstRenderableYearlessFollowup`:`choice_kind === "varga_style"` 且同域账本无 confirmed 带年月事件 → 进 `dropped_probes(reason="unanchored_varga_style")`,不渲染;有事件时 `user_prompt_hint` 带该事件年月,`selection_score` 乘 0.8 排在带年月卡之后。 +- 验收:`rectification-varga-style-*.test.ts` 新增:无同域事件不出卡;有事件出卡且 hint 含年月。 + +### C · 收敛(BUG-560) + +**C1 prior 换尺度。** `decision_policy.py::_relative_support(scores, floor)`:`weight = max(score − floor, 0)`,`floor` = 全分钟网格(`built["candidate_times"]` 全部行,不只是公开代表)的最低分;全零时保持均分。可选 softmax 版本 `exp((score − max)/T)`,两者都实现,由 C2 选定默认并记录在 `POLICY_VERSION`(bump 到 `rectification-candidate-policy-v4`;`ALGORITHM_VERSION` 同步 bump,`test_algorithm_and_policy_versions_change_with_proximity_semantics` 模式照抄)。`rectification_report` 与 `candidate_summary` 里所有引用 relative_support 的文案不变。 +- 验收:`tests/test_rectification_diagnostics_clusters.py` 或新测试——同一 rows 下新支持度的 top-2 领先 ≥ 旧值;均分回退用例。 + +**C2 校准(合入门)。** 新脚本 `scripts/rectification_prior_calibration.py`(只读 `references/real_case_calibration/minute_rectification_development_v1.json` 调参、`minute_rectification_holdout_v3.json` 20 例验证;`excluded_from_tuning=true` 的例子只进验证):每例以真实分钟 ± `candidate_radius_minutes` 为窗口跑 `score_candidates`,分别按旧归一、`score−min`、softmax(T∈{0.25,0.5,1,2}) 计算 `unionStillValidRange`(lead 8)宽度与是否覆盖真实分钟。输出表:方案 × {holdout 覆盖数/20, 中位宽度, 均值宽度}。 +- 通过标准:所选方案 holdout 覆盖数 ≥ 旧方案覆盖数 − 1,且中位宽度 < 旧方案;否则保留旧归一,C1 只留代码不启用,进度记录写明。 +- 表格进 `PROGRESS-*.md` §C2,**产品负责人确认后**才合 C。 + +**C3 存量。** `POLICY_VERSION` 变化会让 `candidateSnapshotSource.scoringPolicyVersion` 失配 → `snapshotCurrent=false` → 已有 Case 下一轮自动重算(`TASK-rectification-tails-20260901.md` 已铺路);执行方确认这条路径仍通,并在 `docs/testing/` 清单里写"旧 Case 打开后第一轮会重算一次"。 + +**C4 显示。** `isRecommendedRectificationCandidate` 沿用 lead 8,不改;`RectificationCandidateCards` 的"相对支持度 N"不改文案。进度记录附一张 4 件事 / 7 件事虚构样例的前后支持度对比表(用 §2.3 的虚构资料即可)。 + +### D · 记录 + +- `docs/BUG_HISTORY.md`:BUG-558(出口)、BUG-559(题型与去重)、BUG-560(prior 压平);BUG-560 关联 BUG-463 与 `TASK-rectification-provisional-adopt-20260901.md` §2。 +- `CHANGELOG.md`:三条用户可感知变化;Skill 版本不 bump(本单不改 SKILL.md;文案单另开)。 +- `docs/tasks/PROGRESS-rectification-convergence-exit-20260906.md`:A/B/C 各一节,含测试数字、C2 校准表、偏离。 +- `docs/testing/rectification-convergence-exit-20260906.md`:真实环境清单——(1) 走完七个领域后出现候选卡或门槛翻译句,不再出现"也可以再说一件事";(2) 口述采集态有"先这样"按钮,点了出范围;(3) 同一年月不再被问两次;(4) 合并卡题干带年月并提到邻近事件;(5) 旧 Case 首轮重算一次。 +- `docs/tasks/README.md` 状态板加一行。 + +## 6. 让步顺序 + +1. A1 + A2 + A4 不可拆,是本单的底线;A3 可以后置到同分支第二次提交。 +2. B1 + B3 必做;B2 若一天内做不完,改为"B1 去重 + 题干锚点",并在进度记录写明 B2 未做与原因,本单状态保持"部分完成",不得标完成。 +3. C1 + C2 必做;C2 不过门就不启用 C1,按 §5.C2 写明。 +4. D 不可省。 + +## 7. 开工前置命令 + +```bash +git fetch origin --prune +git worktree add -b codex/rectification-convergence-exit-20260906 .worktrees/rectification-convergence-exit-20260906 origin/staging +cd .worktrees/rectification-convergence-exit-20260906 +ln -s /workspace/Jyotisha/frontend/node_modules frontend/node_modules +ln -s /workspace/Jyotisha/.venv .venv +# 基线数字(写进进度记录) +cd frontend && npx tsx --test tests/rectification-*.test.ts tests/agent-voice-copy-contract.test.ts 2>&1 | grep -E "^# (tests|pass|fail)"; cd .. +.venv/bin/python -m pytest tests/test_rectification_event_probes.py tests/test_rectification_engine_convergence.py tests/test_rectification_diagnostics_clusters.py tests/test_active_rectification_api.py -q +grep -o "^## BUG-5[0-9][0-9]" docs/BUG_HISTORY.md | tail -1 +``` + +收尾:同一组命令 fail=0;`./node_modules/.bin/tsc --noEmit`;`npm run lint`;`npm run build` 后 `/` 仍 Static、首屏 gzip ±2%;`.venv/bin/python scripts/run_quality_gate.py --profile quick`。 + +## 附录 · 复现脚本(虚构资料,可直接跑) + +```python +# .venv/bin/python 下运行;出生资料为虚构值,仅复现机制 +import uuid, sys, os +sys.path.insert(0, "."); sys.path.insert(0, "scripts") +from scripts.rectification.contracts import normalize_rectification_request +from scripts.rectification.api_service import score_candidates +EV = [("education","education_start","2016-09-01","2016-09-30","month"), + ("education","education_completion","2020-06-01","2020-06-30","month"), + ("relationship","relationship_start","2024-05-01","2024-05-31","month"), + ("relationship","relationship_end","2024-08-08","2024-08-08","day"), + ("finance","finance_loss","2026-01-01","2026-01-31","month"), + ("relocation","relocation","2023-07-01","2023-07-31","month"), + ("health_pressure","pressure_period","2026-01-01","2026-08-31","range")] +req = normalize_rectification_request({"birth_date":"1998-03-15","start_time":"04:30","end_time":"05:30", + "lat":39.9042,"lon":116.4074,"tz":8.0,"events":[{"id":str(uuid.uuid4()),"domain":d,"event_kind":k, + "date_start":s,"date_end":e,"precision":p,"summary":k} for d,k,s,e,p in EV]}) +out = score_candidates(req) +print([(c["time"], c["relative_support"]) for c in out["candidate_decisions"]]) +print([(p["semantic_key"], p["information_gain"]) for p in out["decision_receipt"]["discriminating_event_probes"]]) +```