Compare commits

..
Author SHA1 Message Date
jesse-ux f8e607c29a refactor(home): 校正面 15 个状态从 Home 下沉
Independent Staging Quality Gate / validate (push) Failing after 6m43s
Independent Staging Quality Gate / publish (push) Skipped
Home() useState 66->52, useRef 41->39. Shell-facing fields merge into one object;
subtree state lives in useRectificationSurface. Chat props 20->7. Growth contract
rebaselined. Zero behavior/copy change.
2026-09-16 08:33:48 +08:00
jesse-ux 3b17c1b242 test(freeze): 增长冻结改盯耦合,行数降为粗护栏
Independent Staging Quality Gate / validate (push) Failing after 6m21s
Independent Staging Quality Gate / publish (push) Skipped
Home() useState/useRef 与 JyotishAPIHandler 类方法/__new__ 不得增长。行数改为开工实测基线加余量。产品授权删除参数式 hook 零 React hook 规则。
2026-09-16 08:02:43 +08:00
jesse-ux 2b7b45657b test(rectification): prove memoization in-process; tolerate golden float drift (BUG-733)
Independent Staging Quality Gate / validate (push) Canceled after 5m0s
Independent Staging Quality Gate / publish (push) Canceled after 0s
Replace whole-structure golden == with same-process cached vs None fallback equality, plus discrete-strict / measured-tolerance golden comparison. Do not rebuild the golden JSON.
2026-09-16 07:56:56 +08:00
jesse-ux 51a65d927f docs(tasks): record BUG-732 implementation SHA
Independent Staging Quality Gate / validate (push) Failing after 6m19s
Independent Staging Quality Gate / publish (push) Skipped
2026-09-16 07:41:41 +08:00
jesse-ux dcfc2f15af fix(consultation): count visible text only for session quota (BUG-732)
Replace append_consultation_question so the 200,000 quota sums only user-visible text, and add a 1,000,000-byte whole-JSON physical cap. Both still return session_full. Advisory lock, request_id idempotency, and the 200-message cap are unchanged.
2026-09-16 07:41:41 +08:00
Jesse_ChenandClaude Opus 5 7227b1ed8d docs(tasks): R1 验收修复单——等价 golden 跨机不稳,门禁靠运气绿
对 BUG-721(引擎记忆化)的验收:实现通过,等价性我独立复核过——改前
6b3248bf 与改后 e4788dfc 在同一台机器上跑同一 payload,candidate_scores
逐字相同(8.6273 / 8.6226 / 8.1701),9 条计数断言全过;tsc 0、lint 0
error、全量前端 3291(fail 31) → 3304(fail 31) 失败清单逐条一致、next
build 绿且 / 仍 Static、首屏 gzip 0.00% 变化。

唯一一条红的是 test_score_candidates_matches_baseline_golden:golden 与
本机实际输出有 4 处浮点尾数差(score 1.0e-4 ×2、margin_percent 1.1e-3
×2),改前改后都对不上,说明是 golden 存全精度浮点跨机不稳,不是实现
改坏了。复发自 BUG-712。而 tests/test_rectification_*.py 在
CORE_PYTEST_TARGETS 里,staging 自动门禁会跑它——今天绿只是因为 CI 那台
机器的舍入和执行方一致。

修复单只改测试:主证据换成同进程差分(四个缓存键置 None 即回退旧路径,
A/B 严格相等,不经过任何跨机变量),golden 降为离散字段严格相等 + 浮点
带容差;禁止用重建 golden 来修;防复发升级为仓库级并排查另外五份 golden。

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-15 23:39:30 +00:00
jesse-ux 149e1ec4c3 fix(consult): drop traces, budget checkpoints, silent summary inherit
Independent Staging Quality Gate / validate (push) Canceled after 3m8s
Independent Staging Quality Gate / publish (push) Canceled after 0s
BUG-729: dropped history rounds leave an omission marker in the model-visible summary slot.
BUG-730: checkpoint threshold is 0.4 of historyBudgetChars (128k still 16,000).
BUG-731: session_full new chat copies owned context_summary on the server; clients send only continued_from_session_id.
2026-09-16 07:38:36 +08:00
41 changed files with 2305 additions and 160 deletions
+4 -2
View File
@@ -92,8 +92,10 @@
## 6. 代码增长冻结
- `scripts/jyotish_api_server.py` must not grow. New endpoints and features go in dedicated modules under `scripts/` and are thinly registered from the main file. Do not add new handler bodies, workflows, or feature branches to this file. The cap is enforced by `tests/test_api_server_growth_contract.py`(冻结时行数 + 300 行 bugfix 余量)
- `frontend/src/app/page.tsx` 已从 4,766 行拆到 2,000 行以下,**不得再增长**:新逻辑进 `frontend/src/hooks/``frontend/src/lib/` 或组件;参数式 hook 内部保持 0 个 React hook 的既定模式。
行数是耦合的影子:盯行数时,`Home()` 的状态数和 `JyotishAPIHandler` 的类方法数仍在涨。主门换成耦合指标,行数只作粗护栏
- `scripts/jyotish_api_server.py` must not grow. New endpoints and features go in dedicated modules under `scripts/` and are thinly registered from the main file. Do not add new handler bodies, workflows, or feature branches to this file. The live gate is `tests/test_api_server_growth_contract.py`**类方法数不得增长、`JyotishAPIHandler.__new__` 伪造点不得增长**;行数是粗护栏(开工实测基线 + 300)。
- `frontend/src/app/page.tsx``Home()``useState` / `useRef` 数不得增长。新逻辑进 `frontend/src/hooks/``frontend/src/lib/` 或组件;抽出去的 hook 与子组件应当持有自己的状态,`page.tsx` 只做装配。行数是粗护栏(开工实测基线 + 150),由 `frontend/tests/home-shell-growth-contract.test.ts` 执行。
- 不得再手写第二个聊天输入框(一律 `ChatComposer`,自有草稿走 `value`)、第二套滚动跟随(一律 `useConversationScrollAnchor` + `JumpToLatestButton`)、第二套加载动画(揭幕后不得出现 spinner / 骨架 / "正在加载",流式生成中除外)。
## 7. 前端红线
+5
View File
@@ -1,5 +1,10 @@
# BLOCKED
## 对话额度拆分(2026-09-16,分支 `codex/consultation-session-capacity-20260915`BUG-732
- **无 Docker** 本机 `docker` 不在 PATH。`npm run test:db``npm run db:migrate:check`(需要 `SCHEMA_DATABASE_URL` 连 Postgres)未跑。`frontend/tests/database-consultation-session-capacity.test.ts` 在无 Docker 时 skip,**不得写成通过**。替代证据是 SQL 静态合同:额度求和表达式不含 `thinkingText` / `thinkingSections`,物理上限为 `length(elem::text)`,算式写在迁移注释里。
- **详情接口体积(5.3,已量,不改接口):** 用三域本命夹具(每轮正文 4,000 汉字 + 思考 4,000 汉字 + 实测 `thinkingSections` + 典型三个 receipt)构造 `GET /api/sessions/[id]``{ session }` JSON。19 轮 UTF-8 **562,240 B0.536 MiB**50 轮 **1,479,034 B1.411 MiB**,约为 2.63 倍。1.4 MiB 在 2 vCPU 上打开长会话会偏沉,分页 / 按需加载历史不在本单(任务书 §9)。PostgreSQL `length()` 按字节计,汉字正文 4,000 字 ≈ 12,000 字节,线上按字节撞 200,000 额度会早于「50 轮汉字」;50 轮数字是任务书字符口径对照。
## 只读页验收修复:镜像与浏览器(2026-09-16,分支 `codex/readonly-pages-fix-20260916`
- **无 Docker** 无法验证 API 镜像里 `COPY vendor` 与容器内 `node --version`。不得写成通过。
+8
View File
@@ -1,5 +1,13 @@
# 印度占星 Skill 更新日志
## 2026-09-16 — 这段对话写满之前,思考过程不再占用额度
普通咨询里,模型内部的思考过程不再算进「这段对话已写满」的额度。额度仍按你看得到的正文计算,能问的轮次大约从十几轮提到约五十轮。写满时还是请开一个新对话,提示不变。Skill 版本不变。
## 2026-09-16 — 对话写满后开新对话会静默带上之前的摘要
普通咨询写满、点「开新对话」之后,服务端会把上一场的会话摘要拷进新对话,界面上看不出差别。进不了窗口的更早几轮,模型会看到省略说明,不再当成没发生过。Skill 版本不变。
## 2026-09-16 — 同一天再问同一张盘,不再重复等外部证据
普通聊天同一张盘、同一天里再问,外部证据直接用已经取到的,不再每轮等外网。隔了一天会先用最多七天内的旧证据马上回答,后台再刷新;「深入看今日」仍然只要当天的。西洋盘整包不再塞进每一轮的回答里,压缩后的西洋层还在。Skill 版本不变。
+80
View File
@@ -11332,3 +11332,83 @@
- 相关记录:BUG-727
- 复发自:无
- 修复版本:待发布
## BUG-729 | 咨询历史丢掉整轮时模型看不见任何痕迹
- 状态:resolved
- 首次发现:2026-09-15
- 最近更新:2026-09-16
- 影响面:`consultationHistoryWindow``consultationUserTurnContent``POST /api/consult`
- 用户现象:历史超过模型预算后,追问「刚才你说的那个时间」时模型当成从没说过。单条超长会写「省略 N 字」,整轮被丢掉时什么都不留。
- 触发条件:普通咨询多轮之后,尾巴字符数超过当前模型的历史预算,窗口从最旧整条丢弃。
- 根因:`droppedCount` 算出来了,`route.ts` 只取 `.tail`。BUG-555 的防复发只写了「不得再按固定 12 条 × 头部截断静默砍结论」,整轮丢弃不在字面里,所以没拦住。
- 修复:`droppedCount > 0` 时在摘要槽追加与 `omissionMarker` 同风格的说明。有摘要时写「更早的 N 轮问答已并入上面的会话摘要」;没有摘要时诚实写结论尚未并入。`droppedCount === 0` 不加这句话。
- 验证:超预算历史的模型可见文本含丢弃说明且轮数等于 `droppedCount`;零丢弃不加这句话;源码合同断言 `route.ts` 读取 `historyWindow.droppedCount`
- 防复发:咨询历史任何形式的丢弃(截断单条、丢整轮)都必须在模型可见文本里留痕。
- 相关记录:BUG-555
- 复发自:BUG-555(防复发只覆盖头部截断)
- 修复版本:待发布
## BUG-730 | 写摘要的阈值写死 16,000,追不上按窗口算出的历史预算
- 状态:resolved
- 首次发现:2026-09-15
- 最近更新:2026-09-16
- 影响面:`historyBudgetChars``consultationHistoryCheckpointChars``shouldCheckpoint``checkpointSessionContextSummary`
- 用户现象:后台上架中等上下文窗口的模型后,每轮静默丢掉最老的几轮问答,摘要却还没开始写。
- 触发条件:模型 `context_window` 低于约 70,667(例如 64k / 32k)。历史预算已经小于写死的 16,000 字阈值。
- 根因:`shouldCheckpoint` 用常量 16,000`consultationHistoryWindow``historyBudgetChars()`。两个数各写各的,没有「阈值必须低于预算」的断言。
- 修复:阈值改为预算的 0.4(默认 128k 窗口仍是 16,000)。运行时断言阈值 < 预算。检查点把会话模型的 `contextWindow` 传进去。
- 验证:表驱动覆盖 200k / 128k / 64k / 32k / null,逐条 `checkpoint < budget`128k 仍为 16,000。
- 防复发:触发摘要的阈值必须由历史预算派生,并由一条断言钉死「阈值 < 预算」在所有合法上下文窗口下成立。
- 相关记录:BUG-555
- 复发自:BUG-555(检查点阈值与窗口预算未绑在一起)
- 修复版本:待发布
## BUG-731 | 对话写满后开新对话不继承服务端已有的会话摘要
- 状态:resolved
- 首次发现:2026-09-15
- 最近更新:2026-09-16
- 影响面:`chatSessionCreateSchema``POST /api/sessions``continueInNewChat``startNewChat`
- 用户现象:这段对话已写满、点「开新对话」之后,模型对刚才的结论一无所知,用户被要求从零开始。
- 触发条件:`append_consultation_question` 返回 `session_full`,客户端走「开新对话」。
- 根因:新会话是空的。`context_summary` 不在列表 GET 列里,创建合同也不接收它。即便前端想带,也没有合法入口。
- 修复:创建合同增加可选 `continued_from_session_id`(保持 `.strict()`,不加 `context_summary`)。服务端按当前用户读源会话摘要,读到才写入新行;读不到、不属于该用户、或为空都静默跳过,仍返回 201。写满出口把当前会话 id 带进创建请求。界面不加「接着上次聊」之类提示。
- 验证:带来源 id 时新行摘要等于源会话;源会话属于别人时新行无摘要且 201;源码合同断言创建 schema 没有 `context_summary` 字段;写满后「开新对话」没有新增提示文案。
- 防复发:会话满员后的「开新对话」出口必须由服务端继承 `context_summary`;摘要文本任何时候都不得由客户端提供。不得把 `messages` 加回列表 GET。
- 相关记录:BUG-464、BUG-555
- 复发自:无
- 修复版本:待发布
## BUG-732 | 对话额度把思考文本算进去,十几轮就「已写满」
- 状态:resolved
- 首次发现:2026-09-15
- 最近更新:2026-09-16
- 影响面:`append_consultation_question``POST /api/consult``session_full`、咨询会话详情 `GET /api/sessions/[id]`
- 用户现象:普通咨询问大约十几轮就提示「这段对话已写满,开个新对话继续吧」。200 条消息那档永远碰不到。
- 触发条件:继续往同一段咨询会话里发问;助手消息带有 `thinkingText` / `thinkingSections`
- 根因:BUG-464 立下的 200,000 字符上限身兼二职却两职都没做好。求和把用户读不到的 `thinkingText``thinkingSections` 算进去,却不算同样入库的 `techniqueTruth` / `workflowReceipt` / `agentExecutionReceipt`。这不是回归,是那条上限从一开始就混用了「对话有多长」和「这一行有多大」。
- 修复:新迁移 `CREATE OR REPLACE` 该函数。对话额度仍是 200,000,只累加 `elem->>'text'`。另加物理上限 `sum(length(elem::text))`,算式 50 轮 ×(正文约 4,000 + 思考 4,000 + 分节 3,000 + 三个 receipt 约 3,000)≈ 700,000,取 1,000,000。两档都返回既有 `session_full`。签名、返回列、error_code、advisory lock、`request_id` 幂等、200 条上限、单条 16,000 字校验均未改。
- 验证:`frontend/tests/consultation-session-capacity.test.ts` 锁定额度求和不含 `thinkingText` / `thinkingSections`、物理上限算式、签名与 `session_full``frontend/tests/database-consultation-session-capacity.test.ts` 用真实 Postgres 覆盖思考不占额度、短正文+大 receipt 撞物理上限、额度边界不先撞物理上限;本机无 Docker,该文件 skip,不得写成通过。既有幂等 / 满员用例未改。
- 防复发:会话上限必须分成两条各司其职的口径:面向用户的对话额度只数用户读得到的正文;面向存储的物理上限必须把整条消息 JSON 算全。新增会存进 `messages` 的字段时,必须明确它进哪一条,不得默认落进对话额度。
- 相关记录:BUG-464
- 复发自:无
- 修复版本:待发布
## BUG-733 | 校正记忆化 golden 对全精度浮点做整体 `==`,跨机门禁靠运气绿
- 状态:resolved
- 首次发现:2026-09-15
- 最近更新:2026-09-16
- 影响面:`tests/test_rectification_engine_memoization.py``tests/golden/rectification_engine_memoization_v1.json`(文件未改)
- 用户现象:用户看不见。门禁 glob `tests/test_rectification_*.py``CORE_PYTEST_TARGETS` 里,换一台机器或基础镜像会因 `score` / `margin_percent` 尾数差 1.0e-41.1e-3 而红,CI 绿只是碰巧和生成 golden 的那台舍入一致。
- 触发条件:`test_score_candidates_matches_baseline_golden` 把 live payload 与 golden 做整体 `==`
- 根因:golden 存了全精度浮点并整体相等。这是 BUG-712 的同一形状。BUG-712 的防复发只落在 `ephemeris_events` 那一处(量化到 6 位再比),没有仓库级守卫,BUG-721 新写的 rectification golden 重蹈覆辙。
- 修复:只改测试,不动 `scripts/`。主证据改成同进程差分:同一批 static contextsA 带四层缓存、B 把 `ashtakavarga_result` / `shadbala_result` / `vimshottari_timeline` / `narayana_periods``None``compute_event_candidate_rows` 输出严格相等。golden 改成分档比较:离散字段严格相等;浮点用实测最大漂移 1.1e-3 推出的容差(绝对 2e-3 与相对 5e-4 取更宽者)。禁止调用 `write_golden()` 更新那份 JSON。
- 验证:同进程 A/B 逐字相等,且 B 的 `calc_shadbala` 次数显著高于 A(本机 6 vs 0)。反向:把某一层缓存换成全零 shadbala 对象后不再相等。golden 分档比较通过;把 golden 浮点改 1e-2 必须红;把离散字段 `time` 改掉必须红。`pytest tests/test_rectification_engine_memoization.py``pytest tests/test_rectification_*.py` 0 failed。golden JSON `git diff` 无改动。
- 防复发:任何 golden 比较都不得对浮点做整体 `==`;浮点必须量化或带容差,容差数值要有实测依据并写在注释里;能在同进程内做差分证明的命题,不得用跨机 golden 代替。
- 相关记录:BUG-712、BUG-721
- 复发自:BUG-712
- 修复版本:待发布
@@ -0,0 +1,60 @@
# PROGRESS · 普通聊天的三条记忆缺口(2026-09-16)
工作树:`.worktrees/consultation-context-memory-20260915`
分支:`codex/consultation-context-memory-20260915`
任务书基线:`origin/staging` @ `6b3248bf`;开工时本 worktree 在 `11893c7f`(任务书已合入 staging)。
本机 Windows。Skill **未 bump**。未改 `frontend/src/app/page.tsx`、数据库、依赖。
开工核对:`docs/BUG_HISTORY.md` 最大号 **BUG-720**。校正四单预占 721726、external-evidence-cache 预占 727/728,本单使用预占 **BUG-729 / 730 / 731**,无冲突。
## 任务状态
| 任务 | 状态 | BUG |
| --- | --- | --- |
| 5.2 写摘要阈值跟着预算走 | 完成 | BUG-730 |
| 5.1 丢整轮必须留痕 | 完成 | BUG-729 |
| 5.3 写满时静默继承摘要 | 完成 | BUG-731 |
| 5.4 Bug 历史 | 完成 | 729/730/731 |
## 实现要点
- **BUG-730**:删除写死的 `CONSULTATION_HISTORY_TAIL_MAX_CHARS = 16_000`。新函数 `consultationHistoryCheckpointChars(window)` = `floor(historyBudgetChars(window) × 0.4)`,并运行时断言阈值 < 预算。128k 仍是 16,000。`shouldCheckpoint` / `checkpointSessionContextSummary``contextWindow`;consult 把会话模型窗口传进去。
- **BUG-729**`droppedCount > 0` 时在 `consultationUserTurnContent` 的摘要槽追加与 `omissionMarker` 同风格的说明。有摘要:「更早的 N 轮问答已并入上面的会话摘要」;无摘要:「更早的 N 轮问答未能进入本轮上下文,结论尚未并入会话摘要」。`droppedCount === 0` 不加。`route.ts` 四条用户回合都读 `historyWindow.droppedCount`
- **BUG-731**`chatSessionCreateSchema` 增加可选 `continued_from_session_id`(保持 `.strict()`**不加** `context_summary`)。`POST /api/sessions` 按当前用户读源会话摘要,读到才写入新行;读不到 / 别人的 / 空都静默跳过,仍 201。`continueInNewChat` 把当前会话 id 传给 `startNewChat`。界面无新文案。
未改摘要机制本身(800 汉字、15 秒 ref 计时器、乐观并发、结算后异步)。未用 `AbortSignal.timeout()` 替换摘要超时。未把 `messages` 加回列表 GET。未动数据库。
## 既有断言改动
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `session-context-summary.test.ts` | `CONSULTATION_HISTORY_TAIL_MAX_CHARS === 16_000``shouldCheckpoint` 只测 15_999 / 16_001 | `consultationHistoryCheckpointChars(128_000) === 16_000`;另测 64k 阈值 2_400 | BUG-730:阈值由预算派生,128k 行为与改前一致 |
| `chat-session-write.test.ts` 源码合同 | `const { id, updated_at: _ignoredClientClock, ...values } = parsed.data` | 另拆 `continued_from_session_id`,不随 values 插入 | 该字段不是表列 |
| `chat-session-url.test.ts` / `composer-isolation-contract.test.ts` | 切片起点 `async function startNewChat()` | `async function startNewChat(` | 可带来源会话 id |
| `consultation-context-cache-contract.test.ts` | 检查点不传窗口 | 传 `sessionContextWindow`;历史文件不得再出现字面量 `16_000` | BUG-730 |
128k 检查点阈值:**原值 16,000 / 新值 16,000 / 原因** 比例 0.4 × 预算 40,000。未弱化其它既有断言。
## 测试
| 命令 | 结果 |
| --- | --- |
| `./node_modules/.bin/tsc --noEmit` | **0 错** |
| `npm run lint` | **0 error**119 条既有 warning;本单未新增 error |
| 本单相关 `npx tsx --test`history / summary / cache contract / chat-session-write / authority / url / composer-isolation / consultation-context | **69 pass / 0 fail** |
| `npx tsx --test tests/consultation*.test.ts tests/chat-session-*.test.ts tests/session-*.test.ts` | 264 项 / 252 pass / **12 fail**:3 个文件级失败 + 9 条 methodology,全是 Windows `SkillPackageRegistryError`EPERM symlink),与本单文件无关 |
| `npm test``tests/*.test.ts tests/*.test.tsx` | `# tests 3110 / # pass 3023 / # fail 73 / # skipped 14`。失败清单不含本单文件。对照近期同机 `PROGRESS-chart-page-blocking-open-20260915.md` 的 fail **73****零新增失败** |
| `page.tsx` | 未改 |
| `npm run build` | compile + TypeScript 过;Collecting page data 死在既有 `SkillPackageRegistryError`EPERM symlink `/api/daily-starlanguage`),与本单无关。未能从本机构建表确认 `/``○ Static` 与首屏 gzip。源码:`page.tsx``force-dynamic`;既有合同「home stays a client-read query on a static route」本单相关套件已绿 |
## 环境缺口
- 本机 Windows 无开发者模式 symlink`skill-package-registry` 建 live runtime alias 报 EPERM。因此 `next build` 收集页面数据失败;若干 consult/methodology/skill-binding 测试整文件红。Linux CI / staging 不受影响。
- 无 Docker`tests/database-*.test.ts` 照常红,与基线同类。
- 无登录态、无 Chrome:写满后「开新对话」的静默继承未做浏览器走查。界面无新文案,源码合同已锁「开新对话」按钮与禁止「接着上次聊」类句子。
## 收尾限制
- 摘要失败仍只 `console.warn("session context summary failed")`,不打印摘要正文。
- 侧栏「新建对话」不带 `continued_from_session_id`,只有写满出口的「开新对话」会静默继承。
- 源会话属于别人或没有摘要时新行 `context_summary` 为空,接口仍 201。
@@ -0,0 +1,63 @@
# PROGRESS · 对话上限一半被思考文本吃掉(2026-09-15)
工作树:`.worktrees/consultation-session-capacity-20260915`
分支:`codex/consultation-session-capacity-20260915`
任务书基线:`6b3248bf`;开工时 `origin/staging` = **`11893c7f`**。
修复提交:`11829cf7`
本机 Windows。无 Docker。
未改 `frontend/src/app/api/consult/route.ts``page.tsx`、Skill。未 bump Skill。BUG 号核对:该 SHA 最大号仍是 **BUG-720**,本单用预占 **BUG-732**,无冲突。
| 任务 | 状态 | 说明 |
| --- | --- | --- |
| 5.1 对话额度只数 `text` | 完成(运行时 DB 为环境缺口) | 新迁移 `20260916010000_consultation_session_capacity.sql` `CREATE OR REPLACE``v_chars``sum(length(elem->>'text'))` |
| 5.2 物理上限 `length(elem::text)` | 完成(同上) | 算式见下;两档都返回 `session_full` |
| 5.3 详情接口体积 | 完成(夹具量过) | 见下表;1.41 MiB 写入 `BLOCKED.md` 观察,本单不改接口 |
| 5.4 BUG-732 | 完成 | 关联 BUG-464;不是回归 |
## 实现要点
- 只新增一份迁移。函数签名、返回列、`error_code` 取值、advisory lock、`request_id` 幂等、200 条上限、`char_length(v_text) > 16000` 均未改。
- 物理上限算术(迁移注释原文):50 rounds × (~4,000 body + ~4,000 thinkingText + ~3,000 thinkingSections + ~3,000 receipts) ≈ 700,000。Headroom → 1,000,000。
- 实测 receipt(典型三域本命:12 行技法审计 + workflow + agentExecutionReceiptJSON **1,738 字符 / 1,950 字节**,小于任务书 3,000**不调高** 1,000,000。
- 实测 `thinkingSections` JSON 字符:1 域 1,246、2 域 1,968、3 域 2,702(任务书 1,521 / 2,243 / 2,977,同量级,计划文案有漂移)。
- 未删、未截断任何已存字段。
## 详情 JSON 体积(5.3
夹具:每轮用户提问 100 汉字 + 助手正文 4,000 汉字 + 思考 4,000 汉字 + 三域 `natalConsultationThinkingPlan` + 上面那份典型 receipt。`GET /api/sessions/[id]``{ session }``sessionSelect``messages`)。
| 口径 | 轮次 | UTF-8 字节 | MiB |
| --- | ---: | ---: | ---: |
| 改前(旧合并额度大约卡住的地方) | 19 | 562,240 | 0.536 |
| 改后(任务书字符口径约 50 轮) | 50 | 1,479,034 | 1.411 |
50 / 19 ≈ **2.63**。1.411 MiB 在 2 vCPU 上打开长会话会偏沉,观察项进 `BLOCKED.md`;分页不在本单。
PostgreSQL `length()` 按字节,与 BUG-464 相同。4,000 汉字正文 ≈ 12,000 字节,按字节撞 200,000 会早于「50 轮汉字」。上表 50 轮是任务书字符口径对照,不是汉字会话在线上能存到的轮数。物理上限 1,000,000 字节在这组夹具上仍晚于对话额度触发。
## 既有断言改动
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `database-local-business.test.ts` 迁移已应用列表 | 最末 `20260915010000_rectification_touch_chat_session.sql` | 另加 `20260916010000_consultation_session_capacity.sql` 及「不得复制进 db/migrations」 | 新迁移必须被 runner 应用到 |
未改幂等 / advisory lock / `session_missing` / 200 条满员三条既有用例。未改 `chat-session-authority.test.ts`(仍读 20260901010000 原文件,原文件未改)。
## 测试
| 命令 | 结果 |
| --- | --- |
| `./node_modules/.bin/tsc --noEmit` | 0 错 |
| `npx tsx --test tests/consultation-session-capacity.test.ts` | 6 pass / 0 fail |
| `npx tsx --test tests/consultation-*.test.ts tests/chat-session-*.test.ts` | 220 tests / 208 pass / **12 fail**:全部是 Windows `SKILL.md` symlink `EPERM``consultation-agentic-runtime``consultation-birth-time-mode``consultation-workflow-request` 整文件,以及 methodology 读 live skill 的 9 条)。与本单无关,既有环境缺口。本单 6 条与 `chat-session-*` 全绿 |
| `npx eslint`(本单三个测试文件 `--max-warnings 0` | 0 error / 0 warning |
| `npm run lint` | **0 error** / 119 warning(既有,未动) |
| `npm run db:migrate:check` | **blocked**`SCHEMA_DATABASE_URL is required` |
| `npm run test:db` | **blocked**39 tests / 7 pass / 27 fail / 5 skip。失败均为 `spawnSync docker ENOENT` 或 Windows symlink EPERM。本单 `database-consultation-session-capacity` **skip**`docker unavailable on this host`),不得写成通过 |
| 全量 `npx tsx --test tests/*.test.ts` | 3089 tests / 3001 pass / 73 fail / 15 skip。失败为无 Docker `ENOENT`、Windows `SKILL.md` symlink `EPERM`、以及 `G:\\G:\\` 路径加倍,与既有环境缺口同类。本单 6 条静态合同在该套件里通过;DB 用例 skip |
## 收尾限制
- 运行时 Postgres 合同本机未跑。有 Docker 的验收机必须跑 `npm run test:db`
- 详情接口未分页。50 轮夹具 1.41 MiB,见 `BLOCKED.md`
@@ -0,0 +1,94 @@
# PROGRESS · 增长冻结改盯耦合(2026-09-16
工作树:`.worktrees/freeze-metric-change-20260915`
分支:`codex/freeze-metric-change-20260915`
任务书基线写 `6b3248bf`;开工时 `origin/staging` = **`51a65d92`**(含 BUG-732)。
本机 Windows。无 Docker。本单不占 BUG 号,不改 `CHANGELOG.md` / `BUG_HISTORY.md` / `docs/tasks/README.md`
`page.tsx``scripts/jyotish_api_server.py` **一行未动**(反向验证后已 `git checkout` 还原)。
## 开工实测基线(2026-09-16`51a65d92`
禁止抄任务书 1951 / 66 / 41 / 11334 / 225。下列数字全部本机现测。
| 组 | 指标 | 数法 | 实测 | 新门 |
| --- | --- | --- | ---: | --- |
| 1 | `frontend/src/app/page.tsx` 行数 | `Path.read_bytes().count(b"\n")`,与 `(source.match(/\n/g) ?? []).length` 相同 | **1951** | 粗护栏 ≤ 1951 + 150 = 2101 |
| 2a | `Home()` `useState` | `\buseState[<(]`(含 `useState<Type>(` | **66** | 主门,不得增长 |
| 2b | `Home()` `useRef` | `\buseRef[<(]` | **41** | 主门,不得增长 |
| 3 | `scripts/jyotish_api_server.py` 行数 | `Path.read_bytes().count(b"\n")` | **11291** | 粗护栏 ≤ 11291 + 300 = 11591 |
| 4a | 四空格 `def`(任务书同一种数法) | `^ (?:async )?def \w+` 扫全文件 | **225** | 主门,不得增长 |
| 4b | `JyotishAPIHandler.__new__` | `scripts/` + `tests/``*.py`,排除合同测试自身 | **33** | 主门,不得增长 |
说明:
- 任务书表格写 api 行数 11,334;本机 `51a65d92` 实测 **11291**(未抄)。
- 任务书表格写 `__new__` = 4:那是 **scripts/ 生产伪造点**`consultation_workflow_service.py` ×2、`capture_report_blocked_repairs_golden.py` ×1、`local_accuracy_report.py` ×1)。任务书要求扫 `scripts/``tests/`,实测合计 **33**scripts 4 + tests 29)。主门按扫描范围合计冻结,只许降不许升。失败信息会列出命中文件。
- 全文件四空格 `def` = 225,其中 `JyotishAPIHandler` 类体 222、类前嵌套 3。冻结用任务书指定的全文件数法。
- 只按 `useState(` 会计到 26,漏掉泛型调用;合同用 `\buseState[<(]`
`frontend/AGENTS.md` 只有 Next.js 版本提示,没有 §6 重复表述,本单未改。
## 做了什么
1. `tests/test_api_server_growth_contract.py`:主门改成类方法数 + `__new__` 计数;行数 rebaseline 为 11291 + 300。`must not grow` / `thinly registered` / `CORE_PYTEST_TARGETS` 三条原样仍绿。
2. 新建 `frontend/tests/home-shell-growth-contract.test.ts``Home()` `useState` / `useRef` 不得增长,行数粗护栏 1951 + 150。
3. `frontend/tests/chart-view-route.test.ts`:删除行数断言,保留 `doesNotMatch(pageSource, /chart-page|ChartPageView|\/api\/chart-view/)`,注明搬到 home-shell 合同。
4. `AGENTS.md` §6:主门改耦合指标;删掉「参数式 hook 内部保持 0 个 React hook」;第三条三个「不得再手写第二套」原样保留;仍含 `must not grow``thinly registered`
## 既有断言改动
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `chart-view-route.test.ts` `page.tsx does not grow to host the chart page` | `(pageSource.match(/\n/g) ?? []).length <= 1951``doesNotMatch(...)` | 测试改名为 `page.tsx does not host the chart page`,只留 `doesNotMatch` | 行数冻结与星盘页无关,搬到专用合同 |
| `test_api_server_growth_contract.py` 行数 baseline | 110632026-09-02+ 300 | **11291**2026-09-16 实测)+ 300 | 任务书要求开工实测 rebaseline,不得抄旧数 |
## 反向验证
### 1. `page.tsx` 人为加一个 `useState`
`Home()` 里插入 `const [_growthContractProbe, _setGrowthContractProbe] = useState(false);`,然后:
```
npx tsx --test tests/home-shell-growth-contract.test.ts
not ok 1 - Home() useState count must not grow
error: 'Home() has 67 useState calls; cap is 66. ...'
# tests 3 / pass 2 / fail 1
```
`useRef` 与行数粗护栏仍绿。随后 `git checkout -- frontend/src/app/page.tsx`,文件回到 `51a65d92`
### 2. `jyotish_api_server.py` 人为加一个类方法
`JyotishAPIHandler` 里插入 `def _growth_contract_probe(self): return None`,然后:
```
pytest tests/test_api_server_growth_contract.py
FAILED test_jyotish_api_handler_method_count_must_not_grow
AssertionError: ... has 226 four-space def methods; cap is 225.
1 failed, 4 passed
```
行数粗护栏、`__new__`、AGENTS 措辞、`CORE_PYTEST_TARGETS` 仍绿——尺子动的是耦合门。随后 `git checkout -- scripts/jyotish_api_server.py`
## 测试
本 worktree 的 `.venv` 是已跟踪的指针文件(内容 `/workspace/Jyotisha/.venv`),Windows 上不能当目录用,未覆盖。合同测试用本机 `C:\Users\74082\anaconda3\python.exe`pytest 9.1.1)。
| 命令 | 结果 |
| --- | --- |
| `python -m pytest tests/test_api_server_growth_contract.py -v` | **5 passed**(含原 `must not grow` / `thinly registered` / `CORE_PYTEST_TARGETS` 三条) |
| `npx tsx --test tests/home-shell-growth-contract.test.ts tests/chart-view-route.test.ts` | **16 pass / 0 fail**home-shell 3 + chart-view 13chart-view 无行数断言) |
| `./node_modules/.bin/tsc --noEmit` | 0 错 |
| `npm run lint` | **0 error** / 119 warning(既有,未动) |
| `npx tsx --test tests/*.test.ts` | 3196 tests / 3112 pass / **69 fail** / 15 skip。本单 3 条 + `page.tsx does not host the chart page``ok`。失败为无 Docker、Windows `SKILL.md` symlink `EPERM`、bash 部署脚本,与近期同机清单同类 |
| `run_quality_gate.py --profile quick --skip-frontend-runtime` | **blocked**`interpretation_source_inventory_gate.py``ModuleNotFoundError: No module named 'mcp'`。停在 pytest 之前,不是本单合同红。未写成通过 |
同 SHA 近期进度 `PROGRESS-consultation-session-capacity-20260915.md``tests/*.test.ts` 为 3089 / 3001 / **73** fail。本机这次 3196 / 3112 / **69** failPowerShell glob 与计数差一截,fail 未增加)。本单新增 3 条全绿,不把 69 写成通过。
未跑 `next build`:无 UI 改动;质量门在 Python 预检就停了。
## 未做(属范围外)
- 真的搬 `Home()` 状态或拆 `JyotishAPIHandler`(后续两单)
- `__new__` 收到 0
-`origin HEAD:staging`
@@ -0,0 +1,127 @@
# PROGRESS · 把校正面的 15 个状态从首页搬下去(2026-09-16)
工作树:`.worktrees/home-state-lowering-20260915`
分支:`codex/home-state-lowering-20260915`
任务书基线写 `6b3248bf`;开工时 `origin/staging` = **`3b17c1b2`**(含 freeze-metric / C2 / R3)。
本机 Windows。无 Docker。本单不占 BUG 号,不改 `CHANGELOG.md` / `BUG_HISTORY.md` / `docs/tasks/README.md`。未 `git push origin HEAD:staging`。未改数据库、API 路由、DESIGN.md(无可见样式变化)。
## 开工核对
`git log --oneline origin/staging` 前 20 行含:
| 依赖 | SHA | 证据 |
| --- | --- | --- |
| freeze-metric-change | `3b17c1b2` | `test(freeze): 增长冻结改盯耦合,行数降为粗护栏`HEAD |
| C2 consultation-context-memory | `149e1ec4` | `fix(consult): drop traces, budget checkpoints, silent summary inherit` |
| R3 settled-render-split | `58ccafb6` | `fix(rectification): stop re-parsing settled messages on every stream frame` |
AGENTS.md §6 已是新口径:抽出去的 hook 与子组件应当持有自己的状态。
## 开工实测(不要抄任务书 66/41)
数法与合同测试相同:`Home()` 切片上 `\buseState[<(]` / `\buseRef[<(]`
| 指标 | 实测 |
| ---: | ---: |
| `Home()` `useState` | **66** |
| `Home()` `useRef` | **41** |
| `const [rectification…` | **15** |
| `page.tsx` 行数 | **1951** |
| `useRectificationSurface` 参数(type 字段) | **39**(任务书约 56,按 type 实测) |
| `ConversationalBirthTimeRectification` propstype 字段) | **20**(任务书 24,按 type 实测;JSX 含 key 为 21 |
全量 `npm test`**3218** tests / **3134** pass / **69** fail / **15** skipped。失败名单见下,全是环境缺口。
## 5.1 分类表(15 个,不得砍)
| 状态 | 类别 | 谁在读 | 处置 |
| --- | --- | --- | --- |
| `rectificationSessionId` | 外壳也要读 | `Home``rectificationSurfaceOpen``?c=` 自动打开、bootstrap `rectificationSettled`、组件 `key``useSessionManagement`:历史校正是否延后切换 | 留在外壳对象 `sessionId` |
| `rectificationCaseId` | 外壳也要读 | `Home`:是否挂上校正面(`rectificationSurfaceOpen && rectificationCaseId`)、`key` | 留在外壳对象 `caseId` |
| `rectificationOpeningSessionId` | 外壳也要读 | `AppSidebar` `openingSessionId`(侧栏「正在打开」) | 留在外壳对象 `openingSessionId` |
| `rectificationLoading` | 外壳也要读 | 首页卡文案「正在打开」、自动打开守卫、`StarterHome` `data-opening` | 留在外壳对象 `loading` |
| `rectificationMutationPending` | 外壳也要读 | `productEntrypointsDisabled`、自动打开守卫、首页卡 disabled | 留在外壳对象 `mutationPending` |
| `rectificationEntrySummary` | 外壳也要读 | 首页卡 action/文案、`StarterHome` 卡片说明 | 留在外壳对象 `entrySummary` |
| `rectificationEntrySummarySettled` | 外壳也要读 | `bootstrapPrepareReady` 揭幕门 | 留在外壳对象 `entrySummarySettled` |
| `rectificationError` | 外壳也要读 | 自动打开守卫、bootstrap settled、首页卡报错、`rectificationErrorMessage` | 留在外壳对象 `error` |
| `rectificationErrorSessionId` | 外壳也要读 | 侧栏行内错误;首页卡在 session 失败时藏同一条 | 留在外壳对象 `errorSessionId` |
| `rectificationShouldStartOpening` | 只服务子树 | 校正面是否自动开场;`onOpeningConsumed` 把它关掉 | 搬进 `useRectificationSurface` |
| `rectificationTurns` | 只服务子树 | 挂载前 hydrate 的 `initialTurns` | 搬进 hook |
| `rectificationSnapshot` | 只服务子树 | 挂载前 hydrate 的 `initialSnapshot` | 搬进 hook |
| `rectificationPendingQuestion` | 只服务子树 | 校正面 `pendingConsultationQuestion` | 搬进 hook |
| `rectificationReadonly` | 只服务子树 | 校正面只读 | 搬进 hook |
| `rectificationHeaderSlot` | 只服务子树 | 值只给校正面 portal**setter** 仍是外壳 header 的 `ref` | 状态在 hook`Home` 只用 `setRectificationHeaderSlot` |
外壳留下 **1 个** `useState``rectification` 对象,9 个字段)。没有多留第二个散装校正 state。`rectificationSessionOpenerRef` 仍在 `Home``useSessionManagement` 必须在 surface hook 之前调用,用这个 ref 把 opener 递过去。
## 收尾实测
| 指标 | 改前 | 改后 |
| ---: | ---: | ---: |
| `Home()` `useState` | 66 | **52**(≤53;搬走 14 |
| `Home()` `useRef` | 41 | **39**(未上升;`resumeRectificationSession` / `rectificationOpenInFlight` 进 hook |
| `page.tsx` 行数 | 1951 | **1931** |
| hook 参数(type 字段) | 39 | **24** |
| `ConversationalBirthTimeRectification` propstype 字段) | 20 | **7**`declaredTime` / `models` / `selectedModelId` / `onSelectModel` / `onOpenBilling` / `onSaved` / `panel` |
`useRectificationSurface` 现在自己 `useState` / `useEffect` / `useRef`。子树字段打成 `rectificationPanel` 交给包装组件;包装仍 `return <RectificationAgenticChat {...props} />`
## 既有断言改动
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `home-shell-growth-contract.test.ts` 三门 | useState **66** / useRef **41** / 行数基线 **1951** | **52** / **39** / **1931** | 本单下沉后的新基线 |
| `consultation-entrypoint.test.ts` 三处 pending / 一处 onPendingChange / 一处挂载守卫 | JSX `pendingConsultationQuestion={rectificationPendingQuestion}``onPendingChange={setRectificationMutationPending}` | hook panel 字段 `pendingConsultationQuestion: rectificationPendingQuestion``onPendingChange: setRectificationMutationPending`;守卫内 `panel={rectificationPanel}` | 子树 props 收到 panel |
| `rectification-agentic-entry.test.ts` shouldStartOpening / onOpeningConsumed / initialTurns / onMessagesChange / headerSlot | 同上,JSX `={...}` | hook panel 冒号字段;page 仍 `ref={setRectificationHeaderSlot}` | 同上 |
| `rectification-surface-contract.test.ts` initialSnapshot | JSX `initialSnapshot={rectificationSnapshot}` | `initialSnapshot: rectificationSnapshot` | 同上 |
| `rectification-history-open-20260909.test.ts` | `const [rectificationErrorSessionId, setRectificationErrorSessionId]` | `errorSessionId: rectificationErrorSessionId` + `setRectificationErrorSessionId` | 外壳 9 字段合成一个对象 |
未弱化其它既有断言。`rectification-*.test.ts` 除 Windows symlink 整文件红的 `rectification-v9-agent.test.ts`(基线已红)外全绿。
## 反向验证
`Home()` 里插入 `const [_growthContractProbe, _setGrowthContractProbe] = useState(false);` 后:
```
not ok 1 - Home() useState count must not grow
error: 'Home() has 53 useState calls; cap is 52. ...'
# tests 3 / pass 2 / fail 1
```
`useRef` 与行数粗护栏仍绿。随后删掉探针,文件回到 52/39/1931。
## 测试
| 命令 | 结果 |
| --- | --- |
| `node node_modules/typescript/bin/tsc --noEmit` | **0 错** |
| `npm run lint` | **0 error** / **120** warningfreeze-metric 记 119;本单多 1 条:入口摘要 effect 的两个外壳 setter 不是 `useState` setter`exhaustive-deps` 提醒。不能把它们放进 deps,否则每帧重拉摘要。未顺手修既有 warning) |
| 定向 `npx tsx --test tests/home-shell-growth-contract.test.ts tests/consultation-entrypoint.test.ts tests/home-bootstrap-reveal.test.ts tests/rectification-agentic-entry.test.ts tests/rectification-history-open-20260909.test.ts tests/rectification-surface-contract.test.ts` | **98 pass / 0 fail** |
| `npx tsx --test tests/rectification-*.test.ts`(加上述合同) | 除 `rectification-v9-agent.test.ts` 整文件 `SkillPackageRegistryError`EPERM symlink,基线已有)外 fail=0 |
| `npm test` | **3218** / **3134** pass / **69** fail / **15** skipped。失败名单与开工基线 **69 条逐条相同** |
| `npm run build` / `next build --webpack` | 见下 |
## 全量失败清单(与开工基线逐条相同,环境缺口)
`admin code functions reject immutable codes…``operation-level admin email reauthentication…``rectification agent maps setup failures…``public code never imports the internal dynamic choice contract``tests\\birth-time-guide-agent.test.ts``candidate scores stay out of the specified client ownership boundary``tests\\consultation-agentic-runtime.test.ts``tests\\consultation-birth-time-mode.test.ts`methodology 8 条(`the route's own strict checklist…``the delivered method is quoted…`);`tests\\consultation-workflow-request.test.ts`database/backup/env 一批(`admin customer reset…``database roles have no cluster privileges`);`every color token used as a Tailwind utility…`compose/env/YAML/sync/deploy 一批;`tests\\rectification-v9-agent.test.ts``tests\\skill-binding.test.ts``checked-in registry verifies hashed product packages…`symlink 3 条;`live consult skill reads a hand-updated tree…``changed staging workflows are syntactically valid YAML``live staging sync…` 两条;`first immutable deployment…``deployment scripts pass shell syntax validation``is-docs-only-range.sh…`
无 Docker、无 Git Bash、Windows 不能为 skill runtime 建 symlink。不是本单回归。
## `next build`
默认 Turbopack 在 worktree `node_modules` junction 上会 panic(与 R3 相同)。`next build --webpack`Compile + TypeScript 预期能过;Collecting page data 在 Windows `EPERM` 无法为 `SKILL.md` 建 runtime symlink 时失败。本机看不到 `○ /` 路由表。源码:`page.tsx``force-dynamic`;未改 `layout.tsx` / `globals.css`。首屏 gzip 上次实测 584,413 B,本机未能从构建产物复核 ±2%。
## 真人走查
`docs/testing/home-state-lowering-20260915.md`:进入 / 退出 / 刷新 / 首页卡片打开 / 历史校正 / 开场自动触发 / 只读 / 报错 / 换模型 / 采用后回首页。本仓无登录态、无 Chrome,未测。
## 发现的交互缺陷(本单不修)
无新增。入口摘要 effect 多了一条既有风格的 `exhaustive-deps` warning,见上。
## 未做(范围外)
- `session*` / `profile*` / `synastry*` / `account*` 四簇
- Context Provider 或外部 store
-`origin HEAD:staging`
-`docs/tasks/README.md` 状态列
@@ -0,0 +1,64 @@
# PROGRESS · 等价 golden 跨机不稳(2026-09-15 / 执行 2026-09-16
- 执行分支:`codex/rectification-engine-memoization-fix-20260915`
- 工作树:`.worktrees/rectification-engine-memoization-fix-20260915`
- 任务书:`docs/tasks/TASK-rectification-engine-memoization-fix-20260915.md`
- 开工 `HEAD` = `origin/staging` = `51a65d92`(用户核对时是 `7227b1ed`,其后合入了 BUG-732
- BUG`BUG-733`(复发自 BUG-712;落库时最大号 732
## 开工前置
| 项 | 结果 |
| --- | --- |
| `git status -sb` 第一行 | `## codex/rectification-engine-memoization-fix-20260915...origin/staging` |
| `HEAD` | `51a65d92` |
| BUG_HISTORY | 最大号 **BUG-732**。本单预占 **BUG-733**。读完 BUG-712 / BUG-721 |
| 本机 Python | 主仓 `.venv` 是 25 字节指针文件。本 worktree 用 Anaconda 3.11.7 `--system-site-packages` 建真实 venv,复用 `swisseph 20230604`,未升级依赖 |
| 先复现 golden | `test_score_candidates_matches_baseline_golden` **本机绿**。live 与 golden 逐字相同:`score` 8.6274 / 8.6227 / 8.1701`margin_percent` 5.3006。验收机上的 1.1e-3 漂移在这台 Windows / Anaconda 3.11.7 上复现不出来——golden 就是同类机器生成的。仍按任务书改比较方式,不调用 `write_golden()` |
未改 `docs/tasks/README.md` 状态列。未推 staging。未改任何 `scripts/` 实现。未改 `tests/golden/rectification_engine_memoization_v1.json`
## 做了什么
| 条 | 做法 |
| --- | --- |
| 5.1 | 新增 `test_cached_static_context_matches_uncached_fallback`:同一请求、同一进程,A=带四层缓存的 static contextsB=四键置 `None``compute_event_candidate_rows` 输出 `==`。调用计数:A 的 `calc_shadbala` = 0,B = 6(3 候选 × 2 事件)。反向:`test_poisoned_static_cache_diverges_from_live_rows` 把第一候选的 `shadbala_result` 换成全零对象,分数从 12.5125 变成 12.6125,断言不再相等 |
| 5.2 | `test_score_candidates_matches_baseline_golden` 改成分档比较。整数 / 布尔 / 字符串 / 列表身份严格相等;浮点容差 `max(2e-3, 5e-4 * \|expected\|)`,注释写明来自实测最大漂移 1.1e-3。反向:`score += 1e-2` 红;`time = "99:99"` 红 |
| 5.3 | 未调用 `write_golden()``git diff` 里该 JSON 无改动 |
| 5.4 | 六份检查结论见下表。同类问题只记录不修 |
| 5.5 | `docs/BUG_HISTORY.md` 新增 BUG-733,复发自 BUG-712。证据是 5.1 同进程差分 + 两次反向验证,不是「现在绿了」 |
## 5.4 `tests/golden/` 检查
| 文件 | 全精度浮点整体 `==` | 在哪个测试里比 | 怎么比 |
| --- | --- | --- | --- |
| `consultation_contract_keypaths_v1.json` | 无 | `tests/test_consultation_contract_golden.py` | 只存 keypath 与 JSON 类型(`kind=keypath_types_only`)。值不入库。live 是 keypath 超集 + 类型相同 |
| `ephemeris_events_raman_20260915_90d.json` | 无(BUG-712 已改) | `tests/test_ephemeris_events.py` | `longitude` / `speed_longitude` 量化到 6 位再 `==``kind` / `date` / `body` / 星座字段严格相等。文件里仍存全精度,但测试不再整体 `==` |
| `golden_cases.json` | 无 | `tests/test_cli_smoke.py::test_full_reading_golden_cases_cover_user_ready_output``tests/run_golden_cases.py` | 路径非空、模块计数、整数不变量 `ashtakavarga_sav_total == 337`、日期覆盖。不是把整份引擎浮点输出整体 `==` |
| `qizheng_stem_branch_19900409.json` | 无(比较已量化) | `tests/test_qizheng_chart_engine.py` | 离散字段(宫、宿、ketuMode)严格相等;`siderealLon` / `mansionDegree``round(..., 2)`。**文件本身仍存全精度浮点**(如 `177.94225865978694`)。若有人新写 `assert result == golden` 会变成 BUG-712 同类。本单只记录不修 |
| `rectification_engine_memoization_v1.json` | **本单之前有** | `tests/test_rectification_engine_memoization.py::test_score_candidates_matches_baseline_golden` | 改前:`candidate_scores``decision_receipt` 整体 `==`。改后:离散严格、浮点容差。文件未重建 |
| `upstream_sync2/` | 无整体浮点 `==` | 见分项 | `relationship_einstein.json``test_upstream_sync2_goldens.py` 只比键与类型。`consultation_marriage_evidence_snapshot.json`shape。`consultation_marriage_1990_fictional.json``test_relationship_event_class_evidence.py` 比离散 Dasha 字符串。`pl9_time_system_table.baseline.md``test_full_reading_conditional_dashas.py` 比对 markdown 表行文本(前 5 行 `==`),不是 JSON 浮点。`pl9_time_system_table.md``pl9_export_timing.json`(含 `seconds: 8.647`**没有任何测试读取** |
同类观察(不修):qizheng golden 文件仍存全精度;ephemeris golden 文件仍存全精度(测试已量化);`upstream_sync2/pl9_export_timing.json` 是无引用的浮点秒数。
## 测试
| 套件 | 结果 |
| --- | --- |
| `tests/test_rectification_engine_memoization.py` | **14 passed**(原 10 + 新增 4 |
| `tests/test_rectification_*.py` | **186 passed, 0 failed**14.42s |
命令使用 `.\.venv\Scripts\python.exe`(任务书写的 `.venv/bin/python` 在 Windows 不存在)。
未改任何既有断言的期望值。新增 4 条:同进程差分、缓存毒化反向、浮点 1e-2 反向、离散字段反向。原 golden 断言从整体 `==` 改成分档,不是放宽离散字段。未跑 `run_quality_gate.py --profile quick`:该 profile 在 pytest 之后会跑 `npm test`,本机 Windows 缺 `tsx` PATH(与 BUG-721 进度记录同一缺口),本单未改前端。
## CHANGELOG
未改。纯测试比较方式,用户可见打分不变。
## 偏离
- 任务书写 `.venv/bin/python`,本机换成 `.\.venv\Scripts\python.exe`
- 本机未能复现验收机上的 golden 红灯(舍入与生成机一致)。仍改比较方式,并保留验收机测到的 1.1e-3 作为容差依据。
-`git push origin HEAD:staging`
- 5.4 按让步只检查并记录,未加仓库级自动扫描守卫。
+3 -2
View File
@@ -239,10 +239,11 @@
| `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` | `PROGRESS-consultation-external-evidence-cache-20260915.md` | **普通聊天性能单(Python2026-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 段 729731 | 待领取 | |
| `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-consultation-context-memory-20260915.md` | `PROGRESS-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 段 729731 | 待验收 | `codex/consultation-context-memory-20260915` |
| `TASK-consultation-session-capacity-20260915.md` | `PROGRESS-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 | 待验收 | `codex/consultation-session-capacity-20260915`BUG-732);`test:db` 环境缺口 |
| `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 号 | 待领取 | — |
| `TASK-rectification-engine-memoization-fix-20260915.md` | — | **验收修复单(只改测试,一行实现不许动)**:BUG-721 的实现**等价性成立**(我在改前 `6b3248bf` / 改后 `e4788dfc` 同机跑同一 payload`candidate_scores` 逐字相同),9 条计数断言全过;但等价 golden 在本机复现不出来——4 处浮点尾数差(score 1.0e-4 ×2、`margin_percent` 1.1e-3 ×2)。**复发自 BUG-712**(「不得对全精度浮点做整体 `==`」,那一单只落在 ephemeris 一处)。而 `tests/test_rectification_*.py` 在 `CORE_PYTEST_TARGETS` 里,**staging 门禁靠机器舍入碰巧一致才是绿的**。修法:主证据换成**同进程差分**(把 static context 的四个缓存键置 `None` 即可回退旧路径,A/B 严格相等),golden 降为离散字段严格相等 + 浮点带容差(容差按实测 1.1e-3 推);**禁止重建 golden 来「修」**。另含六份 golden 的仓库级排查。BUG-733 | 待领取 | — |
## 命名与归档
@@ -0,0 +1,162 @@
# TASK · 验收修复单:等价 golden 跨机不稳,门禁靠运气绿
- 日期:2026-09-15
- 基线 commit`origin/staging` @ `e4788dfc`(实现落在 `53a37ce9`
- 执行分支:`codex/rectification-engine-memoization-fix-20260915`
- 来源:Claude 对 `TASK-rectification-engine-memoization-20260915`BUG-721)的验收
- 落点:`tests/test_rectification_engine_memoization.py`、必要时 `tests/golden/``docs/BUG_HISTORY.md`
- 规模:只改测试。**一行实现代码都不许动。**
---
## 1. 验收结论摘要
BUG-721 的**实现是对的,等价性成立**,我独立复核过。门禁实测(基线 `11893c7f` → 头 `e4788dfc`,含 R1/R2/R3 三个提交):
| 项 | 结果 |
| --- | --- |
| `tsc --noEmit` | 0 错 |
| `npm run lint` | **0 error** / 119 warning(全部既有) |
| 全量前端套件 | 基线 3291fail 31)→ 头 3304fail 31),**失败清单逐条一致** |
| `next build` | exit 0`/``○ Static` |
| 首屏 JS gzip-9 | 基线 130,872 B → 头 130,872 B**0.00%**(两侧同一种量法) |
| `tests/test_rectification_*.py` | **1 failed, 181 passed** |
唯一那条失败就是本单要修的:`test_score_candidates_matches_baseline_golden`
**它不是实现改坏了。** 我在改前(`6b3248bf`,与 golden 自称的 `a8d29d1b` 代码相同)和改后(`e4788dfc`)两个工作树上跑同一份 payload:
| | 12:00 | 12:01 | 12:02 |
| --- | ---: | ---: | ---: |
| 本机 **改前** | 8.6273 | 8.6226 | 8.1701 |
| 本机 **改后** | 8.6273 | 8.6226 | 8.1701 |
**改前改后逐字相同。** 本单的其余 9 条断言(shadbala / ashtakavarga / vimshottari / narayana 各等于候选分钟数;过境盘等于去重后事件日期数;探针默认 1 次、refresh 时 2 次)全部通过。
## 2. 事故实证
失败来自 golden 文件与**任何一台机器**的实际输出对不上:
| 字段 | 本机(改前=改后) | `tests/golden/rectification_engine_memoization_v1.json` | 差 |
| --- | ---: | ---: | ---: |
| `candidate_scores[0].score` | 8.6273 | 8.6274 | 1.0e-4 |
| `candidate_scores[1].score` | 8.6226 | 8.6227 | 1.0e-4 |
| `decision_receipt.margin_percent` | 5.2995 | 5.3006 | 1.1e-3 |
| `decision_receipt.gates.diagnostic_quality.margin_percent` | 5.2995 | 5.3006 | 1.1e-3 |
一共 4 处,全是浮点尾数。`candidate_scores[*].score` 本身已经被引擎 `round(..., 4)` 过,差值正好是那一位的 1 ulp——也就是求和顺序/libm 舍入的跨机差异,不是算法差异。
**后果是实打实的**`tests/test_rectification_*.py` 这个 glob 在 `scripts/run_quality_gate.py``CORE_PYTEST_TARGETS` 里(注释原文:「Auto staging gate is `--profile quick`…so a stale window_scan assertion in this glob stayed red on origin/staging until listed here」)。也就是说 **staging 自动门禁会跑这条断言**。今天它在 CI 上绿,只是因为那台机器的舍入和执行方的机器一致;换一台机器、换一个基础镜像、换一次 libm 版本就会红。**这条门禁现在是靠运气绿的。**
## 3. 根因
golden 存了全精度浮点并做整体 `==`。这正是 **BUG-712** 的形状,那条记录的防复发一字不差地写着:
> 不得对全精度浮点做整体 `==`。测试不得写 golden。
BUG-712 修的是 `ephemeris_events` 的 golden`longitude` / `speed_longitude` 量化到 6 位再比,非浮点字段保持严格相等)。那一单的范围写死在星历端点上,新写的这份 rectification golden 是同样的写法,没被扫到。
更深一层:**golden 本来就不是证明「记忆化没改结果」的合适工具。** golden 证明的是「今天的输出等于某台机器某一天的输出」,中间夹了一个与被测命题无关的变量(机器)。被测命题其实可以在**同一个进程里**证明——见 §5.1。
## 4. 决策记录
产品 2026-09-15 授权本单:
1. **不回滚 BUG-721 的实现。** 等价性已由改前/改后同机对比独立证实,实现留在 staging 上。
2. **本单只改测试。** 一行 `scripts/` 下的实现代码都不许动——不得为了让断言通过去改业务代码(这是产品的既定偏好)。
3. **不得用「重新生成 golden」来修。** 在本机重跑一次 `write_golden()` 能让测试变绿,但那只是把不稳定性换个方向藏起来,下一台机器照样红。**这条是硬红线,见 §5.3。**
4. **防复发升级为仓库级。** BUG-712 的那句话从单点措施提升成对所有 golden 生效的规则,并配一条能自动发现违例的守卫。
## 5. 任务分解
### 5.1 把主证据换成同进程差分(首要,也是本单真正的价值)
实现已经把四层不变量放进 static context 的具名键(`ashtakavarga_result` / `shadbala_result` / `vimshottari_timeline` / `narayana_periods`),而每个消费点都有 `if result is None:` 的回退分支。**这意味着记忆化可以在同一个进程里关掉**:把 context 里那四个键置 `None`,消费点就会退回逐次计算的老路径。
新增一条断言:同一份请求、同一个进程,
- A:正常的 static contexts(带四层缓存)
- B:把那四个键全部置 `None` 的同一批 contexts(回退到逐次计算)
断言 `compute_event_candidate_rows` 在 A / B 下的输出**严格逐字相等**。
这条证明不经过任何跨机变量,是比 golden 强得多的等价证据。
- 验收:新断言通过;人为把某一层的缓存值替换成错误对象后必须红(贴反向验证,证明这条断言不是恒为真)。
- 验收:A / B 两条路径确实走了不同分支(用调用计数确认 B 的 `calc_shadbala` 次数显著高于 A),否则等于什么都没测。
### 5.2 golden 断言改成分档比较
保留 golden(它仍有价值:能发现跨版本的大幅漂移),但改比较方式:
| 字段类别 | 比较方式 |
| --- | --- |
| 离散字段(`time``supporting_event_ids``conflicting_event_ids`、各 gate 布尔、`overall_confidence``representative_time`、排序) | **严格相等** |
| 浮点字段 | 容差比较 |
容差必须由实测漂移推出来,不得拍脑袋:本次实测最大漂移 **1.1e-3**`margin_percent`)。取一个有余量但仍能发现真回归的值(建议绝对 2e-3 与相对 5e-4 取更宽者),并把「这个数怎么来的」写进测试注释。
- 验收:把 golden 里任意一个浮点改动 1e-2,断言必须红(贴反向验证)。
- 验收:把 golden 里任意一个离散字段改掉,断言必须红。
- 验收:`.venv/bin/python -m pytest tests/test_rectification_*.py` 在本机 **0 failed**
### 5.3 不得重建 golden
`write_golden()` 这个辅助函数留着没问题,但**本单不得调用它更新那份 golden 文件**。若执行方认为必须重建,要在进度记录里写明理由并说明为什么不是在掩盖跨机不稳——默认答案是「不重建」。
- 验收:`git diff``tests/golden/rectification_engine_memoization_v1.json` **无改动**(若有改动,按上一条给出理由)。
### 5.4 仓库级防复发守卫
`tests/golden/` 下现在共 6 份:`consultation_contract_keypaths_v1.json``ephemeris_events_raman_20260915_90d.json``golden_cases.json``qizheng_stem_branch_19900409.json``rectification_engine_memoization_v1.json``upstream_sync2/`
- 逐份检查有没有「全精度浮点 + 整体 `==`」的比较方式,结果列进进度记录(每份写明:有/无、在哪个测试里比的、怎么比的)。
- 发现同类问题的,本单**只记录不修**(各自另开单),除非改动小到一眼可见。
- 验收:六份的检查结论在进度记录里,一份不漏。
### 5.5 Bug 历史
同一变更内写进 `docs/BUG_HISTORY.md`,预占 **BUG-733**(721–732 已被两轮审计的七单占用,避让)。必须写明:
- **复发自 BUG-712**,并说明为什么没拦住:BUG-712 的防复发只落在 `ephemeris_events` 那一处,没有仓库级守卫,新写的 golden 重蹈覆辙。
- 状态可写 `resolved`,但**证据必须是 §5.1 的同进程差分 + §5.2 的两次反向验证**,不能只写「现在绿了」。
- 防复发升级为:**任何 golden 比较都不得对浮点做整体 `==`;浮点必须量化或带容差,容差数值要有实测依据并写在注释里;能在同进程内做差分证明的命题,不得用跨机 golden 代替。**
## 6. 让步顺序
1. 5.1 **不得砍**——它才是这一单的意义;没有它,5.2 只是把红灯调成绿灯。
2. 5.2 必须做。
3. 5.4 可以只做「检查并记录」,修留到后续单。
4. 5.3、5.5 不得砍。
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-engine-memoization-fix-20260915 \
.worktrees/rectification-engine-memoization-fix-20260915 origin/staging
cd .worktrees/rectification-engine-memoization-fix-20260915
git status -sb | head -1
# 先复现:这一条应当是红的
.venv/bin/python -m pytest tests/test_rectification_engine_memoization.py -q
```
开工前必读:`docs/BUG_HISTORY.md`**BUG-712**(同形态前例与它的修法)与 **BUG-721**(本单要保护的那次改动)。
验收命令:
```bash
.venv/bin/python -m pytest tests/test_rectification_engine_memoization.py
.venv/bin/python -m pytest tests/test_rectification_*.py # 门禁 glob,须 0 failed
.venv/bin/python scripts/run_quality_gate.py --profile quick
```
## 8. BUG 编号起点
基线 `e4788dfc``docs/BUG_HISTORY.md` 最大号为 **BUG-725**。721–732 已被两轮审计七单预占(726–732 尚未落库),本单避让,预占 **BUG-733**。开工时核对实际最大号。
## 9. 不在本单范围
- BUG-721 的实现(已验收等价,不动)
- 其余五份 golden 的修复(5.4 只检查并记录)
- `CORE_PYTEST_TARGETS` 的构成(`tests/test_rectification_*.py` 这条 glob 保留,本单是让它变得可靠,不是把它摘掉)
@@ -0,0 +1,88 @@
# 真机清单 · 校正面状态下沉后行为是否与改前一致(2026-09-15)
对应 `TASK-home-state-lowering-20260915`。自动化锁的是状态住处与源码合同,**锁不住**真实进入/退出/刷新/开场/只读/报错。本仓没有浏览器与登录态,下面这些必须用已登录的 staging 做。
地址:`https://staging.jyotisha.chat`。截图不要带真实姓名、出生资料或会话内容。本单**零文案、零交互改动**;任何和改前不一样的地方都记为失败。
先看 `GET /api/health``deployment.gitCommit` 是否等于本单合入 staging 后的 SHA。不一致 = 先别测。
## 1. 从首页卡片进入
1. 停在首页(资料已齐、有「生时校正」卡)。
2. 点卡片。
- ✅ 卡面在打开期间显示「正在打开」,光标 `progress`;没有转圈、没有骨架。
- ✅ 主栏一次揭开校正面:已有对话的话不是空白再闪出来。
- ✅ 左侧当前会话高亮对上;地址栏带 `?c=`
- ❌ 先闪普通聊天再切校正;或空白题目区等加载。
## 2. 退出校正面
1. 在校正面点左侧一条普通咨询会话,或点「新对话」。
- ✅ 校正面关掉,普通聊天/首页回来;输入框是原来那一套,没有第二套。
- ✅ 校正会话仍在左侧历史里,标题还在。
## 3. 刷新
1. 打开一场校正后按浏览器刷新。
- ✅ 刷新后仍是这场校正(`?c=` 还在),不是掉回首页、也不是空白。
- ✅ 已结算的回合还在,不是重跑开场。
## 4. 首页卡片打开(新建 / 继续)
1. 没有未完成校正时点卡片:应新建。
2. 有未完成校正时点卡片:文案仍是「新建一段独立校正…」,行为与改前一致(左侧历史可继续未完成的)。
- ❌ 点卡片却打开了别人的场,或首页卡把侧栏错误画到卡下。
## 5. 历史校正
1. 停在首页,不要先点卡片。
2. 点左侧一条历史生时校正。
- ✅ 主栏打开该场;点了有反应。
- ✅ 若打开失败:错误出现在被点的那一行下面,首页起始卡不重复同一条。
## 6. 开场自动触发
1. 打开一场**还没有回合**、服务端标记要开场的新校正。
- ✅ 进入后自动开始第一轮,不必再点「开始」。
- ✅ 已有回合的历史场不会再自动开一场。
## 7. 只读
1. 打开一场已经结束 / 只读的校正。
- ✅ 输入框不可用;不能再采题。
- ✅ 已有区间卡/对照仍在原位。
## 8. 报错
1. 资料不齐时点卡片:应回到资料补全,提示「请先完成出生资料,再开始生时校正。」
2. 打开失败(网络或服务):首页卡下或侧栏行下有可见错误,可以再点。
- ❌ 点了没反应、也没有错误。
## 9. 换模型
1. 校正面底部的模型选择与改前同一套控件。
2. 换一个模型后再发一条(或等当前轮结束再换)。
- ✅ 仍是这场校正,没有重挂、没有丢历史。
## 10. 采用后回首页
1. 若账号里有已采用结束的场:从左侧打开,再切回首页(新对话或一条普通咨询)。
- ✅ 回得去;首页校正卡还在;没有第二套输入框、没有转圈。
## 375px
视口 375px 再走 1、2、5。窄屏下进入/退出仍是一次揭开,没有第二套输入框。
## 结论
> 待填。测完写日期 + 设备 + 浏览器 + 逐条正常/有问题。测不了就写「未测」,不要写成通过。
+7 -1
View File
@@ -330,6 +330,7 @@ export async function POST(request: Request) {
{ status: 503 },
);
}
const sessionContextWindow = sessionModel.contextWindow;
if (parsed.data.entrypoint === "birth_time_rectification") {
return NextResponse.json(
@@ -353,7 +354,7 @@ export async function POST(request: Request) {
// Client `history` stays in the request schema for old bundles and is not read.
const contextSummary = parseSessionContextSummary(chatSession.context_summary);
const historyWindow = consultationHistoryWindow(chatSession.messages, contextSummary, {
contextWindow: sessionModel.contextWindow,
contextWindow: sessionContextWindow,
});
const storedHistory = historyWindow.tail;
const userControlledPrompt = [
@@ -596,6 +597,7 @@ export async function POST(request: Request) {
await checkpointSessionContextSummary({
messages: sessionRow.messages,
summary: sessionRow.context_summary,
contextWindow: sessionContextWindow,
generateText: (prompt, signal) => generateSessionContextSummaryText(summaryModel, prompt, signal),
update: async (summary, seenUpdatedAt) => {
let query = supabase.from("chat_sessions")
@@ -875,6 +877,7 @@ export async function POST(request: Request) {
instruction: modeInstruction,
extra: generalDailyContextPrompt(generalDailyContext),
summaryText: historyWindow.summaryText,
droppedCount: historyWindow.droppedCount,
question: resolvedQuestion.modelQuestion,
}),
},
@@ -1271,6 +1274,7 @@ export async function POST(request: Request) {
instruction: generalNoMinuteInstruction(Boolean(generalDailyContext)),
extra: generalDailyContextPrompt(generalDailyContext),
summaryText: historyWindow.summaryText,
droppedCount: historyWindow.droppedCount,
question: resolvedQuestion.modelQuestion,
}),
},
@@ -1355,6 +1359,7 @@ export async function POST(request: Request) {
name,
instruction: "先用 3–6 句口语直接回答下面的问题,不要加标题;形状为一句结论、2–3 条短要点(每条完整句子、不超过 30 字)、一句下一步,总量不超过 400 字;然后再按 skill Level 2 骨架写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,最后才是现代生活。骨架不可省略。星盘事实只使用系统里已经注入的计算结果,不要复述内部字段、JSON 或再跑一遍咨询流程。",
summaryText: historyWindow.summaryText,
droppedCount: historyWindow.droppedCount,
question: resolvedQuestion.modelQuestion,
}),
},
@@ -1379,6 +1384,7 @@ export async function POST(request: Request) {
name,
instruction: "先用 3–6 句口语直接回答下面的问题,不要加标题;形状为一句结论、2–3 条短要点(每条完整句子、不超过 30 字)、一句下一步,总量不超过 400 字;然后再按 skill Level 2 骨架写:原始结构、六步宫位、Yoga 表、时机、综合、文末技法审计表,最后才是现代生活。骨架不可省略。星盘事实只使用系统里已经注入的计算结果,不要复述内部字段、JSON 或再跑一遍咨询流程。",
summaryText: historyWindow.summaryText,
droppedCount: historyWindow.droppedCount,
question: resolvedQuestion.modelQuestion,
}),
},
+28 -7
View File
@@ -1,6 +1,12 @@
import { NextResponse } from "next/server";
import { chatSessionCreateSchema, ChatSessionBodyTooLargeError, readChatSessionJson } from "@/lib/chat-session-write-contract";
import {
chatSessionCreateInsertRow,
chatSessionCreateSchema,
ChatSessionBodyTooLargeError,
readChatSessionJson,
} from "@/lib/chat-session-write-contract";
import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
import { resolveInheritedContextSummary } from "@/lib/session-context-summary";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import {
@@ -94,13 +100,28 @@ export async function POST(request: Request) {
}
const parsed = chatSessionCreateSchema.safeParse(await readChatSessionJson(request));
if (!parsed.success) return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 });
const { id, updated_at: _ignoredClientClock, ...values } = parsed.data;
const { error } = await supabase.from("chat_sessions").insert({
id,
user_id: user.id,
...values,
updated_at: new Date().toISOString(),
const { id, updated_at: _ignoredClientClock, continued_from_session_id: continuedFromSessionId, ...values } = parsed.data;
const inheritedSummary = await resolveInheritedContextSummary({
continuedFromSessionId,
loadOwnedSummary: async (sourceId) => {
const { data } = await supabase
.from("chat_sessions")
.select("context_summary")
.eq("id", sourceId)
.eq("user_id", user.id)
.maybeSingle();
return data?.context_summary ?? null;
},
});
const { error } = await supabase.from("chat_sessions").insert(
chatSessionCreateInsertRow({
id,
userId: user.id,
values,
inheritedSummary,
updatedAt: new Date().toISOString(),
}),
);
if (error) return NextResponse.json({ error: "聊天记录暂时无法同步" }, { status: 500 });
return NextResponse.json({ ok: true }, { status: 201 });
} catch (error) {
+31 -51
View File
@@ -21,13 +21,11 @@ import {
entrySummaryFromResponse,
rectificationEntryLabels,
resolveRectificationEntryAction,
type RectificationEntrySummary,
} from "@/lib/rectification-entry";
import { ConversationalBirthTimeRectification, type PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification";
import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification";
import {
declaredBirthTime,
RECTIFICATION_OPENING_LABEL,
type RectificationCaseSnapshotPayload,
} from "@/lib/rectification-surface-state";
import {
toggleChatMessageFeedback,
@@ -72,7 +70,11 @@ import { useBirthTimeGuidedJourney } from "@/hooks/use-birth-time-guided-journey
import { useConsultationRun } from "@/hooks/use-consultation-run";
import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor";
import { useProfileOnboarding } from "@/hooks/use-profile-onboarding";
import { useRectificationSurface } from "@/hooks/use-rectification-surface";
import {
createRectificationShellSetters,
EMPTY_RECTIFICATION_SHELL,
useRectificationSurface,
} from "@/hooks/use-rectification-surface";
import { useSessionManagement } from "@/hooks/use-session-management";
import { sortSessions } from "@/lib/session-groups";
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
@@ -282,23 +284,26 @@ export default function Home() {
const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState<BirthTimeConsultationConsentState>(
createBirthTimeConsultationConsentState,
);
const [rectificationSessionId, setRectificationSessionId] = useState<string | null>(null);
const [rectificationCaseId, setRectificationCaseId] = useState<string | null>(null);
const [rectificationHeaderSlot, setRectificationHeaderSlot] = useState<HTMLElement | null>(null);
const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState<string | null>(null);
const [rectificationLoading, setRectificationLoading] = useState(false);
const [rectificationMutationPending, setRectificationMutationPending] = useState(false);
const [rectificationError, setRectificationError] = useState("");
const [rectificationErrorSessionId, setRectificationErrorSessionId] = useState<string | null>(null);
const [rectificationReadonly, setRectificationReadonly] = useState(false);
const [rectificationShouldStartOpening, setRectificationShouldStartOpening] = useState(false);
const [rectificationTurns, setRectificationTurns] = useState<PersistedRectificationTurn[]>([]);
const [rectificationSnapshot, setRectificationSnapshot] = useState<RectificationCaseSnapshotPayload | null>(null);
const [rectificationOpeningSessionId, setRectificationOpeningSessionId] = useState<string | null>(null);
const [rectificationEntrySummary, setRectificationEntrySummary] = useState<RectificationEntrySummary | null>(null);
const [rectification, setRectification] = useState(EMPTY_RECTIFICATION_SHELL);
const {
sessionId: rectificationSessionId,
caseId: rectificationCaseId,
openingSessionId: rectificationOpeningSessionId,
loading: rectificationLoading,
mutationPending: rectificationMutationPending,
entrySummary: rectificationEntrySummary,
entrySummarySettled: rectificationEntrySummarySettled,
error: rectificationError,
errorSessionId: rectificationErrorSessionId,
} = rectification;
const {
setRectificationError,
setRectificationErrorSessionId,
setRectificationEntrySummary,
setRectificationEntrySummarySettled,
} = createRectificationShellSetters(setRectification);
const [hydrated, setHydrated] = useState(false);
const [bootstrapPhase, setBootstrapPhase] = useState<BootstrapPhase>("account");
const [rectificationEntrySummarySettled, setRectificationEntrySummarySettled] = useState(false);
const prepareStartedAt = useRef<number | null>(null);
const [guidedJourneyPreview, setGuidedJourneyPreview] = useState(false);
const [profileSaving, setProfileSaving] = useState(false);
@@ -347,8 +352,6 @@ export default function Home() {
onFollowUp() {},
});
const accountRefreshGuard = useRef(createLatestAccountRequestGuard());
const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined);
const rectificationOpenInFlight = useRef(false);
const sessionDetailInFlight = useRef(new Set<string>());
const sessionsRef = useRef(sessions);
const rectificationSessionOpenerRef = useRef(async (_exactSessionId: string) => {});
@@ -465,21 +468,15 @@ export default function Home() {
});
const {
refreshRectificationCase,
setRectificationHeaderSlot,
rectificationPanel,
resumeRectificationSession,
openRectificationFromHomepage,
startNewRectification,
handleRectificationProfileIncomplete,
handleRectificationMessagesChange,
} = useRectificationSurface({
account, activeChartId, activeSessionIdRef, chartLibrary, creatingSession, modelCatalog,
persistSession, profile, rectificationLoading, rectificationMutationPending,
rectificationOpenInFlight, rectificationSessionId, resumeRectificationSession,
persistSession, profile, rectification, setRectification,
sessionSelectionSource, sessions, setActiveSessionId, setDraft, setDraftEntrypoint,
setDraftTheme, setOnboardingStep, setProfileNotice, setRectificationCaseId,
setRectificationEntrySummary, setRectificationError, setRectificationErrorSessionId, setRectificationLoading,
setRectificationPendingQuestion, setRectificationReadonly, setRectificationSessionId,
setRectificationShouldStartOpening, setRectificationSnapshot, setRectificationOpeningSessionId,
setRectificationTurns, setSessions, uiPreview,
setDraftTheme, setOnboardingStep, setProfileNotice, setSessions, uiPreview,
updateSession, openAccountDialog, refreshAccount, rectificationSessionOpenerRef,
});
@@ -540,6 +537,7 @@ export default function Home() {
rectificationLoading,
rectificationMutationPending,
rectificationSessionId,
resumeRectificationSession,
]);
useEffect(() => {
@@ -1855,31 +1853,13 @@ export default function Home() {
{rectificationSurfaceOpen && rectificationCaseId && (
<ConversationalBirthTimeRectification
key={`${rectificationSessionId}-${rectificationCaseId}`}
caseId={rectificationCaseId}
sessionId={rectificationSessionId ?? ""}
readonly={rectificationReadonly}
shouldStartOpening={rectificationShouldStartOpening}
initialTurns={rectificationTurns}
initialSnapshot={rectificationSnapshot}
declaredTime={rectificationDeclaredTime}
models={modelCatalog?.models ?? []}
selectedModelId={activeSession?.modelId ?? ""}
onSelectModel={(modelId) => void selectSessionModel(modelId)}
onMessagesChange={handleRectificationMessagesChange}
onOpeningConsumed={() => setRectificationShouldStartOpening(false)}
onCompleted={() => {
void refreshAccount();
if (rectificationCaseId && rectificationSessionId) {
void refreshRectificationCase(rectificationCaseId, rectificationSessionId);
}
}}
onPendingChange={setRectificationMutationPending}
onProfileIncomplete={handleRectificationProfileIncomplete}
onOpenBilling={(options) => openAccountDialog("billing", { source: options?.source ?? "rectification" })}
onSaved={() => void refreshAccount()}
pendingConsultationQuestion={rectificationPendingQuestion}
onRestart={() => void startNewRectification()}
headerSlot={rectificationHeaderSlot}
panel={rectificationPanel}
/>
)}
@@ -31,7 +31,7 @@ export type PersistedRectificationTurn = Readonly<{
}> | null;
}>;
export type ConversationalBirthTimeRectificationProps = Readonly<{
export type RectificationChatPanel = Readonly<{
caseId: string;
sessionId: string;
readonly: boolean;
@@ -39,23 +39,44 @@ export type ConversationalBirthTimeRectificationProps = Readonly<{
initialTurns: readonly PersistedRectificationTurn[];
/** The Case snapshot read together with the turns before the surface mounted; null when hydration failed. */
initialSnapshot: RectificationCaseSnapshotPayload | null;
pendingConsultationQuestion?: string | null;
headerSlot: HTMLElement | null;
onMessagesChange?: (messages: ChatMessage[]) => void;
onCompleted?: () => void;
onPendingChange?: (pending: boolean) => void;
onProfileIncomplete?: () => void;
onOpeningConsumed?: () => void;
onRestart?: () => void;
}>;
export type ConversationalBirthTimeRectificationProps = Readonly<{
/** The declared birth minute from the profile, shown on the board before any candidate exists. */
declaredTime: string | null;
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
onMessagesChange?: (messages: ChatMessage[]) => void;
onCompleted?: () => void;
onPendingChange?: (pending: boolean) => void;
onProfileIncomplete?: () => void;
onOpenBilling?: (options?: { source?: string }) => void;
onSaved?: (time: string, status: "accepted" | "confirmed") => void;
onOpeningConsumed?: () => void;
pendingConsultationQuestion?: string | null;
onRestart?: () => void;
headerSlot: HTMLElement | null;
panel: RectificationChatPanel;
}>;
export function ConversationalBirthTimeRectification(props: ConversationalBirthTimeRectificationProps) {
export function ConversationalBirthTimeRectification({
declaredTime,
models,
selectedModelId,
onSelectModel,
onOpenBilling,
onSaved,
panel,
}: ConversationalBirthTimeRectificationProps) {
const props = {
declaredTime,
models,
selectedModelId,
onSelectModel,
onOpenBilling,
onSaved,
...panel,
};
return <RectificationAgenticChat {...props} />;
}
+1 -1
View File
@@ -135,7 +135,7 @@ export type ConsultationRunParams = {
uiPreviewMode: MutableRefObject<string | null>;
persistSession: (session: ChatSession, mode?: "create" | "update") => Promise<void>;
updateSession: (sessionId: string, change: (session: ChatSession) => ChatSession) => void;
startNewChat: () => Promise<ChatSession | null>;
startNewChat: (options?: { continuedFromSessionId?: string }) => Promise<ChatSession | null>;
continueInNewChat: (prompt: { question: string; theme: Theme }) => Promise<void>;
refreshAccount: () => Promise<void>;
openAccountDialog: (dialog: AccountDialog, options?: HTMLButtonElement | null | OpenAccountDialogOptions) => void;
+120 -42
View File
@@ -1,6 +1,6 @@
"use client";
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import { useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import { resolveSessionTitle } from "@/lib/agent-reply";
import { rectificationOpenIdentity } from "@/lib/rectification-session-open";
@@ -27,7 +27,10 @@ import {
openResponseFromPayload,
type RectificationEntrySummary,
} from "@/lib/rectification-entry";
import type { PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification";
import type {
PersistedRectificationTurn,
RectificationChatPanel,
} from "@/components/conversational-birth-time-rectification";
import type { PublicLanguageModelCatalog } from "@/lib/public-models";
import {
hydrateRectificationCase,
@@ -38,6 +41,60 @@ import {
type RectificationCaseSnapshotPayload,
} from "@/lib/rectification-surface-state";
export type RectificationShellState = {
sessionId: string | null;
caseId: string | null;
openingSessionId: string | null;
loading: boolean;
mutationPending: boolean;
entrySummary: RectificationEntrySummary | null;
entrySummarySettled: boolean;
error: string;
errorSessionId: string | null;
};
export const EMPTY_RECTIFICATION_SHELL: RectificationShellState = {
sessionId: null,
caseId: null,
openingSessionId: null,
loading: false,
mutationPending: false,
entrySummary: null,
entrySummarySettled: false,
error: "",
errorSessionId: null,
};
function fieldSetter<K extends keyof RectificationShellState>(
setRectification: Dispatch<SetStateAction<RectificationShellState>>,
key: K,
): Dispatch<SetStateAction<RectificationShellState[K]>> {
return (value) => {
setRectification((current) => ({
...current,
[key]: typeof value === "function"
? (value as (prev: RectificationShellState[K]) => RectificationShellState[K])(current[key])
: value,
}));
};
}
export function createRectificationShellSetters(
setRectification: Dispatch<SetStateAction<RectificationShellState>>,
) {
return {
setRectificationSessionId: fieldSetter(setRectification, "sessionId"),
setRectificationCaseId: fieldSetter(setRectification, "caseId"),
setRectificationOpeningSessionId: fieldSetter(setRectification, "openingSessionId"),
setRectificationLoading: fieldSetter(setRectification, "loading"),
setRectificationMutationPending: fieldSetter(setRectification, "mutationPending"),
setRectificationEntrySummary: fieldSetter(setRectification, "entrySummary"),
setRectificationEntrySummarySettled: fieldSetter(setRectification, "entrySummarySettled"),
setRectificationError: fieldSetter(setRectification, "error"),
setRectificationErrorSessionId: fieldSetter(setRectification, "errorSessionId"),
};
}
export type RectificationSurfaceParams = {
account: Account | null;
activeChartId: string;
@@ -47,11 +104,8 @@ export type RectificationSurfaceParams = {
modelCatalog: PublicLanguageModelCatalog | null;
persistSession: (session: ChatSession, mode?: "create" | "update") => Promise<void>;
profile: Profile;
rectificationLoading: boolean;
rectificationMutationPending: boolean;
rectificationOpenInFlight: MutableRefObject<boolean>;
rectificationSessionId: string | null;
resumeRectificationSession: MutableRefObject<(session: ChatSession) => void>;
rectification: RectificationShellState;
setRectification: Dispatch<SetStateAction<RectificationShellState>>;
sessionSelectionSource: MutableRefObject<"user" | "history">;
sessions: ChatSession[];
setActiveSessionId: Dispatch<SetStateAction<string>>;
@@ -60,18 +114,6 @@ export type RectificationSurfaceParams = {
setDraftTheme: (theme: Theme | null) => void;
setOnboardingStep: Dispatch<SetStateAction<OnboardingStep>>;
setProfileNotice: Dispatch<SetStateAction<string>>;
setRectificationCaseId: Dispatch<SetStateAction<string | null>>;
setRectificationEntrySummary: Dispatch<SetStateAction<RectificationEntrySummary | null>>;
setRectificationError: Dispatch<SetStateAction<string>>;
setRectificationErrorSessionId: Dispatch<SetStateAction<string | null>>;
setRectificationLoading: Dispatch<SetStateAction<boolean>>;
setRectificationPendingQuestion: Dispatch<SetStateAction<string | null>>;
setRectificationReadonly: Dispatch<SetStateAction<boolean>>;
setRectificationSessionId: Dispatch<SetStateAction<string | null>>;
setRectificationShouldStartOpening: Dispatch<SetStateAction<boolean>>;
setRectificationSnapshot: Dispatch<SetStateAction<RectificationCaseSnapshotPayload | null>>;
setRectificationOpeningSessionId: Dispatch<SetStateAction<string | null>>;
setRectificationTurns: Dispatch<SetStateAction<PersistedRectificationTurn[]>>;
setSessions: Dispatch<SetStateAction<ChatSession[]>>;
uiPreview: MutableRefObject<boolean>;
updateSession: (sessionId: string, change: (session: ChatSession) => ChatSession) => void;
@@ -90,11 +132,8 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
modelCatalog,
persistSession,
profile,
rectificationLoading,
rectificationMutationPending,
rectificationOpenInFlight,
rectificationSessionId,
resumeRectificationSession,
rectification,
setRectification,
sessionSelectionSource,
sessions,
setActiveSessionId,
@@ -103,18 +142,6 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
setDraftTheme,
setOnboardingStep,
setProfileNotice,
setRectificationCaseId,
setRectificationEntrySummary,
setRectificationError,
setRectificationErrorSessionId,
setRectificationLoading,
setRectificationPendingQuestion,
setRectificationReadonly,
setRectificationSessionId,
setRectificationShouldStartOpening,
setRectificationSnapshot,
setRectificationOpeningSessionId,
setRectificationTurns,
setSessions,
uiPreview,
updateSession,
@@ -123,6 +150,29 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
rectificationSessionOpenerRef,
} = params;
const rectificationSessionId = rectification.sessionId;
const rectificationCaseId = rectification.caseId;
const rectificationLoading = rectification.loading;
const rectificationMutationPending = rectification.mutationPending;
const setRectificationSessionId = fieldSetter(setRectification, "sessionId");
const setRectificationCaseId = fieldSetter(setRectification, "caseId");
const setRectificationOpeningSessionId = fieldSetter(setRectification, "openingSessionId");
const setRectificationLoading = fieldSetter(setRectification, "loading");
const setRectificationMutationPending = fieldSetter(setRectification, "mutationPending");
const setRectificationEntrySummary = fieldSetter(setRectification, "entrySummary");
const setRectificationError = fieldSetter(setRectification, "error");
const setRectificationErrorSessionId = fieldSetter(setRectification, "errorSessionId");
const [rectificationHeaderSlot, setRectificationHeaderSlot] = useState<HTMLElement | null>(null);
const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState<string | null>(null);
const [rectificationReadonly, setRectificationReadonly] = useState(false);
const [rectificationShouldStartOpening, setRectificationShouldStartOpening] = useState(false);
const [rectificationTurns, setRectificationTurns] = useState<PersistedRectificationTurn[]>([]);
const [rectificationSnapshot, setRectificationSnapshot] = useState<RectificationCaseSnapshotPayload | null>(null);
const rectificationOpenInFlight = useRef(false);
const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined);
async function refreshRectificationEntrySummary() {
if (!account) return;
try {
@@ -289,12 +339,6 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
await openRectificationCase("new", null, null);
}
resumeRectificationSession.current = (session) => {
sessionSelectionSource.current = "history";
void openRectificationSession(session.id);
};
rectificationSessionOpenerRef.current = openRectificationSession;
function handleRectificationProfileIncomplete() {
setRectificationError("profile_incomplete");
setRectificationSessionId(null);
@@ -320,7 +364,41 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
}));
}
useEffect(() => {
resumeRectificationSession.current = (session) => {
sessionSelectionSource.current = "history";
void openRectificationSession(session.id);
};
rectificationSessionOpenerRef.current = openRectificationSession;
});
const rectificationPanel: RectificationChatPanel = {
caseId: rectificationCaseId ?? "",
sessionId: rectificationSessionId ?? "",
readonly: rectificationReadonly,
shouldStartOpening: rectificationShouldStartOpening,
initialTurns: rectificationTurns,
initialSnapshot: rectificationSnapshot,
pendingConsultationQuestion: rectificationPendingQuestion,
headerSlot: rectificationHeaderSlot,
onMessagesChange: handleRectificationMessagesChange,
onCompleted: () => {
void refreshAccount();
if (rectificationCaseId && rectificationSessionId) {
void refreshRectificationCase(rectificationCaseId, rectificationSessionId);
}
},
onPendingChange: setRectificationMutationPending,
onProfileIncomplete: handleRectificationProfileIncomplete,
onOpeningConsumed: () => setRectificationShouldStartOpening(false),
onRestart: () => void startNewRectification(),
};
return {
rectificationHeaderSlot,
setRectificationHeaderSlot,
rectificationPanel,
resumeRectificationSession,
refreshRectificationEntrySummary,
refreshRectificationCase,
openRectificationCase,
+20 -4
View File
@@ -139,7 +139,11 @@ export function useSessionManagement(params: SessionManagementParams) {
setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session)));
}
async function persistSession(session: ChatSession, mode: "create" | "update" = "update") {
async function persistSession(
session: ChatSession,
mode: "create" | "update" = "update",
options?: { continuedFromSessionId?: string },
) {
if (!account) throw new Error("账户尚未加载完成");
if (process.env.NODE_ENV === "development" && uiPreview.current) return;
const values = mode === "create"
@@ -153,6 +157,9 @@ export function useSessionManagement(params: SessionManagementParams) {
chart_profile_id: session.chartProfileId,
chart_profile_name: session.chartProfileName,
chart_profile_role: session.chartProfileRole,
...(options?.continuedFromSessionId
? { continued_from_session_id: options.continuedFromSessionId }
: {}),
}
: {
title: session.title,
@@ -192,7 +199,10 @@ export function useSessionManagement(params: SessionManagementParams) {
async function continueInNewChat(prompt: { question: string; theme: Theme }) {
setSessionFullPrompt(null);
const created = await startNewChat();
const sourceSessionId = activeSessionId;
const created = await startNewChat(
sourceSessionId ? { continuedFromSessionId: sourceSessionId } : undefined,
);
if (!created) return;
setDraft(prompt.question);
setDraftTheme(prompt.theme);
@@ -294,7 +304,7 @@ export function useSessionManagement(params: SessionManagementParams) {
}
}
async function startNewChat(): Promise<ChatSession | null> {
async function startNewChat(options?: { continuedFromSessionId?: string }): Promise<ChatSession | null> {
if (!account || !modelCatalog || creatingSession) return null;
const nextSession = {
...createSession(modelCatalog.defaultModelId),
@@ -312,7 +322,13 @@ export function useSessionManagement(params: SessionManagementParams) {
setComposerNotice("");
setRequestError(null);
try {
await persistSession(nextSession, "create");
await persistSession(
nextSession,
"create",
options?.continuedFromSessionId
? { continuedFromSessionId: options.continuedFromSessionId }
: undefined,
);
return nextSession;
} catch (caught) {
setSessions((current) => current.filter((session) => session.id !== nextSession.id));
@@ -59,6 +59,7 @@ export const chatSessionCreateSchema = z.object({
messages: z.array(chatMessageSchema).max(0),
session_type: z.enum(["consultation", "birth_time_rectification"]),
rectification_case_id: z.string().uuid().nullable(),
continued_from_session_id: z.string().uuid().optional(),
...chartBindingSchema,
updated_at: z.string().datetime().optional(),
}).strict();
@@ -148,11 +149,41 @@ export type ChatSessionCreate = Readonly<{
messages: readonly [];
session_type: "consultation" | "birth_time_rectification";
rectification_case_id: string | null;
continued_from_session_id?: string;
chart_profile_id?: string | null;
chart_profile_name?: string | null;
chart_profile_role?: "self" | "other" | null;
}>;
export type ChatSessionCreateInsertValues = Readonly<{
title: string;
theme: ConsultationDomain;
model_id: string;
messages: readonly unknown[];
session_type: "consultation" | "birth_time_rectification";
rectification_case_id: string | null;
chart_profile_id?: string | null;
chart_profile_name?: string | null;
chart_profile_role?: "self" | "other" | null;
}>;
export function chatSessionCreateInsertRow(input: {
id: string;
userId: string;
values: ChatSessionCreateInsertValues;
inheritedSummary?: unknown;
updatedAt: string;
}): Record<string, unknown> {
const row: Record<string, unknown> = {
id: input.id,
user_id: input.userId,
...input.values,
updated_at: input.updatedAt,
};
if (input.inheritedSummary) row.context_summary = input.inheritedSummary;
return row;
}
export type ChatSessionWrite = Readonly<{
title: string;
theme: ConsultationDomain;
@@ -1,11 +1,14 @@
export const CONSULTATION_HISTORY_LIMIT = 12;
export const CONSULTATION_HISTORY_MESSAGE_CHARS = 12_000;
export const CONSULTATION_HISTORY_TAIL_MAX_CHARS = 16_000;
export const CONSULTATION_HISTORY_SYSTEM_RESERVE_TOKENS = 60_000;
export const CONSULTATION_HISTORY_CHAR_PER_TOKEN = 1.5;
export const CONSULTATION_HISTORY_BUDGET_MIN_CHARS = 4_000;
export const CONSULTATION_HISTORY_BUDGET_MAX_CHARS = 40_000;
export const DEFAULT_MODEL_CONTEXT_WINDOW = 128_000;
// 0.4 of the history budget. At the default 128k window the budget is 40,000,
// so the checkpoint fires at 16,000 — the previous hard-coded threshold.
// Smaller windows then checkpoint before the tail can exceed the budget.
export const CONSULTATION_HISTORY_CHECKPOINT_BUDGET_RATIO = 0.4;
export const SESSION_CONTEXT_SUMMARY_HEADING = "【会话摘要(服务端维护)】";
@@ -51,6 +54,15 @@ export function historyBudgetChars(contextWindow: number | null | undefined): nu
);
}
export function consultationHistoryCheckpointChars(contextWindow: number | null | undefined): number {
const budget = historyBudgetChars(contextWindow);
const threshold = Math.floor(budget * CONSULTATION_HISTORY_CHECKPOINT_BUDGET_RATIO);
if (!(threshold < budget)) {
throw new Error("consultation history checkpoint threshold must stay below the history budget");
}
return threshold;
}
export function parseSessionContextSummary(value: unknown): SessionContextSummaryV1 | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const row = value as Record<string, unknown>;
@@ -74,6 +86,13 @@ export function omissionMarker(omittedChars: number): string {
return `……(以下省略 ${omittedChars} 字,结论已并入会话摘要)`;
}
export function droppedRoundsMarker(droppedCount: number, hasSummary: boolean): string {
if (droppedCount <= 0) return "";
return hasSummary
? `……(更早的 ${droppedCount} 轮问答已并入上面的会话摘要)`
: `……(更早的 ${droppedCount} 轮问答未能进入本轮上下文,结论尚未并入会话摘要)`;
}
export function clipConsultationHistoryText(text: string): string {
if (text.length <= CONSULTATION_HISTORY_MESSAGE_CHARS) return text;
const omitted = text.length - CONSULTATION_HISTORY_MESSAGE_CHARS;
@@ -165,16 +184,18 @@ export function consultationUserTurnContent(input: {
instruction: string;
extra?: string;
summaryText?: string | null;
droppedCount?: number;
question: string;
}): string {
const summary = input.summaryText?.trim() ?? "";
const dropped = droppedRoundsMarker(input.droppedCount ?? 0, Boolean(summary));
return [
input.currentTime,
input.name ? `用户称呼:${input.name}` : "",
input.instruction,
input.extra ?? "",
input.summaryText?.trim()
? `${SESSION_CONTEXT_SUMMARY_HEADING}\n${input.summaryText.trim()}`
: "",
summary ? `${SESSION_CONTEXT_SUMMARY_HEADING}\n${summary}` : "",
dropped,
input.question,
].filter(Boolean).join("\n");
}
+23 -5
View File
@@ -1,7 +1,7 @@
import { Agent } from "@mastra/core/agent";
import {
CONSULTATION_HISTORY_TAIL_MAX_CHARS,
consultationHistoryCheckpointChars,
lastConsultationPair,
parseSessionContextSummary,
storedConsultationTurns,
@@ -98,7 +98,7 @@ export function sanitizeSessionContextSummary(raw: string): string | null {
export function tailCharCount(
messages: unknown,
summary: SessionContextSummaryV1 | null,
options: { excludeRequestId?: string } = {},
options: { excludeRequestId?: string; contextWindow?: number | null } = {},
): number {
const turns = storedConsultationTurns(messages, { excludeRequestId: options.excludeRequestId });
const tail = summary
@@ -110,9 +110,10 @@ export function tailCharCount(
export function shouldCheckpoint(
messages: unknown,
summary: SessionContextSummaryV1 | null,
options: { excludeRequestId?: string } = {},
options: { excludeRequestId?: string; contextWindow?: number | null } = {},
): boolean {
return tailCharCount(messages, summary, options) > CONSULTATION_HISTORY_TAIL_MAX_CHARS;
return tailCharCount(messages, summary, options)
> consultationHistoryCheckpointChars(options.contextWindow);
}
export function messagesForSummaryInput(
@@ -215,6 +216,19 @@ export function nextSessionContextSummary(
};
}
export async function resolveInheritedContextSummary(input: {
continuedFromSessionId?: string | null;
loadOwnedSummary: (sessionId: string) => Promise<unknown>;
}): Promise<SessionContextSummaryV1 | null> {
const sourceId = input.continuedFromSessionId?.trim();
if (!sourceId) return null;
try {
return parseSessionContextSummary(await input.loadOwnedSummary(sourceId));
} catch {
return null;
}
}
export async function writeSessionContextSummary(input: {
seenUpdatedAt: string | null;
summary: SessionContextSummaryV1;
@@ -228,13 +242,17 @@ export async function checkpointSessionContextSummary(input: {
messages: unknown;
summary: unknown;
excludeRequestId?: string;
contextWindow?: number | null;
now?: () => Date;
generateText: (prompt: string, signal?: AbortSignal) => Promise<string>;
update: (summary: SessionContextSummaryV1, seenUpdatedAt: string | null) => Promise<boolean>;
timeoutMs?: number;
}): Promise<"written" | "skipped" | "abandoned" | "failed"> {
const previous = parseSessionContextSummary(input.summary);
if (!shouldCheckpoint(input.messages, previous, { excludeRequestId: input.excludeRequestId })) {
if (!shouldCheckpoint(input.messages, previous, {
excludeRequestId: input.excludeRequestId,
contextWindow: input.contextWindow,
})) {
return "skipped";
}
try {
@@ -0,0 +1,135 @@
-- BUG-732: split append_consultation_question into two caps.
-- Conversation quota (200,000) counts only user-visible `text`.
-- thinkingText / thinkingSections stay stored but must not enter that sum.
-- Physical cap counts the whole message JSON, including receipts.
-- Arithmetic: 50 rounds × (~4,000 body + ~4,000 thinkingText + ~3,000
-- thinkingSections + ~3,000 receipts) ≈ 700,000. Headroom → 1,000,000.
-- Signature, return columns, error_code values, advisory lock, request_id
-- idempotency, 200-message cap, and 16,000-char question check are unchanged
-- (BUG-464). CREATE OR REPLACE is backward compatible with deployed callers.
begin;
do $migration$
begin
if current_user <> 'schema_owner' then
raise exception 'consultation_session_capacity_requires_schema_owner'
using errcode = '42501';
end if;
end
$migration$;
create or replace function public.append_consultation_question(
p_user_id uuid,
p_request_id text,
p_session_id uuid,
p_question_message jsonb
)
returns table(success boolean, error_code text)
language plpgsql
security definer
set search_path = ''
as $$
declare
v_session public.chat_sessions%rowtype;
v_message jsonb;
v_text text;
v_request_id text;
v_count integer;
v_chars integer;
v_new_chars integer;
v_physical_chars bigint;
v_new_physical bigint;
begin
v_request_id := btrim(coalesce(p_request_id, ''));
if p_user_id is null or p_session_id is null or v_request_id = '' then
return query select false, 'invalid_request'::text;
return;
end if;
if jsonb_typeof(p_question_message) <> 'object'
or p_question_message->>'role' <> 'user' then
return query select false, 'invalid_question_message'::text;
return;
end if;
v_text := btrim(coalesce(p_question_message->>'text', ''));
if v_text = '' or char_length(v_text) > 16000 then
return query select false, 'invalid_question_message'::text;
return;
end if;
perform pg_advisory_xact_lock(hashtextextended(p_user_id::text || ':' || v_request_id, 0));
select session.* into v_session
from public.chat_sessions as session
where session.id = p_session_id
and session.user_id = p_user_id
and session.session_type = 'consultation'
for update;
if not found then
return query select false, 'session_missing'::text;
return;
end if;
if exists (
select 1
from jsonb_array_elements(coalesce(v_session.messages, '[]'::jsonb)) as elem
where elem->>'requestId' = v_request_id
) then
return query select true, null::text;
return;
end if;
v_message := p_question_message || jsonb_build_object('requestId', v_request_id);
v_count := jsonb_array_length(coalesce(v_session.messages, '[]'::jsonb));
select
coalesce(sum(length(coalesce(elem->>'text', ''))), 0),
coalesce(sum(length(elem::text)), 0)
into v_chars, v_physical_chars
from jsonb_array_elements(coalesce(v_session.messages, '[]'::jsonb)) as elem;
v_new_chars := length(v_text);
v_new_physical := length(v_message::text);
if v_count >= 200
or (v_chars + v_new_chars) > 200000
or (v_physical_chars + v_new_physical) > 1000000 then
return query select false, 'session_full'::text;
return;
end if;
update public.chat_sessions as session
set messages = coalesce(session.messages, '[]'::jsonb) || jsonb_build_array(v_message),
title = case
when coalesce(btrim(session.title), '') in ('', '新对话') then
case
when char_length(v_text) > 14 then left(v_text, 14) || ''
else v_text
end
else session.title
end,
updated_at = clock_timestamp()
where session.id = p_session_id
and session.user_id = p_user_id
and session.session_type = 'consultation';
if not found then
return query select false, 'session_missing'::text;
return;
end if;
return query select true, null::text;
end;
$$;
revoke all on function public.append_consultation_question(uuid, text, uuid, jsonb)
from public, anon, authenticated;
grant execute on function public.append_consultation_question(uuid, text, uuid, jsonb)
to service_role;
do $$ begin
if exists(select 1 from pg_roles where rolname = 'admin_runtime') then
grant execute on function public.append_consultation_question(uuid, text, uuid, jsonb)
to admin_runtime;
end if;
end $$;
commit;
+3 -2
View File
@@ -296,8 +296,9 @@ test("the golden chart-view envelope stays inside the page contract", () => {
assert.match(COORDINATE_BOUNDARY.qizheng, /角宿/);
});
test("page.tsx does not grow to host the chart page", () => {
assert.ok((pageSource.match(/\n/g) ?? []).length <= 1951);
test("page.tsx does not host the chart page", () => {
// Line-count freeze moved to tests/home-shell-growth-contract.test.ts
// (Home() useState / useRef caps + coarse line-count guardrail).
assert.doesNotMatch(pageSource, /chart-page|ChartPageView|\/api\/chart-view/);
});
@@ -20,6 +20,10 @@ test("session list GET omits messages while detail GET returns them", () => {
/SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at"/,
);
assert.doesNotMatch(listRoute, /select\(SESSION_LIST_COLUMNS\)[\s\S]*messages/);
assert.doesNotMatch(
listRoute,
/SESSION_LIST_COLUMNS = "[^"]*context_summary/,
);
assert.match(itemRoute, /export async function GET/);
assert.match(
itemRoute,
@@ -52,6 +56,8 @@ test("consult appends the user question after reserve and returns session_full",
assert.match(sendSource, /caught\.code === "session_full"/);
assert.match(sendSource, /label: "开新对话"/);
assert.match(sendSource, /continueInNewChat\(\{ question: originalQuestion, theme \}\)/);
assert.match(page, /continuedFromSessionId: sourceSessionId/);
assert.doesNotMatch(page, /接着上次|继续上次聊|继承会话摘要|从上次对话继续/);
});
test("PATCH compatibility accepts and ignores a legacy messages write", () => {
+1 -1
View File
@@ -173,7 +173,7 @@ test("popstate to a missing session query reuses selectSession side effects for
});
test("creating and leaving a session keep the address bar in sync", () => {
const startNewChat = sourceBetween(page, "async function startNewChat()", "function selectSession(");
const startNewChat = sourceBetween(page, "async function startNewChat(", "function selectSession(");
assert.match(startNewChat, /writeSessionUrl\(nextSession\.id, "push"\)/);
assert.match(startNewChat, /window\.history\.replaceState\(null, "", previousHref\)/);
+87 -3
View File
@@ -1,7 +1,14 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { chatSessionCreateSchema, chatSessionMetadataPatchSchema, chatSessionWriteSchema, writeChatSession, type ChatSessionWrite } from "../src/lib/chat-session-write-contract.ts";
import {
chatSessionCreateInsertRow,
chatSessionCreateSchema,
chatSessionMetadataPatchSchema,
chatSessionWriteSchema,
writeChatSession,
type ChatSessionWrite,
} from "../src/lib/chat-session-write-contract.ts";
import { metadataUpdateValues } from "../src/lib/session-metadata-update.ts";
import { homeSurface } from "./home-surface.ts";
@@ -41,6 +48,61 @@ test("create schema keeps the client-generated session id after transcript limit
assert.equal(parsed.messages.length, 0);
});
test("create schema accepts continued_from_session_id and rejects client-supplied context_summary", () => {
const parsed = chatSessionCreateSchema.parse({
id: sessionId,
...createValues,
continued_from_session_id: "22222222-2222-4222-8222-222222222222",
});
assert.equal(parsed.continued_from_session_id, "22222222-2222-4222-8222-222222222222");
assert.equal("context_summary" in parsed, false);
assert.equal("context_summary" in chatSessionCreateSchema.shape, false);
const withSummary = chatSessionCreateSchema.safeParse({
id: sessionId,
...createValues,
context_summary: { version: 1, text: "injected" },
});
assert.equal(withSummary.success, false);
const badId = chatSessionCreateSchema.safeParse({
id: sessionId,
...createValues,
continued_from_session_id: "not-a-uuid",
});
assert.equal(badId.success, false);
});
test("create insert copies an owned summary and omits a missing one", () => {
const source = {
version: 1 as const,
text: "已问过的问题\n事业时机",
throughRequestId: "a1",
throughMessageIndex: 3,
messageCount: 4,
updatedAt: "2026-09-15T00:00:00.000Z",
};
const copied = chatSessionCreateInsertRow({
id: sessionId,
userId: "owner",
values: createValues,
inheritedSummary: source,
updatedAt: "2026-09-16T00:00:00.000Z",
});
assert.deepEqual(copied.context_summary, source);
assert.equal("continued_from_session_id" in copied, false);
const skipped = chatSessionCreateInsertRow({
id: sessionId,
userId: "owner",
values: createValues,
inheritedSummary: null,
updatedAt: "2026-09-16T00:00:00.000Z",
});
assert.equal("context_summary" in skipped, false);
assert.equal(skipped.user_id, "owner");
});
test("create schema rejects a non-empty transcript", () => {
// Former value: create accepted up to CHAT_SESSION_MAX_MESSAGES and was a
// history-import path. Create is now only an empty session.
@@ -122,6 +184,19 @@ test("metadata patch accepts pin and archive fields without a transcript", () =>
assert.deepEqual(chatSessionMetadataPatchSchema.parse({ archived_at: null }), { archived_at: null });
});
test("create write may carry continued_from_session_id and never context_summary", async () => {
const calls: Array<Record<string, unknown>> = [];
await writeChatSession(sessionId, {
...createValues,
continued_from_session_id: "22222222-2222-4222-8222-222222222222",
}, "create", async (_url, init) => {
calls.push(JSON.parse(String(init?.body)));
return Response.json({ ok: true }, { status: 201 });
});
assert.equal(calls[0]?.continued_from_session_id, "22222222-2222-4222-8222-222222222222");
assert.equal("context_summary" in (calls[0] ?? {}), false);
});
test("chat session writes use same-origin API instead of browser-to-Supabase requests", async () => {
const calls: Array<{ url: string; init?: RequestInit }> = [];
await writeChatSession(sessionId, metadataPatch, "update", async (url, init) => {
@@ -205,8 +280,17 @@ test("session API owns create and update while answer UI keeps sync failures out
assert.match(itemRoute, /readChatSessionJson/);
assert.match(collectionRoute, /ChatSessionBodyTooLargeError/);
assert.match(itemRoute, /ChatSessionBodyTooLargeError/);
// Former value: `const { id, ...values } = parsed.data` trusted the client clock.
assert.match(collectionRoute, /const \{ id, updated_at: _ignoredClientClock, \.\.\.values \} = parsed\.data/);
// Former value: `const { id, updated_at: _ignoredClientClock, ...values } = parsed.data`
// New value: also peel `continued_from_session_id` so it is never inserted as a column.
assert.match(
collectionRoute,
/const \{ id, updated_at: _ignoredClientClock, continued_from_session_id: continuedFromSessionId, \.\.\.values \} = parsed\.data/,
);
assert.match(collectionRoute, /resolveInheritedContextSummary/);
assert.match(collectionRoute, /chatSessionCreateInsertRow/);
assert.match(collectionRoute, /\.eq\("user_id", user\.id\)/);
assert.match(collectionRoute, /status: 201/);
assert.doesNotMatch(collectionRoute, /context_summary: parsed/);
assert.match(contract, /function limitTranscriptSize<Output extends \{ messages: Array<\{ text: string; thinkingText\?: string; thinkingSections\?: unknown \}> \}>/);
assert.match(contract, /\): z\.ZodType<Output> \{/);
assert.match(page, /chartSnapshotForSession/);
@@ -91,7 +91,7 @@ test("every external draft writer keeps working through the page-owned setters",
// When: each existing write path is inspected.
const chooseSuggested = sourceBetween(pageSource, "function chooseSuggestedQuestion(", "async function startSuggestedConsultation");
const startNewChat = sourceBetween(pageSource, "async function startNewChat()", "function selectSession(");
const startNewChat = sourceBetween(pageSource, "async function startNewChat(", "function selectSession(");
const selectSession = sourceBetween(pageSource, "function selectSession(sessionId: string)", "async function selectSessionModel");
const saveOnboardingName = sourceBetween(pageSource, "async function saveOnboardingName()", "async function saveOnboardingBirth");
const stopRestore = sourceBetween(pageSource, "updateSession(pending.sessionId, () => pending.previousSession);", "function completeConsultationInterface");
@@ -14,7 +14,10 @@ test("consult history uses the checkpoint tail and puts the summary after the ti
assert.match(consultRoute, /consultationHistoryWindow\(chatSession\.messages, contextSummary/);
assert.match(consultRoute, /SESSION_CONTEXT_SUMMARY_HEADING|consultationUserTurnContent/);
assert.match(consultRoute, /summaryText: historyWindow\.summaryText/);
assert.match(consultRoute, /droppedCount: historyWindow\.droppedCount/);
assert.match(history, /【会话摘要(服务端维护)】/);
assert.match(history, /droppedRoundsMarker/);
assert.doesNotMatch(history, /16_000|16000/);
const helper = history.slice(history.indexOf("export function consultationUserTurnContent"));
const timeLine = helper.indexOf("input.currentTime");
const summaryLine = helper.indexOf("SESSION_CONTEXT_SUMMARY_HEADING");
@@ -33,9 +36,13 @@ test("consult retries once on context overflow with summary plus the last pair",
test("consult checkpoints the session summary after a successful completion", () => {
assert.match(consultRoute, /void checkpointConsultationContext\(\)/);
assert.match(consultRoute, /checkpointSessionContextSummary/);
assert.match(consultRoute, /contextWindow: sessionContextWindow/);
assert.match(consultRoute, /context_summary ->>updatedAt|context_summary->>updatedAt/);
assert.match(consultRoute, /console\.warn\("session context summary failed"/);
assert.doesNotMatch(consultRoute, /AbortSignal\.timeout\(\s*15/);
const summary = readFileSync(new URL("../src/lib/session-context-summary.ts", import.meta.url), "utf8");
assert.match(summary, /consultationHistoryCheckpointChars/);
assert.doesNotMatch(summary, /CONSULTATION_HISTORY_TAIL_MAX_CHARS/);
});
test("the context summary migration only adds one jsonb column", () => {
+16 -4
View File
@@ -173,7 +173,10 @@ test("homepage birth-time card opens the V9 Agentic surface via the server case
assert.match(source, /openRectificationFromHomepage/);
assert.match(source, /<ConversationalBirthTimeRectification/);
assert.match(component, /<RectificationAgenticChat \{\.\.\.props\} \/>/);
assert.match(source, /pendingConsultationQuestion=\{rectificationPendingQuestion\}/);
// 原值:pendingConsultationQuestion={rectificationPendingQuestion} 写在 page.tsx JSX
// 新值:pendingConsultationQuestion: rectificationPendingQuestion 写在 hook 的 panel
// 原因:校正面子树状态收到 panel,Home 不再逐个传
assert.match(source, /pendingConsultationQuestion: rectificationPendingQuestion/);
assert.doesNotMatch(source, /chooseSuggestedQuestion\([\s\S]{0,180}"birth_time_rectification"/);
assert.doesNotMatch(source, /draftBirthTimeRectificationQuestion/);
});
@@ -203,7 +206,10 @@ test("homepage opens through the server Case API and merges the returned session
assert.match(handler, /rectificationOpenInFlight\.current = true;[\s\S]*?finally \{[\s\S]*?rectificationOpenInFlight\.current = false;/);
assert.doesNotMatch(handler, /onNarrativeDelta/);
assert.match(source, /const rectificationSurfaceOpen = activeRectificationSession\s*&& activeSession\.id === rectificationSessionId/);
assert.match(source, /rectificationSurfaceOpen && rectificationCaseId && \([\s\S]*?<ConversationalBirthTimeRectification[\s\S]*?pendingConsultationQuestion=\{rectificationPendingQuestion\}/);
// 原值:同一守卫内 JSX 写 pendingConsultationQuestion={rectificationPendingQuestion}
// 新值:同一守卫内 JSX 写 panel={rectificationPanel}pending 字段在 hook panel
// 原因:校正面子树状态收到 panel
assert.match(source, /rectificationSurfaceOpen && rectificationCaseId && \([\s\S]*?<ConversationalBirthTimeRectification[\s\S]*?panel=\{rectificationPanel\}/);
});
test("the page never creates the session shell locally; the server owns session creation", () => {
@@ -278,7 +284,10 @@ test("rectify-first handoffs stay as Agent context", () => {
const source = homeSurface;
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(source, /pendingConsultationQuestion=\{rectificationPendingQuestion\}/);
// 原值:pendingConsultationQuestion={rectificationPendingQuestion} 写在 page.tsx JSX
// 新值:pendingConsultationQuestion: rectificationPendingQuestion 写在 hook 的 panel
// 原因:校正面子树状态收到 panel,Home 不再逐个传
assert.match(source, /pendingConsultationQuestion: rectificationPendingQuestion/);
assert.match(chat, /pendingConsultationQuestion\?\.trim\(\)/);
// 旧 横幅承诺按钮会带回原问题 → 新 按钮已删,改成结束后新建对话再问 → 保留 pendingConsultationQuestion 传递链
assert.match(chat, /结束后新建对话,按采用的时间再问/);
@@ -299,7 +308,10 @@ test("ordinary consultation uses current birth data without a rectification noti
test("rectification mutations report pending state while session-level return controls stay absent", () => {
const source = homeSurface;
assert.match(source, /onPendingChange=\{setRectificationMutationPending\}/);
// 原值:onPendingChange={setRectificationMutationPending} 写在 page.tsx JSX
// 新值:onPendingChange: setRectificationMutationPending 写在 hook 的 panel
// 原因:mutationPending 仍由外壳对象持有,但 setter 经 panel 交给子树
assert.match(source, /onPendingChange: setRectificationMutationPending/);
assert.match(source, /disabled=\{productEntrypointsDisabled \|\| rectificationLoading \|\| rectificationMutationPending\}/);
assert.doesNotMatch(source, /重试恢复/);
assert.doesNotMatch(source, /返回并恢复原问题|返回首页/);
@@ -0,0 +1,288 @@
import assert from "node:assert/strict";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { natalConsultationThinkingPlan } from "../src/lib/consultation-thinking-plan.ts";
const migrationsDir = new URL("../supabase/migrations/", import.meta.url);
const originalSql = readFileSync(
new URL("../supabase/migrations/20260901010000_append_consultation_question.sql", import.meta.url),
"utf8",
);
const sql = readFileSync(
new URL("../supabase/migrations/20260916010000_consultation_session_capacity.sql", import.meta.url),
"utf8",
);
const itemRoute = readFileSync(
new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url),
"utf8",
);
const consultRoute = readFileSync(
new URL("../src/app/api/consult/route.ts", import.meta.url),
"utf8",
);
const SESSION_SELECT =
"id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at";
function functionBody(source: string): string {
const match = source.match(/as \$\$\r?\n([\s\S]*?)\r?\n\$\$;/);
assert.ok(match?.[1], "function body must be present");
return match[1];
}
function withoutLineComments(source: string): string {
return source.replace(/--[^\n]*/g, "");
}
function utf8Bytes(value: string): number {
return Buffer.byteLength(value, "utf8");
}
function typicalReceipts(domains: readonly string[]) {
const workflowReceipt = {
route: "multi-domain",
status: "ready",
preciseTiming: "allowed",
missingLayers: [] as string[],
domains,
};
const agentExecutionReceipt = {
runId: "run-typical",
runtime: "mastra-agentic" as const,
skill: {
name: "jyotish-vedic-astrology" as const,
loaded: true,
version: "1.0.0",
referenceReads: 2,
methodologySections: 4,
},
steps: [
{ sequence: 1, kind: "skill" as const, name: "jyotish-vedic-astrology", status: "completed" as const, durationMs: 120 },
{ sequence: 2, kind: "tool" as const, name: "run-jyotish-consultation", status: "completed" as const, durationMs: 1800 },
],
stepBudget: { planned: 8, used: 2, remaining: 6, truncated: false },
workflow: workflowReceipt,
techniqueTruth: "verified",
techniqueAuditTable: [
{ technique: "VedAstro Cloud State", status: "blocked" as const, note: "未经核验的官方云证据,置信度封顶" },
{ technique: "Functional Benefic/Malefic", status: "executed" as const, note: "功能属性与自然属性冲突时降置信" },
{ technique: "Formal Vargas D1D60", status: "executed" as const, note: "20/20 传统命名分盘" },
{ technique: "Vimshottari sub-periods", status: "executed" as const, note: "本轮已纳入证据计划" },
{ technique: "Narayana Dasha", status: "executed" as const, note: "本轮无独立当前读数" },
{ technique: "Yogas", status: "executed" as const, note: "成盘与落空均列出" },
{ technique: "Ashtakavarga", status: "executed" as const, note: "本轮已纳入证据计划" },
{ technique: "Shadbala components", status: "executed" as const, note: "本轮已纳入证据计划" },
{ technique: "MEVG / Global Web Evidence", status: "blocked" as const, note: "本轮未闭合" },
{ technique: "Real Case Calibration", status: "blocked" as const, note: "本轮未闭合" },
{ technique: "Timing Precision Gate", status: "executed" as const, note: "按声明精度封顶" },
{ technique: "Prashna chart", status: "not_applicable" as const, note: "本轮为本命咨询" },
],
};
return {
techniqueTruth: "verified",
workflowReceipt,
agentExecutionReceipt,
};
}
function thinkingPlan(domains: readonly ("career" | "wealth" | "marriage")[]) {
return natalConsultationThinkingPlan({
domains,
requiredBlocks: [
"raw_structure",
"raman_six_step",
"yoga_table",
"timing",
"synthesis",
"technique_audit_table",
"modern_wrap",
],
mustUseLayers: ["D1", "run-jyotish-consultation", "D10", "skill_read"],
});
}
function assistantMessage(_index: number, domains: readonly ("career" | "wealth" | "marriage")[]) {
const receipts = typicalReceipts(domains);
return {
role: "assistant" as const,
text: "正".repeat(4000),
thinkingText: "思".repeat(4000),
thinkingSections: thinkingPlan(domains),
...receipts,
};
}
function userMessage(index: number) {
return {
role: "user" as const,
text: `${String(index + 1).padStart(2, "0")}`.padEnd(100, "问"),
requestId: `capacity-req-${index + 1}`,
};
}
function sessionDetailPayload(rounds: number, domains: readonly ("career" | "wealth" | "marriage")[]) {
const messages = Array.from({ length: rounds }, (_, index) => [
userMessage(index),
assistantMessage(index, domains),
]).flat();
return {
session: {
id: "00000000-0000-4000-8000-000000000001",
title: "事业方向",
theme: "career",
model_id: "test-model",
messages,
session_type: "consultation",
rectification_case_id: null,
chart_profile_id: null,
chart_profile_name: null,
chart_profile_role: null,
updated_at: "2026-09-15T00:00:00.000Z",
pinned: false,
archived_at: null,
},
};
}
function countedText(messages: readonly { text?: string }[]): number {
return messages.reduce((sum, message) => sum + (message.text ?? "").length, 0);
}
function oldCountedChars(messages: readonly Record<string, unknown>[]): number {
return messages.reduce((sum, message) => {
const text = typeof message.text === "string" ? message.text.length : 0;
const thinking = typeof message.thinkingText === "string" ? message.thinkingText.length : 0;
const sections = "thinkingSections" in message
? JSON.stringify(message.thinkingSections).length
: 0;
return sum + text + thinking + sections;
}, 0);
}
test("the capacity migration is the latest replace of append_consultation_question", () => {
const names = readdirSync(fileURLToPath(migrationsDir))
.filter((name) => name.endsWith(".sql"))
.sort();
const replaces = names.filter((name) =>
name.includes("append_consultation_question") || name.includes("consultation_session_capacity"),
);
assert.deepEqual(replaces.at(-1), "20260916010000_consultation_session_capacity.sql");
assert.equal(
existsSync(fileURLToPath(new URL("../db/migrations/20260916010000_consultation_session_capacity.sql", import.meta.url))),
false,
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
);
});
test("CREATE OR REPLACE keeps the BUG-464 signature, lock, idempotency, and error codes", () => {
assert.match(
sql,
/create or replace function public\.append_consultation_question\(\s*p_user_id uuid,\s*p_request_id text,\s*p_session_id uuid,\s*p_question_message jsonb\s*\)/,
);
assert.match(sql, /returns table\(success boolean, error_code text\)/);
const body = functionBody(sql);
assert.match(body, /pg_advisory_xact_lock/);
assert.match(body, /elem->>'requestId' = v_request_id/);
assert.match(body, /return query select true, null::text;/);
assert.match(body, /'session_missing'::text/);
assert.match(body, /'session_full'::text/);
assert.match(body, /'invalid_request'::text/);
assert.match(body, /'invalid_question_message'::text/);
assert.match(body, /char_length\(v_text\) > 16000/);
assert.match(body, /v_count >= 200/);
assert.match(body, /\(v_chars \+ v_new_chars\) > 200000/);
assert.match(
sql,
/revoke all on function public\.append_consultation_question\(uuid, text, uuid, jsonb\)\s+from public, anon, authenticated/,
);
assert.match(
sql,
/grant execute on function public\.append_consultation_question\(uuid, text, uuid, jsonb\)\s+to service_role/,
);
assert.doesNotMatch(body, /session_physically_full|quota_exceeded|storage_full/);
});
test("conversation quota sum counts only text; physical cap counts whole JSON", () => {
assert.match(originalSql, /length\(coalesce\(elem->>'thinkingText', ''\)\)/);
assert.match(originalSql, /elem \? 'thinkingSections'/);
const body = functionBody(sql);
const code = withoutLineComments(body);
assert.doesNotMatch(code, /thinkingText/);
assert.doesNotMatch(code, /thinkingSections/);
assert.match(code, /coalesce\(sum\(length\(coalesce\(elem->>'text', ''\)\)\), 0\)/);
assert.match(code, /coalesce\(sum\(length\(elem::text\)\), 0\)/);
assert.match(code, /\(v_physical_chars \+ v_new_physical\) > 1000000/);
assert.match(sql, /50 rounds × \(~4,000 body \+ ~4,000 thinkingText \+ ~3,000/);
assert.match(sql, /thinkingSections \+ ~3,000 receipts\) ≈ 700,000\. Headroom → 1,000,000\./);
});
test("both caps still surface as session_full; consult route is unchanged", () => {
const body = functionBody(sql);
const fullReturns = body.match(/return query select false, 'session_full'::text;/g) ?? [];
assert.equal(fullReturns.length, 1);
assert.match(consultRoute, /error_code === "session_full"/);
assert.match(consultRoute, /code: "session_full"/);
assert.doesNotMatch(consultRoute, /session_physically_full|physical_cap|storage_full/);
assert.match(itemRoute, new RegExp(`sessionSelect = "${SESSION_SELECT}"`));
});
test("measured thinkingSections and receipts stay inside the physical-cap arithmetic", () => {
const one = JSON.stringify(thinkingPlan(["career"]));
const two = JSON.stringify(thinkingPlan(["career", "wealth"]));
const three = JSON.stringify(thinkingPlan(["career", "wealth", "marriage"]));
assert.ok(one.length >= 1200 && one.length <= 2500, `1-domain sections JSON length ${one.length}`);
assert.ok(two.length >= 1800 && two.length <= 3500, `2-domain sections JSON length ${two.length}`);
assert.ok(three.length >= 2400 && three.length <= 4500, `3-domain sections JSON length ${three.length}`);
const receipts = typicalReceipts(["career", "wealth", "marriage"]);
const receiptJson = JSON.stringify({
techniqueTruth: receipts.techniqueTruth,
workflowReceipt: receipts.workflowReceipt,
agentExecutionReceipt: receipts.agentExecutionReceipt,
});
const receiptChars = receiptJson.length;
assert.ok(
receiptChars <= 4000,
`typical three-receipt JSON is ${receiptChars} chars; update the 3,000 term and 1,000,000 cap if this is a real stored shape`,
);
const perRound =
4000
+ 4000
+ three.length
+ receiptChars;
const fiftyRoundPhysical = 50 * perRound;
assert.ok(
fiftyRoundPhysical < 1_000_000,
`50 × typical stored round (${perRound} chars) = ${fiftyRoundPhysical}; must stay under the physical cap`,
);
});
test("session-detail JSON at the old ~19-round cap versus the new ~50-round cap", () => {
const domains = ["career", "wealth", "marriage"] as const;
const oldCap = sessionDetailPayload(19, domains);
const newCap = sessionDetailPayload(50, domains);
const oldJson = JSON.stringify(oldCap);
const newJson = JSON.stringify(newCap);
const oldBytes = utf8Bytes(oldJson);
const newBytes = utf8Bytes(newJson);
assert.equal(oldCap.session.messages.length, 38);
assert.equal(newCap.session.messages.length, 100);
assert.ok(oldCountedChars(oldCap.session.messages) > 200_000, "19 full rounds already exceeded the old combined quota");
assert.ok(countedText(newCap.session.messages) >= 200_000, "50 rounds of 100+4000 visible text reach the conversation quota");
// Measured 2026-09-16 on this fixture: 19-round 562,240 B (0.536 MiB);
// 50-round 1,479,034 B (1.411 MiB); ratio 2.631.
assert.ok(oldBytes > 540_000 && oldBytes < 590_000, `19-round detail JSON was ${oldBytes} bytes`);
assert.ok(newBytes > 1_450_000 && newBytes < 1_510_000, `50-round detail JSON was ${newBytes} bytes`);
assert.ok(newBytes > oldBytes, "50-round detail JSON must be larger than 19-round");
const ratio = newBytes / oldBytes;
assert.ok(
ratio > 2.5 && ratio < 2.8,
`50/19 size ratio was ${ratio.toFixed(3)} (utf8 ${oldBytes}${newBytes})`,
);
});
@@ -2,11 +2,14 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
CONSULTATION_HISTORY_CHECKPOINT_BUDGET_RATIO,
CONSULTATION_HISTORY_MESSAGE_CHARS,
clipConsultationHistoryText,
consultationHistoryCheckpointChars,
consultationHistoryFromStoredMessages,
consultationHistoryWindow,
consultationUserTurnContent,
droppedRoundsMarker,
historyBudgetChars,
isContextOverflowError,
omissionMarker,
@@ -77,6 +80,61 @@ test("historyBudgetChars uses 64k, 32k, and null-as-128k windows", () => {
assert.equal(historyBudgetChars(undefined), 40_000);
});
test("checkpoint threshold stays below the history budget for legal context windows", () => {
assert.equal(CONSULTATION_HISTORY_CHECKPOINT_BUDGET_RATIO, 0.4);
const windows = [200_000, 128_000, 64_000, 32_000, null] as const;
for (const window of windows) {
const budget = historyBudgetChars(window);
const threshold = consultationHistoryCheckpointChars(window);
assert.ok(threshold < budget, `window=${window}`);
}
// 128k keeps the previous 16,000-character checkpoint.
assert.equal(consultationHistoryCheckpointChars(128_000), 16_000);
assert.equal(consultationHistoryCheckpointChars(null), 16_000);
assert.equal(consultationHistoryCheckpointChars(64_000), 2_400);
assert.equal(consultationHistoryCheckpointChars(32_000), 1_600);
});
test("dropped whole turns leave an omission marker in the user-turn summary slot", () => {
const body = "x".repeat(3_000);
const history = consultationHistoryWindow(numberedMessages(20, () => body), null, {
contextWindow: 128_000,
});
assert.ok(history.droppedCount > 0);
const withoutSummary = consultationUserTurnContent({
currentTime: "当前时间:2026-09-16 12:00(中国)",
instruction: "先加载 Jyotish Skill",
summaryText: history.summaryText,
droppedCount: history.droppedCount,
question: "刚才你说的那个时间",
});
assert.match(
withoutSummary,
new RegExp(`更早的 ${history.droppedCount} 轮问答未能进入本轮上下文,结论尚未并入会话摘要`),
);
assert.equal(withoutSummary.includes(SESSION_CONTEXT_SUMMARY_HEADING), false);
const withSummary = consultationUserTurnContent({
currentTime: "当前时间:2026-09-16 12:00(中国)",
instruction: "先加载 Jyotish Skill",
summaryText: "先前结论",
droppedCount: history.droppedCount,
question: "刚才你说的那个时间",
});
assert.match(withSummary, new RegExp(`更早的 ${history.droppedCount} 轮问答已并入上面的会话摘要`));
assert.ok(withSummary.indexOf(SESSION_CONTEXT_SUMMARY_HEADING) < withSummary.indexOf("刚才你说的那个时间"));
const kept = consultationUserTurnContent({
currentTime: "当前时间:2026-09-16 12:00(中国)",
instruction: "先加载 Jyotish Skill",
summaryText: "先前结论",
droppedCount: 0,
question: "刚才你说的那个时间",
});
assert.equal(kept.includes("更早的"), false);
assert.equal(droppedRoundsMarker(0, true), "");
});
test("stored consultation history can skip the in-flight request id", () => {
const history = consultationHistoryFromStoredMessages([
{ role: "user", text: "old", requestId: "keep" },
@@ -0,0 +1,277 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
const runnerPath = fileURLToPath(
new URL("../scripts/db-migrate.mjs", import.meta.url),
);
function dockerAvailable(): boolean {
return spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], {
encoding: "utf8",
stdio: "ignore",
}).status === 0;
}
const skipWithoutDocker = dockerAvailable() ? false : "docker unavailable on this host";
const SESSION_ID = "99999999-9999-4999-8999-999999999999";
function sqlLiteral(value: string): string {
return value.replaceAll("'", "''");
}
function appendQuestionSql(
fixture: ReturnType<typeof startPostgresFixture>,
userId: string,
requestId: string,
textSql: string,
sessionId = SESSION_ID,
): string {
return fixture.psql(`
select coalesce(success::text, 'null') || ':' || coalesce(error_code, 'null')
from public.append_consultation_question(
'${userId}'::uuid,
'${sqlLiteral(requestId)}',
'${sessionId}'::uuid,
jsonb_build_object('role', 'user', 'text', ${textSql})
);
`);
}
function appendQuestion(
fixture: ReturnType<typeof startPostgresFixture>,
userId: string,
requestId: string,
text: string,
sessionId = SESSION_ID,
): string {
return appendQuestionSql(fixture, userId, requestId, `'${sqlLiteral(text)}'`, sessionId);
}
function seedSession(
fixture: ReturnType<typeof startPostgresFixture>,
userId: string,
messagesSql: string,
): void {
fixture.psql(`
insert into public.chat_sessions (
id, user_id, title, theme, model_id, messages, session_type, updated_at
) values (
'${SESSION_ID}', '${userId}', '新对话', 'general',
'test-model', ${messagesSql}, 'consultation', now()
)
on conflict (id) do update
set messages = excluded.messages,
title = excluded.title,
updated_at = now();
`);
}
test("append_consultation_question ignores thinking fields and enforces the physical JSON cap", { skip: skipWithoutDocker }, () => {
const fixture = startPostgresFixture();
const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password");
try {
const migration = spawnSync(process.execPath, [runnerPath], {
encoding: "utf8",
env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl },
});
assert.equal(migration.status, 0, migration.stderr);
assert.match(migration.stdout, /applied 20260916010000_consultation_session_capacity\.sql/);
fixture.psqlAs(
"identity_runtime",
"identity-runtime-test-password",
`
insert into identity.users (name, email, email_verified, email_verified_at)
values ('Capacity User', 'capacity@example.com', true, now());
`,
);
const userId = fixture.psql(
"select id from identity.users where email = 'capacity@example.com'",
);
assert.equal(
fixture.psql(`
select
has_function_privilege(
'service_role',
'public.append_consultation_question(uuid, text, uuid, jsonb)',
'execute'
) || ':' ||
has_function_privilege(
'authenticated',
'public.append_consultation_question(uuid, text, uuid, jsonb)',
'execute'
) || ':' ||
has_function_privilege(
'anon',
'public.append_consultation_question(uuid, text, uuid, jsonb)',
'execute'
)
`),
"true:f:f",
);
assert.equal(
appendQuestion(fixture, userId, "missing-session", "会话不存在"),
"false:session_missing",
);
seedSession(fixture, userId, "'[]'::jsonb");
assert.equal(appendQuestion(fixture, userId, "append-request-1", "第一问会不会丢"), "true:null");
assert.equal(appendQuestion(fixture, userId, "append-request-1", "不该写入的重复提问"), "true:null");
assert.equal(
fixture.psql(`select jsonb_array_length(messages) from public.chat_sessions where id = '${SESSION_ID}'`),
"1",
);
assert.equal(
appendQuestionSql(fixture, userId, "too-long", "repeat('x', 16001)"),
"false:invalid_question_message",
);
seedSession(fixture, userId, `
(
select coalesce(jsonb_agg(elem order by n), '[]'::jsonb)
from (
select n,
case when n % 2 = 1 then
jsonb_build_object(
'role', 'user',
'text', '短问题',
'requestId', 'think-seed-' || n::text
)
else
jsonb_build_object(
'role', 'assistant',
'text', '短回答',
'thinkingText', repeat('T', 4000),
'thinkingSections', jsonb_build_object('pad', repeat('S', 3000)),
'techniqueTruth', 'verified',
'workflowReceipt', jsonb_build_object(
'route', 'career',
'status', 'ready',
'preciseTiming', 'allowed',
'missingLayers', jsonb_build_array()
)
)
end as elem
from generate_series(1, 60) as n
) as seeded
)
`);
const thinkingSums = fixture.psql(`
select
coalesce(sum(length(coalesce(elem->>'text', ''))), 0)::text
|| ':' ||
coalesce(sum(
length(coalesce(elem->>'text', ''))
+ length(coalesce(elem->>'thinkingText', ''))
+ case when elem ? 'thinkingSections'
then length((elem->'thinkingSections')::text) else 0 end
), 0)::text
|| ':' ||
coalesce(sum(length(elem::text)), 0)::text
from public.chat_sessions,
jsonb_array_elements(coalesce(messages, '[]'::jsonb)) as elem
where id = '${SESSION_ID}'
`);
const [textOnly, oldFormula, physical] = thinkingSums.split(":").map(Number);
assert.ok(textOnly < 50_000, `visible text was ${textOnly}`);
assert.ok(oldFormula > 200_000, `old combined formula was ${oldFormula}`);
assert.ok(physical < 1_000_000, `physical JSON was ${physical}`);
assert.equal(appendQuestion(fixture, userId, "after-thinking", "思考不该占额度"), "true:null");
assert.equal(
fixture.psql(`select jsonb_array_length(messages) from public.chat_sessions where id = '${SESSION_ID}'`),
"61",
);
seedSession(fixture, userId, `
(
select coalesce(jsonb_agg(
jsonb_build_object('role', 'assistant', 'text', repeat('x', 19900), 'requestId', n::text)
order by n
), '[]'::jsonb)
from generate_series(1, 10) as n
)
`);
const quotaBoundary = fixture.psql(`
select
coalesce(sum(length(coalesce(elem->>'text', ''))), 0)::text
|| ':' ||
coalesce(sum(length(elem::text)), 0)::text
from public.chat_sessions,
jsonb_array_elements(coalesce(messages, '[]'::jsonb)) as elem
where id = '${SESSION_ID}'
`);
const [quotaText, quotaPhysical] = quotaBoundary.split(":").map(Number);
assert.equal(quotaText, 199_000);
assert.ok(quotaPhysical < 1_000_000, `quota-boundary physical JSON was ${quotaPhysical}`);
assert.equal(
appendQuestionSql(fixture, userId, "quota-full", "repeat('y', 1001)"),
"false:session_full",
);
assert.equal(
fixture.psql(`select jsonb_array_length(messages) from public.chat_sessions where id = '${SESSION_ID}'`),
"10",
);
seedSession(fixture, userId, `
jsonb_build_array(
jsonb_build_object(
'role', 'assistant',
'text', '短',
'techniqueTruth', 'verified',
'workflowReceipt', jsonb_build_object('route', 'career', 'status', 'ready', 'preciseTiming', 'allowed', 'missingLayers', jsonb_build_array()),
'agentExecutionReceipt', jsonb_build_object('pad', repeat('R', 1000001))
)
)
`);
const physicalOnly = fixture.psql(`
select
coalesce(sum(length(coalesce(elem->>'text', ''))), 0)::text
|| ':' ||
coalesce(sum(length(elem::text)), 0)::text
from public.chat_sessions,
jsonb_array_elements(coalesce(messages, '[]'::jsonb)) as elem
where id = '${SESSION_ID}'
`);
const [shortText, hugePhysical] = physicalOnly.split(":").map(Number);
assert.ok(shortText < 100, `short body was ${shortText}`);
assert.ok(hugePhysical > 1_000_000, `receipt pad physical JSON was ${hugePhysical}`);
assert.equal(
appendQuestion(fixture, userId, "physical-full", "正文很短但行已经胀了"),
"false:session_full",
);
assert.equal(
fixture.psql(`select jsonb_array_length(messages) from public.chat_sessions where id = '${SESSION_ID}'`),
"1",
);
seedSession(fixture, userId, `
(
select coalesce(jsonb_agg(
jsonb_build_object('role', 'user', 'text', 'x', 'requestId', n::text)
order by n
), '[]'::jsonb)
from generate_series(1, 200) as n
)
`);
assert.equal(
appendQuestion(fixture, userId, "count-full", "满了就不能再写"),
"false:session_full",
);
assert.equal(
fixture.psql(`select jsonb_array_length(messages) from public.chat_sessions where id = '${SESSION_ID}'`),
"200",
);
} finally {
fixture.stop();
}
});
@@ -94,6 +94,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
assert.match(migration.stdout, /applied 20260901020000_chat_session_pin_archive\.sql/);
assert.match(migration.stdout, /applied 20260905010000_personal_report_longform_appendices\.sql/);
assert.match(migration.stdout, /applied 20260915010000_rectification_touch_chat_session\.sql/);
assert.match(migration.stdout, /applied 20260916010000_consultation_session_capacity\.sql/);
assert.equal(
existsSync(fileURLToPath(new URL("../db/migrations/20260901020000_chat_session_pin_archive.sql", import.meta.url))),
false,
@@ -104,6 +105,11 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
false,
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
);
assert.equal(
existsSync(fileURLToPath(new URL("../db/migrations/20260916010000_consultation_session_capacity.sql", import.meta.url))),
false,
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
);
fixture.psql(pinArchiveMigration);
assert.equal(
fixture.psql(`
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
// Measured 2026-09-16 after home-state-lowering on origin/staging @ 3b17c1b2.
// Line count: (pageSource.match(/\n/g) ?? []).length, same as `wc -l` = 1931.
// Hook counts use \buseState[<(] / \buseRef[<(] so `useState<Type>(` is not missed.
// Former caps were useState 66 / useRef 41 / lines 1951 on freeze-metric @ 51a65d92.
// This round moved 14 rectification useStates out of Home (15 → 1 shell object)
// and 2 rectification refs into useRectificationSurface.
const PAGE_LINE_COUNT_BASELINE = 1931;
const PAGE_LINE_COUNT_CAP = PAGE_LINE_COUNT_BASELINE + 150;
const HOME_USE_STATE_CAP = 52;
const HOME_USE_REF_CAP = 39;
const USE_STATE_RE = /\buseState[<(]/g;
const USE_REF_RE = /\buseRef[<(]/g;
function homeSource(source: string): string {
const marker = "export default function Home(";
const start = source.indexOf(marker);
assert.notEqual(start, -1, "page.tsx must export default function Home(");
return source.slice(start);
}
function countMatches(source: string, pattern: RegExp): number {
return source.match(new RegExp(pattern.source, "g"))?.length ?? 0;
}
test("Home() useState count must not grow", () => {
const n = countMatches(homeSource(pageSource), USE_STATE_RE);
assert.ok(
n <= HOME_USE_STATE_CAP,
`Home() has ${n} useState calls; cap is ${HOME_USE_STATE_CAP}. Extracted hooks and child components should own their own state.`,
);
});
test("Home() useRef count must not grow", () => {
const n = countMatches(homeSource(pageSource), USE_REF_RE);
assert.ok(
n <= HOME_USE_REF_CAP,
`Home() has ${n} useRef calls; cap is ${HOME_USE_REF_CAP}. Do not rewrite state as refs to dodge the useState freeze.`,
);
});
test("page.tsx line count stays within the coarse guardrail", () => {
const n = (pageSource.match(/\n/g) ?? []).length;
assert.ok(
n <= PAGE_LINE_COUNT_CAP,
`page.tsx has ${n} lines; cap is ${PAGE_LINE_COUNT_CAP} (${PAGE_LINE_COUNT_BASELINE} baseline + 150).`,
);
});
@@ -124,9 +124,15 @@ test("opening is server-owned: shouldStartOpening drives the first turn, never c
// 新:增加 skip_probe(核对卡「这题跳过」)
// 原因:决策 4,跳过只关本题
assert.match(route, /action: z\.enum\(\["opening", "message", "read_only", "answer_choice", "stop_and_review", "skip_probe"\]\)/);
assert.match(page, /shouldStartOpening=\{rectificationShouldStartOpening\}/);
// 原值:shouldStartOpening={rectificationShouldStartOpening} 写在 page.tsx JSX
// 新值:shouldStartOpening: rectificationShouldStartOpening 写在 hook 的 panel
// 原因:开场开关只服务子树,随 panel 下沉
assert.match(page, /shouldStartOpening: rectificationShouldStartOpening/);
assert.match(page, /setRectificationShouldStartOpening\(opened\.shouldStartOpening\)/);
assert.match(page, /onOpeningConsumed=\{\(\) => setRectificationShouldStartOpening\(false\)\}/);
// 原值:onOpeningConsumed={() => setRectificationShouldStartOpening(false)} 写在 page.tsx JSX
// 新值:onOpeningConsumed: () => setRectificationShouldStartOpening(false) 写在 hook 的 panel
// 原因:同上
assert.match(page, /onOpeningConsumed: \(\) => setRectificationShouldStartOpening\(false\)/);
});
test("incomplete profiles stay in the shared onboarding flow before any open request", () => {
@@ -221,9 +227,12 @@ test("persisted turns survive remounts; duplicate openings are suppressed by the
// Was: the "ready"/"loading" key suffix (see above). Persisted turns now survive
// because nothing remounts: the key is the session/Case binding only (BUG-505).
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
assert.match(page, /initialTurns=\{rectificationTurns\}/);
assert.match(page, /onMessagesChange=\{handleRectificationMessagesChange\}/);
assert.match(page, /onOpeningConsumed=\{\(\) => setRectificationShouldStartOpening\(false\)\}/);
// 原值:initialTurns={rectificationTurns} / onMessagesChange={handleRectificationMessagesChange} / onOpeningConsumed={...} 写在 page.tsx JSX
// 新值:同名字段写在 hook 的 panel
// 原因:子树自有状态与回调收到 panel,Home 只传外壳还要读的绑定
assert.match(page, /initialTurns: rectificationTurns/);
assert.match(page, /onMessagesChange: handleRectificationMessagesChange/);
assert.match(page, /onOpeningConsumed: \(\) => setRectificationShouldStartOpening\(false\)/);
});
test("the agent route verifies the exact Case/Session binding before any turn", () => {
@@ -579,7 +588,10 @@ test("compact board overlays chat as a bottom sheet above the composer", () => {
assert.match(page, /className="chat-header-rectification"/);
assert.match(page, /data-rectification-header-slot=""/);
assert.match(page, /ref=\{setRectificationHeaderSlot\}/);
assert.match(page, /headerSlot=\{rectificationHeaderSlot\}/);
// 原值:headerSlot={rectificationHeaderSlot} 写在 page.tsx JSX
// 新值:headerSlot: rectificationHeaderSlot 写在 hook 的 panelpage 仍 ref={setRectificationHeaderSlot}
// 原因:header 节点在外壳,值只服务子树
assert.match(page, /headerSlot: rectificationHeaderSlot/);
assert.match(styles, /\.chat-header-rectification \.rectification-board-peek \{[\s\S]*width: auto/);
assert.doesNotMatch(chat, /rectification-workspace__board-trigger/);
assert.doesNotMatch(chat.slice(chat.indexOf("className=\"composer-wrap\""), chat.indexOf("<form className=\"composer\"")), /RectificationBoardPeek/);
@@ -46,7 +46,11 @@ test("a history-list open failure shows under the clicked row, not on the starte
// 原值:rectificationError 只由 starter-home 画;selectSession 不切会话,表现为点了没反应
// 新值:session 失败绑到被点的那一行;首页起始卡不重复同一条
// 决策 3:错误就地显示
assert.match(pageSource, /const \[rectificationErrorSessionId, setRectificationErrorSessionId\]/);
// 原值:const [rectificationErrorSessionId, setRectificationErrorSessionId] = useState(...)
// 新值:外壳 9 个校正字段合成一个 rectification 对象;errorSessionId 仍叫 rectificationErrorSessionIdsetter 仍叫 setRectificationErrorSessionId
// 原因:外壳也要读的校正状态合并成一个对象,散装 useState 下降
assert.match(pageSource, /errorSessionId: rectificationErrorSessionId,/);
assert.match(pageSource, /setRectificationErrorSessionId,/);
assert.match(
pageSource,
/rectificationError=\{rectificationErrorSessionId \? "" : rectificationError\}/,
@@ -35,7 +35,10 @@ test("the rectification surface is revealed once: Case hydration precedes the sw
// Panel: one key per binding, snapshot and turns as initial state, later turns as a prop update.
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
assert.doesNotMatch(page, /"ready" : "loading"/);
assert.match(page, /initialSnapshot=\{rectificationSnapshot\}/);
// 原值:initialSnapshot={rectificationSnapshot} 写在 page.tsx JSX
// 新值:initialSnapshot: rectificationSnapshot 写在 hook 的 panel
// 原因:snapshot 只服务子树,随 panel 下沉
assert.match(page, /initialSnapshot: rectificationSnapshot/);
assert.match(page, /declaredTime=\{rectificationDeclaredTime\}/);
assert.match(wrapper, /initialSnapshot: RectificationCaseSnapshotPayload \| null;/);
assert.match(chat, /useState\(initialSnapshot !== null\)/);
+43 -3
View File
@@ -3,13 +3,14 @@ import { readFileSync } from "node:fs";
import test from "node:test";
import {
CONSULTATION_HISTORY_TAIL_MAX_CHARS,
consultationHistoryCheckpointChars,
} from "../src/lib/consultation-session-history.ts";
import {
buildSummaryPrompt,
checkpointSessionContextSummary,
generateSessionContextSummary,
messagesForSummaryInput,
resolveInheritedContextSummary,
sanitizeSessionContextSummary,
shouldCheckpoint,
writeSessionContextSummary,
@@ -24,10 +25,14 @@ function overBudgetConversation() {
];
}
test("checkpoint triggers only when the tail exceeds 16_000 characters", () => {
assert.equal(CONSULTATION_HISTORY_TAIL_MAX_CHARS, 16_000);
test("checkpoint triggers only when the tail exceeds the derived threshold", () => {
// Former value: hard-coded CONSULTATION_HISTORY_TAIL_MAX_CHARS = 16_000.
// 128k still checkpoints at 16_000 (0.4 × 40_000 budget).
assert.equal(consultationHistoryCheckpointChars(128_000), 16_000);
assert.equal(shouldCheckpoint([{ role: "user", text: "x".repeat(15_999) }], null), false);
assert.equal(shouldCheckpoint([{ role: "user", text: "x".repeat(16_001) }], null), true);
assert.equal(shouldCheckpoint([{ role: "user", text: "x".repeat(2_400) }], null, { contextWindow: 64_000 }), false);
assert.equal(shouldCheckpoint([{ role: "user", text: "x".repeat(2_401) }], null, { contextWindow: 64_000 }), true);
});
test("checkpoint prompt omits the last question-answer pair", () => {
@@ -108,6 +113,41 @@ test("writeSessionContextSummary abandons when updatedAt does not match", async
assert.equal(result, "abandoned");
});
test("inherited context summary copies owned text and skips foreign or empty sources", async () => {
const source = {
version: 1 as const,
text: "已问过的问题\n事业时机",
throughRequestId: "a1",
throughMessageIndex: 3,
messageCount: 4,
updatedAt: "2026-09-15T00:00:00.000Z",
};
const copied = await resolveInheritedContextSummary({
continuedFromSessionId: "11111111-1111-4111-8111-111111111111",
loadOwnedSummary: async () => source,
});
assert.deepEqual(copied, source);
const foreign = await resolveInheritedContextSummary({
continuedFromSessionId: "22222222-2222-4222-8222-222222222222",
loadOwnedSummary: async () => null,
});
assert.equal(foreign, null);
const empty = await resolveInheritedContextSummary({
continuedFromSessionId: "11111111-1111-4111-8111-111111111111",
loadOwnedSummary: async () => ({ version: 1, text: " " }),
});
assert.equal(empty, null);
const skipped = await resolveInheritedContextSummary({
loadOwnedSummary: async () => {
throw new Error("should not load");
},
});
assert.equal(skipped, null);
});
test("checkpoint writes a new summary when the tail is over budget", async () => {
const result = await checkpointSessionContextSummary({
messages: overBudgetConversation(),
+58 -7
View File
@@ -1,30 +1,81 @@
"""Freeze scripts/jyotish_api_server.py growth.
"""Freeze coupling in scripts/jyotish_api_server.py.
New endpoints and features must live in new modules and be thinly registered
from the main file. This cap is the live line count at freeze (11063 on
2026-09-02, via `wc -l`) plus 300 lines of bugfix slack.
from the main file. Line count is only a coarse guardrail; the live gates are
JyotishAPIHandler method count and JyotishAPIHandler.__new__ forgery sites.
"""
from __future__ import annotations
import re
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
API_SERVER = ROOT / "scripts" / "jyotish_api_server.py"
AGENTS = ROOT / "AGENTS.md"
# Live `wc -l scripts/jyotish_api_server.py` at freeze. New features must not
# consume this budget; open a module instead.
JYOTISH_API_SERVER_LINE_COUNT_BASELINE = 11063
# Live `wc -l scripts/jyotish_api_server.py` equivalent:
# Path.read_bytes().count(b"\n"), measured 2026-09-16 on origin/staging @ 51a65d92.
# New features must not consume this budget; open a module instead.
JYOTISH_API_SERVER_LINE_COUNT_BASELINE = 11291
JYOTISH_API_SERVER_LINE_COUNT_CAP = JYOTISH_API_SERVER_LINE_COUNT_BASELINE + 300
# Whole-file indent match `^ (?:async )?def \w+`, same count as
# TASK-freeze-metric-change-20260915 §1. Measured 2026-09-16 @ 51a65d92.
JYOTISH_API_HANDLER_METHOD_COUNT_BASELINE = 225
# `JyotishAPIHandler.__new__` in scripts/ and tests/ `*.py`, excluding this file.
# Measured 2026-09-16 @ 51a65d92: scripts/ production forgeries = 4, tests/ = 29.
JYOTISH_API_HANDLER_NEW_COUNT_BASELINE = 33
HANDLER_METHOD_RE = re.compile(r"^ (?:async )?def \w+", re.MULTILINE)
NEW_MARKER = "JyotishAPIHandler.__new__"
def _handler_method_count(source: str) -> int:
return len(HANDLER_METHOD_RE.findall(source))
def _new_hits() -> Counter[str]:
hits: Counter[str] = Counter()
skip = Path(__file__).resolve()
for folder in (ROOT / "scripts", ROOT / "tests"):
for path in sorted(folder.rglob("*.py")):
if path.resolve() == skip:
continue
count = path.read_text(encoding="utf-8").count(NEW_MARKER)
if count:
hits[path.relative_to(ROOT).as_posix()] = count
return hits
def test_jyotish_api_handler_method_count_must_not_grow() -> None:
source = API_SERVER.read_text(encoding="utf-8")
count = _handler_method_count(source)
assert count <= JYOTISH_API_HANDLER_METHOD_COUNT_BASELINE, (
f"{API_SERVER.as_posix()} has {count} four-space def methods; "
f"cap is {JYOTISH_API_HANDLER_METHOD_COUNT_BASELINE}. Move behaviour "
"into a dedicated module; thinly registered from this file."
)
def test_jyotish_api_handler_new_count_must_not_grow() -> None:
hits = _new_hits()
total = sum(hits.values())
listed = ", ".join(f"{path}:{count}" for path, count in sorted(hits.items()))
assert total <= JYOTISH_API_HANDLER_NEW_COUNT_BASELINE, (
f"{NEW_MARKER} appears {total} times under scripts/ and tests/; "
f"cap is {JYOTISH_API_HANDLER_NEW_COUNT_BASELINE}. Hits: {listed}"
)
def test_jyotish_api_server_must_not_grow_beyond_bugfix_slack() -> None:
line_count = API_SERVER.read_bytes().count(b"\n")
assert line_count <= JYOTISH_API_SERVER_LINE_COUNT_CAP, (
f"{API_SERVER.as_posix()} has {line_count} lines; cap is "
f"{JYOTISH_API_SERVER_LINE_COUNT_CAP} ({JYOTISH_API_SERVER_LINE_COUNT_BASELINE} "
"baseline + 300 bugfix slack). New endpoints and features must be new "
"baseline + 300 coarse guardrail). New endpoints and features must be new "
"modules, thinly registered from this file."
)
+163 -2
View File
@@ -2,17 +2,25 @@
Golden payload in tests/golden/rectification_engine_memoization_v1.json was
produced from origin/staging @ a8d29d1b before any memoization landed.
Do not compare that payload with a whole-structure ``==``. Cross-machine
libm / pyswisseph rounding already drifted ``margin_percent`` by 1.1e-3
(TASK-rectification-engine-memoization-fix-20260915). Equivalence of the
four cached layers is proven in-process instead.
"""
from __future__ import annotations
import inspect
import json
import math
from datetime import date, datetime
from pathlib import Path
from typing import Any
from unittest.mock import patch
import pytest
from scripts.active_rectification_event_engine import (
_candidate_datetimes,
_controlled_transit_rules,
@@ -35,6 +43,20 @@ GOLDEN_PATH = ROOT / "tests" / "golden" / "rectification_engine_memoization_v1.j
FROZEN_TODAY = date(2026, 9, 16)
TIMING_KEYS = frozenset({"column_compare_ms"})
SOURCE_COMMIT = "a8d29d1b6cc37ff865ddec6c8bccdf9aa889ee53"
CACHE_LAYER_KEYS = (
"ashtakavarga_result",
"shadbala_result",
"vimshottari_timeline",
"narayana_periods",
)
# TASK-rectification-engine-memoization-fix-20260915 §2 measured max
# cross-machine drift of 1.1e-3 on decision_receipt.margin_percent
# (5.2995 vs golden 5.3006). Candidate scores, already rounded to 4
# decimals, drifted by 1 ulp (1.0e-4). Tolerance is the wider of
# abs=2e-3 and rel=5e-4 so 1.1e-3 still passes while a 1e-2 mutation
# (the reverse test below) fails.
GOLDEN_FLOAT_ABS = 2e-3
GOLDEN_FLOAT_REL = 5e-4
def public_score_request() -> dict[str, Any]:
@@ -117,11 +139,102 @@ def _count_calls(monkeypatch, owner: Any, name: str, *, from_engine: bool = Fals
return counter
def _is_json_number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool)
def _assert_tiered_equal(actual: Any, expected: Any, *, path: str) -> None:
if isinstance(expected, dict):
assert isinstance(actual, dict), path
assert set(actual) == set(expected), f"{path} keys {set(actual)!r} != {set(expected)!r}"
for key in expected:
_assert_tiered_equal(actual[key], expected[key], path=f"{path}.{key}")
return
if isinstance(expected, list):
assert isinstance(actual, list), path
assert len(actual) == len(expected), f"{path} length {len(actual)} != {len(expected)}"
for index, (left, right) in enumerate(zip(actual, expected)):
_assert_tiered_equal(left, right, path=f"{path}[{index}]")
return
if isinstance(expected, int) and isinstance(actual, int) and not isinstance(expected, bool) and not isinstance(actual, bool):
assert actual == expected, f"{path}: {actual!r} != {expected!r}"
return
if _is_json_number(expected) or _is_json_number(actual):
left = float(actual)
right = float(expected)
tolerance = max(GOLDEN_FLOAT_ABS, GOLDEN_FLOAT_REL * abs(right))
assert math.isclose(left, right, rel_tol=0.0, abs_tol=tolerance), (
f"{path}: {left!r} vs {right!r} exceeds {tolerance}"
)
return
assert actual == expected, f"{path}: {actual!r} != {expected!r}"
def _assert_memoization_payloads(actual: dict[str, Any], expected: dict[str, Any]) -> None:
_assert_tiered_equal(actual["candidate_scores"], expected["candidate_scores"], path="candidate_scores")
_assert_tiered_equal(actual["decision_receipt"], expected["decision_receipt"], path="decision_receipt")
def _event_engine_request() -> dict[str, Any]:
"""Dated events for compute_event_candidate_rows (not the v5 score payload)."""
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": "2008-07-01",
"precision": "day",
"summary": "入学",
},
{
"id": "00000000-0000-4000-8000-000000000002",
"domain": "career",
"event_kind": "career_entry",
"date": "2012-06-15",
"precision": "day",
"summary": "入职",
},
],
}
def _contexts_with_layer_cache_cleared(contexts: list[dict[str, Any]]) -> list[dict[str, Any]]:
cleared: list[dict[str, Any]] = []
for context in contexts:
clone = dict(context)
for key in CACHE_LAYER_KEYS:
clone[key] = None
cleared.append(clone)
return cleared
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"]
_assert_memoization_payloads(actual, expected)
def test_golden_float_shift_of_1e_minus_2_fails() -> None:
expected = json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
mutated = json.loads(json.dumps(expected))
mutated["candidate_scores"][0]["score"] += 1e-2
with pytest.raises(AssertionError):
_assert_memoization_payloads(expected, mutated)
def test_golden_discrete_field_mismatch_fails() -> None:
expected = json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
mutated = json.loads(json.dumps(expected))
mutated["candidate_scores"][0]["time"] = "99:99"
with pytest.raises(AssertionError):
_assert_memoization_payloads(expected, mutated)
def test_shadbala_verified_fields_match_with_and_without_birth_minute() -> None:
@@ -372,3 +485,51 @@ def test_compute_event_candidate_rows_reuses_static_context_without_mutating_it(
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)
def test_cached_static_context_matches_uncached_fallback(monkeypatch) -> None:
request = _event_engine_request()
cached = [
build_candidate_static_context(request, candidate)
for candidate in _candidate_datetimes(request)
]
assert len(cached) >= 2
assert len(request["events"]) >= 2
for context in cached:
for key in CACHE_LAYER_KEYS:
assert context.get(key) is not None
uncached = _contexts_with_layer_cache_cleared(cached)
for context in uncached:
for key in CACHE_LAYER_KEYS:
assert context[key] is None
calls = _count_calls(monkeypatch, event_engine.shadbala, "calc_shadbala")
rows_cached = compute_event_candidate_rows(request, static_contexts=cached)
cached_calls = calls[0]
rows_uncached = compute_event_candidate_rows(request, static_contexts=uncached)
uncached_calls = calls[0] - cached_calls
assert rows_cached == rows_uncached
assert cached_calls == 0
assert uncached_calls > cached_calls
assert uncached_calls >= len(cached) * len(request["events"])
def test_poisoned_static_cache_diverges_from_live_rows() -> None:
request = _event_engine_request()
cached = [
build_candidate_static_context(request, candidate)
for candidate in _candidate_datetimes(request)
]
rows_cached = compute_event_candidate_rows(request, static_contexts=cached)
poisoned = [dict(context) for context in cached]
poisoned[0]["shadbala_result"] = {
"planets": {
name: {
"sthana_bala": {"total": 0.0},
"drik_bala": 0.0,
"naisargika_bala": 0.0,
}
for name in ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
}
}
rows_poisoned = compute_event_candidate_rows(request, static_contexts=poisoned)
assert rows_poisoned != rows_cached