Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51a65d927f | ||
|
|
dcfc2f15af | ||
|
|
7227b1ed8d | ||
|
|
149e1ec4c3 | ||
|
|
e61535f464 | ||
|
|
b9c053778a | ||
|
|
e4788dfc00 |
@@ -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 B(0.536 MiB)**;50 轮 **1,479,034 B(1.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`。不得写成通过。
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-16 — 这段对话写满之前,思考过程不再占用额度
|
||||
|
||||
普通咨询里,模型内部的思考过程不再算进「这段对话已写满」的额度。额度仍按你看得到的正文计算,能问的轮次大约从十几轮提到约五十轮。写满时还是请开一个新对话,提示不变。Skill 版本不变。
|
||||
|
||||
## 2026-09-16 — 对话写满后开新对话会静默带上之前的摘要
|
||||
|
||||
普通咨询写满、点「开新对话」之后,服务端会把上一场的会话摘要拷进新对话,界面上看不出差别。进不了窗口的更早几轮,模型会看到省略说明,不再当成没发生过。Skill 版本不变。
|
||||
|
||||
## 2026-09-16 — 同一天再问同一张盘,不再重复等外部证据
|
||||
|
||||
普通聊天同一张盘、同一天里再问,外部证据直接用已经取到的,不再每轮等外网。隔了一天会先用最多七天内的旧证据马上回答,后台再刷新;「深入看今日」仍然只要当天的。西洋盘整包不再塞进每一轮的回答里,压缩后的西洋层还在。Skill 版本不变。
|
||||
|
||||
## 2026-09-16 — 校正时系统没接上会直说,记下经历后没重算也会说明
|
||||
|
||||
生时校正里,如果这边没判断出你在答哪一问,会请你把刚才那句再发一次,而不是说你没说清。记下经历后如果这次没能重新比较候选,会告诉你经历已经记下、稍后再比,不会假装范围已经在收。Skill 版本不变。
|
||||
|
||||
@@ -11285,3 +11285,114 @@
|
||||
- 相关记录:BUG-473、BUG-249、BUG-250、BUG-260、BUG-617
|
||||
- 复发自:BUG-473(影响面含本文件,但两条防复发只约束正在流的那一行)
|
||||
- 修复版本:待发布
|
||||
## BUG-726 | 一轮校正把同一份 Case 档案从数据库取多次
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-15
|
||||
- 最近更新:2026-09-16
|
||||
- 影响面:`loadV9CaseDossier` / `loadV9CaseCompute`、`POST /api/rectification/agent`、regenerate、Case GET/POST
|
||||
- 用户现象:同一轮对话里档案被重复取回。用户看不到这条,但 2 vCPU 主机上这份 jsonb 投影与 Next.js、Python 引擎抢同一批核。
|
||||
- 触发条件:一次 HTTP 请求内多处调用 `get_agentic_rectification_case_dossier` 或 `get_agentic_rectification_case_compute`,中间没有写操作。
|
||||
- 根因:只读投影没有任何请求作用域缓存。每个调用点独立打 Postgres RPC。这不是回归,是 V9 运行时引入以来的分层遗漏。
|
||||
- 修复:新增 `withRectificationRequestCache`,挂在客户端对象上。两个只读投影按 `(fn, p_user_id, p_case_id)` 缓存 in-flight promise;其它 RPC 与 `.from(...)` 一律先清空再转发。生命周期等于一次请求。路由入口各包一处,零调用点改动,不跨请求缓存。
|
||||
- 验证:`frontend/tests/rectification-request-dossier-cache.test.ts`:连续两次读只打一次底层;中间其它 RPC 后必须重读;不同 caseId 互不命中;并发读合并;`.from` 透传后缓存清空;五条路由源码合同。既有 stream 50 / answer-choice 34 一条不改仍全绿。路由预取 + 证据轮 `case_dossier` 5→4。
|
||||
- 防复发:Case 只读投影必须经请求作用域缓存读取;新增只读投影要么进缓存白名单,要么在记录里写明为什么不能缓存。不得把档案挂在模块作用域或 `globalThis`。
|
||||
- 相关记录:BUG-176
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-727 | 普通聊天每轮每域同步等外网,超时不取消导致线程池饱和
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-15
|
||||
- 最近更新:2026-09-16
|
||||
- 影响面:`POST /api/consultation_workflow` 前台路径、`execute_consultation_workflow`、`vedastro_gateway`、前台 VedAstro 线程池
|
||||
- 用户现象:普通聊天每发一条、每个问题域都要等外网;本地排盘只占约 3%。超时后技法表 VedAstro 云状态仍是 blocked,之后每一轮继续白等约 1.5 秒。
|
||||
- 触发条件:网页咨询 `defer_optional_external_evidence: true`;同一张盘一天内多轮提问;前台线程池默认 2 个 worker。
|
||||
- 根因:外网证据本就按「盘 + UTC 日期」组织,却没有按这个键缓存,每轮重新 TLS 握手打 `api.vedastro.org`。`_join_foreground_vedastro` 超时返回 `official_blocked` 但不 `cancel()` 后台任务,任务继续跑满预算;join 1.5 s、budget 8 s、workers 2 三个数不在同一处约束,平均每 4 秒一轮就把池子占死。
|
||||
- 修复:新增独立模块 `scripts/vedastro_snapshot_cache.py`(目录与 key 函数都与 `_api_chart_cache` 分开)。同日命中直接用、零等待、不 submit;1–7 天前先用旧的并后台刷新;`entrypoint=daily_starlanguage` 只接受当天。超时必须 `future.cancel()`;join / budget / workers 成组声明,且 `budget ≤ 2 × join`。本单不是推翻 BUG-301:前台仍取官方层,只是用缓存去掉重复等待。
|
||||
- 验证:`tests/test_vedastro_snapshot_cache.py`(同日第二次零网关调用、跨日 stale+刷新不等待、daily_starlanguage 不吃昨天、缓存目录≠chart 缓存、N+1 等待不超过 join、三常数同块且 budget 比例、超时取消排队任务);既有前台赶上/超时降级回归。
|
||||
- 防复发:前台外部证据必须有「盘 + 日期」级缓存;任何有界等待都必须同时取消它等待的后台任务。
|
||||
- 相关记录:BUG-161、BUG-301、BUG-718
|
||||
- 复发自:无(BUG-301 是故意把 VedAstro 放回前台,本单用缓存同时满足 161 与 301)
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-728 | `western_evidence_packet` 无人读却每轮每域传约 122 KB
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-15
|
||||
- 最近更新:2026-09-16
|
||||
- 影响面:`POST /api/consultation_workflow` 响应体、`consumer_context.western_spectrum`
|
||||
- 用户现象:一轮咨询响应约 52 万字符,其中西洋证据整包约 122 KB;前端解析后丢掉。三个域就是约 366 KB 无效 JSON。
|
||||
- 触发条件:普通聊天每个问题域调用 `/api/consultation_workflow`。
|
||||
- 根因:西洋盘计算是 must-use 层,但整包被无条件放进前台响应。`frontend/src` 零读取点;被读的是 `consumer_context.western_spectrum` 压缩投影。
|
||||
- 修复:默认不把 `western_evidence_packet` 放进咨询工作流响应,计算与 `western_spectrum` 仍保留。MCP、高严谨、显式 `include_western_evidence_packet` / `western_oracle_payload` 仍返回整包。`consultationWorkflowResponseSchema` 为 `.passthrough()`,去掉该键仍能解析。
|
||||
- 验证:`tests/test_vedastro_snapshot_cache.py` 默认省略、显式请求保留、oracle payload 仍返回;`frontend/tests/consultation-workflow-contract.test.ts` 无该键仍 `safeParse` 成功。
|
||||
- 防复发:工作流响应新增大字段前必须有读取点;没有读取点的字段不得进入前台响应。
|
||||
- 相关记录:BUG-727
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## 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
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -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**。校正四单预占 721–726、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,93 @@
|
||||
# PROGRESS · 普通聊天外网证据缓存(2026-09-15 / 2026-09-16)
|
||||
|
||||
工作树:`.worktrees/consultation-external-evidence-cache-20260915`
|
||||
分支:`codex/consultation-external-evidence-cache-20260915`
|
||||
任务书基线:`origin/staging` @ `6b3248bf`(任务书写)
|
||||
开工时远端:`origin/staging` @ `11893c7f973573da78d6dbb48db89cde8b373ecc`
|
||||
预占 BUG:**727 / 728**(基线最大号 720;721–726 为同日其它单预占,未冲突)
|
||||
交付 commit:`d49bd9cd`
|
||||
|
||||
## 串行基线偏离(产品授权)
|
||||
|
||||
`TASK-api-server-decomposition-20260916` **未合入** staging。任务书要求以拆分后的 `scripts/jyotish_api_server.py` 为基线;产品负责人要求本单现在就做。因此:
|
||||
|
||||
- **没有增长** `scripts/jyotish_api_server.py`。新逻辑在 `scripts/vedastro_snapshot_cache.py`、`scripts/vedastro_foreground.py`;主文件只做薄导入、把 submit/join 换成 `start_foreground_vedastro` / `finish_foreground_vedastro`、按需挂 `western_evidence_packet`。
|
||||
- 冻结合同:`wc -l` 口径开工 **11334** 行,交付 **11291** 行(少 43);上限 11063+300=**11363**。
|
||||
|
||||
未改 context-memory 的前端会话文件,未改 session-capacity 的 SQL 迁移。
|
||||
|
||||
## 任务对照
|
||||
|
||||
| 任务 | 状态 | BUG |
|
||||
| --- | --- | --- |
|
||||
| 5.1 盘 + UTC 日期缓存 | 完成 | BUG-727 |
|
||||
| 5.2 超时 cancel + join/budget/workers 成组,`budget ≤ 2×join` | 完成 | BUG-727 |
|
||||
| 5.3 `western_evidence_packet` 按需返回 | 完成 | BUG-728 |
|
||||
| 5.4 staging 单域耗时清单 | 完成(环境缺口:无 staging 登录) | — |
|
||||
| 5.5 BUG-727 / 728 | 完成 | — |
|
||||
|
||||
## 实现要点
|
||||
|
||||
- 缓存键:`sha256(年月日时分秒 + lat + lon + tz + ayanamsa + node + UTC reference_date)`,文件名只有哈希。目录 `scratch/local/vedastro_snapshot_cache`,与 `_api_chart_cache` 分开。
|
||||
- 同日命中:直接用,不 submit。1–7 天:先用旧的(网关包上标 `snapshot_reference_date`),后台刷新只写缓存。`entrypoint=daily_starlanguage` 只接受当天。未命中才走 1.5 s join。
|
||||
- 只缓存 `official_verified` 且带 raw 的包;超时/blocked 不落盘,避免把未交付层标成 executed(BUG-301)。
|
||||
- `_join_foreground_vedastro` 超时 `cancel_event.set()` + `future.cancel()`。budget 默认改为 `2×join`(1.5 s → 3 s),不再默认跑满 8 s。
|
||||
- 非前台路径(报告/高严谨)仍同步跑网关、不受 join 上限,成功后同样写缓存。
|
||||
- 前台响应默认去掉 `western_evidence_packet`,仍算、仍投影 `consumer_context.western_spectrum`。MCP / 高严谨 / `include_western_evidence_packet` / `western_oracle_payload` 仍返回整包。
|
||||
- 前端 `runConsultationWorkflow` 把 `entrypoint` 传给 Python,供 daily 入口拒绝隔日缓存。
|
||||
|
||||
## `western_evidence_packet` 检索
|
||||
|
||||
| 位置 | 处理 |
|
||||
| --- | --- |
|
||||
| `frontend/src` | **零命中**,按「无人读」从咨询响应去掉 |
|
||||
| `scripts/jyotish_api_server.py` | 仍计算;默认不进响应 |
|
||||
| `scripts/vedastro_foreground.py` | `should_include_western_evidence_packet` |
|
||||
| `scripts/western_evidence_packet.py` / `western_chart_engine.py` / `western_oracle_adapter.py` | 保留计算 |
|
||||
| `scripts/unified_consultation_orchestrator.py` | 仍传入做审计日志 |
|
||||
| `scripts/cross_system_arbitrator.py` | 保留 |
|
||||
| `mcp_server.py` | 保留;`surface=skill_mcp` 仍返回整包 |
|
||||
| `tests/` | oracle / MCP / 按需返回的断言保留 |
|
||||
|
||||
## 响应体积
|
||||
|
||||
| | 字符 |
|
||||
| --- | ---: |
|
||||
| 任务书基线(career 同参第二轮) | 523 k,其中 `western_evidence_packet` 122 k |
|
||||
| 本机改后(stub 工作流,默认响应) | 该键不存在 |
|
||||
| 显式 `include_western_evidence_packet` | 键在,体积 ≥ 默认响应 |
|
||||
|
||||
未在 staging 重测 523 k 真包(无登录)。按基线,每域少约 122 k 字符。
|
||||
|
||||
## 测试
|
||||
|
||||
本机无 `G:\Ferti\Jyotisha\.venv`,用 `C:\Users\74082\anaconda3\python.exe`(3.11.7)。任务书写的是 Python 3.13 + swisseph。
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `pytest tests/test_vedastro_snapshot_cache.py` | **10 passed** |
|
||||
| 上项 + 前台赶上/超时/oracle/growth | **18 passed** |
|
||||
| `pytest tests/test_consultation_consumer_context.py tests/test_vedastro_runtime_ops.py tests/test_vedastro_snapshot_cache.py tests/test_api_server_growth_contract.py tests/test_consultation_workflow_domains.py` | **pass**(1 skip:本机未装 vedastro pin) |
|
||||
| `pytest tests/test_api_server_security.py` + 上列相关 | **169 passed / 6 failed / 1 skipped**。6 条失败在 **HEAD 无本单改动时同样失败**(thematic `relationship_strict_evidence` / `月度主状态`;`shadbala` advanced:`timezone inference dependency unavailable`)。不是本单引入。 |
|
||||
| `frontend`:`consultation-workflow-contract` + `consultation-spectrum-parity` | **13 passed / 0 failed** |
|
||||
| `scripts/run_quality_gate.py --profile quick` | **compile 通过**(含新模块)。停在 `interpretation_source_inventory_gate.py`:`ModuleNotFoundError: No module named 'mcp'`(Anaconda 无 FastMCP)。未改依赖。 |
|
||||
|
||||
## 开工预检
|
||||
|
||||
`python scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45`:**fail**。`remote_visibility=verified`,fragment/external engine 扫描通过;`tests/test_preflight_fragment_scan.py` 在本机找不到 `.workbuddy/skills/jyotish-vedic-astrology`(Windows 工作树无该镜像)。属 ERR-007 同类环境,未新开台账。不得把 `.workbuddy` 当运行主仓。
|
||||
|
||||
## 5.4 staging 耗时
|
||||
|
||||
清单:`docs/testing/consultation-external-evidence-cache-20260915.md`。无 staging 登录,**环境缺口**。改前数字用任务书本机表(career 928/865 ms)。改后 staging 两轮 `consultationToolDurationMs` 待产品回填。本单不改三域上限 3。
|
||||
|
||||
## 观察项(本单不修)
|
||||
|
||||
`reference_date` 缺省 UTC 当天,与 UTC+8「今天」早上八点前后会错开一天。
|
||||
|
||||
## 既有断言改动
|
||||
|
||||
| 文件 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| 咨询工作流默认响应 | 总有 `western_evidence_packet` | 默认无该键 | BUG-728 无读取点 |
|
||||
| `_foreground_vedastro_budget_seconds` 默认 | 8 s(2–12) | `min(env, 2×join)`,join 默认 1.5 → budget 3 | 5.2 让步:预算不超过 join 两倍 |
|
||||
| `tests/conftest.py` | 无缓存隔离 | autouse 把 snapshot cache 指到 tmp | 避免测试互相命中磁盘缓存 |
|
||||
@@ -0,0 +1,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 + agentExecutionReceipt)JSON **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,73 @@
|
||||
# PROGRESS · 一轮对话把整份 Case 档案从数据库取 3.4 次(2026-09-15 / 2026-09-16)
|
||||
|
||||
工作树:`.worktrees/rectification-request-dossier-cache-20260915`
|
||||
分支:`codex/rectification-request-dossier-cache-20260915`
|
||||
任务书基线:`6b3248bf`;代码基线 `origin/staging` @ `e4788dfc`(含 failure-attribution `8b982baf`、engine-memoization BUG-721、settled-render BUG-725)
|
||||
本机 Windows。
|
||||
|
||||
Skill **未 bump**。未改 `frontend/src/app/page.tsx`、SQL、轮询间隔、GET 投影形状、计费口径、`docs/tasks/README.md` 状态列。未 push `HEAD:staging`。
|
||||
|
||||
## 开工基线
|
||||
|
||||
| 项 | 数字 |
|
||||
| --- | --- |
|
||||
| `frontend/src/app/page.tsx` | **1879** 行(未改) |
|
||||
| 最大 BUG 号 | **BUG-725**(本单占用 **BUG-726**;任务书预占 726,开工时 722–724 已落库,随后 721/725 也合入 staging) |
|
||||
| `tsc --noEmit` | 收尾 **0 错** |
|
||||
| `npm run lint` | **0 error**(全仓既有 warning;本单文件 0 warning) |
|
||||
|
||||
`frontend/node_modules` 已是指向主仓的 junction,未新建。
|
||||
|
||||
## 任务状态
|
||||
|
||||
| 任务 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 5.1 请求作用域缓存包装器 | 完成 | `withRectificationRequestCache`;写即失效;缓存 in-flight promise |
|
||||
| 5.2 路由入口接线 | 完成 | agent / regenerate / Case GET / repair-exit POST / accept POST 各一处 |
|
||||
| 5.3 前后计数 | 完成 | 见下表;预算断言钉在路由预取 + 证据轮 `≤ 4` |
|
||||
| 5.4 Bug 历史 | 完成 | BUG-726 |
|
||||
|
||||
## 实现要点
|
||||
|
||||
- 新模块 `frontend/src/lib/rectification-agentic/v9/request-cache.ts`。Map 在每次 `withRectificationRequestCache` 的闭包里,不挂模块作用域、不挂 `globalThis`。
|
||||
- 白名单:`get_agentic_rectification_case_dossier`、`get_agentic_rectification_case_compute`。键是 `(fn, p_user_id, p_case_id)`。
|
||||
- 其它 `rpc` 先 `clear` 再转发。Proxy 透传 `.from(...)` 等属性,访问时同样清空。类型用 `as T` 保留 Supabase 客户端形状,**没有为过类型去改业务代码**,也没有收缩成只包 `rpc`。
|
||||
- 调用点零改动。`fakeAccounting` 默认不包缓存(`requestCache` / `RECTIFICATION_REQUEST_CACHE=1` 显式打开);计数导出默认关闭(`countRpc` / `RECTIFICATION_RPC_COUNT=1`)。
|
||||
|
||||
## 改前 / 改后 dossier RPC 计数
|
||||
|
||||
测法:`fakeAccounting` 按实例聚合,只计打过 dossier 或 compute 的场景。改后用 `RECTIFICATION_REQUEST_CACHE=1` 包上同一批测试客户端。既有断言一条未改。
|
||||
|
||||
| 路径 | 场景数 | 改前 dossier(均值) | 改后 dossier(均值) | 改前 compute(均值) | 改后 compute(均值) |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| Agent 轮(`rectification-v9-stream.test.ts`) | 41 | 143(**3.49**) | 143(**3.49**) | 27(0.66) | 27(0.66) |
|
||||
| 点选题(`rectification-answer-choice.test.ts`) | 15 | 31(**2.07**) | 30(**2.00**) | 17(1.13) | 15(1.00) |
|
||||
| 路由预取 + 证据轮(新预算断言) | 1 | 5 | **4** | — | — |
|
||||
|
||||
Agent 轮套件数字没降:这些测试的 fake stream 不执行真实工具,每次 `loadV9CaseDossier` 之间都夹着 skill identity / append / run phase 等非白名单 RPC,按「写即失效」必须重读。生产路径上路由先读档案、再进 `runV9AgentTurn` 再读一次,中间无写,预算断言覆盖这一跳(5→4)。点选题套件 dossier 31→30、compute 17→15。
|
||||
|
||||
## 既有断言改动
|
||||
|
||||
无。stream 50 / answer-choice 34 一条未改仍全绿。
|
||||
|
||||
## 测试
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `tsc --noEmit` | **0 错** |
|
||||
| 本单文件 eslint | **0 error / 0 warning** |
|
||||
| `npm run lint` | **0 error**(全仓既有 warning 同量级) |
|
||||
| `tests/rectification-request-dossier-cache.test.ts` | **8 / 8 绿** |
|
||||
| `rectification-v9-stream.test.ts` | **50 / 50 绿** |
|
||||
| `rectification-answer-choice.test.ts` | **34 / 34 绿** |
|
||||
| `tests/rectification-*.test.ts` + `agentic-rectification-*.test.ts` | **1263 pass / 1 fail / 9 skipped**。唯一失败是既有 `rectification-v9-agent.test.ts` Windows `symlinkSync` EPERM,与本单无关 |
|
||||
| `page.tsx` | 仍 1879 行,未改 |
|
||||
| 全量 `npm test` | `# tests 3197 / # pass 3114 / # fail 69 / # skipped 14`。比 failure-attribution 进度记录的 3189/3104/71/14 多本单 8 条绿;失败仍是无 Docker 的 database-*、部署/Caddy/shell、Windows symlink EPERM。不含本单定向套件 |
|
||||
| `npx next build --webpack` | **Compiled successfully** + TypeScript 37s 过。Collecting page data 死在既有 `/api/consult` skill runtime symlink EPERM。不伪装成 `/` ○ Static 已核过 |
|
||||
|
||||
## 环境缺口
|
||||
|
||||
- 无 Docker:数据库套件红,不伪装成通过。
|
||||
- 无登录态、无 Chrome:浏览器级验收不在本单。
|
||||
- Windows 无 symlink 权限:`rectification-v9-agent.test.ts` 与部分 skill runtime 路径 EPERM。本机 Node 只能建 junction。
|
||||
- `next build`:webpack 编译在本机可过;收集页面数据时 skill runtime symlink 仍可能 EPERM(既有缺口)。不伪装成 `/` ○ Static 已核过。
|
||||
@@ -231,16 +231,19 @@
|
||||
| `TASK-rectification-title-repair-migration-20260915.md` | — | BUG-699 / 704 的数据修补写成了 Node 脚本(要 `SCHEMA_DATABASE_URL`),但 `Migrate Staging Database` 只跑 `migrator` 应用 SQL 迁移、不执行任意脚本——产品没有任何按钮能修自己那批错名字的会话。脚本里本来就是纯 SQL,搬进一次性迁移即可复用现成按钮。生产停在 `7b620c7a`(无 `use-rectification-surface.ts`),where 自然匹配 0 行,是 no-op | 待领取 | `codex/rectification-title-repair-migration-20260915` |
|
||||
| `TASK-staging-dispatch-autofill-sha-20260915.md` | `PROGRESS-staging-dispatch-autofill-sha-20260915.md` | `Migrate Staging Database` 每次都要手抄 40 位 SHA,而那个值恰恰是「最新一个过门禁的 staging 提交」——机器能自己算,查询代码那一步里就有。改成留空自动解析、填了仍走原路径(回滚用),三条安全属性一条不丢。**产品 2026-09-15 明确授权修改该 workflow,执行方不得以 AGENTS.md §2.7 拒改**;生产两个按钮保持手填,那是护栏不是麻烦 | 待验收 | `codex/staging-dispatch-autofill-sha-20260915` |
|
||||
| `TASK-staging-auto-migrate-on-deploy-20260915.md` | `PROGRESS-staging-auto-migrate-on-deploy-20260915.md` | 门禁通过后自动先跑 staging 迁移再部署,不再手点(迁移幂等、无挂起时是 no-op,`db-migrate.mjs --check` 挂起返 3 可用于日志)。今天 `deploy-staging.yml` 完全不提迁移,忘点就让新代码跑在旧 schema 上且无人拦。**产品再次授权改 workflow,范围限 `backend-quality-gate.yml` 的 dispatch 段**;迁移失败必须阻断部署;回滚不自动迁移;生产完全不动。⚠️ 同轮必须把「迁移须对已部署代码向后兼容、破坏性变更拆两轮」写进 AGENTS.md §7.6 | 待验收 | `codex/staging-auto-migrate-on-deploy-20260915` |
|
||||
| `TASK-api-server-decomposition-20260916.md` | `PROGRESS-api-server-decomposition-20260916.md` | **重构单(串行在 qizheng 单之后)**:把业务逻辑搬出 `JyotishAPIHandler`。核心不是行数,是全仓 3 处靠 `JyotishAPIHandler.__new__` 伪造空壳 handler 借方法(`consultation_workflow_service` ×2、`capture_report_blocked_repairs_golden`、`local_accuracy_report`,MCP 也走这条),依赖方向反了、handler 没有 `headers`/`wfile` 随时可炸。四阶段:拆 `__new__` 后门 → 抽 ≥150 行业务方法 → `do_POST`/`do_GET` 改路由表 → 重新冻结行数 baseline(余量 300→50)。纯搬运不改行为,`test_api_server_security.py` 3841 行断言一条不许改。预计 11,314 → 约 9,230 行。BUG 段 710+ | 待领取 | — |
|
||||
| `TASK-api-server-decomposition-20260916.md` | `PROGRESS-api-server-decomposition-20260916.md` | **重构单(串行在 qizheng 单之后;2026-09-15 又加两条前置:C1 外网缓存单先做、freeze-metric-change 先落地)**:把业务逻辑搬出 `JyotishAPIHandler`。核心不是行数,是 3 个文件 **4 处**靠 `JyotishAPIHandler.__new__` 伪造空壳 handler 借方法(`consultation_workflow_service` ×2、`capture_report_blocked_repairs_golden`、`local_accuracy_report`,MCP 也走这条),依赖方向反了、handler 没有 `headers`/`wfile` 随时可炸——实测佐证:**225 个类方法里只有 12 处真的碰 HTTP 上下文**。四阶段:拆 `__new__` 后门 → 抽 ≥150 行业务方法 → `do_POST`/`do_GET` 改路由表 → **阶段 4 已改写**:收尾不再是「行数 baseline + 余量 300→50」(那只是把问题推到三个月后),改成主门 `__new__` 计数必须为 0 + 类方法数不得增长,行数退为粗护栏保持 300 余量。纯搬运不改行为,`test_api_server_security.py` 3841 行断言一条不许改。预计 11,334 → 约 9,230 行。BUG 段 710+ | 待领取 | — |
|
||||
| `TASK-chart-vedastro-decouple-20260915.md` | `PROGRESS-chart-vedastro-decouple-20260915.md` | **P0**:星盘页首屏那一发 `/api/chart` 没传 `skip_vedastro_main_entry_overview`,实测冷算 0.40–0.66 秒里约 0.36 秒是 VedAstro 空转(本机连 endpoint 都没配);生产 env 开着 network + fanout,等于首屏同步等 24 个外部请求 + 3 次领域扫描,而 `chart-view-mapper.ts` / `chart-view-contract.ts` 根本不读这份证据。星历页同端点传了标志,两页策略相反。BUG-718,**复发自 BUG-161**(前台请求不得同步串联可选外部证据)。串行在 chart-page-blocking-open 之后 | 待验收 | `codex/chart-vedastro-decouple-20260915` |
|
||||
| `TASK-vedastro-runtime-ops-20260915.md` | `PROGRESS-vedastro-runtime-ops-20260915.md` | 运行期真相单(与上单并行,文件不重叠;**不得改 `jyotish_api_server.py`**):官方 `vedastro==1.23.25` 其实是 REST 客户端(46 KB,全打 `api.vedastro.org`),且 import 时请求 pypi 并 `pip install --upgrade` 自升级——本机实测 pin 装完一 import 就变 1.23.26,`requirements.txt` 的锁在运行期是假的(BUG-719);无 key 时免费层排队是同步 sleep + 全局锁,24 个请求 ≈ 4.8 分钟堵住前台线程(BUG-720,定级依赖生产 key 是否配置)。生产 env 核对清单在 `docs/testing/vedastro-runtime-20260915.md`,**只能由产品负责人执行**。台账 ERR-107 / ERR-108 | 待验收 | `codex/vedastro-runtime-ops-20260915` |
|
||||
| `TASK-rectification-engine-memoization-20260915.md` | — | **性能单(纯 Python,独占引擎三文件,可并行)**:一次重算 45% 的 CPU 是重复算同一份 Shadbala——`build_candidate_static_context` 每个候选分钟已算过一次却只留哈希、丢掉结果,`_candidate_row` 在「候选 × 事件 × 采样日期」最内层再算 36 遍(实测 2196 次 vs 应 61 次)。过境盘只依赖事件日期却按候选算 2196 次(应 36);鉴别探针一次请求算两遍;`_cached_rows` 是死代码。本机等价实验 3358 → 1604 ms(**快 53%**),`candidate_scores` 与 `decision_receipt` 逐字相同(唯一差异是计时字段)。**只做记忆化,不改算法**;year 精度采满 12 个月**产品 2026-09-15 决定不改、研究单也不立**。BUG 段 721 | 待领取 | — |
|
||||
| `TASK-rectification-failure-attribution-20260915.md` | — | **三处把系统故障说成别的东西(独占 `route.ts`)**:意图分类器两次异常返回的 `null` 与用户真的「说不清」共用一条分支,回一句「我不太确定这句是不是在回答上面的问题」,**用户这句里的经历直接丢弃且不写证据**(BUG-722,采集题分支早已改对、点选题分支没跟上);引擎 429(`ERR_COMPUTE_BUSY` + `Retry-After`)被压成 `engine_request_failed`,不重试不打日志,证据记下了但范围不动、模型照说「记下了」(BUG-723,**复发自 BUG-715**);attempt 210s × 2 = 420s > 路由 `maxDuration` 240s,重试必超预算(BUG-724,**复发自 BUG-059**,BUG-388 的防复发只写了单次尝试)。超时改成整轮一个预算,不砍 attempt 也不提 240。**产品 2026-09-15 决定:意图分类继续用会话选定的贵模型,不新增「工具模型」角色** | 待领取 | — |
|
||||
| `TASK-rectification-settled-render-split-20260915.md` | — | **前端性能单(独占校正会话组件,可并行)**:`rectification-agentic-chat.tsx` 1973 行、`useMemo` 0 个、`memo` 0 个,`messages.map` 内联在组件体里且逐条新建时间轴数组与 choice card,`ChatMessageRow` 无 memo、结算态 Markdown 走没有缓存的 `renderProse`。流式每帧(~60/s)重渲整条会话并重跑每条已结算消息的 Markdown。BUG-473 在本文件只落地了 `stream-frame-buffer`,咨询面的 `SettledMessageList` + `HistoryMessageEntry` 拆分没有跟过来。**零行为变化**;验收必须有按帧驱动的渲染计数断言(照 `home-streaming-render-split.test.ts`)。BUG 段 725 | 待领取 | — |
|
||||
| `TASK-rectification-request-dossier-cache-20260915.md` | — | **低风险单,串行在 failure-attribution 之后(同改 `route.ts`)**:一轮 Agent 对话实测取 3.44 次整份 Case 档案(点选题 2.07 次),全仓约 40 个调用点、请求内零缓存;档案是「最近 50 轮 turns + 全部 evidence + 合成收据」的大 jsonb。做法是包装 `accounting` 客户端做**写即失效**的请求作用域缓存(两个只读投影命中缓存,其余任何 RPC 先清空再转发),**零调用点改动**。不得做成「请求内只读一次」——档案在请求内会变。BUG 段 726 | 待领取 | — |
|
||||
| `TASK-consultation-external-evidence-cache-20260915.md` | — | **普通聊天性能单(Python,串行在 api-server-decomposition 之后)**:每轮每域同步等外网,cProfile 前三名全是 `api.vedastro.org` 的 HTTPS 往返(0.801 + 0.786 + 0.206 s),本地 swisseph 只有 0.022 s。三个护栏数字凑不齐:前台等 1.5 s、后台跑 8 s、线程池只有 2 个 worker,且超时**不 cancel** → 每 4 秒一轮就长期饱和,之后每轮白等再拿 `official_blocked`(BUG-727)。另 `western_evidence_packet` 122 KB 前端零读取点(BUG-728)。**产品定案**:按「出生数据+岁差+交点+UTC 日期」缓存(与引擎 `_official_snapshot_reference_date` 同键,否决自定 TTL),同日 0 等待 / 跨日先用旧的(≤7 天)后台刷新 / `daily_starlanguage` 要求当天 / 冷启动才走 1.5 s。**不许「干脆不调」——那会重开 BUG-301。** 另含 staging 单域耗时实测单(代码注释里的 21 s 与本机 0.5 s 差 40 倍,三域上限就是从它推的)。BUG 段 727–728 | 待领取 | — |
|
||||
| `TASK-consultation-context-memory-20260915.md` | — | **记忆三缺口(TS,可并行)**:历史超预算时从最老整轮丢弃,`droppedCount` 算了却**全仓零读取点**,模型不知道少看了几轮——单条截断有「省略 N 字」标记,整轮丢弃没有(BUG-729,BUG-555 防复发只写了「头部截断」所以漏网);写摘要阈值写死 16,000,历史预算却是 `clamp((窗口−60k)×1.5, 4k, 40k)`,窗口 < **70,667** 时预算低于阈值 → 每轮静默丢(BUG-730,后台上架中等窗口模型即触发);写满时服务端存着摘要,`continueInNewChat` 只带问题不带摘要,而 `context_summary` 根本不在任何会话接口的列里(BUG-731)。**产品定案:静默继承**,且摘要文本永远不许由客户端提供(`chatSessionCreateSchema` 只收来源会话 uuid)。BUG 段 729–731 | 待领取 | — |
|
||||
| `TASK-consultation-session-capacity-20260915.md` | — | **对话上限单(一份迁移,可并行;不碰 route.ts)**:`append_consultation_question` 的 200,000 字符额度里,`thinkingText`(≤4,000) + `thinkingSections`(实测 1,521/2,243/2,977) 占一半以上,而 `techniqueTruth`/`workflowReceipt`/`agentExecutionReceipt` 照样入库却不计入——同一条上限身兼二职且两职都没做好,约 **19 轮** 就「已写满」(200 条那档永远碰不到)。**产品定案:思考文本不计入**,额度只数用户读得到的正文(约 19 → 约 50 轮),另设一条按 `length(elem::text)` 把全部字段算全的物理上限(算式取 1,000,000,写进迁移注释)护住数据库行;两档都返回同一个 `session_full`。保留 advisory lock / 幂等 / 满员拒绝(BUG-464 防复发)。BUG 段 732 | 待领取 | — |
|
||||
| `TASK-consultation-external-evidence-cache-20260915.md` | `PROGRESS-consultation-external-evidence-cache-20260915.md` | **普通聊天性能单(Python;2026-09-15 产品拍板改为排在 api-server-decomposition 之前)**:每轮每域同步等外网,cProfile 前三名全是 `api.vedastro.org` 的 HTTPS 往返(0.801 + 0.786 + 0.206 s),本地 swisseph 只有 0.022 s。三个护栏数字凑不齐:前台等 1.5 s、后台跑 8 s、线程池只有 2 个 worker,且超时**不 cancel** → 每 4 秒一轮就长期饱和,之后每轮白等再拿 `official_blocked`(BUG-727)。另 `western_evidence_packet` 122 KB 前端零读取点(BUG-728)。**产品定案**:按「出生数据+岁差+交点+UTC 日期」缓存(与引擎 `_official_snapshot_reference_date` 同键,否决自定 TTL),同日 0 等待 / 跨日先用旧的(≤7 天)后台刷新 / `daily_starlanguage` 要求当天 / 冷启动才走 1.5 s。**不许「干脆不调」——那会重开 BUG-301。** 另含 staging 单域耗时实测单(代码注释里的 21 s 与本机 0.5 s 差 40 倍,三域上限就是从它推的)。BUG 段 727–728 | 待验收 | `codex/consultation-external-evidence-cache-20260915` |
|
||||
| `TASK-consultation-context-memory-20260915.md` | `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 段 729–731 | 待验收 | `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 | 待领取 | — |
|
||||
|
||||
## 命名与归档
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
|
||||
- 代码基线:`origin/staging` = **`2d7698ea`**(文档树 `6f74aa67`)。开工时以最新 `origin/staging` 为准。
|
||||
- **串行依赖**:本单必须等 `TASK-qizheng-native-chart-20260915` **合入 staging 之后**才开工。那一单独占 `scripts/jyotish_api_server.py` 的写权,并行等于自找冲突。
|
||||
- **2026-09-15 新增串行依赖**:还要等 `TASK-consultation-external-evidence-cache-20260915`(C1)合入。产品拍板 C1 先做——它动的是模块级函数(`execute_consultation_workflow`、`_join_foreground_vedastro`、三个前台 VedAstro 常量),本单动的是 `JyotishAPIHandler` **类**,重叠不大;C1 的用户价值(每轮省掉一次外网等待)高于一次纯搬运。**本单要吸收 C1 的 diff**:开工时 `execute_consultation_workflow` 里会多出走缓存模块的分支,照常搬运即可,不得把它改回去。
|
||||
- **2026-09-15 新增前置**:`TASK-freeze-metric-change-20260915` 决定了阶段 4 的收尾口径(见下方阶段 4 的改写),本单收尾前它必须已合入。
|
||||
- 本单不改任何行为,不新增功能,不动前端。
|
||||
|
||||
## 为什么要做:不是因为文件长,是因为它成了事实上的服务层
|
||||
|
||||
`scripts/jyotish_api_server.py` 现在 **11,314 行**,契约上限 11,363(`tests/test_api_server_growth_contract.py`:baseline 11,063 + 300 bugfix 余量)——**只剩 49 行**。
|
||||
`scripts/jyotish_api_server.py` 现在 **11,334 行**(立单时 11,314,2026-09-15 实测已涨到 11,334),契约上限 11,363(`tests/test_api_server_growth_contract.py`:baseline 11,063 + 300 bugfix 余量)——**只剩 29 行**。
|
||||
|
||||
但真正的问题不是行数,是这个:
|
||||
|
||||
@@ -30,7 +32,7 @@ def build_runtime_evidence_helpers(chart):
|
||||
|
||||
那个声称是「shared consultation workflow boundary for API and MCP callers」的模块,**反过来依赖单体文件**,并且靠 `__new__` 绕过 `BaseHTTPRequestHandler.__init__` 造一个空壳 handler,只为借用它身上的方法。
|
||||
|
||||
全仓一共 **3 处**这样做:
|
||||
全仓 **3 个文件、4 处调用**这样做(下表第一行含两处;收尾断言按 `grep -c` 的 **4** 计,不要按文件数的 3 计):
|
||||
|
||||
| 位置 | 用途 |
|
||||
| --- | --- |
|
||||
@@ -141,17 +143,24 @@ def build_runtime_evidence_helpers(chart):
|
||||
- 404 行为、方法不允许、限流 429、异常映射全部不变。
|
||||
- `do_POST` + `do_GET` 合计 ≤ 90 行。
|
||||
|
||||
### 阶段 4 · 重新冻结行数契约(必做,收尾)
|
||||
### 阶段 4 · 收尾重新冻结(必做,收尾)—— 2026-09-15 改写
|
||||
|
||||
- 把 `tests/test_api_server_growth_contract.py` 的 `JYOTISH_API_SERVER_LINE_COUNT_BASELINE` 更新为**本单收尾时的实际行数**。
|
||||
- 余量从 **300 改为 50**。
|
||||
- 同步更新该文件顶部 docstring 里的日期与说明,以及 `AGENTS.md` §6 里引用的口径。
|
||||
- **不得**在本单之外的任何轮次里调高 baseline。
|
||||
**原方案(行数 baseline 重设 + 余量 300→50)已作废。** 产品 2026-09-15 拍板换口径:余量收到 50 行只是把今天的问题推到三个月后,下一次 bugfix 又会立刻撞墙。新口径由 `TASK-freeze-metric-change-20260915` 落地,本单收尾时按它已经立好的尺子填新基线:
|
||||
|
||||
| 门 | 收尾时要达到 |
|
||||
| --- | --- |
|
||||
| 主门 · `JyotishAPIHandler.__new__` 全仓计数 | **必须为 0**(今天 4)——这是「拆干净了」唯一不可伪造的证据 |
|
||||
| 主门 · `JyotishAPIHandler` 类方法数 | 显著低于 225,并把新值写成不得增长的新基线 |
|
||||
| 粗护栏 · 文件行数 | baseline 重设为收尾实际行数,余量保持 **300**(不再收到 50) |
|
||||
|
||||
- 同步更新 `tests/test_api_server_growth_contract.py` 顶部 docstring 的日期与说明。
|
||||
- **不得**在本单之外的任何轮次里调高任何一条基线。
|
||||
|
||||
**验收标准**
|
||||
|
||||
- `tests/test_api_server_growth_contract.py` 通过,且新 cap = 新实际行数 + 50。
|
||||
- `AGENTS.md` §6 的措辞与新口径一致。
|
||||
- `grep -rn "JyotishAPIHandler.__new__" --include=*.py .`(排除 `skills/*/versions/**`)**零命中**,且该断言已经写进合同测试。
|
||||
- 类方法数新基线已写入合同测试,且人为加一个类方法能让它变红(贴反向验证)。
|
||||
- `AGENTS.md` §6 的措辞与新口径一致(措辞由 freeze-metric-change 单先行落地,本单只填数)。
|
||||
|
||||
## 预期收益(按 AST 实测推算,允许 ±10%)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
- 基线 commit:`origin/staging` @ `6b3248bf`
|
||||
- 执行分支:`codex/consultation-external-evidence-cache-20260915`
|
||||
- 主要落点:`scripts/jyotish_api_server.py`、`scripts/vedastro_service_adapter.py`、`scripts/vedastro_user_entrypoint.py`
|
||||
- **串行在 `TASK-api-server-decomposition-20260916` 之后**:那一单独占 `scripts/jyotish_api_server.py`,本单必须以它合入后的 staging 为基线
|
||||
- **依赖已于 2026-09-15 反转:本单排在 API server 拆解单之前。** 原文写的是「串行在 `TASK-api-server-decomposition-20260916` 之后」,产品拍板改为**本单先做**——理由见 §3.7,拆解单已同步改为串行在本单之后
|
||||
- 与 context-memory 单、session-capacity 单无文件重叠,可并行
|
||||
|
||||
---
|
||||
@@ -69,6 +69,7 @@ cProfile 按 `tottime` 排前五名:
|
||||
4. **后台任务必须可取消、线程池必须可排队。** 现在「超时不 cancel + 只有 2 个 worker」的组合是本单必须解决的部分,不是顺带。
|
||||
5. **`western_evidence_packet` 按需返回,不删计算。** 西洋盘本身仍是 must-use 层之一(被读的是别的字段),本单只是不再把这个整包塞进每一轮的响应。
|
||||
6. **不动并发闸门(默认 2)、不动 `AGENT_TIMEOUT_MS`、不动 `maxDuration`。**
|
||||
7. **本单排在 API server 拆解之前**(2026-09-15 产品拍板,推翻本任务书首版的排序)。依据:本单要改的 `execute_consultation_workflow`、`_join_foreground_vedastro`、那三个常量**全是模块级函数**,而拆解单的核心是把业务逻辑搬出 `JyotishAPIHandler` **类**,两边动的是文件的不同部分;且本单的用户价值(每一轮省掉一次外网等待)远高于一次纯搬运。代价是拆解单将来要吸收本单的 diff,已在那一单里写明。
|
||||
|
||||
## 4. 硬红线
|
||||
|
||||
@@ -76,7 +77,8 @@ cProfile 按 `tottime` 排前五名:
|
||||
2. 赶不上外网时,不得把未交付的官方层标成 `executed`(BUG-301 防复发)。用旧缓存时,证据里必须能看出它是哪一天的。
|
||||
3. 缓存内容里不得落盘姓名、邮箱、用户 ID;出生资料派生值只能以哈希进键,不得明文写进缓存文件名(AGENTS §8)。
|
||||
4. 前台不得再同步串联 overview + snapshot + range scan(BUG-301 防复发,现状已满足,不得回退)。
|
||||
5. `scripts/jyotish_api_server.py` 不得增长(AGENTS §6):新逻辑进 `scripts/` 下的独立模块,主文件只做薄注册。本单又恰好排在拆分单之后,更不能把行数吃回去。
|
||||
5. `scripts/jyotish_api_server.py` 不得增长(AGENTS §6):新逻辑进 `scripts/` 下的独立模块,主文件只做薄注册。**具体到数字**:开工时实测 11,334 行、上限 11,363,**余量只有 29 行**;`JyotishAPIHandler` 有 225 个方法,**本单不得新增任何类方法**。
|
||||
若 `TASK-freeze-metric-change-20260915` 已经合入,主门改为「类方法数不得增长」,行数粗护栏放宽——**以开工当时生效的合同测试为准**,但「不新增类方法、新逻辑进独立模块」这条无论口径怎么换都成立。
|
||||
6. 不得顺手升级依赖、不得顺手修不在本单里的 warning。
|
||||
|
||||
## 5. 任务分解
|
||||
@@ -137,8 +139,8 @@ cProfile 按 `tottime` 排前五名:
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
# 先确认 api-server-decomposition 单已合入 staging
|
||||
git log --oneline origin/staging | head -10
|
||||
# 本单不再等 api-server-decomposition;直接以 origin/staging 为基线
|
||||
git log --oneline origin/staging | head -5
|
||||
git worktree add -b codex/consultation-external-evidence-cache-20260915 \
|
||||
.worktrees/consultation-external-evidence-cache-20260915 origin/staging
|
||||
cd .worktrees/consultation-external-evidence-cache-20260915
|
||||
@@ -164,6 +166,7 @@ cd frontend && npx tsx --test tests/consultation-*.test.ts
|
||||
|
||||
## 9. 不在本单范围
|
||||
|
||||
- `JyotishAPIHandler` 的拆解本身(本单只做薄注册,拆解排在本单之后)
|
||||
- 三域上限 3 → N 的放宽(5.4 只负责量数据,放宽是产品决策)
|
||||
- 并发闸门、`AGENT_TIMEOUT_MS`、`maxDuration`
|
||||
- `reference_date` 缺省用 UTC 当天、而用户的「今天」是 UTC+8,早上八点前后会错开一天——**记为观察项,本单不修**
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# TASK · 把两条增长冻结从「数行数」换成「数耦合」
|
||||
|
||||
- 日期:2026-09-15
|
||||
- 基线 commit:`origin/staging` @ `6b3248bf`
|
||||
- 执行分支:`codex/freeze-metric-change-20260915`
|
||||
- 落点:`AGENTS.md` §6、`tests/test_api_server_growth_contract.py`、新建 `frontend/tests/home-shell-growth-contract.test.ts`、`frontend/tests/chart-view-route.test.ts`(挪走一条断言)
|
||||
- **这一单是后面两单的前置**:`TASK-home-state-lowering-20260915` 与 API server 拆解都需要新口径先生效,否则它们做的事会被旧门禁判红
|
||||
- 与七条在飞分支无文件重叠(它们都不碰这两个合同测试,也都声明「`page.tsx` 一行不许动」)
|
||||
|
||||
---
|
||||
|
||||
## 1. 为什么要换
|
||||
|
||||
两条冻结的余量都用完了:
|
||||
|
||||
| 文件 | 当前 | 上限 | 余量 |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| `frontend/src/app/page.tsx` | 1,951 行 | 1,951 | **0** |
|
||||
| `scripts/jyotish_api_server.py` | 11,334 行 | 11,363 | **29** |
|
||||
|
||||
冻结当初的用意是逼新代码往 hooks / lib / 独立模块里走,第一阶段确实起了作用(`page.tsx` 从 4,766 砍到 1,951)。但顶到线之后它的作用反转:**任何一轮正常改动只要需要在这两个文件里加几行接线就会被打红**,这一轮于是被迫去做一件与本次目标无关的搬运。上一轮的 `TASK-rectification-p0-fix-20260915` 就是这么来的——BUG-705 的十来行接线让 1,951 变 1,964,逻辑本身早就在 lib 里了,超的只是接线。
|
||||
|
||||
而行数从来不是维护成本的来源。两个文件各有一个能直接表达耦合的数字:
|
||||
|
||||
**`page.tsx`**
|
||||
|
||||
| 指标 | BUG-249 当时 | 今天 |
|
||||
| --- | ---: | ---: |
|
||||
| 文件行数 | 4,766 | 1,951 |
|
||||
| `useState` | 56 | **66** |
|
||||
| `useEffect` | 18 | 22 |
|
||||
| `useRef` | — | 41 |
|
||||
| `useCallback` / `useMemo` | 0 / 0 | 0 / 0 |
|
||||
|
||||
**行数砍掉 59%,状态反而从 56 涨到 66。** 抽出去的 hook 是参数式的(§6 现有那句「参数式 hook 内部保持 0 个 React hook」),`useSessionManagement(params)` 开头要解构约 40 个参数——代码搬走了,状态所有权一个都没搬。
|
||||
|
||||
**`scripts/jyotish_api_server.py`**
|
||||
|
||||
| 项 | 数量 |
|
||||
| --- | ---: |
|
||||
| `JyotishAPIHandler` 方法 | **225**(8,219 行,占全文件 72%) |
|
||||
| 模块级函数 | 102(2,981 行) |
|
||||
| `do_POST` / `do_GET` 路径分支 | 78(`do_POST` 单个方法 263 行) |
|
||||
| `self.headers` / `self.wfile` / `self.rfile` / `self.path` 全文件出现次数 | **12** |
|
||||
| `JyotishAPIHandler.__new__` 伪造点 | **4** |
|
||||
|
||||
**225 个方法,只有 12 处真的碰到 HTTP 上下文。** 其余是披着 `self` 外衣的纯函数,这正是那 4 处 `__new__` 后门的成因。
|
||||
|
||||
## 2. 根因
|
||||
|
||||
冻结盯的是「文件有多大」,而维护成本来自「谁依赖谁」。行数是耦合的影子:影子被按住了,本体照长不误——`page.tsx` 的状态数、api server 的类方法数在冻结期内都是**增长**的。
|
||||
|
||||
## 3. 决策记录
|
||||
|
||||
产品 2026-09-15 拍板:**两条冻结的口径都要换**,并明确以下三点。
|
||||
|
||||
1. **主门换成耦合指标,行数降级为粗护栏。** 不是取消行数限制,而是把它 rebaseline 到有余量的位置,让它只拦住「整块新功能塞进来」这种明显情况;真正的门是下面两组数。
|
||||
2. **推翻 `AGENTS.md` §6 现有的「参数式 hook 内部保持 0 个 React hook 的既定模式」。** 这条正是状态搬不走的原因:它要求抽出去的 hook 不持有 React 状态,于是状态只能留在 `Home()`。产品明确授权改掉它——**新口径下,抽出去的 hook 与子组件应当持有自己的状态**。执行方不得以「AGENTS 有这条」为由拒改;本节就是那条红线的推翻记录。
|
||||
3. **`__new__` 计数这一轮只要求「不得增长」,不要求为 0。** 现在是 4 处,收到 0 是 API server 拆解单的验收标准,不是本单的。本单只负责把尺子立起来。
|
||||
|
||||
## 4. 硬红线
|
||||
|
||||
1. **本单不改任何业务代码。** 只改 `AGENTS.md` §6、两个合同测试,以及把一条断言从它现在寄居的文件挪到专用文件。`page.tsx` 与 `jyotish_api_server.py` 一行不许动。
|
||||
2. **不得放宽既有的其它冻结条款。** §6 第三条(不得再手写第二个聊天输入框 / 第二套滚动跟随 / 第二套加载动画)原样保留。
|
||||
3. 行数 rebaseline 的新基线必须取**开工当时的实测值**,并在测试注释里写明取值日期与 `wc -l` 的结果,不得抄本任务书里的数字(七条在飞分支合并后这些数会变)。
|
||||
4. 新的耦合指标基线同理:`useState` / `useRef` / 类方法数 / `__new__` 计数都以开工当时实测为准,只许降不许升。
|
||||
5. 不得顺手升级依赖、不得顺手修不在本单里的 warning。
|
||||
|
||||
## 5. 任务分解
|
||||
|
||||
### 5.1 `page.tsx`:新建专用合同测试
|
||||
|
||||
现在这条断言寄居在 `frontend/tests/chart-view-route.test.ts` 的 `page.tsx does not grow to host the chart page` 里(`assert.ok((pageSource.match(/\n/g) ?? []).length <= 1951)`)——它和星盘页没有关系,只是当时顺手放在那儿。新建 `frontend/tests/home-shell-growth-contract.test.ts`,把增长约束集中过去:
|
||||
|
||||
| 门 | 断言 | 今天的值 |
|
||||
| --- | --- | ---: |
|
||||
| 主门 | `Home()` 里的 `useState` 数不得增长 | 66 |
|
||||
| 主门 | `Home()` 里的 `useRef` 数不得增长(防止把 state 改写成 ref 绕过上面那条) | 41 |
|
||||
| 粗护栏 | 文件行数 ≤ 实测基线 + 150 | 1,951 |
|
||||
|
||||
`chart-view-route.test.ts` 里保留与星盘页真正相关的那半条(`assert.doesNotMatch(pageSource, /chart-page|ChartPageView|\/api\/chart-view/)`),行数断言删除并注明搬到了哪里。
|
||||
|
||||
- 验收:新测试在当前代码上绿;人为在 `page.tsx` 加一个 `useState` 后必须红(执行方在进度记录里贴出这次反向验证,证明尺子会动,不是恒为真)。
|
||||
- 验收:计数方式要能区分 `useState(` 与 `useState<Type>(`(今天 66 这个数就是按 `\buseState[<(]` 数出来的;只按 `useState(` 数会漏掉一半)。
|
||||
- 验收:`npx tsx --test tests/chart-view-route.test.ts` 仍绿,且该文件不再包含行数断言。
|
||||
|
||||
### 5.2 `jyotish_api_server.py`:改 `tests/test_api_server_growth_contract.py`
|
||||
|
||||
现在是 `JYOTISH_API_SERVER_LINE_COUNT_BASELINE = 11063` + 300。改成:
|
||||
|
||||
| 门 | 断言 | 今天的值 |
|
||||
| --- | --- | ---: |
|
||||
| 主门 | `JyotishAPIHandler` 的方法数不得增长 | 225 |
|
||||
| 主门 | 全仓 `JyotishAPIHandler.__new__` 出现次数不得增长 | 4 |
|
||||
| 粗护栏 | 文件行数 ≤ 实测基线 + 300 | 11,334 |
|
||||
|
||||
方法数用缩进匹配(`^ (?:async )?def \w+`)即可,和本任务书 §1 那张表同一种数法。`__new__` 计数扫 `scripts/` 与 `tests/`,把命中文件列进断言失败信息,方便下次一眼看到是谁又开了后门。
|
||||
|
||||
- 验收:新断言在当前代码上绿;人为加一个类方法后必须红(同样贴反向验证)。
|
||||
- 验收:该文件现有的另外三条断言(`AGENTS.md` 里必须出现 `must not grow` / `thinly registered` / 该测试必须在 `CORE_PYTEST_TARGETS` 与快速门里)一条不改仍绿。
|
||||
- 验收:`.venv/bin/python -m pytest tests/test_api_server_growth_contract.py` 通过。
|
||||
|
||||
### 5.3 改 `AGENTS.md` §6
|
||||
|
||||
两条改写,逐字说明新口径:
|
||||
|
||||
- 第一条(api server):把「冻结时行数 + 300 行 bugfix 余量」换成「**类方法数不得增长、`__new__` 伪造点不得增长**,行数是粗护栏」;保留「新端点进独立模块、主文件只做薄注册」这句话——新口径正是在奖励它。
|
||||
- 第二条(`page.tsx`):把「不得再增长」换成「**`Home()` 的 `useState` / `useRef` 数不得增长**」;**删掉「参数式 hook 内部保持 0 个 React hook 的既定模式」**,改成「抽出去的 hook 与子组件应当持有自己的状态;`page.tsx` 只做装配」。
|
||||
|
||||
同时在 §6 里点明这次换口径的理由一句话(行数是耦合的影子),免得下一轮有人以为是放水。
|
||||
|
||||
- 验收:`tests/test_api_server_growth_contract.py` 里那条「`AGENTS.md` 必须包含 must not grow / thinly registered」的断言仍绿(措辞改写时不要把这两个短语弄没了)。
|
||||
- 验收:`frontend/AGENTS.md` 若有重复表述,同轮对齐。
|
||||
|
||||
### 5.4 记录
|
||||
|
||||
本单不产生 Bug 记录(改的是规则,不是缺陷),也不进 `CHANGELOG.md`(无用户可感知变化)。进度记录里必须写清楚:四组基线的实测值、取值日期、以及两次反向验证的结果。
|
||||
|
||||
## 6. 让步顺序
|
||||
|
||||
1. 5.2(api server 口径)最先做——它直接决定 C1 那一单还要不要为 29 行余量拧巴。
|
||||
2. 5.1 次之。
|
||||
3. 5.3 必须和前两条同轮(规则和门禁不许分家,否则下一轮有人按旧 AGENTS 拒改)。
|
||||
4. 5.4 不得砍。
|
||||
|
||||
## 7. 开工前置命令
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git worktree add -b codex/freeze-metric-change-20260915 \
|
||||
.worktrees/freeze-metric-change-20260915 origin/staging
|
||||
cd .worktrees/freeze-metric-change-20260915
|
||||
git status -sb | head -1
|
||||
# 取当时实测基线,不要抄任务书里的数
|
||||
wc -l scripts/jyotish_api_server.py frontend/src/app/page.tsx
|
||||
grep -cE '^ (async )?def ' scripts/jyotish_api_server.py
|
||||
grep -rc 'JyotishAPIHandler.__new__' scripts/ tests/ | grep -v ':0'
|
||||
grep -cE '\buseState[<(]' frontend/src/app/page.tsx
|
||||
grep -cE '\buseRef[<(]' frontend/src/app/page.tsx
|
||||
```
|
||||
|
||||
验收命令:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m pytest tests/test_api_server_growth_contract.py
|
||||
.venv/bin/python scripts/run_quality_gate.py --profile quick
|
||||
cd frontend && npx tsx --test tests/home-shell-growth-contract.test.ts tests/chart-view-route.test.ts
|
||||
npx tsx --test tests/*.test.ts # 与基线逐条比对失败清单
|
||||
```
|
||||
|
||||
## 8. BUG 编号起点
|
||||
|
||||
本单不占 BUG 号。基线 `6b3248bf` 上最大号 **BUG-720**,721–732 已被两轮审计七单预占。
|
||||
|
||||
## 9. 不在本单范围
|
||||
|
||||
- 真的去搬状态或搬方法(见 `TASK-home-state-lowering-20260915.md` 与 API server 拆解单)
|
||||
- `__new__` 收到 0(拆解单的验收标准)
|
||||
- §6 第三条的三个「不得再手写第二套」条款
|
||||
@@ -0,0 +1,159 @@
|
||||
# TASK · 把校正面的 15 个状态从首页搬下去(page.tsx 状态下沉 · 第一簇)
|
||||
|
||||
- 日期:2026-09-15
|
||||
- 基线 commit:`origin/staging` @ `6b3248bf`(开工时以最新 `origin/staging` 为准,见 §7)
|
||||
- 执行分支:`codex/home-state-lowering-20260915`
|
||||
- 落点:`frontend/src/app/page.tsx`、`frontend/src/hooks/use-rectification-surface.ts`、`frontend/src/components/conversational-birth-time-rectification.tsx`(或新建的容器组件)
|
||||
- **串行依赖(三条,缺一不可)**:
|
||||
1. `TASK-freeze-metric-change-20260915` —— 本单要让抽出去的 hook 持有 React 状态,那正是现行 `AGENTS.md §6` 禁止的;必须等新口径生效
|
||||
2. `TASK-consultation-context-memory-20260915`(C2)—— 它在改 `use-session-management.ts` / `use-consultation-run.ts`,与本单同一片状态层
|
||||
3. `TASK-rectification-settled-render-split-20260915`(R3)—— 它在改 `rectification-agentic-chat.tsx`,与本单同一片子树
|
||||
- 规模:一簇状态换个住处。**零行为变化、零文案变化。**
|
||||
|
||||
---
|
||||
|
||||
## 1. 为什么是这一簇
|
||||
|
||||
`page.tsx` 现在 1,951 行,行数上限 1,951,**余量 0**。但行数不是病根:
|
||||
|
||||
| 指标 | BUG-249 当时 | 今天 |
|
||||
| --- | ---: | ---: |
|
||||
| 文件行数 | 4,766 | 1,951 |
|
||||
| `Home()` 的 `useState` | 56 | **66** |
|
||||
| `useEffect` | 18 | 22 |
|
||||
| `useRef` | — | 41 |
|
||||
| `useCallback` / `useMemo` | 0 / 0 | 0 / 0 |
|
||||
|
||||
**行数砍掉 59%,状态反而从 56 涨到 66。** 前几轮拆的是代码不是状态:抽出去的 hook 是参数式的,`useSessionManagement(params)` 开头要解构约 40 个参数,`useRectificationSurface(params)` 约 56 个——状态所有权一个都没搬,每搬一次还要新增一批传参。
|
||||
|
||||
66 个 state 按归属分群:
|
||||
|
||||
| 群 | 个数 |
|
||||
| --- | ---: |
|
||||
| **`rectification*`** | **15** |
|
||||
| `session*` | 10 |
|
||||
| `profile*` | 6 |
|
||||
| `synastry*` | 4 |
|
||||
| `account*` | 4 |
|
||||
| 其余分散 | 27 |
|
||||
|
||||
最大的一簇服务的是一个**已经是 `dynamic()` 懒加载的子树**:`<ConversationalBirthTimeRectification>` 挂着 **24 个 props**,而喂它们的 15 个 state 全住在 `Home()` 里。边界最清楚、收益最大,所以第一簇搬它。
|
||||
|
||||
那 15 个是:
|
||||
|
||||
```
|
||||
rectificationSessionId rectificationShouldStartOpening
|
||||
rectificationCaseId rectificationTurns
|
||||
rectificationHeaderSlot rectificationSnapshot
|
||||
rectificationPendingQuestion rectificationOpeningSessionId
|
||||
rectificationLoading rectificationEntrySummary
|
||||
rectificationMutationPending rectificationEntrySummarySettled
|
||||
rectificationError
|
||||
rectificationErrorSessionId
|
||||
rectificationReadonly
|
||||
```
|
||||
|
||||
## 2. 根因
|
||||
|
||||
`AGENTS.md §6` 现行那句「参数式 hook 内部保持 0 个 React hook 的既定模式」把状态钉死在 `Home()` 里;同一节的行数冻结又不许 `page.tsx` 增长。两条合起来等于「不许再加状态」,而每一轮新功能都要加。`TASK-freeze-metric-change-20260915` 已经拿到产品授权推翻前半句,本单是第一个吃到新口径的轮次。
|
||||
|
||||
## 3. 决策记录
|
||||
|
||||
产品 2026-09-15 拍板:
|
||||
|
||||
1. **`page.tsx` 走「状态下沉」这条路**,不引外部 store、不铺全局 Context Provider。理由:不引新依赖、可以一簇一簇增量做,每搬一簇 `page.tsx` 就真降一截。
|
||||
2. **第一簇搬校正面的 15 个。** 后续 `session*` / `profile*` / `synastry*` 各自另开单,本单不碰。
|
||||
3. **抽出去的 hook 与子组件从此持有自己的状态**(推翻 §6 旧红线,措辞由 freeze-metric-change 单落地)。执行方不得以「AGENTS 说参数式 hook 里不能有 React hook」为由拒改。
|
||||
4. **零行为变化。** 本单不修任何已知交互缺陷,发现了写进进度记录。
|
||||
|
||||
## 4. 硬红线
|
||||
|
||||
1. **不是 15 个都能搬。** 有几个外壳自己要读(例如 `rectificationSessionId` / `rectificationCaseId` 参与决定显示哪个界面、侧栏会话列表也要知道)。**第一步必须逐个分类**:「只服务子树 → 搬下去」「外壳也要读 → 留在外壳,但收敛成一个对象,不再是散装的多个 `useState`」。分类表写进进度记录,不许含糊。
|
||||
2. **零行为变化**是唯一成败判据:进入校正面、退出、刷新、从首页卡片打开、打开历史校正、开场自动触发、只读态、报错态、换模型、采用后回首页——逐条与改前一致。
|
||||
3. `next build` 后 `/` 仍须 `○ Static`;首屏 JS gzip 变化在 ±2 % 内(上次实测 584,413 B)。
|
||||
4. 测试总数不得低于开工时 `origin/staging` 的实测;改任何既有断言必须写「原值 / 新值 / 原因」三栏(AGENTS §7.3)。
|
||||
5. 不得新写第二个聊天输入框、第二套滚动跟随、第二套加载动画(§6 第三条原样有效)。
|
||||
6. 不得顺手升级依赖、不得顺手修不在本单里的 warning。
|
||||
7. **不得改数据库、不得改任何 API 路由。** 本单只动前端状态的住处。
|
||||
|
||||
## 5. 任务分解
|
||||
|
||||
### 5.1 先分类,再动手
|
||||
|
||||
把 15 个逐个归类并写进进度记录:
|
||||
|
||||
| 类别 | 处置 |
|
||||
| --- | --- |
|
||||
| 只服务校正子树 | 搬进子树(`useRectificationSurface` 变成真 hook,或由容器组件持有) |
|
||||
| 外壳也要读 | 留在外壳,但合并成**一个**状态对象,散装 `useState` 数下降 |
|
||||
|
||||
- 验收:分类表在进度记录里,15 个一个不漏,每个写明依据(谁在读它)。
|
||||
|
||||
### 5.2 `useRectificationSurface` 变成真 hook
|
||||
|
||||
现在它要解构约 56 个参数。改成自己 `useState` / `useEffect` 持有第一类状态,对外只暴露子树真正需要的接口;`page.tsx` 侧的传参随之消失。
|
||||
|
||||
- 验收:`useRectificationSurface` 的参数个数显著下降,新值写进进度记录(改前约 56)。
|
||||
- 验收:`<ConversationalBirthTimeRectification>` 的 props 个数下降,新值写进进度记录(改前 24)。
|
||||
|
||||
### 5.3 `Home()` 的状态计数必须真降
|
||||
|
||||
- 验收:`Home()` 的 `useState` 数从 **66** 降到 **≤ 53**(搬走至少 13 个;允许留 2 个在外壳,多留必须逐个说明理由)。
|
||||
- 验收:`useRef` 数不得上升(改前 41)——不许把 state 改写成 ref 来凑数字。
|
||||
- 验收:`frontend/tests/home-shell-growth-contract.test.ts`(由 freeze-metric-change 单建立)在本单收尾时更新为新基线,并贴一次反向验证。
|
||||
|
||||
### 5.4 行为等价证明
|
||||
|
||||
- 验收:改动前后各跑一次全量前端套件,失败清单逐条一致(无 Docker 时数据库套件照常红)。
|
||||
- 验收:`npx tsx --test tests/rectification-*.test.ts` 全绿,断言零改动——如果必须改,按 §4.4 写三栏说明。
|
||||
- 验收:`docs/testing/` 下留一份真人走查清单,覆盖 §4.2 那十条路径(本仓没有浏览器与登录态,这一项只能人工)。
|
||||
|
||||
### 5.5 记录
|
||||
|
||||
本单不产生 Bug 记录(不是缺陷,是结构改造),不进 `CHANGELOG.md`(无用户可感知变化)。若过程中确实改了任何可见样式,必须同提交更新 `frontend/DESIGN.md`(AGENTS §7.5)。
|
||||
|
||||
## 6. 让步顺序
|
||||
|
||||
1. 5.1(分类)**不得砍**——没有分类表就动手,一定会把外壳要读的状态搬下去然后再搬回来。
|
||||
2. 5.2 + 5.3 是主体。
|
||||
3. 5.3 的目标值可以让步(比如只搬到 ≤ 56),但让步幅度和原因必须写进进度记录,**不得静默降低**。
|
||||
4. 5.4 不得砍。
|
||||
5. 5.5 不得砍。
|
||||
|
||||
## 7. 开工前置命令
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
# 三条串行依赖必须都已合入 staging,逐个确认
|
||||
git log --oneline origin/staging | head -20
|
||||
git worktree add -b codex/home-state-lowering-20260915 \
|
||||
.worktrees/home-state-lowering-20260915 origin/staging
|
||||
cd .worktrees/home-state-lowering-20260915/frontend
|
||||
git status -sb | head -1
|
||||
npm ci
|
||||
# 取当时实测基线,不要抄本任务书里的数字(七条在飞分支合并后会变)
|
||||
grep -cE '\buseState[<(]' src/app/page.tsx
|
||||
grep -cE '\buseRef[<(]' src/app/page.tsx
|
||||
grep -oE 'const \[rectification[A-Za-z]+' src/app/page.tsx | wc -l
|
||||
```
|
||||
|
||||
验收命令:
|
||||
|
||||
```bash
|
||||
./node_modules/.bin/tsc --noEmit
|
||||
npm run lint # 0 error
|
||||
npx tsx --test tests/rectification-*.test.ts tests/home-shell-growth-contract.test.ts
|
||||
npx tsx --test tests/*.test.ts # 与基线逐条比对失败清单
|
||||
npm run build # `/` 仍须 ○ Static,首屏 gzip ±2%
|
||||
```
|
||||
|
||||
## 8. BUG 编号起点
|
||||
|
||||
本单不占 BUG 号。基线 `6b3248bf` 上最大号 **BUG-720**,721–732 已被两轮审计七单预占。
|
||||
|
||||
## 9. 不在本单范围
|
||||
|
||||
- `session*` / `profile*` / `synastry*` / `account*` 四簇(各自另开单)
|
||||
- Context Provider 或外部 store(§3.1 已否决这一轮走这两条路)
|
||||
- 校正面自身的任何交互缺陷(本单零行为变化)
|
||||
- API server 拆解(另一条线)
|
||||
@@ -0,0 +1,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(全部既有) |
|
||||
| 全量前端套件 | 基线 3291(fail 31)→ 头 3304(fail 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,41 @@
|
||||
# 真人核对 · staging 单域咨询耗时(外网证据缓存,2026-09-15)
|
||||
|
||||
执行环境无 staging 登录态。下列条目留给有真实账号的人在 staging 走查。本单自动化不替代这些。
|
||||
|
||||
目的:同一张盘、同一个问题域连发两轮,记录 `consultationToolDurationMs`。第一轮冷(可能等外网),第二轮应命中「盘 + UTC 日期」缓存,接近本地计算。
|
||||
|
||||
代码注释里的域上限 3 是按「staging 实测三域共 62.9 秒」反推的,即一域约 21 秒。本机同样调用只要约 0.5 秒。**本单不改三域上限**;若单域已显著低于 21 秒,把数字写进进度记录,放宽另开单。
|
||||
|
||||
## 准备
|
||||
|
||||
1. 打开 `https://staging.jyotisha.chat/login` 并登录(用自己的受控账号)。
|
||||
2. 确认 `https://staging.jyotisha.chat/api/health` 的 `deployment.gitCommit` 等于含本单代码的那次 staging 提交。
|
||||
3. 选一张**当天还没问过**的盘(或等 UTC 零点后再测),避免误用旧缓存。不要把出生资料贴回对话。
|
||||
|
||||
## 冷启动(第一轮)
|
||||
|
||||
4. 新建对话,只问一个域(例如事业)。不要点「深入看今日」。
|
||||
5. 等回答出来。记下:
|
||||
- 主观等待 `____` 秒
|
||||
- 若浏览器/服务端日志能看到 `consultationToolDurationMs`:`____` ms
|
||||
- 技法表「VedAstro 云状态」是 executed 还是 blocked:`____`
|
||||
|
||||
## 同日第二轮(应 0 等待)
|
||||
|
||||
6. 同一条对话里,用同一张盘再问同一域(换一句问法即可)。
|
||||
7. 记下:
|
||||
- 主观等待 `____` 秒
|
||||
- `consultationToolDurationMs`:`____` ms
|
||||
- VedAstro 云状态:`____`
|
||||
8. 第二轮应明显短于第一轮,且不应再出现「空等约 1.5 秒再 blocked」。
|
||||
|
||||
## 跨日(可选,UTC 过零点后)
|
||||
|
||||
9. 第二天用同一张盘再问同一域。第一句应马上有回答(用昨天的证据),证据里能看出是哪一天的快照;下一轮才换成当天。
|
||||
10. 点「深入看今日」时,不得沿用昨天的快照;没有当天的就走原来的 1.5 秒有界等待。
|
||||
|
||||
## 回填后怎么处理
|
||||
|
||||
把 5、7 的两组数字交给验收方,写进 `docs/tasks/PROGRESS-consultation-external-evidence-cache-20260915.md`。若单域已稳定低于 21 秒,结论写成「三域上限可以放宽到 N」的依据,**不要在本单改上限**。
|
||||
|
||||
**在这份清单回填之前,任何人不得声称「staging 单域耗时已验证」。**
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -30,6 +30,7 @@ import { isProductEnabled } from "@/lib/product-access";
|
||||
import { resolveSessionLanguageModel } from "@/lib/model-catalog";
|
||||
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { defaultMessageOrigin, isRectificationMessageOrigin } from "@/lib/rectification-agentic/v9/message-origin";
|
||||
import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter";
|
||||
@@ -150,7 +151,7 @@ export async function POST(request: Request) {
|
||||
let accounting;
|
||||
try {
|
||||
supabase = await createServerSupabaseClient();
|
||||
accounting = createAdminSupabaseClient();
|
||||
accounting = withRectificationRequestCache(createAdminSupabaseClient());
|
||||
} catch (error) {
|
||||
return jsonForSupabaseSetupFailure(error, "POST /api/rectification/agent");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache";
|
||||
import { isProductEnabled } from "@/lib/product-access";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { RectificationToolServiceError } from "@/lib/rectification-agentic/v9/tool-service";
|
||||
@@ -59,7 +60,7 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
let accounting;
|
||||
try {
|
||||
supabase = await createServerSupabaseClient();
|
||||
accounting = createAdminSupabaseClient();
|
||||
accounting = withRectificationRequestCache(createAdminSupabaseClient());
|
||||
} catch (error) {
|
||||
return jsonForSupabaseSetupFailure(error, "POST /api/rectification/cases/[caseId]/candidates/accept");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import {
|
||||
loadV9CaseDossier,
|
||||
@@ -32,7 +33,7 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
let accounting;
|
||||
try {
|
||||
supabase = await createServerSupabaseClient();
|
||||
accounting = createAdminSupabaseClient();
|
||||
accounting = withRectificationRequestCache(createAdminSupabaseClient());
|
||||
} catch (error) {
|
||||
return jsonForSupabaseSetupFailure(error, "POST /api/rectification/cases/[caseId]/repair-exit");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import {
|
||||
loadV9CaseDossier,
|
||||
@@ -29,7 +30,7 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
let accounting;
|
||||
try {
|
||||
supabase = await createServerSupabaseClient();
|
||||
accounting = createAdminSupabaseClient();
|
||||
accounting = withRectificationRequestCache(createAdminSupabaseClient());
|
||||
} catch (error) {
|
||||
return jsonForSupabaseSetupFailure(error, "GET /api/rectification/cases/[caseId]");
|
||||
}
|
||||
|
||||
+2
-1
@@ -9,6 +9,7 @@ import { resolveExactSkillPackage } from "@/lib/skill-package-registry";
|
||||
import { resolveSessionLanguageModel } from "@/lib/model-catalog";
|
||||
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { getRectificationV9RegenerationAgent } from "@/mastra/agentic-rectification";
|
||||
import { logRectificationDeliveryTurn } from "@/lib/rectification-agentic/v9/delivery-turn-guard";
|
||||
@@ -64,7 +65,7 @@ export async function POST(request: Request, context: RouteContext) {
|
||||
let accounting;
|
||||
try {
|
||||
supabase = await createServerSupabaseClient();
|
||||
accounting = createAdminSupabaseClient();
|
||||
accounting = withRectificationRequestCache(createAdminSupabaseClient());
|
||||
} catch (error) {
|
||||
return jsonForSupabaseSetupFailure(error, "POST /api/rectification/cases/[caseId]/turns/[turnId]/regenerate");
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Request-scoped cache for Case read projections.
|
||||
*
|
||||
* `get_agentic_rectification_case_dossier` and
|
||||
* `get_agentic_rectification_case_compute` are unchanged for the life of a
|
||||
* request until this client performs any other RPC or table access. Call sites
|
||||
* keep calling `loadV9CaseDossier` as before; the wrapper sits on the client
|
||||
* object so direct `accounting.rpc(...)` paths are covered too.
|
||||
*
|
||||
* The cache is a closure over a Map created by each `withRectificationRequestCache`
|
||||
* call. It is not stored on `globalThis` or at module scope.
|
||||
*
|
||||
* Other properties are forwarded through a Proxy so `.from(table).update(...)`
|
||||
* (used via `as never` in a few call sites) still works, and accessing them
|
||||
* invalidates the same way a non-projection RPC does.
|
||||
*/
|
||||
import type { RectificationRpcClient } from "./tool-service";
|
||||
|
||||
export const RECTIFICATION_CACHED_PROJECTION_RPCS = [
|
||||
"get_agentic_rectification_case_dossier",
|
||||
"get_agentic_rectification_case_compute",
|
||||
] as const;
|
||||
|
||||
type CachedProjectionRpc = (typeof RECTIFICATION_CACHED_PROJECTION_RPCS)[number];
|
||||
|
||||
const cachedProjectionRpcSet = new Set<string>(RECTIFICATION_CACHED_PROJECTION_RPCS);
|
||||
|
||||
function isCachedProjectionRpc(fn: string): fn is CachedProjectionRpc {
|
||||
return cachedProjectionRpcSet.has(fn);
|
||||
}
|
||||
|
||||
function projectionCacheKey(fn: string, args: Record<string, unknown>): string | null {
|
||||
const userId = args.p_user_id;
|
||||
const caseId = args.p_case_id;
|
||||
if (typeof userId !== "string" || typeof caseId !== "string" || !userId || !caseId) {
|
||||
return null;
|
||||
}
|
||||
return `${fn}\0${userId}\0${caseId}`;
|
||||
}
|
||||
|
||||
type RpcResult = Awaited<ReturnType<RectificationRpcClient["rpc"]>>;
|
||||
|
||||
export function withRectificationRequestCache<T extends RectificationRpcClient>(accounting: T): T {
|
||||
const cache = new Map<string, PromiseLike<RpcResult>>();
|
||||
const originalRpc = accounting.rpc.bind(accounting);
|
||||
|
||||
function invalidate(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
function cachedRpc(fn: string, args: Record<string, unknown>): PromiseLike<RpcResult> {
|
||||
if (!isCachedProjectionRpc(fn)) {
|
||||
invalidate();
|
||||
return originalRpc(fn, args);
|
||||
}
|
||||
const key = projectionCacheKey(fn, args);
|
||||
if (!key) return originalRpc(fn, args);
|
||||
const hit = cache.get(key);
|
||||
if (hit) return hit;
|
||||
const pending = originalRpc(fn, args);
|
||||
cache.set(key, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
return new Proxy(accounting, {
|
||||
get(target, property) {
|
||||
if (property === "rpc") return cachedRpc;
|
||||
invalidate();
|
||||
const value = Reflect.get(target, property);
|
||||
if (typeof value === "function") {
|
||||
return value.bind(target);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
}) as T;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -676,6 +676,7 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
|
||||
signal: context.abortSignal ?? ctx.abortSignal,
|
||||
plan: domainPlan,
|
||||
requestId: ctx.requestId,
|
||||
entrypoint: ctx.entrypoint,
|
||||
});
|
||||
const guarded = applyBirthTimeModeToWorkflowContext(workflow, ctx.consultationMode, {
|
||||
birthTimeSource: ctx.serverChart.truth.birthTimeSource,
|
||||
|
||||
@@ -125,7 +125,13 @@ export function consultationWorkflowFailureCode(error: unknown): ConsultationWor
|
||||
|
||||
export async function runConsultationWorkflow(
|
||||
input: ConsultationInput,
|
||||
options?: { foreground?: boolean; signal?: AbortSignal; plan?: ConsultationPlan; requestId?: string },
|
||||
options?: {
|
||||
foreground?: boolean;
|
||||
signal?: AbortSignal;
|
||||
plan?: ConsultationPlan;
|
||||
requestId?: string;
|
||||
entrypoint?: string;
|
||||
},
|
||||
) {
|
||||
const { entryMode, question, theme, ...workflowInput } = input;
|
||||
const plan = options?.plan ?? createConsultationPlan({ userIntent: question, theme });
|
||||
@@ -139,6 +145,7 @@ export async function runConsultationWorkflow(
|
||||
...workflowInput,
|
||||
// Correlates this call with the API access log when a run fails.
|
||||
...(options?.requestId ? { request_id: options.requestId } : {}),
|
||||
...(options?.entrypoint ? { entrypoint: options.entrypoint } : {}),
|
||||
entry_mode: entryMode,
|
||||
question: workflowRequest.question,
|
||||
question_text: workflowRequest.question,
|
||||
|
||||
@@ -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;
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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\)/);
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 D1–D60", 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" },
|
||||
|
||||
@@ -177,6 +177,19 @@ test("validates and emits non-sensitive workflow and execution receipts", () =>
|
||||
assert.match(route, /streamAgentResponse/);
|
||||
});
|
||||
|
||||
test("consultationWorkflowResponseSchema still parses without western_evidence_packet", () => {
|
||||
const schema = workflow.slice(
|
||||
workflow.indexOf("export const consultationWorkflowResponseSchema"),
|
||||
workflow.indexOf("function record("),
|
||||
);
|
||||
assert.match(schema, /success: z\.boolean\(\)/);
|
||||
assert.match(schema, /chart: z\.record\(z\.unknown\(\)\)/);
|
||||
assert.match(schema, /routing: z\.record\(z\.unknown\(\)\)/);
|
||||
assert.match(schema, /consumer_context:/);
|
||||
assert.match(schema, /\)\.passthrough\(\);/);
|
||||
assert.doesNotMatch(schema, /western_evidence_packet/);
|
||||
});
|
||||
|
||||
test("carries commercial technique truth into the model contract", () => {
|
||||
assert.match(workflow, /technique_truth/);
|
||||
assert.match(mastra, /deterministic_claims_forbidden_for/);
|
||||
|
||||
@@ -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,185 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { withRectificationRequestCache } from "../src/lib/rectification-agentic/v9/request-cache.ts";
|
||||
import { loadV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import { runV9AgentTurn } from "../src/lib/rectification-agentic/v9/agent-run.ts";
|
||||
import { RECTIFICATION_SKILL_NAME } from "../src/lib/rectification-agentic/v9/case-status.ts";
|
||||
import {
|
||||
CASE_COMPUTE_RPC,
|
||||
CASE_DOSSIER_RPC,
|
||||
CASE_ID,
|
||||
SESSION_ID,
|
||||
TURN_ID,
|
||||
USER_ID,
|
||||
countCachedProjectionRpcs,
|
||||
dossierFixture,
|
||||
fakeAccounting,
|
||||
receiptHandlers,
|
||||
} from "./rectification-v9-test-support.ts";
|
||||
|
||||
/** Measured after wrapping: route-style prefetch + one evidence turn. */
|
||||
const CACHED_AGENT_TURN_DOSSIER_BUDGET = 4;
|
||||
|
||||
const WRAPPED_ADMIN_CLIENT = /accounting = withRectificationRequestCache\(createAdminSupabaseClient\(\)\)/;
|
||||
const BARE_ADMIN_CLIENT = /accounting = createAdminSupabaseClient\(\)/;
|
||||
|
||||
const CACHED_ROUTES = [
|
||||
"../src/app/api/rectification/agent/route.ts",
|
||||
"../src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts",
|
||||
"../src/app/api/rectification/cases/[caseId]/route.ts",
|
||||
"../src/app/api/rectification/cases/[caseId]/repair-exit/route.ts",
|
||||
"../src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts",
|
||||
] as const;
|
||||
|
||||
function projectionArgs(caseId = CASE_ID) {
|
||||
return { p_user_id: USER_ID, p_case_id: caseId };
|
||||
}
|
||||
|
||||
function delayedClient(innerCalls: string[], delayMs = 20) {
|
||||
return {
|
||||
rpc(fn: string, args: Record<string, unknown>) {
|
||||
void args;
|
||||
innerCalls.push(fn);
|
||||
return new Promise<{ data: unknown; error: null }>((resolve) => {
|
||||
setTimeout(() => resolve({ data: { ok: fn }, error: null }), delayMs);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("repeated case_dossier reads of the same user and case hit the in-flight cache once", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
[CASE_DOSSIER_RPC]: () => dossierFixture(),
|
||||
}, { requestCache: true });
|
||||
const first = await loadV9CaseDossier(accounting.client, USER_ID, CASE_ID);
|
||||
const second = await loadV9CaseDossier(accounting.client, USER_ID, CASE_ID);
|
||||
assert.equal(first.case.caseId, CASE_ID);
|
||||
assert.equal(second.case.caseId, CASE_ID);
|
||||
assert.deepEqual(countCachedProjectionRpcs(accounting.calls), { dossier: 1, compute: 0 });
|
||||
});
|
||||
|
||||
test("a non-projection rpc invalidates so the next dossier read hits the backing client", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
[CASE_DOSSIER_RPC]: () => dossierFixture(),
|
||||
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
|
||||
}, { requestCache: true });
|
||||
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
|
||||
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
|
||||
await accounting.client.rpc("append_agentic_rectification_turn", projectionArgs());
|
||||
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
|
||||
assert.equal(countCachedProjectionRpcs(accounting.calls).dossier, 2);
|
||||
});
|
||||
|
||||
test("two caseIds do not share a cached projection", async () => {
|
||||
const otherCaseId = "99999999-9999-4999-8999-999999999999";
|
||||
const accounting = fakeAccounting({
|
||||
[CASE_DOSSIER_RPC]: () => dossierFixture(),
|
||||
}, { requestCache: true });
|
||||
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs(CASE_ID));
|
||||
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs(otherCaseId));
|
||||
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs(CASE_ID));
|
||||
assert.equal(
|
||||
accounting.calls.filter((call) => call.fn === CASE_DOSSIER_RPC).length,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test("concurrent duplicate reads share one backing rpc", async () => {
|
||||
const innerCalls: string[] = [];
|
||||
const wrapped = withRectificationRequestCache(delayedClient(innerCalls));
|
||||
const args = projectionArgs();
|
||||
const [first, second] = await Promise.all([
|
||||
wrapped.rpc(CASE_DOSSIER_RPC, args),
|
||||
wrapped.rpc(CASE_DOSSIER_RPC, args),
|
||||
]);
|
||||
assert.deepEqual(first, { data: { ok: CASE_DOSSIER_RPC }, error: null });
|
||||
assert.deepEqual(second, { data: { ok: CASE_DOSSIER_RPC }, error: null });
|
||||
assert.deepEqual(innerCalls, [CASE_DOSSIER_RPC]);
|
||||
});
|
||||
|
||||
test("dossier and compute projections cache independently", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
[CASE_DOSSIER_RPC]: () => dossierFixture(),
|
||||
[CASE_COMPUTE_RPC]: () => ({ case_id: CASE_ID }),
|
||||
}, { requestCache: true });
|
||||
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
|
||||
await accounting.client.rpc(CASE_COMPUTE_RPC, projectionArgs());
|
||||
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
|
||||
await accounting.client.rpc(CASE_COMPUTE_RPC, projectionArgs());
|
||||
assert.deepEqual(countCachedProjectionRpcs(accounting.calls), { dossier: 1, compute: 1 });
|
||||
});
|
||||
|
||||
test(".from passthrough invalidates the projection cache", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
[CASE_DOSSIER_RPC]: () => dossierFixture(),
|
||||
}, { requestCache: true });
|
||||
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
|
||||
await accounting.client.from("profiles").update({ birth_time_source: "approximate" }).eq("id", USER_ID);
|
||||
await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs());
|
||||
assert.equal(countCachedProjectionRpcs(accounting.calls).dossier, 2);
|
||||
assert.equal(accounting.profilePatches.length, 1);
|
||||
});
|
||||
|
||||
test("rectification routes wrap createAdminSupabaseClient once and never use the bare client", () => {
|
||||
for (const relative of CACHED_ROUTES) {
|
||||
const source = readFileSync(new URL(relative, import.meta.url), "utf8");
|
||||
const wraps = source.match(new RegExp(WRAPPED_ADMIN_CLIENT.source, "g")) ?? [];
|
||||
assert.equal(wraps.length, 1, relative);
|
||||
assert.equal(BARE_ADMIN_CLIENT.test(source), false, relative);
|
||||
}
|
||||
});
|
||||
|
||||
test("a cached evidence turn stays within the measured case_dossier budget", async () => {
|
||||
const handlers = {
|
||||
...receiptHandlers,
|
||||
[CASE_DOSSIER_RPC]: () => dossierFixture(),
|
||||
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
|
||||
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
|
||||
};
|
||||
const unwrapped = fakeAccounting(handlers);
|
||||
const wrapped = fakeAccounting(handlers, { requestCache: true });
|
||||
const stream = {
|
||||
stream: async () => ({
|
||||
fullStream: (async function* () {
|
||||
yield { type: "start" };
|
||||
yield { type: "tool-call", payload: { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } } };
|
||||
yield { type: "tool-result", payload: { toolName: "skill" } };
|
||||
yield { type: "tool-call", payload: { toolName: "rectification-read-case", args: { caseId: CASE_ID } } };
|
||||
yield { type: "tool-result", payload: { toolName: "rectification-read-case" } };
|
||||
yield { type: "text-delta", payload: { text: "记下了。" } };
|
||||
yield { type: "finish" };
|
||||
})(),
|
||||
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }),
|
||||
}),
|
||||
getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: "skill" }),
|
||||
};
|
||||
const base = {
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
sessionId: SESSION_ID,
|
||||
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||||
action: "evidence" as const,
|
||||
message: "2016年9月离开家去北京工作",
|
||||
modelName: "gpt-4o-mini",
|
||||
billing: {
|
||||
reserve: async () => ({ success: true, status: 200 }),
|
||||
complete: async () => true,
|
||||
release: async () => true,
|
||||
},
|
||||
emit: () => {},
|
||||
buildAgent: async () => stream as never,
|
||||
};
|
||||
await loadV9CaseDossier(unwrapped.client, USER_ID, CASE_ID);
|
||||
await runV9AgentTurn({ ...base, accounting: unwrapped.client });
|
||||
await loadV9CaseDossier(wrapped.client, USER_ID, CASE_ID);
|
||||
await runV9AgentTurn({ ...base, accounting: wrapped.client });
|
||||
const before = countCachedProjectionRpcs(unwrapped.calls);
|
||||
const after = countCachedProjectionRpcs(wrapped.calls);
|
||||
assert.ok(after.dossier < before.dossier);
|
||||
assert.ok(
|
||||
after.dossier <= CACHED_AGENT_TURN_DOSSIER_BUDGET,
|
||||
`cached dossier rpc ${after.dossier} exceeded budget ${CACHED_AGENT_TURN_DOSSIER_BUDGET} (uncached ${before.dossier})`,
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { RectificationRpcClient } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import { withRectificationRequestCache } from "../src/lib/rectification-agentic/v9/request-cache.ts";
|
||||
|
||||
export const CASE_DOSSIER_RPC = "get_agentic_rectification_case_dossier";
|
||||
export const CASE_COMPUTE_RPC = "get_agentic_rectification_case_compute";
|
||||
|
||||
export type FakeRpcHandler = (
|
||||
fn: string,
|
||||
@@ -17,13 +21,81 @@ export type FakeAccounting = {
|
||||
};
|
||||
};
|
||||
|
||||
export type FakeAccountingOptions = {
|
||||
fallback?: FakeRpcHandler;
|
||||
/**
|
||||
* Default off. When true, wrap `client` with the request-scoped projection
|
||||
* cache. Also enabled by RECTIFICATION_REQUEST_CACHE=1 for before/after counts.
|
||||
*/
|
||||
requestCache?: boolean;
|
||||
/**
|
||||
* Default off. When true, this instance is included in takeFakeAccountingRpcCounts().
|
||||
* Also enabled by RECTIFICATION_RPC_COUNT=1.
|
||||
*/
|
||||
countRpc?: boolean;
|
||||
};
|
||||
|
||||
export type CachedProjectionRpcCounts = {
|
||||
dossier: number;
|
||||
compute: number;
|
||||
};
|
||||
|
||||
export type FakeAccountingRpcCountSummary = {
|
||||
scenes: number;
|
||||
dossier: number;
|
||||
compute: number;
|
||||
dossierMean: number;
|
||||
computeMean: number;
|
||||
};
|
||||
|
||||
const countedAccounting: FakeAccounting[] = [];
|
||||
let rpcCountEnabled = process.env.RECTIFICATION_RPC_COUNT === "1";
|
||||
|
||||
export function countCachedProjectionRpcs(
|
||||
calls: Array<{ fn: string }>,
|
||||
): CachedProjectionRpcCounts {
|
||||
let dossier = 0;
|
||||
let compute = 0;
|
||||
for (const call of calls) {
|
||||
if (call.fn === CASE_DOSSIER_RPC) dossier += 1;
|
||||
else if (call.fn === CASE_COMPUTE_RPC) compute += 1;
|
||||
}
|
||||
return { dossier, compute };
|
||||
}
|
||||
|
||||
export function enableFakeAccountingRpcCount(enabled = true): void {
|
||||
rpcCountEnabled = enabled;
|
||||
if (!enabled) countedAccounting.length = 0;
|
||||
}
|
||||
|
||||
export function takeFakeAccountingRpcCounts(): FakeAccountingRpcCountSummary {
|
||||
const scenes = countedAccounting
|
||||
.map((item) => countCachedProjectionRpcs(item.calls))
|
||||
.filter((row) => row.dossier > 0 || row.compute > 0);
|
||||
const dossier = scenes.reduce((sum, row) => sum + row.dossier, 0);
|
||||
const compute = scenes.reduce((sum, row) => sum + row.compute, 0);
|
||||
return {
|
||||
scenes: scenes.length,
|
||||
dossier,
|
||||
compute,
|
||||
dossierMean: scenes.length === 0 ? 0 : dossier / scenes.length,
|
||||
computeMean: scenes.length === 0 ? 0 : compute / scenes.length,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.env.RECTIFICATION_RPC_COUNT === "1") {
|
||||
process.on("beforeExit", () => {
|
||||
process.stderr.write(`RECTIFICATION_RPC_COUNT ${JSON.stringify(takeFakeAccountingRpcCounts())}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
export function fakeAccounting(
|
||||
handlers: Partial<Record<string, FakeRpcHandler>>,
|
||||
options: { fallback?: FakeRpcHandler } = {},
|
||||
options: FakeAccountingOptions = {},
|
||||
): FakeAccounting {
|
||||
const calls: Array<{ fn: string; args: Record<string, unknown> }> = [];
|
||||
const profilePatches: Array<Record<string, unknown>> = [];
|
||||
const client: FakeAccounting["client"] = {
|
||||
const rawClient: FakeAccounting["client"] = {
|
||||
rpc(fn, args) {
|
||||
calls.push({ fn, args });
|
||||
const handler = handlers[fn] ?? options.fallback;
|
||||
@@ -57,7 +129,12 @@ export function fakeAccounting(
|
||||
};
|
||||
},
|
||||
};
|
||||
return { calls, profilePatches, client };
|
||||
const requestCache = options.requestCache === true
|
||||
|| process.env.RECTIFICATION_REQUEST_CACHE === "1";
|
||||
const client = requestCache ? withRectificationRequestCache(rawClient) : rawClient;
|
||||
const accounting: FakeAccounting = { calls, profilePatches, client };
|
||||
if (options.countRpc === true || rpcCountEnabled) countedAccounting.push(accounting);
|
||||
return accounting;
|
||||
}
|
||||
|
||||
export const CASE_ID = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -52,6 +52,18 @@ try:
|
||||
MIN_SCORING_EVENTS,
|
||||
)
|
||||
from scripts.vedastro_runtime_context import temporary_timeout_seconds
|
||||
from scripts.vedastro_foreground import (
|
||||
_FOREGROUND_VEDASTRO_EXECUTOR,
|
||||
_FOREGROUND_VEDASTRO_WORKERS,
|
||||
_blocked_foreground_vedastro,
|
||||
_foreground_vedastro_budget_seconds,
|
||||
_foreground_vedastro_join_seconds,
|
||||
_join_foreground_vedastro,
|
||||
_run_foreground_vedastro_gateway,
|
||||
finish_foreground_vedastro,
|
||||
should_include_western_evidence_packet,
|
||||
start_foreground_vedastro,
|
||||
)
|
||||
except ModuleNotFoundError: # pragma: no cover - script execution path
|
||||
from local_env import load_local_env
|
||||
from rectification_policy import (
|
||||
@@ -63,6 +75,18 @@ except ModuleNotFoundError: # pragma: no cover - script execution path
|
||||
MIN_SCORING_EVENTS,
|
||||
)
|
||||
from vedastro_runtime_context import temporary_timeout_seconds
|
||||
from vedastro_foreground import (
|
||||
_FOREGROUND_VEDASTRO_EXECUTOR,
|
||||
_FOREGROUND_VEDASTRO_WORKERS,
|
||||
_blocked_foreground_vedastro,
|
||||
_foreground_vedastro_budget_seconds,
|
||||
_foreground_vedastro_join_seconds,
|
||||
_join_foreground_vedastro,
|
||||
_run_foreground_vedastro_gateway,
|
||||
finish_foreground_vedastro,
|
||||
should_include_western_evidence_packet,
|
||||
start_foreground_vedastro,
|
||||
)
|
||||
try:
|
||||
from scripts.unified_consultation_orchestrator import FORMAL_DIVISIONS, UnifiedConsultationOrchestrator
|
||||
except ModuleNotFoundError: # pragma: no cover - script execution path
|
||||
@@ -122,11 +146,6 @@ _ASYNC_JOB_EXECUTOR = ThreadPoolExecutor(
|
||||
max_workers=_ASYNC_JOB_WORKERS,
|
||||
thread_name_prefix='jyotish-job',
|
||||
)
|
||||
_FOREGROUND_VEDASTRO_WORKERS = max(int(os.environ.get('JYOTISH_FOREGROUND_VEDASTRO_WORKERS', '2')), 1)
|
||||
_FOREGROUND_VEDASTRO_EXECUTOR = ThreadPoolExecutor(
|
||||
max_workers=_FOREGROUND_VEDASTRO_WORKERS,
|
||||
thread_name_prefix='jyotish-vedastro-fg',
|
||||
)
|
||||
_ASYNC_JOB_CAPACITY = threading.BoundedSemaphore(_ASYNC_JOB_WORKERS + _ASYNC_JOB_QUEUE_SIZE)
|
||||
_RATE_LIMIT_LOCK = threading.Lock()
|
||||
_RATE_LIMIT_BUCKETS: dict[str, tuple[float, int]] = {}
|
||||
@@ -442,6 +461,8 @@ def _evaluate_vedastro_minute_sensitive_pair(request, times: list[str]) -> dict:
|
||||
try:
|
||||
reports.append(future.result(timeout=remaining))
|
||||
except FuturesTimeoutError:
|
||||
for pending in futures:
|
||||
pending.cancel()
|
||||
return _unevaluated_vedastro_minute_sensitive('vedastro_minute_snapshot_timeout')
|
||||
except Exception:
|
||||
return _unevaluated_vedastro_minute_sensitive('vedastro_minute_snapshot_error')
|
||||
@@ -2036,22 +2057,6 @@ _VEDASTRO_COMPACT_DENY_KEYS = {
|
||||
}
|
||||
|
||||
|
||||
def _foreground_vedastro_budget_seconds() -> float:
|
||||
raw = str(os.environ.get('JYOTISH_FOREGROUND_VEDASTRO_BUDGET_SECONDS', '8')).strip()
|
||||
try:
|
||||
return min(max(float(raw), 2.0), 12.0)
|
||||
except ValueError:
|
||||
return 8.0
|
||||
|
||||
|
||||
def _foreground_vedastro_join_seconds() -> float:
|
||||
raw = str(os.environ.get('JYOTISH_FOREGROUND_VEDASTRO_JOIN_SECONDS', '1.5')).strip()
|
||||
try:
|
||||
return min(max(float(raw), 0.0), 3.0)
|
||||
except ValueError:
|
||||
return 1.5
|
||||
|
||||
|
||||
def _vedastro_natal_signs(raw) -> dict | None:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
@@ -2107,45 +2112,6 @@ def _compact_vedastro_cross_check(vedastro_official: dict, vedastro_gateway: dic
|
||||
return compact
|
||||
|
||||
|
||||
def _blocked_foreground_vedastro(*, reason: str, error_type: str | None = None) -> dict:
|
||||
packet = {
|
||||
'scope': 'vedastro_gateway_run',
|
||||
'status': 'official_blocked',
|
||||
'official_closure_state': 'official_blocked',
|
||||
'official_closure_reason': reason,
|
||||
}
|
||||
if error_type:
|
||||
packet['error_type'] = error_type
|
||||
return packet
|
||||
|
||||
|
||||
def _run_foreground_vedastro_gateway(handler, body: dict) -> dict:
|
||||
try:
|
||||
with temporary_timeout_seconds(_foreground_vedastro_budget_seconds()):
|
||||
result = handler._compute_vedastro_gateway_run(body)
|
||||
except Exception as exc:
|
||||
return _blocked_foreground_vedastro(
|
||||
reason='gateway_invocation_error',
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
return result if isinstance(result, dict) else _blocked_foreground_vedastro(reason='gateway_invocation_error')
|
||||
|
||||
|
||||
def _join_foreground_vedastro(future, *, timeout: float) -> dict:
|
||||
if future is None:
|
||||
return _blocked_foreground_vedastro(reason='foreground_optional_evidence_timeout')
|
||||
try:
|
||||
result = future.result(timeout=timeout)
|
||||
except FuturesTimeoutError:
|
||||
return _blocked_foreground_vedastro(reason='foreground_optional_evidence_timeout')
|
||||
except Exception as exc:
|
||||
return _blocked_foreground_vedastro(
|
||||
reason='gateway_invocation_error',
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
return result if isinstance(result, dict) else _blocked_foreground_vedastro(reason='gateway_invocation_error')
|
||||
|
||||
|
||||
def _blocked_birth_time_sensitivity(*, error_type: str) -> dict:
|
||||
return {
|
||||
'schema': 'jyotish.report_birth_time_sensitivity.v1',
|
||||
@@ -2266,7 +2232,7 @@ def execute_consultation_workflow(
|
||||
western_evidence_packet=western_evidence_packet,
|
||||
blind=bool(body.get('blind') or body.get('blind_technical_mode')),
|
||||
)
|
||||
if western_evidence_packet:
|
||||
if western_evidence_packet and should_include_western_evidence_packet(body, surface=surface):
|
||||
result['western_evidence_packet'] = western_evidence_packet
|
||||
if body.get('return_high_rigor_shape'):
|
||||
result['endpoint'] = 'high_rigor_workflow'
|
||||
@@ -2288,13 +2254,14 @@ def execute_consultation_workflow(
|
||||
rectification = {}
|
||||
muhurta_panchanga = {}
|
||||
computed_chart = bool(chart)
|
||||
foreground_vedastro_future = None
|
||||
if defer_optional_external_evidence:
|
||||
# Overlap a bounded official gateway with local compute. Main-entry
|
||||
# overview / range scan stay skipped so foreground cannot replay BUG-161.
|
||||
foreground_vedastro_future = _FOREGROUND_VEDASTRO_EXECUTOR.submit(
|
||||
_run_foreground_vedastro_gateway, handler, dict(body),
|
||||
)
|
||||
# Overlap a bounded official gateway with local compute. Main-entry
|
||||
# overview / range scan stay skipped so foreground cannot replay BUG-161.
|
||||
# Same-day snapshot cache skips the submit entirely (BUG-727).
|
||||
vedastro_session = start_foreground_vedastro(
|
||||
handler,
|
||||
dict(body),
|
||||
defer_optional_external_evidence=defer_optional_external_evidence,
|
||||
)
|
||||
|
||||
for step in runtime_planner.get('sync_steps', []):
|
||||
if step == 'run_prashna':
|
||||
@@ -2375,21 +2342,8 @@ def execute_consultation_workflow(
|
||||
})
|
||||
executed_steps.append('run_thematic_report')
|
||||
vedastro_gateway = rectification.get('vedastro_gateway') if isinstance(rectification, dict) else None
|
||||
if defer_optional_external_evidence:
|
||||
vedastro_gateway = _join_foreground_vedastro(
|
||||
foreground_vedastro_future,
|
||||
timeout=_foreground_vedastro_join_seconds(),
|
||||
)
|
||||
elif not isinstance(vedastro_gateway, dict):
|
||||
try:
|
||||
vedastro_gateway = handler._compute_vedastro_gateway_run(body)
|
||||
except Exception as exc: # Gateway evidence must not block the local chart result.
|
||||
vedastro_gateway = {
|
||||
'scope': 'vedastro_gateway_run',
|
||||
'status': 'official_blocked',
|
||||
'official_closure_reason': 'gateway_invocation_error',
|
||||
'error_type': type(exc).__name__,
|
||||
}
|
||||
if defer_optional_external_evidence or not isinstance(vedastro_gateway, dict):
|
||||
vedastro_gateway = finish_foreground_vedastro(vedastro_session)
|
||||
|
||||
vedastro_official = handler._high_rigor_vedastro_official_summary(chart)
|
||||
gateway_raw = (
|
||||
@@ -2524,7 +2478,6 @@ def execute_consultation_workflow(
|
||||
'interpretation_source_runtime_coverage': interpretation_source_runtime_coverage,
|
||||
'machine_evidence_packet': machine_evidence_packet,
|
||||
'consumer_context': consumer_context,
|
||||
'western_evidence_packet': western_evidence_packet or {},
|
||||
'real_case_calibration': real_case_calibration,
|
||||
'birth_time_sensitivity': birth_time_sensitivity,
|
||||
'runtime_evidence_log': runtime_evidence_log,
|
||||
@@ -2535,6 +2488,10 @@ def execute_consultation_workflow(
|
||||
'domain-relevant routes execute according to the configured sample/network limits.'
|
||||
),
|
||||
}
|
||||
if should_include_western_evidence_packet(body, surface=surface):
|
||||
result['western_evidence_packet'] = western_evidence_packet or {}
|
||||
if getattr(vedastro_session, 'meta', None):
|
||||
result['vedastro_snapshot_cache'] = vedastro_session.meta
|
||||
if high_rigor:
|
||||
result['high_rigor_external_parity'] = {
|
||||
'status': 'pass' if external_parity_gate.get('status') == 'pass' else 'blocked',
|
||||
|
||||
@@ -75,6 +75,8 @@ CORE_PYTEST_TARGETS = [
|
||||
"tests/test_birth_time_journey_contract.py",
|
||||
# Freeze scripts/jyotish_api_server.py growth; new features must be modules.
|
||||
"tests/test_api_server_growth_contract.py",
|
||||
# Foreground VedAstro snapshot cache + join cancel (BUG-727 / BUG-728).
|
||||
"tests/test_vedastro_snapshot_cache.py",
|
||||
# Native seven-governors adapter and the three read-only chart endpoints.
|
||||
"tests/test_qizheng_chart_engine.py",
|
||||
"tests/test_qizheng_api_productization.py",
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Foreground VedAstro wait, cancel, and snapshot-cache coordination.
|
||||
|
||||
Join, budget, and worker count bound the same wall clock, so they are
|
||||
declared together and must be changed together.
|
||||
|
||||
join: how long the consultation thread waits for the official gateway
|
||||
(default 1.5s, cap 3s).
|
||||
budget: how long a worker may keep running after it has started.
|
||||
workers: process-wide pool. Extra work queues; join still caps the wait.
|
||||
|
||||
A timed-out join must cancel its future so queued work never starts.
|
||||
budget <= k * join (k=2) keeps leftover occupancy of an already-running
|
||||
worker from stretching later requests past the join cap. The previous
|
||||
default budget of 8s with a 1.5s join left cancelled-too-late workers
|
||||
holding the two-thread pool for the rest of the 8s.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from scripts.vedastro_runtime_context import temporary_timeout_seconds
|
||||
from scripts.vedastro_snapshot_cache import (
|
||||
annotate_stale_gateway,
|
||||
is_cacheable_gateway,
|
||||
lookup_snapshot,
|
||||
official_snapshot_reference_date,
|
||||
requires_today_snapshot,
|
||||
store_snapshot,
|
||||
)
|
||||
except ModuleNotFoundError: # pragma: no cover - script execution path
|
||||
from vedastro_runtime_context import temporary_timeout_seconds
|
||||
from vedastro_snapshot_cache import (
|
||||
annotate_stale_gateway,
|
||||
is_cacheable_gateway,
|
||||
lookup_snapshot,
|
||||
official_snapshot_reference_date,
|
||||
requires_today_snapshot,
|
||||
store_snapshot,
|
||||
)
|
||||
|
||||
# --- join / budget / workers (one declaration block; change together) ---
|
||||
FOREGROUND_VEDASTRO_JOIN_SECONDS_DEFAULT = 1.5
|
||||
FOREGROUND_VEDASTRO_JOIN_SECONDS_MAX = 3.0
|
||||
FOREGROUND_VEDASTRO_BUDGET_JOIN_RATIO = 2.0
|
||||
FOREGROUND_VEDASTRO_WORKERS_DEFAULT = 2
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
_FOREGROUND_VEDASTRO_WORKERS = max(
|
||||
int(os.environ.get("JYOTISH_FOREGROUND_VEDASTRO_WORKERS", str(FOREGROUND_VEDASTRO_WORKERS_DEFAULT))),
|
||||
1,
|
||||
)
|
||||
_FOREGROUND_VEDASTRO_EXECUTOR = ThreadPoolExecutor(
|
||||
max_workers=_FOREGROUND_VEDASTRO_WORKERS,
|
||||
thread_name_prefix="jyotish-vedastro-fg",
|
||||
)
|
||||
|
||||
|
||||
def _foreground_vedastro_join_seconds() -> float:
|
||||
raw = str(os.environ.get("JYOTISH_FOREGROUND_VEDASTRO_JOIN_SECONDS", str(FOREGROUND_VEDASTRO_JOIN_SECONDS_DEFAULT))).strip()
|
||||
try:
|
||||
return min(max(float(raw), 0.0), FOREGROUND_VEDASTRO_JOIN_SECONDS_MAX)
|
||||
except ValueError:
|
||||
return FOREGROUND_VEDASTRO_JOIN_SECONDS_DEFAULT
|
||||
|
||||
|
||||
def _foreground_vedastro_budget_seconds() -> float:
|
||||
join = _foreground_vedastro_join_seconds()
|
||||
cap = FOREGROUND_VEDASTRO_BUDGET_JOIN_RATIO * join
|
||||
raw = str(os.environ.get("JYOTISH_FOREGROUND_VEDASTRO_BUDGET_SECONDS", "")).strip()
|
||||
if not raw:
|
||||
return cap
|
||||
try:
|
||||
budget = float(raw)
|
||||
except ValueError:
|
||||
return cap
|
||||
if budget < 0:
|
||||
return cap
|
||||
return min(budget, cap)
|
||||
|
||||
|
||||
def _blocked_foreground_vedastro(*, reason: str, error_type: str | None = None) -> dict:
|
||||
packet = {
|
||||
"scope": "vedastro_gateway_run",
|
||||
"status": "official_blocked",
|
||||
"official_closure_state": "official_blocked",
|
||||
"official_closure_reason": reason,
|
||||
}
|
||||
if error_type:
|
||||
packet["error_type"] = error_type
|
||||
return packet
|
||||
|
||||
|
||||
def _run_foreground_vedastro_gateway(handler, body: dict, cancel_event: threading.Event | None = None) -> dict:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
return _blocked_foreground_vedastro(reason="foreground_optional_evidence_timeout")
|
||||
try:
|
||||
with temporary_timeout_seconds(_foreground_vedastro_budget_seconds()):
|
||||
result = handler._compute_vedastro_gateway_run(body)
|
||||
except Exception as exc:
|
||||
return _blocked_foreground_vedastro(
|
||||
reason="gateway_invocation_error",
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
return result if isinstance(result, dict) else _blocked_foreground_vedastro(reason="gateway_invocation_error")
|
||||
|
||||
|
||||
def _run_foreground_vedastro_gateway_and_store(
|
||||
handler,
|
||||
body: dict,
|
||||
cancel_event: threading.Event | None = None,
|
||||
) -> dict:
|
||||
result = _run_foreground_vedastro_gateway(handler, body, cancel_event)
|
||||
if is_cacheable_gateway(result):
|
||||
try:
|
||||
store_snapshot(body, result)
|
||||
except OSError:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def _join_foreground_vedastro(future, *, timeout: float, cancel_event: threading.Event | None = None) -> dict:
|
||||
if future is None:
|
||||
return _blocked_foreground_vedastro(reason="foreground_optional_evidence_timeout")
|
||||
try:
|
||||
result = future.result(timeout=timeout)
|
||||
except FuturesTimeoutError:
|
||||
if cancel_event is not None:
|
||||
cancel_event.set()
|
||||
future.cancel()
|
||||
return _blocked_foreground_vedastro(reason="foreground_optional_evidence_timeout")
|
||||
except Exception as exc:
|
||||
return _blocked_foreground_vedastro(
|
||||
reason="gateway_invocation_error",
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
return result if isinstance(result, dict) else _blocked_foreground_vedastro(reason="gateway_invocation_error")
|
||||
|
||||
|
||||
def should_include_western_evidence_packet(body: dict | None, *, surface: str = "") -> bool:
|
||||
payload = body if isinstance(body, dict) else {}
|
||||
if payload.get("include_western_evidence_packet") or payload.get("return_western_evidence_packet"):
|
||||
return True
|
||||
if payload.get("return_high_rigor_shape"):
|
||||
return True
|
||||
if isinstance(payload.get("western_oracle_payload"), dict) or isinstance(payload.get("western_astrology_oracle"), dict):
|
||||
return True
|
||||
if isinstance(payload.get("western_evidence_packet"), dict):
|
||||
return True
|
||||
return str(surface or "") in {"skill_mcp", "professional_reading_web", "mcp"}
|
||||
|
||||
|
||||
class ForegroundVedastroSession:
|
||||
def __init__(self, handler, body: dict, *, defer: bool) -> None:
|
||||
self.handler = handler
|
||||
self.body = dict(body)
|
||||
self.defer = bool(defer)
|
||||
self.cached_gateway: dict | None = None
|
||||
self.future = None
|
||||
self.cancel_event = threading.Event()
|
||||
self.meta: dict[str, Any] = {}
|
||||
|
||||
def start(self) -> "ForegroundVedastroSession":
|
||||
today = official_snapshot_reference_date(self.body)
|
||||
hit = lookup_snapshot(self.body, today=today)
|
||||
if hit is not None:
|
||||
record = hit["record"]
|
||||
freshness = hit["freshness"]
|
||||
gateway = record["gateway"]
|
||||
if freshness == "stale":
|
||||
gateway = annotate_stale_gateway(
|
||||
gateway,
|
||||
reference_date=str(record.get("reference_date") or ""),
|
||||
served_on_utc_date=today,
|
||||
)
|
||||
refresh_body = dict(self.body)
|
||||
refresh_body["reference_date"] = today
|
||||
refresh_body["today"] = today
|
||||
refresh_body["current_date"] = today
|
||||
self.future = _FOREGROUND_VEDASTRO_EXECUTOR.submit(
|
||||
_run_foreground_vedastro_gateway_and_store,
|
||||
self.handler,
|
||||
refresh_body,
|
||||
None,
|
||||
)
|
||||
self.cached_gateway = gateway
|
||||
self.meta = {
|
||||
"freshness": freshness,
|
||||
"reference_date": str(record.get("reference_date") or today),
|
||||
"served_on_utc_date": today,
|
||||
"refresh_submitted": freshness == "stale",
|
||||
}
|
||||
return self
|
||||
self.meta = {
|
||||
"freshness": "miss",
|
||||
"reference_date": today,
|
||||
"served_on_utc_date": today,
|
||||
"refresh_submitted": False,
|
||||
"require_today": requires_today_snapshot(self.body),
|
||||
}
|
||||
if self.defer:
|
||||
self.future = _FOREGROUND_VEDASTRO_EXECUTOR.submit(
|
||||
_run_foreground_vedastro_gateway_and_store,
|
||||
self.handler,
|
||||
dict(self.body),
|
||||
self.cancel_event,
|
||||
)
|
||||
return self
|
||||
|
||||
def finish(self) -> dict:
|
||||
if self.cached_gateway is not None:
|
||||
return self.cached_gateway
|
||||
if self.defer:
|
||||
return _join_foreground_vedastro(
|
||||
self.future,
|
||||
timeout=_foreground_vedastro_join_seconds(),
|
||||
cancel_event=self.cancel_event,
|
||||
)
|
||||
try:
|
||||
result = self.handler._compute_vedastro_gateway_run(self.body)
|
||||
except Exception as exc:
|
||||
return _blocked_foreground_vedastro(
|
||||
reason="gateway_invocation_error",
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
if not isinstance(result, dict):
|
||||
return _blocked_foreground_vedastro(reason="gateway_invocation_error")
|
||||
if is_cacheable_gateway(result):
|
||||
try:
|
||||
store_snapshot(self.body, result)
|
||||
except OSError:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def start_foreground_vedastro(handler, body: dict, *, defer_optional_external_evidence: bool) -> ForegroundVedastroSession:
|
||||
return ForegroundVedastroSession(
|
||||
handler,
|
||||
body,
|
||||
defer=defer_optional_external_evidence,
|
||||
).start()
|
||||
|
||||
|
||||
def finish_foreground_vedastro(session: ForegroundVedastroSession | None) -> dict:
|
||||
if session is None:
|
||||
return _blocked_foreground_vedastro(reason="foreground_optional_evidence_timeout")
|
||||
return session.finish()
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Disk cache for foreground VedAstro gateway snapshots.
|
||||
|
||||
Keyed by birth data + ayanamsa + node + UTC reference date. This is a
|
||||
separate store from ``_api_chart_cache`` (BUG-161): different directory
|
||||
and a different key function. Freshness is the reference date itself
|
||||
plus a 7-day stale window — there is no independent TTL environment
|
||||
variable.
|
||||
|
||||
Filenames are sha256 hex only. Cache files must not contain names,
|
||||
emails, or user ids.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CACHE_SCHEMA = "vedastro_snapshot_cache.v1"
|
||||
MAX_STALE_DAYS = 7
|
||||
_SHA256_NAME = re.compile(r"^[0-9a-f]{64}\.json$")
|
||||
_IDENTITY_KEYS = {
|
||||
"name",
|
||||
"email",
|
||||
"user_id",
|
||||
"userid",
|
||||
"user_email",
|
||||
"session_id",
|
||||
"sessionid",
|
||||
"full_name",
|
||||
"display_name",
|
||||
}
|
||||
|
||||
|
||||
def snapshot_cache_dir() -> Path:
|
||||
raw = str(os.environ.get("JYOTISH_VEDASTRO_SNAPSHOT_CACHE_DIR") or "").strip()
|
||||
path = Path(raw) if raw else ROOT / "scratch" / "local" / "vedastro_snapshot_cache"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def official_snapshot_reference_date(case: dict[str, Any] | None) -> str:
|
||||
"""Same date rule as ``vedastro_service_adapter._official_snapshot_reference_date``.
|
||||
|
||||
Duplicated so this module does not import the adapter (and its optional
|
||||
network stack) at cache-lookup time.
|
||||
"""
|
||||
payload = case if isinstance(case, dict) else {}
|
||||
for key in ("reference_date", "today", "transit_date", "current_date"):
|
||||
value = payload.get(key)
|
||||
if not value:
|
||||
continue
|
||||
raw = str(value)[:10]
|
||||
try:
|
||||
datetime.strptime(raw, "%Y-%m-%d")
|
||||
return raw
|
||||
except ValueError:
|
||||
continue
|
||||
return datetime.utcnow().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def requires_today_snapshot(body: dict[str, Any] | None) -> bool:
|
||||
payload = body if isinstance(body, dict) else {}
|
||||
entrypoint = str(payload.get("entrypoint") or payload.get("consult_entrypoint") or "").strip()
|
||||
return entrypoint == "daily_starlanguage"
|
||||
|
||||
|
||||
def snapshot_cache_key(body: dict[str, Any] | None, *, reference_date: str) -> str:
|
||||
payload = body if isinstance(body, dict) else {}
|
||||
ayanamsa = (
|
||||
payload.get("ayanamsa")
|
||||
or payload.get("ayanamsa_name")
|
||||
or payload.get("ayanamsa_policy")
|
||||
or "raman"
|
||||
)
|
||||
node = payload.get("node_mode") or payload.get("nodeMode") or "mean"
|
||||
material = {
|
||||
"year": payload.get("year"),
|
||||
"month": payload.get("month"),
|
||||
"day": payload.get("day"),
|
||||
"hour": payload.get("hour"),
|
||||
"minute": payload.get("minute"),
|
||||
"second": payload.get("second", 0),
|
||||
"lat": payload.get("lat"),
|
||||
"lon": payload.get("lon"),
|
||||
"tz": payload.get("tz"),
|
||||
"ayanamsa_policy": str(ayanamsa).strip().lower(),
|
||||
"node_policy": str(node).strip().lower(),
|
||||
"reference_date": str(reference_date)[:10],
|
||||
}
|
||||
canonical = json.dumps(material, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def snapshot_cache_path(cache_key: str) -> Path:
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", cache_key):
|
||||
raise ValueError("vedastro snapshot cache key must be sha256 hex")
|
||||
return snapshot_cache_dir() / f"{cache_key}.json"
|
||||
|
||||
|
||||
def is_cacheable_gateway(packet: Any) -> bool:
|
||||
if not isinstance(packet, dict):
|
||||
return False
|
||||
state = packet.get("official_closure_state") or packet.get("status")
|
||||
if state != "official_verified":
|
||||
return False
|
||||
raw = packet.get("official_raw_response") or packet.get("raw_response")
|
||||
return bool(raw)
|
||||
|
||||
|
||||
def _strip_identity(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
cleaned = {}
|
||||
for key, item in value.items():
|
||||
if str(key).strip().lower() in _IDENTITY_KEYS:
|
||||
continue
|
||||
cleaned[key] = _strip_identity(item)
|
||||
return cleaned
|
||||
if isinstance(value, list):
|
||||
return [_strip_identity(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _load_record(cache_key: str) -> dict[str, Any] | None:
|
||||
path = snapshot_cache_path(cache_key)
|
||||
if not _SHA256_NAME.match(path.name) or not path.is_file():
|
||||
return None
|
||||
try:
|
||||
record = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(record, dict) or record.get("schema") != CACHE_SCHEMA:
|
||||
return None
|
||||
gateway = record.get("gateway")
|
||||
if not isinstance(gateway, dict):
|
||||
return None
|
||||
reference_date = str(record.get("reference_date") or "")[:10]
|
||||
try:
|
||||
datetime.strptime(reference_date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return None
|
||||
return record
|
||||
|
||||
|
||||
def lookup_snapshot(
|
||||
body: dict[str, Any] | None,
|
||||
*,
|
||||
today: str | None = None,
|
||||
require_today: bool | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
payload = body if isinstance(body, dict) else {}
|
||||
served = today or official_snapshot_reference_date(payload)
|
||||
must_be_today = requires_today_snapshot(payload) if require_today is None else bool(require_today)
|
||||
today_key = snapshot_cache_key(payload, reference_date=served)
|
||||
record = _load_record(today_key)
|
||||
if record is not None:
|
||||
return {
|
||||
"freshness": "fresh",
|
||||
"record": record,
|
||||
"served_on_utc_date": served,
|
||||
}
|
||||
if must_be_today:
|
||||
return None
|
||||
current = datetime.strptime(served, "%Y-%m-%d").date()
|
||||
for days in range(1, MAX_STALE_DAYS + 1):
|
||||
past = (current - timedelta(days=days)).isoformat()
|
||||
record = _load_record(snapshot_cache_key(payload, reference_date=past))
|
||||
if record is None:
|
||||
continue
|
||||
return {
|
||||
"freshness": "stale",
|
||||
"record": record,
|
||||
"served_on_utc_date": served,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def store_snapshot(
|
||||
body: dict[str, Any] | None,
|
||||
gateway: dict[str, Any],
|
||||
*,
|
||||
reference_date: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
if not is_cacheable_gateway(gateway):
|
||||
return None
|
||||
payload = body if isinstance(body, dict) else {}
|
||||
stored_date = (reference_date or official_snapshot_reference_date(payload))[:10]
|
||||
cache_key = snapshot_cache_key(payload, reference_date=stored_date)
|
||||
record = {
|
||||
"schema": CACHE_SCHEMA,
|
||||
"cache_key": cache_key,
|
||||
"reference_date": stored_date,
|
||||
"stored_at": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"gateway": _strip_identity(gateway),
|
||||
}
|
||||
path = snapshot_cache_path(cache_key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
encoded = json.dumps(record, ensure_ascii=False, sort_keys=True)
|
||||
fd, tmp_name = tempfile.mkstemp(prefix=f"{cache_key}.", suffix=".tmp", dir=str(path.parent))
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(encoded)
|
||||
os.replace(tmp_name, path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp_name)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
return record
|
||||
|
||||
|
||||
def annotate_stale_gateway(gateway: dict[str, Any], *, reference_date: str, served_on_utc_date: str) -> dict[str, Any]:
|
||||
"""Shallow copy that shows which day a stale snapshot is from.
|
||||
|
||||
Does not change ``official_closure_state`` / ``status`` — a previously
|
||||
delivered official layer stays whatever it was (BUG-301).
|
||||
"""
|
||||
packet = dict(gateway)
|
||||
packet["snapshot_reference_date"] = reference_date
|
||||
packet["snapshot_freshness"] = "stale"
|
||||
packet["snapshot_served_on_utc_date"] = served_on_utc_date
|
||||
return packet
|
||||
@@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS = str(ROOT / "scripts")
|
||||
WORKBUDDY_SKILL_SCRIPTS = ".workbuddy/skills/jyotish-vedic-astrology/scripts"
|
||||
@@ -38,6 +40,12 @@ def pytest_runtest_setup() -> None:
|
||||
ensure_project_scripts_first()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_vedastro_snapshot_cache(tmp_path_factory, monkeypatch) -> None:
|
||||
cache_dir = tmp_path_factory.mktemp("vedastro_snapshot_cache")
|
||||
monkeypatch.setenv("JYOTISH_VEDASTRO_SNAPSHOT_CACHE_DIR", str(cache_dir))
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(items) -> None:
|
||||
for item in items:
|
||||
if item.fspath.basename == "test_api_server_security.py" and item.name.startswith(SLOW_API_SECURITY_PREFIXES):
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
"""BUG-727 / BUG-728: foreground VedAstro snapshot cache, cancel, and on-demand western packet."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts import jyotish_api_server
|
||||
from scripts import vedastro_foreground as foreground
|
||||
from scripts.jyotish_api_server import JyotishAPIHandler
|
||||
from scripts.vedastro_snapshot_cache import (
|
||||
lookup_snapshot,
|
||||
snapshot_cache_dir,
|
||||
snapshot_cache_key,
|
||||
snapshot_cache_path,
|
||||
store_snapshot,
|
||||
)
|
||||
|
||||
|
||||
def _handler() -> JyotishAPIHandler:
|
||||
return JyotishAPIHandler.__new__(JyotishAPIHandler)
|
||||
|
||||
|
||||
def _verified_gateway(*, request_id: str, reference_date: str) -> dict:
|
||||
return {
|
||||
"scope": "vedastro_gateway_run",
|
||||
"status": "official_verified",
|
||||
"official_closure_state": "official_verified",
|
||||
"official_raw_response": {
|
||||
"request_id": request_id,
|
||||
"natal": {"sun": "Leo", "moon": "Taurus", "ascendant": "Cancer"},
|
||||
"reference_date": reference_date,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _fake_chart() -> dict:
|
||||
return {
|
||||
"success": True,
|
||||
"birth_info": {"date": "1997-08-08", "time": "05:00", "tz": 8},
|
||||
"ascendant": {"lon": 92.0, "sign": "Cancer", "sign_idx": 3},
|
||||
"planets": {},
|
||||
"dasha": {"periods": [{"lord": "Sun", "start": "2026-01-01", "end": "2027-01-01"}]},
|
||||
"modules": {
|
||||
"varga_full": {"D9": {}, "D10": {}},
|
||||
"arudha_padas": {"A10": {}, "UL": {}},
|
||||
"narayana_dasha": {"periods": []},
|
||||
"ashtakavarga": {"sav": []},
|
||||
"kp_cusps": {"houses": []},
|
||||
},
|
||||
"special_lagnas": {"precision": "sunrise_correct"},
|
||||
}
|
||||
|
||||
|
||||
def _stub_local(monkeypatch, handler: JyotishAPIHandler, gateway) -> None:
|
||||
monkeypatch.setattr(handler, "_compute_chart", lambda body: _fake_chart())
|
||||
monkeypatch.setattr(
|
||||
handler,
|
||||
"_compute_rectification_gate",
|
||||
lambda body: {
|
||||
"success": True,
|
||||
"summary": {"recommended_events": [], "warned": [], "disabled": []},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
handler,
|
||||
"_compute_thematic_report",
|
||||
lambda body: {"success": True, "endpoint": "thematic_report", "themes": {}},
|
||||
)
|
||||
monkeypatch.setattr(handler, "_compute_vedastro_gateway_run", gateway)
|
||||
|
||||
|
||||
def _body(*, reference_date: str, extra: dict | None = None) -> dict:
|
||||
payload = {
|
||||
"entry_mode": "direct_chart",
|
||||
"question": "请直接排盘并重点看事业",
|
||||
"theme": ["career"],
|
||||
"year": 1997,
|
||||
"month": 8,
|
||||
"day": 8,
|
||||
"hour": 5,
|
||||
"minute": 0,
|
||||
"lat": 36.420487,
|
||||
"lon": 114.209936,
|
||||
"tz": 8,
|
||||
"reference_date": reference_date,
|
||||
"today": reference_date,
|
||||
"current_date": reference_date,
|
||||
"western_mode": False,
|
||||
"defer_optional_external_evidence": True,
|
||||
}
|
||||
if extra:
|
||||
payload.update(extra)
|
||||
return payload
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def snapshot_cache(tmp_path, monkeypatch):
|
||||
cache_dir = tmp_path / "vedastro_snapshot_cache"
|
||||
monkeypatch.setenv("JYOTISH_VEDASTRO_SNAPSHOT_CACHE_DIR", str(cache_dir))
|
||||
return cache_dir
|
||||
|
||||
|
||||
def test_snapshot_cache_dir_is_not_api_chart_cache_dir(snapshot_cache) -> None:
|
||||
chart_dir = jyotish_api_server._api_chart_cache_dir()
|
||||
snap_dir = snapshot_cache_dir()
|
||||
assert snap_dir.resolve() != chart_dir.resolve()
|
||||
assert snap_dir.name == "vedastro_snapshot_cache"
|
||||
assert chart_dir.name == "api_chart_cache"
|
||||
assert snapshot_cache_key is not jyotish_api_server._api_chart_cache_key
|
||||
|
||||
|
||||
def test_snapshot_cache_key_differs_from_api_chart_cache_key() -> None:
|
||||
body = _body(reference_date="2026-09-15")
|
||||
snap_key = snapshot_cache_key(body, reference_date="2026-09-15")
|
||||
chart_key = jyotish_api_server._api_chart_cache_key(
|
||||
jyotish_api_server._build_api_chart_cache_payload(body)
|
||||
)
|
||||
assert snap_key != chart_key
|
||||
assert len(snap_key) == 64
|
||||
assert "1997" not in snap_key
|
||||
assert "36.420487" not in snap_key
|
||||
assert snapshot_cache_path(snap_key).name == f"{snap_key}.json"
|
||||
|
||||
|
||||
def test_cache_file_strips_identity_and_keeps_hash_filename(snapshot_cache) -> None:
|
||||
body = _body(reference_date="2026-09-15")
|
||||
packet = _verified_gateway(request_id="keep-me", reference_date="2026-09-15")
|
||||
packet["name"] = "Secret Person"
|
||||
packet["email"] = "user@example.com"
|
||||
packet["user_id"] = "usr_123"
|
||||
stored = store_snapshot(body, packet, reference_date="2026-09-15")
|
||||
assert stored is not None
|
||||
path = snapshot_cache_path(stored["cache_key"])
|
||||
text = path.read_text(encoding="utf-8")
|
||||
assert "Secret Person" not in text
|
||||
assert "user@example.com" not in text
|
||||
assert "usr_123" not in text
|
||||
assert "keep-me" in text
|
||||
assert path.name.endswith(".json")
|
||||
assert "1997" not in path.name
|
||||
|
||||
|
||||
def test_second_same_day_consultation_skips_gateway_and_urlopen(snapshot_cache, monkeypatch) -> None:
|
||||
handler = _handler()
|
||||
calls = {"gateway": 0, "urlopen": 0}
|
||||
|
||||
def counting_gateway(body):
|
||||
calls["gateway"] += 1
|
||||
return _verified_gateway(request_id="same-day", reference_date=body.get("reference_date"))
|
||||
|
||||
def counting_urlopen(*_args, **_kwargs):
|
||||
calls["urlopen"] += 1
|
||||
raise AssertionError("urlopen must not run when the gateway is stubbed")
|
||||
|
||||
_stub_local(monkeypatch, handler, counting_gateway)
|
||||
monkeypatch.setattr("urllib.request.urlopen", counting_urlopen)
|
||||
|
||||
body = _body(reference_date="2026-09-15")
|
||||
first = handler._compute_consultation_workflow(body)
|
||||
second = handler._compute_consultation_workflow(body)
|
||||
|
||||
assert calls["gateway"] == 1
|
||||
assert calls["urlopen"] == 0
|
||||
assert first["vedastro_gateway"] == second["vedastro_gateway"]
|
||||
assert first["vedastro_snapshot_cache"]["freshness"] == "miss"
|
||||
assert second["vedastro_snapshot_cache"]["freshness"] == "fresh"
|
||||
|
||||
|
||||
def test_next_day_serves_stale_and_refreshes_without_waiting(snapshot_cache, monkeypatch) -> None:
|
||||
handler = _handler()
|
||||
yesterday = "2026-09-14"
|
||||
today = "2026-09-15"
|
||||
store_snapshot(
|
||||
_body(reference_date=yesterday),
|
||||
_verified_gateway(request_id="yesterday", reference_date=yesterday),
|
||||
reference_date=yesterday,
|
||||
)
|
||||
release = threading.Event()
|
||||
calls = {"gateway": 0}
|
||||
|
||||
def slow_today(body):
|
||||
calls["gateway"] += 1
|
||||
release.wait(timeout=2.0)
|
||||
return _verified_gateway(request_id="today", reference_date=today)
|
||||
|
||||
_stub_local(monkeypatch, handler, slow_today)
|
||||
started = time.monotonic()
|
||||
result = handler._compute_consultation_workflow(_body(reference_date=today))
|
||||
elapsed = time.monotonic() - started
|
||||
release.set()
|
||||
|
||||
assert elapsed < 1.0
|
||||
assert result["vedastro_snapshot_cache"]["freshness"] == "stale"
|
||||
assert result["vedastro_snapshot_cache"]["reference_date"] == yesterday
|
||||
assert result["vedastro_gateway"]["snapshot_reference_date"] == yesterday
|
||||
assert result["vedastro_gateway"]["official_closure_state"] == "official_verified"
|
||||
assert result["vedastro_gateway"]["official_raw_response"]["request_id"] == "yesterday"
|
||||
deadline = time.monotonic() + 2.0
|
||||
while calls["gateway"] < 1 and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert calls["gateway"] >= 1
|
||||
|
||||
|
||||
def test_daily_starlanguage_does_not_eat_yesterday_cache(snapshot_cache, monkeypatch) -> None:
|
||||
handler = _handler()
|
||||
yesterday = "2026-09-14"
|
||||
today = "2026-09-15"
|
||||
store_snapshot(
|
||||
_body(reference_date=yesterday),
|
||||
_verified_gateway(request_id="yesterday", reference_date=yesterday),
|
||||
reference_date=yesterday,
|
||||
)
|
||||
calls = {"gateway": 0}
|
||||
|
||||
def today_gateway(body):
|
||||
calls["gateway"] += 1
|
||||
return _verified_gateway(request_id="today-live", reference_date=today)
|
||||
|
||||
_stub_local(monkeypatch, handler, today_gateway)
|
||||
result = handler._compute_consultation_workflow(
|
||||
_body(reference_date=today, extra={"entrypoint": "daily_starlanguage"})
|
||||
)
|
||||
|
||||
assert calls["gateway"] == 1
|
||||
assert result["vedastro_snapshot_cache"]["freshness"] == "miss"
|
||||
assert result["vedastro_gateway"]["official_raw_response"]["request_id"] == "today-live"
|
||||
assert lookup_snapshot(
|
||||
_body(reference_date=today, extra={"entrypoint": "daily_starlanguage"}),
|
||||
today=today,
|
||||
)["freshness"] == "fresh"
|
||||
|
||||
|
||||
def test_nth_plus_one_wait_stays_within_join_and_queued_work_is_cancelled(
|
||||
snapshot_cache, monkeypatch
|
||||
) -> None:
|
||||
join = 0.2
|
||||
monkeypatch.setenv("JYOTISH_FOREGROUND_VEDASTRO_JOIN_SECONDS", str(join))
|
||||
monkeypatch.setenv("JYOTISH_FOREGROUND_VEDASTRO_BUDGET_SECONDS", "8")
|
||||
handler = _handler()
|
||||
started: list[int] = []
|
||||
lock = threading.Lock()
|
||||
release = threading.Event()
|
||||
|
||||
def slow_gateway(body):
|
||||
with lock:
|
||||
started.append(1)
|
||||
release.wait(timeout=8)
|
||||
return _verified_gateway(request_id="slow", reference_date="2026-09-15")
|
||||
|
||||
_stub_local(monkeypatch, handler, slow_gateway)
|
||||
workers = foreground._FOREGROUND_VEDASTRO_WORKERS
|
||||
body = _body(reference_date="2026-09-15")
|
||||
elapsed: list[float] = []
|
||||
|
||||
def run_one() -> None:
|
||||
t0 = time.monotonic()
|
||||
result = handler._compute_consultation_workflow(body)
|
||||
elapsed.append(time.monotonic() - t0)
|
||||
assert result["vedastro_gateway"]["official_closure_reason"] == "foreground_optional_evidence_timeout"
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=workers + 3) as pool:
|
||||
futs = [pool.submit(run_one) for _ in range(workers + 3)]
|
||||
for fut in futs:
|
||||
fut.result(timeout=8)
|
||||
assert max(elapsed) <= join + 2.5
|
||||
assert len(started) <= workers
|
||||
finally:
|
||||
release.set()
|
||||
|
||||
|
||||
def test_join_budget_workers_declared_together_and_budget_respects_ratio(monkeypatch) -> None:
|
||||
source = Path("scripts/vedastro_foreground.py").read_text(encoding="utf-8")
|
||||
join_idx = source.index("FOREGROUND_VEDASTRO_JOIN_SECONDS_DEFAULT")
|
||||
ratio_idx = source.index("FOREGROUND_VEDASTRO_BUDGET_JOIN_RATIO")
|
||||
workers_idx = source.index("FOREGROUND_VEDASTRO_WORKERS_DEFAULT")
|
||||
span = max(join_idx, ratio_idx, workers_idx) - min(join_idx, ratio_idx, workers_idx)
|
||||
assert span < 400
|
||||
assert "change together" in source
|
||||
|
||||
monkeypatch.delenv("JYOTISH_FOREGROUND_VEDASTRO_JOIN_SECONDS", raising=False)
|
||||
monkeypatch.delenv("JYOTISH_FOREGROUND_VEDASTRO_BUDGET_SECONDS", raising=False)
|
||||
join = foreground._foreground_vedastro_join_seconds()
|
||||
budget = foreground._foreground_vedastro_budget_seconds()
|
||||
assert budget <= foreground.FOREGROUND_VEDASTRO_BUDGET_JOIN_RATIO * join + 1e-9
|
||||
|
||||
monkeypatch.setenv("JYOTISH_FOREGROUND_VEDASTRO_JOIN_SECONDS", "1.5")
|
||||
monkeypatch.setenv("JYOTISH_FOREGROUND_VEDASTRO_BUDGET_SECONDS", "8")
|
||||
assert foreground._foreground_vedastro_budget_seconds() <= 3.0 + 1e-9
|
||||
|
||||
|
||||
def test_default_consultation_omits_western_packet_and_schema_required_keys_remain(
|
||||
snapshot_cache, monkeypatch
|
||||
) -> None:
|
||||
handler = _handler()
|
||||
_stub_local(
|
||||
monkeypatch,
|
||||
handler,
|
||||
lambda body: _verified_gateway(request_id="omit-western", reference_date="2026-09-15"),
|
||||
)
|
||||
omitted = handler._compute_consultation_workflow(_body(reference_date="2026-09-15"))
|
||||
included = handler._compute_consultation_workflow(
|
||||
_body(reference_date="2026-09-15", extra={"include_western_evidence_packet": True})
|
||||
)
|
||||
|
||||
assert "western_evidence_packet" not in omitted
|
||||
assert "western_spectrum" in omitted["consumer_context"]
|
||||
assert omitted["success"] is True
|
||||
assert isinstance(omitted["chart"], dict)
|
||||
assert isinstance(omitted["routing"], dict)
|
||||
assert isinstance(omitted["consumer_context"], dict)
|
||||
assert "western_evidence_packet" in included
|
||||
|
||||
workflow_ts = Path("frontend/src/mastra/consultation-workflow.ts").read_text(encoding="utf-8")
|
||||
schema = workflow_ts.split("export const consultationWorkflowResponseSchema", 1)[1]
|
||||
assert "success: z.boolean()" in schema
|
||||
assert "chart: z.record(z.unknown())" in schema
|
||||
assert "routing: z.record(z.unknown())" in schema
|
||||
assert "consumer_context:" in schema
|
||||
assert ").passthrough()" in schema
|
||||
|
||||
omitted_chars = len(json.dumps(omitted, ensure_ascii=False))
|
||||
included_chars = len(json.dumps(included, ensure_ascii=False))
|
||||
assert included_chars >= omitted_chars
|
||||
|
||||
|
||||
def test_western_oracle_payload_still_returns_packet(snapshot_cache, monkeypatch) -> None:
|
||||
handler = _handler()
|
||||
_stub_local(
|
||||
monkeypatch,
|
||||
handler,
|
||||
lambda body: _verified_gateway(request_id="oracle", reference_date="2026-09-15"),
|
||||
)
|
||||
result = handler._compute_consultation_workflow(
|
||||
_body(
|
||||
reference_date="2026-09-15",
|
||||
extra={
|
||||
"western_oracle_payload": {
|
||||
"source_engine": "kerykeion_external_json",
|
||||
"natal": {"ascendant": "Virgo", "mc": "Gemini"},
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
assert result["western_evidence_packet"]["source_engine"] == "kerykeion_external_json"
|
||||
Reference in New Issue
Block a user