From e4f9f3ee0f35d2439c6d72613b2ee8605b6af083 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 15 Sep 2026 17:13:52 +0000 Subject: [PATCH] =?UTF-8?q?docs(tasks):=20=E6=A0=A1=E6=AD=A3=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=E5=AE=A1=E8=AE=A1=E5=9B=9B=E5=8D=95=EF=BC=88=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E8=AE=B0=E5=BF=86=E5=8C=96=20/=20=E6=95=85=E9=9A=9C?= =?UTF-8?q?=E5=BD=92=E5=9B=A0=20/=20=E6=B8=B2=E6=9F=93=E6=8B=86=E5=88=86?= =?UTF-8?q?=20/=20=E6=A1=A3=E6=A1=88=E7=BC=93=E5=AD=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 只读审计 origin/staging @ 6b3248bf 后出的四份任务书: - engine-memoization(BUG-721,纯 Python 可并行):一次重算 45% CPU 在重复 算同一份 Shadbala,过境盘按候选算了 2196 次(应 36),鉴别探针算两遍, _cached_rows 是死代码。本机等价实验 3358 → 1604 ms,candidate_scores 与 decision_receipt 逐字相同。只做记忆化,不改算法。 - failure-attribution(BUG-722/723/724,独占 route.ts):分类器失败被说成 用户说不清且丢证据;引擎 429 被当成引擎坏、不重试不打日志(复发自 BUG-715);attempt 210s × 2 > maxDuration 240s(复发自 BUG-059)。 - settled-render-split(BUG-725,前端可并行):校正会话流式每帧重渲整条 对话并重跑已结算消息的 Markdown;BUG-473 的咨询面拆分没有跟过来。 - request-dossier-cache(BUG-726,串行在 failure-attribution 之后):一轮 取 3.44 次整份 Case 档案,改成写即失效的请求作用域缓存,零调用点改动。 纯文档推送,不触发门禁、不发布镜像、不部署。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45 --- docs/tasks/README.md | 4 + ...ctification-engine-memoization-20260915.md | 170 +++++++++++++++ ...tification-failure-attribution-20260915.md | 199 ++++++++++++++++++ ...fication-request-dossier-cache-20260915.md | 131 ++++++++++++ ...ification-settled-render-split-20260915.md | 148 +++++++++++++ 5 files changed, 652 insertions(+) create mode 100644 docs/tasks/TASK-rectification-engine-memoization-20260915.md create mode 100644 docs/tasks/TASK-rectification-failure-attribution-20260915.md create mode 100644 docs/tasks/TASK-rectification-request-dossier-cache-20260915.md create mode 100644 docs/tasks/TASK-rectification-settled-render-split-20260915.md diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 8e4e4abe..97c213ab 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -234,6 +234,10 @@ | `TASK-api-server-decomposition-20260916.md` | `PROGRESS-api-server-decomposition-20260916.md` | **重构单(串行在 qizheng 单之后)**:把业务逻辑搬出 `JyotishAPIHandler`。核心不是行数,是全仓 3 处靠 `JyotishAPIHandler.__new__` 伪造空壳 handler 借方法(`consultation_workflow_service` ×2、`capture_report_blocked_repairs_golden`、`local_accuracy_report`,MCP 也走这条),依赖方向反了、handler 没有 `headers`/`wfile` 随时可炸。四阶段:拆 `__new__` 后门 → 抽 ≥150 行业务方法 → `do_POST`/`do_GET` 改路由表 → 重新冻结行数 baseline(余量 300→50)。纯搬运不改行为,`test_api_server_security.py` 3841 行断言一条不许改。预计 11,314 → 约 9,230 行。BUG 段 710+ | 待领取 | — | | `TASK-chart-vedastro-decouple-20260915.md` | `PROGRESS-chart-vedastro-decouple-20260915.md` | **P0**:星盘页首屏那一发 `/api/chart` 没传 `skip_vedastro_main_entry_overview`,实测冷算 0.40–0.66 秒里约 0.36 秒是 VedAstro 空转(本机连 endpoint 都没配);生产 env 开着 network + fanout,等于首屏同步等 24 个外部请求 + 3 次领域扫描,而 `chart-view-mapper.ts` / `chart-view-contract.ts` 根本不读这份证据。星历页同端点传了标志,两页策略相反。BUG-718,**复发自 BUG-161**(前台请求不得同步串联可选外部证据)。串行在 chart-page-blocking-open 之后 | 待验收 | `codex/chart-vedastro-decouple-20260915` | | `TASK-vedastro-runtime-ops-20260915.md` | `PROGRESS-vedastro-runtime-ops-20260915.md` | 运行期真相单(与上单并行,文件不重叠;**不得改 `jyotish_api_server.py`**):官方 `vedastro==1.23.25` 其实是 REST 客户端(46 KB,全打 `api.vedastro.org`),且 import 时请求 pypi 并 `pip install --upgrade` 自升级——本机实测 pin 装完一 import 就变 1.23.26,`requirements.txt` 的锁在运行期是假的(BUG-719);无 key 时免费层排队是同步 sleep + 全局锁,24 个请求 ≈ 4.8 分钟堵住前台线程(BUG-720,定级依赖生产 key 是否配置)。生产 env 核对清单在 `docs/testing/vedastro-runtime-20260915.md`,**只能由产品负责人执行**。台账 ERR-107 / ERR-108 | 待验收 | `codex/vedastro-runtime-ops-20260915` | +| `TASK-rectification-engine-memoization-20260915.md` | — | **性能单(纯 Python,独占引擎三文件,可并行)**:一次重算 45% 的 CPU 是重复算同一份 Shadbala——`build_candidate_static_context` 每个候选分钟已算过一次却只留哈希、丢掉结果,`_candidate_row` 在「候选 × 事件 × 采样日期」最内层再算 36 遍(实测 2196 次 vs 应 61 次)。过境盘只依赖事件日期却按候选算 2196 次(应 36);鉴别探针一次请求算两遍;`_cached_rows` 是死代码。本机等价实验 3358 → 1604 ms(**快 53%**),`candidate_scores` 与 `decision_receipt` 逐字相同(唯一差异是计时字段)。**只做记忆化,不改算法**;year 精度采样 12 个月不在范围。BUG 段 721 | 待领取 | — | +| `TASK-rectification-failure-attribution-20260915.md` | — | **三处把系统故障说成别的东西(独占 `route.ts`)**:意图分类器两次异常返回的 `null` 与用户真的「说不清」共用一条分支,回一句「我不太确定这句是不是在回答上面的问题」,**用户这句里的经历直接丢弃且不写证据**(BUG-722,采集题分支早已改对、点选题分支没跟上);引擎 429(`ERR_COMPUTE_BUSY` + `Retry-After`)被压成 `engine_request_failed`,不重试不打日志,证据记下了但范围不动、模型照说「记下了」(BUG-723,**复发自 BUG-715**);attempt 210s × 2 = 420s > 路由 `maxDuration` 240s,重试必超预算(BUG-724,**复发自 BUG-059**,BUG-388 的防复发只写了单次尝试)。超时改成整轮一个预算,不砍 attempt 也不提 240 | 待领取 | — | +| `TASK-rectification-settled-render-split-20260915.md` | — | **前端性能单(独占校正会话组件,可并行)**:`rectification-agentic-chat.tsx` 1973 行、`useMemo` 0 个、`memo` 0 个,`messages.map` 内联在组件体里且逐条新建时间轴数组与 choice card,`ChatMessageRow` 无 memo、结算态 Markdown 走没有缓存的 `renderProse`。流式每帧(~60/s)重渲整条会话并重跑每条已结算消息的 Markdown。BUG-473 在本文件只落地了 `stream-frame-buffer`,咨询面的 `SettledMessageList` + `HistoryMessageEntry` 拆分没有跟过来。**零行为变化**;验收必须有按帧驱动的渲染计数断言(照 `home-streaming-render-split.test.ts`)。BUG 段 725 | 待领取 | — | +| `TASK-rectification-request-dossier-cache-20260915.md` | — | **低风险单,串行在 failure-attribution 之后(同改 `route.ts`)**:一轮 Agent 对话实测取 3.44 次整份 Case 档案(点选题 2.07 次),全仓约 40 个调用点、请求内零缓存;档案是「最近 50 轮 turns + 全部 evidence + 合成收据」的大 jsonb。做法是包装 `accounting` 客户端做**写即失效**的请求作用域缓存(两个只读投影命中缓存,其余任何 RPC 先清空再转发),**零调用点改动**。不得做成「请求内只读一次」——档案在请求内会变。BUG 段 726 | 待领取 | — | ## 命名与归档 diff --git a/docs/tasks/TASK-rectification-engine-memoization-20260915.md b/docs/tasks/TASK-rectification-engine-memoization-20260915.md new file mode 100644 index 00000000..d6ac7609 --- /dev/null +++ b/docs/tasks/TASK-rectification-engine-memoization-20260915.md @@ -0,0 +1,170 @@ +# TASK · 重算里一半 CPU 在重复算同一件事(纯 Python,不改任何结果) + +- 日期:2026-09-15 +- 基线 commit:`origin/staging` @ `6b3248bf` +- 执行分支:`codex/rectification-engine-memoization-20260915` +- 独占文件:`scripts/active_rectification_event_engine.py`、`scripts/rectification/scoring_service.py`、`scripts/rectification/refinement_packet.py` +- 与本日其它三单**无文件重叠**,可并行 +- 规模:三处记忆化 + 删一段死代码。**输出必须逐字不变。** + +--- + +## 1. 为什么现在做 + +生时校正每记一件证据、每答一道题,都要打一次 `POST /api/rectification/v5/score`,用户在那里干等。本机实测(Python 3.13 + swisseph,生产 2 vCPU 只会更慢): + +| 场景 | 候选分钟 | 事件数 | 日期精度 | 耗时 | +| --- | ---: | ---: | --- | ---: | +| 开场整日扫描(`minute_step=10`) | 144 | 3 | 到月 | 3 578 ms | +| 采集中期 · 60 分钟窗 | 61 | 6 | 到月 | 3 387 ms | +| 采集中期 · 30 分钟窗 | 31 | 15 | 到月 | 2 458 ms | +| 收敛后 · 20 分钟窗 | 31 | 20 | 到月 | 2 175 ms | +| 同一批事件,只给年份 | 31 | 10 | 只到年 | 4 908 ms | +| 同一批事件,给到月 | 31 | 10 | 到月 | 1 891 ms | + +生产主机 2 vCPU,重算闸门并发 2、饱和直接 429 不排队,`domain_calculation_service._SWISSEPH_LOCK` 又把所有排盘串行,加上 GIL,有效吞吐约等于一次一个。**把下面这一半浪费去掉等于吞吐翻倍**,比加机器便宜。 + +## 2. 事故实证 + +对一次「61 候选 × 6 事件」的 `score_candidates` 做 cProfile(总计 7.96 秒,`tottime`/`cumtime` 取自同一次采样): + +| 被重复的计算 | 实际调用 | 应调用 | 占本次 CPU | 它真正依赖什么 | +| --- | ---: | ---: | ---: | --- | +| `shadbala.calc_shadbala`(经 `_shadbala_verified_components_auxiliary`) | 2 196 | 61 | **45 %**(3.60 s) | 只依赖候选分钟 | +| `domain_calculation_service.compute_chart`(经 `_controlled_transit_rules`) | 2 196 | 36 | 12 % | 只依赖事件日期 | +| `_discriminating_event_probe_lists` | 2 | 1 | 9 % | 同一次请求算了两遍 | +| `narayana_dasha.calc_narayana_mahadasha` + `dasha_analyzer.build_dasha_timeline` | 6 876 / 39 852 | 61 | 8 % | 只依赖候选分钟 | +| `ashtakavarga.calc_ashtakavarga`(经 `_ashtakavarga_auxiliary`) | 2 196 | 61 | 3 % | 只依赖候选分钟 | + +`2196 = 61 候选 × 36 个(事件, 采样日期)组合`。三段调用链,符号定位: + +1. `scripts/rectification/scoring_service.py` → `build_event_contribution_matrix`:对每个 event 的每个 `sample_event_dates()` 采样日各调一次 `provider(...)`,`provider` 是 `compute_event_candidate_rows(value, static_contexts=static_contexts)`。`static_contexts` 只算一次(正确),但每次调用都会把 61 个候选全遍历一遍。 +2. `scripts/active_rectification_event_engine.py` → `compute_event_candidate_rows` → `_candidate_row(request, context)`:在这一层里对 `request["events"]` 循环,逐事件调用 `_active_vimshottari` / `_active_narayana` / `_controlled_transit_rules` / `_ashtakavarga_auxiliary` / `_shadbala_verified_components_auxiliary`。这五个里有四个的结果与 `event` 无关。 +3. `scripts/rectification/refinement_packet.py` → `build_refinement_packet`:先 `discriminating_event_probe_set(..., candidate_times=probe_times, precision_current=...)`,紧接着 `candidate_contrast_opportunities(..., candidate_times=grid_times)`。两者最终都落到 `event_probes._discriminating_event_probe_lists`,而该函数体第一行就是 `del precision_current, representative_time` —— 这两个参数根本不参与计算。因此 `probe_times == grid_times` 时(非 `refresh_probes` 路径,即绝大多数轮次)两次调用的入参在语义上完全相同。 + +最刺眼的一条:`build_candidate_static_context` **每个候选分钟已经算过一次 `calc_shadbala` 和 `calc_ashtakavarga`**,但只取 `_feature_hash(...)` 写进 `feature_payload["fingerprints"]`,结果对象随即丢弃;内层再从头算 36 遍。 + +另有死代码:`scoring_service._cached_rows`(`@lru_cache(maxsize=4096)`)全仓无调用方。`build_event_contribution_matrix` 只在 `row_provider is None and static_contexts is None` 时才不走 static context,而生产入口 `score_candidates` 永远两者都是 None → 走 static context 分支 → 绕过 `_cached_rows`。缓存加在了错误的层上。 + +## 3. 根因 + +`build_candidate_static_context` 这个「每个候选分钟只算一次」的抽象是对的,但只放进了**排盘与分盘**(chart / vargas / arudha / KP / 特殊上升)。同样只依赖候选分钟的 **Shadbala、Ashtakavarga、两条 Dasha 时间轴** 留在了 `_candidate_row` 的事件循环里,于是被「候选 × 事件 × 采样日期」三重放大。过境盘则是反向的同一个错误:它只依赖事件日期,却被放在按候选分钟迭代的最内层。 + +## 4. 决策记录 + +产品 2026-09-15 授权本单,范围严格限定为: + +1. **只做记忆化,不改任何算法、权重、阈值、采样规则。** 本单交付后,任何一个存量 Case 重算出来的 `candidate_scores`、`decision_receipt`、`candidate_feature_snapshot` 必须与改前逐字相同。不需要重新校准,不影响任何已有结论。 +2. **不碰 `sample_event_dates`。** 表里「只给年份 4 908 ms vs 给到月 1 891 ms」的 2.6 倍差来自 year 精度采满 12 个月。降采样会改变打分,必须先离线量测命中率,属于另一张研究单,本单不得顺手改。 +3. **不修 `build_candidate_static_context` 里 Shadbala 的 `birth_hour` / `birth_minute` 双算。** 该处传 `birth_hour = hour + minute/60` 的同时又传 `birth_minute = minute`,`calc_kala_bala` 因此把分钟算了两次。它只流进 `fingerprints.shadbala` → `fingerprints.static`,改了会让所有候选特征指纹变化。本单按现状照搬,记为观察项,不修。 +4. **不动 `_SWISSEPH_LOCK`、不动并发闸门、不引入多进程。** 那是另一个量级的改动,先把浪费去掉再谈。 + +## 5. 硬红线 + +1. 输出等价是本单唯一的成败判据。任何一处记忆化如果不能证明等价,就不做那一处,其余照做。 +2. 记忆化的作用域是**单次请求内**,不得引入跨请求的进程级缓存(`lru_cache` 在模块级会跨请求持有出生资料派生数据,违反 §8 隐私边界,也会在窗口收窄后返回陈旧上下文)。允许的载体只有:`build_candidate_static_context` 返回的 context dict,以及 `build_event_contribution_matrix` / `build_refinement_packet` 调用栈内显式传递的局部字典。 +3. Shadbala 复用必须逐项证明:`_shadbala_verified_components_auxiliary` 只读 `sthana_bala.total`、`drik_bala`、`naisargika_bala` 三项;`birth_minute` 只进 `calc_kala_bala`。要么只复用这三项,要么在测试里断言两种调用方式下这三项相等。不得「看起来一样就换」。 +4. 过境盘缓存只缓存 **chart**,不缓存 `_controlled_transit_rules` 的返回值——后者还依赖 `natal_ascendant_index`(每个候选不同)和 `target_houses`。 +5. 探针去重只在 `probe_times` 与 `grid_times` 相等时生效;`refresh_probes` 路径两者不同,必须仍然各算一次。 +6. `scripts/jyotish_api_server.py` 一行不许动(AGENTS §6 增长冻结)。 +7. 不得顺手升级依赖、不得顺手修不在本单里的 warning。 + +## 6. 任务分解 + +### 6.1 Shadbala / Ashtakavarga 进 static context + +`build_candidate_static_context` 已经算出 `ashtakavarga_result` 与 `shadbala_result`。把这两个对象(或它们被下游真正读取的字段)挂进返回的 context,`_candidate_row` 改为从 context 取,`_ashtakavarga_auxiliary` / `_shadbala_verified_components_auxiliary` 改成接收已算好的结果、只做后续的判定与计分。 + +- 验收:新增 pytest 用 monkeypatch 计数,断言一次 `score_candidates`(≥2 事件、≥2 个 year 精度采样)里 `shadbala.calc_shadbala` 与 `ashtakavarga.calc_ashtakavarga` 的调用次数**各等于候选分钟数**,不随事件数或采样日数增长。 +- 验收:同一请求改前/改后的 `candidate_scores` 与 `decision_receipt`(剔除 `column_compare_ms` 等计时字段)逐字相等。 + +### 6.2 两条 Dasha 时间轴进 static context + +`_active_vimshottari` 里的 `lon_to_nakshatra` + `build_dasha_timeline` 只依赖 `(birth_date, moon_longitude)`;`_active_narayana` 里的 `calc_narayana_mahadasha` 只依赖 `(ascendant_index, planet_longitudes)`。两者都是每候选一份。把时间轴/周期表算好放进 context,两个函数只保留随 `event_at` 变化的 `find_current` / `get_current_narayana_dasha` 部分。 + +- 验收:计数断言 `build_dasha_timeline` 与 `calc_narayana_mahadasha` 各等于候选分钟数。 +- 验收:输出等价同 6.1。 + +### 6.3 过境盘按事件日期缓存 + +`_controlled_transit_rules` 里的 `compute_chart` 只依赖 `(event_at.date(), lat, lon, tz, ayanamsa, node_mode)`。在 `compute_event_candidate_rows` 的调用栈内传一个局部字典做缓存;规则判定(`_relative_house` 与 `target_houses` 比对)仍按候选逐个算。 + +- 验收:计数断言这条链上的 `compute_chart` 调用次数等于**去重后的事件日期数**,与候选分钟数无关。 +- 验收:`event["precision"] == "year"` 仍然早退返回 `[]`(现状行为,不得改)。 + +### 6.4 鉴别探针只算一次 + +`build_refinement_packet` 里,当 `probe_times == grid_times` 时把 `discriminating_event_probe_set` 的结果复用给 `candidate_contrast_opportunities`(后者本体就是 `[opportunity_from_probe(p) for p in probes]`)。`probe_times != grid_times` 时保持两次调用。 + +- 验收:计数断言默认路径下 `_discriminating_event_probe_lists` 调用一次;`refresh_probes=True` 且 refresh 列存在时仍为两次。 +- 验收:`refinement_packet` 输出的 `discriminating_event_probes` 与 `candidate_contrast_opportunities` 两个字段改前后逐字相等。 + +### 6.5 删掉 `_cached_rows` + +`scoring_service._cached_rows` 与随之无用的 `_canonical` 引用(若确认无其它调用方)一并删除。不要试图「把它接回去」——正确的缓存位置是 6.1–6.3,不是这里。 + +- 验收:`grep -rn "_cached_rows" scripts/ tests/` 除 `skills/` 下的历史版本副本外无命中(`skills/jyotish-vedic-astrology/versions/*` 是冻结归档,不得改)。 + +### 6.6 落盘一份等价证明 + +在 `tests/` 下新增一个 golden 等价测试:固定一份请求(公开示例数据,禁止真实用户出生资料),把**改动前**跑出的 `candidate_scores` + `decision_receipt`(剔除计时字段)存为 golden,改动后断言相等。golden 必须由基线 `6b3248bf` 的代码真实跑出,不得手造(AGENTS §7.4)。 + +- 验收:该测试在改动前后都能跑,改动前绿、改动后仍绿。 + +## 7. 预期收益(本机已实测) + +不改仓库代码、在基准脚本里把这几层用等价记忆化包起来,同一份请求(61 候选 × 6 事件)跑前后两次: + +| | 耗时 | +| --- | ---: | +| 当前代码 | 3 358 ms | +| 记忆化后 | 1 604 ms | + +**快 53 %。** `candidate_scores` 完全相同;`decision_receipt` 的唯一差异是计时字段 `column_compare_ms` 10.5 → 10.4。这个数是本单的合格线参照,不是硬指标——实际收益随事件数与精度分布浮动,**但等价是硬指标**。 + +## 8. 让步顺序 + +做不完时按此顺序砍,每砍一条在进度记录里写明原因: + +1. 6.1(Shadbala)必须做,它一个人占 45 %。 +2. 6.3(过境盘)次之,12 %,改动最独立。 +3. 6.4(探针去重)再次之,9 %。 +4. 6.2(Dasha 时间轴)可以留到下一轮,它的调用链最长、等价证明最费事。 +5. 6.5 顺手。 +6. 6.6 **不得砍**——没有等价证明的性能改动一律视为未通过。 + +## 9. 开工前置命令 + +```bash +git fetch origin --prune +git worktree add -b codex/rectification-engine-memoization-20260915 \ + .worktrees/rectification-engine-memoization-20260915 origin/staging +cd .worktrees/rectification-engine-memoization-20260915 +git status -sb | head -1 # 确认分支 +``` + +验收命令: + +```bash +.venv/bin/python -m pytest tests/test_rectification_v5_services.py \ + tests/test_rectification_event_probes.py \ + tests/test_rectification_refinement_packet.py \ + tests/test_active_rectification_events.py \ + tests/test_rectification_engine_convergence.py \ + tests/test_rectification_relative_support.py \ + tests/test_rectification_technique_contract.py +.venv/bin/python scripts/run_quality_gate.py --profile quick +``` + +## 10. BUG 编号起点 + +基线 `6b3248bf` 上 `docs/BUG_HISTORY.md` 最大号为 **BUG-720**。本单预占 **BUG-721**(一条:候选不变量被重复计算,重算耗时翻倍)。开工时以当时的实际最大号 +1 为准;同日另有三单在跑,编号以先落库者为准。 + +`BUG-721` 的记录必须写明:这不是回归,是自 `build_candidate_static_context` 引入以来一直存在的分层遗漏;防复发写成「新增的候选分钟不变量必须进 static context,不得留在 `_candidate_row` 的事件循环里;新增的事件不变量不得按候选迭代」。 + +## 11. 不在本单范围 + +- year 精度采样 12 → N 的降采样(要先离线量测命中率,另开研究单) +- `_SWISSEPH_LOCK`、并发闸门、多进程、加机器 +- 前端侧的等待体验(另有三单) +- `build_candidate_static_context` 的 `birth_minute` 双算(§4.3 记为观察项) diff --git a/docs/tasks/TASK-rectification-failure-attribution-20260915.md b/docs/tasks/TASK-rectification-failure-attribution-20260915.md new file mode 100644 index 00000000..f840cf17 --- /dev/null +++ b/docs/tasks/TASK-rectification-failure-attribution-20260915.md @@ -0,0 +1,199 @@ +# TASK · 三处把系统故障说成别的东西(校正轮次链路) + +- 日期:2026-09-15 +- 基线 commit:`origin/staging` @ `6b3248bf` +- 执行分支:`codex/rectification-failure-attribution-20260915` +- 独占文件:`frontend/src/app/api/rectification/agent/route.ts`、`frontend/src/lib/rectification-agentic/v9/turn-intent-classifier.ts`、`frontend/src/lib/rectification-agentic/v9/engine-client.ts`、`frontend/src/lib/rectification-agentic/v9/agent-run.ts`、`frontend/src/lib/rectification-activity-labels.ts` +- **`route.ts` 由本单独占。** `TASK-rectification-request-dossier-cache-20260915` 也要碰同一文件,**必须串行在本单之后**。 +- 与 engine-memoization 单、settled-render-split 单无文件重叠,可并行 + +--- + +## 1. 三条缺陷的共同点 + +都不是算错,是**归因错**:后端出了故障,但对用户显示成「你没说清楚」「已经记下了」或者干脆断流。用户没有任何线索知道要重试,也不知道刚才那句话有没有算数。 + +| # | 用户看到 | 实际发生 | 预占 BUG | +| --- | --- | --- | --- | +| 1 | 「我不太确定这句是不是在回答上面的问题」 | 意图分类器两次都异常,这句话里的经历直接丢弃 | BUG-722 | +| 2 | 「记下了:2016 年 3 月……」但范围一动不动 | 引擎 429(算不过来),重算静默失败 | BUG-723 | +| 3 | 等三五分钟,无错误码断流 | 两次尝试 420 秒 > 路由预算 240 秒 | BUG-724 | + +## 2. 事故实证 + +### 2.1 BUG-722 · 分类器失败被说成用户表达不清 + +`route.ts` 的 `POST` 里,`action === "message"` 且当前焦点是**点选题**(`parseAgentChoiceCopy(focus.expectedAnswerSchema)` 非空)时: + +``` +const intent = await classifyTurnIntentWithRetry(resolvedModel, {...}); +const classified = intent.classified; +... +if (!classified || classified.intent === "unclear") { + const narration = RECTIFICATION_USER_COPY.unclearFocusReply; // 「我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。」 + const turn = await persistV9DeterministicTurn(...); // 本轮落库 + return completedMessageResponse(narration, ...); // 直接返回,不进 Agent +} +``` + +`classifyTurnIntentWithRetry`(`turn-intent-classifier.ts`)的契约是:两次尝试都抛异常 → 返回 `{ classified: null, expectedWrite: "unknown" }`。也就是说 **`classified === null` 表示「模型没答上来」,`classified.intent === "unclear"` 表示「用户确实说不清」**,这是两件性质完全相反的事,路由把它们合并进了同一条分支。 + +后果:模型超时、供应商 5xx、网络抖动时,用户被回一句质疑他表达能力的话;这一轮正常落库,但**没有任何证据写入尝试**,他刚讲的那件事就此消失,只能自己再说一遍。 + +同一文件里**采集题**分支(`isCollectFocusSchema` 为真)已经处理对了:`classified` 为 null 时只记 `collectIntent = "unclassified"`,不短路,继续往下进 Agent,由 `expectedWrite = "unknown"` 的 fail-open 守卫接管。这是 BUG-643 的成果。点选题分支没有跟上。 + +第三处同类:`decision.nextAction === "ask_candidate_discriminator"` 分支里的 `classifyRectificationTurnIntent` 包在 `try { } catch { classified = null }` 里,随后落 `RECTIFICATION_USER_COPY.choicePrompt`。危害小(那条路本来就要出卡),但归因同样错,一并处理。 + +**关联记录**:BUG-643(分类器 null 不得回退到关键词;`collectIntent=unclassified` 就是那一单加的)、BUG-635(证据轮只说「记下了」却没写入)、BUG-522(其记录末尾原文写着「意图分类器超时仍是既有缺口,本单不修」——**这条缺口从 2026-09-04 挂到今天**)。 + +### 2.2 BUG-723 · 引擎「忙」被当成引擎「坏」 + +`engine-client.ts` → `readEngineJson`: + +``` +if (!response.ok) { + ... throw new RectificationEngineError("engine_request_failed", message); +} +``` + +所有非 2xx 一视同仁。而 Python 侧 `jyotish_api_server.py` 的 `except HeavyComputeBusy` 明确返回 **429 + `Retry-After` 头 + `ERR_COMPUTE_BUSY`**,语义是「现在满了,过几秒再来」,不是「坏了」。重算闸门 `api_heavy_compute_gate.py` 并发默认 2、饱和 fail-fast 不排队——**这是设计,不要改它**,要改的是调用侧。 + +失败之后:`rectification-v9-tools.ts` 的 `autoRescoreAfterEvidenceChange` 整个包在 `try/catch` 里,返回 `{ status: "failed", errorCode, openQuestion: null }`,**不打任何日志**。这个结果进 `record-evidence-batch` 的 projection 交给模型,但系统提示词明确要求「工具执行保持静默……不叙述工具或内部状态」,所以模型不会说。于是:证据入库成功 → 重算静默失败 → 模型照常写「记下了:2016 年 3 月……」→ 范围一动不动 → 也没有下一问被盖戳。 + +这正是历史上反复出现的「说记下了但范围没变」的一个来源,而且它**只在两个人同时校正时出现**,本机永远复现不了。 + +**复发自 BUG-715**(星盘页同一天刚修完同一个错误)。BUG-715 的防复发原文:「引擎调用不得用 `catch {}` 吞掉原因,失败必须留服务端日志且用户文案按原因分档。」那一单只改了 `chart-view-engine.ts`,校正链路的 `engine-client.ts` 是同样的写法,没有被扫到。 + +### 2.3 BUG-724 · 超时预算自相矛盾 + +| 位置 | 值 | +| --- | ---: | +| `route.ts` `export const maxDuration` | 240 s | +| `regenerate/route.ts` `export const maxDuration` | 240 s | +| `agent-run.ts` `RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS` | 210 s | +| `agent-run.ts` `MAX_ATTEMPTS` | 2 | + +单次尝试 210 s < 240 s,满足 BUG-388 防复发的字面要求(「attempt 超时必须小于路由 `maxDuration`」)。但两次加起来 **420 s > 240 s**,违反 BUG-059 的防复发:「**两次模型尝试的总预算必须显式小于路由 `maxDuration`**」。 + +只要发生一次重试(`empty_stream`、`evidence_not_written` 都会触发),这一轮必然撞上路由预算或边缘代理超时,用户等三五分钟拿到一个连错误码都没有的断流。 + +另有一处隐患:`RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS = 210_000` 在仓库里**定义了两遍**——`agent-run.ts:123`(服务端真正用的)和 `rectification-activity-labels.ts:40`(前端「即将超时」提示用的)。改一个不改另一个,提示时机就会漂。 + +**复发自 BUG-059**;**关联 BUG-388**(把 105 s 提到 210 s、把 120 s 提到 240 s 的那一单,它的防复发只写了单次尝试,没写总预算,所以这次没拦住)。 + +## 3. 根因 + +三条共用一个根因:**故障的机器语义在传递过程中被压平**。分类器把「抛异常」和「答了 unclear」压成同一个 `null`;引擎客户端把 429/500/超时/坏 JSON 压成同一个 `engine_request_failed`;运行器把「单次尝试的预算」当成「整轮的预算」。压平之后,上层再想按原因分档就没有信息可用了。 + +## 4. 决策记录 + +产品 2026-09-15 授权本单,并明确以下口径: + +1. **分类器失败必须让用户知道「是我们这边的事」,并且要能把这句话捡回来。** 允许的做法是提示重试;**不允许**用年份正则、关键词表或任何模式匹配去猜用户意图——这是 BUG-643 的防复发红线,本单不推翻。 +2. **429 不是错误,是排队信号。** 允许在重算链路上按 `Retry-After` 做有限次退避重试。退避重试仍失败时,**必须让用户知道这次没有重算**,不得让模型继续说「记下了」而范围不动。具体文案由执行方按 `frontend/docs/VOICE.md` 拟,评审在验收轮。 +3. **超时按「整轮一个预算」重构,不是简单调数字。** 不接受把 attempt 砍到 110 s——那会把 BUG-388 重新打开(带引擎重算的轮次实测就要超过 105 s)。也不接受把 `maxDuration` 提到 430 s——让用户等七分钟不是产品。 +4. **并发闸门(默认 2)、`_SWISSEPH_LOCK`、fail-fast 不排队三项一律不动。** 那是主机只有 2 vCPU 的保护,不是 bug。吞吐问题由 `TASK-rectification-engine-memoization-20260915` 解决。 +5. **不改计费口径。** 现在这三条失败路径都发生在 `billing.reserve()` 之前或走 release,用户不扣点;改完必须仍然不扣点。 + +## 5. 硬红线 + +1. 不得引入任何关键词/正则/词表兜底去替代分类器(BUG-643 防复发)。 +2. 不得放宽 `expectedWrite` 守卫、不得改 `evidence_not_written` 的重试语义、不得用确定性业务模板伪装成模型生成成功(BUG-059 防复发)。 +3. 引擎调用失败必须留服务端日志,只含路径、状态码或错误名、耗时;**不得含出生资料、案例 ID、用户原文、模型原文、JWT**(BUG-715 防复发 + AGENTS §8)。 +4. 超时改动后,「两次尝试总预算 < 路由 `maxDuration`」必须由一条测试断言钉死,而不是靠注释。 +5. 超时后若本轮已盖戳 `open_question`,仍不得把整轮打成空 `run_timeout`(BUG-388 防复发,现状行为,保持)。 +6. `frontend/src/app/page.tsx` 一行不许动(1951/2000,AGENTS §6)。 +7. 不得顺手升级依赖、不得顺手修不在本单里的 warning。 + +## 6. 任务分解 + +### 6.1 分类器区分「模型没答上来」与「用户说不清」 + +`classifyTurnIntentWithRetry` 的返回值加一个判别字段(例如 `outcome: "classified" | "unclear" | "classifier_unavailable"`),`classifier_unavailable` 专指两次尝试都抛异常。`classified: null` 仍然保留给调用方兼容,但路由不再据此分档。 + +`route.ts` 三处改为: + +- `outcome === "unclear"` → 维持现状,回 `unclearFocusReply`。 +- `outcome === "classifier_unavailable"` → 走一条新文案(大意:这边没接上,把刚才那句再发一次就行),并且**这一轮不得被记成用户已经答过当前焦点**;焦点保持 active,下一次重发能正常进入同一条路径。 +- 同一改动覆盖点选题分支、`ask_candidate_discriminator` 分支的 `try/catch`。采集题分支已经正确,只需保证它的 `collectIntent` 语义不被本次重构改坏。 + +另加一条服务端日志(只含 case 前缀无关的机器码与耗时,不含原文),让这类失败在日志里可数。 + +- 验收:`rectification-turn-intent-classifier.test.ts` 新增断言——分类器抛异常两次时 `outcome === "classifier_unavailable"`;模型正常返回 `intent: "unclear"` 时 `outcome === "unclear"`;两者的路由回复文案不同。 +- 验收:源码合同断言 `route.ts` 不存在 `!classified || classified.intent === "unclear"` 这种合并判断。 +- 验收:`classifier_unavailable` 路径不写任何证据、不推进焦点状态、不扣点。 + +### 6.2 引擎失败按原因分档 + +`engine-client.ts` 的 `readEngineJson` 改为按原因产出判别码:`busy`(429)/ `http_error` / `timeout` / `bad_payload`,并读取 `Retry-After`。每一种非 ok 打一条 `console.warn`(路径、状态码或错误名、耗时)。对齐 BUG-715 在 `chart-view-engine.ts` 里已经落地的那套形状,不要另发明一套。 + +`scoreAndPersistCurrentEvidence` / `autoRescoreAfterEvidenceChange` 链路上:`busy` 按 `Retry-After`(上限取一个显式常量,建议 ≤ 2 次、总退避 ≤ 6 s,写成具名常量并在测试里钉死)退避重试;仍失败时把失败原因显式带回 projection,并让本轮的主持人正文告诉用户这次没有重算、经历已经记下、稍后会再比一次。 + +- 验收:新增测试,桩出 429 + `Retry-After: 2`、500、超时、坏 JSON 四种,断言四种分别产出不同判别码、各有可区分日志、只有 429 触发退避重试。 +- 验收:断言退避总时长有上限,且上限是具名常量不是字面量散落。 +- 验收:`busy` 重试成功后,`rescore.status` 必须是 `completed`,与从未失败过的那条路径逐字相同。 +- 验收:`busy` 最终失败时,用户可见正文里必须出现「这次没有重新比较」这一语义(具体文案对照 `frontend/docs/VOICE.md`),且不得出现「范围在收窄」这类进度句。 + +### 6.3 超时改成整轮一个预算 + +在 `runV9AgentTurn` 里引入一个整轮 deadline(建议 `RECTIFICATION_RUN_BUDGET_MS`,取值必须显式小于 `maxDuration`,例如 225 s),单次尝试仍保留 210 s 上限,但实际超时取 `min(单次上限, 剩余预算)`。重试条件从 `attemptNumber < MAX_ATTEMPTS` 改为 `attemptNumber < MAX_ATTEMPTS && 剩余预算 ≥ 最小可用尝试时长`(同样具名常量)。预算不够重试时,走现有的 `hostFallbackUsed` 优雅路径,而不是启动一次注定被砍断的尝试。 + +同时把 `RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS` 收敛成**一处定义**,`rectification-activity-labels.ts` 从那一处 import,消除两份 210_000 漂移的可能。 + +- 验收:新增断言 `RECTIFICATION_RUN_BUDGET_MS < maxDuration`,且 `maxDuration` 从路由模块读取而不是重写一遍字面量(`agent` 与 `regenerate` 两条路由都要覆盖)。 +- 验收:`rectification-v9-stream.test.ts` 新增用例——第一次尝试耗尽大部分预算后返回 retryable,运行器不得发起第二次尝试,必须走 host fallback 并给出可见正文。 +- 验收:源码合同断言全仓只有一个 `210_000` 的定义点。 +- 验收:已盖戳 `open_question` 的超时轮仍然落题干、不空失败(BUG-388 现状行为回归)。 + +### 6.4 三条 Bug 历史 + +同一变更内写进 `docs/BUG_HISTORY.md`,编号连续,每条都要有「复发自 / 关联记录」: + +- BUG-722:关联 BUG-643、BUG-635、BUG-522(522 里写明的既有缺口本单关闭)。 +- BUG-723:**复发自 BUG-715**,说明 715 的防复发为什么没覆盖到 `engine-client.ts`(那一单的范围写死在星盘页三个文件里)。防复发要升级成仓库级:**任何调用 Python 引擎的客户端都不得把非 2xx 压平成单一错误码,429 必须单独成档。** +- BUG-724:**复发自 BUG-059**,说明 BUG-388 的防复发只约束了单次尝试、没约束总预算,因此没拦住。 + +## 7. 让步顺序 + +1. 6.1 必须做,它是唯一会**丢用户数据**的一条。 +2. 6.3 次之,改动最小、风险最低,而且它是重试链路的前提。 +3. 6.2 的「分档 + 日志」必须做;「按 `Retry-After` 退避重试」可以砍到下一轮,但那样的话**用户可见的「这次没有重算」提示不得砍**——宁可不重试也不许静默。 +4. 6.4 不得砍。 + +## 8. 开工前置命令 + +```bash +git fetch origin --prune +git worktree add -b codex/rectification-failure-attribution-20260915 \ + .worktrees/rectification-failure-attribution-20260915 origin/staging +cd .worktrees/rectification-failure-attribution-20260915/frontend +git status -sb | head -1 +npm ci +``` + +验收命令: + +```bash +./node_modules/.bin/tsc --noEmit +npm run lint +npx tsx --test tests/rectification-turn-intent-classifier.test.ts \ + tests/rectification-v9-stream.test.ts \ + tests/rectification-answer-choice.test.ts \ + tests/rectification-spoken-collect.test.ts \ + tests/rectification-unwritten-evidence.test.ts \ + tests/rectification-v9-agent.test.ts \ + tests/chart-view-engine.test.ts +npx tsx --test tests/*.test.ts # 与基线逐条比对失败清单 +npm run build # `/` 仍须 ○ Static,首屏 gzip ±2% +``` + +## 9. BUG 编号起点 + +基线 `6b3248bf` 上最大号 **BUG-720**。本单预占 **BUG-722 / 723 / 724**(BUG-721 留给 engine-memoization 单)。开工时核对当时的实际最大号;同日四单并行,先落库者先占号,冲突时顺延并在进度记录里写明。 + +## 10. 不在本单范围 + +- 分类是否改用便宜快模型(现在三次分类都走会话选的贵模型):需要在模型目录里新增「工具模型」角色,涉及计费口径,**待产品拍板后另开单**。 +- 并发闸门、`_SWISSEPH_LOCK`、加机器。 +- 引擎本身的耗时(见 `TASK-rectification-engine-memoization-20260915`)。 +- 请求内 Case 档案缓存(见 `TASK-rectification-request-dossier-cache-20260915`,串行在本单之后)。 diff --git a/docs/tasks/TASK-rectification-request-dossier-cache-20260915.md b/docs/tasks/TASK-rectification-request-dossier-cache-20260915.md new file mode 100644 index 00000000..33c4ad71 --- /dev/null +++ b/docs/tasks/TASK-rectification-request-dossier-cache-20260915.md @@ -0,0 +1,131 @@ +# TASK · 一轮对话把整份 Case 档案从数据库取 3.4 次 + +- 日期:2026-09-15 +- 基线 commit:`origin/staging` @ `6b3248bf` +- 执行分支:`codex/rectification-request-dossier-cache-20260915` +- **串行在 `TASK-rectification-failure-attribution-20260915` 之后**:两单都要改 `frontend/src/app/api/rectification/agent/route.ts`,本单必须以那一单合入后的 staging 为基线,不得并行 +- 主要落点:`frontend/src/lib/rectification-agentic/v9/tool-service.ts`(或新建同目录的缓存模块)、`route.ts` 一处接线 +- 规模:一个请求内缓存层 + 一处接线。**零调用点改动、零行为变化。** + +--- + +## 1. 事故实证 + +`loadV9CaseDossier` / `loadV9CaseCompute`(`tool-service.ts`)每次调用都是一次 Postgres RPC,没有任何缓存。全仓约 **40 个调用点**:`rectification-v9-tools.ts` 13 + 3、`answer-choice.ts` 6 + 4、`block-scan-answer.ts` 3、`agent-run.ts` 3 + 1、`score-persist.ts` 2 + 1、`route.ts` 2 + 2,以及 `regenerate-turn.ts`、`refresh-discriminator-probes.ts`、三条 GET 路由各一。 + +用现有测试套件的 RPC 计数器实测(在 `fakeAccounting` 上按场景聚合): + +| 路径 | 场景数 | `get_..._case_dossier` | 每场景均值 | `get_..._case_compute` | 均值 | +| --- | ---: | ---: | ---: | ---: | ---: | +| Agent 轮(`rectification-v9-stream.test.ts`) | 39 | 134 | **3.44** | 25 | 0.64 | +| 点选题(`rectification-answer-choice.test.ts`) | 15 | 31 | **2.07** | 17 | 1.13 | + +同一轮里还有 `insert_agentic_rectification_run_phase` 7.05 次、`set_agentic_rectification_conversation_focus` 2.46 次——那些是真写入,不在本单范围。 + +这份档案不小。`get_agentic_rectification_case_dossier`(`supabase/migrations/20260907020000_rectification_declared_uncertainty.sql` 里的最新定义)单次要做: + +- 取 `agentic_rectification_cases` 一行 +- 取**最近 50 轮** turns,`cross join lateral` 展开成 user/assistant 两条消息再 `jsonb_agg` +- 取该 Case 的**全部** evidence 行 `jsonb_agg` +- 两次 `count(*)`(evidence、turns) +- 取 conversation summary(不存在时还要先 `refresh_..._conversation_summary`) +- 取最新 result,并调 `compose_agentic_rectification_decision_receipt` 合成收据(内含 candidates 数组) + +在 2 vCPU 的生产主机上,这段 jsonb 构建与 Next.js、Python 引擎抢的是同一批核。 + +界面侧还会放大:`questionGap === "preparing"` 时 `useVisibilityAwarePoll` 每 `RECTIFICATION_QUESTION_RETRY_INTERVAL_MS = 2000` 毫秒打一次 `GET /api/rectification/cases/[caseId]`,每次又是一份完整档案(有重试次数上限,不是无限轮询)。 + +## 2. 根因 + +档案在**一次请求内**是不变的(除非本请求自己写了东西),但每个需要它的地方都独立重查。没有请求作用域的概念,于是「读一次用多处」退化成「用几处读几次」。 + +## 3. 决策记录 + +产品 2026-09-15 授权本单,口径: + +1. **缓存必须是「写即失效」的,不是「请求内固定」。** 档案在一次请求里**会**变——写证据、设焦点、落候选、切状态都会改它。任何「整个请求只读一次」的实现都会让下游拿到陈旧档案,那比多查几次严重得多。 +2. **不得改动那 40 个调用点。** 逐点传缓存参数既大又容易漏一处,漏的那一处就是陈旧数据。缓存必须挂在**客户端对象**上,对调用点完全透明。 +3. **不引入跨请求缓存。** 档案里有出生资料派生数据与会话内容,跨请求持有违反 AGENTS §8;而且并发校正之间必然串味。 +4. **本单不动轮询间隔、不动 GET 路由的投影形状。** 「档案能不能瘦一点」「GET 能不能只返回下一问」是另一个话题,需要先确认哪些字段真的有人读,本单不碰。 + +## 4. 硬红线 + +1. 写操作后必须能读到写后的档案。这是本单唯一的成败判据。 +2. 缓存生命周期严格等于一次 HTTP 请求(含流式响应体执行期间),不得挂在模块作用域、`globalThis` 或任何跨请求容器上。 +3. 缓存键必须包含 `userId` 与 `caseId`。同一请求内不会出现第二个用户,但键里带上它是防止将来被误用的最低成本。 +4. `route.ts` 只允许新增**一处**接线(包装 `createAdminSupabaseClient()` 的返回值)。不得在别处零散加缓存。 +5. 不得改 `get_agentic_rectification_case_dossier` / `_compute` 的 SQL 定义(本轮不动数据库结构,AGENTS §7.6)。 +6. 不得顺手升级依赖、不得顺手修不在本单里的 warning。 + +## 5. 任务分解 + +### 5.1 请求作用域的缓存包装器 + +新增 `withRectificationRequestCache(accounting)`,返回一个与 `RectificationRpcClient` 结构相同的包装对象: + +- `rpc("get_agentic_rectification_case_dossier" | "get_agentic_rectification_case_compute", args)`:按 `(fn, p_user_id, p_case_id)` 命中则返回缓存的 in-flight promise,未命中则实际调用并缓存该 promise(缓存 promise 而不是结果,可以顺带把并发的重复读合并成一次)。 +- `rpc(其它任何函数名, args)`:**先整体清空缓存**,再转发。不需要区分读写——除这两个只读投影外的 RPC 一律按可能写处理,这是保守且正确的一侧。 +- 任何其它属性(例如某些调用点用 `accounting as never` 走的 `.from(table).update(...)`)必须透传,并且在被访问时同样清空缓存。用 `Proxy` 或显式转发都行,选一个并在注释里写明为什么。 + +注意:`agent-run.ts`、`case-service.ts`、`session.ts`、`regenerate-turn.ts` 里有若干 `accounting.rpc(...)` 的**直接**调用(不走 `tool-service.rpc` 这个私有 helper)。正因为如此,缓存必须挂在客户端对象上——挂在 `tool-service` 的 helper 里会漏掉这些。 + +- 验收:单元测试覆盖——连续两次读同一 `(userId, caseId)` 只产生一次底层 RPC;中间插入任意一次其它 RPC 后,第三次读必须重新打底层;两个不同 `caseId` 互不命中;并发两次读只打一次底层。 +- 验收:`.from(...)` 透传后缓存被清空。 + +### 5.2 在路由入口接上 + +`route.ts` 里 `accounting = createAdminSupabaseClient()` 之后立刻包一层,后续一切原样。`regenerate` 路由与三条 `cases/[caseId]/*` GET/POST 路由同样处理(每条也只允许一处接线)。 + +- 验收:源码合同断言这几条路由里 `createAdminSupabaseClient()` 的返回值都经过包装器,没有裸用。 + +### 5.3 用现有套件量出前后差 + +把 §1 那张表的测法固化下来:在 `rectification-v9-test-support.ts` 的 `fakeAccounting` 上加一个可选的计数导出(**默认关闭**,靠显式开关启用,不得影响既有断言),新增一条测试断言「同一场景下 `get_..._case_dossier` 的调用次数不超过 N」。N 取改动后的实测值,不是拍的。 + +- 验收:进度记录里贴出改前/改后两组计数(Agent 轮与点选题各一组)。 +- 验收:既有 34 + 47 条断言一条不改仍全绿。 + +### 5.4 Bug 历史 + +预占 **BUG-726**,状态可写 `resolved`(有针对性回归)。这不是回归,是自 V9 运行时引入以来的分层遗漏。防复发写成:**Case 只读投影必须经请求作用域缓存读取;新增只读投影要么进缓存白名单,要么在记录里写明为什么不能缓存。** + +## 6. 让步顺序 + +1. 5.1 + 5.2 是一体,必须一起做。只做 5.1 不接线等于没做。 +2. 5.3 的计数断言可以只覆盖 Agent 轮一条路径,点选题那条砍掉时在进度记录里写明。 +3. 5.4 不得砍。 +4. 如果 5.1 的「其它属性透传」在类型上过不去(`AccountingClient` 只声明了 `rpc`,多处调用点用 `as never` 绕过),**宁可缩小范围**:只包装 `rpc`,并在记录里写明 `.from(...)` 路径未覆盖、可能读到陈旧档案的具体位置。不得为了让类型通过去改业务代码(这是产品明确的偏好)。 + +## 7. 开工前置命令 + +```bash +git fetch origin --prune +# 确认 failure-attribution 单已合入 staging 再开工 +git log --oneline origin/staging | head -5 +git worktree add -b codex/rectification-request-dossier-cache-20260915 \ + .worktrees/rectification-request-dossier-cache-20260915 origin/staging +cd .worktrees/rectification-request-dossier-cache-20260915/frontend +git status -sb | head -1 +npm ci +``` + +验收命令: + +```bash +./node_modules/.bin/tsc --noEmit +npm run lint +npx tsx --test tests/rectification-*.test.ts tests/agentic-rectification-*.test.ts +npx tsx --test tests/*.test.ts # 与基线逐条比对失败清单 +npm run build +``` + +## 8. BUG 编号起点 + +基线 `6b3248bf` 上最大号 **BUG-720**。本单预占 **BUG-726**。因为串行在 failure-attribution(722–724)之后,开工时那几号大概率已落库;以当时实际最大号 +1 为准。 + +## 9. 不在本单范围 + +- 档案投影瘦身(哪些字段真的有人读、50 轮 turns 是不是必要) +- `useVisibilityAwarePoll` 的 2 秒间隔与重试上限 +- `insert_agentic_rectification_run_phase`(每轮 7 次)的批量化 +- 引擎耗时、前端渲染、故障归因(各有专单) diff --git a/docs/tasks/TASK-rectification-settled-render-split-20260915.md b/docs/tasks/TASK-rectification-settled-render-split-20260915.md new file mode 100644 index 00000000..9e064d23 --- /dev/null +++ b/docs/tasks/TASK-rectification-settled-render-split-20260915.md @@ -0,0 +1,148 @@ +# TASK · 校正会话流式时整条对话每帧重建,已结算消息每帧重跑 Markdown + +- 日期:2026-09-15 +- 基线 commit:`origin/staging` @ `6b3248bf` +- 执行分支:`codex/rectification-settled-render-split-20260915` +- 独占文件:`frontend/src/components/rectification-agentic-chat.tsx`、新建的行组件文件、`frontend/src/components/chat-message-row.tsx`、`frontend/tests/` 下新增用例 +- 与本日其它三单**无文件重叠**,可并行 +- 规模:抽一个行组件 + 把逐消息派生搬进去 + 稳住回调身份。**不改任何交互行为、不改任何文案。** + +--- + +## 1. 事故实证 + +`frontend/src/components/rectification-agentic-chat.tsx`:1 973 行,`useState` 14 个,`useCallback` 12 个,**`useMemo` 0 个,`memo` 0 个**。 + +消息列表是直接内联在组件体里的 `messages.map((message) => {...})`(在 `
` 内),而且 map 体里每条消息都要做非平凡的派生: + +- `{...message}` 展开出一个新的 `displayedMessage` +- `rectificationTimelineRows({ trace, receipt, activity, settled })` —— 每次都新建数组 +- `vargaSentenceFromMethods(message.completedReceipt?.methods)` +- `choiceCardFromQuestion(question, ...)` —— 每次都新建对象 +- 为带题的消息构造 `afterAnswer` 整棵 JSX(含 `RectificationChoiceCard`) + +渲染出的 `ChatMessageRow`(`chat-message-row.tsx`)**没有 `memo`**,它内部的 `ChatMessageContent` 对**已结算**消息走的是 `renderProse(spoken, renderMarkdown)` 这条没有任何记忆化的分支(`StableMarkdownPrefix` 的 memo 只覆盖流式那一条)。`ChatMessageActions` 的 `onFeedback` / `onCopy` / `onRegenerate` 全是内联箭头函数,`submitChoice` / `submitStop` / `copyMessage` / `regenerateMessage` 也不在那 12 个 `useCallback` 里,每次渲染都是新身份。 + +流式输出这一侧是对的:`createStreamFrameBuffer` 已经把每个 `answer.delta` 合并成**每个动画帧最多提交一次**(BUG-473 的成果,该单影响面里就写着本文件)。但每一帧的那一次 `setMessages(current => current.map(...))` 会重渲整个列表 —— 于是每秒约 60 次,把整条会话里**每一条早已结算的消息**连同它的 Markdown 全部重算一遍。 + +校正会话天生就长:采集 + 探针 + 交付,二三十轮很常见。会话越长越卡,而且卡在最不该卡的时候——正在出结果的那一轮。 + +## 2. 根因 + +BUG-473 修的是「每个网络事件都提交一次」,它在本文件里落地了 `stream-frame-buffer`。但同一单在**咨询面**还做了第二件事:`chat-transcript.tsx` 把「已结算的历史」和「正在流的那一条」拆成两个 `memo` 组件(`SettledMessageList` + `HistoryMessageEntry`,以及 `LatestAssistantEntry`),历史整体只渲染一次。 + +**校正面只继承了前一半。** BUG-473 的两条防复发(「流式状态必须经 `stream-frame-buffer` 提交」「流式期间的 Markdown 渲染必须走前缀/尾块切分」)都只约束**正在流的那一行**,没有一条要求给**已结算的行**留记忆化边界,所以这半边缺失没有被任何断言拦住;`home-streaming-render-split.test.ts` 也只覆盖咨询面的那几个组件。 + +同类前史:BUG-249(`Home()` 2 723 行、`useCallback` 与 `useMemo` 各为 0,每次击键重渲整个组件)。同一个形态,换了个组件。 + +## 3. 决策记录 + +产品 2026-09-15 授权本单,口径: + +1. **纯性能重构,零行为变化。** 交付后校正会话的每一个可见行为——消息顺序、题目嵌入位置、选择卡可点性、跳过提示、交付卡出现时机、重新生成、复制、点赞点踩、停止——必须与改前逐字一致。本单不修任何已知交互缺陷,发现了写进进度记录。 +2. **不改文案、不改动效、不改间距。** 因此本轮**不需要**更新 `frontend/DESIGN.md`(AGENTS §7.5 的触发条件是「改 UI」,纯记忆化不算)。若执行中确实动了任何可见样式,则必须同提交更新 `DESIGN.md`。 +3. **不启用 React Compiler。** BUG-260 的结论是它在本仓对目标组件静默放弃且无法证明生效;本单用显式 `memo` 解决,不碰 `next.config.ts`。 +4. **不做「把流式文本从 `messages` 里搬出去」的大改。** 咨询面走的是 `streamingText` 独立 state 的路子,校正面把流式文本写在 `messages` 里。改成前者是更彻底的方案,但会动到快照回填、重新生成、题目绑定等一大片逻辑,风险与本单不匹配。**逐行 `memo` 就够**:`.map` 每帧产生新数组不影响 memo,因为 memo 比的是**每一行自己的 props**,而已结算消息的对象身份在流式期间是稳定的(每帧只有直播那一行的对象被替换)。 + +## 4. 硬红线 + +1. `frontend/src/app/page.tsx` 一行不许动(1951/2000,AGENTS §6)。 +2. 不得新写第二个聊天输入框、第二套滚动跟随、第二套加载动画(AGENTS §6)。滚动锚点仍是 `useConversationScrollAnchor` + `JumpToLatestButton`,本单不得改它们的调用方式。 +3. 测试总数不得低于开工时 `origin/staging` 的实测;改任何既有断言必须写「原值 / 新值 / 原因」三栏(AGENTS §7.3)。 +4. `next build` 后 `/` 仍须 `○ Static`,首屏 JS gzip 变化在 ±2 % 内(上次实测 584 413 B)。 +5. 不得用 `React.lazy` + `Suspense` 承载 Markdown(BUG-250 的结论:promise 落定后仍需一次重渲,可能露出一帧 fallback)。 +6. 不得顺手升级依赖、不得顺手修不在本单里的 warning。 + +## 5. 任务分解 + +### 5.1 抽出逐消息行组件并加 `memo` + +把 `messages.map` 的整个 body 搬进一个新文件里的 `memo` 组件(建议 `rectification-message-entry.tsx` → `RectificationMessageEntry`),§1 列的那五项派生全部搬进组件内部计算,不再由容器每帧算好再传下去。 + +组件的 props 只允许: +- `message`(已结算行在流式期间身份稳定) +- 标量:`busy`、`readonly`、`regenerating`、`canRegenerate`、`liveQuestion` 所依赖的那几个 id 与布尔、`choiceNonce`、`savedTime`、`copied` +- 该行自己的 `feedback` 值(**不是整张 feedback map**) +- 一个 `actionsRef`(见 5.2) + +不允许把每帧新建的对象/数组直接当 props 传下去(时间轴行、choice card、`afterAnswer` JSX 都属此列)。 + +- 验收:源码合同断言 `rectification-agentic-chat.tsx` 里不再直接调用 `rectificationTimelineRows` / `vargaSentenceFromMethods` / `choiceCardFromQuestion`。 +- 验收:源码合同断言新组件由 `memo(` 包裹。 + +### 5.2 用 `actionsRef` 稳住回调身份 + +照搬 `chat-transcript.tsx` 已经在用的模式:把 `submitChoice`、`submitStop`、`copyMessage`、`regenerateMessage`、feedback 切换等放进一个 ref 容器,每帧只更新 ref 的 `.current`,组件内部通过 `actionsRef.current.xxx(...)` 调用。不要逐个加 `useCallback` 去追依赖数组——那条路在本文件的 state 规模下一定会漏。 + +- 验收:源码合同断言新组件的 props 里没有任何函数类型字段(`actionsRef` 除外)。 + +### 5.3 给 `ChatMessageRow` 加 `memo` + +`chat-message-row.tsx` 的 `ChatMessageRow` 目前是裸函数。加 `memo` 后咨询面同样受益。注意它的 `afterAnswer` 是 `ReactNode` prop——由 5.1 保证它在新组件内部构造,从而与该行自身的 props 同源。 + +- 验收:咨询面既有测试(`home-streaming-render-split.test.ts`、`chat-markdown-split.test.ts` 等)全绿,无断言改动。 + +### 5.4 结算态 Markdown 也要记忆化 + +`chat-message-content.tsx` 里 `streaming === false` 走的 `renderProse(spoken, renderMarkdown)` 没有任何缓存。给结算态也套一层按文本内容记忆的组件(`StableMarkdownPrefix` 已经是这个形状,可直接复用或抽成共用组件)。这一条独立于 5.1,即使行组件的 memo 因为某个 prop 抖动而失效,它也能兜住最贵的那部分。 + +- 验收:新增断言——同一段文本连续渲染两次,Markdown 解析器只被调用一次(用可计数的桩 renderer)。 + +### 5.5 渲染次数回归测试 + +照 `frontend/tests/home-streaming-render-split.test.ts` 的手法,为校正面补一份:复用 `src/lib/home-streaming-render-probe.ts` 的计数器(在新组件里调 `noteSettledRowRender()`),按帧手工驱动渲染,断言**已结算行的渲染次数不随流式帧数增长**。 + +必须照搬那份测试已经做对的两件事: +- 由测试手工驱动渲染次数,不假装 `renderToString` 能反映真实 memo 命中(本仓测试是字符串渲染,数不了重渲染次数——BUG-617 记录里已经写过这条); +- 同时跑一份「未拆分」对照,断言拆分后的计数**严格小于**对照,证明这个取证方法不是恒为真。 + +- 验收:新测试在改动前跑是红的、改动后是绿的(执行方需在进度记录里贴出这两次运行结果)。 + +### 5.6 Bug 历史 + +同一变更内写进 `docs/BUG_HISTORY.md`,预占 **BUG-725**。必须写明:**关联 BUG-473**(它的影响面包含本文件,但两条防复发都只约束正在流的那一行),以及 BUG-249(同形态前史)。防复发升级为:**任何流式会话面都必须为已结算消息保留记忆化边界,并由一条按帧驱动的渲染计数断言钉死;新增会话面必须同时补这条断言。** + +## 6. 让步顺序 + +1. 5.4(结算态 Markdown 记忆化)最先做——最小、最独立,单独就能拿走最贵的一块。 +2. 5.1 + 5.2 是主体,必须一起做(只加 `memo` 不稳住回调身份等于没加)。 +3. 5.3 可以砍,砍了在进度记录里写明。 +4. 5.5 **不得砍**——没有渲染计数断言的性能改动一律视为未通过(这正是本次缺口能存在这么久的原因)。 +5. 5.6 不得砍。 + +## 7. 开工前置命令 + +```bash +git fetch origin --prune +git worktree add -b codex/rectification-settled-render-split-20260915 \ + .worktrees/rectification-settled-render-split-20260915 origin/staging +cd .worktrees/rectification-settled-render-split-20260915/frontend +git status -sb | head -1 +npm ci +``` + +验收命令: + +```bash +./node_modules/.bin/tsc --noEmit +npm run lint # 0 error +npx tsx --test tests/rectification-*.test.ts tests/home-streaming-render-split.test.ts \ + tests/chat-markdown-split.test.ts tests/stream-frame-buffer.test.ts +npx tsx --test tests/*.test.ts # 与基线逐条比对失败清单(无 Docker 时数据库套件照常红) +npm run build # `/` 仍须 ○ Static +``` + +## 8. 真人验收欠账 + +本仓没有浏览器环境,「长会话流式时是否还卡」只能由产品在真实环境确认。执行方必须在 `docs/testing/` 下留一份可照做的条目,至少包含:开一个已有二十轮以上的校正会话 → 发一条会触发长回答的消息 → 观察流式期间页面是否还有明显掉帧、历史消息是否闪烁。 + +## 9. BUG 编号起点 + +基线 `6b3248bf` 上最大号 **BUG-720**。本单预占 **BUG-725**。同日四单并行(721 / 722–724 / 725 / 726),开工时核对实际最大号,冲突顺延并在进度记录写明。 + +## 10. 不在本单范围 + +- 把流式文本从 `messages` 搬进独立 state(§3.4 已否决,风险不匹配) +- React Compiler(§3.3) +- 首屏包体积(584 KB gzip 是全站壳的问题,另议) +- 校正面任何交互缺陷(本单零行为变化)