diff --git a/TASK-rectification-convergence-20260830.md b/TASK-rectification-convergence-20260830.md new file mode 100644 index 00000000..b6db2911 --- /dev/null +++ b/TASK-rectification-convergence-20260830.md @@ -0,0 +1,312 @@ +# 任务书 · 生时校正收敛重构(2026-08-30) + +基线:`origin/staging` @ `e7226bbf`。 + +**下面所有行号只是线索,请按选择器/函数名定位**,后续提交可能让行号偏移。 + +## 为什么要做 + +本轮的依据是对着代码和标定数据做的一次审计。先摆事实。 + +### 事实 1 · 仓库里有三套互不相干的校正系统,被评测的那套没人用 + +| | 系统 | 谁在跑 | 打分由谁做 | 评测状态 | +| --- | --- | --- | --- | --- | +| **A** | `skills/jyotish-vedic-astrology/.../references/birth-time-rectification-{advanced,decision-tree,cases}.md`(约 15.5k 字符) | 本地 Agent(合伙人的用法) | **模型自己推理** | **从未评测** | +| **B** | `_compute_rectification_v5_score`(`scripts/jyotish_api_server.py`),前端经 `/api/rectification/v5/score` 调用 | **线上生产** | 服务端 v5 打分器 | **从未评测** | +| **C** | `scripts/minute_rectification_fact_ranker_v4.py` | 只有评测脚本引用 | 服务端 v4 打分器 | 测过,见下 | + +`references/rectification_sealed_holdout.v1.json` 的那组数字——`top_1_rate 0.15`、`top_3_rate 0.25`、`mean_absolute_minute_error 6.95`、`confirmation_coverage_rate 0.0`、`status not_ready`、`source_audit_status invalidated_after_replay`——**测的是 C**。而且文件自己写明: + +``` +current_tree_scorer.matches_metrics_scorer = false +current_tree_scorer.official_eval_trial_count = 0 +``` + +`grep` 确认 `fact_ranker_v4` 只被 `minute_rectification_fact_blind_eval_v4.py` / `minute_rectification_development_eval.py` / `conversational_minute_rectification_replay.py` 引用,**没有被 `mcp_server.py` 或前端引用过**。 + +生产 skill(`skills/jyotish-birth-time-rectification/versions/10.0.9/SKILL.md`)第 29 行明确规定"全部计算与持久化只走服务端工具"、"候选范围与评分"以服务器为唯一权威——**B 链路的模型被禁止参与打分**。而 A 链路的模型是自己做方法学推理的。这是两种根本不同的架构,不是同一系统的两个版本。 + +**所以:线上这套(B)的真实能力,目前没有任何数据。** + +### 事实 2 · 标定数据总量极小 + +| 数据集 | 案例数 | 状态 | +| --- | ---: | --- | +| `minute_rectification_holdout_v3` | 20 | 源审计后作废 | +| `minute_rectification_holdout_v4_intake` | 4 | `exposed_awaiting_human_rereview`,`blind_holdout_eligible_case_count = 0` | +| `minute_rectification_development_v1` | 3 | 开发集 | + +v4 的准入门槛写在 `minimum_gate` 里:`public_aa_cases: 20`,且 `production_tuning_allowed: false`。也就是说**现在没有任何一个可用于盲测的冻结 holdout**。 + +### 事实 3 · 访谈不收敛,是因为终止条件不可达 + +近 40 个校正提交里最大的一类是停滞: + +``` +prevent silent collect focus stalls +prevent collect focus dead-end after choice answers +close non-converging range offer without an exit +keep collection denials from stalling the interview +stop occupation coverage from locking questions and the range exit +BUG-028 收到具体经历后仍重复泛问 / BUG-029 提前结束 / BUG-236 长期停留在建立记录 +``` + +`confirmation-gate.ts` 是 fail-closed 的:holdout 没 ready 就永不发唯一分钟确认。于是访谈只能继续问。**这些 stall 不是各自独立的 bug,是"终止条件是确认唯一分钟、而该条件当前不可达"这一个结构性矛盾的下游症状。** + +### 事实 4 · 一条消息有四个作者 + +服务端决定问什么(`src/lib/rectification-agentic/v9/method-followup.ts`,2,036 行)→ 模型写正文 → 服务端把题干接在正文之后 → UI 再渲染选择卡。 + +于是 `src/mastra/agentic-rectification.ts` 的系统提示第 4 条要求模型判断自己处在 `choice` / `collect_spoken` / 无持久化问题 等状态中的哪一种,然后区别行动。模型做不到,所以有了 `src/lib/rectification-agentic/v9/spoken-answer.ts`:**33 条内部标识符正则 + 68 条中文过程话术正则,共 101 条模式**,在事后擦模型漏进用户可见文本的内心戏。 + +每一条正则都是一个曾经发生过的 bug。 + +### 事实 5 · 有一条与线上结论冲突的一手观察 + +产品侧报告:**A 链路(本地 Agent 直接执行 skill 方法学)能收敛到准确的出生时间。** + +这条观察不能被忽略,也不能直接采信,因为两件事同时成立: + +- 它**可能是真的且极其重要**。如果模型执行方法学的效果好于手写的 v5 打分器,那么生产(B)建在了错误的引擎上,而这恰好能解释访谈为什么永远不收敛——B 到不了终点,整个对话引擎是在为它打补丁。 +- 它**目前不可证伪**。本地运行几乎肯定不是盲测:跑的人知道答案,或案例的声明窗口本就很窄。`holdout_v3` 被判 `invalidated_after_replay`、`v4_intake` 状态是 `exposed_awaiting_human_rereview`,说明这个仓库已经吃过一次"结果被曝光后作废"的亏。 + +**这不是一个靠讨论能解决的分歧,是一个靠对照实验能解决的问题。** 任务 0 因此改为三方盲测对照,在拿到结果之前,本任务书不预设哪条链路更好。 + +### 结论 + +**任务 0 之前不做任何架构结论。** 三条候选路线,由任务 0 的数据决定: + +1. 若 A 显著优于 B → 生产改用模型执行方法学,B 降级为校验器。这会让任务 1–3 的形态大改,但方向明确。 +2. 若 B 与 A 相当或更好 → 保留 B,按本任务书的任务 1–3 重构收敛与交互。 +3. 若两者都达不到"稳定收窄区间" → 停止交互层投入,回到方法学与打分本身。 + +无论哪条路线,**产品目标都应从"确认唯一分钟"改为"交付收窄后的区间"**——因为 `confirmation-gate.ts` 的 fail-closed 依赖 sealed holdout,而 holdout 的准入门槛(20 例 AA 盲测案例)在任务 4 完成前不可能满足。唯一分钟保留为 holdout 通过后才开启的路径。 + +--- + +## 硬红线 + +1. **任务 0 是门控。** 三条链路(A 本地方法学 / B 线上 v5 / C v4 基线)的真实指标必须先在同一盲测协议下测出来。**在拿到对照数据之前,不得改动任何打分逻辑、不得改动收敛阈值、也不得把生产切换到任一链路。**「合伙人说本地能收敛」是待验证的假设,不是可以直接依据的结论;同样,「holdout 显示 15%」测的是 C,不能用来否定 A 或 B。 +2. **诚实性不可让渡。** 不得为了让访谈"看起来能收敛"而放宽 `confirmation-gate.ts` 的任何 blocker,不得降低 `SEALED_MINUTE_HOLDOUT` 的门槛,不得在 `confirmation_allowed=false` 时宣称唯一分钟。这套 fail-closed 机制是本产品最值钱的资产之一,它现在正在正确工作。 +3. **不得用 holdout 调参。** `minute_rectification_holdout_v4_intake.json` 的 `production_tuning_allowed` 是 `false`,`boundary` 字段写明晋级需要新版本、通过源审计、且打分身份在盲测前冻结。任何"跑一下看看效果再调"都属于污染,一旦发生该数据集即作废。开发集用于调参,holdout 只用于一次性验证。 +4. **收敛函数是唯一判据。** 任务 1 落地后,任何模块不得再单方面决定访谈是否继续。所有约束以输入形式喂给它。 +5. **区间只能变窄或不变,不得变宽。** 这是收敛的单调性不变量,必须有属性测试守住。 +6. **状态机留在 Postgres 函数里**,沿用既有模式(`agentic_rectification_*` 的 security definer 函数)。不得把状态机搬进应用层。 +7. **不得修改既有测试断言** —— 除非该断言锁的正是本轮要改的缺陷本身;那种情况必须在断言上方注明原值与原因,并在 PROGRESS 单列。 +8. 推 staging 前必须 `./node_modules/.bin/tsc --noEmit` 通过。**不要用 `npx tsc`**,本仓库环境下会装到空包 `tsc@2.0.4`。 +9. **数据库测试必须真跑。** `npm run test:db` 需要 Docker。**没有 Docker 就不要推** —— 把环境缺口写进 `BLOCKED.md` 并停下。 +10. 不得改 `.gitea/workflows/**`。不得在有未提交改动的工作树上切分支。不得自行把 staging 提升到 main。 + +让步顺序:**诚实性不回退 > 数据不损坏 > 功能与测试不回归 > 可验证的收敛改进 > 代码整洁 > 成本**。 + +## 开工前置 + +```bash +git fetch origin --prune +git worktree add -b codex/rectification-convergence-20260830 \ + ../.worktrees/rectification-convergence-20260830 origin/staging +``` + +读 `pre_work_error_ledger.md`,跑 `scripts/pre_work_check.py`,读 `frontend/AGENTS.md`。改前在 `docs/BUG_HISTORY.md` 检索 `rectification` / 生时校正 相关记录(至少 30 条,务必读完停滞类那几条)。 + +**先读这些再动手:** + +- `references/rectification_sealed_holdout.v1.json` —— 打分器现状的唯一权威 +- `scripts/minute_rectification_fact_ranker_v4.py` —— 打分器实现 +- `scripts/minute_rectification_fact_blind_eval_v4.py` —— 盲测入口(`--manifest` 参数) +- `src/lib/rectification-agentic/v9/confirmation-gate.ts` —— fail-closed 的确认闸门 +- `src/lib/rectification-agentic/v9/candidate-plateau.ts` —— `indistinguishableWidthMinutes`,本轮要把它从否决器改成目标 +- `src/lib/rectification-agentic/v9/{decision-from-dossier,turn-decision,server-focus,method-followup}.ts` —— 当前分散的六处决策 +- `src/lib/rectification-agentic/v9/spoken-answer.ts` —— 101 条清洗正则,任务 2 要整个删掉 +- `src/mastra/agentic-rectification.ts:61-68` —— 系统提示,任务 2 要重写第 4 条 + +--- + +## 任务 0(P0,门控)· 三方盲测对照 + +### 目的 + +在同一批案例、同一套盲测协议下,测出 A(本地 Agent 执行方法学)、B(线上 v5 打分器)、C(v4 fact ranker,作为历史基线)三条链路的真实能力。**本任务的产出直接决定后续架构走向,任务 1–5 在它出数前一律不得开工。** + +### 盲测协议(不可简化) + +1. **案例集**:`minute_rectification_holdout_v3` 的 20 例。v3 作为 sealed holdout 已作废,因此本轮结果**只能作为开发期对照,不得写入 `rectification_sealed_holdout.v1.json` 的 sealed 字段,不得对外宣称为盲测认证结果**。产出物落在新的对照报告文件里。 +2. **真实出生时间必须对三条链路全程屏蔽。** 执行者(含人和 Agent)在出结果前不得接触答案。A 链路尤其要注意:**由不知道答案的人或自动化脚本驱动,不得由了解案例的人手动引导**。这是本任务唯一真正的技术难点,也是它值得做的原因。 +3. **三条链路吃完全相同的输入**:同一份出生日期、地点、声明窗口、事件列表。不得给 A 更多上下文。 +4. **打分身份先冻结**:记录三条链路各自的实现 `sha256`(B 取 `jyotish_api_server.py` 的相关函数,C 取 `minute_rectification_fact_ranker_v4.py`,A 取三份 reference md 的哈希),冻结后再跑。 +5. 每条链路每例只跑一次,**不得挑最好的一次**。失败或超时如实记为失败。 + +### 要报的指标 + +对三条链路各出一份,**区间指标是重点**,唯一分钟命中率是次要的: + +| 指标 | 说明 | +| --- | --- | +| `mean_absolute_minute_error` | 与真实时间的平均偏差 | +| `p50` / `p90` 区间宽度 | 交付物的实际精度 | +| 收窄曲线 | 证据条数 N=1..8 时的区间宽度,**这是任务 1 阈值的唯一依据** | +| `top_1_rate` / `top_3_rate` | 与历史基线可比 | +| 完成率 | 有多少例根本没收敛/超时/报错 | +| 单例成本 | A 链路的 token 成本可能显著高于 B,这直接影响 `TASK-billing-pricing` 的定价 | + +### 验收 + +- 三条链路的对照表,同一批 20 例,同一协议。 +- 盲测隔离有可复核的证据(谁跑的、答案何时揭晓、脚本或流程记录)。 +- 各链路实现 `sha256` 已记录。 +- **没有任何 holdout 文件被调参污染**;`v4_intake` 的 `production_tuning_allowed` 仍为 `false`。 +- PROGRESS 里给出明确的路线建议(结论章节的三选一),并说明依据。 + +### 停止条件 + +- 若无法建立可信的盲测隔离(例如 A 链路只能由知情者手动驱动)→ **停下**,写进 `BLOCKED.md`。一个不盲的对照结果比没有结果更危险,因为它会被当成决策依据。 +- 若三条链路的收窄曲线都不随证据增加而下降 → **停下**,问题在方法学与打分,不在交互层,任务 1–3 全部作废重议。 + +## 任务 1(P0)· 收敛判据收敛成一个纯函数 + +**前置:本任务仅在任务 0 选定路线 2(保留 B)或路线 1(改用 A)后开工,且收敛阈值必须取自任务 0 的收窄曲线。** + +### 事实 + +"该继续问 / 该出牌 / 该收摊"目前散在至少六处:`decision-from-dossier.ts`、`turn-decision.ts`、`confirmation-gate.ts`、`candidate-plateau.ts`、`method-followup.ts`、`server-focus.ts`。每一处都能单方面返回空把流程卡住。`stop occupation coverage from locking questions and the range exit` 就是两个模块互相顶死的产物。 + +### 要做什么 + +1. 新建 `src/lib/rectification-agentic/core/convergence.ts`,导出一个**纯函数**: + +``` +converge(候选集, 证据账本, 已问问题, 门闸状态) → + | { kind: "ASK", question, expectedGainMinutes } + | { kind: "NARROW", range, widthMinutes } + | { kind: "SETTLE", range, widthMinutes, confidence } + | { kind: "EXHAUSTED", reason, bestRange } + | { kind: "BLOCKED", reason } +``` + +**没有第六种返回,不得返回 null/undefined。** confirmation gate、plateau、方法覆盖、holdout 状态全部作为**输入参数**传入,不得在函数内部再去读全局或查库。 + +2. 六处现有决策改为调用它。**本任务内行为保持等价** —— 先建立唯一判据,不改判断结果,风险最低。行为要变的部分留到任务 3。 +3. `SETTLE` 的触发条件用任务 0 的实测宽度曲线定,不得用任务书里的任何数字。 + +### 属性测试(这是本任务的核心交付,不是附属品) + +在 `frontend/tests/` 下新增,至少覆盖: + +- **全域非空**:对任意合法输入组合,`converge` 必返回五种之一。用随机生成的候选集/证据组合跑,不是几个手写用例。 +- **单调性**:追加一条证据后,`widthMinutes` 只能变小或不变,**永不变大**(红线 5)。 +- **可终止**:从任意状态出发,反复喂 `ASK` 返回的问题的答案,必须在有限步内到达 `SETTLE` / `EXHAUSTED` / `BLOCKED`。**不存在无限 ASK 循环。** +- **拒答不卡死**:用户对每一个 `ASK` 都拒答时,必须到达 `EXHAUSTED`,不得停在 `ASK`。 + +这四条属性一次性锁死未来所有 stall 类 bug,比再补 20 个 case 测试有效得多。 + +### 验收 + +- 四条属性测试全绿。 +- 既有校正端到端测试全绿(本任务行为等价)。 +- 六处决策点不再各自返回空。 + +--- + +## 任务 2(P0)· 提问权收归服务端,模型降级为一句确认 + +### 事实 + +见"事实 4"。当前 `CurrentQuestionKind = "choice" | "collect_spoken"`(`turn-decision.ts:33`)两套所有权规则,加上"无持久化问题时模型自己问",共三种模式,靠系统提示区分,靠 101 条正则兜底。 + +### 要做什么 + +1. **模型永远不提问。** 它在一轮里的唯一职责:针对用户刚说的内容写**一句**确认/承接。系统提示第 4 条整条重写,删掉所有关于题干归属的条件分支。 +2. **问题永远由服务端渲染,永远出现在同一个问题区。** `choice` 与 `collect_spoken` 合并为一个"问题槽":有选项就可点,没选项就是输入框加提示。数据模型上不再区分 kind,UI 上不再有两条渲染路径。 +3. **不再有"服务端把题干接在模型正文之后"的拼接。** 一条消息 = 模型的一句确认(流式)+ 服务端问题区(结构化渲染),两者物理分离,各自有唯一作者。 +4. **删除 `src/lib/rectification-agentic/v9/spoken-answer.ts` 的 101 条清洗正则。** 模型的输出面窄到不需要清洗。如果删完发现仍需清洗,说明第 1 步没做干净,回去改第 1 步,**不要把正则加回来**。 +5. 服务端未产出问题时,问题区为空——这是一个**可断言、可监控**的显式状态,不是静默停滞。加一条服务端告警:`ASK` 之外的返回没有对应终态投影时上报。 + +### 验收 + +- 用户可见文本里不可能出现内部标识符或过程话术,且**不是靠正则保证的**,是靠模型输出面收窄保证的。 +- 端到端:连续 10 轮问答,问题始终出现在问题区,位置一致,不重复、不消失。 +- 停滞类既有测试(`rectification-collect-stall.test.ts` 等)全绿。 +- `spoken-answer.ts` 的两条大正则不再存在于代码库。 + +--- + +## 任务 3(P0)· 交付物从第 0 轮就存在 + +### 事实 + +"何时算交付完成"之所以难,是因为交付物本身没定义。BUG-031(未收敛仍永久扣费)就是这个缺口的直接后果。 + +### 要做什么 + +1. 定义**校正报告**投影,随时可读: + - 结论:区间 `[HH:MM, HH:MM]` 与代表分钟(明确标注代表性,不是唯一解) + - 置信度:基于 N 条证据的吻合率 + - 逐条证据:事件 → 支持哪个候选 → 用的哪个方法(dasha / D9 / D10 / D12…) + - 被排除的候选与排除理由 + - 局限声明(沿用 `confirmation-gate` 的 blocker 文案,不要另写一套) +2. **第 0 轮就存在**:证据 0 条时区间 = 用户声明的出生窗口。每答一题重算。 +3. 用户任何时刻可查看、可导出、可主动结束。结束即交付当前版本,不算失败。 +4. UI 给出区间收窄进度:`±120 分钟 → ±45 → ±18 → ±7`。这是用户为 ¥198 买到的东西的可视化。 +5. 计费与交付对齐:只要产生过至少一版报告,就是有效交付;`EXHAUSTED` 不再是失败态。**具体扣费金额由 `TASK-billing-pricing-20260830.md` 的任务 3 定,本任务只负责让"交付"这个事实可判定。** + +### 验收 + +- 新开的 case 在零证据时即可读出一份报告(区间 = 声明窗口)。 +- 每轮答题后区间单调收窄或不变,与任务 1 的单调性不变量一致。 +- 用户主动结束 → 拿到报告,状态是完成不是失败。 +- 与计费任务书不冲突:本任务不写任何金额。 + +--- + +## 任务 4(P1)· 解决标定数据饥荒 + +### 事实 + +`blind_holdout_eligible_case_count = 0`。v4 门槛要 20 例 AA 级公开案例,现在有 4 例且已曝光。**这是产品能不能承诺唯一分钟的唯一瓶颈,且它是数据问题,不是工程问题。** + +### 要做什么 + +做一个**免费的"验证我们"入口**:面向**已经知道自己准确出生时间**(有出生证明/医院记录)的用户。 + +1. 用户提供出生日期、地点、以及事件;**准确时间单独收集并对打分链路屏蔽**,全程不得进入候选生成或打分。 +2. 系统盲跑校正,出结果后再与真实时间比对,把偏差当场展示给用户。 +3. 每一例都是一条合格的标定数据,且用户拿到了一次免费的、可验证的能力演示。 + +这一个入口同时解决三件事:标定数据来源、最有说服力的社会证明("我们在 N 个有出生证明的案例上盲测过,中位偏差 X 分钟")、以及获客内容。 + +### 硬约束 + +- **真实时间必须在架构上不可能泄漏进打分链路**,不是靠约定。写测试锁死。 +- 入口收集的案例默认进 `intake` 队列,**晋级为冻结 holdout 必须走既有流程**:新版本号、通过源审计、打分身份在盲测前冻结(`minimum_gate` 与 `boundary` 字段已写明规则,照办)。 +- 免费入口要有独立的滥用限制,不得复用咨询的公平使用配额。 + +### 验收 + +- 真实时间泄漏的负向测试存在且通过。 +- 新案例正确落入 intake 队列,不会被自动当成 holdout。 +- 用户侧能看到"预测 vs 真实"的偏差对比。 + +--- + +## 任务 5(P2)· 清理 + +**任务 1–3 全部上线并稳定运行两周后才做。** 提前做会让回滚变难。 + +1. **两代数据模型并存**:`birth_time_rectification_*` 17 张(旧)+ `agentic_rectification_*` 16 张(新),共 31 张。逐张确认旧表是否仍被 RPC 写入,确认无引用的归档后删除。这是 50 个校正迁移的主要来源之一。 +2. **删掉永不执行的方法分支**:`method-followup.ts` 注释写明第 5 项(外貌体质)与第 6 项(胎记疤痕)`skipped; never asked`。代码里留着不问的分支只会误导后来者。 +3. **36 个 v9 模块并成 8 个**:`convergence` / `probe-catalog` / `scoring` / `evidence` / `case` / `narration` / `delivery` / `billing`。 +4. 每删一张表、每并一个模块单独一个提交,便于二分回滚。 + +### 不要动 + +打分引擎与 Swiss Ephemeris 计算、证据账本、Postgres 里的状态机、sealed holdout 机制。这些是对的。 + +--- + +## 交付 + +- PROGRESS 写进 `PROGRESS-rectification-convergence-20260830.md`,任务 0 的实测数据单列成表。 +- 每个任务一个提交。 +- 推 staging 前:`./node_modules/.bin/tsc --noEmit` 通过、`npm run lint` 无新增 error、`npm run test:db` 在 Docker 下 fail=0、`npm run build` 通过。 +- 全量 `./node_modules/.bin/tsx --test tests/*.test.ts` 的失败清单与基线逐条比对,确认无新增。