产品 2026-09-15 三项拍板,落成两份新单与两处既有单的修订: - 新增 TASK-freeze-metric-change-20260915(无 BUG 号,后面两单的前置): 两条冻结余量已用完(page.tsx 1951/1951 余 0;api server 11334/11363 余 29), 冻结从「逼新代码往外走」退化成拦路。实证:page.tsx 行数砍 59% 但 Home() 的 useState 从 56 涨到 66;api server 225 个类方法只有 12 处真碰 HTTP。 主门换成耦合指标,行数降为粗护栏;同时推翻 §6「参数式 hook 内部保持 0 个 React hook」——那正是状态搬不走的原因。 - 新增 TASK-home-state-lowering-20260915(无 BUG 号):先搬 rectification* 那 15 个 state 进已经是 dynamic 子树的校正面,Home() useState 66 → ≤53。 零行为变化;串行在 freeze-metric-change + C2 + R3 之后。 - 修订 TASK-consultation-external-evidence-cache-20260915:依赖反转,C1 排在 API server 拆解之前(它动模块级函数,拆解动类方法);补「不得新增类方法、 行数余量仅 29」的硬红线。 - 修订 TASK-api-server-decomposition-20260916:串行依赖加 C1 与 freeze-metric-change;__new__ 计数按 grep 的 4 计(原文 3 是文件数); 阶段 4 收尾口径改写;基线 11,314 → 11,334。 纯文档推送,不触发门禁、不发布镜像、不部署。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
174 lines
14 KiB
Markdown
174 lines
14 KiB
Markdown
# 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`
|
||
- **依赖已于 2026-09-15 反转:本单排在 API server 拆解单之前。** 原文写的是「串行在 `TASK-api-server-decomposition-20260916` 之后」,产品拍板改为**本单先做**——理由见 §3.7,拆解单已同步改为串行在本单之后
|
||
- 与 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`。**
|
||
7. **本单排在 API server 拆解之前**(2026-09-15 产品拍板,推翻本任务书首版的排序)。依据:本单要改的 `execute_consultation_workflow`、`_join_foreground_vedastro`、那三个常量**全是模块级函数**,而拆解单的核心是把业务逻辑搬出 `JyotishAPIHandler` **类**,两边动的是文件的不同部分;且本单的用户价值(每一轮省掉一次外网等待)远高于一次纯搬运。代价是拆解单将来要吸收本单的 diff,已在那一单里写明。
|
||
|
||
## 4. 硬红线
|
||
|
||
1. 缓存键必须与 `_api_chart_cache` 的键**分开**(BUG-161 防复发原文:「快速排盘与完整证据排盘必须使用不同缓存键」)。不得复用同一个目录或同一个 key 函数。
|
||
2. 赶不上外网时,不得把未交付的官方层标成 `executed`(BUG-301 防复发)。用旧缓存时,证据里必须能看出它是哪一天的。
|
||
3. 缓存内容里不得落盘姓名、邮箱、用户 ID;出生资料派生值只能以哈希进键,不得明文写进缓存文件名(AGENTS §8)。
|
||
4. 前台不得再同步串联 overview + snapshot + range scan(BUG-301 防复发,现状已满足,不得回退)。
|
||
5. `scripts/jyotish_api_server.py` 不得增长(AGENTS §6):新逻辑进 `scripts/` 下的独立模块,主文件只做薄注册。**具体到数字**:开工时实测 11,334 行、上限 11,363,**余量只有 29 行**;`JyotishAPIHandler` 有 225 个方法,**本单不得新增任何类方法**。
|
||
若 `TASK-freeze-metric-change-20260915` 已经合入,主门改为「类方法数不得增长」,行数粗护栏放宽——**以开工当时生效的合同测试为准**,但「不新增类方法、新逻辑进独立模块」这条无论口径怎么换都成立。
|
||
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;直接以 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
|
||
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 校正四单已预占 **721–726**。本单预占 **BUG-727 / 728**。开工时核对当时的实际最大号,冲突顺延并在进度记录写明。
|
||
|
||
## 9. 不在本单范围
|
||
|
||
- `JyotishAPIHandler` 的拆解本身(本单只做薄注册,拆解排在本单之后)
|
||
- 三域上限 3 → N 的放宽(5.4 只负责量数据,放宽是产品决策)
|
||
- 并发闸门、`AGENT_TIMEOUT_MS`、`maxDuration`
|
||
- `reference_date` 缺省用 UTC 当天、而用户的「今天」是 UTC+8,早上八点前后会错开一天——**记为观察项,本单不修**
|
||
- 对话记忆与上限(见另外两单)
|