Compare commits

...
Author SHA1 Message Date
jesse-ux e61535f464 fix(consultation): 外网证据按盘+日期缓存,超时取消前台任务
Independent Staging Quality Gate / validate (push) Canceled after 2m21s
Independent Staging Quality Gate / publish (push) Canceled after 0s
BUG-727:同日 VedAstro 快照零等待,跨日先用旧的并后台刷新;join 超时必须 cancel,budget 不超过 2×join。BUG-728:western_evidence_packet 无读取点,默认不再进咨询响应。jyotish_api_server.py 未增长(11334→11291)。
2026-09-16 07:36:09 +08:00
jesse-ux b9c053778a fix(rectification): 请求内 Case 档案只读投影写即失效缓存
Independent Staging Quality Gate / validate (push) Canceled after 2m18s
Independent Staging Quality Gate / publish (push) Canceled after 0s
同一请求里 dossier/compute 按 (fn, userId, caseId) 合并重复读,其它 RPC 与 .from 立即失效。路由入口各包一处,零调用点改动。
2026-09-16 07:32:34 +08:00
Jesse_ChenandClaude Opus 5 e4788dfc00 docs(tasks): 换掉两条增长冻结口径 + page.tsx 状态下沉第一簇 + C1 提前
产品 2026-09-15 三项拍板,落成两份新单与两处既有单的修订:

- 新增 TASK-freeze-metric-change-20260915(无 BUG 号,后面两单的前置):
  两条冻结余量已用完(page.tsx 1951/1951 余 0;api server 11334/11363 余 29),
  冻结从「逼新代码往外走」退化成拦路。实证:page.tsx 行数砍 59% 但 Home()
  的 useState 从 56 涨到 66;api server 225 个类方法只有 12 处真碰 HTTP。
  主门换成耦合指标,行数降为粗护栏;同时推翻 §6「参数式 hook 内部保持
  0 个 React hook」——那正是状态搬不走的原因。
- 新增 TASK-home-state-lowering-20260915(无 BUG 号):先搬 rectification*
  那 15 个 state 进已经是 dynamic 子树的校正面,Home() useState 66 → ≤53。
  零行为变化;串行在 freeze-metric-change + C2 + R3 之后。
- 修订 TASK-consultation-external-evidence-cache-20260915:依赖反转,C1 排在
  API server 拆解之前(它动模块级函数,拆解动类方法);补「不得新增类方法、
  行数余量仅 29」的硬红线。
- 修订 TASK-api-server-decomposition-20260916:串行依赖加 C1 与
  freeze-metric-change;__new__ 计数按 grep 的 4 计(原文 3 是文件数);
  阶段 4 收尾口径改写;基线 11,314 → 11,334。

纯文档推送,不触发门禁、不发布镜像、不部署。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-15 23:15:37 +00:00
jesse-ux 58ccafb67c fix(rectification): stop re-parsing settled messages on every stream frame
Independent Staging Quality Gate / validate (push) Failing after 6m18s
Independent Staging Quality Gate / publish (push) Skipped
2026-09-16 07:13:12 +08:00
jesse-ux 53a37ce944 fix(rectification): 候选分钟不变量记忆化(BUG-721)
Independent Staging Quality Gate / validate (push) Canceled after 1m57s
Independent Staging Quality Gate / publish (push) Canceled after 0s
Shadbala / Ashtakavarga / Dasha 时间轴进 static context;过境盘按事件日期缓存 chart;探针网格相同时只算一次。打分与决策回执与基线 golden 逐字相同(剔除计时字段)。
2026-09-16 07:11:07 +08:00
57 changed files with 4979 additions and 359 deletions
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-09-16 — 同一天再问同一张盘,不再重复等外部证据
普通聊天同一张盘、同一天里再问,外部证据直接用已经取到的,不再每轮等外网。隔了一天会先用最多七天内的旧证据马上回答,后台再刷新;「深入看今日」仍然只要当天的。西洋盘整包不再塞进每一轮的回答里,压缩后的西洋层还在。Skill 版本不变。
## 2026-09-16 — 校正时系统没接上会直说,记下经历后没重算也会说明
生时校正里,如果这边没判断出你在答哪一问,会请你把刚才那句再发一次,而不是说你没说清。记下经历后如果这次没能重新比较候选,会告诉你经历已经记下、稍后再比,不会假装范围已经在收。Skill 版本不变。
+78
View File
@@ -11206,6 +11206,22 @@
- 复发自:无
- 修复版本:待发布
## BUG-721 | 生时校正把候选分钟不变量放在事件循环里重复计算
- 状态:resolved
- 首次发现:2026-09-15
- 最近更新:2026-09-16
- 影响面:`scripts/active_rectification_event_engine.py`、`scripts/rectification/scoring_service.py`、`scripts/rectification/refinement_packet.py`
- 用户现象:每记一条证据或答一道题都要重算 `POST /api/rectification/v5/score`,等待明显偏长。不是回归,也不是结果算错。
- 触发条件:一次请求里有多个候选分钟,并且事件带 year/month 采样(采样日把候选×事件再放大)。
- 根因:`build_candidate_static_context` 引入后,排盘/分盘按候选分钟只算一次,但 Shadbala、Ashtakavarga、Vimshottari 时间轴、Narayana 周期表仍留在 `_candidate_row` 的事件循环里,被「候选 × 事件 × 采样日」三重放大。过境盘只依赖事件日期,却按候选分钟在最内层重算。`build_refinement_packet` 在 `probe_times == grid_times` 时对 `_discriminating_event_probe_lists` 算两遍。`scoring_service._cached_rows` 是加错层的死代码,生产入口从不走。
- 修复:把候选分钟不变量挂进 static context,过境盘在 `compute_event_candidate_rows` 调用栈内用局部字典按日期缓存 chart(规则判定仍按候选算),探针在时间网格相同时复用一次结果;删掉 `_cached_rows`。不改算法、权重、阈值、采样规则,也不修 static context 里 Shadbala 的 `birth_minute` 双算。
- 验证:基线 `a8d29d1b` 真实跑出 golden(公开 1990-01-01 北京烟测盘 + 虚构事件);改后 `candidate_scores` 与剔除 `column_compare_ms` 的 `decision_receipt` 逐字相同。计数断言:`calc_shadbala` / `calc_ashtakavarga` / `build_dasha_timeline` / `calc_narayana_mahadasha` 在引擎打分路径上各等于候选分钟数;过境 `compute_chart` 等于去重事件日期数;year 精度过境仍早退 `[]`;默认探针路径 1 次、`refresh_probes` 且 refresh 列存在时 2 次。
- 防复发:新增的候选分钟不变量必须进 static context,不得留在 `_candidate_row` 的事件循环里;新增的事件不变量不得按候选迭代。记忆化只允许请求内显式传递的 context / 局部字典,禁止模块级 `lru_cache` 跨请求持有出生资料派生数据。
- 相关记录:无
- 复发自:无
- 修复版本:待发布
## BUG-722 | 意图分类器两次异常被说成用户没说清,经历被丢掉
- 状态:resolved
@@ -11254,3 +11270,65 @@
- 复发自:BUG-059(总预算约束);BUG-388 的防复发只写了单次尝试,因此没拦住
- 修复版本:待发布
## BUG-725 | 校正会话流式时整条对话每帧重建,已结算消息每帧重跑 Markdown
- 状态:resolved
- 首次发现:2026-09-15
- 最近更新:2026-09-16
- 影响面:`rectification-agentic-chat.tsx` 消息列表、`chat-message-content.tsx` 结算态 Markdown、`chat-message-row.tsx`
- 用户现象:生时校正会话越长越卡,尤其在正在写出结果的那一轮。历史气泡跟着直播行一起抖。
- 触发条件:校正会话已有十余轮结算消息,再发一条会触发长流式回答。
- 根因:BUG-473 在本文件落地了 `stream-frame-buffer`(每帧最多提交一次),但校正面没有咨询面那套「已结算历史 / 正在流的那一条」记忆化边界。`messages.map` 内联派生时间轴、varga 句、选择题卡和 `afterAnswer`;`ChatMessageRow` 无 `memo`;结算态走 `renderProse` 全量 parse。每帧 `setMessages` 只替换直播行对象,但整张列表仍重渲,已结算行连同 Markdown 全部重算。同类前史:BUG-249(巨型容器每次击键全量重渲)。
- 修复:结算态 Markdown 复用 `StableMarkdownPrefix`(`memo` + 按文本的解析缓存);抽出 `RectificationMessageEntry`(`memo`),把逐消息派生搬进组件,回调经 `actionsRef` 更新 `.current`;`ChatMessageRow` 同样 `memo`。不把流式文本搬出 `messages`,不启用 React Compiler,不用 `React.lazy`+`Suspense` 承载 Markdown。
- 验证:`frontend/tests/rectification-settled-render-split.test.ts`(源码合同 + 按帧渲染计数:已结算行 4 次不随 5 个流式帧增长,未拆分对照 25 次);`chat-markdown-split.test.ts` 新增「同一段文本连续渲染两次,解析器只调用一次」(改前 2 次,改后 1 次)。改前该计数测试因模块不存在而红。咨询面既有 `home-streaming-render-split` / `chat-markdown-split` / `stream-frame-buffer` 无断言改动且全绿。
- 防复发:任何流式会话面都必须为已结算消息保留记忆化边界,并由一条按帧驱动的渲染计数断言钉死;新增会话面必须同时补这条断言。流式状态仍须经 `stream-frame-buffer` 提交;流式期间 Markdown 仍走前缀/尾块切分;结算态不得再走无缓存的整篇 parse。
- 相关记录:BUG-473、BUG-249、BUG-250、BUG-260、BUG-617
- 复发自:BUG-473(影响面含本文件,但两条防复发只约束正在流的那一行)
- 修复版本:待发布
## BUG-726 | 一轮校正把同一份 Case 档案从数据库取多次
- 状态:resolved
- 首次发现:2026-09-15
- 最近更新:2026-09-16
- 影响面:`loadV9CaseDossier` / `loadV9CaseCompute`、`POST /api/rectification/agent`、regenerate、Case GET/POST
- 用户现象:同一轮对话里档案被重复取回。用户看不到这条,但 2 vCPU 主机上这份 jsonb 投影与 Next.js、Python 引擎抢同一批核。
- 触发条件:一次 HTTP 请求内多处调用 `get_agentic_rectification_case_dossier` 或 `get_agentic_rectification_case_compute`,中间没有写操作。
- 根因:只读投影没有任何请求作用域缓存。每个调用点独立打 Postgres RPC。这不是回归,是 V9 运行时引入以来的分层遗漏。
- 修复:新增 `withRectificationRequestCache`,挂在客户端对象上。两个只读投影按 `(fn, p_user_id, p_case_id)` 缓存 in-flight promise;其它 RPC 与 `.from(...)` 一律先清空再转发。生命周期等于一次请求。路由入口各包一处,零调用点改动,不跨请求缓存。
- 验证:`frontend/tests/rectification-request-dossier-cache.test.ts`:连续两次读只打一次底层;中间其它 RPC 后必须重读;不同 caseId 互不命中;并发读合并;`.from` 透传后缓存清空;五条路由源码合同。既有 stream 50 / answer-choice 34 一条不改仍全绿。路由预取 + 证据轮 `case_dossier` 5→4。
- 防复发:Case 只读投影必须经请求作用域缓存读取;新增只读投影要么进缓存白名单,要么在记录里写明为什么不能缓存。不得把档案挂在模块作用域或 `globalThis`。
- 相关记录:BUG-176
- 复发自:无
- 修复版本:待发布
## BUG-727 | 普通聊天每轮每域同步等外网,超时不取消导致线程池饱和
- 状态:resolved
- 首次发现:2026-09-15
- 最近更新:2026-09-16
- 影响面:`POST /api/consultation_workflow` 前台路径、`execute_consultation_workflow`、`vedastro_gateway`、前台 VedAstro 线程池
- 用户现象:普通聊天每发一条、每个问题域都要等外网;本地排盘只占约 3%。超时后技法表 VedAstro 云状态仍是 blocked,之后每一轮继续白等约 1.5 秒。
- 触发条件:网页咨询 `defer_optional_external_evidence: true`;同一张盘一天内多轮提问;前台线程池默认 2 个 worker。
- 根因:外网证据本就按「盘 + UTC 日期」组织,却没有按这个键缓存,每轮重新 TLS 握手打 `api.vedastro.org`。`_join_foreground_vedastro` 超时返回 `official_blocked` 但不 `cancel()` 后台任务,任务继续跑满预算;join 1.5 s、budget 8 s、workers 2 三个数不在同一处约束,平均每 4 秒一轮就把池子占死。
- 修复:新增独立模块 `scripts/vedastro_snapshot_cache.py`(目录与 key 函数都与 `_api_chart_cache` 分开)。同日命中直接用、零等待、不 submit;1–7 天前先用旧的并后台刷新;`entrypoint=daily_starlanguage` 只接受当天。超时必须 `future.cancel()`;join / budget / workers 成组声明,且 `budget ≤ 2 × join`。本单不是推翻 BUG-301:前台仍取官方层,只是用缓存去掉重复等待。
- 验证:`tests/test_vedastro_snapshot_cache.py`(同日第二次零网关调用、跨日 stale+刷新不等待、daily_starlanguage 不吃昨天、缓存目录≠chart 缓存、N+1 等待不超过 join、三常数同块且 budget 比例、超时取消排队任务);既有前台赶上/超时降级回归。
- 防复发:前台外部证据必须有「盘 + 日期」级缓存;任何有界等待都必须同时取消它等待的后台任务。
- 相关记录:BUG-161、BUG-301、BUG-718
- 复发自:无(BUG-301 是故意把 VedAstro 放回前台,本单用缓存同时满足 161 与 301)
- 修复版本:待发布
## BUG-728 | `western_evidence_packet` 无人读却每轮每域传约 122 KB
- 状态:resolved
- 首次发现:2026-09-15
- 最近更新:2026-09-16
- 影响面:`POST /api/consultation_workflow` 响应体、`consumer_context.western_spectrum`
- 用户现象:一轮咨询响应约 52 万字符,其中西洋证据整包约 122 KB;前端解析后丢掉。三个域就是约 366 KB 无效 JSON。
- 触发条件:普通聊天每个问题域调用 `/api/consultation_workflow`。
- 根因:西洋盘计算是 must-use 层,但整包被无条件放进前台响应。`frontend/src` 零读取点;被读的是 `consumer_context.western_spectrum` 压缩投影。
- 修复:默认不把 `western_evidence_packet` 放进咨询工作流响应,计算与 `western_spectrum` 仍保留。MCP、高严谨、显式 `include_western_evidence_packet` / `western_oracle_payload` 仍返回整包。`consultationWorkflowResponseSchema` 为 `.passthrough()`,去掉该键仍能解析。
- 验证:`tests/test_vedastro_snapshot_cache.py` 默认省略、显式请求保留、oracle payload 仍返回;`frontend/tests/consultation-workflow-contract.test.ts` 无该键仍 `safeParse` 成功。
- 防复发:工作流响应新增大字段前必须有读取点;没有读取点的字段不得进入前台响应。
- 相关记录:BUG-727
- 复发自:无
- 修复版本:待发布
@@ -0,0 +1,93 @@
# PROGRESS · 普通聊天外网证据缓存(2026-09-15 / 2026-09-16)
工作树:`.worktrees/consultation-external-evidence-cache-20260915`
分支:`codex/consultation-external-evidence-cache-20260915`
任务书基线:`origin/staging` @ `6b3248bf`(任务书写)
开工时远端:`origin/staging` @ `11893c7f973573da78d6dbb48db89cde8b373ecc`
预占 BUG:**727 / 728**(基线最大号 720;721–726 为同日其它单预占,未冲突)
交付 commit:`d49bd9cd`
## 串行基线偏离(产品授权)
`TASK-api-server-decomposition-20260916` **未合入** staging。任务书要求以拆分后的 `scripts/jyotish_api_server.py` 为基线;产品负责人要求本单现在就做。因此:
- **没有增长** `scripts/jyotish_api_server.py`。新逻辑在 `scripts/vedastro_snapshot_cache.py`、`scripts/vedastro_foreground.py`;主文件只做薄导入、把 submit/join 换成 `start_foreground_vedastro` / `finish_foreground_vedastro`、按需挂 `western_evidence_packet`。
- 冻结合同:`wc -l` 口径开工 **11334** 行,交付 **11291** 行(少 43);上限 11063+300=**11363**。
未改 context-memory 的前端会话文件,未改 session-capacity 的 SQL 迁移。
## 任务对照
| 任务 | 状态 | BUG |
| --- | --- | --- |
| 5.1 盘 + UTC 日期缓存 | 完成 | BUG-727 |
| 5.2 超时 cancel + join/budget/workers 成组,`budget ≤ 2×join` | 完成 | BUG-727 |
| 5.3 `western_evidence_packet` 按需返回 | 完成 | BUG-728 |
| 5.4 staging 单域耗时清单 | 完成(环境缺口:无 staging 登录) | — |
| 5.5 BUG-727 / 728 | 完成 | — |
## 实现要点
- 缓存键:`sha256(年月日时分秒 + lat + lon + tz + ayanamsa + node + UTC reference_date)`,文件名只有哈希。目录 `scratch/local/vedastro_snapshot_cache`,与 `_api_chart_cache` 分开。
- 同日命中:直接用,不 submit。1–7 天:先用旧的(网关包上标 `snapshot_reference_date`),后台刷新只写缓存。`entrypoint=daily_starlanguage` 只接受当天。未命中才走 1.5 s join。
- 只缓存 `official_verified` 且带 raw 的包;超时/blocked 不落盘,避免把未交付层标成 executed(BUG-301)。
- `_join_foreground_vedastro` 超时 `cancel_event.set()` + `future.cancel()`。budget 默认改为 `2×join`(1.5 s → 3 s),不再默认跑满 8 s。
- 非前台路径(报告/高严谨)仍同步跑网关、不受 join 上限,成功后同样写缓存。
- 前台响应默认去掉 `western_evidence_packet`,仍算、仍投影 `consumer_context.western_spectrum`。MCP / 高严谨 / `include_western_evidence_packet` / `western_oracle_payload` 仍返回整包。
- 前端 `runConsultationWorkflow` 把 `entrypoint` 传给 Python,供 daily 入口拒绝隔日缓存。
## `western_evidence_packet` 检索
| 位置 | 处理 |
| --- | --- |
| `frontend/src` | **零命中**,按「无人读」从咨询响应去掉 |
| `scripts/jyotish_api_server.py` | 仍计算;默认不进响应 |
| `scripts/vedastro_foreground.py` | `should_include_western_evidence_packet` |
| `scripts/western_evidence_packet.py` / `western_chart_engine.py` / `western_oracle_adapter.py` | 保留计算 |
| `scripts/unified_consultation_orchestrator.py` | 仍传入做审计日志 |
| `scripts/cross_system_arbitrator.py` | 保留 |
| `mcp_server.py` | 保留;`surface=skill_mcp` 仍返回整包 |
| `tests/` | oracle / MCP / 按需返回的断言保留 |
## 响应体积
| | 字符 |
| --- | ---: |
| 任务书基线(career 同参第二轮) | 523 k,其中 `western_evidence_packet` 122 k |
| 本机改后(stub 工作流,默认响应) | 该键不存在 |
| 显式 `include_western_evidence_packet` | 键在,体积 ≥ 默认响应 |
未在 staging 重测 523 k 真包(无登录)。按基线,每域少约 122 k 字符。
## 测试
本机无 `G:\Ferti\Jyotisha\.venv`,用 `C:\Users\74082\anaconda3\python.exe`(3.11.7)。任务书写的是 Python 3.13 + swisseph。
| 命令 | 结果 |
| --- | --- |
| `pytest tests/test_vedastro_snapshot_cache.py` | **10 passed** |
| 上项 + 前台赶上/超时/oracle/growth | **18 passed** |
| `pytest tests/test_consultation_consumer_context.py tests/test_vedastro_runtime_ops.py tests/test_vedastro_snapshot_cache.py tests/test_api_server_growth_contract.py tests/test_consultation_workflow_domains.py` | **pass**(1 skip:本机未装 vedastro pin) |
| `pytest tests/test_api_server_security.py` + 上列相关 | **169 passed / 6 failed / 1 skipped**。6 条失败在 **HEAD 无本单改动时同样失败**(thematic `relationship_strict_evidence` / `月度主状态`;`shadbala` advanced:`timezone inference dependency unavailable`)。不是本单引入。 |
| `frontend`:`consultation-workflow-contract` + `consultation-spectrum-parity` | **13 passed / 0 failed** |
| `scripts/run_quality_gate.py --profile quick` | **compile 通过**(含新模块)。停在 `interpretation_source_inventory_gate.py`:`ModuleNotFoundError: No module named 'mcp'`(Anaconda 无 FastMCP)。未改依赖。 |
## 开工预检
`python scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45`:**fail**。`remote_visibility=verified`,fragment/external engine 扫描通过;`tests/test_preflight_fragment_scan.py` 在本机找不到 `.workbuddy/skills/jyotish-vedic-astrology`(Windows 工作树无该镜像)。属 ERR-007 同类环境,未新开台账。不得把 `.workbuddy` 当运行主仓。
## 5.4 staging 耗时
清单:`docs/testing/consultation-external-evidence-cache-20260915.md`。无 staging 登录,**环境缺口**。改前数字用任务书本机表(career 928/865 ms)。改后 staging 两轮 `consultationToolDurationMs` 待产品回填。本单不改三域上限 3。
## 观察项(本单不修)
`reference_date` 缺省 UTC 当天,与 UTC+8「今天」早上八点前后会错开一天。
## 既有断言改动
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| 咨询工作流默认响应 | 总有 `western_evidence_packet` | 默认无该键 | BUG-728 无读取点 |
| `_foreground_vedastro_budget_seconds` 默认 | 8 s(2–12) | `min(env, 2×join)`,join 默认 1.5 → budget 3 | 5.2 让步:预算不超过 join 两倍 |
| `tests/conftest.py` | 无缓存隔离 | autouse 把 snapshot cache 指到 tmp | 避免测试互相命中磁盘缓存 |
@@ -0,0 +1,61 @@
# PROGRESS · 重算记忆化(2026-09-15 / 执行 2026-09-16)
- 执行分支:`codex/rectification-engine-memoization-20260915`
- 工作树:`.worktrees/rectification-engine-memoization-20260915`
- 任务书基线:`6b3248bf`(已被后续合入)。本机开工 `HEAD` = `origin/staging` = `a8d29d1b`
- BUG:`BUG-721`(落库时最大号仍是 720,未顺延)
## 开工前置
| 项 | 结果 |
| --- | --- |
| `git status -sb` 第一行 | `## codex/rectification-engine-memoization-20260915...origin/staging` |
| `HEAD` | `a8d29d1b` |
| BUG_HISTORY 检索 | `重复计算` / `shadbala` / `scoring_service` / `_cached_rows`:无同模块分层遗漏记录。最大号 BUG-720 |
| 本机 Python | 主仓 `.venv` 是 25 字节指针。本 worktree 用 Anaconda 3.11.7 `--system-site-packages` 建真实 venv,复用已有 `pyswisseph 2.10.3.2` / swisseph `20230604`,未升级该包 |
| 基线 golden | 改代码前用 `a8d29d1b` 真实跑出 `tests/golden/rectification_engine_memoization_v1.json`。golden 测试改前绿 |
未改 `docs/tasks/README.md` 状态列。未推 staging。未改 `jyotish_api_server.py`、前端、`sample_event_dates`、`_SWISSEPH_LOCK`。
## 做了什么
6.1–6.6 全做,未按 §8 让步砍项。
| 条 | 做法 |
| --- | --- |
| 6.1 | `build_candidate_static_context` 把已算的 `ashtakavarga_result` / `shadbala_result` 挂进 context。辅助函数只读结果做判定。Shadbala 只复用 `sthana_bala.total` / `drik_bala` / `naisargika_bala`;测试证明带/不带 `birth_minute` 这三项相等。`birth_minute` 双算不修(§4.3) |
| 6.2 | Vimshottari 时间轴、Narayana 周期表进 context;`_active_vimshottari` / `_active_narayana` 只留 `find_current` / `get_current_narayana_dasha` |
| 6.3 | `compute_event_candidate_rows` 内局部字典按事件日期缓存过境 **chart**;规则仍按候选算。`precision == "year"` 仍早退 `[]` |
| 6.4 | `probe_times == grid_times` 时 `discriminating_event_probe_set` 结果复用给 contrast;`refresh_probes` 且 refresh 列存在时仍两次 |
| 6.5 | 删除 `scoring_service._cached_rows`。`_canonical` 仍被 `sha256` 使用,保留 |
| 6.6 | 公开 1990-01-01 北京烟测盘 + 虚构事件。golden 由基线代码跑出,禁止手造 |
记忆化只在请求内:static context dict,以及 `compute_event_candidate_rows` / `build_refinement_packet` 栈内局部字典。无模块级跨请求缓存。
## 测试
定向(任务书 §9 + 本单新文件):
| 套件 | 结果 |
| --- | --- |
| `tests/test_rectification_engine_memoization.py` | **10 passed** |
| `test_rectification_v5_services.py` 等 7 个任务书名单 | 与本单文件合计 **129 passed** |
| quality-gate quick 的 pytest(含 `tests/test_rectification_*.py`) | **776 passed, 1 skipped**(575.78s) |
本机 `run_quality_gate.py --profile quick` 在 pytest 之后的 `npm test` 失败:`tsx` 不在 PATH(Windows 前端环境缺口)。本单未改前端,不把这条写成通过。
未改任何既有断言的期望值。测试总数高于开工时(新增 10 条)。
## CHANGELOG
未改。纯内部记忆化,用户可见打分与决策回执逐字不变(计时字段除外)。
## 偏离
- 任务书写 `.venv/bin/python`,本机换成 `.\.venv\Scripts\python.exe`。
- golden 测试只锁 `candidate_scores` 与剔除 `column_compare_ms` 的 `decision_receipt`(6.6 原文)。`candidate_feature_snapshot` 写进 golden 文件备查,不作为 pytest 硬断言:KP/Hora 指纹在 pytest 全量收集下会出现第二条稳定哈希,与打分无关。
- 未 `git push origin HEAD:staging`。
## 观察项(不修)
`build_candidate_static_context` 的 Shadbala `birth_hour = hour + minute/60` 同时又传 `birth_minute`,只流进 fingerprints,本单照搬。
@@ -0,0 +1,73 @@
# PROGRESS · 一轮对话把整份 Case 档案从数据库取 3.4 次(2026-09-15 / 2026-09-16)
工作树:`.worktrees/rectification-request-dossier-cache-20260915`
分支:`codex/rectification-request-dossier-cache-20260915`
任务书基线:`6b3248bf`;代码基线 `origin/staging` @ `e4788dfc`(含 failure-attribution `8b982baf`、engine-memoization BUG-721、settled-render BUG-725)
本机 Windows。
Skill **未 bump**。未改 `frontend/src/app/page.tsx`、SQL、轮询间隔、GET 投影形状、计费口径、`docs/tasks/README.md` 状态列。未 push `HEAD:staging`。
## 开工基线
| 项 | 数字 |
| --- | --- |
| `frontend/src/app/page.tsx` | **1879** 行(未改) |
| 最大 BUG 号 | **BUG-725**(本单占用 **BUG-726**;任务书预占 726,开工时 722–724 已落库,随后 721/725 也合入 staging) |
| `tsc --noEmit` | 收尾 **0 错** |
| `npm run lint` | **0 error**(全仓既有 warning;本单文件 0 warning) |
`frontend/node_modules` 已是指向主仓的 junction,未新建。
## 任务状态
| 任务 | 状态 | 说明 |
| --- | --- | --- |
| 5.1 请求作用域缓存包装器 | 完成 | `withRectificationRequestCache`;写即失效;缓存 in-flight promise |
| 5.2 路由入口接线 | 完成 | agent / regenerate / Case GET / repair-exit POST / accept POST 各一处 |
| 5.3 前后计数 | 完成 | 见下表;预算断言钉在路由预取 + 证据轮 `≤ 4` |
| 5.4 Bug 历史 | 完成 | BUG-726 |
## 实现要点
- 新模块 `frontend/src/lib/rectification-agentic/v9/request-cache.ts`。Map 在每次 `withRectificationRequestCache` 的闭包里,不挂模块作用域、不挂 `globalThis`。
- 白名单:`get_agentic_rectification_case_dossier`、`get_agentic_rectification_case_compute`。键是 `(fn, p_user_id, p_case_id)`。
- 其它 `rpc` 先 `clear` 再转发。Proxy 透传 `.from(...)` 等属性,访问时同样清空。类型用 `as T` 保留 Supabase 客户端形状,**没有为过类型去改业务代码**,也没有收缩成只包 `rpc`。
- 调用点零改动。`fakeAccounting` 默认不包缓存(`requestCache` / `RECTIFICATION_REQUEST_CACHE=1` 显式打开);计数导出默认关闭(`countRpc` / `RECTIFICATION_RPC_COUNT=1`)。
## 改前 / 改后 dossier RPC 计数
测法:`fakeAccounting` 按实例聚合,只计打过 dossier 或 compute 的场景。改后用 `RECTIFICATION_REQUEST_CACHE=1` 包上同一批测试客户端。既有断言一条未改。
| 路径 | 场景数 | 改前 dossier(均值) | 改后 dossier(均值) | 改前 compute(均值) | 改后 compute(均值) |
| --- | ---: | ---: | ---: | ---: | ---: |
| Agent 轮(`rectification-v9-stream.test.ts`) | 41 | 143(**3.49**) | 143(**3.49**) | 27(0.66) | 27(0.66) |
| 点选题(`rectification-answer-choice.test.ts`) | 15 | 31(**2.07**) | 30(**2.00**) | 17(1.13) | 15(1.00) |
| 路由预取 + 证据轮(新预算断言) | 1 | 5 | **4** | — | — |
Agent 轮套件数字没降:这些测试的 fake stream 不执行真实工具,每次 `loadV9CaseDossier` 之间都夹着 skill identity / append / run phase 等非白名单 RPC,按「写即失效」必须重读。生产路径上路由先读档案、再进 `runV9AgentTurn` 再读一次,中间无写,预算断言覆盖这一跳(5→4)。点选题套件 dossier 31→30、compute 17→15。
## 既有断言改动
无。stream 50 / answer-choice 34 一条未改仍全绿。
## 测试
| 命令 | 结果 |
| --- | --- |
| `tsc --noEmit` | **0 错** |
| 本单文件 eslint | **0 error / 0 warning** |
| `npm run lint` | **0 error**(全仓既有 warning 同量级) |
| `tests/rectification-request-dossier-cache.test.ts` | **8 / 8 绿** |
| `rectification-v9-stream.test.ts` | **50 / 50 绿** |
| `rectification-answer-choice.test.ts` | **34 / 34 绿** |
| `tests/rectification-*.test.ts` + `agentic-rectification-*.test.ts` | **1263 pass / 1 fail / 9 skipped**。唯一失败是既有 `rectification-v9-agent.test.ts` Windows `symlinkSync` EPERM,与本单无关 |
| `page.tsx` | 仍 1879 行,未改 |
| 全量 `npm test` | `# tests 3197 / # pass 3114 / # fail 69 / # skipped 14`。比 failure-attribution 进度记录的 3189/3104/71/14 多本单 8 条绿;失败仍是无 Docker 的 database-*、部署/Caddy/shell、Windows symlink EPERM。不含本单定向套件 |
| `npx next build --webpack` | **Compiled successfully** + TypeScript 37s 过。Collecting page data 死在既有 `/api/consult` skill runtime symlink EPERM。不伪装成 `/` ○ Static 已核过 |
## 环境缺口
- 无 Docker:数据库套件红,不伪装成通过。
- 无登录态、无 Chrome:浏览器级验收不在本单。
- Windows 无 symlink 权限:`rectification-v9-agent.test.ts` 与部分 skill runtime 路径 EPERM。本机 Node 只能建 junction。
- `next build`:webpack 编译在本机可过;收集页面数据时 skill runtime symlink 仍可能 EPERM(既有缺口)。不伪装成 `/` ○ Static 已核过。
@@ -0,0 +1,107 @@
# PROGRESS · 校正会话已结算行记忆化(2026-09-15)
工作树:`.worktrees/rectification-settled-render-split-20260915`
分支:`codex/rectification-settled-render-split-20260915`
开工基线:`origin/staging` @ `a8d29d1b`(任务书写的 `6b3248bf` 已被后续合入;以当前 origin/staging 为准)
开工后远端又快进 1 个纯文档提交 `11893c7f`(普通聊天审计三单),本分支 rebase 到其上。
**BUG 编号:** 本树最大号仍是 **BUG-720**。721–724 / 726 未进本树,**BUG-725** 无冲突,未顺延。
未改 `docs/tasks/README.md` 状态列。未 `git push origin HEAD:staging`。未碰 `page.tsx`、`route.ts`、Python 引擎、React Compiler、`React.lazy`。未改 DESIGN.md(无可见样式变化)。未新写第二套输入框 / 滚动跟随 / 加载动画。
## 改前红 / 改后绿
### 5.4 结算态 Markdown 解析次数
同一段文本连续 `renderToString` 两次,可计数桩 renderer:
```
# 改前(StableMarkdownPrefix 已 export,尚无内容缓存)
not ok 5 - settled markdown parses the same text only once across consecutive renders
Expected values to be strictly equal:
2 !== 1
```
```
# 改后(rememberedProse + 结算口语/报告走 StableMarkdownPrefix)
ok 5 - settled markdown parses the same text only once across consecutive renders
# tests 5
# pass 5
# fail 0
```
### 5.5 按帧渲染计数
```
# 改前(行组件文件尚不存在)
Error [ERR_MODULE_NOT_FOUND]: Cannot find module
'.../frontend/src/components/rectification-message-entry.tsx'
not ok 1 - tests\\rectification-settled-render-split.test.ts
```
```
# 改后
ok 1 - the chat container no longer derives per-row timeline, varga, or choice cards
ok 2 - the extracted row is memoised and takes no function props except actionsRef
ok 3 - the split architecture renders settled rectification rows once while streaming tokens
# tests 3
# pass 3
# fail 0
```
手驱动 4 条已结算 + 5 个流式帧:拆分路径 `settledRowRenders = 4`、`streamingRowRenders ≤ 5`;未拆分对照 `unsplitListRenders = 5`、`settledRowRenders = 25`。拆分计数严格小于对照。
## 做了什么
| 任务 | 状态 | 落点 |
| --- | --- | --- |
| 5.4 结算态 Markdown 记忆化 | 完成 | `chat-message-content.tsx`:`StableMarkdownPrefix` 加按文本解析缓存;结算口语层与报告层都走它 |
| 5.1 抽出 `RectificationMessageEntry` + `memo` | 完成 | 新文件 `rectification-message-entry.tsx`;时间轴 / varga / choiceCard / afterAnswer 全部在组件内 |
| 5.2 `actionsRef` | 完成 | 容器 `useEffect` 每帧写 `.current`(与咨询面 `page.tsx` 相同,避免渲染期写 ref 撞 `react-hooks/refs`) |
| 5.3 `ChatMessageRow` `memo` | 完成,未砍 | 咨询面同样受益 |
| 5.5 渲染计数断言 | 完成,未砍 | `tests/rectification-settled-render-split.test.ts` |
| 5.6 BUG-725 | 完成,未砍 | 关联 BUG-473、BUG-249 |
交付卡 / 只读范围仍在容器 `messages.map` 里(标量 host 判定 + 既有 `candidateResult` state),不把每帧新建的 JSX 当行 props。
## 验收命令
| 命令 | 结果 |
| --- | --- |
| `./node_modules/.bin/tsc --noEmit` | 退出 0,无输出 |
| `npm run lint` | **0 error**,119 warning(既有,本单未新增、未顺手修) |
| `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` 及本单改过的合同文件 | 定向套件 fail=0 |
| `npm test` | **3108** 条(含本单新增 4 条:结算 Markdown 1 + 行拆分 3)。pass 3021 / fail 73 / skipped 14。失败清单见下,均为环境缺口,不是本单回归 |
| `npm run build` | 默认 Turbopack 因 worktree `node_modules` junction 指向主仓而 panic(`points out of the filesystem root`) |
| `next build --webpack` | Compile + TypeScript 通过(3.7 min + 46 s)。Collecting page data 在 `/api/birth-time-guide` 因 Windows `EPERM` 无法为 `SKILL.md` 建 runtime symlink 而失败。本机看不到 `○ /` 路由表。`page.tsx` / `layout.tsx` / `globals.css` 未改,首屏源码差为 0 |
## 全量失败清单(环境缺口,与本单无关)
Windows、无 Docker、无 Git Bash、skill runtime 不能建 symlink。典型几类:
- `database-*.test.ts` / 迁移 / RLS / Better Auth:无 Docker
- `skill-binding` / `rectification-v9-agent` / `rectification-v9-stream` / `rectification-exhaustion-exit` 等整文件:`SkillPackageRegistryError` `EPERM: symlink`
- `*.sh` / staging sync / compose env / is-docs-only-range:Windows 无 POSIX shell
- `every color token used as a Tailwind utility is exposed through @theme`:既有 warning 级合同,本单未改 CSS
本单引起、后来已修的合同红(改 token 路径,三栏见各测试注释):`chat-answer-detail`、`chat-bundle-splitting-contract`、`rectification-agentic-entry`、`rectification-spoken-collect`、`rectification-surface-contract`、`chat-composer-queue`、`rectification-adopt-flow`、`rectification-delivery-ui-simplify`、`rectification-targeted-card-live`、`rectification-timeline-adapter`、`chat-stream-layout`、`agent-voice-copy-contract`、`rectification-question-in-message`、`rectification-varga-style-copy`、`rectification-walkthrough-polish`。修完后这些在定向重跑里全绿。
## 既有断言三栏(摘要)
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `chat-markdown-split.test.ts` | `: renderProse(spoken, renderMarkdown)` | `: <StableMarkdownPrefix text={spoken} … />` | BUG-725 结算口语层记忆化 |
| `chat-bundle-splitting-contract.test.ts` | `renderProse(spoken/report)` 计数 2 | `StableMarkdownPrefix` 结算两层计数仍为 2;尾块仍 `renderProse(split.tail)` | 同上,计数不放宽 |
| `chat-answer-detail.test.ts` | `{renderProse(report, renderMarkdown)}` | `<StableMarkdownPrefix text={report} … />` | 报告层同样记忆化 |
| 若干校正源码合同 | token 在 `rectification-agentic-chat.tsx` 的 `messages.map` | 同一 token 在 `rectification-message-entry.tsx` | 5.1 抽出行组件 |
## 真人验收
`docs/testing/rectification-settled-render-split-20260915.md`。本环境无登录态、无 Chrome,长会话是否还卡**未测**,不要写成通过。
## 明确未做
- 把流式文本从 `messages` 搬进独立 state(任务书 §3.4 否决)
- React Compiler
- 首屏包体积(584 KB gzip 是全站壳,另议;本单未改首屏源)
- 校正面任何交互缺陷
+4 -2
View File
@@ -231,16 +231,18 @@
| `TASK-rectification-title-repair-migration-20260915.md` | — | BUG-699 / 704 的数据修补写成了 Node 脚本(要 `SCHEMA_DATABASE_URL`),但 `Migrate Staging Database` 只跑 `migrator` 应用 SQL 迁移、不执行任意脚本——产品没有任何按钮能修自己那批错名字的会话。脚本里本来就是纯 SQL,搬进一次性迁移即可复用现成按钮。生产停在 `7b620c7a`(无 `use-rectification-surface.ts`),where 自然匹配 0 行,是 no-op | 待领取 | `codex/rectification-title-repair-migration-20260915` |
| `TASK-staging-dispatch-autofill-sha-20260915.md` | `PROGRESS-staging-dispatch-autofill-sha-20260915.md` | `Migrate Staging Database` 每次都要手抄 40 位 SHA,而那个值恰恰是「最新一个过门禁的 staging 提交」——机器能自己算,查询代码那一步里就有。改成留空自动解析、填了仍走原路径(回滚用),三条安全属性一条不丢。**产品 2026-09-15 明确授权修改该 workflow,执行方不得以 AGENTS.md §2.7 拒改**;生产两个按钮保持手填,那是护栏不是麻烦 | 待验收 | `codex/staging-dispatch-autofill-sha-20260915` |
| `TASK-staging-auto-migrate-on-deploy-20260915.md` | `PROGRESS-staging-auto-migrate-on-deploy-20260915.md` | 门禁通过后自动先跑 staging 迁移再部署,不再手点(迁移幂等、无挂起时是 no-op,`db-migrate.mjs --check` 挂起返 3 可用于日志)。今天 `deploy-staging.yml` 完全不提迁移,忘点就让新代码跑在旧 schema 上且无人拦。**产品再次授权改 workflow,范围限 `backend-quality-gate.yml` 的 dispatch 段**;迁移失败必须阻断部署;回滚不自动迁移;生产完全不动。⚠️ 同轮必须把「迁移须对已部署代码向后兼容、破坏性变更拆两轮」写进 AGENTS.md §7.6 | 待验收 | `codex/staging-auto-migrate-on-deploy-20260915` |
| `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-api-server-decomposition-20260916.md` | `PROGRESS-api-server-decomposition-20260916.md` | **重构单(串行在 qizheng 单之后;2026-09-15 又加两条前置:C1 外网缓存单先做、freeze-metric-change 先落地)**:把业务逻辑搬出 `JyotishAPIHandler`。核心不是行数,是 3 个文件 **4 处**靠 `JyotishAPIHandler.__new__` 伪造空壳 handler 借方法(`consultation_workflow_service` ×2、`capture_report_blocked_repairs_golden`、`local_accuracy_report`,MCP 也走这条),依赖方向反了、handler 没有 `headers`/`wfile` 随时可炸——实测佐证:**225 个类方法里只有 12 处真的碰 HTTP 上下文**。四阶段:拆 `__new__` 后门 → 抽 ≥150 行业务方法 → `do_POST`/`do_GET` 改路由表 → **阶段 4 已改写**:收尾不再是「行数 baseline + 余量 300→50」(那只是把问题推到三个月后),改成主门 `__new__` 计数必须为 0 + 类方法数不得增长,行数退为粗护栏保持 300 余量。纯搬运不改行为,`test_api_server_security.py` 3841 行断言一条不许改。预计 11,334 → 约 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 个月**产品 2026-09-15 决定不改、研究单也不立**。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。**产品 2026-09-15 决定:意图分类继续用会话选定的贵模型,不新增「工具模型」角色** | 待领取 | — |
| `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 | 待领取 | — |
| `TASK-consultation-external-evidence-cache-20260915.md` | — | **普通聊天性能单(Python,串行在 api-server-decomposition 之后)**:每轮每域同步等外网,cProfile 前三名全是 `api.vedastro.org` 的 HTTPS 往返(0.801 + 0.786 + 0.206 s),本地 swisseph 只有 0.022 s。三个护栏数字凑不齐:前台等 1.5 s、后台跑 8 s、线程池只有 2 个 worker,且超时**不 cancel** → 每 4 秒一轮就长期饱和,之后每轮白等再拿 `official_blocked`(BUG-727)。另 `western_evidence_packet` 122 KB 前端零读取点(BUG-728)。**产品定案**:按「出生数据+岁差+交点+UTC 日期」缓存(与引擎 `_official_snapshot_reference_date` 同键,否决自定 TTL),同日 0 等待 / 跨日先用旧的(≤7 天)后台刷新 / `daily_starlanguage` 要求当天 / 冷启动才走 1.5 s。**不许「干脆不调」——那会重开 BUG-301。** 另含 staging 单域耗时实测单(代码注释里的 21 s 与本机 0.5 s 差 40 倍,三域上限就是从它推的)。BUG 段 727–728 | 待领取 | — |
| `TASK-consultation-external-evidence-cache-20260915.md` | `PROGRESS-consultation-external-evidence-cache-20260915.md` | **普通聊天性能单(Python;2026-09-15 产品拍板改为排在 api-server-decomposition 之前)**:每轮每域同步等外网,cProfile 前三名全是 `api.vedastro.org` 的 HTTPS 往返(0.801 + 0.786 + 0.206 s),本地 swisseph 只有 0.022 s。三个护栏数字凑不齐:前台等 1.5 s、后台跑 8 s、线程池只有 2 个 worker,且超时**不 cancel** → 每 4 秒一轮就长期饱和,之后每轮白等再拿 `official_blocked`(BUG-727)。另 `western_evidence_packet` 122 KB 前端零读取点(BUG-728)。**产品定案**:按「出生数据+岁差+交点+UTC 日期」缓存(与引擎 `_official_snapshot_reference_date` 同键,否决自定 TTL),同日 0 等待 / 跨日先用旧的(≤7 天)后台刷新 / `daily_starlanguage` 要求当天 / 冷启动才走 1.5 s。**不许「干脆不调」——那会重开 BUG-301。** 另含 staging 单域耗时实测单(代码注释里的 21 s 与本机 0.5 s 差 40 倍,三域上限就是从它推的)。BUG 段 727–728 | 待验收 | `codex/consultation-external-evidence-cache-20260915` |
| `TASK-consultation-context-memory-20260915.md` | — | **记忆三缺口(TS,可并行)**:历史超预算时从最老整轮丢弃,`droppedCount` 算了却**全仓零读取点**,模型不知道少看了几轮——单条截断有「省略 N 字」标记,整轮丢弃没有(BUG-729,BUG-555 防复发只写了「头部截断」所以漏网);写摘要阈值写死 16,000,历史预算却是 `clamp((窗口−60k)×1.5, 4k, 40k)`,窗口 < **70,667** 时预算低于阈值 → 每轮静默丢(BUG-730,后台上架中等窗口模型即触发);写满时服务端存着摘要,`continueInNewChat` 只带问题不带摘要,而 `context_summary` 根本不在任何会话接口的列里(BUG-731)。**产品定案:静默继承**,且摘要文本永远不许由客户端提供(`chatSessionCreateSchema` 只收来源会话 uuid)。BUG 段 729–731 | 待领取 | — |
| `TASK-consultation-session-capacity-20260915.md` | — | **对话上限单(一份迁移,可并行;不碰 route.ts)**:`append_consultation_question` 的 200,000 字符额度里,`thinkingText`(≤4,000) + `thinkingSections`(实测 1,521/2,243/2,977) 占一半以上,而 `techniqueTruth`/`workflowReceipt`/`agentExecutionReceipt` 照样入库却不计入——同一条上限身兼二职且两职都没做好,约 **19 轮** 就「已写满」(200 条那档永远碰不到)。**产品定案:思考文本不计入**,额度只数用户读得到的正文(约 19 → 约 50 轮),另设一条按 `length(elem::text)` 把全部字段算全的物理上限(算式取 1,000,000,写进迁移注释)护住数据库行;两档都返回同一个 `session_full`。保留 advisory lock / 幂等 / 满员拒绝(BUG-464 防复发)。BUG 段 732 | 待领取 | — |
| `TASK-freeze-metric-change-20260915.md` | — | **规则单(后面两单的前置,无 BUG 号)**:两条增长冻结余量都用完(`page.tsx` 1,951/1,951 余 **0**;`jyotish_api_server.py` 11,334/11,363 余 **29**),冻结从「逼新代码往外走」退化成「拦路」。实证:`page.tsx` 行数砍 59% 但 `Home()` 的 `useState` 从 56 涨到 **66**(拆的是代码不是状态);api server **225 个类方法只有 12 处真碰 HTTP 上下文**,4 处 `__new__` 伪造空壳就是这么来的。**产品拍板换口径**:主门改成「`Home()` 的 useState/useRef 不得增长」与「类方法数 + `__new__` 计数不得增长」,行数降级为粗护栏;**同时推翻 §6「参数式 hook 内部保持 0 个 React hook」**(那正是状态搬不走的原因)。改 `AGENTS.md` §6 + 两个合同测试,不碰业务代码 | 待领取 | — |
| `TASK-home-state-lowering-20260915.md` | — | **page.tsx 状态下沉第一簇(串行在 freeze-metric-change + C2 + R3 之后)**:66 个 state 里 `rectification*` 占 **15** 个,而它们服务的 `<ConversationalBirthTimeRectification>` 本来就是 `dynamic()` 懒加载子树、挂着 24 个 props;`useRectificationSurface` 要解构约 56 个参数。把这簇搬进子树,`Home()` 的 useState 从 66 降到 ≤ 53。**零行为变化**;第一步必须先把 15 个逐个分类(只服务子树 / 外壳也要读)。产品否决了 Context Provider 与外部 store 两条路。不占 BUG 号 | 待领取 | — |
## 命名与归档
@@ -4,11 +4,13 @@
- 代码基线:`origin/staging` = **`2d7698ea`**(文档树 `6f74aa67`)。开工时以最新 `origin/staging` 为准。
- **串行依赖**:本单必须等 `TASK-qizheng-native-chart-20260915` **合入 staging 之后**才开工。那一单独占 `scripts/jyotish_api_server.py` 的写权,并行等于自找冲突。
- **2026-09-15 新增串行依赖**:还要等 `TASK-consultation-external-evidence-cache-20260915`(C1)合入。产品拍板 C1 先做——它动的是模块级函数(`execute_consultation_workflow`、`_join_foreground_vedastro`、三个前台 VedAstro 常量),本单动的是 `JyotishAPIHandler` **类**,重叠不大;C1 的用户价值(每轮省掉一次外网等待)高于一次纯搬运。**本单要吸收 C1 的 diff**:开工时 `execute_consultation_workflow` 里会多出走缓存模块的分支,照常搬运即可,不得把它改回去。
- **2026-09-15 新增前置**:`TASK-freeze-metric-change-20260915` 决定了阶段 4 的收尾口径(见下方阶段 4 的改写),本单收尾前它必须已合入。
- 本单不改任何行为,不新增功能,不动前端。
## 为什么要做:不是因为文件长,是因为它成了事实上的服务层
`scripts/jyotish_api_server.py` 现在 **11,314 行**,契约上限 11,363(`tests/test_api_server_growth_contract.py`:baseline 11,063 + 300 bugfix 余量)——**只剩 49 行**。
`scripts/jyotish_api_server.py` 现在 **11,334 行**(立单时 11,314,2026-09-15 实测已涨到 11,334),契约上限 11,363(`tests/test_api_server_growth_contract.py`:baseline 11,063 + 300 bugfix 余量)——**只剩 29 行**。
但真正的问题不是行数,是这个:
@@ -30,7 +32,7 @@ def build_runtime_evidence_helpers(chart):
那个声称是「shared consultation workflow boundary for API and MCP callers」的模块,**反过来依赖单体文件**,并且靠 `__new__` 绕过 `BaseHTTPRequestHandler.__init__` 造一个空壳 handler,只为借用它身上的方法。
全仓一共 **3 处**这样做:
全仓 **3 个文件、4 处调用**这样做(下表第一行含两处;收尾断言按 `grep -c` 的 **4** 计,不要按文件数的 3 计):
| 位置 | 用途 |
| --- | --- |
@@ -141,17 +143,24 @@ def build_runtime_evidence_helpers(chart):
- 404 行为、方法不允许、限流 429、异常映射全部不变。
- `do_POST` + `do_GET` 合计 ≤ 90 行。
### 阶段 4 · 重新冻结行数契约(必做,收尾)
### 阶段 4 · 收尾重新冻结(必做,收尾)—— 2026-09-15 改写
- 把 `tests/test_api_server_growth_contract.py` 的 `JYOTISH_API_SERVER_LINE_COUNT_BASELINE` 更新为**本单收尾时的实际行数**。
- 余量从 **300 改为 50**。
- 同步更新该文件顶部 docstring 里的日期与说明,以及 `AGENTS.md` §6 里引用的口径。
- **不得**在本单之外的任何轮次里调高 baseline。
**原方案(行数 baseline 重设 + 余量 300→50)已作废。** 产品 2026-09-15 拍板换口径:余量收到 50 行只是把今天的问题推到三个月后,下一次 bugfix 又会立刻撞墙。新口径由 `TASK-freeze-metric-change-20260915` 落地,本单收尾时按它已经立好的尺子填新基线:
| 门 | 收尾时要达到 |
| --- | --- |
| 主门 · `JyotishAPIHandler.__new__` 全仓计数 | **必须为 0**(今天 4)——这是「拆干净了」唯一不可伪造的证据 |
| 主门 · `JyotishAPIHandler` 类方法数 | 显著低于 225,并把新值写成不得增长的新基线 |
| 粗护栏 · 文件行数 | baseline 重设为收尾实际行数,余量保持 **300**(不再收到 50) |
- 同步更新 `tests/test_api_server_growth_contract.py` 顶部 docstring 的日期与说明。
- **不得**在本单之外的任何轮次里调高任何一条基线。
**验收标准**
- `tests/test_api_server_growth_contract.py` 通过,且新 cap = 新实际行数 + 50。
- `AGENTS.md` §6 的措辞与新口径一致。
- `grep -rn "JyotishAPIHandler.__new__" --include=*.py .`(排除 `skills/*/versions/**`)**零命中**,且该断言已经写进合同测试。
- 类方法数新基线已写入合同测试,且人为加一个类方法能让它变红(贴反向验证)。
- `AGENTS.md` §6 的措辞与新口径一致(措辞由 freeze-metric-change 单先行落地,本单只填数)。
## 预期收益(按 AST 实测推算,允许 ±10%)
@@ -4,7 +4,7 @@
- 基线 commit:`origin/staging` @ `6b3248bf`
- 执行分支:`codex/consultation-external-evidence-cache-20260915`
- 主要落点:`scripts/jyotish_api_server.py`、`scripts/vedastro_service_adapter.py`、`scripts/vedastro_user_entrypoint.py`
- **串行在 `TASK-api-server-decomposition-20260916` 之后**:那一单独占 `scripts/jyotish_api_server.py`,本单必须以它合入后的 staging 为基线
- **依赖已于 2026-09-15 反转:本单排在 API server 拆解单之前。** 原文写的是「串行在 `TASK-api-server-decomposition-20260916` 之后」,产品拍板改为**本单先做**——理由见 §3.7,拆解单已同步改为串行在本单之后
- 与 context-memory 单、session-capacity 单无文件重叠,可并行
---
@@ -69,6 +69,7 @@ cProfile 按 `tottime` 排前五名:
4. **后台任务必须可取消、线程池必须可排队。** 现在「超时不 cancel + 只有 2 个 worker」的组合是本单必须解决的部分,不是顺带。
5. **`western_evidence_packet` 按需返回,不删计算。** 西洋盘本身仍是 must-use 层之一(被读的是别的字段),本单只是不再把这个整包塞进每一轮的响应。
6. **不动并发闸门(默认 2)、不动 `AGENT_TIMEOUT_MS`、不动 `maxDuration`。**
7. **本单排在 API server 拆解之前**(2026-09-15 产品拍板,推翻本任务书首版的排序)。依据:本单要改的 `execute_consultation_workflow`、`_join_foreground_vedastro`、那三个常量**全是模块级函数**,而拆解单的核心是把业务逻辑搬出 `JyotishAPIHandler` **类**,两边动的是文件的不同部分;且本单的用户价值(每一轮省掉一次外网等待)远高于一次纯搬运。代价是拆解单将来要吸收本单的 diff,已在那一单里写明。
## 4. 硬红线
@@ -76,7 +77,8 @@ cProfile 按 `tottime` 排前五名:
2. 赶不上外网时,不得把未交付的官方层标成 `executed`(BUG-301 防复发)。用旧缓存时,证据里必须能看出它是哪一天的。
3. 缓存内容里不得落盘姓名、邮箱、用户 ID;出生资料派生值只能以哈希进键,不得明文写进缓存文件名(AGENTS §8)。
4. 前台不得再同步串联 overview + snapshot + range scan(BUG-301 防复发,现状已满足,不得回退)。
5. `scripts/jyotish_api_server.py` 不得增长(AGENTS §6):新逻辑进 `scripts/` 下的独立模块,主文件只做薄注册。本单又恰好排在拆分单之后,更不能把行数吃回去。
5. `scripts/jyotish_api_server.py` 不得增长(AGENTS §6):新逻辑进 `scripts/` 下的独立模块,主文件只做薄注册。**具体到数字**:开工时实测 11,334 行、上限 11,363,**余量只有 29 行**;`JyotishAPIHandler` 有 225 个方法,**本单不得新增任何类方法**。
若 `TASK-freeze-metric-change-20260915` 已经合入,主门改为「类方法数不得增长」,行数粗护栏放宽——**以开工当时生效的合同测试为准**,但「不新增类方法、新逻辑进独立模块」这条无论口径怎么换都成立。
6. 不得顺手升级依赖、不得顺手修不在本单里的 warning。
## 5. 任务分解
@@ -137,8 +139,8 @@ cProfile 按 `tottime` 排前五名:
```bash
git fetch origin --prune
# 先确认 api-server-decomposition 单已合入 staging
git log --oneline origin/staging | head -10
# 本单不再等 api-server-decomposition;直接以 origin/staging 为基线
git log --oneline origin/staging | head -5
git worktree add -b codex/consultation-external-evidence-cache-20260915 \
.worktrees/consultation-external-evidence-cache-20260915 origin/staging
cd .worktrees/consultation-external-evidence-cache-20260915
@@ -164,6 +166,7 @@ cd frontend && npx tsx --test tests/consultation-*.test.ts
## 9. 不在本单范围
- `JyotishAPIHandler` 的拆解本身(本单只做薄注册,拆解排在本单之后)
- 三域上限 3 → N 的放宽(5.4 只负责量数据,放宽是产品决策)
- 并发闸门、`AGENT_TIMEOUT_MS`、`maxDuration`
- `reference_date` 缺省用 UTC 当天、而用户的「今天」是 UTC+8,早上八点前后会错开一天——**记为观察项,本单不修**
@@ -0,0 +1,159 @@
# TASK · 把两条增长冻结从「数行数」换成「数耦合」
- 日期:2026-09-15
- 基线 commit:`origin/staging` @ `6b3248bf`
- 执行分支:`codex/freeze-metric-change-20260915`
- 落点:`AGENTS.md` §6、`tests/test_api_server_growth_contract.py`、新建 `frontend/tests/home-shell-growth-contract.test.ts`、`frontend/tests/chart-view-route.test.ts`(挪走一条断言)
- **这一单是后面两单的前置**:`TASK-home-state-lowering-20260915` 与 API server 拆解都需要新口径先生效,否则它们做的事会被旧门禁判红
- 与七条在飞分支无文件重叠(它们都不碰这两个合同测试,也都声明「`page.tsx` 一行不许动」)
---
## 1. 为什么要换
两条冻结的余量都用完了:
| 文件 | 当前 | 上限 | 余量 |
| --- | ---: | ---: | ---: |
| `frontend/src/app/page.tsx` | 1,951 行 | 1,951 | **0** |
| `scripts/jyotish_api_server.py` | 11,334 行 | 11,363 | **29** |
冻结当初的用意是逼新代码往 hooks / lib / 独立模块里走,第一阶段确实起了作用(`page.tsx` 从 4,766 砍到 1,951)。但顶到线之后它的作用反转:**任何一轮正常改动只要需要在这两个文件里加几行接线就会被打红**,这一轮于是被迫去做一件与本次目标无关的搬运。上一轮的 `TASK-rectification-p0-fix-20260915` 就是这么来的——BUG-705 的十来行接线让 1,951 变 1,964,逻辑本身早就在 lib 里了,超的只是接线。
而行数从来不是维护成本的来源。两个文件各有一个能直接表达耦合的数字:
**`page.tsx`**
| 指标 | BUG-249 当时 | 今天 |
| --- | ---: | ---: |
| 文件行数 | 4,766 | 1,951 |
| `useState` | 56 | **66** |
| `useEffect` | 18 | 22 |
| `useRef` | — | 41 |
| `useCallback` / `useMemo` | 0 / 0 | 0 / 0 |
**行数砍掉 59%,状态反而从 56 涨到 66。** 抽出去的 hook 是参数式的(§6 现有那句「参数式 hook 内部保持 0 个 React hook」),`useSessionManagement(params)` 开头要解构约 40 个参数——代码搬走了,状态所有权一个都没搬。
**`scripts/jyotish_api_server.py`**
| 项 | 数量 |
| --- | ---: |
| `JyotishAPIHandler` 方法 | **225**(8,219 行,占全文件 72%) |
| 模块级函数 | 102(2,981 行) |
| `do_POST` / `do_GET` 路径分支 | 78(`do_POST` 单个方法 263 行) |
| `self.headers` / `self.wfile` / `self.rfile` / `self.path` 全文件出现次数 | **12** |
| `JyotishAPIHandler.__new__` 伪造点 | **4** |
**225 个方法,只有 12 处真的碰到 HTTP 上下文。** 其余是披着 `self` 外衣的纯函数,这正是那 4 处 `__new__` 后门的成因。
## 2. 根因
冻结盯的是「文件有多大」,而维护成本来自「谁依赖谁」。行数是耦合的影子:影子被按住了,本体照长不误——`page.tsx` 的状态数、api server 的类方法数在冻结期内都是**增长**的。
## 3. 决策记录
产品 2026-09-15 拍板:**两条冻结的口径都要换**,并明确以下三点。
1. **主门换成耦合指标,行数降级为粗护栏。** 不是取消行数限制,而是把它 rebaseline 到有余量的位置,让它只拦住「整块新功能塞进来」这种明显情况;真正的门是下面两组数。
2. **推翻 `AGENTS.md` §6 现有的「参数式 hook 内部保持 0 个 React hook 的既定模式」。** 这条正是状态搬不走的原因:它要求抽出去的 hook 不持有 React 状态,于是状态只能留在 `Home()`。产品明确授权改掉它——**新口径下,抽出去的 hook 与子组件应当持有自己的状态**。执行方不得以「AGENTS 有这条」为由拒改;本节就是那条红线的推翻记录。
3. **`__new__` 计数这一轮只要求「不得增长」,不要求为 0。** 现在是 4 处,收到 0 是 API server 拆解单的验收标准,不是本单的。本单只负责把尺子立起来。
## 4. 硬红线
1. **本单不改任何业务代码。** 只改 `AGENTS.md` §6、两个合同测试,以及把一条断言从它现在寄居的文件挪到专用文件。`page.tsx` 与 `jyotish_api_server.py` 一行不许动。
2. **不得放宽既有的其它冻结条款。** §6 第三条(不得再手写第二个聊天输入框 / 第二套滚动跟随 / 第二套加载动画)原样保留。
3. 行数 rebaseline 的新基线必须取**开工当时的实测值**,并在测试注释里写明取值日期与 `wc -l` 的结果,不得抄本任务书里的数字(七条在飞分支合并后这些数会变)。
4. 新的耦合指标基线同理:`useState` / `useRef` / 类方法数 / `__new__` 计数都以开工当时实测为准,只许降不许升。
5. 不得顺手升级依赖、不得顺手修不在本单里的 warning。
## 5. 任务分解
### 5.1 `page.tsx`:新建专用合同测试
现在这条断言寄居在 `frontend/tests/chart-view-route.test.ts` 的 `page.tsx does not grow to host the chart page` 里(`assert.ok((pageSource.match(/\n/g) ?? []).length <= 1951)`)——它和星盘页没有关系,只是当时顺手放在那儿。新建 `frontend/tests/home-shell-growth-contract.test.ts`,把增长约束集中过去:
| 门 | 断言 | 今天的值 |
| --- | --- | ---: |
| 主门 | `Home()` 里的 `useState` 数不得增长 | 66 |
| 主门 | `Home()` 里的 `useRef` 数不得增长(防止把 state 改写成 ref 绕过上面那条) | 41 |
| 粗护栏 | 文件行数 ≤ 实测基线 + 150 | 1,951 |
`chart-view-route.test.ts` 里保留与星盘页真正相关的那半条(`assert.doesNotMatch(pageSource, /chart-page|ChartPageView|\/api\/chart-view/)`),行数断言删除并注明搬到了哪里。
- 验收:新测试在当前代码上绿;人为在 `page.tsx` 加一个 `useState` 后必须红(执行方在进度记录里贴出这次反向验证,证明尺子会动,不是恒为真)。
- 验收:计数方式要能区分 `useState(` 与 `useState<Type>(`(今天 66 这个数就是按 `\buseState[<(]` 数出来的;只按 `useState(` 数会漏掉一半)。
- 验收:`npx tsx --test tests/chart-view-route.test.ts` 仍绿,且该文件不再包含行数断言。
### 5.2 `jyotish_api_server.py`:改 `tests/test_api_server_growth_contract.py`
现在是 `JYOTISH_API_SERVER_LINE_COUNT_BASELINE = 11063` + 300。改成:
| 门 | 断言 | 今天的值 |
| --- | --- | ---: |
| 主门 | `JyotishAPIHandler` 的方法数不得增长 | 225 |
| 主门 | 全仓 `JyotishAPIHandler.__new__` 出现次数不得增长 | 4 |
| 粗护栏 | 文件行数 ≤ 实测基线 + 300 | 11,334 |
方法数用缩进匹配(`^ (?:async )?def \w+`)即可,和本任务书 §1 那张表同一种数法。`__new__` 计数扫 `scripts/` 与 `tests/`,把命中文件列进断言失败信息,方便下次一眼看到是谁又开了后门。
- 验收:新断言在当前代码上绿;人为加一个类方法后必须红(同样贴反向验证)。
- 验收:该文件现有的另外三条断言(`AGENTS.md` 里必须出现 `must not grow` / `thinly registered` / 该测试必须在 `CORE_PYTEST_TARGETS` 与快速门里)一条不改仍绿。
- 验收:`.venv/bin/python -m pytest tests/test_api_server_growth_contract.py` 通过。
### 5.3 改 `AGENTS.md` §6
两条改写,逐字说明新口径:
- 第一条(api server):把「冻结时行数 + 300 行 bugfix 余量」换成「**类方法数不得增长、`__new__` 伪造点不得增长**,行数是粗护栏」;保留「新端点进独立模块、主文件只做薄注册」这句话——新口径正是在奖励它。
- 第二条(`page.tsx`):把「不得再增长」换成「**`Home()` 的 `useState` / `useRef` 数不得增长**」;**删掉「参数式 hook 内部保持 0 个 React hook 的既定模式」**,改成「抽出去的 hook 与子组件应当持有自己的状态;`page.tsx` 只做装配」。
同时在 §6 里点明这次换口径的理由一句话(行数是耦合的影子),免得下一轮有人以为是放水。
- 验收:`tests/test_api_server_growth_contract.py` 里那条「`AGENTS.md` 必须包含 must not grow / thinly registered」的断言仍绿(措辞改写时不要把这两个短语弄没了)。
- 验收:`frontend/AGENTS.md` 若有重复表述,同轮对齐。
### 5.4 记录
本单不产生 Bug 记录(改的是规则,不是缺陷),也不进 `CHANGELOG.md`(无用户可感知变化)。进度记录里必须写清楚:四组基线的实测值、取值日期、以及两次反向验证的结果。
## 6. 让步顺序
1. 5.2(api server 口径)最先做——它直接决定 C1 那一单还要不要为 29 行余量拧巴。
2. 5.1 次之。
3. 5.3 必须和前两条同轮(规则和门禁不许分家,否则下一轮有人按旧 AGENTS 拒改)。
4. 5.4 不得砍。
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/freeze-metric-change-20260915 \
.worktrees/freeze-metric-change-20260915 origin/staging
cd .worktrees/freeze-metric-change-20260915
git status -sb | head -1
# 取当时实测基线,不要抄任务书里的数
wc -l scripts/jyotish_api_server.py frontend/src/app/page.tsx
grep -cE '^ (async )?def ' scripts/jyotish_api_server.py
grep -rc 'JyotishAPIHandler.__new__' scripts/ tests/ | grep -v ':0'
grep -cE '\buseState[<(]' frontend/src/app/page.tsx
grep -cE '\buseRef[<(]' frontend/src/app/page.tsx
```
验收命令:
```bash
.venv/bin/python -m pytest tests/test_api_server_growth_contract.py
.venv/bin/python scripts/run_quality_gate.py --profile quick
cd frontend && npx tsx --test tests/home-shell-growth-contract.test.ts tests/chart-view-route.test.ts
npx tsx --test tests/*.test.ts # 与基线逐条比对失败清单
```
## 8. BUG 编号起点
本单不占 BUG 号。基线 `6b3248bf` 上最大号 **BUG-720**,721–732 已被两轮审计七单预占。
## 9. 不在本单范围
- 真的去搬状态或搬方法(见 `TASK-home-state-lowering-20260915.md` 与 API server 拆解单)
- `__new__` 收到 0(拆解单的验收标准)
- §6 第三条的三个「不得再手写第二套」条款
@@ -0,0 +1,159 @@
# TASK · 把校正面的 15 个状态从首页搬下去(page.tsx 状态下沉 · 第一簇)
- 日期:2026-09-15
- 基线 commit:`origin/staging` @ `6b3248bf`(开工时以最新 `origin/staging` 为准,见 §7)
- 执行分支:`codex/home-state-lowering-20260915`
- 落点:`frontend/src/app/page.tsx`、`frontend/src/hooks/use-rectification-surface.ts`、`frontend/src/components/conversational-birth-time-rectification.tsx`(或新建的容器组件)
- **串行依赖(三条,缺一不可)**:
1. `TASK-freeze-metric-change-20260915` —— 本单要让抽出去的 hook 持有 React 状态,那正是现行 `AGENTS.md §6` 禁止的;必须等新口径生效
2. `TASK-consultation-context-memory-20260915`(C2)—— 它在改 `use-session-management.ts` / `use-consultation-run.ts`,与本单同一片状态层
3. `TASK-rectification-settled-render-split-20260915`(R3)—— 它在改 `rectification-agentic-chat.tsx`,与本单同一片子树
- 规模:一簇状态换个住处。**零行为变化、零文案变化。**
---
## 1. 为什么是这一簇
`page.tsx` 现在 1,951 行,行数上限 1,951,**余量 0**。但行数不是病根:
| 指标 | BUG-249 当时 | 今天 |
| --- | ---: | ---: |
| 文件行数 | 4,766 | 1,951 |
| `Home()` 的 `useState` | 56 | **66** |
| `useEffect` | 18 | 22 |
| `useRef` | — | 41 |
| `useCallback` / `useMemo` | 0 / 0 | 0 / 0 |
**行数砍掉 59%,状态反而从 56 涨到 66。** 前几轮拆的是代码不是状态:抽出去的 hook 是参数式的,`useSessionManagement(params)` 开头要解构约 40 个参数,`useRectificationSurface(params)` 约 56 个——状态所有权一个都没搬,每搬一次还要新增一批传参。
66 个 state 按归属分群:
| 群 | 个数 |
| --- | ---: |
| **`rectification*`** | **15** |
| `session*` | 10 |
| `profile*` | 6 |
| `synastry*` | 4 |
| `account*` | 4 |
| 其余分散 | 27 |
最大的一簇服务的是一个**已经是 `dynamic()` 懒加载的子树**:`<ConversationalBirthTimeRectification>` 挂着 **24 个 props**,而喂它们的 15 个 state 全住在 `Home()` 里。边界最清楚、收益最大,所以第一簇搬它。
那 15 个是:
```
rectificationSessionId rectificationShouldStartOpening
rectificationCaseId rectificationTurns
rectificationHeaderSlot rectificationSnapshot
rectificationPendingQuestion rectificationOpeningSessionId
rectificationLoading rectificationEntrySummary
rectificationMutationPending rectificationEntrySummarySettled
rectificationError
rectificationErrorSessionId
rectificationReadonly
```
## 2. 根因
`AGENTS.md §6` 现行那句「参数式 hook 内部保持 0 个 React hook 的既定模式」把状态钉死在 `Home()` 里;同一节的行数冻结又不许 `page.tsx` 增长。两条合起来等于「不许再加状态」,而每一轮新功能都要加。`TASK-freeze-metric-change-20260915` 已经拿到产品授权推翻前半句,本单是第一个吃到新口径的轮次。
## 3. 决策记录
产品 2026-09-15 拍板:
1. **`page.tsx` 走「状态下沉」这条路**,不引外部 store、不铺全局 Context Provider。理由:不引新依赖、可以一簇一簇增量做,每搬一簇 `page.tsx` 就真降一截。
2. **第一簇搬校正面的 15 个。** 后续 `session*` / `profile*` / `synastry*` 各自另开单,本单不碰。
3. **抽出去的 hook 与子组件从此持有自己的状态**(推翻 §6 旧红线,措辞由 freeze-metric-change 单落地)。执行方不得以「AGENTS 说参数式 hook 里不能有 React hook」为由拒改。
4. **零行为变化。** 本单不修任何已知交互缺陷,发现了写进进度记录。
## 4. 硬红线
1. **不是 15 个都能搬。** 有几个外壳自己要读(例如 `rectificationSessionId` / `rectificationCaseId` 参与决定显示哪个界面、侧栏会话列表也要知道)。**第一步必须逐个分类**:「只服务子树 → 搬下去」「外壳也要读 → 留在外壳,但收敛成一个对象,不再是散装的多个 `useState`」。分类表写进进度记录,不许含糊。
2. **零行为变化**是唯一成败判据:进入校正面、退出、刷新、从首页卡片打开、打开历史校正、开场自动触发、只读态、报错态、换模型、采用后回首页——逐条与改前一致。
3. `next build` 后 `/` 仍须 `○ Static`;首屏 JS gzip 变化在 ±2 % 内(上次实测 584,413 B)。
4. 测试总数不得低于开工时 `origin/staging` 的实测;改任何既有断言必须写「原值 / 新值 / 原因」三栏(AGENTS §7.3)。
5. 不得新写第二个聊天输入框、第二套滚动跟随、第二套加载动画(§6 第三条原样有效)。
6. 不得顺手升级依赖、不得顺手修不在本单里的 warning。
7. **不得改数据库、不得改任何 API 路由。** 本单只动前端状态的住处。
## 5. 任务分解
### 5.1 先分类,再动手
把 15 个逐个归类并写进进度记录:
| 类别 | 处置 |
| --- | --- |
| 只服务校正子树 | 搬进子树(`useRectificationSurface` 变成真 hook,或由容器组件持有) |
| 外壳也要读 | 留在外壳,但合并成**一个**状态对象,散装 `useState` 数下降 |
- 验收:分类表在进度记录里,15 个一个不漏,每个写明依据(谁在读它)。
### 5.2 `useRectificationSurface` 变成真 hook
现在它要解构约 56 个参数。改成自己 `useState` / `useEffect` 持有第一类状态,对外只暴露子树真正需要的接口;`page.tsx` 侧的传参随之消失。
- 验收:`useRectificationSurface` 的参数个数显著下降,新值写进进度记录(改前约 56)。
- 验收:`<ConversationalBirthTimeRectification>` 的 props 个数下降,新值写进进度记录(改前 24)。
### 5.3 `Home()` 的状态计数必须真降
- 验收:`Home()` 的 `useState` 数从 **66** 降到 **≤ 53**(搬走至少 13 个;允许留 2 个在外壳,多留必须逐个说明理由)。
- 验收:`useRef` 数不得上升(改前 41)——不许把 state 改写成 ref 来凑数字。
- 验收:`frontend/tests/home-shell-growth-contract.test.ts`(由 freeze-metric-change 单建立)在本单收尾时更新为新基线,并贴一次反向验证。
### 5.4 行为等价证明
- 验收:改动前后各跑一次全量前端套件,失败清单逐条一致(无 Docker 时数据库套件照常红)。
- 验收:`npx tsx --test tests/rectification-*.test.ts` 全绿,断言零改动——如果必须改,按 §4.4 写三栏说明。
- 验收:`docs/testing/` 下留一份真人走查清单,覆盖 §4.2 那十条路径(本仓没有浏览器与登录态,这一项只能人工)。
### 5.5 记录
本单不产生 Bug 记录(不是缺陷,是结构改造),不进 `CHANGELOG.md`(无用户可感知变化)。若过程中确实改了任何可见样式,必须同提交更新 `frontend/DESIGN.md`(AGENTS §7.5)。
## 6. 让步顺序
1. 5.1(分类)**不得砍**——没有分类表就动手,一定会把外壳要读的状态搬下去然后再搬回来。
2. 5.2 + 5.3 是主体。
3. 5.3 的目标值可以让步(比如只搬到 ≤ 56),但让步幅度和原因必须写进进度记录,**不得静默降低**。
4. 5.4 不得砍。
5. 5.5 不得砍。
## 7. 开工前置命令
```bash
git fetch origin --prune
# 三条串行依赖必须都已合入 staging,逐个确认
git log --oneline origin/staging | head -20
git worktree add -b codex/home-state-lowering-20260915 \
.worktrees/home-state-lowering-20260915 origin/staging
cd .worktrees/home-state-lowering-20260915/frontend
git status -sb | head -1
npm ci
# 取当时实测基线,不要抄本任务书里的数字(七条在飞分支合并后会变)
grep -cE '\buseState[<(]' src/app/page.tsx
grep -cE '\buseRef[<(]' src/app/page.tsx
grep -oE 'const \[rectification[A-Za-z]+' src/app/page.tsx | wc -l
```
验收命令:
```bash
./node_modules/.bin/tsc --noEmit
npm run lint # 0 error
npx tsx --test tests/rectification-*.test.ts tests/home-shell-growth-contract.test.ts
npx tsx --test tests/*.test.ts # 与基线逐条比对失败清单
npm run build # `/` 仍须 ○ Static,首屏 gzip ±2%
```
## 8. BUG 编号起点
本单不占 BUG 号。基线 `6b3248bf` 上最大号 **BUG-720**,721–732 已被两轮审计七单预占。
## 9. 不在本单范围
- `session*` / `profile*` / `synastry*` / `account*` 四簇(各自另开单)
- Context Provider 或外部 store(§3.1 已否决这一轮走这两条路)
- 校正面自身的任何交互缺陷(本单零行为变化)
- API server 拆解(另一条线)
@@ -0,0 +1,41 @@
# 真人核对 · staging 单域咨询耗时(外网证据缓存,2026-09-15)
执行环境无 staging 登录态。下列条目留给有真实账号的人在 staging 走查。本单自动化不替代这些。
目的:同一张盘、同一个问题域连发两轮,记录 `consultationToolDurationMs`。第一轮冷(可能等外网),第二轮应命中「盘 + UTC 日期」缓存,接近本地计算。
代码注释里的域上限 3 是按「staging 实测三域共 62.9 秒」反推的,即一域约 21 秒。本机同样调用只要约 0.5 秒。**本单不改三域上限**;若单域已显著低于 21 秒,把数字写进进度记录,放宽另开单。
## 准备
1. 打开 `https://staging.jyotisha.chat/login` 并登录(用自己的受控账号)。
2. 确认 `https://staging.jyotisha.chat/api/health` 的 `deployment.gitCommit` 等于含本单代码的那次 staging 提交。
3. 选一张**当天还没问过**的盘(或等 UTC 零点后再测),避免误用旧缓存。不要把出生资料贴回对话。
## 冷启动(第一轮)
4. 新建对话,只问一个域(例如事业)。不要点「深入看今日」。
5. 等回答出来。记下:
- 主观等待 `____` 秒
- 若浏览器/服务端日志能看到 `consultationToolDurationMs`:`____` ms
- 技法表「VedAstro 云状态」是 executed 还是 blocked:`____`
## 同日第二轮(应 0 等待)
6. 同一条对话里,用同一张盘再问同一域(换一句问法即可)。
7. 记下:
- 主观等待 `____` 秒
- `consultationToolDurationMs`:`____` ms
- VedAstro 云状态:`____`
8. 第二轮应明显短于第一轮,且不应再出现「空等约 1.5 秒再 blocked」。
## 跨日(可选,UTC 过零点后)
9. 第二天用同一张盘再问同一域。第一句应马上有回答(用昨天的证据),证据里能看出是哪一天的快照;下一轮才换成当天。
10. 点「深入看今日」时,不得沿用昨天的快照;没有当天的就走原来的 1.5 秒有界等待。
## 回填后怎么处理
把 5、7 的两组数字交给验收方,写进 `docs/tasks/PROGRESS-consultation-external-evidence-cache-20260915.md`。若单域已稳定低于 21 秒,结论写成「三域上限可以放宽到 N」的依据,**不要在本单改上限**。
**在这份清单回填之前,任何人不得声称「staging 单域耗时已验证」。**
@@ -0,0 +1,32 @@
# 真机清单 · 校正长会话流式时已结算消息是否还卡(2026-09-15)
代码级合同在 `frontend/tests/rectification-settled-render-split.test.ts`、`chat-markdown-split.test.ts`。本仓没有浏览器环境,「长会话流式时是否还卡」只能由产品在真实环境确认。下面这些必须用真机或已登录的 staging 做,自动化替代不了。
地址:`https://staging.jyotisha.chat`。截图不要带真实姓名、出生资料或会话内容。
## A. 长会话流式期间历史是否还卡
1. 登录后打开一个**已有二十轮以上**的生时校正会话(采集 + 探针都走过、气泡很多的那种)。
2. 发一条会触发**长回答**的消息(例如请它解释当前范围为什么收到这里、对照了哪些分盘)。
3. 流式开始后不要滚走,盯着屏幕。
4. **看:** 正在写的那一条在动;上面已经写完的历史气泡**不应**跟着闪、整页掉帧、或像被整表重绘。滚动条位置保持贴底(若你本来就在底部)。
5. 流式尚未结束时,用手向上滚一点历史。**看:** 跟随解除,「跳到最新」出现;历史气泡仍不闪。点「跳到最新」回到底部,跟随恢复。
6. 回答写完。**看:** 刚写完的那一条没有淡出再淡入;时间线从展开收到「已完成 N 步」时,下面正文不整段跳一下。
## B. 题目、选择卡、交付卡仍在原位
同一条长会话里再走一轮带题的回答(选择题或口述采集)。
1. **看:** 题干仍嵌在同一条助手气泡里,不跑到输入框上方,也没有第二套输入框。
2. 选择题:选项可点;点完进入下一轮;「先这样,先看当前范围」仍在卡内。
3. 口述采集:只有输入框,没有「没有 / 记不清」芯片。
4. 若已到交付:区间卡仍跟在该条助手消息下面;点「更像这个」时三列卡不整张消失。
5. 复制 / 赞 / 踩 / 重新生成(只允许最近一条)与改前一致。
## C. 375px
视口宽度 375px 再走一遍 A.2–A.4。历史气泡在窄屏下也不应在流式时整列闪。
## 结论
> 待填。测完写日期 + 设备 + 浏览器 + 逐条正常/有问题。测不了就写「未测」,不要写成通过。
@@ -30,6 +30,7 @@ import { isProductEnabled } from "@/lib/product-access";
import { resolveSessionLanguageModel } from "@/lib/model-catalog";
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { defaultMessageOrigin, isRectificationMessageOrigin } from "@/lib/rectification-agentic/v9/message-origin";
import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter";
@@ -150,7 +151,7 @@ export async function POST(request: Request) {
let accounting;
try {
supabase = await createServerSupabaseClient();
accounting = createAdminSupabaseClient();
accounting = withRectificationRequestCache(createAdminSupabaseClient());
} catch (error) {
return jsonForSupabaseSetupFailure(error, "POST /api/rectification/agent");
}
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache";
import { isProductEnabled } from "@/lib/product-access";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { RectificationToolServiceError } from "@/lib/rectification-agentic/v9/tool-service";
@@ -59,7 +60,7 @@ export async function POST(request: Request, context: RouteContext) {
let accounting;
try {
supabase = await createServerSupabaseClient();
accounting = createAdminSupabaseClient();
accounting = withRectificationRequestCache(createAdminSupabaseClient());
} catch (error) {
return jsonForSupabaseSetupFailure(error, "POST /api/rectification/cases/[caseId]/candidates/accept");
}
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import {
loadV9CaseDossier,
@@ -32,7 +33,7 @@ export async function POST(request: Request, context: RouteContext) {
let accounting;
try {
supabase = await createServerSupabaseClient();
accounting = createAdminSupabaseClient();
accounting = withRectificationRequestCache(createAdminSupabaseClient());
} catch (error) {
return jsonForSupabaseSetupFailure(error, "POST /api/rectification/cases/[caseId]/repair-exit");
}
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import {
loadV9CaseDossier,
@@ -29,7 +30,7 @@ export async function GET(request: Request, context: RouteContext) {
let accounting;
try {
supabase = await createServerSupabaseClient();
accounting = createAdminSupabaseClient();
accounting = withRectificationRequestCache(createAdminSupabaseClient());
} catch (error) {
return jsonForSupabaseSetupFailure(error, "GET /api/rectification/cases/[caseId]");
}
@@ -9,6 +9,7 @@ import { resolveExactSkillPackage } from "@/lib/skill-package-registry";
import { resolveSessionLanguageModel } from "@/lib/model-catalog";
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { getRectificationV9RegenerationAgent } from "@/mastra/agentic-rectification";
import { logRectificationDeliveryTurn } from "@/lib/rectification-agentic/v9/delivery-turn-guard";
@@ -64,7 +65,7 @@ export async function POST(request: Request, context: RouteContext) {
let accounting;
try {
supabase = await createServerSupabaseClient();
accounting = createAdminSupabaseClient();
accounting = withRectificationRequestCache(createAdminSupabaseClient());
} catch (error) {
return jsonForSupabaseSetupFailure(error, "POST /api/rectification/cases/[caseId]/turns/[turnId]/regenerate");
}
@@ -53,19 +53,37 @@ function renderProse(text: string, renderMarkdown: MarkdownRenderer | undefined)
));
}
const settledMarkdownNodes = new WeakMap<MarkdownRenderer, Map<string, ReactNode>>();
function rememberedProse(text: string, renderMarkdown: MarkdownRenderer | undefined): ReactNode {
if (!text) return null;
if (!renderMarkdown) return renderProse(text, undefined);
let byText = settledMarkdownNodes.get(renderMarkdown);
if (!byText) {
byText = new Map();
settledMarkdownNodes.set(renderMarkdown, byText);
}
const cached = byText.get(text);
if (cached !== undefined) return cached;
const node = renderMarkdown(text);
byText.set(text, node);
return node;
}
/**
* The completed part of a streaming answer. `memo` keeps React from calling the
* markdown parser again while `text` is unchanged, so a frame that only grew
* the tail costs one small parse instead of one over the whole answer.
* Settled prose and the completed prefix of a streaming answer. `memo` plus a
* content-keyed parse cache keep React from calling the markdown parser again
* while `text` is unchanged, so a parent re-render (or a frame that only grew
* the tail) does not parse the whole answer.
*/
const StableMarkdownPrefix = memo(function StableMarkdownPrefix({
export const StableMarkdownPrefix = memo(function StableMarkdownPrefix({
text,
renderMarkdown,
}: Readonly<{
text: string;
renderMarkdown: MarkdownRenderer | undefined;
}>) {
return <>{renderProse(text, renderMarkdown)}</>;
return <>{rememberedProse(text, renderMarkdown)}</>;
});
export function StreamingMarkdown({
@@ -104,7 +122,9 @@ export function ChatMessageContent({
const reportBody = !streaming && report
? (
<>
<div className="message-markdown">{renderProse(report, renderMarkdown)}</div>
<div className="message-markdown">
<StableMarkdownPrefix text={report} renderMarkdown={renderMarkdown} />
</div>
{foldedAudit}
</>
)
@@ -116,7 +136,7 @@ export function ChatMessageContent({
<div className="message-markdown">
{streaming
? <StreamingMarkdown text={spoken} renderMarkdown={renderMarkdown} />
: renderProse(spoken, renderMarkdown)}
: <StableMarkdownPrefix text={spoken} renderMarkdown={renderMarkdown} />}
</div>
) : null}
{report
+3 -3
View File
@@ -5,7 +5,7 @@ import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
import { ChatMessageContent } from "@/components/chat-message-content";
import { ConsultationRunTimeline } from "@/components/consultation-run-timeline";
import type { ChatMessageView } from "@/lib/chat-message-view";
import { useEffect, useLayoutEffect, useRef, type ReactNode } from "react";
import { memo, useEffect, useLayoutEffect, useRef, type ReactNode } from "react";
type GsapCore = typeof import("gsap")["gsap"];
@@ -35,7 +35,7 @@ export function AgentAvatar() {
return <span className="agent-avatar" aria-hidden="true" />;
}
export function ChatMessageRow({
export const ChatMessageRow = memo(function ChatMessageRow({
message,
showActivity = message.state !== "settled",
vargaSentence,
@@ -170,4 +170,4 @@ export function ChatMessageRow({
</div>
</article>
);
}
});
@@ -5,7 +5,7 @@ import { useQueuedMessage } from "@/hooks/use-queued-message";
import { appendQueuedText, queuedDraftSettleAction } from "@/lib/queued-draft";
import { createPortal } from "react-dom";
import { parseAgentReply } from "@/lib/agent-reply";
import { nextActivityView, type AgentActivityView, type ChatMessage, type ChatMessageView } from "@/lib/chat-message-view";
import { nextActivityView, type AgentActivityView, type ChatMessage } from "@/lib/chat-message-view";
import { createStreamFrameBuffer } from "@/lib/stream-frame-buffer";
import {
completeActivityTrace,
@@ -44,7 +44,6 @@ import {
diffRectificationBoard,
RECTIFICATION_BOARD_SPLIT_MIN_PX,
} from "@/lib/rectification-board-model";
import { rectificationTimelineRows } from "@/lib/rectification-timeline-adapter";
import {
rectificationAdoptingLabel,
RECTIFICATION_EMPTY_ACTION_LABEL,
@@ -59,7 +58,6 @@ import {
RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
RECTIFICATION_COLLECT_WAITING_PLACEHOLDER,
RECTIFICATION_DELIVERED_COPY,
RECTIFICATION_STOPPED_NOTICE,
isAbortError,
rectificationConversationState,
rectificationInitialLiveLabel,
@@ -81,7 +79,6 @@ import {
buildRectificationTimeline,
type RectificationTimelineStage,
} from "@/lib/rectification-timeline-scale";
import { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence";
import {
isPublicRectificationActivity,
isPublicRectificationMethod,
@@ -99,19 +96,20 @@ import {
} from "@/lib/rectification-agentic/v9/choice-action";
import { postAdoptVerifyDoneCopy } from "@/lib/rectification-agentic/user-copy";
import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status";
import { CHOICE_MODE, CHOICE_SKIP_QUESTION_LABEL, CHOICE_SKIP_QUESTION_MESSAGE, CHOICE_STOP_LABEL, CHOICE_STOP_MESSAGE, isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card";
import { isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card";
import type { PublicLanguageModel } from "@/lib/public-models";
import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor";
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
import { CharacterRemaining } from "./character-remaining";
import { ChatComposer } from "./chat-composer";
import { ChatMessageRow } from "./chat-message-row";
import { ConsultationTimelineLiveRow } from "./consultation-run-timeline";
import { JumpToLatestButton } from "./jump-to-latest-button";
import { toggleChatMessageFeedback } from "./chat-message-actions";
import {
ChatMessageActions,
toggleChatMessageFeedback,
} from "./chat-message-actions";
RectificationMessageEntry,
type RectificationMessageActions,
type RenderMessage,
} from "./rectification-message-entry";
import { ModelSelector } from "./model-selector";
import { RectificationBoard, RectificationBoardPeek } from "./rectification-board";
import { RectificationChoiceCard } from "./rectification-choice-card";
@@ -124,7 +122,6 @@ import {
questionIsAnswered,
resolveSelectionCardMessageKey,
type SelectionCardLock,
type TurnQuestion,
} from "@/lib/rectification-agentic/v9/turn-question";
import { Button } from "@/components/ui/button";
import {
@@ -239,17 +236,6 @@ type RectificationAgenticChatProps = Readonly<{
headerSlot: HTMLElement | null;
}>;
type RenderMessage = ChatMessageView & {
renderKey: string;
completedReceipt?: CompletedActivityReceiptView;
failed?: boolean;
stopped?: boolean;
turnId?: string;
activityTrace?: readonly AgentActivityTraceItem[];
question?: TurnQuestion;
candidateOffer?: Readonly<{ resultId: string }>;
};
function markQuestionAnswered(
current: RenderMessage[],
focusId: string,
@@ -273,37 +259,6 @@ function snapshotTurns(payload: { turns?: unknown } | null | undefined): readonl
return Array.isArray(payload?.turns) ? payload.turns : [];
}
function choiceCardFromQuestion(
question: TurnQuestion,
live: ChoiceCardModel | null,
): ChoiceCardModel | null {
if (live && live.focus_id === question.focus_id && live.options.length === 4) {
return { ...live, prompt: question.prompt };
}
if (!question.options || question.options.length !== 4) return null;
return {
question_id: question.question_id,
method_id: "",
prompt: question.prompt,
why: "",
varga: null,
choice_mode: CHOICE_MODE,
options: question.options.map((option) => ({
key: option.key,
label: option.label,
answer_class: "unsure",
role: "primary",
})),
stop_label: question.kind === "reverse_verify" ? CHOICE_SKIP_QUESTION_LABEL : CHOICE_STOP_LABEL,
stop_message: question.kind === "reverse_verify" ? CHOICE_SKIP_QUESTION_MESSAGE : CHOICE_STOP_MESSAGE,
scoring: question.kind !== "reverse_verify",
probe_id: question.probe_id,
case_revision: null,
focus_id: question.focus_id,
...(live?.skip_this_probe ? { skip_this_probe: true } : {}),
};
}
function completedReceiptFromPersisted(receipt: PersistedTurn["receipt"]): CompletedActivityReceiptView {
if (!receipt) return { steps: [], methods: [] };
if (Array.isArray(receipt.tool_activities)) {
@@ -478,6 +433,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
const [regeneratingMessageKey, setRegeneratingMessageKey] = useState<string | null>(null);
const [choiceNonce, setChoiceNonce] = useState(0);
const messageActionsRef = useRef<RectificationMessageActions>({
submitChoice() {},
submitStop() {},
copyMessage() {},
regenerateMessage() {},
onFeedback() {},
});
const conversation = useRef<HTMLElement>(null);
const workspace = useRef<HTMLDivElement>(null);
const composer = useRef<HTMLTextAreaElement>(null);
@@ -1649,6 +1611,21 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
void submitStructuredChoice(STOP_ACTION, "stop");
}
useEffect(() => {
messageActionsRef.current = {
submitChoice,
submitStop,
copyMessage,
regenerateMessage,
onFeedback(renderKey, requested) {
setFeedback((current) => ({
...current,
[renderKey]: toggleRectificationFeedback(current[renderKey], requested),
}));
},
};
});
const boardPeek = compactBoard && !boardOpen ? (
<RectificationBoardPeek
result={candidateResult}
@@ -1689,118 +1666,31 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</div>
)}
{messages.map((message) => {
const showActions = message.role === "assistant"
&& message.state === "settled"
&& !message.failed
&& Boolean(message.text);
const regenerating = regeneratingMessageKey === message.renderKey;
const canRegenerate = message.renderKey === latestRegeneratableKey
&& !busy
&& !readonly
&& regeneratingMessageKey === null;
// Both surfaces render the same step timeline: the tool trace and receipt
// are projected onto ConsultationRunTimeline rows. A regenerating reply shows
// the queued row until real events fill it in, never a staged label.
const displayedMessage: RenderMessage = regenerating
? {
...message,
text: "",
thinkingText: undefined,
state: "thinking" as const,
activity: undefined,
activityTrace: emptyActivityTrace(),
completedReceipt: undefined,
timeline: [],
}
: {
...message,
timeline: rectificationTimelineRows({
trace: message.activityTrace,
receipt: message.completedReceipt,
activity: message.activity,
settled: message.state === "settled",
}),
};
const vargaSentence = !message.failed
? vargaSentenceFromMethods(message.completedReceipt?.methods)
: null;
const question = regenerating ? undefined : message.question;
const liveQuestion = Boolean(
question
&& currentQuestion?.focus_id === question.focus_id
&& !questionIsAnswered(question)
&& !busy
&& !readonly
&& regeneratingMessageKey === null
&& (
question.kind === "collect_spoken"
|| (
choiceCard
&& choiceCard.focus_id === question.focus_id
&& (question.kind === "choice" || question.kind === "reverse_verify")
)
),
);
const unansweredDeadChoice = Boolean(
question
&& !questionIsAnswered(question)
&& !liveQuestion
&& (question.kind === "choice" || question.kind === "reverse_verify")
);
const embeddedCard = question && !unansweredDeadChoice
? choiceCardFromQuestion(question, liveQuestion ? choiceCard : null)
: null;
const afterAnswer = question && displayedMessage.state === "settled"
? (
<div className="rectification-message-question">
<p className="rectification-message-question__prompt">{question.prompt}</p>
{embeddedCard && (
<RectificationChoiceCard
key={`${embeddedCard.question_id}:${question.answer_option ?? choiceNonce}:${question.status}`}
variant="embedded"
card={embeddedCard}
pending={busy && liveQuestion}
disabled={!liveQuestion}
selectedKey={question.answer_option ?? ""}
onSelect={submitChoice}
onStop={submitStop}
/>
)}
{question.status === "skipped" && (
<p className="rectification-question-skipped" role="status">
{savedTime ? `已跳过(已采用 ${savedTime})` : "已跳过"}
</p>
)}
</div>
)
: undefined;
return (
<div
key={message.renderKey}
className="rectification-message-wrap rectification-message-entry"
>
{(!message.failed || Boolean(displayedMessage.text) || regenerating) && (
<ChatMessageRow
message={displayedMessage}
showActivity={displayedMessage.state !== "settled"}
vargaSentence={vargaSentence}
afterAnswer={afterAnswer}
stoppedNotice={message.stopped ? RECTIFICATION_STOPPED_NOTICE : undefined}
/>
)}
{showActions && !regenerating && (
<ChatMessageActions
feedback={feedback[message.renderKey]}
copied={copiedMessageKey === message.renderKey}
canRegenerate={canRegenerate}
onFeedback={(requested) => setFeedback((current) => ({
...current,
[message.renderKey]: toggleRectificationFeedback(current[message.renderKey], requested),
}))}
onCopy={() => void copyMessage(message)}
onRegenerate={() => void regenerateMessage(message)}
/>
)}
<RectificationMessageEntry
message={message}
busy={busy}
readonly={readonly}
regenerating={regenerating}
canRegenerate={canRegenerate}
currentQuestionFocusId={currentQuestion?.focus_id ?? null}
interactive={!busy && !readonly && regeneratingMessageKey === null}
liveChoiceCard={choiceCard}
choiceNonce={choiceNonce}
savedTime={savedTime}
copied={copiedMessageKey === message.renderKey}
feedback={feedback[message.renderKey]}
actionsRef={messageActionsRef}
/>
{showSelectionCards && candidateResult && message.renderKey === selectionCardMessageKey && (
<>
<RectificationRangeDelivery
@@ -0,0 +1,259 @@
"use client";
import { memo, type MutableRefObject } from "react";
import {
ChatMessageActions,
type ChatMessageFeedback,
} from "@/components/chat-message-actions";
import { ChatMessageRow } from "@/components/chat-message-row";
import { RectificationChoiceCard } from "@/components/rectification-choice-card";
import { emptyActivityTrace, type AgentActivityTraceItem } from "@/lib/agent-activity-trace";
import type { ChatMessageView } from "@/lib/chat-message-view";
import {
noteSettledRowRender,
noteStreamingRowRender,
noteUnsplitListRender,
} from "@/lib/home-streaming-render-probe";
import type { CompletedActivityReceiptView } from "@/lib/rectification-activity-receipt";
import {
CHOICE_MODE,
CHOICE_SKIP_QUESTION_LABEL,
CHOICE_SKIP_QUESTION_MESSAGE,
CHOICE_STOP_LABEL,
CHOICE_STOP_MESSAGE,
type ChoiceKey,
type RectificationChoiceCard as ChoiceCardModel,
} from "@/lib/rectification-agentic/v9/choice-card";
import {
questionIsAnswered,
type TurnQuestion,
} from "@/lib/rectification-agentic/v9/turn-question";
import { RECTIFICATION_STOPPED_NOTICE } from "@/lib/rectification-surface-state";
import { rectificationTimelineRows } from "@/lib/rectification-timeline-adapter";
import { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence";
export type RenderMessage = ChatMessageView & {
renderKey: string;
completedReceipt?: CompletedActivityReceiptView;
failed?: boolean;
stopped?: boolean;
turnId?: string;
activityTrace?: readonly AgentActivityTraceItem[];
question?: TurnQuestion;
candidateOffer?: Readonly<{ resultId: string }>;
};
export type RectificationMessageActions = Readonly<{
submitChoice: (key: ChoiceKey) => void;
submitStop: () => void;
copyMessage: (message: RenderMessage) => void;
regenerateMessage: (message: RenderMessage) => void;
onFeedback: (renderKey: string, requested: ChatMessageFeedback) => void;
}>;
export type RectificationMessageEntryProps = Readonly<{
message: RenderMessage;
busy: boolean;
readonly: boolean;
regenerating: boolean;
canRegenerate: boolean;
currentQuestionFocusId: string | null;
interactive: boolean;
liveChoiceCard: ChoiceCardModel | null;
choiceNonce: number;
savedTime: string | null;
copied: boolean;
feedback: ChatMessageFeedback | undefined;
actionsRef: MutableRefObject<RectificationMessageActions>;
}>;
export function choiceCardFromQuestion(
question: TurnQuestion,
live: ChoiceCardModel | null,
): ChoiceCardModel | null {
if (live && live.focus_id === question.focus_id && live.options.length === 4) {
return { ...live, prompt: question.prompt };
}
if (!question.options || question.options.length !== 4) return null;
return {
question_id: question.question_id,
method_id: "",
prompt: question.prompt,
why: "",
varga: null,
choice_mode: CHOICE_MODE,
options: question.options.map((option) => ({
key: option.key,
label: option.label,
answer_class: "unsure",
role: "primary",
})),
stop_label: question.kind === "reverse_verify" ? CHOICE_SKIP_QUESTION_LABEL : CHOICE_STOP_LABEL,
stop_message: question.kind === "reverse_verify" ? CHOICE_SKIP_QUESTION_MESSAGE : CHOICE_STOP_MESSAGE,
scoring: question.kind !== "reverse_verify",
probe_id: question.probe_id,
case_revision: null,
focus_id: question.focus_id,
...(live?.skip_this_probe ? { skip_this_probe: true } : {}),
};
}
function RectificationMessageEntryView({
message,
busy,
regenerating,
canRegenerate,
currentQuestionFocusId,
interactive,
liveChoiceCard,
choiceNonce,
savedTime,
copied,
feedback,
actionsRef,
}: RectificationMessageEntryProps) {
const showActions = message.role === "assistant"
&& message.state === "settled"
&& !message.failed
&& Boolean(message.text);
const displayedMessage: RenderMessage = regenerating
? {
...message,
text: "",
thinkingText: undefined,
state: "thinking" as const,
activity: undefined,
activityTrace: emptyActivityTrace(),
completedReceipt: undefined,
timeline: [],
}
: {
...message,
timeline: rectificationTimelineRows({
trace: message.activityTrace,
receipt: message.completedReceipt,
activity: message.activity,
settled: message.state === "settled",
}),
};
const vargaSentence = !message.failed
? vargaSentenceFromMethods(message.completedReceipt?.methods)
: null;
const question = regenerating ? undefined : message.question;
const liveQuestion = Boolean(
question
&& currentQuestionFocusId === question.focus_id
&& !questionIsAnswered(question)
&& interactive
&& (
question.kind === "collect_spoken"
|| (
liveChoiceCard
&& liveChoiceCard.focus_id === question.focus_id
&& (question.kind === "choice" || question.kind === "reverse_verify")
)
),
);
const unansweredDeadChoice = Boolean(
question
&& !questionIsAnswered(question)
&& !liveQuestion
&& (question.kind === "choice" || question.kind === "reverse_verify")
);
const embeddedCard = question && !unansweredDeadChoice
? choiceCardFromQuestion(question, liveQuestion ? liveChoiceCard : null)
: null;
const afterAnswer = question && displayedMessage.state === "settled"
? (
<div className="rectification-message-question">
<p className="rectification-message-question__prompt">{question.prompt}</p>
{embeddedCard && (
<RectificationChoiceCard
key={`${embeddedCard.question_id}:${question.answer_option ?? choiceNonce}:${question.status}`}
variant="embedded"
card={embeddedCard}
pending={busy && liveQuestion}
disabled={!liveQuestion}
selectedKey={question.answer_option ?? ""}
onSelect={(key) => actionsRef.current.submitChoice(key)}
onStop={() => actionsRef.current.submitStop()}
/>
)}
{question.status === "skipped" && (
<p className="rectification-question-skipped" role="status">
{savedTime ? `已跳过(已采用 ${savedTime})` : "已跳过"}
</p>
)}
</div>
)
: undefined;
return (
<>
{(!message.failed || Boolean(displayedMessage.text) || regenerating) && (
<ChatMessageRow
message={displayedMessage}
showActivity={displayedMessage.state !== "settled"}
vargaSentence={vargaSentence}
afterAnswer={afterAnswer}
stoppedNotice={message.stopped ? RECTIFICATION_STOPPED_NOTICE : undefined}
/>
)}
{showActions && !regenerating && (
<ChatMessageActions
feedback={feedback}
copied={copied}
canRegenerate={canRegenerate}
onFeedback={(requested) => actionsRef.current.onFeedback(message.renderKey, requested)}
onCopy={() => void actionsRef.current.copyMessage(message)}
onRegenerate={() => void actionsRef.current.regenerateMessage(message)}
/>
)}
</>
);
}
export const RectificationMessageEntry = memo(function RectificationMessageEntry(
props: RectificationMessageEntryProps,
) {
if (props.regenerating || props.message.state !== "settled") noteStreamingRowRender();
else noteSettledRowRender();
return <RectificationMessageEntryView {...props} />;
});
export function UnsplitRectificationMessageList({
messages,
latestRegeneratableKey,
copiedMessageKey,
feedbackByKey,
regeneratingMessageKey,
...shared
}: Omit<RectificationMessageEntryProps, "message" | "regenerating" | "canRegenerate" | "copied" | "feedback"> & {
messages: readonly RenderMessage[];
latestRegeneratableKey?: string;
copiedMessageKey: string | null;
feedbackByKey: Readonly<Record<string, ChatMessageFeedback | undefined>>;
regeneratingMessageKey: string | null;
}) {
noteUnsplitListRender();
return (
<>
{messages.map((message) => {
noteSettledRowRender();
if (message.state !== "settled") noteStreamingRowRender();
return (
<RectificationMessageEntryView
key={message.renderKey}
{...shared}
message={message}
regenerating={regeneratingMessageKey === message.renderKey}
canRegenerate={message.renderKey === latestRegeneratableKey && shared.interactive}
copied={copiedMessageKey === message.renderKey}
feedback={feedbackByKey[message.renderKey]}
/>
);
})}
</>
);
}
@@ -0,0 +1,76 @@
/**
* Request-scoped cache for Case read projections.
*
* `get_agentic_rectification_case_dossier` and
* `get_agentic_rectification_case_compute` are unchanged for the life of a
* request until this client performs any other RPC or table access. Call sites
* keep calling `loadV9CaseDossier` as before; the wrapper sits on the client
* object so direct `accounting.rpc(...)` paths are covered too.
*
* The cache is a closure over a Map created by each `withRectificationRequestCache`
* call. It is not stored on `globalThis` or at module scope.
*
* Other properties are forwarded through a Proxy so `.from(table).update(...)`
* (used via `as never` in a few call sites) still works, and accessing them
* invalidates the same way a non-projection RPC does.
*/
import type { RectificationRpcClient } from "./tool-service";
export const RECTIFICATION_CACHED_PROJECTION_RPCS = [
"get_agentic_rectification_case_dossier",
"get_agentic_rectification_case_compute",
] as const;
type CachedProjectionRpc = (typeof RECTIFICATION_CACHED_PROJECTION_RPCS)[number];
const cachedProjectionRpcSet = new Set<string>(RECTIFICATION_CACHED_PROJECTION_RPCS);
function isCachedProjectionRpc(fn: string): fn is CachedProjectionRpc {
return cachedProjectionRpcSet.has(fn);
}
function projectionCacheKey(fn: string, args: Record<string, unknown>): string | null {
const userId = args.p_user_id;
const caseId = args.p_case_id;
if (typeof userId !== "string" || typeof caseId !== "string" || !userId || !caseId) {
return null;
}
return `${fn}\0${userId}\0${caseId}`;
}
type RpcResult = Awaited<ReturnType<RectificationRpcClient["rpc"]>>;
export function withRectificationRequestCache<T extends RectificationRpcClient>(accounting: T): T {
const cache = new Map<string, PromiseLike<RpcResult>>();
const originalRpc = accounting.rpc.bind(accounting);
function invalidate(): void {
cache.clear();
}
function cachedRpc(fn: string, args: Record<string, unknown>): PromiseLike<RpcResult> {
if (!isCachedProjectionRpc(fn)) {
invalidate();
return originalRpc(fn, args);
}
const key = projectionCacheKey(fn, args);
if (!key) return originalRpc(fn, args);
const hit = cache.get(key);
if (hit) return hit;
const pending = originalRpc(fn, args);
cache.set(key, pending);
return pending;
}
return new Proxy(accounting, {
get(target, property) {
if (property === "rpc") return cachedRpc;
invalidate();
const value = Reflect.get(target, property);
if (typeof value === "function") {
return value.bind(target);
}
return value;
},
}) as T;
}
@@ -676,6 +676,7 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
signal: context.abortSignal ?? ctx.abortSignal,
plan: domainPlan,
requestId: ctx.requestId,
entrypoint: ctx.entrypoint,
});
const guarded = applyBirthTimeModeToWorkflowContext(workflow, ctx.consultationMode, {
birthTimeSource: ctx.serverChart.truth.birthTimeSource,
+8 -1
View File
@@ -125,7 +125,13 @@ export function consultationWorkflowFailureCode(error: unknown): ConsultationWor
export async function runConsultationWorkflow(
input: ConsultationInput,
options?: { foreground?: boolean; signal?: AbortSignal; plan?: ConsultationPlan; requestId?: string },
options?: {
foreground?: boolean;
signal?: AbortSignal;
plan?: ConsultationPlan;
requestId?: string;
entrypoint?: string;
},
) {
const { entryMode, question, theme, ...workflowInput } = input;
const plan = options?.plan ?? createConsultationPlan({ userIntent: question, theme });
@@ -139,6 +145,7 @@ export async function runConsultationWorkflow(
...workflowInput,
// Correlates this call with the API access log when a run fails.
...(options?.requestId ? { request_id: options.requestId } : {}),
...(options?.entrypoint ? { entrypoint: options.entrypoint } : {}),
entry_mode: entryMode,
question: workflowRequest.question,
question_text: workflowRequest.question,
@@ -175,7 +175,11 @@ test("question stem ownership stays on set-focus spokenPrompt, not a slot or a s
assert.doesNotMatch(exit, /assistantMessage\.(?:includes|match|search)/);
assert.doesNotMatch(exit, /question\.prompt\.(?:includes|match|search)/);
assert.doesNotMatch(chat, /rectification-question-slot/);
assert.match(chat, /afterAnswer/);
// 原值: 容器源码含 afterAnswer。
// 新值: afterAnswer 在行组件;题干类名仍在对话面。
// 原因: BUG-725。
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
assert.match(messageEntry, /afterAnswer/);
assert.match(chat, /rectification-message-question/);
});
+4 -1
View File
@@ -31,7 +31,10 @@ test("ChatMessageContent splits spoken from report and only folds when a report
});
test("the folded technique audit moves inside the report fold when a report exists", () => {
assert.match(contentSource, /<div className="message-markdown">\{renderProse\(report, renderMarkdown\)\}<\/div>/);
// 原值: `{renderProse(report, renderMarkdown)}` 每次重渲全量 parse。
// 新值: 报告层走 `StableMarkdownPrefix`。
// 原因: BUG-725 结算态 Markdown 按文本记忆化。
assert.match(contentSource, /<div className="message-markdown">\s*<StableMarkdownPrefix text=\{report\} renderMarkdown=\{renderMarkdown\} \/>/);
assert.match(contentSource, /\{foldedAudit\}/);
assert.match(contentSource, /const reportBody = !streaming && report/);
assert.match(contentSource, /AnswerDetailDisclosure headings=\{layers\.headings\} streaming=\{streaming\}/);
@@ -57,15 +57,16 @@ test("the pre-markdown fallback renders prose but never raw markdown", () => {
// 原因: 口语层与报告层共用 renderProse,plainParagraphs 只在它内部调用一次。
assert.match(contentSource, /\(plainParagraphs\(text\) \?\? \[\]\)\.map/);
// 两层必须都走 renderProse,不得一层 markdown 一层裸文本。
// 口语层若改成 {spoken} / renderMarkdown(spoken)、报告层仍走 renderProse(或反过来),
// 下面的精确计数会从 2 掉到 1。不得改成 >= 1。
assert.match(contentSource, /renderProse\(spoken,\s*renderMarkdown\)/);
assert.match(contentSource, /renderProse\(report,\s*renderMarkdown\)/);
// 原值: 口语层与报告层都直接 `renderProse(spoken/report, renderMarkdown)`,计数为 2。
// 新值: 结算两层都走 `StableMarkdownPrefix`;流式尾块仍走 `renderProse(split.tail)`。
// 原因: BUG-725 结算态按文本记忆化。计数仍锁死为 2,不得放宽成 >= 1。
assert.match(contentSource, /<StableMarkdownPrefix text=\{spoken\} renderMarkdown=\{renderMarkdown\} \/>/);
assert.match(contentSource, /<StableMarkdownPrefix text=\{report\} renderMarkdown=\{renderMarkdown\} \/>/);
assert.equal(
contentSource.match(/renderProse\((?:spoken|report),\s*renderMarkdown\)/g)?.length,
contentSource.match(/<StableMarkdownPrefix text=\{(?:spoken|report)\} renderMarkdown=\{renderMarkdown\} \/>/g)?.length,
2,
);
assert.match(contentSource, /renderProse\(split\.tail, renderMarkdown\)/);
assert.doesNotMatch(contentSource, /\{renderMarkdown\((?:spoken|report)\)\}/);
assert.doesNotMatch(contentSource, />\{(?:spoken|report)\}<\/div>/);
});
+5 -1
View File
@@ -100,7 +100,11 @@ test("rectification abort settles as stopped, not a failed alert", () => {
assert.match(rectification, /stopped: true/);
assert.match(rectification, /failed: false,\s*stopped: true/);
assert.doesNotMatch(rectification, /if \(raw\.trim\(\)\) setError\(RECTIFICATION_STOPPED_NOTICE\)/);
assert.match(rectification, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
// 原值: 停止提示 prop 写在 rectification-agentic-chat.tsx。
// 新值: 同一表达式在行组件内。
// 原因: BUG-725。
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
assert.match(messageEntry, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
assert.match(messageRow, /stoppedNotice/);
assert.match(messageRow, /className="message-stopped-notice"/);
assert.equal(RECTIFICATION_STOPPED_NOTICE, "已停止,已生成的内容保留;本次不会扣点。");
+18 -5
View File
@@ -4,7 +4,7 @@ import test from "node:test";
import { createElement } from "react";
import { renderToString } from "react-dom/server";
import { StreamingMarkdown } from "../src/components/chat-message-content.tsx";
import { StableMarkdownPrefix, StreamingMarkdown } from "../src/components/chat-message-content.tsx";
import { splitStableMarkdown } from "../src/lib/chat-markdown-split.ts";
const contentSource = readFileSync(new URL("../src/components/chat-message-content.tsx", import.meta.url), "utf8");
@@ -68,9 +68,22 @@ test("the streaming renderer parses the prefix and the tail as two separate docu
assert.match(contentSource, /const StableMarkdownPrefix = memo\(function StableMarkdownPrefix/);
assert.match(contentSource, /splitStableMarkdown\(text\)/);
assert.match(contentSource, /streaming\s*\?\s*<StreamingMarkdown/);
// 原值: `: renderMarkdown ? renderMarkdown(spoken)` 只给口语层走 markdown。
// 新值: 口语层与报告层都走 `renderProse`。
// 原因: 两层可见性后,折叠内外共用同一套解析,避免两套 fallback。
assert.match(contentSource, /: renderProse\(spoken, renderMarkdown\)/);
// 原值: `: renderProse(spoken, renderMarkdown)` 结算口语层每次重渲都全量 parse。
// 新值: 结算口语层走 `StableMarkdownPrefix`(按文本记忆 + 解析缓存)。
// 原因: BUG-725 流式帧不得重跑已结算 Markdown。
assert.match(contentSource, /: <StableMarkdownPrefix text=\{spoken\} renderMarkdown=\{renderMarkdown\} \/>/);
assert.match(messageRowSource, /streaming=\{message\.state !== "settled"\}/);
});
test("settled markdown parses the same text only once across consecutive renders", () => {
const calls: string[] = [];
const renderMarkdown = (text: string) => {
calls.push(text);
return createElement("p", null, text);
};
const text = "已结算的一段回答,对照了 D9 和 D10。";
renderToString(createElement(StableMarkdownPrefix, { text, renderMarkdown }));
renderToString(createElement(StableMarkdownPrefix, { text, renderMarkdown }));
assert.equal(calls.length, 1);
assert.deepEqual(calls, [text]);
});
+9 -1
View File
@@ -189,7 +189,15 @@ test("ordinary consultation replies reuse the shared Agent action bar", () => {
assert.doesNotMatch(rectificationChat, /event\.type === "thinking\.delta"/);
assert.match(rectificationChat, /startActivityTraceStep/);
assert.match(rectificationChat, /completeActivityTraceStep/);
assert.match(rectificationChat, /<ChatMessageActions/);
// 原值: 校正面容器源码含 `<ChatMessageActions`。
// 新值: 动作条随行组件抽出,容器仍 import 行组件。
// 原因: BUG-725。
const rectificationEntry = readFileSync(
new URL("../src/components/rectification-message-entry.tsx", import.meta.url),
"utf8",
);
assert.match(rectificationEntry, /<ChatMessageActions/);
assert.match(rectificationChat, /<RectificationMessageEntry/);
assert.match(globalStyles, /\.message-actions \{/);
assert.doesNotMatch(globalStyles, /\.rectification-message-actions \{/);
});
@@ -177,6 +177,19 @@ test("validates and emits non-sensitive workflow and execution receipts", () =>
assert.match(route, /streamAgentResponse/);
});
test("consultationWorkflowResponseSchema still parses without western_evidence_packet", () => {
const schema = workflow.slice(
workflow.indexOf("export const consultationWorkflowResponseSchema"),
workflow.indexOf("function record("),
);
assert.match(schema, /success: z\.boolean\(\)/);
assert.match(schema, /chart: z\.record\(z\.unknown\(\)\)/);
assert.match(schema, /routing: z\.record\(z\.unknown\(\)\)/);
assert.match(schema, /consumer_context:/);
assert.match(schema, /\)\.passthrough\(\);/);
assert.doesNotMatch(schema, /western_evidence_packet/);
});
test("carries commercial technique truth into the model contract", () => {
assert.match(workflow, /technique_truth/);
assert.match(mastra, /deterministic_claims_forbidden_for/);
@@ -410,7 +410,11 @@ test("chat owns adopt cards on the offering message and drops the list-end hando
// 原因: BUG-595 决策 3
assert.doesNotMatch(chat, /rectification-adopt-status/);
assert.doesNotMatch(chat, /用这个时间看盘/);
assert.match(chat, /CHOICE_STOP_LABEL/);
// 原值: 容器源码含 CHOICE_STOP_LABEL、已跳过(已采用)、scoring: question.kind。
// 新值: 这些 token 随 choiceCardFromQuestion / afterAnswer 进了行组件。
// 原因: BUG-725。
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
assert.match(messageEntry, /CHOICE_STOP_LABEL/);
// 原值: 口述采集输入框上方必须有与点选卡同款停止按钮
// 新值: 停止只留选择题卡与生成中「停止回答」
// 原因: BUG-595 决策 2
@@ -420,11 +424,11 @@ test("chat owns adopt cards on the offering message and drops the list-end hando
readFileSync(new URL("../src/components/rectification-choice-card.tsx", import.meta.url), "utf8"),
/stop_label !== CHOICE_STOP_LABEL/,
);
assert.match(chat, /已跳过(已采用/);
assert.match(messageEntry, /已跳过(已采用/);
assert.match(chat, /rectificationLiveProgressLabel/);
assert.doesNotMatch(chat, /rectification-consult-handoff/);
assert.match(chat, /kind === "reverse_verify"/);
assert.match(chat, /scoring: question\.kind !== "reverse_verify"/);
assert.match(messageEntry, /scoring: question\.kind !== "reverse_verify"/);
});
test("choice cards use 这题跳过 for reverse_verify and 先这样 for collect", () => {
@@ -12,6 +12,10 @@ const chat = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
const messageEntry = readFileSync(
new URL("../src/components/rectification-message-entry.tsx", import.meta.url),
"utf8",
);
const activityStatus = readFileSync(
new URL("../src/components/agent-activity-status.tsx", import.meta.url),
"utf8",
@@ -352,10 +356,13 @@ test("rectification keeps receipts for the varga sentence and shows live tool pr
assert.match(activityHelper, /filter\(isPublicRectificationTool\)/);
assert.match(activityHelper, /receipt\.methods/);
assert.match(activityHelper, /filter\(isPublicRectificationMethod\)/);
assert.match(chat, /completedReceipt\?: CompletedActivityReceiptView/);
assert.match(chat, /showActivity=\{displayedMessage\.state !== "settled"\}/);
assert.match(chat, /vargaSentenceFromMethods/);
assert.match(chat, /vargaSentence=\{vargaSentence\}/);
// 原值: 上述 token 都在 rectification-agentic-chat.tsx 的 messages.map 内。
// 新值: RenderMessage / 时间轴 / varga 句随行组件抽出。
// 原因: BUG-725 已结算行记忆化,派生不得留在容器每帧计算。
assert.match(messageEntry, /completedReceipt\?: CompletedActivityReceiptView/);
assert.match(messageEntry, /showActivity=\{displayedMessage\.state !== "settled"\}/);
assert.match(messageEntry, /vargaSentenceFromMethods/);
assert.match(messageEntry, /vargaSentence=\{vargaSentence\}/);
assert.match(chat, /activityTraceFromReceipt/);
assert.match(activityStatus, /vargaSentence/);
assert.match(activityStatus, /VargaTraceStep/);
@@ -375,9 +382,14 @@ test("rectification keeps receipts for the varga sentence and shows live tool pr
chat.indexOf("{messages.map((message) => {"),
chat.indexOf("{savedTime &&"),
);
const replyIndex = messageRender.indexOf("<ChatMessageRow");
// 原值: `<ChatMessageRow` 写在容器 messages.map 里。
// 新值: 行渲染在 `rectification-message-entry.tsx`,容器 map 只挂行组件。
// 原因: BUG-725 抽出 memo 行。
const replyIndex = messageEntry.indexOf("<ChatMessageRow");
assert.ok(replyIndex >= 0);
assert.match(messageRender, /<RectificationMessageEntry/);
assert.doesNotMatch(messageRender, /<RectificationHouseTableView/);
assert.doesNotMatch(messageEntry, /<RectificationHouseTableView/);
// Former locks on `completed-activity-receipt.tsx` (`<details>`, never open, the "本轮完成 · N 个
// 步骤" summary): that was a second collapsed block under every settled rectification reply.
// Its methods now sit as source chips on the shared timeline's calculate row (BUG-476).
@@ -397,7 +409,10 @@ test("rectification keeps receipts for the varga sentence and shows live tool pr
assert.match(chat, /completeActivityTraceStep/);
// Former lock: `activityStatus` contained `activityTrace` — the rectification-only trace panel.
// The trace is now projected onto the shared timeline rows (BUG-476); the panel is a fallback.
assert.match(chat, /rectificationTimelineRows/);
// 原值: chat 源码直接调用 rectificationTimelineRows。
// 新值: 调用点在行组件内。
// 原因: BUG-725 容器不再每帧派生时间轴行。
assert.match(messageEntry, /rectificationTimelineRows/);
assert.doesNotMatch(activityStatus, /activityTrace/);
assert.match(activityStatus, /className="message-thinking"/);
assert.match(activityStatus, />思考</);
@@ -409,7 +424,10 @@ test("completed Agent replies restore feedback, copy and safe in-place regenerat
for (const label of ["赞", "踩", "复制回答", "重新生成回答"]) {
assert.match(messageActions, new RegExp(`aria-label="${label}"`));
}
assert.match(chat, /<ChatMessageActions/);
// 原值: `<ChatMessageActions` 写在容器 messages.map 里。
// 新值: 动作条随行组件抽出;容器仍切换 feedback。
// 原因: BUG-725 动作回调走 actionsRef,组件本身仍要渲染动作条。
assert.match(messageEntry, /<ChatMessageActions/);
assert.match(chat, /toggleRectificationFeedback/);
assert.match(chat, /navigator\.clipboard\.writeText\(copyTextForMessage\(message\.text, message\.question\)\)/);
assert.match(chat, /\/turns\/\$\{encodeURIComponent\(message\.turnId\)\}\/regenerate/);
@@ -419,11 +437,12 @@ test("completed Agent replies restore feedback, copy and safe in-place regenerat
chat.indexOf("{messages.map((message) => {"),
chat.indexOf("{savedTime &&"),
);
const replyIndex = messageRender.indexOf("<ChatMessageRow");
const actionsIndex = messageRender.indexOf("<ChatMessageActions");
const replyIndex = messageEntry.indexOf("<ChatMessageRow");
const actionsIndex = messageEntry.indexOf("<ChatMessageActions");
assert.ok(replyIndex >= 0 && actionsIndex >= 0);
assert.ok(replyIndex < actionsIndex);
assert.doesNotMatch(messageRender, /<CompletedActivityReceipt/);
assert.doesNotMatch(messageEntry, /<CompletedActivityReceipt/);
assert.doesNotMatch(chat, /send\("message",\s*(?:oldText|previousText)/);
assert.doesNotMatch(regenerateRoute, /consultation-billing|reserveConsultation|billing\./);
@@ -486,7 +505,10 @@ test("the natal house table is a live board beside the chat, not a message", ()
board.indexOf("className=\"rectification-snapshot\""),
board.indexOf("{scoringMinutes.length > 0 || displayMinutes.length > 0"),
);
const rowIndex = messageLoop.indexOf("<ChatMessageRow");
// 原值: `<ChatMessageRow` 在容器 map 循环里。
// 新值: 循环渲染 `RectificationMessageEntry`,ChatMessageRow 在行组件内。
// 原因: BUG-725。
const rowIndex = messageLoop.indexOf("<RectificationMessageEntry");
const snapshotIndex = snapshot.indexOf("className=\"rectification-snapshot\"");
const houseIndex = snapshot.indexOf("<RectificationHouseTableView");
assert.ok(rowIndex >= 0);
@@ -582,12 +604,18 @@ test("rectification composer can stop a live agent run", () => {
// 新值:isAbortError(caught)
// 原因:选择题与采用路径共用同一中止判定(BUG-552)
assert.match(chat, /isAbortError\(caught\)/);
assert.match(chat, /showActivity=\{displayedMessage\.state !== "settled"\}/);
// 原值: 容器 map 写 `showActivity={displayedMessage.state !== "settled"}`。
// 新值: 同一表达式在行组件内。
// 原因: BUG-725。
assert.match(messageEntry, /showActivity=\{displayedMessage\.state !== "settled"\}/);
});
test("aborting a live run marks the bubble stopped without an alert", () => {
assert.match(chat, /failed: false,\s*stopped: true/);
assert.match(chat, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
// 原值: 停止提示 prop 写在容器 map 的 ChatMessageRow 上。
// 新值: 同一表达式在行组件内。
// 原因: BUG-725。
assert.match(messageEntry, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
assert.doesNotMatch(chat, /role="alert"[\s\S]{0,80}RECTIFICATION_STOPPED_NOTICE/);
const choiceFetch = chat.slice(chat.indexOf("const submitStructuredChoice"), chat.indexOf("const acceptCandidate"));
assert.match(choiceFetch, /runAbort\.current = abortController/);
@@ -609,9 +637,13 @@ test("time-selection cards use server adoption state and stay mutually exclusive
chat.indexOf("{messages.map((message) => {"),
chat.indexOf("{savedTime &&"),
);
const actionsIndex = messageLoop.indexOf("<ChatMessageActions");
// 原值: ChatMessageActions 与 RangeDelivery 都在容器 map 里,且 actions 在卡之前。
// 新值: 动作条在行组件内;交付卡仍在容器 map、排在行组件之后。
// 原因: BUG-725 行组件抽出,交付卡不属于逐消息 Markdown 边界。
const entryIndex = messageLoop.indexOf("<RectificationMessageEntry");
const cardsIndex = messageLoop.indexOf("<RectificationRangeDelivery");
assert.ok(actionsIndex >= 0 && cardsIndex > actionsIndex);
assert.ok(entryIndex >= 0 && cardsIndex > entryIndex);
assert.match(messageEntry, /<ChatMessageActions/);
// 旧:showSelectionCards 只看 selectionAllowed && canAdopt && !showLiveChoiceCard && !busy,
// 锚在 latestSettledAssistant。采集题没有 A–D 卡,所以 collect_evidence 会出采用卡;
// 采用后 busy 把卡藏掉,新消息又把卡带走。
@@ -644,11 +676,18 @@ test("time-selection cards use server adoption state and stay mutually exclusive
// 新值: 还要有 candidateResult 才渲染行选卡
// 原因: BUG-595 决策 1
assert.match(messageLoop, /showSelectionCards && candidateResult && message\.renderKey === selectionCardMessageKey/);
// 原值: 嵌入卡 token(RectificationChoiceCard / variant / choiceNonce / answer_option)在容器 map。
// 新值: 嵌入卡在行组件;容器 map 仍有 persisted 题卡的 RectificationChoiceCard。
// 原因: BUG-725。
assert.match(messageEntry, /<RectificationChoiceCard/);
assert.match(messageEntry, /variant="embedded"/);
assert.match(messageEntry, /choiceNonce/);
assert.match(messageEntry, /question\.answer_option/);
assert.match(messageLoop, /<RectificationChoiceCard/);
assert.match(messageLoop, /variant="embedded"/);
assert.match(messageLoop, /choiceNonce/);
assert.match(messageLoop, /question\.answer_option/);
assert.doesNotMatch(messageLoop, /rectification-question-slot/);
assert.doesNotMatch(messageEntry, /rectification-question-slot/);
assert.doesNotMatch(choiceCardComponent, /setSelectedKey\(""\)/);
assert.match(choiceCardComponent, /selectedKey/);
assert.match(choiceCardComponent, /is-answered/);
@@ -664,8 +703,12 @@ test("time-selection cards use server adoption state and stay mutually exclusive
assert.doesNotMatch(chat, /send\("message", choiceCard\?\.stop_message/);
assert.doesNotMatch(chat, /choiceCardUserMessage/);
assert.match(caseProjection, /choice_card: choiceCardFromCaseDossier/);
assert.match(chat, /choiceCard\s+&& choiceCard\.focus_id === question\.focus_id/);
assert.match(chat, /disabled=\{!liveQuestion\}/);
// 原值: 容器 map 用 `choiceCard && choiceCard.focus_id === question.focus_id` 和 `disabled={!liveQuestion}`。
// 新值: 行组件用 liveChoiceCard / liveQuestion;容器的 live 卡判定仍比对 latestLiveQuestion。
// 原因: BUG-725。
assert.match(messageEntry, /liveChoiceCard\s+&& liveChoiceCard\.focus_id === question\.focus_id/);
assert.match(messageEntry, /disabled=\{!liveQuestion\}/);
assert.match(chat, /choiceCard\s+&& choiceCard\.focus_id === latestLiveQuestion\.focus_id/);
assert.match(caseProjection, /current_question: projectCurrentQuestion/);
assert.match(caseProjection, /overlayPublicDecision/);
assert.match(caseProjection, /const fields = publicDecisionFields\(decision\)/);
@@ -107,6 +107,10 @@ test("composer wrap no longer hosts 先这样 or the adopt status bar", () => {
assert.doesNotMatch(wrap, /rectification-collect-stop/);
assert.doesNotMatch(wrap, /rectification-composer-meta/);
assert.doesNotMatch(wrap, /rectification-adopt-status/);
assert.match(chat, /CHOICE_STOP_LABEL/);
// 原值: 容器源码含 CHOICE_STOP_LABEL(嵌入卡 stop 文案)。
// 新值: 常量在行组件的 choiceCardFromQuestion 里;composer-wrap 仍不得托管。
// 原因: BUG-725。
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
assert.match(messageEntry, /CHOICE_STOP_LABEL/);
assert.match(chat, /postAdoptVerifyDoneCopy/);
});
@@ -636,7 +636,11 @@ test("collect spoken stop stays on choice cards; range line is status only", ()
);
const wrap = chat.slice(chat.indexOf("className=\"composer-wrap\""), chat.indexOf("className=\"composer-footer\""));
assert.doesNotMatch(wrap, /rectification-collect-stop/);
assert.match(chat, /CHOICE_STOP_LABEL/);
// 原值: 容器源码含 CHOICE_STOP_LABEL。
// 新值: 常量在行组件;容器仍有 submitStop,composer-wrap 仍不得托管。
// 原因: BUG-725。
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
assert.match(messageEntry, /CHOICE_STOP_LABEL/);
assert.match(chat, /kind === "collect_spoken"/);
assert.match(chat, /function submitStop/);
assert.match(chat, /RectificationReadonlyRange/);
@@ -135,9 +135,13 @@ test("copy text is body, blank line, stem, then option lines", () => {
test("one assistant article owns the stem and options; the slot class is gone", () => {
assert.match(row, /afterAnswer/);
assert.match(chat, /afterAnswer=\{afterAnswer\}/);
// 原值: 容器源码含 `afterAnswer={afterAnswer}`。
// 新值: 行组件内部构造 afterAnswer;题干类名与 embedded 变体仍在对话面。
// 原因: BUG-725。
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
assert.match(messageEntry, /afterAnswer=\{afterAnswer\}/);
assert.match(chat, /rectification-message-question/);
assert.match(chat, /variant="embedded"/);
assert.match(messageEntry, /variant="embedded"/);
assert.match(chat, /function markQuestionAnswered/);
assert.match(chat, /"typed"/);
assert.doesNotMatch(chat, /rectification-question-slot/);
@@ -0,0 +1,185 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { withRectificationRequestCache } from "../src/lib/rectification-agentic/v9/request-cache.ts";
import { loadV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { runV9AgentTurn } from "../src/lib/rectification-agentic/v9/agent-run.ts";
import { RECTIFICATION_SKILL_NAME } from "../src/lib/rectification-agentic/v9/case-status.ts";
import {
CASE_COMPUTE_RPC,
CASE_DOSSIER_RPC,
CASE_ID,
SESSION_ID,
TURN_ID,
USER_ID,
countCachedProjectionRpcs,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
/** Measured after wrapping: route-style prefetch + one evidence turn. */
const CACHED_AGENT_TURN_DOSSIER_BUDGET = 4;
const WRAPPED_ADMIN_CLIENT = /accounting = withRectificationRequestCache\(createAdminSupabaseClient\(\)\)/;
const BARE_ADMIN_CLIENT = /accounting = createAdminSupabaseClient\(\)/;
const CACHED_ROUTES = [
"../src/app/api/rectification/agent/route.ts",
"../src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts",
"../src/app/api/rectification/cases/[caseId]/route.ts",
"../src/app/api/rectification/cases/[caseId]/repair-exit/route.ts",
"../src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts",
] as const;
function projectionArgs(caseId = CASE_ID) {
return { p_user_id: USER_ID, p_case_id: caseId };
}
function delayedClient(innerCalls: string[], delayMs = 20) {
return {
rpc(fn: string, args: Record<string, unknown>) {
void args;
innerCalls.push(fn);
return new Promise<{ data: unknown; error: null }>((resolve) => {
setTimeout(() => resolve({ data: { ok: fn }, error: null }), delayMs);
});
},
};
}
test("repeated case_dossier reads of the same user and case hit the in-flight cache once", async () => {
const accounting = fakeAccounting({
[CASE_DOSSIER_RPC]: () => dossierFixture(),
}, { requestCache: true });
const first = await loadV9CaseDossier(accounting.client, USER_ID, CASE_ID);
const second = await loadV9CaseDossier(accounting.client, USER_ID, CASE_ID);
assert.equal(first.case.caseId, CASE_ID);
assert.equal(second.case.caseId, CASE_ID);
assert.deepEqual(countCachedProjectionRpcs(accounting.calls), { dossier: 1, compute: 0 });
});
test("a non-projection rpc invalidates so the next dossier read hits the backing client", async () => {
const accounting = fakeAccounting({
[CASE_DOSSIER_RPC]: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
}, { requestCache: true });
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
await accounting.client.rpc("append_agentic_rectification_turn", projectionArgs());
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
assert.equal(countCachedProjectionRpcs(accounting.calls).dossier, 2);
});
test("two caseIds do not share a cached projection", async () => {
const otherCaseId = "99999999-9999-4999-8999-999999999999";
const accounting = fakeAccounting({
[CASE_DOSSIER_RPC]: () => dossierFixture(),
}, { requestCache: true });
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs(CASE_ID));
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs(otherCaseId));
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs(CASE_ID));
assert.equal(
accounting.calls.filter((call) => call.fn === CASE_DOSSIER_RPC).length,
2,
);
});
test("concurrent duplicate reads share one backing rpc", async () => {
const innerCalls: string[] = [];
const wrapped = withRectificationRequestCache(delayedClient(innerCalls));
const args = projectionArgs();
const [first, second] = await Promise.all([
wrapped.rpc(CASE_DOSSIER_RPC, args),
wrapped.rpc(CASE_DOSSIER_RPC, args),
]);
assert.deepEqual(first, { data: { ok: CASE_DOSSIER_RPC }, error: null });
assert.deepEqual(second, { data: { ok: CASE_DOSSIER_RPC }, error: null });
assert.deepEqual(innerCalls, [CASE_DOSSIER_RPC]);
});
test("dossier and compute projections cache independently", async () => {
const accounting = fakeAccounting({
[CASE_DOSSIER_RPC]: () => dossierFixture(),
[CASE_COMPUTE_RPC]: () => ({ case_id: CASE_ID }),
}, { requestCache: true });
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
await accounting.client.rpc(CASE_COMPUTE_RPC, projectionArgs());
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
await accounting.client.rpc(CASE_COMPUTE_RPC, projectionArgs());
assert.deepEqual(countCachedProjectionRpcs(accounting.calls), { dossier: 1, compute: 1 });
});
test(".from passthrough invalidates the projection cache", async () => {
const accounting = fakeAccounting({
[CASE_DOSSIER_RPC]: () => dossierFixture(),
}, { requestCache: true });
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
await accounting.client.from("profiles").update({ birth_time_source: "approximate" }).eq("id", USER_ID);
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
assert.equal(countCachedProjectionRpcs(accounting.calls).dossier, 2);
assert.equal(accounting.profilePatches.length, 1);
});
test("rectification routes wrap createAdminSupabaseClient once and never use the bare client", () => {
for (const relative of CACHED_ROUTES) {
const source = readFileSync(new URL(relative, import.meta.url), "utf8");
const wraps = source.match(new RegExp(WRAPPED_ADMIN_CLIENT.source, "g")) ?? [];
assert.equal(wraps.length, 1, relative);
assert.equal(BARE_ADMIN_CLIENT.test(source), false, relative);
}
});
test("a cached evidence turn stays within the measured case_dossier budget", async () => {
const handlers = {
...receiptHandlers,
[CASE_DOSSIER_RPC]: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
};
const unwrapped = fakeAccounting(handlers);
const wrapped = fakeAccounting(handlers, { requestCache: true });
const stream = {
stream: async () => ({
fullStream: (async function* () {
yield { type: "start" };
yield { type: "tool-call", payload: { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } } };
yield { type: "tool-result", payload: { toolName: "skill" } };
yield { type: "tool-call", payload: { toolName: "rectification-read-case", args: { caseId: CASE_ID } } };
yield { type: "tool-result", payload: { toolName: "rectification-read-case" } };
yield { type: "text-delta", payload: { text: "记下了。" } };
yield { type: "finish" };
})(),
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }),
}),
getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: "skill" }),
};
const base = {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
action: "evidence" as const,
message: "2016年9月离开家去北京工作",
modelName: "gpt-4o-mini",
billing: {
reserve: async () => ({ success: true, status: 200 }),
complete: async () => true,
release: async () => true,
},
emit: () => {},
buildAgent: async () => stream as never,
};
await loadV9CaseDossier(unwrapped.client, USER_ID, CASE_ID);
await runV9AgentTurn({ ...base, accounting: unwrapped.client });
await loadV9CaseDossier(wrapped.client, USER_ID, CASE_ID);
await runV9AgentTurn({ ...base, accounting: wrapped.client });
const before = countCachedProjectionRpcs(unwrapped.calls);
const after = countCachedProjectionRpcs(wrapped.calls);
assert.ok(after.dossier < before.dossier);
assert.ok(
after.dossier <= CACHED_AGENT_TURN_DOSSIER_BUDGET,
`cached dossier rpc ${after.dossier} exceeded budget ${CACHED_AGENT_TURN_DOSSIER_BUDGET} (uncached ${before.dossier})`,
);
});
@@ -0,0 +1,160 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createElement, createRef } from "react";
import { renderToString } from "react-dom/server";
import test from "node:test";
import type { ChatMessageFeedback } from "../src/components/chat-message-actions.tsx";
import {
RectificationMessageEntry,
UnsplitRectificationMessageList,
type RectificationMessageActions,
type RectificationMessageEntryProps,
type RenderMessage,
} from "../src/components/rectification-message-entry.tsx";
import {
disableHomeStreamingRenderProbe,
enableHomeStreamingRenderProbe,
homeStreamingRenderProbeSnapshot,
resetHomeStreamingRenderProbe,
} from "../src/lib/home-streaming-render-probe.ts";
const chatSource = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
const entrySource = readFileSync(
new URL("../src/components/rectification-message-entry.tsx", import.meta.url),
"utf8",
);
const rowSource = readFileSync(
new URL("../src/components/chat-message-row.tsx", import.meta.url),
"utf8",
);
function settled(role: "user" | "assistant", text: string, key: string): RenderMessage {
return {
role,
text,
renderKey: key,
state: "settled",
};
}
function liveAssistant(text: string): RenderMessage {
return {
role: "assistant",
text,
renderKey: "live-assistant",
state: "streaming",
};
}
function emptyActions(): RectificationMessageEntryProps["actionsRef"] {
const actionsRef = createRef<RectificationMessageActions>() as RectificationMessageEntryProps["actionsRef"];
actionsRef.current = {
submitChoice() {},
submitStop() {},
copyMessage() {},
regenerateMessage() {},
onFeedback() {},
};
return actionsRef;
}
function sharedProps(): Omit<
RectificationMessageEntryProps,
"message" | "regenerating" | "canRegenerate" | "copied" | "feedback"
> {
return {
busy: true,
readonly: false,
currentQuestionFocusId: null,
interactive: false,
liveChoiceCard: null,
choiceNonce: 0,
savedTime: null,
actionsRef: emptyActions(),
};
}
function entryProps(
message: RenderMessage,
extras: Partial<Pick<RectificationMessageEntryProps, "regenerating" | "canRegenerate" | "copied" | "feedback">> = {},
): RectificationMessageEntryProps {
return {
...sharedProps(),
message,
regenerating: extras.regenerating ?? false,
canRegenerate: extras.canRegenerate ?? false,
copied: extras.copied ?? false,
feedback: extras.feedback,
};
}
test("the chat container no longer derives per-row timeline, varga, or choice cards", () => {
assert.doesNotMatch(chatSource, /rectificationTimelineRows\s*\(/);
assert.doesNotMatch(chatSource, /vargaSentenceFromMethods\s*\(/);
assert.doesNotMatch(chatSource, /choiceCardFromQuestion\s*\(/);
assert.match(entrySource, /rectificationTimelineRows\s*\(/);
assert.match(entrySource, /vargaSentenceFromMethods\s*\(/);
assert.match(entrySource, /choiceCardFromQuestion\s*\(/);
});
test("the extracted row is memoised and takes no function props except actionsRef", () => {
assert.match(entrySource, /export const RectificationMessageEntry = memo\(function RectificationMessageEntry/);
const propsBlock = entrySource.slice(
entrySource.indexOf("export type RectificationMessageEntryProps"),
entrySource.indexOf("export function choiceCardFromQuestion"),
);
assert.match(propsBlock, /actionsRef: MutableRefObject<RectificationMessageActions>/);
assert.doesNotMatch(propsBlock, /=>/);
assert.doesNotMatch(propsBlock, /on[A-Z]\w+\?:/);
assert.match(rowSource, /export const ChatMessageRow = memo\(function ChatMessageRow/);
});
test("the split architecture renders settled rectification rows once while streaming tokens", () => {
const history: RenderMessage[] = [
settled("user", "第一件经历", "u1"),
settled("assistant", "记下了第一件。", "a1"),
settled("user", "第二件经历", "u2"),
settled("assistant", "记下了第二件。", "a2"),
];
const tokens = ["甲", "甲乙", "甲乙丙", "甲乙丙丁", "甲乙丙丁戊"];
const shared = sharedProps();
const feedbackByKey: Readonly<Record<string, ChatMessageFeedback | undefined>> = {};
resetHomeStreamingRenderProbe();
enableHomeStreamingRenderProbe();
for (const message of history) {
renderToString(createElement(RectificationMessageEntry, entryProps(message)));
}
for (const streamingText of tokens) {
renderToString(createElement(RectificationMessageEntry, entryProps(liveAssistant(streamingText))));
}
const split = homeStreamingRenderProbeSnapshot();
disableHomeStreamingRenderProbe();
resetHomeStreamingRenderProbe();
enableHomeStreamingRenderProbe();
for (const streamingText of tokens) {
renderToString(createElement(UnsplitRectificationMessageList, {
...shared,
messages: [...history, liveAssistant(streamingText)],
latestRegeneratableKey: "a2",
copiedMessageKey: null,
feedbackByKey,
regeneratingMessageKey: null,
}));
}
const unsplit = homeStreamingRenderProbeSnapshot();
disableHomeStreamingRenderProbe();
assert.equal(split.settledRowRenders, history.length);
assert.ok(split.streamingRowRenders <= tokens.length);
assert.ok(split.streamingRowRenders >= 1);
assert.equal(unsplit.unsplitListRenders, tokens.length);
// Unsplit counts every row on every frame, including the live one.
assert.equal(unsplit.settledRowRenders, (history.length + 1) * tokens.length);
assert.ok(unsplit.settledRowRenders > split.settledRowRenders);
});
@@ -126,9 +126,13 @@ test("cases current_question remains the submit contract, not a visual slot", ()
assert.doesNotMatch(wrap, /rectification-collect-reply/);
assert.doesNotMatch(wrap, /send\("message", "没有"\)/);
assert.doesNotMatch(wrap, /rectification-step-state/);
const afterAnswer = chat.slice(
chat.indexOf("const afterAnswer = question"),
chat.indexOf("key={message.renderKey}"),
// 原值: 切片容器 map 里的 `const afterAnswer = question`。
// 新值: afterAnswer 在行组件内构造。
// 原因: BUG-725。
const messageEntryForReplies = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
const afterAnswer = messageEntryForReplies.slice(
messageEntryForReplies.indexOf("const afterAnswer = question"),
messageEntryForReplies.indexOf("return ("),
);
assert.doesNotMatch(afterAnswer, /CollectSpokenReplies/);
assert.doesNotMatch(afterAnswer, /shouldShowCollectSpokenReplies/);
@@ -155,7 +159,11 @@ test("collect_spoken stem lives on turn.question inside the same assistant artic
assert.match(chat, /const collectSpokenPrompt =/);
assert.match(chat, /parseTurnQuestion\(turn\.question\)/);
assert.match(chat, /function markQuestionAnswered/);
assert.match(chat, /afterAnswer=\{afterAnswer\}/);
// 原值: 容器把 afterAnswer 作为 ChatMessageRow prop。
// 新值: 行组件内部构造 afterAnswer 再传给 ChatMessageRow。
// 原因: BUG-725。
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
assert.match(messageEntry, /afterAnswer=\{afterAnswer\}/);
assert.match(chat, /rectification-message-question/);
assert.match(row, /afterAnswer/);
assert.doesNotMatch(chat, /composeCollectSpokenAssistantText/);
@@ -212,18 +220,26 @@ test("missing current_question is explicit only for resumable cases", () => {
test("choice options render inside the same assistant message, not a sibling slot", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
const render = chat.slice(
chat.indexOf("{messages.map((message) => {") ,
chat.indexOf("{savedTime &&"),
);
assert.match(chat, /showLiveChoiceCard/);
// 原值: 嵌入卡、afterAnswer、onSelect={submitChoice} 都在容器 map 里。
// 新值: 嵌入卡与 afterAnswer 在行组件内,经 actionsRef 提交;容器的 persisted 题卡仍直接绑 submitChoice。
// 原因: BUG-725 已结算行记忆化,回调不得当 props。
assert.match(messageEntry, /<RectificationChoiceCard/);
assert.match(messageEntry, /afterAnswer/);
assert.match(messageEntry, /actionsRef\.current\.submitChoice/);
assert.match(messageEntry, /actionsRef\.current\.submitStop/);
assert.match(render, /<RectificationChoiceCard/);
assert.match(render, /onSelect=\{submitChoice\}/);
assert.match(render, /onStop=\{submitStop\}/);
assert.match(render, /afterAnswer/);
assert.doesNotMatch(render, /rectification-question-slot/);
assert.doesNotMatch(render, /liveSpoken/);
assert.doesNotMatch(render, /showLiveSpokenCollect/);
assert.doesNotMatch(messageEntry, /rectification-question-slot/);
});
test("openQuestionFromPersistedFocus returns collect_spoken without making collect choice-ready", () => {
@@ -6,6 +6,7 @@ import { homeSurface as page } from "./home-surface.ts";
const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), "utf8");
const chat = read("../src/components/rectification-agentic-chat.tsx");
const messageEntry = read("../src/components/rectification-message-entry.tsx");
const hook = read("../src/hooks/use-rectification-surface.ts");
const sessions = read("../src/hooks/use-session-management.ts");
const bootstrap = read("../src/lib/home-bootstrap.ts");
@@ -126,7 +127,10 @@ test("stopping keeps what streamed and says so; a 402 explains itself before lea
// 新值:气泡 stopped + 灰字 RECTIFICATION_STOPPED_NOTICE,error-message 只留给真正失败
// 原因:停止是用户动作,不能穿报错衣服(BUG-552)
assert.match(chat, /failed: false,\s*stopped: true/);
assert.match(chat, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
// 原值: 停止提示 prop 写在容器 map 的 ChatMessageRow 上。
// 新值: 同一表达式在行组件内。
// 原因: BUG-725。
assert.match(messageEntry, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
assert.doesNotMatch(chat, /if \(raw\.trim\(\)\) setError\(RECTIFICATION_STOPPED_NOTICE\)/);
assert.match(chat, /inputDisabled=\{readonly\}/);
assert.match(chat, /setError\(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE\);[\s\S]*onOpenBilling\?\.\(\{ source: "rectification" \}\);/);
@@ -174,5 +174,9 @@ test("a choice question without a GET card is the repair path, not collect waiti
assert.match(chat, /interviewChoiceCardUnavailable/);
assert.match(chat, /questionSource === "unavailable" \|\| deadChoice/);
assert.match(chat, /\/api\/rectification\/cases\/\$\{encodeURIComponent\(caseId\)\}\/repair-exit/);
assert.match(chat, /unansweredDeadChoice/);
// 原值: 容器 map 内有 `unansweredDeadChoice`。
// 新值: 同一判定在行组件内。
// 原因: BUG-725。
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
assert.match(messageEntry, /unansweredDeadChoice/);
});
@@ -17,6 +17,7 @@ import { PUBLIC_RECTIFICATION_METHODS } from "../src/lib/rectification-agentic/v
const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
const chatSource = read("src/components/rectification-agentic-chat.tsx");
const entrySource = read("src/components/rectification-message-entry.tsx");
const rowSource = read("src/components/chat-message-row.tsx");
const activitySource = read("src/components/agent-activity-status.tsx");
const packageJson = read("package.json");
@@ -156,8 +157,12 @@ test("after attempt.reset the timeline is empty, and a settled reply without a r
});
test("both chat surfaces render one timeline, one live marker, and no second receipt block", () => {
assert.match(chatSource, /rectificationTimelineRows\(\{/);
assert.match(chatSource, /timeline: rectificationTimelineRows/);
// 原值: 容器源码直接 `timeline: rectificationTimelineRows(...)`。
// 新值: 同一调用在行组件内;容器不得再每帧派生。
// 原因: BUG-725 已结算行记忆化。
assert.match(entrySource, /rectificationTimelineRows\(\{/);
assert.match(entrySource, /timeline: rectificationTimelineRows/);
assert.doesNotMatch(chatSource, /rectificationTimelineRows\s*\(/);
assert.doesNotMatch(chatSource, /rectification-activity-failure|<CompletedActivityReceipt/);
assert.doesNotMatch(chatSource, /label: "正在组织回答…",\s*\}\),\s*\}\s*:\s*message;/);
assert.match(chatSource, /rememberLiveActivity\(RECTIFICATION_ANALYZING_LIVE_LABEL, null\)/);
@@ -1,4 +1,8 @@
import type { RectificationRpcClient } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { withRectificationRequestCache } from "../src/lib/rectification-agentic/v9/request-cache.ts";
export const CASE_DOSSIER_RPC = "get_agentic_rectification_case_dossier";
export const CASE_COMPUTE_RPC = "get_agentic_rectification_case_compute";
export type FakeRpcHandler = (
fn: string,
@@ -17,13 +21,81 @@ export type FakeAccounting = {
};
};
export type FakeAccountingOptions = {
fallback?: FakeRpcHandler;
/**
* Default off. When true, wrap `client` with the request-scoped projection
* cache. Also enabled by RECTIFICATION_REQUEST_CACHE=1 for before/after counts.
*/
requestCache?: boolean;
/**
* Default off. When true, this instance is included in takeFakeAccountingRpcCounts().
* Also enabled by RECTIFICATION_RPC_COUNT=1.
*/
countRpc?: boolean;
};
export type CachedProjectionRpcCounts = {
dossier: number;
compute: number;
};
export type FakeAccountingRpcCountSummary = {
scenes: number;
dossier: number;
compute: number;
dossierMean: number;
computeMean: number;
};
const countedAccounting: FakeAccounting[] = [];
let rpcCountEnabled = process.env.RECTIFICATION_RPC_COUNT === "1";
export function countCachedProjectionRpcs(
calls: Array<{ fn: string }>,
): CachedProjectionRpcCounts {
let dossier = 0;
let compute = 0;
for (const call of calls) {
if (call.fn === CASE_DOSSIER_RPC) dossier += 1;
else if (call.fn === CASE_COMPUTE_RPC) compute += 1;
}
return { dossier, compute };
}
export function enableFakeAccountingRpcCount(enabled = true): void {
rpcCountEnabled = enabled;
if (!enabled) countedAccounting.length = 0;
}
export function takeFakeAccountingRpcCounts(): FakeAccountingRpcCountSummary {
const scenes = countedAccounting
.map((item) => countCachedProjectionRpcs(item.calls))
.filter((row) => row.dossier > 0 || row.compute > 0);
const dossier = scenes.reduce((sum, row) => sum + row.dossier, 0);
const compute = scenes.reduce((sum, row) => sum + row.compute, 0);
return {
scenes: scenes.length,
dossier,
compute,
dossierMean: scenes.length === 0 ? 0 : dossier / scenes.length,
computeMean: scenes.length === 0 ? 0 : compute / scenes.length,
};
}
if (process.env.RECTIFICATION_RPC_COUNT === "1") {
process.on("beforeExit", () => {
process.stderr.write(`RECTIFICATION_RPC_COUNT ${JSON.stringify(takeFakeAccountingRpcCounts())}\n`);
});
}
export function fakeAccounting(
handlers: Partial<Record<string, FakeRpcHandler>>,
options: { fallback?: FakeRpcHandler } = {},
options: FakeAccountingOptions = {},
): FakeAccounting {
const calls: Array<{ fn: string; args: Record<string, unknown> }> = [];
const profilePatches: Array<Record<string, unknown>> = [];
const client: FakeAccounting["client"] = {
const rawClient: FakeAccounting["client"] = {
rpc(fn, args) {
calls.push({ fn, args });
const handler = handlers[fn] ?? options.fallback;
@@ -57,7 +129,12 @@ export function fakeAccounting(
};
},
};
return { calls, profilePatches, client };
const requestCache = options.requestCache === true
|| process.env.RECTIFICATION_REQUEST_CACHE === "1";
const client = requestCache ? withRectificationRequestCache(rawClient) : rawClient;
const accounting: FakeAccounting = { calls, profilePatches, client };
if (options.countRpc === true || rpcCountEnabled) countedAccounting.push(accounting);
return accounting;
}
export const CASE_ID = "11111111-1111-4111-8111-111111111111";
@@ -349,6 +349,11 @@ test("embedded choice cards hide the duplicate legend; standalone cards keep the
assert.match(card, /<legend className="sr-only">\{props\.card\.prompt\}<\/legend>/);
assert.match(card, /variant === "embedded"/);
assert.match(chat, /rectification-message-question__prompt/);
// 原值: 容器源码含 variant="embedded"(消息内嵌卡)。
// 新值: 消息内嵌卡在行组件;容器 persisted 题卡仍是 embedded。
// 原因: BUG-725。
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
assert.match(messageEntry, /variant="embedded"/);
assert.match(chat, /variant="embedded"/);
// 原值: className="rectification-choice-why"(引擎作题简报)
// 新值: className="rectification-choice-why-user"(折叠「为什么问这题」)
@@ -423,7 +423,11 @@ test("agent instructions do not assert that the next question is already on scre
assert.doesNotMatch(agent, /自然过渡到界面上的下一步/);
assert.match(chat, /await loadCaseSnapshot\(\)/);
assert.match(chat, /mergeTurnQuestions/);
assert.match(chat, /afterAnswer=\{afterAnswer\}/);
// 原值: 容器源码含 `afterAnswer={afterAnswer}`。
// 新值: 行组件内部构造 afterAnswer。
// 原因: BUG-725。
const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
assert.match(messageEntry, /afterAnswer=\{afterAnswer\}/);
assert.doesNotMatch(chat, /isLatestMessage/);
});
+112 -24
View File
@@ -107,13 +107,15 @@ def _active_vimshottari(
birth_date: str,
moon_longitude: float,
event_at: datetime,
timeline: list[dict[str, Any]] | None = None,
) -> tuple[str, str, str]:
nakshatra, progress, _ = dasha_analyzer.lon_to_nakshatra(moon_longitude)
timeline, _, _, _ = dasha_analyzer.build_dasha_timeline(
birth_date,
nakshatra,
progress,
)
if timeline is None:
nakshatra, progress, _ = dasha_analyzer.lon_to_nakshatra(moon_longitude)
timeline, _, _, _ = dasha_analyzer.build_dasha_timeline(
birth_date,
nakshatra,
progress,
)
_, major = dasha_analyzer.find_current(timeline, event_at)
minor = dasha_analyzer.find_current_sub(
dasha_analyzer.build_antardasha(major),
@@ -131,11 +133,13 @@ def _active_narayana(
planet_longitudes: dict[str, float],
birth_at: datetime,
event_at: datetime,
periods: list[dict[str, Any]] | None = None,
) -> tuple[int | None, int | None]:
periods = narayana_dasha.calc_narayana_mahadasha(
ascendant_index,
planet_longitudes,
)
if periods is None:
periods = narayana_dasha.calc_narayana_mahadasha(
ascendant_index,
planet_longitudes,
)
age = max((event_at - birth_at).total_seconds() / (365.2425 * 86_400), 0.0)
active = narayana_dasha.get_current_narayana_dasha(periods, age)
major = active.get("md") or {}
@@ -280,23 +284,45 @@ def _score_event(
}
def _transit_chart_cache_key(
request: RectificationEventRequest,
event_at: datetime,
) -> tuple[Any, ...]:
return (
event_at.date().isoformat(),
float(request["lat"]),
float(request["lon"]),
float(request["tz"]),
request.get("ayanamsa", AYANAMSA),
request.get("node_mode", NODE_MODE),
)
def _controlled_transit_rules(
request: RectificationEventRequest,
event: LifeEvent,
natal_ascendant_index: int,
target_houses: tuple[int, ...],
transit_chart_cache: dict[tuple[Any, ...], dict[str, Any]] | None = None,
) -> list[str]:
"""Use only Jupiter/Saturn and only day/month dated events as a weak check."""
if event["precision"] == "year":
return []
event_at = _event_datetime(event)
transit_chart = domain_calculation_service.compute_chart({
payload = {
"year": event_at.year, "month": event_at.month, "day": event_at.day,
"hour": 12, "minute": 0, "lat": request["lat"], "lon": request["lon"],
"tz": request["tz"],
"ayanamsa": request.get("ayanamsa", AYANAMSA),
"node_mode": request.get("node_mode", NODE_MODE),
})
}
cache_key = _transit_chart_cache_key(request, event_at)
if transit_chart_cache is not None and cache_key in transit_chart_cache:
transit_chart = transit_chart_cache[cache_key]
else:
transit_chart = domain_calculation_service.compute_chart(payload)
if transit_chart_cache is not None:
transit_chart_cache[cache_key] = transit_chart
rules: list[str] = []
for planet in ("Jupiter", "Saturn"):
item = transit_chart.get("planets", {}).get(planet) or {}
@@ -305,9 +331,16 @@ def _controlled_transit_rules(
return rules
def _ashtakavarga_auxiliary(natal_chart: dict, ascendant_index: int, target_houses: tuple[int, ...]) -> tuple[list[str], float]:
def _ashtakavarga_auxiliary(
natal_chart: dict,
ascendant_index: int,
target_houses: tuple[int, ...],
ashtakavarga_result: dict[str, Any] | None = None,
) -> tuple[list[str], float]:
"""Return a bounded SAV consistency adjustment, never a standalone trigger."""
result = ashtakavarga.calc_ashtakavarga(natal_chart.get("planets", {}), ascendant_index)
result = ashtakavarga_result
if result is None:
result = ashtakavarga.calc_ashtakavarga(natal_chart.get("planets", {}), ascendant_index)
if not result.get("all_bav_valid") or not (result.get("sav") or {}).get("valid"):
return [], 0.0
house_scores = result.get("house_scores_full") or {}
@@ -323,17 +356,24 @@ def _ashtakavarga_auxiliary(natal_chart: dict, ascendant_index: int, target_hous
return [], 0.0
def _shadbala_verified_components_auxiliary(natal_chart: dict, birth_hour: float, dasha_lords: tuple[str, str, str]) -> tuple[list[str], float]:
def _shadbala_verified_components_auxiliary(
natal_chart: dict,
birth_hour: float,
dasha_lords: tuple[str, str, str],
shadbala_result: dict[str, Any] | None = None,
) -> tuple[list[str], float]:
"""Use only Sthana/Drik/Naisargika, whose oracle comparison is already matched."""
planets = natal_chart.get("planets", {})
sun = planets.get("Sun") or {}
moon = planets.get("Moon") or {}
if not isinstance(sun.get("lon"), (int, float)) or not isinstance(moon.get("lon"), (int, float)):
return [], 0.0
result = shadbala.calc_shadbala(
planets, str(natal_chart["ascendant"].get("sign") or "Aries"), birth_hour,
float(sun["lon"]), float(moon["lon"]),
)
result = shadbala_result
if result is None:
result = shadbala.calc_shadbala(
planets, str(natal_chart["ascendant"].get("sign") or "Aries"), birth_hour,
float(sun["lon"]), float(moon["lon"]),
)
values = {
planet: float((row.get("sthana_bala") or {}).get("total", 0)) + float(row.get("drik_bala", 0)) + float(row.get("naisargika_bala", 0))
for planet, row in (result.get("planets") or {}).items()
@@ -490,6 +530,26 @@ def build_candidate_static_context(
except (KeyError, TypeError, ValueError):
blocked_layers.append("Shadbala")
vimshottari_timeline = None
try:
nakshatra, progress, _ = dasha_analyzer.lon_to_nakshatra(planet_longitudes["Moon"])
vimshottari_timeline, _, _, _ = dasha_analyzer.build_dasha_timeline(
candidate_at.date().isoformat(),
nakshatra,
progress,
)
except (KeyError, TypeError, ValueError):
vimshottari_timeline = None
narayana_periods = None
try:
narayana_periods = narayana_dasha.calc_narayana_mahadasha(
ascendant_index,
planet_longitudes,
)
except (KeyError, TypeError, ValueError):
narayana_periods = None
birth_info = chart.get("birth_info") if isinstance(chart.get("birth_info"), dict) else {}
kp_snapshot = observe_kp_cusps(
birth_info.get("julian_day"),
@@ -538,6 +598,10 @@ def build_candidate_static_context(
"arudha_padas": arudha_padas,
"varga_charts": varga_charts,
"feature": feature_payload,
"ashtakavarga_result": ashtakavarga_result,
"shadbala_result": shadbala_result,
"vimshottari_timeline": vimshottari_timeline,
"narayana_periods": narayana_periods,
}
@@ -572,7 +636,12 @@ def _candidate_row(
missing_layers.extend(prefixes)
continue
try:
vimshottari = _active_vimshottari(candidate_at.date().isoformat(), moon_longitude, event_at)
vimshottari = _active_vimshottari(
candidate_at.date().isoformat(),
moon_longitude,
event_at,
context.get("vimshottari_timeline"),
)
except (KeyError, TypeError, ValueError):
missing_layers.append("Vimshottari_MD_AD_PD")
continue
@@ -582,6 +651,7 @@ def _candidate_row(
planet_longitudes,
candidate_at,
event_at,
context.get("narayana_periods"),
)
except (KeyError, TypeError, ValueError):
missing_layers.append("Narayana_MD_AD")
@@ -598,16 +668,30 @@ def _candidate_row(
narayana=narayana,
arudha_padas=arudha_padas,
))
transit_rules = _controlled_transit_rules(request, event, ascendant_index, DOMAIN_CONFIG[event["domain"]][1])
transit_rules = _controlled_transit_rules(
request,
event,
ascendant_index,
DOMAIN_CONFIG[event["domain"]][1],
context.get("_transit_chart_cache"),
)
if transit_rules:
evidence[-1]["rule_ids"].extend(transit_rules)
evidence[-1]["points"] = round(evidence[-1]["points"] + 0.25 * len(transit_rules) * precision_weight(event["precision"]), 4)
av_rules, av_points = _ashtakavarga_auxiliary(chart, ascendant_index, DOMAIN_CONFIG[event["domain"]][1])
av_rules, av_points = _ashtakavarga_auxiliary(
chart,
ascendant_index,
DOMAIN_CONFIG[event["domain"]][1],
context.get("ashtakavarga_result"),
)
if av_rules:
evidence[-1]["rule_ids"].extend(av_rules)
evidence[-1]["points"] = round(evidence[-1]["points"] + av_points * precision_weight(event["precision"]), 4)
shadbala_rules, shadbala_points = _shadbala_verified_components_auxiliary(
chart, candidate_at.hour + candidate_at.minute / 60, vimshottari,
chart,
candidate_at.hour + candidate_at.minute / 60,
vimshottari,
context.get("shadbala_result"),
)
if shadbala_rules:
evidence[-1]["rule_ids"].extend(shadbala_rules)
@@ -719,4 +803,8 @@ def compute_event_candidate_rows(
) -> list[CandidateScoreRow]:
"""Return every computed minute row while reusing one static chart scan per candidate."""
contexts = list(static_contexts) if static_contexts is not None else compute_candidate_static_contexts(request, candidates=candidates)
return [_candidate_row(request, context) for context in contexts]
transit_chart_cache: dict[tuple[Any, ...], dict[str, Any]] = {}
return [
_candidate_row(request, {**context, "_transit_chart_cache": transit_chart_cache})
for context in contexts
]
+41 -84
View File
@@ -52,6 +52,18 @@ try:
MIN_SCORING_EVENTS,
)
from scripts.vedastro_runtime_context import temporary_timeout_seconds
from scripts.vedastro_foreground import (
_FOREGROUND_VEDASTRO_EXECUTOR,
_FOREGROUND_VEDASTRO_WORKERS,
_blocked_foreground_vedastro,
_foreground_vedastro_budget_seconds,
_foreground_vedastro_join_seconds,
_join_foreground_vedastro,
_run_foreground_vedastro_gateway,
finish_foreground_vedastro,
should_include_western_evidence_packet,
start_foreground_vedastro,
)
except ModuleNotFoundError: # pragma: no cover - script execution path
from local_env import load_local_env
from rectification_policy import (
@@ -63,6 +75,18 @@ except ModuleNotFoundError: # pragma: no cover - script execution path
MIN_SCORING_EVENTS,
)
from vedastro_runtime_context import temporary_timeout_seconds
from vedastro_foreground import (
_FOREGROUND_VEDASTRO_EXECUTOR,
_FOREGROUND_VEDASTRO_WORKERS,
_blocked_foreground_vedastro,
_foreground_vedastro_budget_seconds,
_foreground_vedastro_join_seconds,
_join_foreground_vedastro,
_run_foreground_vedastro_gateway,
finish_foreground_vedastro,
should_include_western_evidence_packet,
start_foreground_vedastro,
)
try:
from scripts.unified_consultation_orchestrator import FORMAL_DIVISIONS, UnifiedConsultationOrchestrator
except ModuleNotFoundError: # pragma: no cover - script execution path
@@ -122,11 +146,6 @@ _ASYNC_JOB_EXECUTOR = ThreadPoolExecutor(
max_workers=_ASYNC_JOB_WORKERS,
thread_name_prefix='jyotish-job',
)
_FOREGROUND_VEDASTRO_WORKERS = max(int(os.environ.get('JYOTISH_FOREGROUND_VEDASTRO_WORKERS', '2')), 1)
_FOREGROUND_VEDASTRO_EXECUTOR = ThreadPoolExecutor(
max_workers=_FOREGROUND_VEDASTRO_WORKERS,
thread_name_prefix='jyotish-vedastro-fg',
)
_ASYNC_JOB_CAPACITY = threading.BoundedSemaphore(_ASYNC_JOB_WORKERS + _ASYNC_JOB_QUEUE_SIZE)
_RATE_LIMIT_LOCK = threading.Lock()
_RATE_LIMIT_BUCKETS: dict[str, tuple[float, int]] = {}
@@ -442,6 +461,8 @@ def _evaluate_vedastro_minute_sensitive_pair(request, times: list[str]) -> dict:
try:
reports.append(future.result(timeout=remaining))
except FuturesTimeoutError:
for pending in futures:
pending.cancel()
return _unevaluated_vedastro_minute_sensitive('vedastro_minute_snapshot_timeout')
except Exception:
return _unevaluated_vedastro_minute_sensitive('vedastro_minute_snapshot_error')
@@ -2036,22 +2057,6 @@ _VEDASTRO_COMPACT_DENY_KEYS = {
}
def _foreground_vedastro_budget_seconds() -> float:
raw = str(os.environ.get('JYOTISH_FOREGROUND_VEDASTRO_BUDGET_SECONDS', '8')).strip()
try:
return min(max(float(raw), 2.0), 12.0)
except ValueError:
return 8.0
def _foreground_vedastro_join_seconds() -> float:
raw = str(os.environ.get('JYOTISH_FOREGROUND_VEDASTRO_JOIN_SECONDS', '1.5')).strip()
try:
return min(max(float(raw), 0.0), 3.0)
except ValueError:
return 1.5
def _vedastro_natal_signs(raw) -> dict | None:
if not isinstance(raw, dict):
return None
@@ -2107,45 +2112,6 @@ def _compact_vedastro_cross_check(vedastro_official: dict, vedastro_gateway: dic
return compact
def _blocked_foreground_vedastro(*, reason: str, error_type: str | None = None) -> dict:
packet = {
'scope': 'vedastro_gateway_run',
'status': 'official_blocked',
'official_closure_state': 'official_blocked',
'official_closure_reason': reason,
}
if error_type:
packet['error_type'] = error_type
return packet
def _run_foreground_vedastro_gateway(handler, body: dict) -> dict:
try:
with temporary_timeout_seconds(_foreground_vedastro_budget_seconds()):
result = handler._compute_vedastro_gateway_run(body)
except Exception as exc:
return _blocked_foreground_vedastro(
reason='gateway_invocation_error',
error_type=type(exc).__name__,
)
return result if isinstance(result, dict) else _blocked_foreground_vedastro(reason='gateway_invocation_error')
def _join_foreground_vedastro(future, *, timeout: float) -> dict:
if future is None:
return _blocked_foreground_vedastro(reason='foreground_optional_evidence_timeout')
try:
result = future.result(timeout=timeout)
except FuturesTimeoutError:
return _blocked_foreground_vedastro(reason='foreground_optional_evidence_timeout')
except Exception as exc:
return _blocked_foreground_vedastro(
reason='gateway_invocation_error',
error_type=type(exc).__name__,
)
return result if isinstance(result, dict) else _blocked_foreground_vedastro(reason='gateway_invocation_error')
def _blocked_birth_time_sensitivity(*, error_type: str) -> dict:
return {
'schema': 'jyotish.report_birth_time_sensitivity.v1',
@@ -2266,7 +2232,7 @@ def execute_consultation_workflow(
western_evidence_packet=western_evidence_packet,
blind=bool(body.get('blind') or body.get('blind_technical_mode')),
)
if western_evidence_packet:
if western_evidence_packet and should_include_western_evidence_packet(body, surface=surface):
result['western_evidence_packet'] = western_evidence_packet
if body.get('return_high_rigor_shape'):
result['endpoint'] = 'high_rigor_workflow'
@@ -2288,13 +2254,14 @@ def execute_consultation_workflow(
rectification = {}
muhurta_panchanga = {}
computed_chart = bool(chart)
foreground_vedastro_future = None
if defer_optional_external_evidence:
# Overlap a bounded official gateway with local compute. Main-entry
# overview / range scan stay skipped so foreground cannot replay BUG-161.
foreground_vedastro_future = _FOREGROUND_VEDASTRO_EXECUTOR.submit(
_run_foreground_vedastro_gateway, handler, dict(body),
)
# Overlap a bounded official gateway with local compute. Main-entry
# overview / range scan stay skipped so foreground cannot replay BUG-161.
# Same-day snapshot cache skips the submit entirely (BUG-727).
vedastro_session = start_foreground_vedastro(
handler,
dict(body),
defer_optional_external_evidence=defer_optional_external_evidence,
)
for step in runtime_planner.get('sync_steps', []):
if step == 'run_prashna':
@@ -2375,21 +2342,8 @@ def execute_consultation_workflow(
})
executed_steps.append('run_thematic_report')
vedastro_gateway = rectification.get('vedastro_gateway') if isinstance(rectification, dict) else None
if defer_optional_external_evidence:
vedastro_gateway = _join_foreground_vedastro(
foreground_vedastro_future,
timeout=_foreground_vedastro_join_seconds(),
)
elif not isinstance(vedastro_gateway, dict):
try:
vedastro_gateway = handler._compute_vedastro_gateway_run(body)
except Exception as exc: # Gateway evidence must not block the local chart result.
vedastro_gateway = {
'scope': 'vedastro_gateway_run',
'status': 'official_blocked',
'official_closure_reason': 'gateway_invocation_error',
'error_type': type(exc).__name__,
}
if defer_optional_external_evidence or not isinstance(vedastro_gateway, dict):
vedastro_gateway = finish_foreground_vedastro(vedastro_session)
vedastro_official = handler._high_rigor_vedastro_official_summary(chart)
gateway_raw = (
@@ -2524,7 +2478,6 @@ def execute_consultation_workflow(
'interpretation_source_runtime_coverage': interpretation_source_runtime_coverage,
'machine_evidence_packet': machine_evidence_packet,
'consumer_context': consumer_context,
'western_evidence_packet': western_evidence_packet or {},
'real_case_calibration': real_case_calibration,
'birth_time_sensitivity': birth_time_sensitivity,
'runtime_evidence_log': runtime_evidence_log,
@@ -2535,6 +2488,10 @@ def execute_consultation_workflow(
'domain-relevant routes execute according to the configured sample/network limits.'
),
}
if should_include_western_evidence_packet(body, surface=surface):
result['western_evidence_packet'] = western_evidence_packet or {}
if getattr(vedastro_session, 'meta', None):
result['vedastro_snapshot_cache'] = vedastro_session.meta
if high_rigor:
result['high_rigor_external_parity'] = {
'status': 'pass' if external_parity_gate.get('status') == 'pass' else 'blocked',
+15 -8
View File
@@ -697,7 +697,11 @@ def build_refinement_packet(
"unique_minute_claim": False,
"confirmation_allowed": False,
}
from scripts.rectification.candidate_contrast import PROBE_PHASE_HOLDOUT_VALIDATION, event_year
from scripts.rectification.candidate_contrast import (
PROBE_PHASE_HOLDOUT_VALIDATION,
event_year,
opportunity_from_probe,
)
from scripts.rectification.case_holdout import reserved_holdout_events
from scripts.rectification.event_probes import (
candidate_contrast_opportunities,
@@ -729,13 +733,16 @@ def build_refinement_packet(
dropped = list(bundle["dropped"])
clarification = event_clarification_probes(request)
collection = evidence_collection_probes(request)
opportunities = candidate_contrast_opportunities(
request,
built,
scan=scan,
candidate_times=grid_times,
representative_time=representative_time,
)
if probe_times == grid_times:
opportunities = [opportunity_from_probe(probe) for probe in probes]
else:
opportunities = candidate_contrast_opportunities(
request,
built,
scan=scan,
candidate_times=grid_times,
representative_time=representative_time,
)
reserved = reserved_holdout_events(request.get("events") or [])
holdout = [
{
-6
View File
@@ -5,7 +5,6 @@ import json
from collections import defaultdict
from collections.abc import Callable, Sequence
from datetime import date, timedelta
from functools import lru_cache
from typing import Any
from scripts.active_rectification_event_engine import compute_candidate_static_contexts, compute_event_candidate_rows
@@ -126,11 +125,6 @@ def _canonical(value: Any) -> str:
return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
@lru_cache(maxsize=4096)
def _cached_rows(serialized: str) -> tuple[CandidateScoreRow, ...]:
return tuple(compute_event_candidate_rows(json.loads(serialized)))
_SUPPORT_RULES = (
"functional_benefic_auxiliary",
"arudha_auxiliary",
+2
View File
@@ -75,6 +75,8 @@ CORE_PYTEST_TARGETS = [
"tests/test_birth_time_journey_contract.py",
# Freeze scripts/jyotish_api_server.py growth; new features must be modules.
"tests/test_api_server_growth_contract.py",
# Foreground VedAstro snapshot cache + join cancel (BUG-727 / BUG-728).
"tests/test_vedastro_snapshot_cache.py",
# Native seven-governors adapter and the three read-only chart endpoints.
"tests/test_qizheng_chart_engine.py",
"tests/test_qizheng_api_productization.py",
+250
View File
@@ -0,0 +1,250 @@
"""Foreground VedAstro wait, cancel, and snapshot-cache coordination.
Join, budget, and worker count bound the same wall clock, so they are
declared together and must be changed together.
join: how long the consultation thread waits for the official gateway
(default 1.5s, cap 3s).
budget: how long a worker may keep running after it has started.
workers: process-wide pool. Extra work queues; join still caps the wait.
A timed-out join must cancel its future so queued work never starts.
budget <= k * join (k=2) keeps leftover occupancy of an already-running
worker from stretching later requests past the join cap. The previous
default budget of 8s with a 1.5s join left cancelled-too-late workers
holding the two-thread pool for the rest of the 8s.
"""
from __future__ import annotations
import os
import threading
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
from typing import Any
try:
from scripts.vedastro_runtime_context import temporary_timeout_seconds
from scripts.vedastro_snapshot_cache import (
annotate_stale_gateway,
is_cacheable_gateway,
lookup_snapshot,
official_snapshot_reference_date,
requires_today_snapshot,
store_snapshot,
)
except ModuleNotFoundError: # pragma: no cover - script execution path
from vedastro_runtime_context import temporary_timeout_seconds
from vedastro_snapshot_cache import (
annotate_stale_gateway,
is_cacheable_gateway,
lookup_snapshot,
official_snapshot_reference_date,
requires_today_snapshot,
store_snapshot,
)
# --- join / budget / workers (one declaration block; change together) ---
FOREGROUND_VEDASTRO_JOIN_SECONDS_DEFAULT = 1.5
FOREGROUND_VEDASTRO_JOIN_SECONDS_MAX = 3.0
FOREGROUND_VEDASTRO_BUDGET_JOIN_RATIO = 2.0
FOREGROUND_VEDASTRO_WORKERS_DEFAULT = 2
# ----------------------------------------------------------------------
_FOREGROUND_VEDASTRO_WORKERS = max(
int(os.environ.get("JYOTISH_FOREGROUND_VEDASTRO_WORKERS", str(FOREGROUND_VEDASTRO_WORKERS_DEFAULT))),
1,
)
_FOREGROUND_VEDASTRO_EXECUTOR = ThreadPoolExecutor(
max_workers=_FOREGROUND_VEDASTRO_WORKERS,
thread_name_prefix="jyotish-vedastro-fg",
)
def _foreground_vedastro_join_seconds() -> float:
raw = str(os.environ.get("JYOTISH_FOREGROUND_VEDASTRO_JOIN_SECONDS", str(FOREGROUND_VEDASTRO_JOIN_SECONDS_DEFAULT))).strip()
try:
return min(max(float(raw), 0.0), FOREGROUND_VEDASTRO_JOIN_SECONDS_MAX)
except ValueError:
return FOREGROUND_VEDASTRO_JOIN_SECONDS_DEFAULT
def _foreground_vedastro_budget_seconds() -> float:
join = _foreground_vedastro_join_seconds()
cap = FOREGROUND_VEDASTRO_BUDGET_JOIN_RATIO * join
raw = str(os.environ.get("JYOTISH_FOREGROUND_VEDASTRO_BUDGET_SECONDS", "")).strip()
if not raw:
return cap
try:
budget = float(raw)
except ValueError:
return cap
if budget < 0:
return cap
return min(budget, cap)
def _blocked_foreground_vedastro(*, reason: str, error_type: str | None = None) -> dict:
packet = {
"scope": "vedastro_gateway_run",
"status": "official_blocked",
"official_closure_state": "official_blocked",
"official_closure_reason": reason,
}
if error_type:
packet["error_type"] = error_type
return packet
def _run_foreground_vedastro_gateway(handler, body: dict, cancel_event: threading.Event | None = None) -> dict:
if cancel_event is not None and cancel_event.is_set():
return _blocked_foreground_vedastro(reason="foreground_optional_evidence_timeout")
try:
with temporary_timeout_seconds(_foreground_vedastro_budget_seconds()):
result = handler._compute_vedastro_gateway_run(body)
except Exception as exc:
return _blocked_foreground_vedastro(
reason="gateway_invocation_error",
error_type=type(exc).__name__,
)
return result if isinstance(result, dict) else _blocked_foreground_vedastro(reason="gateway_invocation_error")
def _run_foreground_vedastro_gateway_and_store(
handler,
body: dict,
cancel_event: threading.Event | None = None,
) -> dict:
result = _run_foreground_vedastro_gateway(handler, body, cancel_event)
if is_cacheable_gateway(result):
try:
store_snapshot(body, result)
except OSError:
pass
return result
def _join_foreground_vedastro(future, *, timeout: float, cancel_event: threading.Event | None = None) -> dict:
if future is None:
return _blocked_foreground_vedastro(reason="foreground_optional_evidence_timeout")
try:
result = future.result(timeout=timeout)
except FuturesTimeoutError:
if cancel_event is not None:
cancel_event.set()
future.cancel()
return _blocked_foreground_vedastro(reason="foreground_optional_evidence_timeout")
except Exception as exc:
return _blocked_foreground_vedastro(
reason="gateway_invocation_error",
error_type=type(exc).__name__,
)
return result if isinstance(result, dict) else _blocked_foreground_vedastro(reason="gateway_invocation_error")
def should_include_western_evidence_packet(body: dict | None, *, surface: str = "") -> bool:
payload = body if isinstance(body, dict) else {}
if payload.get("include_western_evidence_packet") or payload.get("return_western_evidence_packet"):
return True
if payload.get("return_high_rigor_shape"):
return True
if isinstance(payload.get("western_oracle_payload"), dict) or isinstance(payload.get("western_astrology_oracle"), dict):
return True
if isinstance(payload.get("western_evidence_packet"), dict):
return True
return str(surface or "") in {"skill_mcp", "professional_reading_web", "mcp"}
class ForegroundVedastroSession:
def __init__(self, handler, body: dict, *, defer: bool) -> None:
self.handler = handler
self.body = dict(body)
self.defer = bool(defer)
self.cached_gateway: dict | None = None
self.future = None
self.cancel_event = threading.Event()
self.meta: dict[str, Any] = {}
def start(self) -> "ForegroundVedastroSession":
today = official_snapshot_reference_date(self.body)
hit = lookup_snapshot(self.body, today=today)
if hit is not None:
record = hit["record"]
freshness = hit["freshness"]
gateway = record["gateway"]
if freshness == "stale":
gateway = annotate_stale_gateway(
gateway,
reference_date=str(record.get("reference_date") or ""),
served_on_utc_date=today,
)
refresh_body = dict(self.body)
refresh_body["reference_date"] = today
refresh_body["today"] = today
refresh_body["current_date"] = today
self.future = _FOREGROUND_VEDASTRO_EXECUTOR.submit(
_run_foreground_vedastro_gateway_and_store,
self.handler,
refresh_body,
None,
)
self.cached_gateway = gateway
self.meta = {
"freshness": freshness,
"reference_date": str(record.get("reference_date") or today),
"served_on_utc_date": today,
"refresh_submitted": freshness == "stale",
}
return self
self.meta = {
"freshness": "miss",
"reference_date": today,
"served_on_utc_date": today,
"refresh_submitted": False,
"require_today": requires_today_snapshot(self.body),
}
if self.defer:
self.future = _FOREGROUND_VEDASTRO_EXECUTOR.submit(
_run_foreground_vedastro_gateway_and_store,
self.handler,
dict(self.body),
self.cancel_event,
)
return self
def finish(self) -> dict:
if self.cached_gateway is not None:
return self.cached_gateway
if self.defer:
return _join_foreground_vedastro(
self.future,
timeout=_foreground_vedastro_join_seconds(),
cancel_event=self.cancel_event,
)
try:
result = self.handler._compute_vedastro_gateway_run(self.body)
except Exception as exc:
return _blocked_foreground_vedastro(
reason="gateway_invocation_error",
error_type=type(exc).__name__,
)
if not isinstance(result, dict):
return _blocked_foreground_vedastro(reason="gateway_invocation_error")
if is_cacheable_gateway(result):
try:
store_snapshot(self.body, result)
except OSError:
pass
return result
def start_foreground_vedastro(handler, body: dict, *, defer_optional_external_evidence: bool) -> ForegroundVedastroSession:
return ForegroundVedastroSession(
handler,
body,
defer=defer_optional_external_evidence,
).start()
def finish_foreground_vedastro(session: ForegroundVedastroSession | None) -> dict:
if session is None:
return _blocked_foreground_vedastro(reason="foreground_optional_evidence_timeout")
return session.finish()
+228
View File
@@ -0,0 +1,228 @@
"""Disk cache for foreground VedAstro gateway snapshots.
Keyed by birth data + ayanamsa + node + UTC reference date. This is a
separate store from ``_api_chart_cache`` (BUG-161): different directory
and a different key function. Freshness is the reference date itself
plus a 7-day stale window — there is no independent TTL environment
variable.
Filenames are sha256 hex only. Cache files must not contain names,
emails, or user ids.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import tempfile
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
CACHE_SCHEMA = "vedastro_snapshot_cache.v1"
MAX_STALE_DAYS = 7
_SHA256_NAME = re.compile(r"^[0-9a-f]{64}\.json$")
_IDENTITY_KEYS = {
"name",
"email",
"user_id",
"userid",
"user_email",
"session_id",
"sessionid",
"full_name",
"display_name",
}
def snapshot_cache_dir() -> Path:
raw = str(os.environ.get("JYOTISH_VEDASTRO_SNAPSHOT_CACHE_DIR") or "").strip()
path = Path(raw) if raw else ROOT / "scratch" / "local" / "vedastro_snapshot_cache"
path.mkdir(parents=True, exist_ok=True)
return path
def official_snapshot_reference_date(case: dict[str, Any] | None) -> str:
"""Same date rule as ``vedastro_service_adapter._official_snapshot_reference_date``.
Duplicated so this module does not import the adapter (and its optional
network stack) at cache-lookup time.
"""
payload = case if isinstance(case, dict) else {}
for key in ("reference_date", "today", "transit_date", "current_date"):
value = payload.get(key)
if not value:
continue
raw = str(value)[:10]
try:
datetime.strptime(raw, "%Y-%m-%d")
return raw
except ValueError:
continue
return datetime.utcnow().strftime("%Y-%m-%d")
def requires_today_snapshot(body: dict[str, Any] | None) -> bool:
payload = body if isinstance(body, dict) else {}
entrypoint = str(payload.get("entrypoint") or payload.get("consult_entrypoint") or "").strip()
return entrypoint == "daily_starlanguage"
def snapshot_cache_key(body: dict[str, Any] | None, *, reference_date: str) -> str:
payload = body if isinstance(body, dict) else {}
ayanamsa = (
payload.get("ayanamsa")
or payload.get("ayanamsa_name")
or payload.get("ayanamsa_policy")
or "raman"
)
node = payload.get("node_mode") or payload.get("nodeMode") or "mean"
material = {
"year": payload.get("year"),
"month": payload.get("month"),
"day": payload.get("day"),
"hour": payload.get("hour"),
"minute": payload.get("minute"),
"second": payload.get("second", 0),
"lat": payload.get("lat"),
"lon": payload.get("lon"),
"tz": payload.get("tz"),
"ayanamsa_policy": str(ayanamsa).strip().lower(),
"node_policy": str(node).strip().lower(),
"reference_date": str(reference_date)[:10],
}
canonical = json.dumps(material, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def snapshot_cache_path(cache_key: str) -> Path:
if not re.fullmatch(r"[0-9a-f]{64}", cache_key):
raise ValueError("vedastro snapshot cache key must be sha256 hex")
return snapshot_cache_dir() / f"{cache_key}.json"
def is_cacheable_gateway(packet: Any) -> bool:
if not isinstance(packet, dict):
return False
state = packet.get("official_closure_state") or packet.get("status")
if state != "official_verified":
return False
raw = packet.get("official_raw_response") or packet.get("raw_response")
return bool(raw)
def _strip_identity(value: Any) -> Any:
if isinstance(value, dict):
cleaned = {}
for key, item in value.items():
if str(key).strip().lower() in _IDENTITY_KEYS:
continue
cleaned[key] = _strip_identity(item)
return cleaned
if isinstance(value, list):
return [_strip_identity(item) for item in value]
return value
def _load_record(cache_key: str) -> dict[str, Any] | None:
path = snapshot_cache_path(cache_key)
if not _SHA256_NAME.match(path.name) or not path.is_file():
return None
try:
record = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(record, dict) or record.get("schema") != CACHE_SCHEMA:
return None
gateway = record.get("gateway")
if not isinstance(gateway, dict):
return None
reference_date = str(record.get("reference_date") or "")[:10]
try:
datetime.strptime(reference_date, "%Y-%m-%d")
except ValueError:
return None
return record
def lookup_snapshot(
body: dict[str, Any] | None,
*,
today: str | None = None,
require_today: bool | None = None,
) -> dict[str, Any] | None:
payload = body if isinstance(body, dict) else {}
served = today or official_snapshot_reference_date(payload)
must_be_today = requires_today_snapshot(payload) if require_today is None else bool(require_today)
today_key = snapshot_cache_key(payload, reference_date=served)
record = _load_record(today_key)
if record is not None:
return {
"freshness": "fresh",
"record": record,
"served_on_utc_date": served,
}
if must_be_today:
return None
current = datetime.strptime(served, "%Y-%m-%d").date()
for days in range(1, MAX_STALE_DAYS + 1):
past = (current - timedelta(days=days)).isoformat()
record = _load_record(snapshot_cache_key(payload, reference_date=past))
if record is None:
continue
return {
"freshness": "stale",
"record": record,
"served_on_utc_date": served,
}
return None
def store_snapshot(
body: dict[str, Any] | None,
gateway: dict[str, Any],
*,
reference_date: str | None = None,
) -> dict[str, Any] | None:
if not is_cacheable_gateway(gateway):
return None
payload = body if isinstance(body, dict) else {}
stored_date = (reference_date or official_snapshot_reference_date(payload))[:10]
cache_key = snapshot_cache_key(payload, reference_date=stored_date)
record = {
"schema": CACHE_SCHEMA,
"cache_key": cache_key,
"reference_date": stored_date,
"stored_at": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
"gateway": _strip_identity(gateway),
}
path = snapshot_cache_path(cache_key)
path.parent.mkdir(parents=True, exist_ok=True)
encoded = json.dumps(record, ensure_ascii=False, sort_keys=True)
fd, tmp_name = tempfile.mkstemp(prefix=f"{cache_key}.", suffix=".tmp", dir=str(path.parent))
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(encoded)
os.replace(tmp_name, path)
except Exception:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
return record
def annotate_stale_gateway(gateway: dict[str, Any], *, reference_date: str, served_on_utc_date: str) -> dict[str, Any]:
"""Shallow copy that shows which day a stale snapshot is from.
Does not change ``official_closure_state`` / ``status`` — a previously
delivered official layer stays whatever it was (BUG-301).
"""
packet = dict(gateway)
packet["snapshot_reference_date"] = reference_date
packet["snapshot_freshness"] = "stale"
packet["snapshot_served_on_utc_date"] = served_on_utc_date
return packet
+8
View File
@@ -5,6 +5,8 @@ from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = str(ROOT / "scripts")
WORKBUDDY_SKILL_SCRIPTS = ".workbuddy/skills/jyotish-vedic-astrology/scripts"
@@ -38,6 +40,12 @@ def pytest_runtest_setup() -> None:
ensure_project_scripts_first()
@pytest.fixture(autouse=True)
def _isolate_vedastro_snapshot_cache(tmp_path_factory, monkeypatch) -> None:
cache_dir = tmp_path_factory.mktemp("vedastro_snapshot_cache")
monkeypatch.setenv("JYOTISH_VEDASTRO_SNAPSHOT_CACHE_DIR", str(cache_dir))
def pytest_collection_modifyitems(items) -> None:
for item in items:
if item.fspath.basename == "test_api_server_security.py" and item.name.startswith(SLOW_API_SECURITY_PREFIXES):
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,374 @@
"""Memoization for candidate-minute invariants in the rectification engine.
Golden payload in tests/golden/rectification_engine_memoization_v1.json was
produced from origin/staging @ a8d29d1b before any memoization landed.
"""
from __future__ import annotations
import inspect
import json
from datetime import date, datetime
from pathlib import Path
from typing import Any
from unittest.mock import patch
from scripts.active_rectification_event_engine import (
_candidate_datetimes,
_controlled_transit_rules,
_shadbala_verified_components_auxiliary,
build_candidate_static_context,
compute_event_candidate_rows,
)
from scripts.rectification.api_service import score_candidates
from scripts.rectification.candidate_contrast import opportunity_from_probe
from scripts.rectification.contracts import normalize_rectification_request
from scripts.rectification.refinement_packet import build_refinement_packet
from scripts.rectification.scoring_service import sample_event_dates, scoreable_request
import scripts.active_rectification_event_engine as event_engine
import scripts.rectification.event_probes as event_probes
import scripts.rectification.scoring_service as scoring_service
import shadbala
ROOT = Path(__file__).resolve().parents[1]
GOLDEN_PATH = ROOT / "tests" / "golden" / "rectification_engine_memoization_v1.json"
FROZEN_TODAY = date(2026, 9, 16)
TIMING_KEYS = frozenset({"column_compare_ms"})
SOURCE_COMMIT = "a8d29d1b6cc37ff865ddec6c8bccdf9aa889ee53"
def public_score_request() -> dict[str, Any]:
"""Fictional events on the public 1990-01-01 Beijing smoke chart."""
return {
"birth_date": "1990-01-01",
"start_time": "12:00",
"end_time": "12:02",
"lat": 39.9,
"lon": 116.4,
"tz": 8,
"events": [
{
"id": "00000000-0000-4000-8000-000000000001",
"domain": "education",
"event_kind": "education_start",
"date_start": "2008-01-01",
"date_end": "2008-12-31",
"precision": "year",
"summary": "入学",
},
{
"id": "00000000-0000-4000-8000-000000000002",
"domain": "career",
"event_kind": "career_entry",
"date_start": "2012-06-01",
"date_end": "2012-06-30",
"precision": "month",
"summary": "入职",
},
],
}
def _strip_timing(value: Any) -> Any:
if isinstance(value, dict):
return {
key: _strip_timing(item)
for key, item in value.items()
if key not in TIMING_KEYS
}
if isinstance(value, list):
return [_strip_timing(item) for item in value]
return value
def _normalized_request() -> dict[str, Any]:
return normalize_rectification_request(public_score_request(), today=FROZEN_TODAY)
def _golden_payload() -> dict[str, Any]:
scored = score_candidates(_normalized_request())
return {
"source_commit": SOURCE_COMMIT,
"candidate_scores": scored["candidate_scores"],
"decision_receipt": _strip_timing(scored["decision_receipt"]),
"candidate_feature_snapshot": scored["candidate_feature_snapshot"],
}
def write_golden(path: Path = GOLDEN_PATH) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(_golden_payload(), ensure_ascii=True, indent=2) + "\n", encoding="utf-8")
return path
def _count_calls(monkeypatch, owner: Any, name: str, *, from_engine: bool = False) -> list[int]:
original = getattr(owner, name)
counter = [0]
def wrapped(*args: Any, **kwargs: Any) -> Any:
if from_engine:
caller = inspect.stack()[1].filename.replace("\\", "/")
if not caller.endswith("/active_rectification_event_engine.py"):
return original(*args, **kwargs)
counter[0] += 1
return original(*args, **kwargs)
monkeypatch.setattr(owner, name, wrapped)
return counter
def test_score_candidates_matches_baseline_golden() -> None:
expected = json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
actual = json.loads(json.dumps(_golden_payload(), ensure_ascii=True))
assert actual["candidate_scores"] == expected["candidate_scores"]
assert actual["decision_receipt"] == expected["decision_receipt"]
def test_shadbala_verified_fields_match_with_and_without_birth_minute() -> None:
request = {
"birth_date": "1990-01-01",
"start_time": "12:17",
"end_time": "12:17",
"lat": 39.9,
"lon": 116.4,
"tz": 8,
"events": [],
}
candidate_at = datetime(1990, 1, 1, 12, 17)
context = build_candidate_static_context(request, candidate_at)
chart = context["chart"]
planets = chart.get("planets", {})
sun = float(planets["Sun"]["lon"])
moon = float(planets["Moon"]["lon"])
sign = str(chart["ascendant"].get("sign"))
birth_hour = candidate_at.hour + candidate_at.minute / 60
with_minute = shadbala.calc_shadbala(
planets,
sign,
birth_hour,
sun,
moon,
birth_minute=float(candidate_at.minute),
)
without_minute = shadbala.calc_shadbala(planets, sign, birth_hour, sun, moon)
fields_with = {
planet: (
float((row.get("sthana_bala") or {}).get("total", 0)),
float(row.get("drik_bala", 0)),
float(row.get("naisargika_bala", 0)),
)
for planet, row in (with_minute.get("planets") or {}).items()
}
fields_without = {
planet: (
float((row.get("sthana_bala") or {}).get("total", 0)),
float(row.get("drik_bala", 0)),
float(row.get("naisargika_bala", 0)),
)
for planet, row in (without_minute.get("planets") or {}).items()
}
assert fields_with == fields_without
reused_rules, reused_points = _shadbala_verified_components_auxiliary(
chart, birth_hour, ("Sun", "Moon", "Mars"), shadbala_result=with_minute,
)
fresh_rules, fresh_points = _shadbala_verified_components_auxiliary(
chart, birth_hour, ("Sun", "Moon", "Mars"),
)
assert reused_rules == fresh_rules
assert reused_points == fresh_points
def test_shadbala_and_ashtakavarga_run_once_per_candidate(monkeypatch) -> None:
shadbala_calls = _count_calls(monkeypatch, event_engine.shadbala, "calc_shadbala")
ashtakavarga_calls = _count_calls(monkeypatch, event_engine.ashtakavarga, "calc_ashtakavarga")
request = _normalized_request()
scored = score_candidates(request)
candidate_count = len(scored["candidate_scores"])
assert candidate_count >= 2
assert len(request["events"]) >= 2
year_samples = sample_event_dates(request["events"][0])
assert request["events"][0]["precision"] == "year"
assert len(year_samples) >= 2
assert shadbala_calls[0] == candidate_count
assert ashtakavarga_calls[0] == candidate_count
def test_dasha_timelines_run_once_per_candidate(monkeypatch) -> None:
# Year-only events skip dasha-transition proximity, which also calls these
# two functions from event_probes. The scoring path itself must stay 1×/minute.
body = public_score_request()
body["events"][1]["precision"] = "year"
body["events"][1]["date_start"] = "2012-01-01"
body["events"][1]["date_end"] = "2012-12-31"
request = normalize_rectification_request(body, today=FROZEN_TODAY)
vim_calls = _count_calls(
monkeypatch, event_engine.dasha_analyzer, "build_dasha_timeline", from_engine=True,
)
narayana_calls = _count_calls(
monkeypatch, event_engine.narayana_dasha, "calc_narayana_mahadasha", from_engine=True,
)
scored = score_candidates(request)
candidate_count = len(scored["candidate_scores"])
assert len(request["events"]) >= 2
assert vim_calls[0] == candidate_count
assert narayana_calls[0] == candidate_count
def test_transit_charts_match_unique_event_dates_not_candidates(monkeypatch) -> None:
transit_calls = [0]
original = event_engine.domain_calculation_service.compute_chart
def wrapped(payload: dict[str, Any], *args: Any, **kwargs: Any) -> Any:
# Natal candidates in this fixture also sit at 12:00; transit charts use the event year.
if payload.get("hour") == 12 and payload.get("minute") == 0 and payload.get("year") != 1990:
transit_calls[0] += 1
return original(payload, *args, **kwargs)
monkeypatch.setattr(event_engine.domain_calculation_service, "compute_chart", wrapped)
request = _normalized_request()
scored = score_candidates(request)
candidate_count = len(scored["candidate_scores"])
expected_dates = {
sampled
for event in scoreable_request(request)["events"]
for sampled in sample_event_dates(event)
}
assert candidate_count >= 2
assert len(expected_dates) >= 2
assert transit_calls[0] == len(expected_dates)
assert transit_calls[0] != candidate_count * len(expected_dates)
def test_year_precision_transits_still_short_circuit() -> None:
request = {
"birth_date": "1990-01-01",
"start_time": "12:00",
"end_time": "12:00",
"lat": 39.9,
"lon": 116.4,
"tz": 8,
"events": [{
"id": "00000000-0000-4000-8000-000000000009",
"domain": "career",
"event_kind": "career_entry",
"date": "2012",
"precision": "year",
"summary": "入职",
}],
}
with patch.object(event_engine.domain_calculation_service, "compute_chart") as compute_chart:
rules = _controlled_transit_rules(request, request["events"][0], 0, (10,))
assert rules == []
compute_chart.assert_not_called()
def _probe_request_and_built() -> tuple[dict[str, Any], dict[str, Any], list[str]]:
body = public_score_request()
body["events"] = [
{
"id": f"00000000-0000-4000-8000-{index:012d}",
"domain": domain,
"event_kind": kind,
"date_start": "2012-01-01",
"date_end": "2012-12-31",
"precision": "year",
"summary": kind,
}
for index, (domain, kind) in enumerate(
(
("education", "education_start"),
("career", "career_entry"),
("relationship", "relationship_start"),
),
start=1,
)
]
request = normalize_rectification_request(body, today=FROZEN_TODAY)
times = ["12:00", "12:01", "12:02"]
event_ids = [event["id"] for event in request["events"]]
built = {
"candidate_times": times,
"matrix": {
event_id: {
clock: {"points": 4, "rule_ids": ["vim_md_domain_house"], "technique_layers": ["vim_md_domain_house"]}
for clock in times
}
for event_id in event_ids
},
"date_sensitivity": [],
"missing_layers": [],
"static_contexts": [],
}
return request, built, times
def test_discriminating_probes_run_once_when_probe_times_equal_grid(monkeypatch) -> None:
calls = _count_calls(monkeypatch, event_probes, "_discriminating_event_probe_lists")
request, built, times = _probe_request_and_built()
packet = build_refinement_packet(
request,
built,
representative_time="12:00",
candidate_times=times,
)
assert calls[0] == 1
assert packet["candidate_contrast_opportunities"] == [
opportunity_from_probe(probe) for probe in packet["discriminating_event_probes"]
]
def test_discriminating_probes_run_twice_on_refresh_columns(monkeypatch) -> None:
calls = _count_calls(monkeypatch, event_probes, "_discriminating_event_probe_lists")
request, built, times = _probe_request_and_built()
request = {**request, "refresh_probes": True}
packet = build_refinement_packet(
request,
built,
representative_time="12:00",
candidate_times=times,
column_times=["12:00"],
)
assert times != ["12:00"]
assert calls[0] == 2
assert "discriminating_event_probes" in packet
assert "candidate_contrast_opportunities" in packet
def test_dead_row_cache_removed_from_scoring_service() -> None:
token = "_cached_" + "rows"
assert not hasattr(scoring_service, token)
hits: list[str] = []
for folder in ("scripts", "tests"):
for path in (ROOT / folder).rglob("*.py"):
text = path.read_text(encoding="utf-8")
if token in text:
hits.append(str(path.relative_to(ROOT)).replace("\\", "/"))
assert hits == []
def test_compute_event_candidate_rows_reuses_static_context_without_mutating_it() -> None:
request = {
"birth_date": "1990-01-01",
"start_time": "12:00",
"end_time": "12:01",
"lat": 39.9,
"lon": 116.4,
"tz": 8,
"events": [{
"id": "00000000-0000-4000-8000-000000000003",
"domain": "career",
"event_kind": "career_entry",
"date": "2012-06-15",
"precision": "day",
"summary": "入职",
}],
}
contexts = [
build_candidate_static_context(request, candidate)
for candidate in _candidate_datetimes(request)
]
original_keys = [frozenset(context) for context in contexts]
rows = compute_event_candidate_rows(request, static_contexts=contexts)
assert len(rows) == 2
assert [frozenset(context) for context in contexts] == original_keys
assert all("_transit_chart_cache" not in context for context in contexts)
+350
View File
@@ -0,0 +1,350 @@
"""BUG-727 / BUG-728: foreground VedAstro snapshot cache, cancel, and on-demand western packet."""
from __future__ import annotations
import json
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import pytest
from scripts import jyotish_api_server
from scripts import vedastro_foreground as foreground
from scripts.jyotish_api_server import JyotishAPIHandler
from scripts.vedastro_snapshot_cache import (
lookup_snapshot,
snapshot_cache_dir,
snapshot_cache_key,
snapshot_cache_path,
store_snapshot,
)
def _handler() -> JyotishAPIHandler:
return JyotishAPIHandler.__new__(JyotishAPIHandler)
def _verified_gateway(*, request_id: str, reference_date: str) -> dict:
return {
"scope": "vedastro_gateway_run",
"status": "official_verified",
"official_closure_state": "official_verified",
"official_raw_response": {
"request_id": request_id,
"natal": {"sun": "Leo", "moon": "Taurus", "ascendant": "Cancer"},
"reference_date": reference_date,
},
}
def _fake_chart() -> dict:
return {
"success": True,
"birth_info": {"date": "1997-08-08", "time": "05:00", "tz": 8},
"ascendant": {"lon": 92.0, "sign": "Cancer", "sign_idx": 3},
"planets": {},
"dasha": {"periods": [{"lord": "Sun", "start": "2026-01-01", "end": "2027-01-01"}]},
"modules": {
"varga_full": {"D9": {}, "D10": {}},
"arudha_padas": {"A10": {}, "UL": {}},
"narayana_dasha": {"periods": []},
"ashtakavarga": {"sav": []},
"kp_cusps": {"houses": []},
},
"special_lagnas": {"precision": "sunrise_correct"},
}
def _stub_local(monkeypatch, handler: JyotishAPIHandler, gateway) -> None:
monkeypatch.setattr(handler, "_compute_chart", lambda body: _fake_chart())
monkeypatch.setattr(
handler,
"_compute_rectification_gate",
lambda body: {
"success": True,
"summary": {"recommended_events": [], "warned": [], "disabled": []},
},
)
monkeypatch.setattr(
handler,
"_compute_thematic_report",
lambda body: {"success": True, "endpoint": "thematic_report", "themes": {}},
)
monkeypatch.setattr(handler, "_compute_vedastro_gateway_run", gateway)
def _body(*, reference_date: str, extra: dict | None = None) -> dict:
payload = {
"entry_mode": "direct_chart",
"question": "请直接排盘并重点看事业",
"theme": ["career"],
"year": 1997,
"month": 8,
"day": 8,
"hour": 5,
"minute": 0,
"lat": 36.420487,
"lon": 114.209936,
"tz": 8,
"reference_date": reference_date,
"today": reference_date,
"current_date": reference_date,
"western_mode": False,
"defer_optional_external_evidence": True,
}
if extra:
payload.update(extra)
return payload
@pytest.fixture
def snapshot_cache(tmp_path, monkeypatch):
cache_dir = tmp_path / "vedastro_snapshot_cache"
monkeypatch.setenv("JYOTISH_VEDASTRO_SNAPSHOT_CACHE_DIR", str(cache_dir))
return cache_dir
def test_snapshot_cache_dir_is_not_api_chart_cache_dir(snapshot_cache) -> None:
chart_dir = jyotish_api_server._api_chart_cache_dir()
snap_dir = snapshot_cache_dir()
assert snap_dir.resolve() != chart_dir.resolve()
assert snap_dir.name == "vedastro_snapshot_cache"
assert chart_dir.name == "api_chart_cache"
assert snapshot_cache_key is not jyotish_api_server._api_chart_cache_key
def test_snapshot_cache_key_differs_from_api_chart_cache_key() -> None:
body = _body(reference_date="2026-09-15")
snap_key = snapshot_cache_key(body, reference_date="2026-09-15")
chart_key = jyotish_api_server._api_chart_cache_key(
jyotish_api_server._build_api_chart_cache_payload(body)
)
assert snap_key != chart_key
assert len(snap_key) == 64
assert "1997" not in snap_key
assert "36.420487" not in snap_key
assert snapshot_cache_path(snap_key).name == f"{snap_key}.json"
def test_cache_file_strips_identity_and_keeps_hash_filename(snapshot_cache) -> None:
body = _body(reference_date="2026-09-15")
packet = _verified_gateway(request_id="keep-me", reference_date="2026-09-15")
packet["name"] = "Secret Person"
packet["email"] = "user@example.com"
packet["user_id"] = "usr_123"
stored = store_snapshot(body, packet, reference_date="2026-09-15")
assert stored is not None
path = snapshot_cache_path(stored["cache_key"])
text = path.read_text(encoding="utf-8")
assert "Secret Person" not in text
assert "user@example.com" not in text
assert "usr_123" not in text
assert "keep-me" in text
assert path.name.endswith(".json")
assert "1997" not in path.name
def test_second_same_day_consultation_skips_gateway_and_urlopen(snapshot_cache, monkeypatch) -> None:
handler = _handler()
calls = {"gateway": 0, "urlopen": 0}
def counting_gateway(body):
calls["gateway"] += 1
return _verified_gateway(request_id="same-day", reference_date=body.get("reference_date"))
def counting_urlopen(*_args, **_kwargs):
calls["urlopen"] += 1
raise AssertionError("urlopen must not run when the gateway is stubbed")
_stub_local(monkeypatch, handler, counting_gateway)
monkeypatch.setattr("urllib.request.urlopen", counting_urlopen)
body = _body(reference_date="2026-09-15")
first = handler._compute_consultation_workflow(body)
second = handler._compute_consultation_workflow(body)
assert calls["gateway"] == 1
assert calls["urlopen"] == 0
assert first["vedastro_gateway"] == second["vedastro_gateway"]
assert first["vedastro_snapshot_cache"]["freshness"] == "miss"
assert second["vedastro_snapshot_cache"]["freshness"] == "fresh"
def test_next_day_serves_stale_and_refreshes_without_waiting(snapshot_cache, monkeypatch) -> None:
handler = _handler()
yesterday = "2026-09-14"
today = "2026-09-15"
store_snapshot(
_body(reference_date=yesterday),
_verified_gateway(request_id="yesterday", reference_date=yesterday),
reference_date=yesterday,
)
release = threading.Event()
calls = {"gateway": 0}
def slow_today(body):
calls["gateway"] += 1
release.wait(timeout=2.0)
return _verified_gateway(request_id="today", reference_date=today)
_stub_local(monkeypatch, handler, slow_today)
started = time.monotonic()
result = handler._compute_consultation_workflow(_body(reference_date=today))
elapsed = time.monotonic() - started
release.set()
assert elapsed < 1.0
assert result["vedastro_snapshot_cache"]["freshness"] == "stale"
assert result["vedastro_snapshot_cache"]["reference_date"] == yesterday
assert result["vedastro_gateway"]["snapshot_reference_date"] == yesterday
assert result["vedastro_gateway"]["official_closure_state"] == "official_verified"
assert result["vedastro_gateway"]["official_raw_response"]["request_id"] == "yesterday"
deadline = time.monotonic() + 2.0
while calls["gateway"] < 1 and time.monotonic() < deadline:
time.sleep(0.01)
assert calls["gateway"] >= 1
def test_daily_starlanguage_does_not_eat_yesterday_cache(snapshot_cache, monkeypatch) -> None:
handler = _handler()
yesterday = "2026-09-14"
today = "2026-09-15"
store_snapshot(
_body(reference_date=yesterday),
_verified_gateway(request_id="yesterday", reference_date=yesterday),
reference_date=yesterday,
)
calls = {"gateway": 0}
def today_gateway(body):
calls["gateway"] += 1
return _verified_gateway(request_id="today-live", reference_date=today)
_stub_local(monkeypatch, handler, today_gateway)
result = handler._compute_consultation_workflow(
_body(reference_date=today, extra={"entrypoint": "daily_starlanguage"})
)
assert calls["gateway"] == 1
assert result["vedastro_snapshot_cache"]["freshness"] == "miss"
assert result["vedastro_gateway"]["official_raw_response"]["request_id"] == "today-live"
assert lookup_snapshot(
_body(reference_date=today, extra={"entrypoint": "daily_starlanguage"}),
today=today,
)["freshness"] == "fresh"
def test_nth_plus_one_wait_stays_within_join_and_queued_work_is_cancelled(
snapshot_cache, monkeypatch
) -> None:
join = 0.2
monkeypatch.setenv("JYOTISH_FOREGROUND_VEDASTRO_JOIN_SECONDS", str(join))
monkeypatch.setenv("JYOTISH_FOREGROUND_VEDASTRO_BUDGET_SECONDS", "8")
handler = _handler()
started: list[int] = []
lock = threading.Lock()
release = threading.Event()
def slow_gateway(body):
with lock:
started.append(1)
release.wait(timeout=8)
return _verified_gateway(request_id="slow", reference_date="2026-09-15")
_stub_local(monkeypatch, handler, slow_gateway)
workers = foreground._FOREGROUND_VEDASTRO_WORKERS
body = _body(reference_date="2026-09-15")
elapsed: list[float] = []
def run_one() -> None:
t0 = time.monotonic()
result = handler._compute_consultation_workflow(body)
elapsed.append(time.monotonic() - t0)
assert result["vedastro_gateway"]["official_closure_reason"] == "foreground_optional_evidence_timeout"
try:
with ThreadPoolExecutor(max_workers=workers + 3) as pool:
futs = [pool.submit(run_one) for _ in range(workers + 3)]
for fut in futs:
fut.result(timeout=8)
assert max(elapsed) <= join + 2.5
assert len(started) <= workers
finally:
release.set()
def test_join_budget_workers_declared_together_and_budget_respects_ratio(monkeypatch) -> None:
source = Path("scripts/vedastro_foreground.py").read_text(encoding="utf-8")
join_idx = source.index("FOREGROUND_VEDASTRO_JOIN_SECONDS_DEFAULT")
ratio_idx = source.index("FOREGROUND_VEDASTRO_BUDGET_JOIN_RATIO")
workers_idx = source.index("FOREGROUND_VEDASTRO_WORKERS_DEFAULT")
span = max(join_idx, ratio_idx, workers_idx) - min(join_idx, ratio_idx, workers_idx)
assert span < 400
assert "change together" in source
monkeypatch.delenv("JYOTISH_FOREGROUND_VEDASTRO_JOIN_SECONDS", raising=False)
monkeypatch.delenv("JYOTISH_FOREGROUND_VEDASTRO_BUDGET_SECONDS", raising=False)
join = foreground._foreground_vedastro_join_seconds()
budget = foreground._foreground_vedastro_budget_seconds()
assert budget <= foreground.FOREGROUND_VEDASTRO_BUDGET_JOIN_RATIO * join + 1e-9
monkeypatch.setenv("JYOTISH_FOREGROUND_VEDASTRO_JOIN_SECONDS", "1.5")
monkeypatch.setenv("JYOTISH_FOREGROUND_VEDASTRO_BUDGET_SECONDS", "8")
assert foreground._foreground_vedastro_budget_seconds() <= 3.0 + 1e-9
def test_default_consultation_omits_western_packet_and_schema_required_keys_remain(
snapshot_cache, monkeypatch
) -> None:
handler = _handler()
_stub_local(
monkeypatch,
handler,
lambda body: _verified_gateway(request_id="omit-western", reference_date="2026-09-15"),
)
omitted = handler._compute_consultation_workflow(_body(reference_date="2026-09-15"))
included = handler._compute_consultation_workflow(
_body(reference_date="2026-09-15", extra={"include_western_evidence_packet": True})
)
assert "western_evidence_packet" not in omitted
assert "western_spectrum" in omitted["consumer_context"]
assert omitted["success"] is True
assert isinstance(omitted["chart"], dict)
assert isinstance(omitted["routing"], dict)
assert isinstance(omitted["consumer_context"], dict)
assert "western_evidence_packet" in included
workflow_ts = Path("frontend/src/mastra/consultation-workflow.ts").read_text(encoding="utf-8")
schema = workflow_ts.split("export const consultationWorkflowResponseSchema", 1)[1]
assert "success: z.boolean()" in schema
assert "chart: z.record(z.unknown())" in schema
assert "routing: z.record(z.unknown())" in schema
assert "consumer_context:" in schema
assert ").passthrough()" in schema
omitted_chars = len(json.dumps(omitted, ensure_ascii=False))
included_chars = len(json.dumps(included, ensure_ascii=False))
assert included_chars >= omitted_chars
def test_western_oracle_payload_still_returns_packet(snapshot_cache, monkeypatch) -> None:
handler = _handler()
_stub_local(
monkeypatch,
handler,
lambda body: _verified_gateway(request_id="oracle", reference_date="2026-09-15"),
)
result = handler._compute_consultation_workflow(
_body(
reference_date="2026-09-15",
extra={
"western_oracle_payload": {
"source_engine": "kerykeion_external_json",
"natal": {"ascendant": "Virgo", "mc": "Gemini"},
}
},
)
)
assert result["western_evidence_packet"]["source_engine"] == "kerykeion_external_json"