From 517df002b5b60df08093360ae2826d7c361a3447 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 7 Sep 2026 10:03:28 +0800 Subject: [PATCH] =?UTF-8?q?fix(rectification):=20narrate=20the=20credible?= =?UTF-8?q?=20range=20and=20stop=20scoring=20periods=20by=20length=20(BUG-?= =?UTF-8?q?569=E2=80=93570)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Choice copy was comparing the search window, so every answer said the range had not changed. Block-scan summed raw scores, so longer afternoon windows won before any evidence difference. Co-authored-by: Cursor --- BLOCKED.md | 17 ++ CHANGELOG.md | 4 + docs/BLOCKED.md | 21 --- docs/BUG_HISTORY.md | 32 ++++ ...S-rectification-capability-fix-20260907.md | 42 +++++ ...ification-convergence-exit-fix-20260906.md | 4 +- docs/tasks/README.md | 2 +- .../rectification-explain-layer-20260906.md | 6 +- .../rectification-agentic/v9/answer-choice.ts | 4 +- .../rectification-agentic/v9/choice-action.ts | 6 +- .../rectification-agentic/v9/probe-explain.ts | 11 +- .../src/lib/rectification-varga-sentence.ts | 8 +- .../tests/agent-voice-copy-contract.test.ts | 7 + .../tests/rectification-answer-choice.test.ts | 145 +++++++++++++++++- .../tests/rectification-choice-card.test.ts | 3 + scripts/rectification/api_service.py | 6 +- tests/test_rectification_v5_services.py | 40 +++++ 17 files changed, 312 insertions(+), 46 deletions(-) delete mode 100644 docs/BLOCKED.md create mode 100644 docs/tasks/PROGRESS-rectification-capability-fix-20260907.md diff --git a/BLOCKED.md b/BLOCKED.md index c12fe8a6..81eb5d97 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -83,3 +83,20 @@ - 无法获取任务书要求的上游 `interview_playbook.md`、`evidence_thresholds.md`:任务书所指的 `~/.workbuddy/skills/jyotish-birth-time-rectification/` 在当前执行环境不存在,仓库内只有测试对该外部路径的引用;未伪造文件,也没有可验证的上游来源可供导入。 - 无法获取一次真实本地校正会话完整记录:当前仓库没有可证明为真实线上会话的完整原始记录,执行环境也没有受控会话/上游维护者提供的记录。因此无法可靠回答轮数、最终区间宽度、`confidence` 与 `can_apply`。 - 该信息收集缺口不阻塞任务 1–3,按 v2 任务书继续实现并在进度文件中标记为未验证;不得据此声称已验证“固定题数后停止”的上游机制。 + +## BLK-001 · 长对话本地收窄后 VedAstro 调用窗口与断言不一致(2026-09-06) + +- 状态:blocked(基线即失败,非 BUG-565~567 引入) +- 首次记录:2026-09-06 +- 测试:`tests/test_active_rectification_api.py::test_long_real_conversation_reaches_vedastro_after_local_range_is_narrow` +- 复现: + +```bash +.venv/bin/python -m pytest -q tests/test_active_rectification_api.py::test_long_real_conversation_reaches_vedastro_after_local_range_is_narrow --tb=line +``` + +- 失败断言:`result["winning_segment"]` 期望 `start_time=05:07`、`end_time=05:08`、`representative_time=05:07`、`width_minutes=2`;实测 `04:16` / `04:16` / `04:16` / `width_minutes=1`。 +- 已知同样失败的 SHA:`e2f4b55c`(父任务书验收段已复跑);工作树 `43a26a3c`(文档头,Python 与 `origin/staging` 一致)同样失败。测试首次出现于 `3ca30ed7`。未做完整二分:父任务已证明早于 BUG-558~560。 +- 本单范围:只记录。不改引擎、不改门槛、不改断言来让它变绿。 +- 相关:BUG-560 根因升级(分钟级原始分区分力≈随机);`docs/tasks/TASK-rectification-convergence-exit-20260906.md` 验收段。 + diff --git a/CHANGELOG.md b/CHANGELOG.md index 367c40af..4232a22e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-07 — 答完题后的范围跟顶部一致,不清楚出生时间时不再偏爱下午 + +生时校正点完选择题后,旁白里的范围跟输入框上方那一行走,不会再把开工搜索窗口说成「范围没变」。完全不清楚出生时间时,清晨、上午、下午、傍晚、夜里五段按段内平均分来比,不会因为下午更长就先天占优。Skill 版本仍是 10.0.14。 + ## 2026-09-06 — 完全不知道出生时间时先比时段 选「完全不清楚」后可以直接开始生时校正:先用记得的经历比出清晨、上午、下午、傍晚或夜里哪一段更像,选定后再按分钟收窄。不会再劝退成「以后再做」,也不会在全日窗口上给出分钟或采用卡。家人记得的时段只作旁白建议,改不了搜索窗口。Skill 版本仍是 10.0.14。 diff --git a/docs/BLOCKED.md b/docs/BLOCKED.md deleted file mode 100644 index 95764ec5..00000000 --- a/docs/BLOCKED.md +++ /dev/null @@ -1,21 +0,0 @@ -# Blocked work - -本文件记录当前无法在本仓内闭环、也不得标成通过的验证。新条目追加在表后。不写姓名、出生资料或完整请求体。 - -## 条目 - -### BLK-001 · 长对话本地收窄后 VedAstro 调用窗口与断言不一致 - -- 状态:blocked(基线即失败,非 BUG-565~567 引入) -- 首次记录:2026-09-06 -- 测试:`tests/test_active_rectification_api.py::test_long_real_conversation_reaches_vedastro_after_local_range_is_narrow` -- 复现: - -```bash -.venv/bin/python -m pytest -q tests/test_active_rectification_api.py::test_long_real_conversation_reaches_vedastro_after_local_range_is_narrow --tb=line -``` - -- 失败断言:`result["winning_segment"]` 期望 `start_time=05:07`、`end_time=05:08`、`representative_time=05:07`、`width_minutes=2`;实测 `04:16` / `04:16` / `04:16` / `width_minutes=1`。 -- 已知同样失败的 SHA:`e2f4b55c`(父任务书验收段已复跑);本工作树 `43a26a3c`(文档头,Python 与 `origin/staging` 一致)同样失败。测试首次出现于 `3ca30ed7`。未做完整二分:父任务已证明早于 BUG-558~560。 -- 本单范围:只记录。不改引擎、不改门槛、不改断言来让它变绿。 -- 相关:BUG-560 根因升级(分钟级原始分区分力≈随机);`docs/tasks/TASK-rectification-convergence-exit-20260906.md` 验收段。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 181a5c78..47edddfb 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -8806,4 +8806,36 @@ - 复发自:无 - 修复版本:待合入 `origin/staging`(`codex/rectification-convergence-exit-fix-20260906`) +## BUG-569 | 答后旁白把搜索窗口当成可信区间,每题都说「范围没变」 + +- 状态:resolved +- 首次发现:2026-09-07 +- 最近更新:2026-09-07 +- 影响面:`composeChoiceNarration`、`applyRectificationChoice`、`probe-explain.explainRangeChange` +- 用户现象:顶部只读范围已经随答题收窄,助手旁白仍说「范围没变」。 +- 触发条件:点选区分卡 A/B/C,搜索窗口 `range_start/range_end` 不变,而 `credible_range` 变了。 +- 根因:`applyRectificationChoice` 把 `InferenceState.range_start/range_end`(开工搜索窗口)传给旁白。答题改的是 `credible_range`。直喂 `composeChoiceNarration` 的单测没走这条路径。 +- 修复:旁白改比 `previous.credible_range` 与 `applied.state.credible_range`。入参改名 `credibleBefore/credibleAfter`。任一为空则不说范围句。 +- 验证:`frontend/tests/rectification-answer-choice.test.ts`:搜索窗口固定、`credible_range` 收窄时旁白含「范围从 X 收到 Y」;`credible_range` 不变时仍说「范围没变」。源扫描锁定不再传 `range_start`。 +- 防复发:不得把 `range_start/range_end` 再传给答后范围句。新增端到端用例必须走 `applyRectificationChoice`,不能只直喂纯函数。 +- 相关记录:BUG-568 +- 复发自:过程解释层(`814c924e`)把搜索窗口误当可信区间 +- 修复版本:待合入 `origin/staging`(`codex/rectification-capability-fix-20260907`) + +## BUG-570 | 时段支持度按段内原始分求和,长时段先天占优 + +- 状态:resolved +- 首次发现:2026-09-07 +- 最近更新:2026-09-07 +- 影响面:`scripts/rectification/api_service.py::block_scan` +- 用户现象:完全不清楚出生时间时,第一张时段卡下午段支持度偏高,即使各分钟分数相同。 +- 触发条件:`stage=block_scan`、10 分钟步长扫 24 小时。五段候选数 24/24/36/30/30。 +- 根因:`relative_support` 用段内 `sum(score)`。下午 36 个候选,清晨 24 个;引擎原始分底座远大于事件差,于是在没有证据差异时也是在比时段长度。 +- 修复:`raw_support = max(mean(score in block) − min(all scores), 0)`,再归一。全部为零时五段各 20%。不进采用门,不改分钟级 `_relative_support`。 +- 验证:`tests/test_rectification_v5_services.py`:144 行同分 → 五段各 20.0;只抬高清晨 → 清晨最高,下午不再因长度领先。`minute_step=1` 指纹用例未改。 +- 防复发:`block_scan` 不得再对段内原始分求和。分钟级 prior 仍走原来的正比例归一(BUG-560)。 +- 相关记录:BUG-560 +- 复发自:unknown-time `block_scan`(`814c924e`)按时段长度偏置 +- 修复版本:待合入 `origin/staging`(`codex/rectification-capability-fix-20260907`) + diff --git a/docs/tasks/PROGRESS-rectification-capability-fix-20260907.md b/docs/tasks/PROGRESS-rectification-capability-fix-20260907.md new file mode 100644 index 00000000..13145736 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-capability-fix-20260907.md @@ -0,0 +1,42 @@ +# PROGRESS · 校正能力补齐修复单(2026-09-07) + +工作树:`.worktrees/rectification-capability-fix-20260907` +分支:`codex/rectification-capability-fix-20260907`(跟踪 `origin/staging`) +基线:`origin/staging` @ `d6ee8cbb`(含 `814c924e` BUG-565~568) + +未改 `SKILL.md`(仍 10.0.14)、未改采用门 / 确认门 / `MIN_SEPARATION_LEAD` / 分钟级 `_relative_support`、未改 `minute_step=1` 路径、未改 `page.tsx`。未把 `.venv` 加入提交。 + +本单状态:**待验收**。BUG-569 / BUG-570 已实现;P2 文案与 P3 BLOCKED 同提交。 + +| 任务 | 状态 | BUG | +| --- | --- | --- | +| 4.1 旁白比 credible_range | 完成 | BUG-569 | +| 4.2 时段支持度按段内均值 | 完成 | BUG-570 | +| 4.3 TRACK_LABEL + BLOCKED 归位 | 完成 | P2 / P3 | +| 4.4 记录 | 完成 | BUG_HISTORY / CHANGELOG / 本文件 / testing 第 2 条 | + +## 实现要点 + +- `composeChoiceNarration` 入参改为 `credibleBefore/credibleAfter`。`applyRectificationChoice` 传入 `previous.credible_range` 与 `applied.state.credible_range`。搜索窗口 `range_start/range_end` 不再进旁白。 +- `block_scan`:`raw_support = max(mean(score in block) − min(all scores), 0)`,全零时五段各 20%。`candidate_count` 保留。 +- `probe-explain.ts` 的大运标签改走 `publicRectificationMethodLabel`(`rectification-varga-sentence.ts`),不再维护第二份 TRACK_LABEL。 +- `docs/BLOCKED.md` 的 BLK-001 搬回根目录 `BLOCKED.md` 并删除前者。 + +### 既有断言改动 + +| 文件 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `rectification-answer-choice` 纯函数旁白 | `rangeBefore/rangeAfter` 直喂 | 入参改名 `credibleBefore/credibleAfter`,仍测拼句;新增 `applyRectificationChoice` 端到端 | 直喂拦不住把搜索窗口当可信区间 | +| `rectification-choice-card` why_user | 只锁「分成两组」 | 并锁 Vimshottari / Narayana,禁止音译 | P2 | + +## 测试 + +| 命令 | 结果 | +| --- | --- | +| 非 DB `rectification-*` + `consultation-*` + `report-*` + `personal-report-*` + `agent-voice-copy-contract` | **1521 pass / 0 fail**(≥ 1518) | +| `.venv/bin/python -m pytest tests/test_rectification_v5_services.py -k 'not seven_events'` | **31 pass / 0 fail**(含 BUG-570 等分网格与清晨抬高) | +| `test_block_scan_seven_events_finishes_within_fifteen_seconds` 单独跑 | 通过。整文件连跑时偶发 16.0s / 24s / 34.8s,与本次均值公式无关;机器有负载时 15s 预算偏紧 | +| `npx tsc --noEmit` | 0 错 | +| `./node_modules/.bin/eslint --quiet src tests` | 0 error | + +排除 `rectification-*-database.test.ts`。ERR-011:验收命令用 `.venv/bin/python -m pytest`。未改 `page.tsx`。未改 `minute_step=1` 指纹。 diff --git a/docs/tasks/PROGRESS-rectification-convergence-exit-fix-20260906.md b/docs/tasks/PROGRESS-rectification-convergence-exit-fix-20260906.md index d826b1d2..44b6f184 100644 --- a/docs/tasks/PROGRESS-rectification-convergence-exit-fix-20260906.md +++ b/docs/tasks/PROGRESS-rectification-convergence-exit-fix-20260906.md @@ -13,7 +13,7 @@ | 5.1 单一写入方 | 完成 | BUG-565 | | 5.2 穷尽态视为已交付 | 完成 | BUG-565 | | 5.3 穷尽分支不吞题 | 完成 | BUG-566 | -| 5.4 范围小字还原 + 记录 | 完成 | BUG-567;BUG-560 根因升级;`docs/BLOCKED.md` BLK-001 | +| 5.4 范围小字还原 + 记录 | 完成 | BUG-567;BUG-560 根因升级;`BLOCKED.md` BLK-001 | ## 实现要点 @@ -38,7 +38,7 @@ | 非 DB `tests/rectification-*.test.ts` + `agent-voice-copy-contract.test.ts` | **910 pass / 0 fail**(解释层叠上后 917) | | `npx tsc --noEmit` | 0 错 | | `npm run lint` | 0 error / 81 warning(既有 warning,未清) | -| `.venv/bin/python -m pytest tests/test_active_rectification_api.py::test_long_real_conversation_reaches_vedastro_after_local_range_is_narrow` | 失败:`winning_segment` 04:16 / 宽 1 vs 期望 05:07–05:08 / 宽 2。基线 `e2f4b55c` 同样失败。见 `docs/BLOCKED.md` BLK-001。 | +| `.venv/bin/python -m pytest tests/test_active_rectification_api.py::test_long_real_conversation_reaches_vedastro_after_local_range_is_narrow` | 失败:`winning_segment` 04:16 / 宽 1 vs 期望 05:07–05:08 / 宽 2。基线 `e2f4b55c` 同样失败。见 `BLOCKED.md` BLK-001。 | 排除 `rectification-*-database.test.ts`。ERR-011:验收命令用 `.venv/bin/python -m pytest`。未改 `page.tsx`。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 7582f6c9..5d9d54e7 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -66,7 +66,7 @@ | `TASK-rectification-explain-layer-20260906.md` | `PROGRESS-rectification-explain-layer-20260906.md` | 过程解释层(对照上游 yinduzhanxing 旧工作台唯一领先的业务层):每张卡服务端生成「为什么问这题」与 A/B/C/D「答了会怎样」、答后旁白改成「哪段升降 + 范围从 X 收到 Y」、每轮步骤条「第 N 步 / 为什么 / 下一步」;不做双视图 | 已验收(带修复单:答后旁白比的是搜索窗口,永远「范围没变」,P1) | `814c924e`;修复单 `TASK-rectification-capability-fix-20260907.md`(BUG-569) | | `TASK-rectification-range-reading-20260906.md` | `PROGRESS-rectification-range-reading-20260906.md` | 可信区间成为一等公民:采用时落库 `adopted_credible_range`,报告 `read_report_candidate_range` 与聊天 `verified_chart(accepted)` 都改读它并接同一份 `birth_time_sensitivity`(现在报告读的是开工窗口,BUG-568);引擎 >15 分钟只取 3 样本改为 ≤31 逐分钟;采用旁白加「稳定 / 随分钟变」两句;`declared_birth_window` 复用 | 已验收通过(test:db 37/0 为执行方数字,本机无 Docker) | `814c924e`(BUG-568);两条迁移待应用 | | `TASK-rectification-unknown-time-20260906.md` | `PROGRESS-rectification-unknown-time-20260906.md` | 完全不知道出生时间的两段式路线:`stage=block_scan` 以 10 分钟步长扫 24 小时只做事件计分、出五时段四选卡(不写账本不采用),选定后进现有分钟流程;引擎加 `minute_step`;开场读 `birth_time_clue`;删 intake 劝退文案 | 已验收(带修复单:时段支持度按段长偏置,下午段先天 25%,P1) | `814c924e`;修复单 `TASK-rectification-capability-fix-20260907.md`(BUG-570) | -| `TASK-rectification-capability-fix-20260907.md` | `PROGRESS-rectification-capability-fix-20260907.md` | 能力补齐修复单:答后旁白把 `range_start/range_end`(搜索窗口)当范围比较,每题都说「范围没变」;`block_scan` 五段支持度按段内原始分求和,长时段先天占优(24/24/36/30/30 个候选);`TRACK_LABEL` 音译与产品 Vimshottari/Narayana 口径不一;BLK-001 写到 `docs/BLOCKED.md` 应回根目录 | 待领取 | `codex/rectification-capability-fix-20260907`(BUG-569~570) | +| `TASK-rectification-capability-fix-20260907.md` | `PROGRESS-rectification-capability-fix-20260907.md` | 能力补齐修复单:答后旁白把 `range_start/range_end`(搜索窗口)当范围比较,每题都说「范围没变」;`block_scan` 五段支持度按段内原始分求和,长时段先天占优(24/24/36/30/30 个候选);`TRACK_LABEL` 音译与产品 Vimshottari/Narayana 口径不一;BLK-001 写到 `docs/BLOCKED.md` 应回根目录 | 待验收 | `codex/rectification-capability-fix-20260907`(BUG-569~570) | | `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/testing/rectification-explain-layer-20260906.md b/docs/testing/rectification-explain-layer-20260906.md index 148171da..7455ca0a 100644 --- a/docs/testing/rectification-explain-layer-20260906.md +++ b/docs/testing/rectification-explain-layer-20260906.md @@ -15,12 +15,12 @@ - ✅ 预期:题干下方有可点开的「为什么问这题」。展开后能看到年月或 D9/D10,以及「分成两组」或「类型差异」。没有「概率 / 置信度 / 确定」。 - ❌ 失败:没有折叠说明;或说明仍是给模型看的「时间范围锁定…语义目标是…」。 -## 2. 答完一题能看见哪段升降和范围变化 +## 2. 答完一题旁白里的范围与顶部范围一致 点 A 或 B。 -- ✅ 预期:助手旁白仍以「已记录你的选择」开头,并写哪段领先/落后;范围变了就写「范围从 … 收到 …」,没变就写「范围没变」。点 D 仍是「这题先不计分,换一件事问」。 -- ❌ 失败:仍只有「并更新了候选比较」;或范围没变却写成收窄。 +- ✅ 预期:助手旁白仍以「已记录你的选择」开头,并写哪段领先/落后;旁白里的范围与输入框上方那一行相同,变了就写「范围从 … 收到 …」,没变就写「范围没变」。点 D 仍是「这题先不计分,换一件事问」。 +- ❌ 失败:仍只有「并更新了候选比较」;或顶部范围已经收窄,旁白却写「范围没变」。 ## 3. 每轮顶部有第 N 步 / 原因 / 下一步 diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 244df842..f5a9fa9d 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -592,8 +592,8 @@ export async function applyRectificationChoice( appliedInference: persistable && scoring, answerClass, deltasByCluster: clusterScoreDeltas(previous.candidates, appliedScoreDeltas), - rangeBefore: [previous.range_start, previous.range_end], - rangeAfter: [applied.state.range_start, applied.state.range_end], + credibleBefore: previous.credible_range, + credibleAfter: applied.state.credible_range, }); const evidenceFp = dossier.latestResult?.evidenceLedgerFingerprint ?? evidenceLedgerFingerprint(dossier.evidence); diff --git a/frontend/src/lib/rectification-agentic/v9/choice-action.ts b/frontend/src/lib/rectification-agentic/v9/choice-action.ts index 2fc02cb0..81062f8a 100644 --- a/frontend/src/lib/rectification-agentic/v9/choice-action.ts +++ b/frontend/src/lib/rectification-agentic/v9/choice-action.ts @@ -102,8 +102,8 @@ export function composeChoiceNarration(input: { appliedInference: boolean; answerClass?: AnswerClass | null; deltasByCluster?: readonly ClusterScoreDelta[]; - rangeBefore?: readonly [string, string] | null; - rangeAfter?: readonly [string, string] | null; + credibleBefore?: readonly [string, string] | null; + credibleAfter?: readonly [string, string] | null; }): string { if (input.optionId === "stop") { return `已记录你的选择,并结束本次校正,交付当前可信区间和代表性工作时间。${RECTIFICATION_TERMINATION_COPY}`; @@ -119,7 +119,7 @@ export function composeChoiceNarration(input: { } if (input.appliedInference) { const movement = explainScoreMovement(input.deltasByCluster ?? []); - const range = explainRangeChange(input.rangeBefore, input.rangeAfter); + const range = explainRangeChange(input.credibleBefore, input.credibleAfter); if (movement || range) { return ["已记录你的选择", movement, range].filter(Boolean).join("。") + "。"; } diff --git a/frontend/src/lib/rectification-agentic/v9/probe-explain.ts b/frontend/src/lib/rectification-agentic/v9/probe-explain.ts index 283340e7..1e8d7394 100644 --- a/frontend/src/lib/rectification-agentic/v9/probe-explain.ts +++ b/frontend/src/lib/rectification-agentic/v9/probe-explain.ts @@ -3,6 +3,7 @@ * The model writes the stem; this module writes "why" and "what answering does". */ +import { publicRectificationMethodLabel } from "../../rectification-varga-sentence.ts"; import type { AnswerClass } from "../core/types.ts"; import type { DiscriminatingEventProbe, ProbeExpectedOutcome } from "./refinement-packet.ts"; @@ -31,11 +32,6 @@ export type ProbeUserExplain = Readonly<{ answer_impact: ProbeAnswerImpact; }>; -const TRACK_LABEL: Readonly> = { - vimshottari: "毗湿奴多利", - narayana: "那罗延", -}; - const CLOCK = /^(?:[01]\d|2[0-3]):[0-5]\d$/; export const PROBE_EXPLAIN_COPY = { @@ -91,7 +87,10 @@ function rangesForTimes( } function trackLabels(tracks: readonly string[] | undefined): string { - const labels = [...new Set((tracks ?? []).map((track) => TRACK_LABEL[track]).filter(Boolean))]; + const labels = [...new Set((tracks ?? []).flatMap((track) => { + const label = publicRectificationMethodLabel(track); + return label ? [label] : []; + }))]; return labels.join("、"); } diff --git a/frontend/src/lib/rectification-varga-sentence.ts b/frontend/src/lib/rectification-varga-sentence.ts index 5b11f2a6..7de05392 100644 --- a/frontend/src/lib/rectification-varga-sentence.ts +++ b/frontend/src/lib/rectification-varga-sentence.ts @@ -22,11 +22,17 @@ export const PUBLIC_RECTIFICATION_METHOD_LABELS: Readonly { - const label = PUBLIC_RECTIFICATION_METHOD_LABELS[method as PublicRectificationMethod]; + const label = publicRectificationMethodLabel(method); return label ? [label] : []; }))]; if (labels.length === 0) return null; diff --git a/frontend/tests/agent-voice-copy-contract.test.ts b/frontend/tests/agent-voice-copy-contract.test.ts index df6a7862..808e49a1 100644 --- a/frontend/tests/agent-voice-copy-contract.test.ts +++ b/frontend/tests/agent-voice-copy-contract.test.ts @@ -150,6 +150,13 @@ test("collect prompt skip no longer writes a second assistant turn", () => { assert.doesNotMatch(exit, /shouldPersistFocusPromptTurn/); }); +test("probe explain tracks use the shared Vimshottari / Narayana labels", () => { + const explain = readFileSync(new URL("../src/lib/rectification-agentic/v9/probe-explain.ts", import.meta.url), "utf8"); + const visible = listUserVisibleCopy().join("\n"); + assert.doesNotMatch(explain, /毗湿奴多利|那罗延/); + assert.doesNotMatch(visible, /毗湿奴多利|那罗延/); +}); + test("E-2 display copy keeps engine meaning out of instruction tone", () => { const dasha = engineMeaningToDisplayCopy( "主限更偏向 05:00,分盘大运更偏向 04:54。冲突时不能按更高把握收口。", diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts index c9e222c3..595b32d9 100644 --- a/frontend/tests/rectification-answer-choice.test.ts +++ b/frontend/tests/rectification-answer-choice.test.ts @@ -28,6 +28,7 @@ import { isIncompleteRunBanner, } from "../src/lib/rectification-agentic/v9/run-diagnostic.ts"; import { applyHoldoutAnswer, buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; +import { applyChoiceWithoutEvidence } from "../src/lib/rectification-agentic/v9/inference-adapter.ts"; import { RECTIFICATION_TERMINATION_COPY } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { containsBoundarySemantics, RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts"; import { parseV9CaseDossier, RectificationToolServiceError } from "../src/lib/rectification-agentic/v9/tool-service.ts"; @@ -193,6 +194,68 @@ function inferenceState() { }); } +function rangeNarrationInference(leadSupport: number, trailSupport: number) { + return buildInferenceState({ + range_start: "04:31", + range_end: "05:07", + candidates: [ + { id: "04:31", time: "04:31", relative_support: leadSupport }, + { id: "05:07", time: "05:07", relative_support: trailSupport }, + ], + events: [ + { id: "e1", domain: "education", year: 2016, precision: "month" }, + { id: "e2", domain: "career", year: 2018, precision: "year" }, + { id: "e3", domain: "relationship", year: 2021, precision: "year" }, + { id: "e4", domain: "family", year: 2023, precision: "year" }, + ], + probes: [{ + id: "p-cd", + semantic_key: "career.2015", + candidate_split_hash: "04:31|05:07", + domain: "career", + year: 2015, + question: "2016 年前后有没有高考或重要考试发挥失常?", + candidate_ids: ["04:31", "05:07"], + expected_outcomes: [ + { answer_class: "yes", supports: ["04:31"], conflicts: ["05:07"] }, + { answer_class: "no", supports: ["05:07"], conflicts: ["04:31"] }, + { answer_class: "unsure", supports: [], conflicts: [] }, + ], + information_gain: 0.4, + source: "dasha_boundary", + }], + }); +} + +function rangeNarrationDossier(inference: ReturnType) { + return dossierFixture({ + latestResult: candidateSnapshotFixture({ + decisionReceipt: { inference_state: inference }, + }), + conversationSummary: conversationSummaryFixture({ + activeFocus: activeFocusFixture({ + expectedAnswerSchema: { + choice: { + prompt: "2016 年前后,有没有明显高考或重要考试发挥失常?", + option_a: "是,大概就在那段时间", + option_b: "有类似,但年份不对或不够重大", + option_c: "没有明显发生", + option_d: "不记得 / 不确定", + options: [ + { key: "A", label: "是,大概就在那段时间", answer_class: "yes" }, + { key: "B", label: "有类似,但年份不对或不够重大", answer_class: "weak_yes" }, + { key: "C", label: "没有明显发生", answer_class: "no" }, + { key: "D", label: "不记得 / 不确定", answer_class: "unsure" }, + ], + }, + probe_id: "p-cd", + semantic_key: "career.2015", + }, + }), + }), + }); +} + function choiceDossier() { const inference = inferenceState(); const snapshot = candidateSnapshotFixture({ @@ -1207,8 +1270,10 @@ test("a scoring choice narrates cluster movement and range change", () => { { range: ["04:31", "04:39"], delta: 2 }, { range: ["05:00", "05:07"], delta: -2 }, ], - rangeBefore: ["04:31", "05:07"], - rangeAfter: ["04:31", "04:39"], + // 旧:rangeBefore/rangeAfter 直喂两个不同搜索窗口 → 新:入参改名 credibleBefore/credibleAfter,仍测纯函数拼句 + // 原因:直喂拦不住 applyRectificationChoice 把 range_start/range_end 当成范围 + credibleBefore: ["04:31", "05:07"], + credibleAfter: ["04:31", "04:39"], }); assert.match(narrowed, /领先/); assert.match(narrowed, /落后/); @@ -1218,8 +1283,8 @@ test("a scoring choice narrates cluster movement and range change", () => { scoring: true, appliedInference: true, deltasByCluster: [{ range: ["04:31", "04:39"], delta: 1 }], - rangeBefore: ["04:31", "04:39"], - rangeAfter: ["04:31", "04:39"], + credibleBefore: ["04:31", "04:39"], + credibleAfter: ["04:31", "04:39"], }); assert.match(unchanged, /范围没变/); const unsure = composeChoiceNarration({ @@ -1227,12 +1292,80 @@ test("a scoring choice narrates cluster movement and range change", () => { scoring: true, appliedInference: true, answerClass: "unsure", - rangeBefore: ["04:31", "04:39"], - rangeAfter: ["04:31", "04:39"], + credibleBefore: ["04:31", "04:39"], + credibleAfter: ["04:31", "04:39"], }); assert.equal(unsure, "已记录。这题先不计分,换一件事问。"); }); +test("answering A narrates the credible range even when the search window stays put", async () => { + const previous = rangeNarrationInference(10, 3); + assert.deepEqual([previous.range_start, previous.range_end], ["04:31", "05:07"]); + assert.deepEqual(previous.credible_range, ["04:31", "05:07"]); + const reduced = applyChoiceWithoutEvidence(previous, { + choiceKey: "A", + schema: { + probe_id: "p-cd", + semantic_key: "career.2015", + options: [ + { key: "A", label: "是,大概就在那段时间", answer_class: "yes" }, + ], + }, + }); + assert.equal(reduced.applied, true); + assert.deepEqual([reduced.state.range_start, reduced.state.range_end], ["04:31", "05:07"]); + assert.deepEqual(reduced.state.credible_range, ["04:31", "04:31"]); + const applied = await applyRectificationChoice(persistChoiceAccounting(rangeNarrationDossier(previous)).client, { + userId: USER_ID, + caseId: CASE_ID, + sessionId: SESSION_ID, + actionId: ACTION_ID, + action: CHOICE_ACTION, + focusId: FOCUS_ID, + questionId: QUESTION_ID, + probeId: "p-cd", + optionId: "A", + expectedRevision: previous.revision, + }); + assert.match(applied.narration, /范围从 04:31–05:07 收到 04:31–04:31/); + assert.doesNotMatch(applied.narration, /范围没变/); + const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8"); + assert.match(source, /credibleBefore:\s*previous\.credible_range/); + assert.match(source, /credibleAfter:\s*applied\.state\.credible_range/); + assert.doesNotMatch(source, /rangeBefore:\s*\[previous\.range_start/); +}); + +test("answering A says the range did not change when the credible range stays put", async () => { + const previous = rangeNarrationInference(10, 10); + assert.deepEqual(previous.credible_range, ["04:31", "05:07"]); + const reduced = applyChoiceWithoutEvidence(previous, { + choiceKey: "A", + schema: { + probe_id: "p-cd", + semantic_key: "career.2015", + options: [ + { key: "A", label: "是,大概就在那段时间", answer_class: "yes" }, + ], + }, + }); + assert.deepEqual([reduced.state.range_start, reduced.state.range_end], ["04:31", "05:07"]); + assert.deepEqual(reduced.state.credible_range, ["04:31", "05:07"]); + const applied = await applyRectificationChoice(persistChoiceAccounting(rangeNarrationDossier(previous)).client, { + userId: USER_ID, + caseId: CASE_ID, + sessionId: SESSION_ID, + actionId: ACTION_ID, + action: CHOICE_ACTION, + focusId: FOCUS_ID, + questionId: QUESTION_ID, + probeId: "p-cd", + optionId: "A", + expectedRevision: previous.revision, + }); + assert.match(applied.narration, /范围没变/); + assert.doesNotMatch(applied.narration, /范围从 /); +}); + test("the public agent route treats structured choice as a non-model command", () => { const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8"); const start = route.indexOf("if (isStructuredChoice)"); diff --git a/frontend/tests/rectification-choice-card.test.ts b/frontend/tests/rectification-choice-card.test.ts index a03e0c4f..a9d3ff37 100644 --- a/frontend/tests/rectification-choice-card.test.ts +++ b/frontend/tests/rectification-choice-card.test.ts @@ -130,6 +130,9 @@ test("choice frames ask one biographical event from a server probe, not competin assert.equal(mergeChoiceCard(frame, null), null); assert.match(frame.why_user, /2016 年前后/); assert.match(frame.why_user, /分成两组/); + assert.match(frame.why_user, /Vimshottari/); + assert.match(frame.why_user, /Narayana/); + assert.doesNotMatch(frame.why_user, /毗湿奴多利|那罗延/); assert.match(frame.answer_impact.A, /05:00/); assert.match(frame.answer_impact.A, /领先/); assert.equal(frame.answer_impact.D, "不计分,换一题"); diff --git a/scripts/rectification/api_service.py b/scripts/rectification/api_service.py index 39007d9a..529dfabe 100644 --- a/scripts/rectification/api_service.py +++ b/scripts/rectification/api_service.py @@ -427,6 +427,8 @@ def block_scan(request: RectificationRequest) -> dict[str, Any]: row for row in scored.get("candidate_scores") or [] if isinstance(row, dict) and str(row.get("time") or "")[:5] ] + day_scores = [float(row.get("score") or 0) for row in rows] + min_day = min(day_scores) if day_scores else 0.0 raw_support: list[float] = [] blocks: list[dict[str, Any]] = [] for period, start_time, end_time in BLOCK_SCAN_PERIODS: @@ -448,7 +450,9 @@ def block_scan(request: RectificationRequest) -> dict[str, Any]: "domain": event.get("domain"), "summary": event.get("summary"), }) - raw_support.append(sum(float(row.get("score") or 0) for row in members)) + member_scores = [float(row.get("score") or 0) for row in members] + mean = (sum(member_scores) / len(member_scores)) if member_scores else 0.0 + raw_support.append(max(mean - min_day, 0.0)) blocks.append({ "period": period, "start_time": start_time, diff --git a/tests/test_rectification_v5_services.py b/tests/test_rectification_v5_services.py index a55a99fb..fd9ebc64 100644 --- a/tests/test_rectification_v5_services.py +++ b/tests/test_rectification_v5_services.py @@ -908,6 +908,46 @@ class RectificationV5ServicesTest(unittest.TestCase): self.assertEqual(result["candidate_count"], 144) self.assertLessEqual(elapsed, 15, f"block_scan took {elapsed:.1f}s") + def test_block_scan_equal_scores_share_evenly_and_length_does_not_win(self): + from scripts.rectification.api_service import block_scan + + def grid(score_for): + return [ + {"time": f"{hour:02d}:{minute:02d}", "score": score_for(hour), "supporting_event_ids": []} + for hour in range(24) + for minute in range(0, 60, 10) + ] + + equal_rows = grid(lambda _hour: 12.0) + morning_rows = grid(lambda hour: 20.0 if 4 <= hour <= 7 else 10.0) + dummy = { + "result_id": "00000000-0000-4000-8000-000000000099", + "algorithm_version": "test", + "calculation_spec": {}, + "calculation_spec_hash": "abc", + "decision_receipt": {}, + } + request_body = { + "birth_date": "1998-03-15", + "start_time": "00:00", + "end_time": "23:59", + "lat": 39.9042, + "lon": 116.4074, + "tz": 8.0, + "minute_step": 10, + "events": [], + } + with patch("scripts.rectification.api_service.score_candidates", return_value={**dummy, "candidate_scores": equal_rows}): + equal = block_scan(request_body) + self.assertEqual([row["relative_support"] for row in equal["blocks"]], [20.0, 20.0, 20.0, 20.0, 20.0]) + self.assertEqual([row["candidate_count"] for row in equal["blocks"]], [24, 24, 36, 30, 30]) + with patch("scripts.rectification.api_service.score_candidates", return_value={**dummy, "candidate_scores": morning_rows}): + morning = block_scan(request_body) + shares = {row["period"]: row["relative_support"] for row in morning["blocks"]} + self.assertEqual(max(shares, key=shares.get), "early_morning") + self.assertGreater(shares["early_morning"], shares["afternoon"]) + self.assertEqual(shares["afternoon"], 0.0) + if __name__ == "__main__": unittest.main()