diff --git a/BLOCKED.md b/BLOCKED.md index b318b253..69250e6f 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -29,3 +29,9 @@ - **顺手活一律未做,登记在此:** 其一,`frontend/src/lib/skill-package-registry.ts:523` 的 `readFileSync(currentRegistryPath, "utf8")` 触发 Turbopack 构建警告「Dynamic filesystem access causes tracing of the whole project」,会把整个项目(含 `public/`)打进 server 产物,影响部署体积;升级前后都存在,与本轮无关,未改。其二,`npx eslint` 有 4 个既有 `no-unused-vars` warning,分别在 `birth-time-candidate-result.tsx:143`、`birth-time-candidate-completion.ts:10,11`、`tests/identity-auth-factory.test.ts:48`,未改。其三,`eslint-config-next` 仍是 16.2.10、与 next 16.3.1 版本号不同步,但 eslint 实测 0 error,按「不许顺手升别的依赖」未动。 - **测试环境噪声(非阻塞,已自行消化):** `frontend/tests/rectification-v9-database.test.ts` 的「v9 migration applies on a fresh database and re-applies idempotently」在全量并发下偶发失败(`database migration failed`,1 !== 0),单独重跑 7/7 通过、全量重跑 1592/1592 通过。靠 Docker 起临时 Postgres,判定为资源争用型 flake,与 Next 版本无关。未改任何测试文件。 - **文件名偏离:** 任务书要求新建 `PROGRESS.md`,但根目录已有受版本控制的 `progress.md`(1025 行)且本机文件系统大小写不敏感,写 `PROGRESS.md` 等于覆盖清单外的文件,故进度记录落在 `PROGRESS-react-compiler-20260817.md`。 + +## 生时校正收敛重构任务 0(2026-08-31,分支 `codex/rectification-convergence-impl-20260830`) + +- 无法获取任务书要求的上游 `interview_playbook.md`、`evidence_thresholds.md`:任务书所指的 `~/.workbuddy/skills/jyotish-birth-time-rectification/` 在当前执行环境不存在,仓库内只有测试对该外部路径的引用;未伪造文件,也没有可验证的上游来源可供导入。 +- 无法获取一次真实本地校正会话完整记录:当前仓库没有可证明为真实线上会话的完整原始记录,执行环境也没有受控会话/上游维护者提供的记录。因此无法可靠回答轮数、最终区间宽度、`confidence` 与 `can_apply`。 +- 该信息收集缺口不阻塞任务 1–3,按 v2 任务书继续实现并在进度文件中标记为未验证;不得据此声称已验证“固定题数后停止”的上游机制。 diff --git a/PROGRESS-rectification-convergence-20260830.md b/PROGRESS-rectification-convergence-20260830.md new file mode 100644 index 00000000..fd493f6c --- /dev/null +++ b/PROGRESS-rectification-convergence-20260830.md @@ -0,0 +1,70 @@ +# 生时校正收敛重构进度(2026-08-30) + +## 基线与范围 + +- Git 根目录:`/Users/jesse/Downloads/Copse/astrology/yinduzhanxing` +- 隔离 worktree:`/Users/jesse/Downloads/Copse/astrology/.worktrees/rectification-convergence-impl-20260830` +- 分支:`codex/rectification-convergence-impl-20260830` +- 基线:`origin/staging` @ `db6716e76c7a44adb568701dc811de52d8d29a96` +- staging 同步范围:`7db2dd2d..db6716e7 staging -> staging` +- 当前状态:已提交,未 push 或 deploy。 + +## 任务 0:上游资料与真实会话 + +当前无法完成,原因已记录在 `BLOCKED.md`:外部 `~/.workbuddy/skills/jyotish-birth-time-rectification/` 不存在,仓库没有受控的真实本地校正会话完整记录,也没有可验证的上游资料来源。因此以下三项不能被事实化回答:总轮数、最终交付区间宽度、`confidence` / `can_apply`。 + +基于 v2 任务书与当前仓库/上游代码比较,可以确认本轮落点不是更换评分器,而是恢复两个缺失的生产契约: + +1. 服务端持久化预算必须被生产判定消费,超预算仍交付候选区间并结束; +2. 第 0 轮起就要有可读的候选结果/校正报告,停下来时要说明下一步与局限。 + +尚不能确认或声称上游机制等于“问完固定题数后给出候选区间并停止”。 + +## 任务 1:预算与终止机制 + +已实现: + +- 接入服务端持久化的 `inferenceRounds`、`effectiveAnswerCount`、`plateauRounds`。 +- 复用既有预算常量:`DEFAULT_MAX_DISCRIMINATION_ROUNDS = 8`、`RECTIFICATION_POLICY.maxPlateauRounds`、`EFFECTIVE_ANSWER_SAFETY_CAP = 10`。 +- `!separation.sufficient` 分支在继续 `discriminate` 前进行预算检查;超预算统一走既有 `completeWithRange(..., "exhausted")`。 +- 未修改 `convergence-evaluator.ts`、confirmation gate blocker 或 sealed holdout 阈值。 +- 新增 `frontend/tests/rectification-convergence-budget.test.ts`,覆盖可终止、连续拒答终止、超预算交付区间、区间宽度单调性。 + +## 任务 2:提问权与问题槽 + +已完成并审核: + +- live path 不再 import/call `spoken-answer.ts`;该旧解析/拼接模块已删除。 +- 模型只输出确认/承接正文;`current_question` / `choice_card` 由服务端结构化返回。 +- UI 使用统一问题槽展示选择题和自由输入提示;缺失问题时显示可监控状态。 +- 旧历史回放保留原始 assistant 正文,不再重新解析或拼接问题。 + +## 任务 3:候选结果交代契约 + +已实现: + +- 恢复 `build_candidate_result_summary()`。 +- `score_candidates()` / `diagnostics()` 返回 `candidate_summary`、`next_step_codes`、`stability.label` 与 `rectification_report`。 +- 报告包含当前候选区间、代表分钟及“代表性候选,不是唯一解”标记、置信度、逐条事件证据状态、technique layers、被排除候选、confirmation gate blocker 文案与局限声明。 +- `events=[]` 时第 0 轮区间回退到用户声明的出生窗口;跨午夜窗口已验证可用;不生成唯一分钟结论。 +- 新增回归覆盖零证据、跨午夜窗口、事件方法、排除候选、报告一致性与 `stability.label`。 + +## 已通过验证 + +- `python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45`:pass。 +- `python3.12 -m pytest -q tests/test_active_rectification_events.py tests/test_rectification_v5_services.py`:通过(当前修改对应测试)。 +- 前端聚焦测试:79/79 通过: + `rectification-decision-authority.test.ts`、`rectification-decide-next-action.test.ts`、`rectification-inference-machine.test.ts`、`rectification-range-offer-deadend.test.ts`、`rectification-collect-stall.test.ts`、`rectification-convergence-budget.test.ts`。 +- `git diff --check`:通过。 +- 任务 2 聚焦测试:98/98 通过;相关补充测试(含问题槽 CSS 合同):38/38 通过。 +- `./node_modules/.bin/tsc --noEmit`:通过。 +- `npm run lint`:0 errors;25 个既有 warnings。 +- `npm run build`:通过;仅有既有 Turbopack filesystem tracing warnings。 +- `npm run test:db`:34/34 通过,fail=0。 +- 前端全量测试:2338/2338 通过,fail=0;使用 `PYTHON=/opt/anaconda3/bin/python3.12`,避免默认解释器缺少 PyYAML 的环境性失败。 + +## 交付状态 + +- 任务 1、任务 2、任务 3 已分别精确提交。 +- 任务 4(标定数据入口)与任务 5(清理/同步机制)按任务书明确留待后续,不在本轮实现。 +- 未 push、未 deploy;等待用户明确要求。 diff --git a/TASK-rectification-convergence-20260830.md b/TASK-rectification-convergence-20260830.md index b6db2911..06b78eab 100644 --- a/TASK-rectification-convergence-20260830.md +++ b/TASK-rectification-convergence-20260830.md @@ -1,101 +1,125 @@ -# 任务书 · 生时校正收敛重构(2026-08-30) +# 任务书 · 生时校正收敛重构(2026-08-30,v2) -基线:`origin/staging` @ `e7226bbf`。 +基线:`origin/staging` @ `7db2dd2d`。 -**下面所有行号只是线索,请按选择器/函数名定位**,后续提交可能让行号偏移。 +**下面所有行号只是线索,请按选择器/函数名定位**,后续提交可能让行号偏移。标注为「上游」的行号来自对 `/workspace/yinduzhanxing` 的一次调研,动手前请自行复核。 + +> **v2 说明**:v1 的判断是「线上打分器可能不如本地方法学,需要三方盲测决定是否换引擎」。对上游仓库 `/workspace/yinduzhanxing` 做完比对后,这个判断被推翻了。换引擎的路线已排除,本版的落地点比 v1 小一个数量级。v1 的任务 0(三方盲测)作废。 ## 为什么要做 -本轮的依据是对着代码和标定数据做的一次审计。先摆事实。 +### 事实 1 · 方法学没有丢,也没有落后 -### 事实 1 · 仓库里有三套互不相干的校正系统,被评测的那套没人用 +`/workspace/Jyotisha` 与上游 `/workspace/yinduzhanxing` 同源(共同初始提交 `a5bbba28`,2026-04-20),分叉于 `825a9ea5`(2026-07-17)。分叉后上游 808 个提交、本仓 1,275 个提交,**互相同步 0 次**。 -| | 系统 | 谁在跑 | 打分由谁做 | 评测状态 | -| --- | --- | --- | --- | --- | -| **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**。而且文件自己写明: +但生时校正方法学的三份文件 `cmp` 逐字节相同: ``` -current_tree_scorer.matches_metrics_scorer = false -current_tree_scorer.official_eval_trial_count = 0 +IDENTICAL birth-time-rectification-advanced.md +IDENTICAL birth-time-rectification-decision-tree.md +IDENTICAL birth-time-rectification-cases.md ``` -`grep` 确认 `fact_ranker_v4` 只被 `minute_rectification_fact_blind_eval_v4.py` / `minute_rectification_development_eval.py` / `conversational_minute_rectification_replay.py` 引用,**没有被 `mcp_server.py` 或前端引用过**。 +(`yinduzhanxing/references/` ↔ `Jyotisha/skills/jyotish-vedic-astrology/versions/6.9.14/references/`) -生产 skill(`skills/jyotish-birth-time-rectification/versions/10.0.9/SKILL.md`)第 29 行明确规定"全部计算与持久化只走服务端工具"、"候选范围与评分"以服务器为唯一权威——**B 链路的模型被禁止参与打分**。而 A 链路的模型是自己做方法学推理的。这是两种根本不同的架构,不是同一系统的两个版本。 +上游六周里在校正上新增的是治理、契约与复现文档,不是方法学本身。**「本地版本更新」不成立。** -**所以:线上这套(B)的真实能力,目前没有任何数据。** +### 事实 2 · 三套打分链路是同一算法家族 -### 事实 2 · 标定数据总量极小 +- `Jyotisha/scripts/rectification/scoring_service.py:11` 直接 `from scripts.active_rectification_event_engine import ...` —— 线上 v5 调用的就是上游那个事件引擎。 +- `Jyotisha/scripts/minute_rectification_fact_ranker_v4.py:16` import 同一个 `DOMAIN_CONFIG`。 +- 两边共享同一套权重:Vimshottari MD/AD/PD = `2.0 / 1.5 / 0.75`,Narayana MD/AD = `2.0 / 1.0`。 -| 数据集 | 案例数 | 状态 | -| --- | ---: | --- | -| `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 | 开发集 | +因此 `references/rectification_sealed_holdout.v1.json` 那组数字(`top_1_rate 0.15`、`mean_absolute_minute_error 6.95`、`confirmation_coverage_rate 0.0`)虽然测的是 v4,**对线上 v5 是弱但真实的先验,不能当作无关数据**。上游也没有更好的打分器可换。 -v4 的准入门槛写在 `minimum_gate` 里:`public_aa_cases: 20`,且 `production_tuning_allowed: false`。也就是说**现在没有任何一个可用于盲测的冻结 holdout**。 +### 事实 3 · 真正丢掉的是「停下来」的机制 -### 事实 3 · 访谈不收敛,是因为终止条件不可达 +上游三条链路每一条都天然终止: -近 40 个校正提交里最大的一类是停滞: +| 上游链路 | 终止方式 | +| --- | --- | +| `scripts/active_rectification_questions.py` | `QUESTION_TEMPLATES` 是**写死的 8 题**分 3 轮;问完 `workflow_status` 转 `dated_event_review_required`(上游 :537) | +| `scripts/active_rectification_events.py` | `adjudicate_candidate_rows()` **一次性批量裁决**(上游 :212),算完直接给 low/medium/high | +| `jyotish-app/rectification-engine.js` | 浏览器内一次算完给置信度标签,无追问 | -``` -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 长期停留在建立记录 +且上游**从不承诺分钟**:`can_apply` 默认 `False`、`truth_status: not_birth_time_truth` 写死在代码里。 + +Jyotisha 把这套替换成了服务端逐轮动态生成 discriminator probe 的**无界循环**。 + +### 事实 4 · 无限访谈的机械解释 + +生产判定 `frontend/src/lib/rectification-agentic/core/rectification-decision.ts:144-149`: + +```ts +if (!separation.sufficient) { + if (probe && !userStopped) return discriminate(...); // 只要有探针就继续问 + return completeWithRange(..., "offer"); +} ``` -`confirmation-gate.ts` 是 fail-closed 的:holdout 没 ready 就永不发唯一分钟确认。于是访谈只能继续问。**这些 stall 不是各自独立的 bug,是"终止条件是确认唯一分钟、而该条件当前不可达"这一个结构性矛盾的下游症状。** +**这条路径里没有任何轮次计数器。** 同时: -### 事实 4 · 一条消息有四个作者 +- `confirmation_coverage_rate: 0.0` —— sealed holdout 的 20 例里**零例**达到确认门,即门几乎不会开。 +- probe 的 `semantic_key` 按 `domain.year.month` 构造,随候选集每轮重新生成,实际不会耗尽。 -服务端决定问什么(`src/lib/rectification-agentic/v9/method-followup.ts`,2,036 行)→ 模型写正文 → 服务端把题干接在正文之后 → UI 再渲染选择卡。 +**门永远不开 + 探针永不耗尽 = 无限访谈。** 这是全部 stall 类 bug 的根因。 -于是 `src/mastra/agentic-rectification.ts` 的系统提示第 4 条要求模型判断自己处在 `choice` / `collect_spoken` / 无持久化问题 等状态中的哪一种,然后区别行动。模型做不到,所以有了 `src/lib/rectification-agentic/v9/spoken-answer.ts`:**33 条内部标识符正则 + 68 条中文过程话术正则,共 101 条模式**,在事后擦模型漏进用户可见文本的内心戏。 +### 事实 5 · 8 轮上限算了,但没人拿它当终止条件 -每一条正则都是一个曾经发生过的 bug。 +不要误以为它是死代码。生产链路确实在跑它: -### 事实 5 · 有一条与线上结论冲突的一手观察 +``` +mastra/rectification-v9-tools.ts:945 → buildCaseInferenceState + → v9/inference-adapter.ts:248 → buildInferenceState + → core/build-state.ts:162 → evaluateConvergence + → core/convergence-evaluator.ts:54 maxRounds = state.max_rounds ?? DEFAULT_MAX_DISCRIMINATION_ROUNDS + → core/types.ts:10 DEFAULT_MAX_DISCRIMINATION_ROUNDS = 8 +``` -产品侧报告:**A 链路(本地 Agent 直接执行 skill 方法学)能收敛到准确的出生时间。** +`max_rounds` 没有被传入,取默认 8,算出的 `result_status` 也写进了 state。**但 `rectification-decision.ts` 的判定不读它。** -这条观察不能被忽略,也不能直接采信,因为两件事同时成立: +所以修法**不是**「启用死代码」(去改 `convergence-evaluator.ts` 改了也没用),而是**把轮次预算接进 `rectification-decision.ts` 的判定输入**。 -- 它**可能是真的且极其重要**。如果模型执行方法学的效果好于手写的 v5 打分器,那么生产(B)建在了错误的引擎上,而这恰好能解释访谈为什么永远不收敛——B 到不了终点,整个对话引擎是在为它打补丁。 -- 它**目前不可证伪**。本地运行几乎肯定不是盲测:跑的人知道答案,或案例的声明窗口本就很窄。`holdout_v3` 被判 `invalidated_after_replay`、`v4_intake` 状态是 `exposed_awaiting_human_rereview`,说明这个仓库已经吃过一次"结果被曝光后作废"的亏。 +而且终止分支已经存在:`rectification-decision.ts:158` 就有 `completeWithRange(separation, holdout, range, "exhausted")`,`:336` 的 `kind` 枚举也已支持 `"exhausted"`。**代码路径是现成的,只是没人触发。** -**这不是一个靠讨论能解决的分歧,是一个靠对照实验能解决的问题。** 任务 0 因此改为三方盲测对照,在拿到结果之前,本任务书不预设哪条链路更好。 +另有一套完整但闲置的停机策略:`frontend/src/lib/birth-time-dynamic-stop-policy.ts:43-48`(高置信 / 答满 10 条 safety cap / 平台期 2 轮 / 零信息增益 / 重复分区),属旧链路,V9 不走它。 + +### 事实 6 · 上游同样没有盲测证据 + +`yinduzhanxing/references/real_case_calibration/timing_rectification_*_2026_07_23.json`: + +``` +blind_replay_allowed = false +ready_case_count = 0 (minimum_case_count = 3) +reviewer_2_confirmed_count = 0 +status = blocked_until_human_labels +production_tuning_allowed = false +progress.md:1611 — "zero cases ready for blind replay" +``` + +`birth-time-rectification-cases.md` 里的奥巴马/居里夫人案例,是**拿已知出生时间回验事件**并自评「吻合度 95%」——知道答案的复盘,不是校正测试。 + +**两边都没有盲测数据。产品侧「本地能收敛」的观察,最可能是定义差异**:上游问完固定题数就给候选区间并停下,那个体验就是「收敛」;按代码它给的一定是区间 + low/medium/high 标签,不是确定的分钟。 ### 结论 -**任务 0 之前不做任何架构结论。** 三条候选路线,由任务 0 的数据决定: +产品目标定为:**交付收窄后的区间,不交付唯一分钟。** 唯一分钟保留为 sealed holdout 通过后才开启的路径。 -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 函数)。不得把状态机搬进应用层。 +1. **任务 0 是门控,但它只是一次信息收集,不许写代码。** 在拿到上游访谈手册与一份真实会话记录之前,不得改动 `rectification-decision.ts` 的判定。 +2. **诚实性不可让渡。** 不得为了让访谈「看起来能收敛」而放宽 `confirmation-gate.ts` 的任何 blocker,不得降低 `SEALED_MINUTE_HOLDOUT` 门槛,不得在 `confirmation_allowed=false` 时宣称唯一分钟。这套 fail-closed 机制现在正在正确工作。 +3. **超预算的出口必须是「交付区间」,不是「失败」。** 走 `completeWithRange(..., "exhausted")`,用户拿到候选区间与明确的边界说明。任何把超预算实现成报错、静默停止或空白页的做法都是错的。 +4. **不得用 holdout 调参。** `minute_rectification_holdout_v3` 的 `source_audit_status` 是 `invalidated_after_replay`,只能当开发集看趋势,**不得作为发布指标,不得写回 sealed 字段**。`v4_intake` 的 `production_tuning_allowed` 保持 `false`。 +5. **不要引入第五套打分器。** 不要移植 `jyotish-app/rectification-engine.js` 的 40/35/15/10 权重。它信号更弱(无 Narayana / Arudha / Ashtakavarga / Shadbala)且从未评测。 +6. **状态机留在 Postgres 函数里**,沿用既有模式。不得把状态机搬进应用层。 7. **不得修改既有测试断言** —— 除非该断言锁的正是本轮要改的缺陷本身;那种情况必须在断言上方注明原值与原因,并在 PROGRESS 单列。 8. 推 staging 前必须 `./node_modules/.bin/tsc --noEmit` 通过。**不要用 `npx tsc`**,本仓库环境下会装到空包 `tsc@2.0.4`。 -9. **数据库测试必须真跑。** `npm run test:db` 需要 Docker。**没有 Docker 就不要推** —— 把环境缺口写进 `BLOCKED.md` 并停下。 +9. **数据库测试必须真跑。** `npm run test:db` 需要 Docker。没有 Docker 就不要推 —— 写进 `BLOCKED.md` 并停下。 10. 不得改 `.gitea/workflows/**`。不得在有未提交改动的工作树上切分支。不得自行把 staging 提升到 main。 让步顺序:**诚实性不回退 > 数据不损坏 > 功能与测试不回归 > 可验证的收敛改进 > 代码整洁 > 成本**。 @@ -108,103 +132,81 @@ 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 条,务必读完停滞类那几条)。 +读 `pre_work_error_ledger.md`,跑 `scripts/pre_work_check.py`,读 `frontend/AGENTS.md`。改前在 `docs/BUG_HISTORY.md` 检索校正相关记录(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 条 +- `frontend/src/lib/rectification-agentic/core/rectification-decision.ts` —— 生产判定,本轮主战场 +- `frontend/src/lib/rectification-agentic/core/convergence-evaluator.ts` + `core/build-state.ts:162` —— 已在跑但没被消费的轮次判定 +- `frontend/src/lib/birth-time-dynamic-stop-policy.ts:43-48` —— 现成但闲置的停机策略 +- `frontend/src/lib/rectification-agentic/v9/spoken-answer.ts` —— 101 条清洗正则,任务 2 要删 +- `frontend/src/mastra/agentic-rectification.ts:61-68` —— 系统提示,任务 2 要重写第 4 条 +- 上游 `/workspace/yinduzhanxing/scripts/active_rectification_events.py:115-179` —— 任务 3 要移植的 `build_candidate_result_summary()` +- 上游 `/workspace/yinduzhanxing/jyotish-app/rectification-collaboration.js` —— 四阶段状态机,参考它有多简单 --- -## 任务 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 的收窄曲线。** +## 任务 0(P0,门控,不写代码)· 拿到上游访谈手册与一份真实会话 ### 事实 -"该继续问 / 该出牌 / 该收摊"目前散在至少六处:`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` 就是两个模块互相顶死的产物。 +调研发现一个**仓库外**的独立 skill 包:`~/.workbuddy/skills/jyotish-birth-time-rectification/`,含 `references/interview_playbook.md`(访谈手册)与 `references/evidence_thresholds.md`。它不在 `yinduzhanxing` 仓库里,只有 `yinduzhanxing/tests/test_birth_time_rectification_skill_contract.py:7` 引用了这个路径。 + +产品侧「本地 Agent 能收敛」的观察,如果另有机制,最可能就在这两份文件里。 ### 要做什么 -1. 新建 `src/lib/rectification-agentic/core/convergence.ts`,导出一个**纯函数**: +1. 向上游维护者索取 `interview_playbook.md` 与 `evidence_thresholds.md`,放进 `references/upstream/` 并在 PROGRESS 记录来源与获取日期。 +2. 索取**一次真实本地校正会话的完整记录**,要能回答三个问题: + - 一共问了几轮? + - 最后交付的是一个分钟,还是一个区间?区间多宽? + - `confidence` / `can_apply` 是什么值? +3. 把答案写进 PROGRESS,并据此确认或推翻这条判断:**「上游的收敛 = 问完固定题数后给出候选区间并停止」。** -``` -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 状态全部作为**输入参数**传入,不得在函数内部再去读全局或查库。 +- 两份文件在仓库里,或明确记录「无法获取」及原因。 +- 会话记录的三个问题有答案。 +- PROGRESS 给出结论:上游的终止机制是什么,Jyotisha 要复制的具体是哪一条。 -2. 六处现有决策改为调用它。**本任务内行为保持等价** —— 先建立唯一判据,不改判断结果,风险最低。行为要变的部分留到任务 3。 -3. `SETTLE` 的触发条件用任务 0 的实测宽度曲线定,不得用任务书里的任何数字。 +### 如果拿不到 -### 属性测试(这是本任务的核心交付,不是附属品) +**不阻塞任务 1–3。** 任务 1 的修法不依赖这份手册——轮次预算该接还是要接。但拿不到这件事要写进 `BLOCKED.md`,因为它是「本地能收敛」这条观察唯一可能的另一种解释。 -在 `frontend/tests/` 下新增,至少覆盖: +--- -- **全域非空**:对任意合法输入组合,`converge` 必返回五种之一。用随机生成的候选集/证据组合跑,不是几个手写用例。 -- **单调性**:追加一条证据后,`widthMinutes` 只能变小或不变,**永不变大**(红线 5)。 -- **可终止**:从任意状态出发,反复喂 `ASK` 返回的问题的答案,必须在有限步内到达 `SETTLE` / `EXHAUSTED` / `BLOCKED`。**不存在无限 ASK 循环。** -- **拒答不卡死**:用户对每一个 `ASK` 都拒答时,必须到达 `EXHAUSTED`,不得停在 `ASK`。 +## 任务 1(P0)· 把轮次预算接进生产判定 -这四条属性一次性锁死未来所有 stall 类 bug,比再补 20 个 case 测试有效得多。 +### 事实 + +见事实 4、5。`rectification-decision.ts:144-149` 的 `!separation.sufficient` 分支只要有 probe 且用户没喊停就无限 `discriminate`。轮次判定在别处算好了但没人读。 + +### 要做什么 + +1. 给 `rectification-decision.ts` 的判定输入增加**预算三件套**,参数一律取自已有常量,**不要新造数字**: + - 轮次上限 —— `core/types.ts:10` 的 `DEFAULT_MAX_DISCRIMINATION_ROUNDS = 8` + - 平台期 —— `references/rectification_policy.v1.json` 的 `maxPlateauRounds`(当前只有 `scripts/rectification_policy.py` 读了,前端没用) + - 安全帽 —— `birth-time-dynamic-stop-policy.ts:45` 的 `effectiveAnswerCount >= 10` +2. 在 `!separation.sufficient` 分支的 `discriminate(...)` **之前**插入预算检查。超预算时走**已存在的** `completeWithRange(separation, holdout, range, "exhausted")`(:158)。 +3. **不要去改 `convergence-evaluator.ts`。** 它算得没错,问题是没人消费。要么把 `result_status` 传进判定输入,要么在判定层独立计数——二选一,在 PROGRESS 说明选了哪个及理由。 +4. 轮次计数必须**服务端持久化**,不得靠对话历史推断。沿用既有的 receipt / turn 计数来源。 + +### 属性测试(本任务的核心交付) + +在 `frontend/tests/` 新增: + +- **可终止**:从任意状态出发,反复喂 `discriminate` 返回的 probe 的答案,必须在 ≤ 预算轮数内到达某个 `completeWithRange` 分支。**不存在无限 discriminate 循环。** +- **拒答不卡死**:用户对每一个 probe 都拒答时必须到达终态,不得停在 `discriminate`。 +- **超预算即交付**:超预算时返回的是 `"exhausted"` 且带候选区间,不是错误或空结果。 +- **单调性**:追加证据后区间宽度只能变小或不变,永不变大。 + +这四条一次性锁死未来所有 stall 类 bug。 ### 验收 - 四条属性测试全绿。 -- 既有校正端到端测试全绿(本任务行为等价)。 -- 六处决策点不再各自返回空。 +- 停滞类既有测试(`rectification-collect-stall.test.ts`、`rectification-range-offer-deadend.test.ts` 等)全绿。 +- 端到端:一个用户即使一直不给有效证据,也会在预算内拿到区间并结束。 --- @@ -212,50 +214,58 @@ converge(候选集, 证据账本, 已问问题, 门闸状态) → ### 事实 -见"事实 4"。当前 `CurrentQuestionKind = "choice" | "collect_spoken"`(`turn-decision.ts:33`)两套所有权规则,加上"无持久化问题时模型自己问",共三种模式,靠系统提示区分,靠 101 条正则兜底。 +一条消息目前有**四个作者**:服务端决定问什么(`v9/method-followup.ts`,2,036 行)→ 模型写正文 → 服务端把题干接在正文之后 → UI 再渲染选择卡。 + +于是 `src/mastra/agentic-rectification.ts:66` 要求模型判断自己处在 `choice` / `collect_spoken` / 无持久化问题 三种状态中的哪一种再区别行动。模型做不到,所以有了 `v9/spoken-answer.ts`:**33 条内部标识符正则 + 68 条中文过程话术正则,共 101 条**,事后擦模型漏出的内心戏。每一条正则都是一个曾经发生过的 bug。 ### 要做什么 -1. **模型永远不提问。** 它在一轮里的唯一职责:针对用户刚说的内容写**一句**确认/承接。系统提示第 4 条整条重写,删掉所有关于题干归属的条件分支。 -2. **问题永远由服务端渲染,永远出现在同一个问题区。** `choice` 与 `collect_spoken` 合并为一个"问题槽":有选项就可点,没选项就是输入框加提示。数据模型上不再区分 kind,UI 上不再有两条渲染路径。 -3. **不再有"服务端把题干接在模型正文之后"的拼接。** 一条消息 = 模型的一句确认(流式)+ 服务端问题区(结构化渲染),两者物理分离,各自有唯一作者。 -4. **删除 `src/lib/rectification-agentic/v9/spoken-answer.ts` 的 101 条清洗正则。** 模型的输出面窄到不需要清洗。如果删完发现仍需清洗,说明第 1 步没做干净,回去改第 1 步,**不要把正则加回来**。 -5. 服务端未产出问题时,问题区为空——这是一个**可断言、可监控**的显式状态,不是静默停滞。加一条服务端告警:`ASK` 之外的返回没有对应终态投影时上报。 +1. **模型永远不提问。** 一轮里它的唯一职责是针对用户刚说的内容写**一句**确认/承接。系统提示第 4 条整条重写,删掉所有关于题干归属的条件分支。 +2. **问题永远由服务端渲染,永远出现在同一个问题区。** `choice` 与 `collect_spoken` 合并成一个「问题槽」:有选项就可点,没选项就是输入框加提示。数据模型不再区分 kind,UI 不再有两条渲染路径。 +3. **取消「服务端把题干接在模型正文之后」的拼接。** 一条消息 = 模型的一句确认(流式)+ 服务端问题区(结构化渲染),两者物理分离,各自唯一作者。 +4. **删掉 `spoken-answer.ts` 的 101 条清洗正则。** 如果删完发现仍需清洗,说明第 1 步没做干净,回去改第 1 步,**不要把正则加回来**。 +5. 服务端未产出问题时问题区为空 —— 这是可断言、可监控的显式状态,不是静默停滞。加服务端告警。 ### 验收 -- 用户可见文本里不可能出现内部标识符或过程话术,且**不是靠正则保证的**,是靠模型输出面收窄保证的。 -- 端到端:连续 10 轮问答,问题始终出现在问题区,位置一致,不重复、不消失。 -- 停滞类既有测试(`rectification-collect-stall.test.ts` 等)全绿。 +- 用户可见文本不可能出现内部标识符或过程话术,且**不是靠正则保证**,是靠模型输出面收窄保证。 +- 端到端连续 10 轮,问题始终在问题区,位置一致,不重复、不消失。 - `spoken-answer.ts` 的两条大正则不再存在于代码库。 --- -## 任务 3(P0)· 交付物从第 0 轮就存在 +## 任务 3(P0)· 恢复「停下来时怎么交代」的契约,交付物从第 0 轮就存在 ### 事实 -"何时算交付完成"之所以难,是因为交付物本身没定义。BUG-031(未收敛仍永久扣费)就是这个缺口的直接后果。 +上游 `active_rectification_events.py:115-179` 有 `build_candidate_result_summary()`,产出四个面向用户的 next-step code: + +``` +collect_at_least_five_events +resolve_candidate_tie_or_narrow_window +narrow_window_before_minute_claim +do_not_apply_as_birth_time_truth +``` + +**Jyotisha 把这整段删了。** 于是访谈终止时没有话术锚点,只能继续问。 + +上游的降级阈值 Jyotisha 其实完整继承在 `references/rectification_policy.v1.json`(4 事件 / 3 域 / 5 分钟宽度 / 20% margin)——**判据没丢,丢的是「达不到时说什么」。** ### 要做什么 -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 定,本任务只负责让"交付"这个事实可判定。** +1. 把上游的 `build_candidate_result_summary()` 恢复到 `Jyotisha/scripts/active_rectification_events.py`,让 `scripts/rectification/api_service.py` 的 `score_candidates()` 返回体带上 `next_step_codes` 与 `stability.label`。 +2. 定义**校正报告**投影,随时可读:结论区间 + 代表分钟(明确标注为代表性、非唯一解)、置信度、逐条证据(事件 → 支持哪个候选 → 用哪个方法)、被排除的候选与理由、局限声明(复用 `confirmation-gate` 的 blocker 文案,不要另写一套)。 +3. **第 0 轮就存在**:证据 0 条时区间 = 用户声明的出生窗口。每答一题重算。 +4. 用户任何时刻可查看、可导出、可主动结束。结束即交付当前版本,**不算失败**。 +5. UI 给出区间收窄进度(`±120 → ±45 → ±18 → ±7`)。这是用户为这笔钱买到的东西的可视化。 +6. 计费与交付对齐:产生过至少一版报告即为有效交付,`exhausted` 不是失败态。**具体金额由 `TASK-billing-pricing-20260830.md` 决定,本任务不写任何金额。** ### 验收 -- 新开的 case 在零证据时即可读出一份报告(区间 = 声明窗口)。 -- 每轮答题后区间单调收窄或不变,与任务 1 的单调性不变量一致。 -- 用户主动结束 → 拿到报告,状态是完成不是失败。 -- 与计费任务书不冲突:本任务不写任何金额。 +- 新 case 在零证据时即可读出一份报告。 +- 每轮答题后区间单调收窄或不变。 +- 用户主动结束或超预算 → 拿到报告,状态是完成不是失败。 +- `next_step_codes` 出现在服务端返回体里,并被终止话术使用。 --- @@ -263,50 +273,54 @@ converge(候选集, 证据账本, 已问问题, 门闸状态) → ### 事实 -`blind_holdout_eligible_case_count = 0`。v4 门槛要 20 例 AA 级公开案例,现在有 4 例且已曝光。**这是产品能不能承诺唯一分钟的唯一瓶颈,且它是数据问题,不是工程问题。** +两边都没有盲测数据(事实 6)。Jyotisha 的 `v4_intake` `blind_holdout_eligible_case_count = 0`,上游 `ready_case_count = 0`。这是产品能不能承诺唯一分钟的唯一瓶颈,**且它是数据问题不是工程问题**。 + +上游停在这里六周了,指望它自己解决不现实。 ### 要做什么 -做一个**免费的"验证我们"入口**:面向**已经知道自己准确出生时间**(有出生证明/医院记录)的用户。 +做一个**免费的「验证我们」入口**,面向已经知道自己准确出生时间(有出生证明/医院记录)的用户: -1. 用户提供出生日期、地点、以及事件;**准确时间单独收集并对打分链路屏蔽**,全程不得进入候选生成或打分。 +1. 用户提供出生日期、地点、事件;**准确时间单独收集并对打分链路屏蔽**,全程不得进入候选生成或打分。 2. 系统盲跑校正,出结果后再与真实时间比对,把偏差当场展示给用户。 -3. 每一例都是一条合格的标定数据,且用户拿到了一次免费的、可验证的能力演示。 +3. 每一例都是一条合格标定数据,用户同时拿到一次免费的、可验证的能力演示。 -这一个入口同时解决三件事:标定数据来源、最有说服力的社会证明("我们在 N 个有出生证明的案例上盲测过,中位偏差 X 分钟")、以及获客内容。 +一个入口同时解决三件事:标定数据来源、最有说服力的社会证明(「我们在 N 个有出生证明的案例上盲测过,中位偏差 X 分钟」)、获客内容。 ### 硬约束 -- **真实时间必须在架构上不可能泄漏进打分链路**,不是靠约定。写测试锁死。 -- 入口收集的案例默认进 `intake` 队列,**晋级为冻结 holdout 必须走既有流程**:新版本号、通过源审计、打分身份在盲测前冻结(`minimum_gate` 与 `boundary` 字段已写明规则,照办)。 +- **真实时间必须在架构上不可能泄漏进打分链路**,不是靠约定。写负向测试锁死。 +- 案例默认进 intake 队列,晋级为冻结 holdout 必须走既有流程:新版本号、通过源审计、打分身份在盲测前冻结。 - 免费入口要有独立的滥用限制,不得复用咨询的公平使用配额。 +- 与上游共享数据前先对齐口径,避免两边各建一套互不承认的标定集。 ### 验收 - 真实时间泄漏的负向测试存在且通过。 - 新案例正确落入 intake 队列,不会被自动当成 holdout。 -- 用户侧能看到"预测 vs 真实"的偏差对比。 +- 用户侧能看到「预测 vs 真实」的偏差对比。 --- -## 任务 5(P2)· 清理 +## 任务 5(P2)· 清理与同步机制 -**任务 1–3 全部上线并稳定运行两周后才做。** 提前做会让回滚变难。 +**任务 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. 每删一张表、每并一个模块单独一个提交,便于二分回滚。 +1. **两代数据模型并存**:`birth_time_rectification_*` 17 张 + `agentic_rectification_*` 16 张 = 31 张,是 50 个校正迁移的主要来源。逐张确认旧表是否仍被 RPC 写入,无引用的归档删除。 +2. **删掉永不执行的分支**:`method-followup.ts` 注释写明第 5 项(外貌体质)与第 6 项(胎记疤痕)`skipped; never asked`。 +3. **`MIN_SEPARATION_LEAD` 与上游口径对齐**:`core/candidate-separation.ts:7` 当前是绝对分差 8;上游 `active_rectification_events.py:256` 用的是相对 margin(< 10% 降级)。改成相对量后用 `minute_rectification_holdout_v3` 的 20 例看 `not_separated` 比例变化 —— **只看趋势,不作发布指标**(红线 4)。 +4. **建立与上游的同步机制。** 六周零同步是本轮很多问题的背景。约定一个节奏(例如每月一次),至少同步 `references/`、`scripts/active_rectification_*`、方法学 md。这不是代码任务,是流程约定,写进 `AGENTS.md`。 +5. 每删一张表、每并一个模块单独一个提交,便于二分回滚。 ### 不要动 -打分引擎与 Swiss Ephemeris 计算、证据账本、Postgres 里的状态机、sealed holdout 机制。这些是对的。 +打分引擎与 Swiss Ephemeris 计算、证据账本、Postgres 里的状态机、sealed holdout 机制、`rectification_policy.v1.json` 的阈值。这些都是对的。 --- ## 交付 -- PROGRESS 写进 `PROGRESS-rectification-convergence-20260830.md`,任务 0 的实测数据单列成表。 +- 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` 的失败清单与基线逐条比对,确认无新增。 diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index b53351bc..f8054a91 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -15,7 +15,6 @@ import { applyCollectFocusDenial, ensureNonTerminalTurnExit, persistNextInterviewIfIdle, - persistCollectSpokenAssistantIfNew, } from "@/lib/rectification-agentic/v9/answer-choice"; import { mapRectificationRpcError } from "@/lib/rectification-agentic/v9/case-service"; import { CHOICE_ACTION, STOP_ACTION } from "@/lib/rectification-agentic/v9/choice-action"; @@ -674,14 +673,12 @@ export async function POST(request: Request) { send({ type: "error", message: "生时校正暂时不可用,请稍后重试。" }); } else { if (action === "message") { - let idleHostNarration: string | null = null; try { const idle = await persistNextInterviewIfIdle({ accounting: accounting as never, userId, caseId, }); - idleHostNarration = idle.hostNarration; } catch (error) { console.warn( `[rectification-v9] persist next interview after turn failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`, @@ -693,29 +690,11 @@ export async function POST(request: Request) { userId, caseId, }); - idleHostNarration = exit.hostNarration ?? idleHostNarration; } catch (error) { console.warn( `[rectification-v9] nonterminal turn exit repair failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`, ); } - try { - const fallback = await persistCollectSpokenAssistantIfNew({ - accounting: accounting as never, - userId, - caseId, - requestId: crypto.randomUUID(), - answerText: result.answerText, - previousFocusId: result.previousFocusId, - alreadyEmitted: result.collectSpokenEmitted, - hostNarration: idleHostNarration, - }); - if (fallback) send({ type: "answer.delta", text: fallback }); - } catch (error) { - console.warn( - `[rectification-v9] collect spoken visibility fallback failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`, - ); - } } send({ type: "done", emitted: true }); } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 7cca7c46..302ff338 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -2942,6 +2942,30 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class font-size: var(--type-caption); } +.rectification-question-slot { + display: grid; + gap: var(--space-2); + margin-block: var(--space-3); +} +.rectification-question-slot__spoken { + display: grid; + gap: var(--space-1); + padding: var(--space-3) var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-canvas-soft); +} +.rectification-question-slot__prompt { + margin: 0; + color: var(--color-ink); + font-weight: 600; +} +.rectification-question-slot__hint, +.rectification-question-slot__status { + margin: 0; + color: var(--color-ink-secondary); + font-size: var(--type-caption); +} .rectification-snapshot { display: grid; gap: var(--space-3); diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index bc42c162..5785a7ee 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -55,7 +55,7 @@ import { stableChoiceActionKey, type ChoiceOptionId, } from "@/lib/rectification-agentic/v9/choice-action"; -import { finalizeRectificationSpokenAndThinking } from "@/lib/rectification-agentic/v9/spoken-answer"; +import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status"; import { isPersistedFocusId, parseRectificationChoiceCard, @@ -95,6 +95,23 @@ type PersistedTurn = Readonly<{ }>; type CandidateResult = RectificationCandidateResult | null; +type CurrentQuestionModel = Readonly<{ + kind: "choice" | "collect_spoken"; + prompt: string | null; +}>; + +function currentQuestionFromSnapshot(value: unknown): CurrentQuestionModel | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const question = value as { kind?: unknown; prompt?: unknown }; + if (question.kind !== "choice" && question.kind !== "collect_spoken") return null; + return { + kind: question.kind, + prompt: typeof question.prompt === "string" && question.prompt.trim() + ? question.prompt.trim() + : null, + }; +} + function RectificationCandidateCards({ result, @@ -177,10 +194,6 @@ type RenderMessage = ChatMessageView & { choiceAttachment?: SettledChoiceAttachment; }; -function turnOfferedSelection(message: RenderMessage): boolean { - return Boolean(message.completedReceipt?.steps.includes("rectification-offer-candidates")); -} - function completedReceiptFromPersisted(receipt: PersistedTurn["receipt"]): CompletedActivityReceiptView { if (!receipt) return { steps: [], methods: [] }; if (Array.isArray(receipt.tool_activities)) { @@ -232,11 +245,10 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag const raw = failed ? "" : turn.text ?? ""; if (failed && !raw) return []; if (isIncompleteRunBanner(raw)) return []; - const split = raw ? finalizeRectificationSpokenAndThinking(raw) : { thinking: "", spoken: raw }; const completedReceipt = completedReceiptFromPersisted(turn.receipt); return [{ role: "assistant", - text: split.spoken, + text: raw, renderKey: key, state: turn.status === "completed" || failed ? "settled" : "thinking", completedReceipt, @@ -286,6 +298,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null); const [candidateResult, setCandidateResult] = useState(null); const [choiceCard, setChoiceCard] = useState(null); + const [currentQuestion, setCurrentQuestion] = useState(null); + const [caseStatus, setCaseStatus] = useState(null); + const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(false); const [acceptingCandidateId, setAcceptingCandidateId] = useState(null); const [feedback, setFeedback] = useState>({}); const [copiedMessageKey, setCopiedMessageKey] = useState(null); @@ -309,6 +324,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const [boardDiff, setBoardDiff] = useState(() => diffRectificationBoard(null, null)); const boardId = useId(); const boardTitleId = useId(); + const questionHintId = useId(); useLayoutEffect(() => { const query = window.matchMedia(`(max-width: ${RECTIFICATION_BOARD_SPLIT_MIN_PX - 1}px)`); @@ -387,16 +403,28 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { // from parsing agent text or hidden sentinels. const applyCaseSnapshot = useCallback((payload: { latest_result?: unknown; + current_question?: unknown; choice_card?: unknown; - case?: { accepted_time?: unknown; confirmed_time?: unknown }; + case?: { + status?: unknown; + accepted_time?: unknown; + confirmed_time?: unknown; + }; } | null) => { if (!payload) return; const nextCandidate = parseRectificationCandidateResult(payload.latest_result); + const nextQuestion = currentQuestionFromSnapshot(payload.current_question); const nextChoice = parseRectificationChoiceCard(payload.choice_card); + const nextCaseStatus = isRectificationCaseStatus(payload.case?.status) + ? payload.case.status + : null; const acceptedTime = typeof payload.case?.accepted_time === "string" ? payload.case.accepted_time : null; const confirmedTime = typeof payload.case?.confirmed_time === "string" ? payload.case.confirmed_time : null; setCandidateResult(nextCandidate); + setCurrentQuestion(nextQuestion); setChoiceCard(nextChoice); + setCaseStatus(nextCaseStatus); + setCaseSnapshotLoaded(true); if (confirmedTime) { setSavedTime(confirmedTime); setSavedStatus("confirmed"); @@ -1025,19 +1053,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { ? [message.choiceAttachment.card.question_id] : []), ); - const liveChoiceHost = [...messages] - .reverse() - .find((message) => ( - message.role === "assistant" - && message.state === "settled" - && Boolean(message.text) - && !message.choiceAttachment - )); const showLiveChoiceCard = Boolean( - choiceCard - && liveChoiceHost + currentQuestion?.kind === "choice" + && choiceCard && !answeredQuestionIds.has(choiceCard.question_id) - && !turnOfferedSelection(liveChoiceHost) && !busy && !readonly && regeneratingMessageKey === null, @@ -1058,9 +1077,26 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const selectionCardMessageKey = showSelectionCards && latestSettledAssistant ? latestSettledAssistant.renderKey : undefined; - const liveChoiceMessageKey = showLiveChoiceCard && liveChoiceHost - ? liveChoiceHost.renderKey - : undefined; + const collectSpokenPrompt = currentQuestion?.kind === "collect_spoken" + ? currentQuestion.prompt + : null; + const resumableCase = caseStatus !== null && isResumableStatus(caseStatus); + const showMissingQuestion = Boolean( + caseSnapshotLoaded + && resumableCase + && !readonly + && !busy + && currentQuestion === null, + ); + const showUnavailableQuestion = Boolean( + caseSnapshotLoaded + && resumableCase + && !readonly + && !busy + && currentQuestion !== null + && ((currentQuestion.kind === "choice" && !choiceCard) + || (currentQuestion.kind === "collect_spoken" && !collectSpokenPrompt)), + ); const canSend = !busy && !readonly && !regeneratingMessageKey; function submitChoice(key: ChoiceKey) { @@ -1125,10 +1161,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { } : message; const settledChoice = message.choiceAttachment; - const liveChoice = showLiveChoiceCard && message.renderKey === liveChoiceMessageKey - ? choiceCard - : null; - const visibleChoiceCard = settledChoice?.card ?? liveChoice; + const visibleChoiceCard = settledChoice?.card; const vargaSentence = !message.failed ? vargaSentenceFromMethods(message.completedReceipt?.methods) : null; @@ -1173,7 +1206,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { ? `${visibleChoiceCard.question_id}:answered:${settledChoice.selectedKey}` : `${visibleChoiceCard.question_id}:${choiceNonce}`} card={visibleChoiceCard} - pending={Boolean(liveChoice) && busy} + pending={false} disabled={readonly || Boolean(settledChoice)} selectedKey={settledChoice?.selectedKey ?? ""} onSelect={submitChoice} @@ -1183,6 +1216,37 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { ); })} +
+ {showLiveChoiceCard && choiceCard && ( + + )} + {collectSpokenPrompt && ( +
+

{collectSpokenPrompt}

+

+ 请在下方输入框回答,可以写你记得的年份、经过和结果。 +

+
+ )} + {showMissingQuestion && ( +

+ 当前没有可回答的问题,正在等待服务端更新。 +

+ )} + {showUnavailableQuestion && ( +

+ 当前问题暂时无法显示,请等待服务端更新。 +

+ )} +
{savedTime && savedStatus === "confirmed" && (

已确认校正时间:{savedTime} @@ -1221,13 +1285,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {