docs(tasks): 普通聊天审计三单(外网缓存 / 记忆三缺口 / 对话上限)

只读审计 origin/staging @ 6b3248bf 后出的三份任务书,含产品 2026-09-15
拍板的三项口径:

- external-evidence-cache(BUG-727/728,Python,串行在 api-server-decomposition
  之后):每轮每域同步等 api.vedastro.org,cProfile 前三名全是 TLS 往返,本地
  swisseph 只占 0.022 s;前台 1.5 s / 后台 8 s / 2 个 worker 且超时不 cancel。
  定案按「出生数据+岁差+交点+UTC 日期」缓存,与引擎内部同键;不许「干脆不调」,
  那会重开 BUG-301。另 western_evidence_packet 122 KB 无人读。
- context-memory(BUG-729/730/731,TS,可并行):droppedCount 算了没人读、
  摘要阈值与预算脱钩(窗口 < 70,667 即每轮静默丢)、写满时不继承摘要。
  定案静默继承,摘要文本永不由客户端提供。
- session-capacity(BUG-732,一份迁移,可并行):200,000 额度一半被思考文本
  吃掉,约 19 轮即写满。定案思考文本不计入,另设按整条 JSON 计的物理上限。

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
Jesse_Chen
2026-09-15 22:47:55 +00:00
co-authored by Claude Opus 5
parent a8d29d1b6c
commit 11893c7f97
4 changed files with 497 additions and 0 deletions
+3
View File
@@ -238,6 +238,9 @@
| `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 段 729731 | 待领取 | — |
| `TASK-consultation-session-capacity-20260915.md` | — | **对话上限单(一份迁移,可并行;不碰 route.ts)**`append_consultation_question` 的 200,000 字符额度里,`thinkingText`(≤4,000) + `thinkingSections`(实测 1,521/2,243/2,977) 占一半以上,而 `techniqueTruth`/`workflowReceipt`/`agentExecutionReceipt` 照样入库却不计入——同一条上限身兼二职且两职都没做好,约 **19 轮** 就「已写满」(200 条那档永远碰不到)。**产品定案:思考文本不计入**,额度只数用户读得到的正文(约 19 → 约 50 轮),另设一条按 `length(elem::text)` 把全部字段算全的物理上限(算式取 1,000,000,写进迁移注释)护住数据库行;两档都返回同一个 `session_full`。保留 advisory lock / 幂等 / 满员拒绝(BUG-464 防复发)。BUG 段 732 | 待领取 | — |
## 命名与归档
@@ -0,0 +1,174 @@
# TASK · 普通聊天的三条记忆缺口:丢了不留痕、阈值追不上预算、写满不继承
- 日期:2026-09-15
- 基线 commit`origin/staging` @ `6b3248bf`
- 执行分支:`codex/consultation-context-memory-20260915`
- 独占文件:`frontend/src/lib/consultation-session-history.ts``frontend/src/lib/session-context-summary.ts``frontend/src/app/api/consult/route.ts``frontend/src/app/api/sessions/route.ts``frontend/src/lib/chat-session-write-contract.ts``frontend/src/hooks/use-session-management.ts``frontend/src/hooks/use-consultation-run.ts`
- 与 external-evidence-cache 单、session-capacity 单无文件重叠,可并行
- 规模:一处留痕、一处算式、一次服务端复制。**不改摘要机制本身。**
---
## 1. 背景:机制是对的,坏在三个边界
BUG-555 建立的检查点摘要机制本身没问题:尾巴超过 16,000 字就在结算后异步让模型写一份 ≤800 汉字的滚动摘要存进 `chat_sessions.context_summary`,下一轮把摘要挂在最后一条用户消息里,历史只带摘要之后的部分。本单不动这套机制,只修它的三个边界。
## 2. 事故实证
### 2.1 BUG-729 · 丢掉整轮历史时不留任何痕迹
`consultation-session-history.ts``consultationHistoryWindow`
```
let kept = clipped;
while (kept.length > 0 && kept.reduce(...) > budget) {
kept = kept.slice(1);
droppedCount += 1;
}
...
return { tail: kept, summaryText, droppedCount };
```
`droppedCount` 被算出来了,然后——`route.ts` 只取 `.tail`
```
const historyWindow = consultationHistoryWindow(chatSession.messages, contextSummary, {...});
const storedHistory = historyWindow.tail;
```
全仓检索 `droppedCount`,**除了定义处没有任何读取点**。对照同一个文件里的 `clipConsultationHistoryText`:单条消息被截断时会追加 `omissionMarker()`——「……(以下省略 N 字,结论已并入会话摘要)」。**单条被砍有标记,整轮被丢没有。** 模型不知道自己少看了几轮,只会当成没发生过;用户问「刚才你说的那个时间」时,模型会以为自己没说过。
这是 BUG-555 防复发那句话的漏网之鱼:原文写的是「咨询历史不得再按固定 12 条 × **头部截断**静默砍结论」,整轮丢弃不属于「头部截断」,所以没被拦住。
### 2.2 BUG-730 · 「什么时候写摘要」和「能带多少历史」是两个各写各的数
| 常量 | 值 | 谁决定 |
| --- | --- | --- |
| `historyBudgetChars(contextWindow)` | `clamp((窗口 60,000) × 1.5, 4,000, 40,000)` 字符 | 跟着模型上下文窗口走 |
| `CONSULTATION_HISTORY_TAIL_MAX_CHARS` | **16,000** 字符(写死) | 与窗口无关 |
`shouldCheckpoint()` 用后者,`consultationHistoryWindow()` 用前者。分界线是 **70,667**
| 上下文窗口 | 历史预算 | 写摘要阈值 | 后果 |
| ---: | ---: | ---: | --- |
| 200 k | 40,000 | 16,000 | 正常,摘要先于丢弃发生 |
| 128 k`DEFAULT_MODEL_CONTEXT_WINDOW`,未配置时) | 40,000 | 16,000 | 正常 |
| 64 k | **6,000** | 16,000 | 预算低于阈值 → 每轮静默丢最老的几轮 |
| 32 k | **4,000**(下限) | 16,000 | 同上,且只剩一两轮历史 |
`context_window` 来自后台模型目录(`model-catalog.ts``PublishedModelRow.context_window`),是运营可配置的值。**后台上架一个中等窗口的模型就会触发**,代码里没有任何断言拦这件事。注意这条与 2.1 是叠加的:预算追不上阈值 → 每轮丢 → 丢了还不留痕。
### 2.3 BUG-731 · 写满时服务端有摘要,新对话一个字不带
`use-consultation-run.ts` 收到 409 `session_full` 后弹提示并给出「开新对话」按钮,走 `use-session-management.ts` 的:
```
async function continueInNewChat(prompt: { question: string; theme: Theme }) {
setSessionFullPrompt(null);
const created = await startNewChat();
if (!created) return;
setDraft(prompt.question);
setDraftTheme(prompt.theme);
}
```
新建一个空会话,只把用户那句问题填回输入框。`context_summary` 既没被带过去,而且 `SESSION_LIST_COLUMNS``sessions/[id]``sessionSelect` **都不含这个列**——前端想带也拿不到。用户在最需要延续的那一刻被要求从零开始。
## 3. 决策记录
产品 2026-09-15 授权本单,并明确:
1. **新对话继承摘要是「静默继承」。** 服务端把 `context_summary` 复制进新会话即可,**不加任何「接着上次聊」之类的提示**,界面看不出差别。产品原话:静默继承。
2. **摘要文本永远不许由客户端提供。** `chatSessionCreateSchema` 现在是 `.strict()` 且不含 `context_summary`,本单不得为了省事把它加进去——那等于开一个把任意文本注入模型提示词的口子。客户端只能传**来源会话的 uuid**,由服务端校验归属后自己复制。
3. **丢历史留痕用的是既有的摘要位置,不是新开一个字段。**`omissionMarker` 同一套说法,不发明第二种表达。
4. **不动摘要机制本身**:不改 800 汉字上限、不改 15 秒 ref 计时器、不改乐观并发写、不改「结算后异步做」。
## 4. 硬红线
1. **摘要超时不得用 `AbortSignal.timeout()`**BUG-555 防复发;BUG-523 的教训是它始终 unref,会让进程提前退出而不是等)。现有 `composedAbortSignal` 里那个 ref 住的 `setTimeout` 不得替换。
2. **溢出重试不得新增用户可见等待态**(BUG-555 防复发)。本单不碰那条路径。
3. **不得把 `messages` 加回列表 GET**BUG-464 防复发)。本单要读 `context_summary`,只能按会话单取,不得顺手把列表接口的列加宽。
4. 摘要与留痕文本里不得出现出生日期、出生时间、出生地、姓名、邮箱(`sanitizeSessionContextSummary` 已在做,本单不得放宽)。
5. `frontend/src/app/page.tsx` 一行不许动(1951/2000AGENTS §6)。
6. 本轮**不改数据库结构**`context_summary` 列已存在),因此不得顺带动迁移(AGENTS §7.6)。
7. 不得顺手升级依赖、不得顺手修不在本单里的 warning。
## 5. 任务分解
### 5.1 丢整轮必须留痕(BUG-729
`consultationHistoryWindow``droppedCount > 0` 时,在交给模型的摘要位置追加一句与 `omissionMarker` 同风格的说明(例如「更早的 N 轮问答已并入上面的会话摘要」)。落点在 `consultationUserTurnContent``summaryText` 段,`route.ts``droppedCount` 传进去。没有摘要却发生了丢弃时(见 5.2,理论上应被消除)也必须留痕,措辞要诚实——那种情况下结论**没有**并入摘要。
- 验收:新增断言——构造一段超预算历史,断言模型可见文本里出现丢弃说明,且句中的轮数等于 `droppedCount`
- 验收:`droppedCount === 0` 时不得出现这句话(不许无条件加)。
- 验收:源码合同断言 `route.ts` 读取了 `droppedCount`(防止再次只取 `.tail`)。
### 5.2 写摘要的阈值跟着预算走(BUG-730)
`CONSULTATION_HISTORY_TAIL_MAX_CHARS` 从写死常量改成由 `historyBudgetChars()` 派生的函数(取预算的一个比例,比例写成具名常量并说明依据),并加一条**断言**:任何合法上下文窗口下,`阈值 < 预算`
- 验收:新增表驱动测试,覆盖 200 k / 128 k / 64 k / 32 k / null 五种窗口,逐条断言 `checkpointThreshold(w) < historyBudgetChars(w)`
- 验收:128 k 下的行为与改前逐字一致(阈值仍应落在 16,000 附近;若比例导致它变化,必须在进度记录里写「原值 / 新值 / 原因」三栏)。
- 验收:`shouldCheckpoint``tailCharCount` 的调用方全部改用新函数,没有残留的字面量 16,000。
### 5.3 写满时静默继承摘要(BUG-731)
三处小改,按此顺序:
1. `chatSessionCreateSchema` 增加可选字段 `continued_from_session_id: z.string().uuid().optional()`(保持 `.strict()`**不加 `context_summary`**)。
2. `POST /api/sessions`:拿到该字段后,用当前用户身份读源会话的 `context_summary``.eq("user_id", user.id)`),只有读到才写进新行;读不到、不属于该用户、或为空都静默跳过,不报错。
3. `continueInNewChat` 把当前会话 id 传给 `startNewChat`,由它带进创建请求。
界面不得出现任何新提示(产品定的静默继承)。
- 验收:新增合同测试——带 `continued_from_session_id` 创建会话时,新行的 `context_summary` 等于源会话的;源会话属于**别的用户**时新行为 null 且接口仍返回 201。
- 验收:源码合同断言 `chatSessionCreateSchema` 里没有 `context_summary` 字段(拒绝客户端提供摘要文本)。
- 验收:UI 断言——`session_full` 后点「开新对话」,界面上不出现任何新增提示文案。
### 5.4 三条 Bug 历史
同一变更内写进 `docs/BUG_HISTORY.md`
- **BUG-729**:关联 **BUG-555**,写明它的防复发只写了「头部截断」,整轮丢弃不在字面里所以没拦住。防复发升级为:**咨询历史任何形式的丢弃(截断单条、丢整轮)都必须在模型可见文本里留痕。**
- **BUG-730**:关联 BUG-555。防复发:**触发摘要的阈值必须由历史预算派生,并由一条断言钉死「阈值 < 预算」在所有合法上下文窗口下成立。**
- **BUG-731**:关联 BUG-464`session_full` 的来源)、BUG-555。防复发:**会话满员后的「开新对话」出口必须由服务端继承 `context_summary`;摘要文本任何时候都不得由客户端提供。**
## 6. 让步顺序
1. 5.2(阈值跟预算)最先做——它是另外两条的上游,做完之后 5.1 的触发面会小很多。
2. 5.1 次之,改动最小。
3. 5.3 最后,它跨的文件最多。砍了要在进度记录里写明,并说明用户仍会在写满时丢掉记忆。
4. 5.4 不得砍。
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/consultation-context-memory-20260915 \
.worktrees/consultation-context-memory-20260915 origin/staging
cd .worktrees/consultation-context-memory-20260915/frontend
git status -sb | head -1
npm ci
```
验收命令:
```bash
./node_modules/.bin/tsc --noEmit
npm run lint # 0 error
npx tsx --test tests/consultation-context.test.ts tests/consultation-*.test.ts \
tests/chat-session-*.test.ts tests/session-*.test.ts
npx tsx --test tests/*.test.ts # 与基线逐条比对失败清单(无 Docker 时数据库套件照常红)
npm run build # `/` 仍须 ○ Static,首屏 gzip ±2%
```
## 8. BUG 编号起点
基线 `6b3248bf` 上最大号 **BUG-720**;校正四单预占 **721726**external-evidence-cache 单预占 **727 / 728**。本单预占 **BUG-729 / 730 / 731**。开工时核对实际最大号,冲突顺延并写进进度记录。
## 9. 不在本单范围
- 摘要机制本身(800 汉字上限、15 秒计时器、乐观并发、结算后异步)
- 对话字符上限与思考文本的计入口径(见 `TASK-consultation-session-capacity-20260915.md`
- 外网证据缓存(见 `TASK-consultation-external-evidence-cache-20260915.md`
- 会话列表 / 详情接口的列宽(BUG-464 红线)
@@ -0,0 +1,170 @@
# TASK · 普通聊天每一轮都在同步等外网,本地计算只占 3%
- 日期:2026-09-15
- 基线 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 为基线
- 与 context-memory 单、session-capacity 单无文件重叠,可并行
---
## 1. 事故实证
普通聊天每发一条消息,每个问题域调一次 `POST /api/consultation_workflow`。本机(Python 3.13 + swisseph)热身后实测:
| 问题域 | 第 1 次 | 第 2 次(同参数) | 响应体积 |
| --- | ---: | ---: | ---: |
| career | 928 ms | 865 ms | 523 k 字符 |
| wealth | 493 ms | 490 ms | 523 k 字符 |
| marriage | 493 ms | 492 ms | 518 k 字符 |
| general | 491 ms | 493 ms | 542 k 字符 |
| timing | 523 ms | 507 ms | 550 k 字符 |
cProfile 按 `tottime` 排前五名:
| | 耗时 |
| --- | ---: |
| `select.poll`(等外网 socket | 0.801 s |
| `_ssl._SSLSocket.read` | 0.786 s |
| `_ssl._SSLSocket.do_handshake` | 0.206 s |
| `swisseph.calc_ut`(本地排盘) | **0.022 s** |
| `copy.deepcopy` | 0.048 s |
前三名全部来自 `_join_foreground_vedastro``_run_foreground_vedastro_gateway``vedastro_service_adapter``urlopen`,目标是 `api.vedastro.org`,而且每次重新 TLS 握手。**本地占星计算连 3% 都不到。**
三个护栏数字凑不到一起,符号定位:
| 位置 | 值 | 含义 |
| --- | ---: | --- |
| `jyotish_api_server.py` `_foreground_vedastro_join_seconds` | 1.5 s(上限 3 s) | 前台最多等多久 |
| `jyotish_api_server.py` `_foreground_vedastro_budget_seconds` | 8 s(2–12 s) | 后台任务自己跑多久 |
| `jyotish_api_server.py` `_FOREGROUND_VEDASTRO_WORKERS` | **2** | 整个进程的前台线程池 |
`_join_foreground_vedastro` 超时走 `FuturesTimeoutError` 分支返回 `official_blocked`**但不 `cancel()` 那个 future**,任务继续跑满 8 秒预算。于是:只要平均每 4 秒有一轮聊天,2 个 worker 就永远被占着,之后每一轮都白等 1.5 秒再拿到 `official_blocked`——用户付出了等待,拿不到证据,技法表上那一层还是 blocked。
另有一条纯浪费:响应体 52 万字符里 `western_evidence_packet`**122 KB**,全仓检索前端**零读取点**`grep -rn "western_evidence_packet" frontend/src` 无命中)。算出来、序列化、走 HTTP、在 Node 里 `JSON.parse` 一遍,然后丢掉。三个域就是 366 KB 的无效解析,全压在 2 vCPU 上。其余键体积:`chart` 238 KB、`consumer_context` 43 KB、`rectification` 23 KB、`vedastro_gateway` 21 KB。
## 2. 根因
这份外网证据**本来就是按「盘 + 日期」组织的**,只是没有按这个键缓存过:
- `vedastro_service_adapter._official_snapshot_reference_date()` 在 case 里找不到 `reference_date` / `today` / `transit_date` / `current_date` 时,缺省取 **UTC 当天**
- `consultationInputSchema``frontend/src/mastra/consultation-workflow.ts`)**没有任何日期字段**,所以普通聊天永远走缺省。
- `_official_dasha_range_body()` 把大运区间取成该日期所在**自然年的 1 月 1 日到 12 月 31 日**。
也就是说:本命部分永不变,随时间变的部分粒度是天/年。同一个人一天里问十轮,十次拿到的是同一份东西,却打了十次外网。
## 3. 决策记录
产品 2026-09-15 授权本单,并明确以下口径:
1. **不许「干脆不调 VedAstro」。** 那正是 BUG-301 修掉的问题(前台跳过后技法表里 VedAstro 云状态永远 blocked)。本单的目标是**留住证据、去掉等待**,不是二选一。
2. **缓存键沿用引擎内部已有的那一个**:出生数据 + 岁差 + 交点 + **UTC 日期**。产品明确否决了「自己定一个 N 小时 TTL」——那会和 `_official_snapshot_reference_date` 的日期键错位,跨 UTC 零点时以为还新鲜、内容其实已经该换。
3. **新鲜度分档**(产品定):
- 同一天命中 → 直接用,**0 等待**。
- 跨天了 → **先用旧的**(上限 7 天)立刻返回,同时后台刷新,下一轮就是新的。
- `entrypoint = daily_starlanguage`(「深入看今日」)→ **要求当天**,没有当天的不吃旧的,走下一档。
- 完全没有缓存 → 才走现在那套 1.5 秒有界等待。
4. **后台任务必须可取消、线程池必须可排队。** 现在「超时不 cancel + 只有 2 个 worker」的组合是本单必须解决的部分,不是顺带。
5. **`western_evidence_packet` 按需返回,不删计算。** 西洋盘本身仍是 must-use 层之一(被读的是别的字段),本单只是不再把这个整包塞进每一轮的响应。
6. **不动并发闸门(默认 2)、不动 `AGENT_TIMEOUT_MS`、不动 `maxDuration`。**
## 4. 硬红线
1. 缓存键必须与 `_api_chart_cache` 的键**分开**(BUG-161 防复发原文:「快速排盘与完整证据排盘必须使用不同缓存键」)。不得复用同一个目录或同一个 key 函数。
2. 赶不上外网时,不得把未交付的官方层标成 `executed`(BUG-301 防复发)。用旧缓存时,证据里必须能看出它是哪一天的。
3. 缓存内容里不得落盘姓名、邮箱、用户 ID;出生资料派生值只能以哈希进键,不得明文写进缓存文件名(AGENTS §8)。
4. 前台不得再同步串联 overview + snapshot + range scanBUG-301 防复发,现状已满足,不得回退)。
5. `scripts/jyotish_api_server.py` 不得增长(AGENTS §6):新逻辑进 `scripts/` 下的独立模块,主文件只做薄注册。本单又恰好排在拆分单之后,更不能把行数吃回去。
6. 不得顺手升级依赖、不得顺手修不在本单里的 warning。
## 5. 任务分解
### 5.1 外网证据按「盘 + UTC 日期」缓存
新增一个独立模块(例如 `scripts/vedastro_snapshot_cache.py`),提供读/写/新鲜度判定:
- 键:`sha256(出生年月日时分秒 + lat + lon + tz + ayanamsa_policy + node_policy + reference_date)`
- 落盘位置与 `_api_chart_cache_dir()` **不同目录**TTL 由 `reference_date` 与「最多 7 天」共同决定,不再引入第二个 TTL 环境变量。
- `execute_consultation_workflow` 里:命中当天 → 直接用,**完全不 submit 后台任务**;命中 1–7 天前 → 直接用旧的并 submit 一次后台刷新(刷新结果只写缓存,不参与本轮);`entrypoint == "daily_starlanguage"` 时只接受当天;全未命中 → 现有 1.5 秒有界路径,拿到结果后写缓存。
- 验收:新增 pytest——同一份 body 连调两次,第二次**零外网调用**(用 stub/monkeypatch 计数 `urlopen` 或适配器入口),且两次返回的 `vedastro_gateway` 逐字相等。
- 验收:把 `reference_date` 推到第二天,断言缓存不再命中当天档、走「先用旧的 + 后台刷新」,且**本轮不等待**。
- 验收:`daily_starlanguage` 入口在只有昨天缓存时不得使用它。
- 验收:缓存目录与 `_api_chart_cache_dir()` 不同,且有一条断言钉死这一点。
### 5.2 后台任务可取消 + 线程池可排队
`_join_foreground_vedastro` 超时时必须 `future.cancel()`;已经在跑的任务要能被 `temporary_timeout_seconds` 之外的显式取消标记打断,或者把预算从 8 秒收到不超过 join 的两倍并说明理由。线程池大小与 join/budget 三个数必须在同一处成组声明,并写明它们的关系(照 `consultation-tools.ts` 里那段预算注释的写法)。
- 验收:新增测试——连续发起超过 `_FOREGROUND_VEDASTRO_WORKERS` 个前台请求,断言第 N+1 个的等待时间仍不超过 join 上限,且不会因为前面的任务没结束而排队变长。
- 验收:源码合同断言三个数在同一个声明块里,且 `budget ≤ k × join`k 取实现选定的常量)。
### 5.3 `western_evidence_packet` 按需返回
默认不放进 `/api/consultation_workflow` 的响应;需要它的调用方(个人报告、高严谨工作流,若确有读取点)显式请求。开工第一步先把真实读取点查清楚,查到的写进进度记录,没查到的按「无人读」处理。
- 验收:`grep -rn "western_evidence_packet" frontend/src scripts/ tests/` 的结果写进进度记录,逐个说明保留或去掉。
- 验收:改后响应体积实测下降幅度写进进度记录(改前 523 k 字符是本单的基线数)。
- 验收:`consultationWorkflowResponseSchema` 仍能解析(它是 `.passthrough()`,去掉一个键不应报错——但必须有测试证明,不能靠推断)。
### 5.4 在 staging 量一次真实的单域耗时
`frontend/src/mastra/consultation-tools.ts` 的预算注释写明:域上限 3 是按「staging 实测三域共 62.9 秒」反推的,即一域约 21 秒。本机同样的调用只要 0.5 秒,**差 40 倍**。这单交付后必须在 staging 上重新量一次:
- 交付一份可照做的清单进 `docs/testing/`,内容是:在 staging 上对同一张盘连发两轮同域提问,记录两轮的 `consultationToolDurationMs`(第一轮冷、第二轮应命中缓存)。
- 结论写进进度记录:如果单域耗时已显著低于 21 秒,**明确写出「三域上限可以放宽到 N」的依据**,但**本单不改那个上限**——放宽是产品决策,另开单。
- 验收:`docs/testing/` 下有这份清单;进度记录里有改前/改后的实测两组数字,或明确写成环境缺口(无 staging 访问权时)。
### 5.5 两条 Bug 历史
同一变更内写进 `docs/BUG_HISTORY.md`
- **BUG-727**:普通聊天每轮每域同步等外网,本地计算只占 3%;超时不取消 + 2 个 worker 让线程池长期饱和。**关联 BUG-161、BUG-301、BUG-718**,并写清楚:BUG-301 是**故意**把 VedAstro 放回前台的(否则技法表永远 blocked),所以本单不是推翻它,而是用缓存同时满足 161 和 301。防复发写成:**前台外部证据必须有「盘 + 日期」级缓存;任何有界等待都必须同时取消它等待的后台任务。**
- **BUG-728**`western_evidence_packet` 无人读却每轮每域传 122 KB。防复发写成:**工作流响应新增大字段前必须有读取点;没有读取点的字段不得进入前台响应。**
## 6. 让步顺序
1. 5.1(缓存)必须做,它是本单的全部意义。
2. 5.2(可取消 + 可排队)必须做——只加缓存不修线程池,冷启动和跨天刷新仍会把池子占死。
3. 5.3 可以砍到下一轮,砍了在进度记录里写明。
4. 5.4 **不得砍**,但允许写成环境缺口(没有 staging 访问权时,把清单交出来即可)。
5. 5.5 不得砍。
## 7. 开工前置命令
```bash
git fetch origin --prune
# 先确认 api-server-decomposition 单已合入 staging
git log --oneline origin/staging | head -10
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
git status -sb | head -1
python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45 # AGENTS §9:本单涉及外部 oracle
```
开工前必读:`docs/research/pre_work_error_ledger.md`AGENTS §9),以及 `docs/testing/vedastro-runtime-20260915.md`BUG-719/720 的运行期真相,与本单同一外部服务)。
验收命令:
```bash
.venv/bin/python -m pytest tests/test_api_server_security.py \
tests/test_consultation_consumer_context.py \
tests/test_vedastro_runtime_ops.py
.venv/bin/python scripts/run_quality_gate.py --profile quick
cd frontend && npx tsx --test tests/consultation-*.test.ts
```
## 8. BUG 编号起点
基线 `6b3248bf` 上最大号 **BUG-720**2026-09-15 校正四单已预占 **721726**。本单预占 **BUG-727 / 728**。开工时核对当时的实际最大号,冲突顺延并在进度记录写明。
## 9. 不在本单范围
- 三域上限 3 → N 的放宽(5.4 只负责量数据,放宽是产品决策)
- 并发闸门、`AGENT_TIMEOUT_MS``maxDuration`
- `reference_date` 缺省用 UTC 当天、而用户的「今天」是 UTC+8,早上八点前后会错开一天——**记为观察项,本单不修**
- 对话记忆与上限(见另外两单)
@@ -0,0 +1,150 @@
# TASK · 对话上限一半被思考文本吃掉,十几轮就「已写满」
- 日期:2026-09-15
- 基线 commit`origin/staging` @ `6b3248bf`
- 执行分支:`codex/consultation-session-capacity-20260915`
- 主要落点:新增一份迁移(`CREATE OR REPLACE FUNCTION public.append_consultation_question`)、`frontend/tests/` 下的合同测试
- **不改 `frontend/src/app/api/consult/route.ts`**:两档上限都继续返回同一个 `session_full`,路由与客户端一行都不用动
- 与 external-evidence-cache 单、context-memory 单无文件重叠,可并行
---
## 1. 事故实证
问下一个问题之前,`append_consultation_question``supabase/migrations/20260901010000_append_consultation_question.sql`)先数这段对话:
```sql
select coalesce(sum(
length(coalesce(elem->>'text', ''))
+ length(coalesce(elem->>'thinkingText', ''))
+ case when elem ? 'thinkingSections'
then length((elem->'thinkingSections')::text) else 0 end
), 0) into v_chars from jsonb_array_elements(...) as elem;
if v_count >= 200 or (v_chars + v_new_chars) > 200000 then
return query select false, 'session_full'::text;
```
超了就 409,文案「这段对话已写满,开个新对话继续吧」。
每一轮一问一答实际吃掉多少:
| 计入的内容 | 每轮字符 | 用户读得到吗 |
| --- | ---: | --- |
| 助手正文 `text` | 约 3,000 5,000 | 是,这才是对话 |
| 思考文本 `thinkingText` | 最多 4,000`route.ts``slice(0, 4_000)` | 默认折叠 |
| 思考分节 `thinkingSections` | **1,521 / 2,243 / 2,977**(实测 1 / 2 / 3 个问题域) | 只是步骤标签 |
| 用户提问 | 约 20 200 | 是 |
分节那三个数是跑 `natalConsultationThinkingPlan` 实测的 JSON 长度(3 / 4 / 5 个 section19 / 29 / 39 个 step)。合计每轮约 **9,000 12,000** 字符,**一半以上是模型内部状态**。
200,000 ÷ 每轮约 10,500 ≈ **19 轮**。另一档上限 200 条消息(100 轮)**永远碰不到**——真正卡住用户的是字符数,而字符数一半是思考。
口径本来就不一致:同一条助手消息里还存了 `techniqueTruth``workflowReceipt``agentExecutionReceipt`,这三个字段**照样入库却完全不计入**上限。
## 2. 根因
这条上限身兼二职却一职没做好:
- 想当「这段对话有多长」→ 不该数用户读不到、也无法控制的思考文本。
- 想当「护住数据库那一行」→ 那就该把 `techniqueTruth` / `workflowReceipt` / `agentExecutionReceipt` 一起数,可它没数。
两个目标挤在一个数字里,结果是两边都不成立:用户的对话额度被模型内部状态吃掉一半,而数据库行的真实大小从来没被这条上限约束过。
## 3. 决策记录
产品 2026-09-15 拍板:**思考文本不计入对话上限**,并把两个目标拆成两条上限。
1. **对话额度只数用户读得到的东西**:助手正文 `text` + 用户提问 `text``thinkingText``thinkingSections` 不计入。额度数值 **200,000 不变**——变的只是数什么。预期从约 19 轮提到约 50 轮。
2. **另设一条物理上限护住数据库行**,把**全部字段**(含三个 receipt)都算进去。它的作用是防止单行异常膨胀,正常使用下不应该先于对话额度触发。
3. **两档都返回同一个 `session_full`**。用户看到的处置一样(开新对话),没必要让前端分两种文案,也就不用动路由和客户端。
4. **不删、不截断任何已存字段。** 「老轮次的思考文本要不要继续留着」是另一个产品问题,见 §9 观察项,本单不碰。
5. **200 条消息那档上限保留不动。**
## 4. 硬红线
1. `append_consultation_question` 必须保持 **advisory lock、`request_id` 幂等、满员拒绝** 三件事(BUG-464 防复发原文)。本单只改「数什么」和「加一档」,不得动这三条。
2. 迁移必须对**当前已部署的那一版代码向后兼容**(AGENTS §7.6):`CREATE OR REPLACE` 保持函数签名与返回列不变,`error_code` 仍只用既有的取值。staging 迁移在部署之前自动应用,旧代码必须能继续正常调用。
3. 不得新增列、不得删列、不得改类型(本轮不动表结构)。
4. 不得放宽 `char_length(v_text) > 16000` 那条单条提问长度校验。
5. 物理上限的数值必须由算术推出来并写进迁移注释,不得拍脑袋写一个整数。
6. 不得顺手升级依赖、不得顺手修不在本单里的 warning。
## 5. 任务分解
### 5.1 对话额度只数正文
新迁移里 `CREATE OR REPLACE` 该函数,把 `v_chars` 的求和改成只累加 `elem->>'text'``thinkingText``thinkingSections` 不再进这个和。上限常数 200,000 不变。
- 验收:`npm run test:db --prefix frontend`(需 Docker)新增用例——同一段会话,助手消息带 4,000 字 `thinkingText` 与 3,000 字 `thinkingSections` 时,**不再**影响还能问几轮;只有正文长度影响。
- 验收:无 Docker 时写成环境缺口进 `BLOCKED.md`,并用 SQL 静态合同断言(求和表达式里不含 `thinkingText` / `thinkingSections`)替代,**不得写成「通过」**。
- 验收:幂等分支(同 `request_id` 重入返回 `true, null`)、advisory lock、`session_missing` 三条既有行为的测试一条不改仍全绿。
### 5.2 加一条把全部字段算上的物理上限
同一个函数里加第二次求和:对每条消息取 `length(elem::text)`(整条 JSON 的长度,字段增减都自动覆盖),超过物理上限同样返回 `session_full`
数值按算术定,并把算式写进迁移注释:50 轮 × 每轮(正文约 4,000 + 思考 4,000 + 分节 3,000 + 三个 receipt 约 3,000)≈ 700,000,取 **1,000,000** 作为留有余量的物理上限。执行方若测得 receipt 实际更大,按实测调整并在进度记录里写明新算式。
- 验收:新增用例——正文很短但 receipt 极大的会话会被物理上限拒绝,且 `error_code` 仍是 `session_full`
- 验收:正常形态的会话在触发对话额度之前**不会**先撞上物理上限(用 5.1 的夹具跑到额度边界,断言物理上限未触发)。
- 验收:迁移注释里有那行算式。
### 5.3 量一次真实的详情接口体积
对话轮数上限从约 19 提到约 50`GET /api/sessions/[id]` 返回的整份 `messages` 也会按比例变大(该接口的 `sessionSelect``messages`,打开一段长会话是整份取回)。本单不改这个接口,但必须把数字量出来:
- 构造一段跑到新额度边界的会话(测试夹具即可,不需要真实用户数据),记录详情接口 JSON 的体积。
- 结论写进进度记录。如果在 2 vCPU 上明显偏大,追加到 §9 观察项并写进 `BLOCKED.md`**不得默默略过**。
- 验收:进度记录里有改前(约 19 轮)与改后(约 50 轮)两组体积数字。
### 5.4 Bug 历史
同一变更内写进 `docs/BUG_HISTORY.md`,预占 **BUG-732**。必须写明:**关联 BUG-464**200 / 200,000 两档上限与 `append_consultation_question` 都是那一单立的),以及这不是回归,是那条上限从一开始就身兼二职。防复发写成:
> 会话上限必须分成两条各司其职的口径:面向用户的对话额度只数用户读得到的正文;面向存储的物理上限必须把整条消息 JSON 算全。新增会存进 `messages` 的字段时,必须明确它进哪一条,不得默认落进对话额度。
## 6. 让步顺序
1. 5.1 必须做,它是本单的全部意义。
2. 5.2 必须和 5.1 同轮——只放宽不设物理上限,等于把行大小的护栏整个拆掉。
3. 5.3 可以退化成「写成环境缺口」,但不得跳过不提。
4. 5.4 不得砍。
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/consultation-session-capacity-20260915 \
.worktrees/consultation-session-capacity-20260915 origin/staging
cd .worktrees/consultation-session-capacity-20260915
git status -sb | head -1
cd frontend && npm ci
```
开工前必读:`docs/BUG_HISTORY.md` 里的 **BUG-464**(这两档上限的来历与三条防复发),以及 AGENTS §7.6 关于 staging 自动迁移必须向后兼容的那一段。
验收命令:
```bash
cd frontend
./node_modules/.bin/tsc --noEmit
npm run lint
npm run db:migrate:check # 迁移可应用性
npm run test:db # 需 Docker;无 Docker 写 BLOCKED.md
npx tsx --test tests/consultation-*.test.ts tests/chat-session-*.test.ts
npx tsx --test tests/*.test.ts # 与基线逐条比对失败清单
```
## 8. BUG 编号起点
基线 `6b3248bf` 上最大号 **BUG-720**;校正四单预占 **721726**external-evidence-cache 单预占 **727 / 728**context-memory 单预占 **729 / 730 / 731**。本单预占 **BUG-732**。开工时核对实际最大号,冲突顺延并写进进度记录。
## 9. 观察项与不在本单范围
- **老轮次的思考文本要不要一直留着**:`thinkingText` 会在历史消息里渲染(`chat-message-row.tsx` 对已结算消息也判断 `message.thinkingText?.trim()`),所以它是个真功能,不是纯浪费。但它的价值随时间衰减,只保留最近 N 轮是个可选方向——**这是产品决策,本单不做,也不得顺手做。**
- 详情接口分页 / 按需加载历史(5.3 只负责量数据)
- 200 条消息那档上限
- 上下文记忆三条(见 `TASK-consultation-context-memory-20260915.md`
- 外网证据缓存(见 `TASK-consultation-external-evidence-cache-20260915.md`