From 0be4cb299c9e65fc81917dc35345ba51ba5f98f4 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 14 Oct 2026 00:34:35 +0800 Subject: [PATCH 1/5] docs(tasks): code-health + performance audit briefs, 16 tasks (BUG-1323 onward) Batch 1 (dispatch now): rect-scoring-perf, node-runtime-resources, home-sidebar-rerender, api-disk-cache-retention, report-reference-perf, consult-prep-parallel, frontend-dead-code. Batch 2 (queued on predecessors): api-chart-subprocess-gate, answer-effort-ab, consult-route-split, pl9-render-split, python-hygiene, dasha-unify, api-server-service-extract, data-layer-types, contracts-helpers-dedupe. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v --- docs/tasks/README.md | 23 ++ docs/tasks/TASK-answer-effort-ab-20261013.md | 169 ++++++++++ ...TASK-api-chart-subprocess-gate-20261013.md | 197 +++++++++++ .../TASK-api-disk-cache-retention-20261013.md | 225 +++++++++++++ ...ASK-api-server-service-extract-20261013.md | 197 +++++++++++ .../TASK-consult-prep-parallel-20261013.md | 242 ++++++++++++++ .../TASK-consult-route-split-20261013.md | 170 ++++++++++ .../TASK-contracts-helpers-dedupe-20261013.md | 155 +++++++++ docs/tasks/TASK-dasha-unify-20261013.md | 240 +++++++++++++ docs/tasks/TASK-data-layer-types-20261013.md | 163 +++++++++ .../tasks/TASK-frontend-dead-code-20261013.md | 193 +++++++++++ .../TASK-home-sidebar-rerender-20261013.md | 142 ++++++++ .../TASK-node-runtime-resources-20261013.md | 249 ++++++++++++++ docs/tasks/TASK-pl9-render-split-20261013.md | 187 +++++++++++ docs/tasks/TASK-python-hygiene-20261013.md | 274 +++++++++++++++ docs/tasks/TASK-rect-scoring-perf-20261013.md | 315 ++++++++++++++++++ .../TASK-report-reference-perf-20261013.md | 245 ++++++++++++++ 17 files changed, 3386 insertions(+) create mode 100644 docs/tasks/TASK-answer-effort-ab-20261013.md create mode 100644 docs/tasks/TASK-api-chart-subprocess-gate-20261013.md create mode 100644 docs/tasks/TASK-api-disk-cache-retention-20261013.md create mode 100644 docs/tasks/TASK-api-server-service-extract-20261013.md create mode 100644 docs/tasks/TASK-consult-prep-parallel-20261013.md create mode 100644 docs/tasks/TASK-consult-route-split-20261013.md create mode 100644 docs/tasks/TASK-contracts-helpers-dedupe-20261013.md create mode 100644 docs/tasks/TASK-dasha-unify-20261013.md create mode 100644 docs/tasks/TASK-data-layer-types-20261013.md create mode 100644 docs/tasks/TASK-frontend-dead-code-20261013.md create mode 100644 docs/tasks/TASK-home-sidebar-rerender-20261013.md create mode 100644 docs/tasks/TASK-node-runtime-resources-20261013.md create mode 100644 docs/tasks/TASK-pl9-render-split-20261013.md create mode 100644 docs/tasks/TASK-python-hygiene-20261013.md create mode 100644 docs/tasks/TASK-rect-scoring-perf-20261013.md create mode 100644 docs/tasks/TASK-report-reference-perf-20261013.md diff --git a/docs/tasks/README.md b/docs/tasks/README.md index e9d145e2..6ecd43f3 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -456,3 +456,26 @@ | —(直接执行,无任务书) | —(记录在 BUG-1256) | **普通对话提示词减半**(10-07 产品授权 + 临时 key 对比):形状说明 4 份合 1、方法书只摘诚实边界、共享方法不再进聊天、边界要点改写补回;系统提示 55,242 → 26,591 字符,模型对比无退化 | 已验收 | Claude 直接执行;对比记录 `docs/testing/prompt-slim-20261007-model-runs.md` | | —(直接执行,无任务书) | —(记录在 BUG-1257) | **生时校正口吻统一 + 开场 / 只读轮按章节附 Skill**:两版系统提示共用知心姐姐口吻头;开场 11,812 → 4,576 字符;读档案那一步(每轮多一次模型调用)待真实步耗时数据后另定 | 已验收 | Claude 直接执行 | | —(直接执行,无任务书) | —(记录在 BUG-1258) | **生时校正服务器固定句改温和口吻**:约 30 句,产品认可 15 句样稿后全量改;防重复正则补新回执 | 已验收 | Claude 直接执行 | + +### 2026-10-13 全仓屎山与性能排查(agent 循环,Grok 执行) + +基线 `origin/staging` `77057a819`。第一批直接进 `agent/待执行`;排队的单等前置合入 staging 后再放行。P1/P2 级改动按产品 10-13 要求做 A/B。 + +| 任务书 | 进度 | 主题 | 状态 | 落点 | +| --- | --- | --- | --- | --- | +| `TASK-rect-scoring-perf-20261013.md` | — | 校正打分提速:引导窗口平方扫描、候选不变量重复算、响应带无读取点字段(BUG-1323~1325) | 待领取 | `codex/rect-scoring-perf-20261013` | +| `TASK-node-runtime-resources-20261013.md` | — | 报告 worker abort 监听泄漏、本地 PG 适配层往返与元数据缓存、无上限内存表(BUG-1326~1328,PG 部分 A/B) | 待领取 | `codex/node-runtime-resources-20261013` | +| `TASK-home-sidebar-rerender-20261013.md` | — | 流式回答时侧栏每帧整栏重渲染(BUG-1329,A/B) | 待领取 | `codex/home-sidebar-rerender-20261013` | +| `TASK-api-disk-cache-retention-20261013.md` | — | API 磁盘缓存只进不出、VedAstro 审计文件留存出生资料、失败快照久缓存、缓存键规范化(BUG-1330~1332,A/B) | 待领取 | `codex/api-disk-cache-retention-20261013` | +| `TASK-report-reference-perf-20261013.md` | — | 长报告出稿提速:术语替换命中预检、哈希单次遍历、同盘复用(BUG-1335~1336,A/B) | 待领取 | `codex/report-reference-perf-20261013` | +| `TASK-consult-prep-parallel-20261013.md` | — | 普通对话发问前准备步骤并行、模型版本短缓存、多领域并发开关(BUG-1337~1339,A/B) | 待领取 | `codex/consult-prep-parallel-20261013` | +| `TASK-frontend-dead-code-20261013.md` | — | 前端死代码 17 文件与无调用方接口删除,含未登录重计算接口(BUG-1342) | 待领取 | `codex/frontend-dead-code-20261013` | +| `TASK-api-chart-subprocess-gate-20261013.md` | — | 合盘不带 skip 串行排盘、报告无效子进程、名额轻重混用(只模拟)(BUG-1333~1334,A/B) | 待领取(排队:api-disk-cache-retention + report-reference-perf) | `codex/api-chart-subprocess-gate-20261013` | +| `TASK-answer-effort-ab-20261013.md` | — | 答题步推理强度开关 + A/B(需模型 key) | 待领取(排队:consult-prep-parallel) | `codex/answer-effort-ab-20261013` | +| `TASK-consult-route-split-20261013.md` | — | consult POST 拆分第一阶段:开流前加载 + 计费结算 | 待领取(排队:consult-prep-parallel、answer-effort-ab) | `codex/consult-route-split-20261013` | +| `TASK-pl9-render-split-20261013.md` | — | render_pl9_markdown 第一阶段:搬出无依赖内部函数 | 待领取(排队:api-chart-subprocess-gate) | `codex/pl9-render-split-20261013` | +| `TASK-python-hygiene-20261013.md` | — | 免费层排队开关报反、静默吞异常、功能吉凶兜底合一、测试专用脚本归位(BUG-1340) | 待领取(排队:pl9-render-split) | `codex/python-hygiene-20261013` | +| `TASK-dasha-unify-20261013.md` | — | /api/dasha 改走引擎同一套实现、删占位注册表(BUG-1341) | 待领取(排队:python-hygiene) | `codex/dasha-unify-20261013` | +| `TASK-api-server-service-extract-20261013.md` | — | API 单体第一阶段:拆出不带 HTTP 的计算类,生产伪造 handler 归零 | 待领取(排队:dasha-unify) | `codex/api-server-service-extract-20261013` | +| `TASK-data-layer-types-20261013.md` | — | 数据访问层改用自己的 DataClient 类型第一阶段(BUG-1343) | 待领取(排队:node-runtime-resources) | `codex/data-layer-types-20261013` | +| `TASK-contracts-helpers-dedupe-20261013.md` | — | 前后端契约与小工具去重第一阶段 | 待领取(排队:frontend-dead-code、consult-route-split) | `codex/contracts-helpers-dedupe-20261013` | diff --git a/docs/tasks/TASK-answer-effort-ab-20261013.md b/docs/tasks/TASK-answer-effort-ab-20261013.md new file mode 100644 index 00000000..76389a02 --- /dev/null +++ b/docs/tasks/TASK-answer-effort-ab-20261013.md @@ -0,0 +1,169 @@ +# TASK · 普通对话答题步推理强度:开关 + A/B(issue #待定) + +- 日期:2026-10-13 +- 来源:「2026-10-13 全仓屎山与性能排查」P1 · 答题步推理强度 +- 执行方:**Grok(Windows + Docker)**,分支 `codex/answer-effort-ab-20261013`,只推这个分支 +- 验收:Claude(Linux) +- BUG 编号:**无 BUG 编号**(研究 + 开关,记录写 PROGRESS 与 A/B 记录)。若 T0 证实「既有的 `reasoningEffort` 管道从未送到供应商」需要立 Bug,开工时核对 `docs/BUG_HISTORY.md` 最大号(写作时最大号 BUG-1317,1335~1339 已分配)顺延取号,并在 PROGRESS 写明。(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起) +- 开工前置:**`consult-prep-parallel` 已合入 `origin/staging`**(两单都改 `frontend/src/mastra/consultation-tools.ts`)。 + +## 1. 基线 + +- 基线 commit:`origin/staging` `77057a819`(开工时以合入 `consult-prep-parallel` 之后的 staging 为准) +- 涉及文件: + - `frontend/src/mastra/consultation-tools.ts::consultationGenerationSettings`(第 224 行)、`consultationSliceGenerationSettings`(第 232 行)、`consultationNatalPrepareStep` + - `frontend/src/lib/agent-generation-settings.ts::agentGenerationSettings` + - `frontend/src/app/api/consult/route.ts`(第 1214 行 `...consultationGenerationSettings(selectedModel.model)`,`natalStreamOptions` / `windowStreamOptions`) + - `frontend/scripts/research/consult-biography-backtest.mts`(AGENTS.md §10 指定的名人生平回测,A/B 的底座) + +## 2. 事故实证 + +### 2.1 推理占答题时间的大头(既有实测,非本轮) + +10-04 咨询耗时审计(Claude 记忆与 `docs/testing/consult-plain-answer-20261001-model-runs.md`、`docs/testing/prompt-slim-20261007-model-runs.md`):答题步推理 token 中位 9.5k–11.9k,约占答题步输出 90%,约 200 tok/s,答题步 45–60 s,占整轮 75–85%。10-07 精简提示词后:deepseek-flash 10 题平均思考 5,683 token、30.3 s;deepseek-v4-pro 3 题平均思考 3,588 token、33.6 s。这些数字本轮未重测。 + +### 2.2 普通对话答题从不传推理强度 + +`consultationGenerationSettings(model)` = `agentGenerationSettings(model, { thinking: "enabled" })`,没有 `reasoningEffort`。route 第 1214 行把它摊进同一轮的 `streamOptions`,第 0 步(决定调工具)与第 1 步(写答案)用同一份设置。 + +### 2.3 既有的 `reasoningEffort: "low"` 管道很可能从未到达供应商(起草时读依赖源码得出,需 T0 用真实请求证实) + +1. `consultationSliceGenerationSettings`(唯一传 `reasoningEffort: "low"` 的地方)**在产品代码里没有调用方**:`grep -rn consultationSliceGenerationSettings frontend/src` 只有定义;只有 `frontend/tests/consultation-agentic-runtime.test.ts:2460` 与 `consultation-workflow-contract.test.ts:210` 断言它的形状。 +2. 形状放错了位置:`agentGenerationSettings` 产出 `providerOptions[providerId] = { thinking: { type: "enabled", reasoningEffort: "low" } }`,强度嵌在 `thinking` 里。 +3. 产品的模型走哪条通道:`model-catalog.ts::resolveRow` 对非 openai / anthropic 的供应商给 `{ providerId: <供应商 code>, modelId, url, apiKey }`;Mastra 1.50.1(`package-lock.json`)在 `config.url` 存在时一律 `createOpenAICompatible({ name: providerId })`(`@mastra/core/dist/chunk-AR6WPTSV.js` 约 518 行)。 +4. OpenAI 兼容通道的取参(`@mastra/core/dist/chunk-DRULWNQ6.js` 约 3225–3290 行):只认 `providerOptions[name]` **顶层**的 `reasoningEffort`(字符串)→ 请求体 `reasoning_effort`;其余键原样摊进请求体。所以今天的形状会发出 `"thinking": {"type": "enabled", "reasoningEffort": "low"}`、**没有** `reasoning_effort` 字段——DeepSeek 大概率忽略这个嵌套键。 +5. 同版本 Mastra 内置的 DeepSeek 原生通道(`chunk-YJB7RPO7.js` `deepseekChatOptions`,约 1515 行)的注释是「Controls the thinking strength for DeepSeek V4 reasoning models」,取值只有 `"high" | "max"`。也就是说 **DeepSeek V4 可能根本不接受 `low` / `medium`**,产品要的「low / medium / 不设」三组可能有一组或两组发不出去。 + +上面 3~5 是读打包后源码的推断,必须 T0 用真实请求证实,不得直接当结论。 + +### 2.4 以前的 A/B 做法 + +`docs/testing/prompt-slim-20261007-model-runs.md`:同一套拼法(系统提示 + 用户轮指令 + 工具调用 + 工具结果)直连 DeepSeek,公开名人 golden 盘造工具结果,`max_tokens` 与线上一致(24,576;8,000 会让 4/10 空答),thinking 模式下 assistant 的 tool_call 消息必须带 `reasoning_content`。那次脚本没入库;现在仓库里已有等价且更完整的 `frontend/scripts/research/consult-biography-backtest.mts`(真实 `getJyotishAgent`、真实 `consultationGenerationSettings`、真实 `streamAgentResponse`,引擎调用换成 `frontend/tests/fixtures/consult-biography-backtest-golden.json`,9 张公开 AA 盘 × 4 领域,确定性检查对照 `docs/research/consult_biography_backtest_rubric.json`),本单在它上面加参数,不另写一套拼提示词的脚本。 + +## 3. 根因 + +- 2.2:从未设计。BUG-1142 把答题钟从 70 s 放到 300 s 时记录了「推理 token 也从同一只钟里扣」,但没有人去调强度。 +- 2.3:`consultationSliceGenerationSettings` 加入时只写了形状测试(断言 `providerOptions.deepseek` 等于嵌套形状),没有对真实请求体做断言,所以「传了 low」看上去成立。这是测试只验源码/对象形状、不验线上请求的老问题(同 BUG-148 防复发「不得用只匹配源码字符串的断言替代该回归」)。 +- 关联:BUG-1142(答题钟只防卡死)、BUG-1051(截断不扣点)、BUG-1232(`modelSteps`、`answer.reasoning_ms` 埋点,本单 A/B 用它)、BUG-1256(提示词精简 A/B)、BUG-282 / BUG-937(第 0 步关思考的风险,本单不碰第 0 步)。 + +## 4. 决策记录 + +- 产品授权:2026-10-13 产品负责人同意按排查结论全部立单交 Grok 执行,并**已同意做答题步推理强度 A/B**;默认值等 A/B 结论交产品拍板。 +- 本单默认行为**不变**:开关不设时,请求体与 `77057a819` 逐字节相同。 +- `consultationSliceGenerationSettings` 的嵌套形状与它的两条断言**本单不改**(没有调用方,改它不影响线上;是否删除或修正交产品,写进 PROGRESS「需拍板」)。 +- 「答题步关思考」(`thinking: disabled`)**不作为候选默认**:只在 T0 证实 DeepSeek 不接受降档时,作为对照组 D 跑数据供产品参考;若要采用需另行拍板(BUG-282 / 937 记录过关思考的质量风险)。 +- 不推翻任何既有红线。 + +## 5. 硬红线 + +1. 开关未设置时:发给供应商的请求体逐字节不变(T1 用拦截 `fetch` 的测试证明)。 +2. 只影响**答题步**(拿到计算结果之后写答案的那一步,及其续写/重试——续写本来就是 `thinking: disabled`,不动)。第 0 步(决定调工具)的请求体不变。若 Mastra 的 `prepareStep` 返回 `providerOptions` 不是按步生效,则在 PROGRESS 写明并改为整轮生效,A/B 照常,但要把第 0 步耗时单列。 +3. 不改答题钟(300 s)、工具钟(110 s)、`AGENT_ANSWER_OUTPUT_TOKENS` / `AGENT_THINKING_OUTPUT_TOKENS`、`max_tokens`。 +4. 不改系统提示、Skill、证据卡、回答形状(改了就要另跑生平回测,超出本单)。 +5. 模型 key 只从环境变量读(`DS_KEY` / `BACKTEST_MODEL_KEY`),不得写进任何文件、日志、提交、A/B 记录;回答原文不入库,A/B 记录只写数字、条数与短评。 +6. 不动表结构与迁移;不改 `scripts/jyotish_api_server.py`;不碰 `frontend/src/app/(app)/page.tsx`;不改 `.gitea/workflows/**`;不升级依赖(包括 `@mastra/core`);不顺手修 warning。 +7. 改既有断言写「原值 / 新值 / 原因」三栏。开工先 grep:`grep -rn "consultationGenerationSettings\|agentGenerationSettings\|reasoningEffort\|thinking: { type" frontend/tests`。 + +## 6. 任务分解 + +### T0 · 先证实:供应商收到了什么、接受什么(需 key;Grok 无 key 则只交付脚本,由 Claude 跑) + +- 写 `frontend/scripts/research/answer-effort-probe.mts`: + 1. **请求体拦截**(不需要 key):用 Mastra 的 `createOpenAICompatible` 通道(与产品同一构造方式:`{ providerId: "deepseek", modelId, url, apiKey: "dummy" }`)配一个假 `fetch`,分别以 (a) 今天的 `consultationGenerationSettings`、(b) `consultationSliceGenerationSettings`、(c) T1 的新形状 `low` / `medium` / `high` 发一次 `agent.stream`,打印实际请求体里的 `thinking`、`reasoning_effort`、`max_tokens`。预期 (b) 只有嵌套键、无 `reasoning_effort`——证实或推翻 §2.3。 + 2. **供应商接受度**(需 key):对 `deepseek-flash` 与 `deepseek-v4-pro`(以 `docs/testing/prompt-slim-20261007-model-runs.md` 用过的模型 id 为准;若产品库里的 id 不同,以产品负责人提供的为准),各发一个短问题,`reasoning_effort` 依次 不设 / `low` / `medium` / `high` / `max`,记录 HTTP 状态、错误码、`completion_tokens_details.reasoning_tokens`。每档 3 次。 +- 判定: + - 某档 HTTP 4xx → 该档「不支持」,A/B 去掉这档; + - 某档 200 但推理 token 与「不设」差异 < 10%(中位数)→ 视为「被忽略」,同样去掉; + - `low` 与 `medium` 都不支持或被忽略 → 推理强度这条路不通:只跑对照组 D(答题步 `thinking: disabled`)供参考,结论写「供应商不支持降档」,交产品。 +- 产出写进 `docs/testing/answer-effort-ab-20261013.md` 第一节。 + +### T1 · 环境变量开关(Grok 自跑 / Claude 验收) + +- 新开关 `CONSULT_ANSWER_REASONING_EFFORT`:取值 `low` / `medium` / `high` / `max` / 空;空或非法 = 不设(今天的行为),非法值 `console.warn` 一次。 +- `agent-generation-settings.ts::agentGenerationSettings` 增加一个**新**选项(例如 `providerReasoningEffort`),把强度写到 `providerOptions[providerId].reasoningEffort` **顶层**(OpenAI 兼容通道据此发 `reasoning_effort`;`providerOptions.openai` 同样顶层),不改现有 `reasoningEffort`(嵌套)选项的行为——slice 的断言因此不需要改。 +- 生效位置:`consultationNatalPrepareStep` / `consultationWindowPrepareStep` 在答题步(`stepNumber ≥ 1` 且计算结果已在手——与 `createConsultationRunClock` 的 `answerReady` 同一判据)返回带强度的 `providerOptions`;第 0 步不带。无工具的公开日历路线(`usesPublicDailyGeneralAgent`,`answerFromStart`)从第一步起就是答题步,同样带。 +- 验收: + - 新测试 `frontend/tests/answer-effort-switch-20261013.test.ts`:拦截 `fetch`,开关不设时第 0、1 步请求体与改前逐字节一致(快照取自改前代码运行结果,`fixtureProvenance` 写明命令);设 `low` 时只有答题步出现 `"reasoning_effort":"low"`、第 0 步没有;续写请求仍是 `thinking: disabled` 且无 `reasoning_effort`。 + - `tsc --noEmit` 0 错、`npm run lint` 0 error。 + +### T2 · A/B 脚本(Grok 写;有 key 才跑) + +- 在 `consult-biography-backtest.mts` 上加最少的参数,不复制提示词: + - `--cases figure:domain,…`:固定题集(取代 `--figures × --domains` 的笛卡尔积); + - 强度由环境变量 `CONSULT_ANSWER_REASONING_EFFORT` 传入(脚本走的就是产品的 `consultationGenerationSettings` 与 prepareStep),summary 里记下当次取值; + - 每行额外记录:端到端秒数、第 0 / 1 步秒数与推理 token(复用 BUG-1232 的 `modelStepTimings` / `answerReasoningMs`)、输出 token、`finish`、是否空答、是否截断、正文字数。 +- 固定 10 题(9 张公开 AA 盘均在 golden 里): + + | # | 盘 | 领域 | + | --- | --- | --- | + | 1 | steve_jobs | career | + | 2 | barack_obama | parents | + | 3 | elizabeth_taylor | marriage | + | 4 | marilyn_monroe | parents | + | 5 | judy_garland | health | + | 6 | edith_piaf | marriage | + | 7 | frida_kahlo | health | + | 8 | george_w_bush | career | + | 9 | zinedine_zidane | career | + | 10 | marilyn_monroe | marriage | + +- 组别:A = 不设;B1 = `low`;B2 = `medium`(T0 去掉的档不跑);D = 答题步关思考(只在 T0 判定降档不通时跑)。每组 10 题 × 3 次,模型先 `deepseek-flash`;`deepseek-v4-pro` 抽 1、4、6 三题 × 3 次。 +- 批量跑时 `--concurrency` 不超过 4,组与组交错跑(同一题 A、B1、B2 相邻),避免供应商时段波动全落在某一组。 + +### T3 · 质量对比与判定(Claude 验收时做;产品拍板) + +- 自动项(脚本出):rubric `must_not` 命中数、空答数、截断数、`finish` 非正常数、英文术语/后台词等 `recheck` 已有检查。 +- 人工项(Claude 读,同题 A/B 正文并排):每题记 缺事实(证据卡里有、与问题直接相关却没说)/ 跑题 / 重复 / 空答 的条数,三次运行都读。 +- 判定门槛(某档 B 可提议为默认,须**全部**满足): + 1. 答题步推理 token 中位数降 ≥ 30%,且端到端耗时中位数降 ≥ 20%; + 2. 空答、截断均为 0(A 若有,B 不得多于 A); + 3. rubric `must_not` 命中总数 ≤ A; + 4. 人工四项每一项的总条数 ≤ A(30 份回答合计); + 5. `deepseek-v4-pro` 抽样不出现 A 没有的空答或截断。 + 满足者取降幅最大的一档提议为默认;都不满足 → 默认维持「不设」,开关保留。结论只是**提议**,由产品负责人拍板后另一个小提交改默认值(`CONSULT_ANSWER_REASONING_EFFORT` 的部署默认写在哪里也由那次决定,本单不改部署文件)。 + +### T4 · 记录(Grok) + +- `docs/testing/answer-effort-ab-20261013.md`:T0 结果(请求体实拍、各档接受度)、T2/T3 的表(耗时、推理 token、质量条数),不贴回答原文。 +- `docs/tasks/PROGRESS-answer-effort-ab-20261013.md`:开关实现、三栏改断言表(若有)、「需拍板」清单(默认值;`consultationSliceGenerationSettings` 删除或修正)。 +- 若 T0 证实 §2.3,按 §头部规则取一个 BUG 号,记录「既有低强度设置从未送达供应商」,状态 `investigating`(slice 无调用方,线上无影响)。 +- `CHANGELOG.md`:本单默认不变,不写;产品拍板改默认那次再写。 +- `frontend/DESIGN.md`:不改。 +- 无 key 时在 `BLOCKED.md` 记「A/B 未跑:缺模型 key」。 + +## 7. 让步顺序 + +T0 第 1 步(请求体拦截)与 T1 不可砍。时间不够先砍:`deepseek-v4-pro` 抽样 → 组 D → B2(`medium`)。 + +## 8. 验收命令分工 + +| 谁 | 命令 | +| --- | --- | +| Grok 自跑 | `cd frontend && ./node_modules/.bin/tsc --noEmit && npm run lint`;`./node_modules/.bin/tsx --test tests/answer-effort-switch-20261013.test.ts tests/consultation-agentic-runtime.test.ts tests/consultation-workflow-contract.test.ts tests/consult-answer-clock-20261001.test.ts tests/consult-latency-timing-20261005.test.ts`(`# fail` 与 `# cancelled` 都为 0);`./node_modules/.bin/tsx scripts/research/answer-effort-probe.mts --dry-run`(只做请求体拦截)。有 key 才跑 T0 第 2 步与 T2;**没有 key 就回报「A/B 未跑:缺模型 key」,不得写通过** | +| Claude 验收 | 上述全部 + 前端全量 `npm test` 与基线逐名比对 + `next build`(`/` 仍 Static,首屏 gzip ±2%)+ 用产品负责人提供的临时 key 跑 T0、T2,按 T3 读稿判定 | +| 不需要 | `test:db`、Python 快速门(不动 Python) | + +## 9. 开工前置命令 + +```bash +git fetch origin --prune +# 前置单必须已在 staging 上: +git log origin/staging --oneline | grep -i "consult-prep-parallel\|BUG-1337" | head -3 # 为空则停,回报「前置未合入」 +git worktree add -b codex/answer-effort-ab-20261013 .worktrees/answer-effort-ab-20261013 origin/staging +cd .worktrees/answer-effort-ab-20261013 +grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -3 +grep -rn "consultationSliceGenerationSettings" frontend/src # 起草时只有定义,没有调用方 +grep -rn "consultationGenerationSettings\|agentGenerationSettings\|reasoningEffort\|thinking: { type" frontend/tests +``` + +## 10. BUG 编号起点 + +无(研究 + 开关)。T0 若证实 §2.3 需立 Bug,开工时取当前最大号 + 1。 + +## 11. 交付物清单 + +- 代码:`agent-generation-settings.ts`(新选项)、`consultation-tools.ts`(开关读取 + prepareStep) +- 测试:`frontend/tests/answer-effort-switch-20261013.test.ts`(新) +- 脚本:`frontend/scripts/research/answer-effort-probe.mts`(新)、`consult-biography-backtest.mts`(加 `--cases` 与计时字段) +- 文档:`docs/testing/answer-effort-ab-20261013.md`、`docs/tasks/PROGRESS-answer-effort-ab-20261013.md`、视 T0 结果 `docs/BUG_HISTORY.md`、无 key 时 `BLOCKED.md` +- 不改:`CHANGELOG.md`、`frontend/DESIGN.md`、部署文件、提示词 diff --git a/docs/tasks/TASK-api-chart-subprocess-gate-20261013.md b/docs/tasks/TASK-api-chart-subprocess-gate-20261013.md new file mode 100644 index 00000000..f4ec6fcd --- /dev/null +++ b/docs/tasks/TASK-api-chart-subprocess-gate-20261013.md @@ -0,0 +1,197 @@ +# TASK · 星盘接口与报告的多余子进程、合盘串行、重计算名额轻重混用(issue #待定) + +- 日期:2026-10-13 +- 来源:「2026-10-13 全仓屎山与性能排查」P1 · 星盘接口子进程与闸门 +- 执行方:**Grok(Windows + Docker)**,分支 `codex/api-chart-subprocess-gate-20261013`,只推这个分支 +- 验收:Claude(Linux) +- BUG 编号:**BUG-1333~1334**(开工时核对 `docs/BUG_HISTORY.md` 最大号;写作时最大号 BUG-1317,BUG-1323~1332 已被同批其它任务书占用)(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起) +- **开工前置**:`TASK-api-disk-cache-retention-20261013`(分支 `codex/api-disk-cache-retention-20261013`)**已合入 `origin/staging`**。两单都改 `scripts/jyotish_api_server.py`,且本单 A/B 依赖那一单的缓存键规范化;未合入不得开工。 +- **主会话补充的串行链(2026-10-14)**:引擎/API 文件链按此顺序逐单合入后开工(`upstream-sync7` 也改 `jyotish_engine.py` / `jyotish_api_server.py`,若在途,后合一方 rebase):`api-disk-cache-retention` + `report-reference-perf` → `api-chart-subprocess-gate` → `pl9-render-split` → `python-hygiene` → `dasha-unify` → `api-server-service-extract`。本单的前置以这条链为准(与上一行不一致时以本行为准)。 + +## 1. 基线 + +- 基线 commit:`origin/staging` `77057a819`(开工时以前置单合入后的 `origin/staging` 为准,§9 有核对命令)。 +- 生产镜像只装 `requirements.txt`:**有** `vedastro==1.23.25`,**没有** PyJHora(PyJHora 在 `requirements-reference-engines.txt`,AGPL,只作离线参照)。本单所有计时**必须**在「模拟生产依赖」下再跑一遍:把只含下面这个 `sitecustomize.py` 的目录放在 `PYTHONPATH` 最前(子进程会继承)。 + +```python +# <临时目录>/nojhora/sitecustomize.py —— 只挡 jhora;要模拟「无 VedAstro SDK」时把 'vedastro' 也加进集合 +import sys +class _Block: + def find_spec(self, name, path=None, target=None): + if name.split('.')[0] in {'jhora'}: + raise ModuleNotFoundError(f"No module named '{name}'") + return None +sys.meta_path.insert(0, _Block()) +``` + +## 2. 事故实证 + +计时脚本:用 `JyotishAPIHandler.__new__(JyotishAPIHandler)` 造处理器,直接调 `_compute_chart_sync` / `professional_report_reference.build_professional_report_reference`,monkeypatch `subprocess.run` 记录每次命令与耗时;`JYOTISH_API_CHART_CACHE_TTL_SECONDS=0` 避开缓存。虚构盘 1990-01-01 12:00 新德里 28.6139/77.2090 +5.5。本机(Linux,无 vedastro SDK、外网关闭)实测: + +### 2.1 `/api/chart` 不带 skip 每次起 2 个子进程(BUG-1333) + +| 请求 | 子进程 | 热态耗时(4 次,后 3 次中位) | 响应体 | +| --- | --- | --- | --- | +| 不带 `skip_vedastro_main_entry_overview` | 2 个:`vedastro_official_capability_runner.py --bundle official_full_snapshot` 与 `--bundle official_full_capability_catalog` | **约 1.03–1.09 s**(排查报告另一次测得 0.56 s) | 160,643 字节 | +| 带 skip | 0 | **约 5–9 ms** | 55,345 字节 | + +- 子进程来自 `scripts/jyotish_api_server.py::_attach_vedastro_main_entry_overview` → `vedastro_evidence_orchestrator.orchestrate_vedastro_evidence(route='overview')` → `scripts/vedastro_service_adapter.py` 的 `subprocess.run`。本机没有 SDK,子进程秒败;**生产装了 SDK,若开了外网,这两个子进程会真的打 VedAstro,耗时上限是 `VEDASTRO_TIMEOUT_SECONDS`**——本机测不到,是环境缺口。 +- 出生资料以 `--birth-json` 明文出现在子进程命令行里(同机 `ps` 可见)。记录,本单不改。 +- `/api/chart` 不在 `scripts/api_heavy_compute_gate.py::HEAVY_COMPUTE_PATHS` 里(`tests/test_api_heavy_compute_gate.py::test_health_and_light_paths_are_not_gated` 明确断言 `"/api/chart" not in HEAVY_COMPUTE_PATHS`),所以这 1 s 级的子进程调用不受名额约束。 + +### 2.2 前端调用方逐一核对 + +| 调用方 | 是否带 skip | 用到响应里的什么 | 需要 VedAstro 主入口概览吗 | +| --- | --- | --- | --- | +| `frontend/src/lib/chart-view-load.ts`(星盘页主盘,`payload`) | 带 | 盘面 | 否 | +| 同文件 transit 层 → `frontend/src/lib/transit-chart-body.ts::transitChartBody` | 带 | 行运盘 | 否 | +| `frontend/src/app/api/ephemeris/route.ts`(两处) | 带 | 盘面 | 否 | +| `frontend/src/app/api/synastry/route.ts::POST` | **不带** | 只读 `planets`、`ascendant`、月亮经度(`moonLongitude`) | **否** | + +- 合盘还**串行**:`selfChart` 与 `partnerChart` 两次 `await postPython("/api/chart", …)` 先后执行;romance 分支里两次 `/api/varga_full`(D9)也是串行(business 分支已是 `Promise.all`)。按本机数字,合盘一次光是两次 chart 就约 2 s,带 skip 并发后约 10 ms 级。 +- Python 内部:`execute_consultation_workflow` 的 `compute_chart` 步骤按 `defer_optional_external_evidence` 传 skip(前台咨询 skip,BUG-161);个人报告、高严谨、主题报告、MCP / API Explorer(`API_COMMAND_MAP` 的 `chart`、`full-reading`)走不 skip 的完整链。**这些保持不变**(BUG-161:个人报告保持默认完整工作流)。 + +### 2.3 报告每次起 10 个子进程(BUG-1334) + +`/api/professional_report_reference`(`edition=full_data`、`languages=["zh","en"]`,个人报告长文的真实请求形状,见 `frontend/src/lib/personal-report-longform-generate.ts`),各跑 2 次: + +| 环境 | 总耗时 | 子进程数 | 子进程合计 | +| --- | --- | --- | --- | +| 本机(装了 PyJHora) | 19.8 s / 18.4 s | 10 | 6.5 s / 6.0 s | +| 模拟生产(挡 `jhora`,本机本就无 vedastro SDK) | 13.5 s / 13.2 s | 11 | 0.71 s / 0.92 s | + +子进程明细(模拟生产): + +| 子进程 | 次数 | 每次 | 来源 | +| --- | --- | --- | --- | +| `vedastro_official_capability_runner.py`(snapshot / catalog)+ 无 SDK 时多一个 `vedastro_python_bridge.py` | 2–3 | 0.06–0.09 s | 官方快照,报告完整链的一部分,**保留** | +| `oracle_collection_queue.py` → `oracle_evidence_validator.py` | 2 | 0.03–0.27 s | `scripts/jyotish_engine.py::_oracle_progress_snapshot`:读仓库里的静态文件 `references/oracle/dasha_shadbala_oracle_cases.json` 做校验,**与用户盘无关,每份报告重跑** | +| `python -c "from scripts.annual_pyjhora_replay import _isolated_tajaka_worker…"` | 3 | 0.06–0.08 s | `scripts/annual_pyjhora_replay.py::_run_isolated_tajaka_year_lord` | +| `python -c "… _isolated_annual_worker…"` | 3 | 0.06–0.08 s | `::_run_isolated_annual_replay` | + +- **修正排查报告**:生产没有 PyJHora 时这 6 个 PyJHora 子进程确实「快速失败但仍付启动开销」,但每个只有约 60–80 ms,合计约 0.4 s;排查报告里的「年度回放 3 次 ~4.2 s、Tajika 3 次 ~0.9 s」只在装了 PyJHora 的机器(开发机、参照环境)成立。生产上的收益是**每份报告约 0.5–0.9 s、8 个子进程**,不是 5 s。 +- 另:`scripts/tajika.py::_resolve_external_pyjhora_python` 不缓存「找不到 PyJHora」的结论,每次都对 `python3.14`、`python3` 各起一个 `find_spec` 探测子进程(当前解释器路径与候选相同时才跳过)。生产镜像里 `sys.executable` 与 `which python3` 是否同一路径未核实——执行方在 Docker 镜像里核实并写进 PROGRESS。 + +### 2.4 重计算名额轻重混用(只评估) + +- `HEAVY_COMPUTE_PATHS` 共 22 条,共用一个 `BoundedSemaphore(JYOTISH_HEAVY_COMPUTE_CONCURRENCY=2)`,满了立刻 429(`acquire_heavy_compute_slot` 非阻塞)。 +- 排查报告数字(本单 A/B 复测):`/api/consultation_workflow` 卡片缓存命中时约 0.2 s;个人报告约 18 s(本机模拟生产 13 s);校正打分 5–12 s。两份报告同时在算时,任何一条 0.2 s 的咨询请求都会拿到 429(前端 `frontend/src/mastra/consultation-workflow.ts` 映射为 `workflow_rate_limited`)。 +- 现在 429 发生时日志里看不出名额被谁占着,staging 上无法从记录确认这种「连带 429」的频率。 + +## 3. 根因 + +1. **BUG-1333**:合盘路由写于 BUG-161 之前或之外,没跟上「只读盘面的调用方带 skip」的约定;`/api/chart` 默认走完整链(为 MCP / 报告保留),于是任何漏带 skip 的调用方都会白付两个 VedAstro 子进程。串行 `await` 是写法问题。不是回归,是约定从没落到这个路由,也没有合同测试拦。 +2. **BUG-1334**:PyJHora 回放为了 AGPL 隔离用子进程(正确),但没在父进程先判断「本机根本没装 PyJHora」;oracle 进度快照是给离线审计表用的,被挂在每份报告的技法审计表构建里,又没有进程内复用。 +3. **名额**:闸门按「路径」一刀切,不按耗时分级;BUG-717 明令不得靠放宽路径或调高并发解决 429,所以轻重分名额不是技术问题,是产品取舍。 + +关联:BUG-161、BUG-301(前台仍取官方层)、BUG-715 / BUG-717(星盘页不得 429、不得放宽 `HEAVY_COMPUTE_PATHS` 或提高并发)、BUG-720(前台不得无上限排队)、BUG-721(禁止模块级缓存跨请求持有出生资料派生数据)、BUG-723(429 必须单独成档)、BUG-1302(预热不占名额)、BUG-1265 / 验收须模拟无 PyJHora。 + +## 4. 决策记录 + +- 产品负责人 2026-10-13 同意按排查结论全部立单交 Grok 执行;P1,必须 A/B。 +- 授权范围:合盘改带 skip 并发;报告里与用户盘无关或必然失败的子进程在父进程短路;给闸门加观测。 +- **不推翻 BUG-717**:本单**不**把任何路径移出 `HEAVY_COMPUTE_PATHS`,**不**新增第二个并发池,**不**改 `JYOTISH_HEAVY_COMPUTE_CONCURRENCY` 的语义与默认值。轻重分名额只做评估与模拟(T5),结论交产品**拍板**后另立单。 +- **不推翻 BUG-161 / BUG-301**:`/api/chart` 不带 skip 时的默认行为(附 VedAstro 主入口概览)不变;报告、高严谨、MCP 的完整链不变。 +- `/api/chart` 不带 skip 时占名额(T3)是**收紧**而非放宽,但会让 MCP 等外部调用方可能拿到 429,所以做成开关、**默认关**,A/B 有证据再提议打开(打开仍需拍板)。 + +## 5. 硬红线 + +1. `scripts/jyotish_api_server.py` **净行数不得增长**(`tests/test_api_server_growth_contract.py`);不新增 `JyotishAPIHandler` 类方法。闸门的判定逻辑(含 BUG-1302 的 `consult_card_warm_only` 判断)可以搬进 `scripts/api_heavy_compute_gate.py`,主文件调用点保持一行。 +2. `frontend/src/app/(app)/page.tsx` 不碰;不写第二套请求封装。 +3. 不改 `deploy/**`、`.gitea/workflows/**`、数据库结构与迁移;不升级依赖。 +4. 不改引擎数值。所有改动在「模拟生产依赖」与「装了 PyJHora」两种环境下,报告 Markdown(中英)与 `fact_table_packet` **逐字节一致**(去掉生成时间戳等已知易变字段,易变字段清单写进差分脚本并在 PROGRESS 列出)。 +5. 进程内复用只允许缓存**与用户无关**的结论(PyJHora 是否可导入、静态 oracle 文件的校验结果,键含文件路径 + mtime + 大小);不得缓存任何含出生资料的结果(BUG-721)。 +6. `tests/test_api_heavy_compute_gate.py` 现有断言(含 `"/api/chart" not in HEAVY_COMPUTE_PATHS`、`DEFAULT_CONCURRENCY == 2`)一条不改。 +7. 改既有断言写「原值 / 新值 / 原因」三栏;golden 来自真实引擎输出。 + +## 6. 任务分解 + +### T1 · 合盘:带 skip、两两并发(BUG-1333) + +- `frontend/src/app/api/synastry/route.ts`:两次 `/api/chart` 请求体加 `skip_vedastro_main_entry_overview: true`(在路由里加,**不改** `synastryBirthPayload`,因为 `global-birth-payloads.ts` 别处也用);`selfChart` / `partnerChart` 改 `Promise.all`;romance 分支两次 D9 `/api/varga_full` 改 `Promise.all`;`/api/synastry` 调用仍在两者之后。错误语义不变(任一失败仍走现有 `catch` 返回 `blocked`)。 +- 新合同测试(前端):桩 `fetch`,断言两次 `/api/chart` 请求体都带 skip、两次是并发发出(第二次在第一次 resolve 之前已发出);romance 与 business 两分支的最终 JSON 与改前桩输出一致。 +- 同时加一条**源码合同**:`frontend/src/app/api/**` 与 `frontend/src/lib/**` 里所有向 Python `/api/chart` 发的请求体都必须带 `skip_vedastro_main_entry_overview: true`,除非在白名单里写明理由(当前白名单为空)。 +- 验收:`tsc --noEmit` 0 错、`npm run lint` 0 error、新测试与 `frontend/tests/chart-view-route.test.ts`、`frontend/tests/ephemeris-route.test.ts` 单文件通过。 +- 分工:Grok 自跑;Claude 复验 + 前端全量 `npm test`。 + +### T2 · 报告子进程短路(BUG-1334) + +- `scripts/annual_pyjhora_replay.py`:在起子进程前,父进程用 `importlib.util.find_spec("jhora")` 判断(进程内记一次,可被测试重置)。不可导入时**不起子进程**,直接返回与「子进程在无 PyJHora 环境下返回的结果」**完全相同**的 dict。做法:先在模拟生产环境里真实跑一次子进程,把返回值存成 golden(`tests/fixtures/…`,写明生成命令),短路分支的返回值必须与 golden 相等(`request_hash`、`settings` 等按入参计算的字段照常计算)。 +- `scripts/tajika.py::_resolve_external_pyjhora_python`:结果进程内记一次(含「没找到」)。 +- `scripts/jyotish_engine.py::_oracle_progress_snapshot`:按 (`oracle_file` 路径, mtime, 大小) 进程内记一次;文件变了自动重算。 +- 不改:VedAstro 官方快照子进程;装了 PyJHora 时的子进程隔离(AGPL 边界,必须仍是子进程)。 +- 验收: + - 新测试:无 PyJHora 时 `_run_isolated_tajaka_year_lord` / `_run_isolated_annual_replay` 不调用 `subprocess.run` 且返回值等于 golden;装了 PyJHora(或 `find_spec` 被桩成可用)时仍调用子进程;`_oracle_progress_snapshot` 连调两次只跑一次子进程,`touch` 文件后重跑。 + - 既有 `tests/test_report_reader_main.py`、`tests/test_report_longform_parity.py`、`tests/test_report_density_facts.py` 及 `grep -rln "annual_pyjhora_replay\|_oracle_progress_snapshot\|_resolve_external_pyjhora_python" tests` 命中的测试全绿。 +- 分工:Grok 自跑;Claude 复验 + 全量 pytest 与基线逐名比对(引擎相关文件有改动)。 + +### T3 · `/api/chart` 不带 skip 时占重计算名额(开关,默认关)(BUG-1333) + +- `scripts/api_heavy_compute_gate.py` 新增 `acquire_heavy_compute_slot_for_request(path, body)`(或同义名):现有按路径的逻辑原样保留;另加:`path == "/api/chart"` 且 body 不带真值 `skip_vedastro_main_entry_overview` 且 body 不带 `async`/`enqueue` 且开关 `JYOTISH_GATE_CHART_OVERVIEW=1` 时,占一个名额。BUG-1302 的 `consult_card_warm_only` 判断一并搬进来。 +- `jyotish_api_server.py` 调用点改成这一个函数,**净行数 ≤ 0**。 +- `HEAVY_COMPUTE_PATHS` 不加 `/api/chart`。 +- 验收:新测试——开关关时任何 `/api/chart` 都不占名额;开关开时只有不带 skip 的同步请求占名额,带 skip 的、`async` 的不占;`test_warm_consultation_does_not_consume_the_user_slot` 等既有测试不改仍绿。 +- 分工:Grok 自跑;Claude 复验。 + +### T4 · 名额观测(BUG-1333 防复发的一部分) + +- `api_heavy_compute_gate.py` 记录当前占名额的路径(只记路径字符串与开始时间,进程内计数);`HeavyComputeBusy` 抛出时,服务端打一行结构化日志:被拒路径、当前占用者路径列表与已占秒数。**不得**记 body、出生资料、用户标识。 +- 若能不增长主文件:`/api/health` 的返回里加 `heavy_compute: {limit, in_use}`(只数字);做不到不增长就不做,写进 PROGRESS。 +- 验收:新测试——饱和时日志行含占用者路径、不含 body 字段;释放后计数归零。 +- 分工:Grok 自跑;Claude 复验。 + +### T5 · A/B 验证(P1 必做) + +- 脚本 `scripts/research/api_chart_subprocess_ab.py`(位置执行方定,写进 PROGRESS)。A = 前置单合入后的 `origin/staging`(`git show :` 导出到临时目录同进程 import),B = 本分支。每组重复 3 次取中位数,两种依赖环境(模拟生产 / 装了 PyJHora)各一遍;Grok 侧若装了 vedastro SDK 且有外网,再加一组「真 SDK」并如实标注。 + 1. **合盘**:同一对虚构盘,A 的调用序列(两次不带 skip、串行)vs B(两次带 skip、并发),在 Python 侧用线程池模拟并发。度量:总耗时、子进程数、响应字节。正确性:合盘最终 JSON(`selfChart`/`partnerChart` 摘要、`synastry`、`relationshipReport`)A/B 逐字节一致。 + 2. **报告**:`build_professional_report_reference`(真实请求形状),度量总耗时、子进程数与合计耗时;正确性:中英 Markdown 与 `fact_table_packet` 去易变字段后逐字节一致。 + 3. **闸门模拟**(评估用,不改生产行为):用真实 `api_heavy_compute_gate` 模块,按实测耗时用 `sleep` 模拟混合负载(例:每分钟 2 份报告、20 次咨询 0.2 s、6 次校正打分 8 s,随机种子固定),统计各路径 429 比例。比较方案:O1 现状;O2 轻路径独立小池(**违反 BUG-717,只模拟不实现**);O3 轻路径有界等待(≤1.5 s,不增并发,只模拟);O4 名额预留(重路径最多占 `N-1`,只模拟)。输出每个方案下咨询 429 率、报告/校正 429 率、CPU 最高并发数。 +- 目标:合盘 chart 段耗时 B ≤ A 的 10%、子进程 4 → 0;报告子进程 B ≤ 3(模拟生产)、总耗时不劣于 A;所有正确性比对 0 差异。达不到如实写。 +- 开关默认值:T3 默认关;T1、T2 不设开关(纯去冗余,正确性由逐字节比对保证)。 +- 记录写 `docs/testing/api-chart-subprocess-gate-ab-20261013.md`;O1–O4 的模拟结果整理成给产品看的一张表(方案 / 咨询 429 率 / 报告 429 率 / 是否触碰 BUG-717)。 +- 分工:Grok 跑并交记录;Claude 在 Linux 上复跑 1、2 两项(数字差 >20% 以 Claude 为准)。 + +### T6 · 记录 + +- `docs/BUG_HISTORY.md`:BUG-1333(`/api/chart` 漏带 skip 的合盘白付 VedAstro 子进程且串行、不占名额、429 无观测);BUG-1334(报告每份 10 个子进程,其中 PyJHora 回放在生产必然失败、oracle 静态校验每次重跑)。BUG-1334 的「验证」里写清生产收益是约 0.5–0.9 s / 份,不是 5 s。 +- `docs/tasks/PROGRESS-api-chart-subprocess-gate-20261013.md`,含 T5 O1–O4 结论与「需拍板」一节。 +- `CHANGELOG.md`:「合盘更快」一句。 +- `docs/tasks/README.md` 索引加本单。 +- 不改 `frontend/DESIGN.md`(无 UI 变化)。 + +## 7. 让步顺序 + +时间不够时按此顺序往后砍:T4 的 `/api/health` 字段 → T3 → T5.3 闸门模拟 → T2 的 `_resolve_external_pyjhora_python`。T1、T2 主体、T5.1–5.2、T6 不可砍。 + +## 8. 验收命令分工 + +| 谁 | 命令 | +| --- | --- | +| Grok 自跑 | `.venv` 下:`python -m pytest tests/test_api_heavy_compute_gate.py tests/test_api_server_growth_contract.py tests/test_report_reader_main.py tests/test_report_longform_parity.py tests/test_report_density_facts.py -q`;前端 `tsc --noEmit`、`npm run lint`、T1 新测试与 `chart-view-route` / `ephemeris-route` 单文件;T5 脚本。前端全量、Python 快速门、全量 pytest **不跑**,以 Claude 为准 | +| Claude 验收 | 上述全部 + `run_quality_gate.py --profile quick` + 全量 `pytest` 与基线逐名比对(T2 动了引擎文件)+ 前端全量 `npm test` + 在挡 `jhora` 的环境复跑 T2 测试与 T5.1–5.2 + `wc -l scripts/jyotish_api_server.py` 不超过开工值 | +| 不需要 | `test:db`;`next build` 只在 Claude 侧跑一次确认 `/` 仍 Static(只改了 API 路由) | + +## 9. 开工前置命令 + +```bash +git fetch origin --prune +# 前置单必须已合入: +git log origin/staging --oneline | grep -i "api-disk-cache-retention\|BUG-1330" | head -3 # 必须有结果 +git merge-base --is-ancestor origin/codex/api-disk-cache-retention-20261013 origin/staging && echo merged +git worktree add -b codex/api-chart-subprocess-gate-20261013 .worktrees/api-chart-subprocess-gate-20261013 origin/staging +cd .worktrees/api-chart-subprocess-gate-20261013 +git log -1 --format=%h +grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -3 # 确认 1333、1334 未被占 +wc -l scripts/jyotish_api_server.py # 记下开工行数 +grep -rn "skip_vedastro_main_entry_overview\|\"/api/chart\"" frontend/src --include=*.ts --include=*.tsx +grep -rln "annual_pyjhora_replay\|_oracle_progress_snapshot\|_resolve_external_pyjhora_python\|acquire_heavy_compute_slot" scripts tests +# Docker 镜像内核实(写进 PROGRESS): +# python -c "import sys,shutil;print(sys.executable, shutil.which('python3'), shutil.which('python3.14'))" +# python -c "import importlib.util as u;print(u.find_spec('jhora'))" # 预期 None +``` + +## 10. 环境缺口(预告) + +- 生产装了 vedastro SDK:不带 skip 的 `/api/chart` 在开外网时的真实耗时(上限 `VEDASTRO_TIMEOUT_SECONDS`)本机测不到;合盘改动部署 staging 后,由 Claude 用虚构盘在内网复测一次合盘耗时,或写进 `docs/testing/` 清单交产品负责人在页面上计时。 +- 闸门「连带 429」的真实频率:T4 上线后看 staging 日志一周再给产品结论。 +- 轻重分名额方案(O2/O3/O4):**需产品拍板**,本单不实施。 diff --git a/docs/tasks/TASK-api-disk-cache-retention-20261013.md b/docs/tasks/TASK-api-disk-cache-retention-20261013.md new file mode 100644 index 00000000..461759c0 --- /dev/null +++ b/docs/tasks/TASK-api-disk-cache-retention-20261013.md @@ -0,0 +1,225 @@ +# TASK · API 磁盘缓存只进不出:清理、留存期与缓存键规范化(issue #待定) + +- 日期:2026-10-13 +- 来源:「2026-10-13 全仓屎山与性能排查」P1 · 磁盘缓存与留存(chart 缓存不清理、VedAstro 快照与 artifact 留存出生资料、缓存键碎片) +- 执行方:**Grok(Windows + Docker)**,分支 `codex/api-disk-cache-retention-20261013`,只推这个分支 +- 验收:Claude(Linux) +- BUG 编号:**BUG-1330~1332**(开工时核对 `docs/BUG_HISTORY.md` 最大号;写作时最大号 BUG-1317,BUG-1323~1329 已被同批其它任务书占用)(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起) +- 开工前置:**无**(第一批,立即派发)。注意:`TASK-api-chart-subprocess-gate-20261013` 也改 `scripts/jyotish_api_server.py`,排在本单**合入 staging 之后**开工。 +- **主会话补充的串行链(2026-10-14)**:引擎/API 文件链按此顺序逐单合入后开工(`upstream-sync7` 也改 `jyotish_engine.py` / `jyotish_api_server.py`,若在途,后合一方 rebase):`api-disk-cache-retention` + `report-reference-perf` → `api-chart-subprocess-gate` → `pl9-render-split` → `python-hygiene` → `dasha-unify` → `api-server-service-extract`。本单的前置以这条链为准(与上一行不一致时以本行为准)。 + +## 1. 基线 + +- 基线 commit:`origin/staging` `77057a819` +- 生产 API 容器把整个 `scratch/local` 挂在命名卷 `api_scratch`(`deploy/docker-compose.server.yml`),**部署不清空**;下面列的每个目录都会跨部署一直累积。 +- 本机复现环境:系统 `python3`,装了 swisseph、PyJHora,**没有** vedastro SDK,外网关闭。模拟生产依赖(无 PyJHora)用一个只含 `sitecustomize.py` 的目录挡掉 `jhora`(与 `vedastro`)导入,放到 `PYTHONPATH` 最前(子进程会继承): + +```python +# <临时目录>/nojhora/sitecustomize.py +import sys +class _Block: + def find_spec(self, name, path=None, target=None): + if name.split('.')[0] in {'jhora', 'vedastro'}: + raise ModuleNotFoundError(f"No module named '{name}'") + return None +sys.meta_path.insert(0, _Block()) +``` + +## 2. 事故实证(`77057a819`,虚构盘 1990-01-01 12:00 新德里 28.6139/77.2090 +5.5) + +### 2.1 chart 响应缓存从不清理(BUG-1330) + +| 事实 | 位置 | +| --- | --- | +| chart 缓存写在 `scratch/local/api_chart_cache/.json`,记录只有 `cache_key / created_at / payload`,**没有 `expires_at_unix`** | `scripts/jyotish_api_server.py::_api_chart_cache_dir`、`::_store_api_chart_response_cache` | +| TTL(`JYOTISH_API_CHART_CACHE_TTL_SECONDS`,默认 900 s)只在**读**时比较,过期文件不删 | `::_load_api_chart_response_cache` | +| 清理函数遍历 `_async_job_dir(_API_CHART_CACHE_SCOPE)` = `scratch/local/api_chart_response_jobs/`(chart **异步任务**目录),按 `expires_at_unix` 删;`api_chart_cache/` 一次都没扫过 | `::prune_expired_async_jobs`、`::_async_job_dir` | +| 清理只在进程启动时跑一次;长时间不重启的容器里,到期的异步任务记录(含完整盘面结果)只在被轮询时才删 | `::start_server` 调 `prune_expired_async_jobs()` | +| 缓存键含 `today`,所以同一人每天一个新文件;带 VedAstro 概览的响应约 **160 KB**,带 `skip_vedastro_main_entry_overview` 约 **55 KB**(本机实测 `json.dumps` 字节数) | `::_build_api_chart_cache_payload` | +| 缓存文件按进程默认权限写(本机 0644),里面是完整出生资料与盘面 | 同上 | + +### 2.2 缓存键用请求原值,同一张盘多份(BUG-1330) + +`_build_api_chart_cache_payload` 直接把 `body.get('hour')` 等原值放进键。实测(复现命令见 §9): + +| 输入变体 | 生成的不同键数 | +| --- | --- | +| `hour` = `12` / `12.0` / `"12"` | **3** | +| 基准 / `tz="5.5"` / 显式 `second=0` / `lat="28.6139"` / `ayanamsa="LAHIRI"` | **4**(`ayanamsa` 已经被 `_request_ayanamsa` 小写,另三种各成一键) | + +计算侧对这些变体走 `_get_int` / `_get_float` / `_parse_timezone`,结果相同(T3 需用字节比对证实),缓存却各存一份、各 miss 一次。 + +### 2.3 VedAstro 官方快照的 artifact 与语义缓存(BUG-1331) + +| 事实 | 位置 | +| --- | --- | +| 每次成功归一(含 `status=partial`)都写一份**缩进**的 `official_full_snapshot--.json` 到 `scratch/local/vedastro_adapter/`,无任何清理。本机无 SDK、外网关闭时一次 `/api/chart`(不带 skip)照样写出 **48–52 KB** 的文件,`request_manifest.requests[].body` 里有 `StdTime "12:00 01/01/1990 +05:30"` 与经纬度,文件权限 0644 | `scripts/vedastro_service_adapter.py::_write_artifact`(调用点:`::_normalize_success`、`::_normalize_range_scan_success`、`::_merge_range_scan_reports`、`::_normalize_official_full_snapshot_success`) | +| 文件名按「请求哈希 + 响应哈希」,同盘同日覆盖;不同人、不同日期都是新文件 | `::_artifact_path` | +| 读这些文件的只有离线工具与诊断:`::list_official_full_snapshot_artifacts`、`scripts/three_engine_parity_runner.py`(`VEDASTRO_ARTIFACT_DIR`)、`jyotish_api_server.py` 运行状态里的 `latest_artifact`;用户请求链路不读 | grep `ARTIFACT_DIR` / `artifact_path` | +| 语义缓存 `official_full_snapshot_cache/`:`run_official_full_snapshot_for_case` 对**任何 dict 结果**(成功、partial、blocked)都调 `_store_official_full_snapshot_semantic_cache`,TTL>0 即落盘 | `::run_official_full_snapshot_for_case` | +| TTL 取 `VEDASTRO_OFFICIAL_FULL_SNAPSHOT_CACHE_TTL_SECONDS`,**没设时回落到 `VEDASTRO_CACHE_TTL_SECONDS`**,都没设为 0(不缓存)。`.env.cn.example` 两个都写 `604800`(7 天)→ 按样例部署时,一次失败的快照会被原样复用 7 天 | `::_official_full_snapshot_cache_ttl_seconds`、`.env.cn.example` 13–14 行 | +| 诊断接口报出的 `official_full_snapshot_cache_ttl_seconds` 只读自己的变量,**不含回落**,与实际生效值可能不一致 | `scripts/vedastro_gateway.py::gateway_status`(`_int_env("VEDASTRO_OFFICIAL_FULL_SNAPSHOT_CACHE_TTL_SECONDS", 0)`) | +| 业务响应缓存 `vedastro_adapter/response_cache/`:默认 TTL 86400 s,同样只在读时判断、不删 | `::_load_cached_payload`、`::_store_cached_payload` | +| 生产 `.env.production` 里这几个变量的实际值本单读不到(密钥文件,不得打印) | 环境缺口,见 §10 | + +### 2.4 其它只进不出的目录(BUG-1332) + +| 目录 | 写入方 | 现状 | +| --- | --- | --- | +| `scratch/local/vedastro_snapshot_cache/` | `scripts/vedastro_snapshot_cache.py::store_snapshot`(BUG-727) | 键含 UTC 参考日;`lookup_snapshot` 最多回看 `MAX_STALE_DAYS=7` 天,**更早的文件永远不会再被读,但从不删**。每人每天一个 | +| `scratch/local/vedastro_snapshot_negative_cache/` | `scripts/vedastro_consultation_snapshot.py::store_negative_snapshot`(BUG-1231,10-05 遗留) | 只认当天(`lookup_negative_snapshot` 比对 `reference_date`),**昨天及以前的全是死文件**,每人每天一个,无清理 | +| `scratch/local/vedastro_gateway_jobs/` | `scripts/vedastro_gateway.py::_write_job` / `::_raw_response_archive` | 仅队列模式写;无清理;job 文件含 `request.case` 出生资料,缩进写、默认权限(原始响应存档已是 0600) | +| `scratch/local/varga_segments/` | `scripts/rectification_varga_segments_api.py` | 按校正窗口一份,无清理 | +| `scratch/local/high_rigor_jobs/`、`api_chart_response_jobs/` | 异步任务 | 有 `expires_at_unix`,但只在启动时清(见 2.1) | +| `scratch/local/consult_full_data_cache/` | `scripts/consult_full_data_cache.py` | **已有**两天清理(`prune_cache`,每次 `get_or_build` 都 glob+stat 全目录)。本单只记录,见 T6 | +| `$TMPDIR/jyotisha-reports/` | `jyotish_api_server.py` 报告 HTML/PDF 产物(`REPORT_ARTIFACT_DIR`) | 不在命名卷里,容器重建即清;容器存活期间无清理。本单只记录 | + +### 2.5 咨询卡片缓存体积(只评估) + +虚构盘、模拟生产依赖,`consult_full_data_cache.get_or_build` 实测:未命中构建 1.9 s;文件 **5,097,751 字节**;命中时 `json.loads` 中位 **68.5 ms**,整次 `get_or_build` 命中 **82.5 ms**(含 `prune_cache` 全目录 stat)。每人每天一份、保留两天 ≈ 每个活跃用户 10 MB。 + +## 3. 根因 + +1. **chart 缓存(BUG-1330)**:写缓存的人给了读时 TTL,写清理函数的人以为 chart 缓存和 chart 异步任务在同一个目录、同一种记录格式;两者从未对上。清理只挂在启动钩子上。不是回归,是从一开始就漏设计。键不规范化同理:键直接取 `body` 原值,而计算侧会先解析成数字。 +2. **VedAstro artifact(BUG-1331)**:`_write_artifact` 是早期为「外部 oracle 证据可审计」写的落盘,当时只在离线脚本里跑;后来同一归一函数进了 `/api/chart`、报告、校正等在线路径,落盘没有跟着关掉。快照语义缓存把「拿到了一个 dict」当成「拿到了可复用的结果」,没有区分失败。BUG-734 已确立「失败 TTL 必须显著短于成功 TTL」,这里没跟上。 +3. **前台快照缓存与负缓存(BUG-1332)**:BUG-727 / BUG-1231 只设计了「何时还能读」,没有设计「何时可以删」。 +4. 所有这些目录在生产都是持久卷,部署不会顺带清空,所以问题只会随时间变大。 + +关联:BUG-161(快速排盘与完整证据排盘必须不同缓存键)、BUG-301、BUG-727、BUG-734、BUG-1231、BUG-721(禁止模块级缓存跨请求持有出生资料派生数据)、BUG-994(不得用 `volume prune` 换磁盘)。 + +## 4. 决策记录 + +- 产品负责人 2026-10-13 同意按排查结论全部立单交 Grok 执行;本单为 P1,按「P1/P2 必须 A/B」要求执行。 +- **artifact 默认不写**:`official_full_snapshot-*.json` 等审计落盘改为开关,默认关;需要做外部引擎比对时由离线脚本显式打开。依据 AGENTS.md §8「真实用户出生资料不得进入……任何提交」的精神扩展到服务器磁盘:在线请求不留存非必要的出生资料副本。 +- **失败快照短 TTL**:沿用 BUG-734 的口径(失败 TTL 显著短于成功 TTL)。 +- **不推翻任何既有红线**: + - BUG-161:skip 与不 skip 的 chart 请求仍是不同缓存键(规范化不得合并这两类)。 + - BUG-727 / BUG-1231:`vedastro_snapshot_cache.snapshot_cache_key` 的键材料**本单不改**(BUG-1231 写明「整数小时和规范后的浮点小时靠原始请求对齐」,改键会打断这条对齐);本单只给它加「删过期文件」。 + - BUG-301:前台仍取官方层;本单不改任何取数路径与等待预算。 + - BUG-721:不得为省 T6 的 80 ms 加模块级/进程级的已解析数据包缓存(那是跨请求持有出生资料派生数据)。 +- **本单不做,需拍板**:改 `deploy/docker-compose*.yml` 的卷配置、给卷设容量上限、改生产 `.env.production` 里的 TTL 值、手工清理生产卷上的存量文件(上线后由新清理逻辑自然清掉;若产品要立刻清,另行拍板并由有权限的人执行,不用 `volume prune`)。 + +## 5. 硬红线 + +1. `scripts/jyotish_api_server.py` **净行数不得增长**(`tests/test_api_server_growth_contract.py`,当前 10,970 行)。新逻辑全部进新模块(建议 `scripts/api_scratch_retention.py`),主文件只做薄调用;`prune_expired_async_jobs` 的函数体可以整体搬进新模块,主文件留同名薄包装以保住 `tests/test_api_async_job_contract.py` 的调用入口。 +2. 不新增 `JyotishAPIHandler` 类方法;不新增端点。 +3. 不改 `deploy/**`、`.gitea/workflows/**`、数据库结构与迁移。 +4. 不改任何引擎数值、盘面结果、证据取舍;chart 接口在规范化前后**响应逐字节一致**(`runtime_cache` 字段除外)。 +5. 清理只删**按现有读逻辑已经不可能再被读到**的文件,或本单新定义的留存期已过的文件;不删未知格式文件中 mtime 在 24 小时内的;任何清理异常只计数、不抛、不阻塞请求。 +6. 清理不得在请求线程里同步扫大目录:周期清理走后台线程或「距上次清理 ≥ N 秒才跑」的节流,单次耗时写进返回计数。 +7. 不顺手升级依赖、不修任务书外的 warning;改既有断言写「原值 / 新值 / 原因」三栏。 +8. 测试与 A/B 只用虚构或公开名人盘;A/B 记录里不得出现生产路径下的真实文件名、哈希以外的出生资料。 + +## 6. 任务分解 + +### T1 · 统一的 scratch 留存模块 + chart 缓存清理(BUG-1330) + +- 新模块 `scripts/api_scratch_retention.py`: + - 一张登记表,每项 = 目录 + 判定「可删」的规则 + 是否含出生资料。至少登记:`api_chart_cache`(`created_at + chart TTL` 已过)、`high_rigor_jobs` 与 `api_chart_response_jobs`(`expires_at_unix` 已过,即现有逻辑)、T2/T4 的各目录。 + - `prune_all(now=None) -> dict`:逐目录返回 `scanned / removed / errors / elapsed_ms`,只计数不打印文件名。 + - 节流的周期清理:`start_periodic_prune(interval_s)`,后台守护线程,默认间隔 `JYOTISH_SCRATCH_PRUNE_INTERVAL_SECONDS=1800`,`0` 关闭周期清理(启动清理仍跑)。 +- `jyotish_api_server.py`:`start_server` 里把 `prune_expired_async_jobs()` 换成新模块的启动清理 + 启动周期线程(保持打印一行汇总的行为);`prune_expired_async_jobs` 函数体搬走,留薄包装。**净行数 ≤ 0**。 +- chart 缓存新写入的文件改 0600、改为临时文件 + `os.replace` 原子写(与 `_write_async_job_record` 一致)。 +- 不改:TTL 默认值、读缓存逻辑、`runtime_cache` 字段。 +- 验收标准: + - 新测试 `tests/test_api_scratch_retention.py`:在 `tmp_path` 下造三类 chart 缓存文件(未过期 / 已过期 / 坏 JSON 且 mtime 超 24 h),`prune_all` 只删后两者;异步任务目录行为与现有 `test_startup_cleanup_removes_only_expired_job_records` 一致;周期线程在 `interval=0` 时不启动;清理抛错时返回 `errors>0` 不抛。 + - `tests/test_api_async_job_contract.py`、`tests/test_api_server_security.py -k "chart_cache or chart_response_cache"`、`tests/test_api_server_growth_contract.py` 全绿。 +- 分工:Grok 自跑以上;Claude 复验 + 快速门。 + +### T2 · VedAstro artifact 默认不写、快照缓存失败短 TTL、TTL 诊断如实(BUG-1331) + +- `scripts/vedastro_service_adapter.py::_write_artifact`:新开关 `VEDASTRO_WRITE_AUDIT_ARTIFACTS`(默认 `0`)。关时不落盘,`source_metadata.artifact_path` 写 `None`(字段保留,值为空;先 grep 所有读 `artifact_path` 的地方确认对 `None` 安全:`scripts/vedastro_user_entrypoint.py`、`scripts/diagnose_external_engine_adapters.py`、`scripts/three_engine_parity_runner.py`、`jyotish_api_server.py` 的 `latest_artifact`)。开时照旧写,但改为紧凑 JSON、0600、原子写。 +- `run_official_full_snapshot_for_case`:只有「成功」结果按正常 TTL 缓存;失败 / partial / blocked 用短 TTL `VEDASTRO_OFFICIAL_FULL_SNAPSHOT_NEGATIVE_TTL_SECONDS`(默认 300,且强制 ≤ 正常 TTL)。「成功」的判据由执行方读 `_normalize_official_full_snapshot_success` 与 `_run_official_full_snapshot_case` 的返回形状确定后写进 PROGRESS(预期:`available is True` 且 `status` 为成功态)。语义缓存记录增加 `negative: true/false`,读时按对应 TTL 判断。语义缓存文件改 0600、原子写。 +- `scripts/vedastro_gateway.py::gateway_status` 的 `official_full_snapshot_cache_ttl_seconds` 改为调用 adapter 的 `_official_full_snapshot_cache_ttl_seconds()`(含回落),另加 `official_full_snapshot_negative_cache_ttl_seconds`。 +- 留存:把 `vedastro_adapter/official_full_snapshot_cache/`、`vedastro_adapter/response_cache/`(按各自 `created_at + TTL`)、`vedastro_adapter/*.json` artifact(开关关时:mtime 超 24 h 即删;开关开时:保留 `VEDASTRO_AUDIT_ARTIFACT_KEEP_DAYS`,默认 7)登记进 T1 的表。 +- `.env.cn.example`:在两个 604800 旁加注释说明「失败结果另有短 TTL;含出生资料,7 天为上限」——**不改数值**(是否把生产值调短,需拍板)。 +- 不改:快照的取数、并发、超时、`official_closure_state` 判定。 +- 验收标准: + - 新测试:开关默认关时,一次 `_normalize_official_full_snapshot_success`(用现有测试里的 fixture)不产生任何文件、`artifact_path is None`;开关开时产生 0600 文件;失败结果在 TTL=604800 下 301 s 后不再命中、成功结果仍命中;`gateway_status` 在只设 `VEDASTRO_CACHE_TTL_SECONDS=604800` 时报 604800。 + - 既有 `tests/test_vedastro_official_artifact_manifest.py`、`tests/test_vedastro_official_full_snapshot.py`、`tests/test_vedastro_range_scan_replay.py`、`tests/test_three_engine_parity_runner.py`、`tests/test_vedastro_gateway.py`、`tests/test_vedastro_user_entrypoint.py`、`tests/test_vedastro_runtime_ops.py` 全绿。需要 artifact 文件的既有测试改为显式 `monkeypatch.setenv("VEDASTRO_WRITE_AUDIT_ARTIFACTS", "1")`——这算改测试前置,逐条写「原值 / 新值 / 原因」。 +- 分工:Grok 自跑以上;Claude 复验。 + +### T3 · chart 缓存键规范化(BUG-1330) + +- 新模块里写 `normalize_chart_cache_birth(body)`:`year/month/day` → `int`;`hour/minute/second/lat/lon/tz` 能解析成有限数字的 → `float`(**不四舍五入**,`12`、`12.0`、`"12"` 都成 `12.0`);解析不了的保留原值(不得把不同输入合并);`today` / `transit_date` 取前 10 位。`_build_api_chart_cache_payload` 改用它;`cache_schema_version` 5 → 6(旧缓存自然失效,由 T1 清掉)。 +- `skip_vedastro_main_entry_overview` 维持独立字段(BUG-161);`vedastro_runtime` 指纹不动。 +- **不改** `vedastro_snapshot_cache.snapshot_cache_key`、`consult_full_data_cache.identity_from_body`、`_official_full_snapshot_cache_key`(见决策记录)。 +- 验收标准: + - 新测试:上面 §2.2 的 7 种变体中,数值等价的变体同键;`tz="Asia/Kolkata"` 之类的非数字时区与 `tz=5.5` 不同键;skip 与非 skip 不同键;`test_api_chart_cache_key_tracks_vedastro_runtime_state` 不改仍绿。 + - **逐字节等价证明**:差分脚本对每组数值等价变体,在 `JYOTISH_API_CHART_CACHE_TTL_SECONDS=0` 下各算一次 `_compute_chart_sync`(skip=True 与 skip=False 各一轮),去掉 `runtime_cache` 后 `json.dumps(sort_keys=True)` 必须相同;任何一组不同即说明该字段不能合并,保留原值并写进 PROGRESS。 +- 分工:Grok 自跑;Claude 复验并独立重跑差分脚本。 + +### T4 · 前台快照缓存、负缓存及其它目录的清理(BUG-1332) + +- 登记进 T1 的表: + - `vedastro_snapshot_cache/`:`reference_date` 早于「今天(UTC)− `MAX_STALE_DAYS` − 1」即删(与 `lookup_snapshot` 的回看窗口一致,直接引用该常量,不另写数字)。 + - `vedastro_snapshot_negative_cache/`:`reference_date` 早于今天(UTC)即删。 + - `vedastro_gateway_jobs/`:`updated_at` 超 `VEDASTRO_GATEWAY_JOB_KEEP_SECONDS`(默认 86400)且状态为 completed / failed 的 job 及其 `.official_raw_response.json` 一并删;queued / running 不删。 + - `varga_segments/`:mtime 超 `JYOTISH_VARGA_SEGMENTS_KEEP_DAYS`(默认 7)删。 + - 所有删除只认 64 位十六进制文件名或该目录既有的命名格式,其它文件不碰。 +- 不改:这些模块的读写逻辑、键、时效窗口。 +- 验收标准:新测试覆盖每个目录「该删的删、该留的留、命名不符的不碰」;`tests/test_vedastro_snapshot_cache.py`、`tests/test_vedastro_consultation_snapshot.py` 全绿且断言不改。 +- 分工:Grok 自跑;Claude 复验。 + +### T5 · A/B 验证(P1 必做) + +- 脚本 `scripts/research/api_disk_cache_ab.py`(或 `tools/`,执行方定,写进 PROGRESS),**同进程**分别跑 A(`origin/staging` 原函数,通过 `git show 77057a819:` 导出到临时目录再 import)与 B(新代码): + - 输入:固定 20 张虚构盘 × 3 种数值写法(`12` / `12.0` / `"12"`,`lat` 字符串 / 数字)× skip/非 skip × 模拟 7 个 `today`(连续日期),每组重复 3 次取中位数。 + - 环境:模拟生产依赖(§1 的 `sitecustomize` 挡 `jhora`,Windows 侧装了 vedastro SDK 则**另跑一组不挡 vedastro 的**,外网可用与否如实记录);`JYOTISH_SCRATCH_ROOT` 或 monkeypatch 指到临时目录,不碰工作树 `scratch/`。 + - 度量:每个目录的文件数与总字节(模拟 7 天后、各自清理跑过之后);chart 缓存命中率(命中次数 / 请求数);单次 `/api/chart` 计算耗时中位数(命中 / 未命中);一次全量清理耗时(目录里预置 10,000 个文件时)。 + - 正确性:B 的 chart 响应与 A 去掉 `runtime_cache` 后逐字节一致(T3 差分脚本复用)。 +- 目标(达不到如实写,不改目标):chart 缓存命中率在三种写法混合输入下 B ≥ A 的 2.5 倍;模拟 7 天后 `api_chart_cache` 文件数 B ≤ 当天活跃键数;`vedastro_adapter` 下 artifact 文件数 B = 0(开关默认关);负缓存目录只剩当天文件;清理 10,000 文件 < 2 s 且不在请求线程。 +- 开关默认值按 A/B 结论定:artifact 开关默认关是决策项,不随 A/B 变;周期清理间隔、负 TTL 可按结论调整并写明理由。 +- 记录写 `docs/testing/api-disk-cache-retention-ab-20261013.md`(表格:改前 / 改后 / 变化;命令可复制)。 +- 分工:Grok 跑并交记录;Claude 在 Linux 上按同一命令复跑一遍,数字差 >20% 时以 Claude 为准并在验收里说明。 + +### T6 · 咨询卡片缓存体积(只记录,本单不改代码) + +- 在 PROGRESS 写清 §2.5 的数字与两条可选方向(交产品/下一轮决定):(a) 卡片缓存按领域切片存、命中时只读所需领域;(b) `prune_cache` 改为节流(每 N 分钟一次)而不是每次 `get_or_build` 全目录 stat。**不得**做进程内已解析包缓存(BUG-721)。 +- 若 T1 的周期清理已覆盖 `consult_full_data_cache`,可以把 (b) 顺手收进来(把它登记进表、`get_or_build` 去掉每次 `prune_cache()`),但必须证明两天留存语义不变,且 `tests/test_consult_card_full_source.py` 不改仍绿;做不到就不做。 + +### T7 · 记录 + +- `docs/BUG_HISTORY.md` 新增 BUG-1330(chart 缓存不清理 + 键碎片)、BUG-1331(VedAstro artifact 留存出生资料 + 失败快照长缓存 + TTL 诊断失真)、BUG-1332(前台快照/负缓存/网关任务/分盘段缓存无清理);每条写「防复发:新增磁盘缓存必须同时登记留存规则到 `api_scratch_retention`」,并在测试里加一条**合同**:`scripts/` 下新出现的 `scratch/local/<子目录>` 写入点必须在登记表里(grep 源码实现,白名单列出离线脚本)。 +- `docs/tasks/PROGRESS-api-disk-cache-retention-20261013.md`。 +- `CHANGELOG.md`:一句话(用户不可感知,写「服务器不再长期保留出生资料副本」)。 +- `docs/tasks/README.md` 索引加本单。 +- 不改 `frontend/DESIGN.md`(无 UI)。 + +## 7. 让步顺序 + +时间不够时按此顺序**往后砍**:T6(只记录)→ T4 中 `varga_segments` 与 `vedastro_gateway_jobs` → T3 → T4 其余。T1、T2、T5、T7 不可砍;砍掉的写进 PROGRESS「未做」与 `BLOCKED.md`(如因环境)。 + +## 8. 验收命令分工 + +| 谁 | 命令 | +| --- | --- | +| Grok 自跑 | `.venv` 下:`python -m pytest tests/test_api_scratch_retention.py tests/test_api_async_job_contract.py tests/test_api_server_growth_contract.py tests/test_vedastro_official_artifact_manifest.py tests/test_vedastro_official_full_snapshot.py tests/test_vedastro_range_scan_replay.py tests/test_three_engine_parity_runner.py tests/test_vedastro_gateway.py tests/test_vedastro_user_entrypoint.py tests/test_vedastro_runtime_ops.py tests/test_vedastro_snapshot_cache.py tests/test_vedastro_consultation_snapshot.py tests/test_consult_card_full_source.py -q`;`python -m pytest tests/test_api_server_security.py -q -k "cache"`;T3 差分脚本;T5 A/B 脚本。前端、Python 快速门、全量 pytest **不跑**,以 Claude 为准 | +| Claude 验收 | 上述全部 + `.venv/bin/python scripts/run_quality_gate.py --profile quick` + 全量 `pytest` 与 `origin/staging` 基线逐名比对 + 在模拟生产依赖(挡 `jhora`)下复跑 T3 差分与 T5 A/B + `wc -l scripts/jyotish_api_server.py` ≤ 10,970 | +| 不需要 | 前端任何命令(不改前端);`test:db`(不动数据库) | + +## 9. 开工前置命令 + +```bash +git fetch origin --prune +git worktree add -b codex/api-disk-cache-retention-20261013 .worktrees/api-disk-cache-retention-20261013 origin/staging +cd .worktrees/api-disk-cache-retention-20261013 +git log -1 --format=%h # 应为 77057a819 或其后代 +grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -3 # 核对最大号,确认 1330–1332 未被占 +wc -l scripts/jyotish_api_server.py # 记下开工行数 +grep -rn "ARTIFACT_DIR\|artifact_path\|_write_artifact\|prune_expired_async_jobs\|_build_api_chart_cache_payload" scripts tests --include=*.py +# 键碎片复现: +PYTHONPATH=.:scripts python - <<'EOF' +import jyotish_api_server as s +b=dict(year=1990,month=1,day=1,minute=0,lat=28.6139,lon=77.2090,tz=5.5) +print(len({s._api_chart_cache_key(s._build_api_chart_cache_payload(dict(b,hour=h))) for h in (12,12.0,'12')})) # 改前 3 +EOF +``` + +跑完任何 Python 后 `git status --short` 必须为空(`scratch/` 已在 `.gitignore`,但 A/B 必须写临时目录)。 + +## 10. 环境缺口(预告) + +- 生产 / staging 的 `VEDASTRO_CACHE_TTL_SECONDS`、`VEDASTRO_OFFICIAL_FULL_SNAPSHOT_CACHE_TTL_SECONDS` 实际值未知:部署后由产品负责人在服务器内网查 `/api/vedastro_gateway/status` 的两个 TTL 字段(T2 修正后含回落),只回报数字,不贴其它内容。 +- 生产卷上已有的存量文件数量与体积未知:部署后可让运维在容器内跑 `du -sh /app/scratch/local/*`(只回报各目录体积与文件数)。新清理上线后首次启动即会清掉按规则可删的部分。 +- 真实 VedAstro 外网下的快照文件体积、成功率:本机无 SDK、无外网,A/B 只能在 Grok 侧(若有 SDK 与外网)或 staging 部署后复核。 diff --git a/docs/tasks/TASK-api-server-service-extract-20261013.md b/docs/tasks/TASK-api-server-service-extract-20261013.md new file mode 100644 index 00000000..49493bed --- /dev/null +++ b/docs/tasks/TASK-api-server-service-extract-20261013.md @@ -0,0 +1,197 @@ +# TASK · API 单体第一阶段:拆出不带 HTTP 的计算类,生产代码不再伪造 handler(issue #待定) + +- 日期:2026-10-13 +- 来源:2026-10-13 全仓屎山与性能排查 ·「`jyotish_api_server.py` 单体 / `JyotishAPIHandler.__new__` 伪造」条目 +- 执行方:**Grok(Windows + Docker)**,分支 `codex/api-server-service-extract-20261013`,只推这个分支 +- 验收:Claude(Linux) +- BUG 编号:**无 BUG 编号**(结构改造,不是缺陷),记录写 PROGRESS(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起) +- **开工前置**:`dasha-unify`(BUG-1341)、`python-hygiene`(BUG-1340)两单已合入 `origin/staging`。两单都改 `scripts/jyotish_api_server.py`,本单整类重排,必须最后做。 +- **主会话补充的串行链(2026-10-14)**:引擎/API 文件链按此顺序逐单合入后开工(`upstream-sync7` 也改 `jyotish_engine.py` / `jyotish_api_server.py`,若在途,后合一方 rebase):`api-disk-cache-retention` + `report-reference-perf` → `api-chart-subprocess-gate` → `pl9-render-split` → `python-hygiene` → `dasha-unify` → `api-server-service-extract`。本单的前置以这条链为准(与上一行不一致时以本行为准)。 + +## 1. 基线 + +- 基线 commit:`origin/staging` `77057a819`(写作时);开工以前置两单合入后的 `origin/staging` 为准,第 9 节命令重测全部数字。 +- 前序任务书(必读):`docs/tasks/TASK-api-server-decomposition-20260916.md`(阶段 1 目标「`__new__` 归零」)、`docs/tasks/TASK-api-server-backdoor-close-20260916.md` 与 `docs/tasks/PROGRESS-api-server-backdoor-close-20260916.md`(mixin 方案 spike **红**、退回 A,scripts 侧伪造 4 → 2)。 + +## 2. 事故实证(`77057a819`) + +### 2.1 现状数字(排查报告有两处要修正) + +| 指标 | 排查报告 | 实测 | 口径 | +| --- | --- | --- | --- | +| 文件行数 | 10,970 | **10,970** | `wc -l` | +| `JyotishAPIHandler` 类体 | 7,751 行 / 215 方法 | **7,752 行 / 215 个类级方法**;合同口径(全文件 `^ def`)**218** | AST / `tests/test_api_server_growth_contract.py` | +| `JyotishAPIHandler.__new__` | 34 处、23 个测试文件 | **31 处**:`scripts/` 2(`consultation_workflow_service.py::execute_consultation_workflow`、`capture_report_blocked_repairs_golden.py::_handler`),`tests/` 29(**22** 个文件) | 合同口径(文本计数,不含合同文件本身) | +| 合同基线 | 31 | 行数 10,924 + 300;方法 218;伪造 31;scripts 伪造 2 | 合同文件常量 | + +### 2.2 `execute_consultation_workflow` 不是 400 行的问题 + +排查报告建议「把 `execute_consultation_workflow`(400 行)及其依赖方法改成不依赖 self 的服务模块」。AST 求它的**传递闭包**(从模块级 `execute_consultation_workflow` 出发,沿 `handler.x` / `self.x` 与同文件模块级函数引用展开): + +| 闭包成分 | 个数 | 行数 | +| --- | ---: | ---: | +| `JyotishAPIHandler` 方法 | **107** | **3,805** | +| 同文件模块级函数 | **62** | **1,958** | +| 用到的 mixin 方法 | 6 | (已在 `offline_compute_mixins.py`) | +| 引用 HTTP 成员(`headers` / `wfile` / `send_*` / `client_address` …) | **0** | — | + +也就是说,「不依赖 self 的服务模块」= 搬 ~5,800 行,远超一轮可控的范围;而且 9 月 16 日的 spike 已证明这条路会撞墙:闭包里有 5 个模块级函数被测试**按 `jyotish_api_server` 模块属性打补丁**(`_load_local_module`、`_write_async_job_record`、`_async_job_dir`、`_sqlite_job_db_path`、`_attach_vedastro_main_entry_overview`),其中 `tests/test_api_server_security.py`(约第 2399 行 `_compute_consultation_workflow` 用例)与 `tests/test_consultation_consumer_context.py`(约第 211 行)直接走咨询链。函数搬到新模块后,查找走新模块 globals,补丁静默失效。 + +### 2.3 真正可以一刀切开的地方:HTTP 只占 15 个方法 + +对类内 215 个方法求「直接碰 HTTP 成员或覆写 `BaseHTTPRequestHandler`」及其**向上**调用者: + +| 集合 | 方法 | 行数 | +| --- | ---: | ---: | +| 直接碰 HTTP | 9:`_json`、`_html`、`_send_cors_headers`、`_read_json_body`、`_enforce_request_security`、`_job_access_token`、`do_GET`、`do_POST`、`do_OPTIONS` | 456 | +| 调用上面任一的方法 | 6:`_error_json`、`_get_chart_job`、`_get_high_rigor_job`、`_require_dynamic_rectification_token`、`_compute_dynamic_rectification_score`、`_compute_dynamic_rectification_opportunities` | 119 | +| **合计 HTTP 侧** | **15** | **575** | +| 其余(纯计算) | 200 | ~7,170 | + +另外核实: + +- 类里**没有任何方法给 `self.xxx` 赋值**(无实例状态);唯一的动态读取是 `_enforce_request_security` 的 `getattr(self, 'client_address', …)`(属于 HTTP 侧)。 +- 与 `BaseHTTPRequestHandler` 同名的类属性只有 `server_version`(必须留在 HTTP 类,否则 MRO 会让基类的值生效)。 +- 咨询闭包 107 个方法与 HTTP 侧 15 个方法**交集为空**。 + +所以:把类拆成「纯计算基类 + HTTP 子类」,**两个类都留在同一个文件里**——模块 globals 不变,测试补丁落点不变,`self` 语义不变;纯计算类可以正常 `JyotishComputeService()` 实例化,不再需要绕过 `BaseHTTPRequestHandler.__init__`。 + +### 2.4 测试里的 29 处伪造 + +按「文件内是否调用 HTTP 侧方法或读写 `headers` / `wfile` / `client_address`」粗分(grep 启发式,执行方以实际运行为准): + +| 类别 | 文件数 | 伪造处数 | +| --- | ---: | ---: | +| 需要 HTTP 侧(`test_api_server_security.py`、`test_api_async_job_contract.py`、`test_runtime_security_p0.py`、`test_dynamic_rectification_scoring.py`、`test_professional_report_reference_api.py`) | 5 | 5 | +| 只用计算方法 | 17 | 24 | + +### 2.5 咨询工作流输出的确定性(golden 方法依据) + +用 `scripts/consultation_workflow_service.execute_consultation_workflow`(MCP 入口)跑虚构盘(1990-01-01 12:00 新德里 28.6139/77.2090 +5.5,`theme=["career"]`,`defer_optional_external_evidence=True`,`today=2026-10-13`,`VEDASTRO_ENABLE_NETWORK=0`),约 1 秒、响应约 420 KB: + +| 对比 | 结果 | +| --- | --- | +| 两次都清空排盘缓存(`scratch/local/api_chart_cache`) | 只差 2 个字段:`chart.runtime_cache.cache_created_at` / `cache_expires_at`(墙钟) | +| 换 `PYTHONHASHSEED` | 同上,与种子无关 | +| 第二次命中排盘缓存 | 另有 36 处差异:`chart.consultation_native_layers.argala.*` 里 `Sun` / `Saturn` 先后顺序互换(**附带发现**:缓存往返改变了 Argala 列表顺序,语义相同;本单不修,列入回报) | + +结论:golden 比对必须「清空排盘缓存 + 去掉这两个墙钟字段」,其余逐字节相等。 + +## 3. 根因 + +业务逻辑长在 HTTP handler 类上,非 HTTP 调用方(MCP、golden 生成)没有合法实例可用,只能 `__new__` 伪造。9 月两单想把逻辑**搬出文件**,撞上「测试按模块属性打补丁」;本单换方向:**不搬出文件,先在文件内把 HTTP 与计算分成两个类**,伪造就失去存在理由。 + +## 4. 决策记录 + +- 2026-10-13 产品负责人同意按排查结论全部立单交 Grok 执行。 +- **范围修正(主会话转交产品确认)**:排查报告的「第一阶段把 `execute_consultation_workflow` 及依赖改成独立服务模块」实测闭包约 5,800 行且会触发 9 月 spike 的补丁失效,**本单不做**;改为同文件内拆类(约 600 行纯移动),达成同一个可验证目标:**生产伪造 2 → 0**。 +- 9 月 `TASK-api-server-backdoor-close` 红线「`tests/test_api_server_security.py` 一条断言不许改」**继续有效**:本单不改它的任何断言,也不改它的补丁落点。 +- 本单允许改测试里的**构造语句**(`JyotishAPIHandler.__new__(JyotishAPIHandler)` → `JyotishComputeService()`),这不是断言;仍按三栏写进进度记录。`test_api_server_security.py` 里那 1 处伪造属于 HTTP 侧,不改。 +- 增长合同基线**只许下调**:scripts 伪造 2 → 0;总伪造 31 → 实测;方法数与行数不变或下降。 +- `consultation_workflow_service.py` 仍 import `jyotish_api_server`(依赖方向的根治属于后续阶段,需要拍板改补丁落点,见末尾)。 + +## 5. 硬红线 + +1. **纯移动**:方法体逐字不改;只允许把 15 个 HTTP 侧方法与 `server_version` 从原类剪到新 HTTP 子类、改类声明、调整两个调用方的构造语句。用 `git diff --color-moved=dimmed-zebra` 与 AST 方法体对比(第 6 节 T2)证明。 +2. `tests/test_api_server_security.py` 一个字节不改且全绿。 +3. `scripts/jyotish_api_server.py` 净行数不得增长:拆类新增约 3 行(新类声明 + PEP 8 空行),**用同文件真实死代码抵消**——写作时全仓按名零引用的模块级函数有 `_strict_adjudication_bundle_from_contract`(16 行)与 `_high_rigor_job_path`(2 行),开工重核(含 `skills/*/versions/**` 以外的全部 `.py` / `.ts`、字符串引用、`getattr`),确认无引用才删;不得用合并语句、删注释凑行数。 +4. 类方法数(合同口径)不得增长;`JyotishAPIHandler.__new__` 计数只许降。 +5. 不新增端点、不改任何端点请求 / 响应合同;HTTP 路由、安全检查、限流 429、异常映射行为不变。 +6. 新计算类不得继承 `BaseHTTPRequestHandler`,其方法不得引用 HTTP 成员(加 AST 测试钉住)。 +7. 不改 `.gitea/workflows/**`、不动表结构、不升级依赖、不碰 `skills/*/versions/**`。 +8. golden 只能来自真实引擎输出;改既有断言写三栏(本单预期只有增长合同常量)。 + +## 6. 任务分解 + +### T1 · 搬前 golden(必做,先做) + +- 写一个采集脚本(放 `scripts/research/` 或测试 helper,**不放进 `scripts/` 根目录**),在**基线工作树**上用第 2.5 节方法采集: + 1. MCP 入口 `consultation_workflow_service.execute_consultation_workflow`:career、wealth、relationship 三题各一次; + 2. HTTP 侧 handler 的 `_compute_consultation_workflow`(构造方式与现有测试相同)同三题; + 3. `scripts/capture_report_blocked_repairs_golden.py` 现有产物; + 4. `mcp_server.py` 里经由 `build_runtime_evidence_helpers` 的路径一次(9 月已有端到端测试,复用)。 +- 去掉 `cache_created_at` / `cache_expires_at`,`json.dumps(sort_keys=True, ensure_ascii=False)` 落盘到 `tests/golden/`(体积过大则只存 sha256 + 关键键路径,fixture 写 `fixtureProvenance`:命令、基线 SHA、环境变量)。 +- 验收:基线上连跑两次哈希一致(证明可复现)。 + +### T2 · 同文件拆类(必做) + +- `class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryMixin)` 改为 `class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin)`,保留 200 个计算方法原位不动。 +- 在其后新增 `class JyotishAPIHandler(BaseHTTPRequestHandler, JyotishComputeService)`,放入 `server_version` 与第 2.3 节 15 个 HTTP 侧方法(开工按同口径重算名单,进度记录贴脚本输出)。`ThreadingHTTPServer((host, port), JyotishAPIHandler)` 不变。 +- 类 docstring 一行说明两类分工即可(计入行数,用第 5 节第 3 条的死代码抵消)。 +- 验收: + - AST 对比脚本:基线与本分支两个文件里,所有方法按名取 `ast.dump(body)`,逐个相等;方法总集合相等;只有 16 个成员换了所属类。 + - `JyotishAPIHandler.__mro__` 中 `BaseHTTPRequestHandler` 先于 `JyotishComputeService`;`JyotishAPIHandler.server_version` 值不变。 + - `tests/test_api_server_security.py` 全绿、零改动(`git diff --stat` 为空)。 + - `tests/test_offline_compute_mixins.py` 第 85–86 行 `issubclass(JyotishAPIHandler, VedastroEvidenceMixin / SynastryMixin)` 仍成立(经由新基类)。 + +### T3 · 生产伪造归零(必做) + +- `scripts/consultation_workflow_service.py::execute_consultation_workflow`:`JyotishAPIHandler.__new__(JyotishAPIHandler)` → `JyotishComputeService()`。 +- `scripts/capture_report_blocked_repairs_golden.py::_handler`:同上(返回类型注解随之改)。 +- 验收: + - T1 的 MCP 与 HTTP 两路 golden 逐字节一致(第 2.5 节方法);`capture_report_blocked_repairs_golden.py` 产物与 T1 一致。 + - `grep -rn "JyotishAPIHandler.__new__" scripts` 零命中。 + +### T4 · 测试伪造下调(可砍) + +- 对第 2.4 节「只用计算方法」的 17 个文件,把构造语句换成 `JyotishComputeService()`;逐文件跑,**红了就回退该文件**(说明它其实用到 HTTP 侧),回退名单写进进度记录。 +- 每处改动按三栏记录(原值 `JyotishAPIHandler.__new__(JyotishAPIHandler)` / 新值 `JyotishComputeService()` / 原因:计算类可正常实例化,不再需要绕过 HTTP 构造)。 + +### T5 · 合同与守卫(必做) + +- `tests/test_api_server_growth_contract.py`: + - `JYOTISH_API_HANDLER_NEW_SCRIPTS_BASELINE` 2 → **0**;`JYOTISH_API_HANDLER_NEW_COUNT_BASELINE` 31 → 收尾实测;方法数基线若因删死代码下降同步下调;行数基线不动(只要不超)。三栏写在常量旁注释与进度记录。docstring 更新日期与说明。 + - 新增两条:① `JyotishComputeService` 不是 `BaseHTTPRequestHandler` 子类;② AST 检查 `JyotishComputeService` 的方法体不引用 `self.headers` / `wfile` / `rfile` / `client_address` / `send_response` / `send_header` / `end_headers` / `path` / `command`,也不调用第 2.3 节 HTTP 侧方法名。 + - 反向验证:在计算类里临时加一行 `self.headers`,第 ② 条变红;在 scripts 里临时加一处 `JyotishAPIHandler.__new__`,scripts 伪造条变红。输出贴进度记录后撤回。 +- `AGENTS.md` §6 措辞若提到伪造基线数字,同步数字(纯文档)。 + +### T6 · 记录(必做) + +- `docs/tasks/PROGRESS-api-server-service-extract-20261013.md`:第 2 节全部数字的开工重测值、HTTP 侧名单脚本输出、AST 方法体对比结果、golden 对照、T4 回退名单、反向验证输出、搬前 / 搬后行数与方法数。 +- 不写 BUG_HISTORY;不写 CHANGELOG(无用户可感知变化);不改 `frontend/DESIGN.md`。 + +## 7. 让步顺序 + +时间不够按此顺序砍:**T4 → T5 第 ② 条 AST 守卫(保留 ① 与基线下调)**。T1、T2、T3、T5 基线下调、T6 不可砍。若 T2 让 `test_api_server_security.py` 任何一条变红且不是搬错所致,**停下回报,不得改测试**。 + +## 8. 验收命令分工 + +| 谁 | 命令 | +| --- | --- | +| Grok 自跑 | `python -m pytest tests/test_api_server_security.py tests/test_api_server_growth_contract.py tests/test_offline_compute_mixins.py tests/test_consultation_workflow_domains.py tests/test_consultation_consumer_context.py tests/test_consultation_plan_contract.py tests/test_commercial_domain_calculation_contract.py tests/test_calculation_p0_regressions.py -q` + T4 改到的每个测试文件 + T1/T3 golden 对照 + AST 方法体对比。快速门、全量 pytest **不跑**,以 Claude 为准 | +| Claude 验收 | 上述 + 快速门 + 全量 `pytest` 与基线逐名比对 + 本机起 API 服务做冒烟:未登录 `/api/health` 200、`/api/chart` 虚构盘 200、超大请求体与非 JSON 的错误码与基线相同 | +| 不需要 | `test:db`、前端 | + +## 9. 开工前置命令 + +```bash +git fetch origin --prune +git log --oneline origin/staging | grep -i "dasha-unify\|python-hygiene" # 两单都已合入 +git worktree add -b codex/api-server-service-extract-20261013 .worktrees/api-server-service-extract-20261013 origin/staging +cd .worktrees/api-server-service-extract-20261013 +wc -l scripts/jyotish_api_server.py +grep -c "^ def \|^ async def " scripts/jyotish_api_server.py +grep -rn "JyotishAPIHandler.__new__" --include=*.py scripts tests | grep -v test_api_server_growth_contract | wc -l +grep -rn "JyotishAPIHandler" --include=*.py scripts tests mcp_server.py | grep -v "__new__" | head -40 # 其他按类名的用法(issubclass、注解、import) +python -m pytest tests/test_api_server_security.py -q | tail -3 +python -m pytest tests -q -p no:cacheprovider > /tmp/baseline-full.txt 2>&1; tail -3 /tmp/baseline-full.txt +``` + +## 10. BUG 编号起点 + +无(结构改造)。若途中发现缺陷,按开工时 `docs/BUG_HISTORY.md` 最大号 +1 另记,并在回报里说明。 + +## 11. 交付物清单 + +- 代码:`scripts/jyotish_api_server.py`(拆类、删两处死代码)、`scripts/consultation_workflow_service.py`、`scripts/capture_report_blocked_repairs_golden.py` +- 测试:`tests/test_api_server_growth_contract.py`(基线下调 + 两条新守卫)、T1 golden 与对照测试、T4 改到的测试文件 +- 文档:`docs/tasks/PROGRESS-api-server-service-extract-20261013.md`;(可能)`AGENTS.md` §6 数字 +- 不改:`frontend/**`、`frontend/DESIGN.md`、`CHANGELOG.md`、`docs/BUG_HISTORY.md` + +## 后续阶段(本单不做) + +1. **把咨询闭包搬出文件**(107 方法 / 3,805 行 + 62 函数 / 1,958 行):前提是先解决 5 个被按模块属性打补丁的函数。可行做法是把 `_load_local_module` 与异步任务记录层抽成独立模块、两边都按「模块属性」调用,并把测试补丁落点改到新模块——**会改 `test_api_server_security.py` 的补丁行,需产品拍板**(9 月红线)。之后 `consultation_workflow_service.py` 才能不再 import 单体。 +2. 按入口继续切计算类:图表计算、报告、校正、外部证据各成模块(每次一个,增长合同只许降)。 +3. `do_POST` / `do_GET` 改路由表(9 月 decomposition 阶段 3,仍未做)。 +4. 测试里剩下的 5 处 HTTP 侧伪造:给 HTTP 子类一个测试用构造工厂(放 `tests/` 辅助模块),伪造计数归零。 +5. 附带发现:排盘缓存命中时 Argala 列表顺序与未命中不同(第 2.5 节),另开单。 diff --git a/docs/tasks/TASK-consult-prep-parallel-20261013.md b/docs/tasks/TASK-consult-prep-parallel-20261013.md new file mode 100644 index 00000000..4648a39c --- /dev/null +++ b/docs/tasks/TASK-consult-prep-parallel-20261013.md @@ -0,0 +1,242 @@ +# TASK · 普通对话发问前的准备步骤改为并行(issue #待定) + +- 日期:2026-10-13 +- 来源:「2026-10-13 全仓屎山与性能排查」P2 · 咨询前置并行 / 多领域并发 / 报告章节并发 +- 执行方:**Grok(Windows + Docker)**,分支 `codex/consult-prep-parallel-20261013`,只推这个分支 +- 验收:Claude(Linux) +- BUG 编号:**BUG-1337~1339**(开工时核对 `docs/BUG_HISTORY.md` 最大号;写作时最大号 BUG-1317,1335/1336 已被其它单占用)(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起) +- 开工前置:无(第一批)。**本单之后**还有两单改同一批文件,必须串行:`answer-effort-ab`(改 `consultation-tools.ts`)→ `consult-route-split`(搬 `route.ts`)。本单不要顺手搬 `route.ts` 的其它段落。 + +## 1. 基线 + +- 基线 commit:`origin/staging` `77057a819` +- 涉及文件: + - `frontend/src/app/api/consult/route.ts::POST`(1,840 行文件,`POST` 从第 296 行起) + - `frontend/src/lib/model-catalog.ts::readVersion` / `resolveSessionLanguageModel` / `invalidateLanguageModelCatalog` + - `frontend/src/lib/moderation/index.ts::moderate` + - `frontend/src/mastra/consultation-tools.ts::createConsultationTools`(多领域循环) + - `frontend/src/lib/personal-report-generation.ts::generateSectionedPersonalReport`(只核实,不改,见 §4 决策 4) + +## 2. 事故实证 + +### 2.1 开流前的串行等待(`route.ts::POST`) + +开流(`streamFirstResponse`,第 1821 行)之前,下面每一步都是 `await` 完上一步才开始: + +| 顺序 | 行(`77057a819`) | 步骤 | 依赖 | +| --- | --- | --- | --- | +| 1 | 301–316 | `createServerSupabaseClient` + `supabase.auth.getUser()` | — | +| 2 | 318 | `refuseWhenAccountDeletionPending(user.id)`(一次 service client RPC) | 只要 `user.id` | +| 3 | 321 | `request.json()` + zod | 无 | +| 4 | 331 | 读 `chat_sessions`(含整段 `messages`) | 只要 `user.id` + 请求体的 `sessionId` | +| 5 | 365 | `resolveSessionLanguageModel(model_id, model_config_version)` | 第 4 步 | +| 6 | 424 | `moderate(visibleQuestion, "input")`:本地词表恒跑;`MODERATION_PROVIDER=aliyun` 时再远程,上限 `MODERATION_REMOTE_TIMEOUT_MS = 1_500` | 第 4、5 步通过 | + +第 2 步与第 3~5 步互不依赖;第 6 步排在首帧之前,开了阿里云时**首帧最多晚 1.5 s**(BUG-1074 的「先建流」只把第 444 行以后搬进了流里)。 + +流内(`runTurn`)还有两处串行: + +- 第 674 行 `classifyConsultationTurn`(`SMALLTALK_TIMEOUT_MS = 3_000`)排在 `prepareConsultationRoute`(资料/时区/候选区间读取 + `reserve_consultation_usage`)与 `append_consultation_question` 之后。 +- 第 741 行分类完成后才再读一次 `chat_sessions.title`(只读,不依赖分类结果)。 + +### 2.2 `readVersion` 每轮查库 + 解密 + DNS(`model-catalog.ts`) + +`resolveSessionLanguageModel` 在会话绑定了版本号时(普通对话都绑定)走 `readVersion` → `queryCatalog`(一次联表 SQL)→ `resolveRow`:`decryptModelProviderApiKey` + 对 openai-compatible 供应商 `assertAllowedModelProviderUrl`(`dns.lookup` 全部地址并逐个判公网)。**没有缓存**;同文件的 `loadLanguageModelCatalog` 对**同样的**解析结果(含同一个 DNS 校验)已经缓存 15 s(`cacheTtlMs = 15_000`)。 + +核实「版本不可变」:**只对了一半**。 + +- 不可变:已发布/已退役版本所用供应商的 `code`、`provider_type`、`base_url`(触发器 `prevent_published_model_provider_runtime_mutation`,`frontend/supabase/migrations/20260808010000_model_provider_emergency_disable.sql`;管理端报 `model_provider_runtime_immutable`)。 +- **可变**:`model_providers.enabled`(紧急停用)、`model_config_versions.enabled`、供应商密钥(重新启用时可换 key)。`readVersion` 的 where 条件里有 `v.enabled and p.enabled`,所以缓存必须有 TTL 且能被管理端操作清掉。 + +核实 SSRF(AGENTS.md §8.3):`assertAllowedModelProviderUrl` 是**解析模型配置时**的公网地址校验;之后真正发模型请求的是 Mastra 的 `createOpenAICompatible`(`baseURL` 是主机名,`fetch` 会重新解析 DNS,不是 `pinnedAddressLookup` 固定地址)。所以今天这道 DNS 校验也不是逐请求绑定,目录路径已经把它缓存 15 s。结论:**按(模型, 版本)缓存、TTL 与目录相同(15 s)、管理端变更时一并清除,不削弱现有防护**;TTL 更长或缓存失败结果则会削弱,不做。 + +### 2.3 多领域逐个 `await`(`consultation-tools.ts::createConsultationTools`) + +第 1230–1280 行附近 `for (let index = 0; index < domains.length; …)` 逐个 `await runConsultationWorkflow`(`POST /api/consultation_workflow`)。`MAX_CONSULTATION_DOMAINS = floor((110_000 − 45_000) / 31_000) = 2`。`/api/consultation_workflow` 在 `scripts/api_heavy_compute_gate.py::HEAVY_COMPUTE_PATHS` 里,`JYOTISH_HEAVY_COMPUTE_CONCURRENCY` 默认 2,满了直接 429;前端 `consultation-workflow.ts::httpFailureCode` 把 429 记成 `workflow_rate_limited`,**不退避重试**,整次计算失败。 + +本机实测(起草时,`77057a819`,系统 python3.13,无 vedastro SDK,本地起 `ThreadingHTTPServer`,Steve Jobs 公开 AA 盘,事业 + 婚恋两领域,每组 3 次取中位数): + +| 情形 | 串行 | 两路并发 | +| --- | --- | --- | +| 盘已缓存 | 0.47 s | 0.60 s(**更慢**) | +| 盘未缓存(每轮换出生分钟) | 2.25 s(第一域约 2.0 s,第二域吃到排盘缓存 0.29 s) | 2.03 s(两路各自冷算约 2.0 s) | + +复现:`scratchpad/bench-prep/bench2.py`、`bench3.py`(起草会话临时目录,执行方按 §6 T5 重写进 `frontend/scripts/research/`)。 + +解读:Python 服务是 `ThreadingHTTPServer`,排盘吃 GIL;串行时第二个领域复用第一个领域的排盘缓存,并发时两路各算一遍。**本机并发只省约 10%,CPU 翻倍、一次占满两个重计算名额。** 生产容器装了 vedastro SDK,每域约 1.66 s 里有 1.33 s 是 BUG-301 保留的前台网络等待(`consult-latency-audit` 10-05 记录),网络等待会释放 GIL,所以生产上的收益可能比本机大——要靠 A/B 实测,不能推断。 + +另:同一盘、同一分钟,「串行时第二个领域(命中缓存)」与「冷启动单算该领域」的响应逐字段比对,除计时/缓存字段(`consult_card_facts.timing.*`、`chart.runtime_cache.*`)外,还有 `module_execution_audit[*].module_id` 与 `consultation_native_layers.d9_summary.vargottama` 两处**列表顺序**不同(集合遍历顺序,ERR-111:需 `PYTHONHASHSEED=0`)。所以 §6 T5 的差分脚本必须固定 `PYTHONHASHSEED=0` 并列出忽略字段,不能直接 `cmp`。 + +### 2.4 报告章节并发:目标代码已退役 + +`generateSectionedPersonalReport`(`personal-report-generation.ts` 第 3510 行)确实逐章串行,但它只在 `PERSONAL_REPORT_WRITER_ENABLED` 为真时被 worker 调用;`frontend/src/lib/personal-report-writer-flag.ts` 写死 `false`(注释:「Writer narrative pipeline is retired for new reports. Keep the code; do not call it.」,`cfcd369d4`)。新报告走 `generateLongformReport`(Python 完整数据版模板渲染,不逐章调模型)。`/api/reports` 的 `resolveReportCreate` 有 `jobs` 时只入队。**改它对线上零影响**,见 §4 决策 4。 + +## 3. 根因 + +- 2.1:不是回归。各步按当时目的串行写成;BUG-1074 只把「会等模型或数据库的大头」搬进流里,开流前那几步与流内的读标题没有人回头看能否并行;输入审核 09-30 加在开流前。 +- 2.2:从未设计缓存。目录缓存(15 s)只服务「没绑版本」的旧路径,绑版本的 `readVersion` 是后加的。 +- 2.3:BUG-257 的防复发明确写了「任何领域级并行提议必须先证明后端能承接并发,判据是 Python 侧的进程模型、GIL 约束与有界队列」。本单只做开关和证明,不默认开启。 + +关联记录(均已读完并核对防复发仍在): + +| BUG | 与本单的关系 | 必须保留的防线 | +| --- | --- | --- | +| BUG-1074 | 先建流、首帧是确定性进度句 | 开流前只留鉴权/请求体/会话/会话模型(`consult-first-frame-and-pacing-20260928.test.tsx` 两张清单);流内拒绝必须能表达成 `request_rejected` | +| BUG-1075 | 放字节奏 | 不碰 | +| BUG-976 / 977 | 寒暄分类 | 「reserve 仍在分类前,零点无订阅仍被拦」——分类器不得早于预留 | +| BUG-1142 | 答题钟 300 s / 工具钟 110 s / 领域预算 65 s、2 个 | 领域上限公式不变,并发不得改 `MAX_CONSULTATION_DOMAINS` | +| BUG-257 / 945 | 领域上限由时钟推导;计划长度与执行长度两个常数 | 并发仍按 `executableDomainPlan` 截断、仍披露 `omitted_domains`、仍降级 | +| BUG-723 | 引擎 429 单独成档 | 并发版本对 429 不得压平;并发导致的 429 必须在 A/B 里计数 | +| BUG-1231 / 1232 | 分段计时(`modelSteps`、`answer.reasoning_ms`、分类耗时进用量) | 不删字段;本单新增的准备阶段计时走同一个 `logAgentObservability` | +| BUG-148 | DNS 固定地址请求 | `requestAllowedModelProvider` 不动 | +| BUG-496 | 报告分章失败不得中止其余章 | 本单不改报告 | + +## 4. 决策记录 + +产品授权:2026-10-13 产品负责人同意按排查结论全部立单交 Grok 执行;**P1/P2 改动必须做 A/B、做成开关,默认值按 A/B 定**。 + +1. 开流前的独立读取并行(删号检查 ∥ 读请求体 + 读会话 → 解析模型):做。 +2. 输入审核:**仍在任何模型调用和预留之前生效**,但不再挡首帧——开流后与资料读取并行,在 `reserve` 之前等它的结论;被拦时仍不扣点、不进模型;报错优先级与今天一致(见 T2)。不推翻 BUG-1074 的开流前清单(审核本来就不在那张清单里)。 +3. `readVersion` 缓存:做,TTL 与目录缓存相同(15 s),不缓存失败和 `null`,管理端模型/供应商变更时清除。不延长 TTL。 +4. **报告章节并发:本单不做**。目标函数已退役(§2.4),改了不会让任何用户的报告变快;排查报告里这一条作废。若产品以后重新启用 writer 管线,另立单。 +5. **分类器与预留并发:本单不做,需拍板。** 它会让零点用户的问题在预留失败前先送进模型(违反 BUG-976 边界「reserve 仍在分类前」),且分类调用产生供应商成本。可选折中「预留成功后,分类与 `append_consultation_question` 并行;append 失败则丢弃分类结果并取消」也涉及计费顺序,同样留待拍板。 +6. 多领域并发:做成开关,**默认关**(串行),A/B 达标才改默认。不改领域上限、不改时钟。 +7. 不推翻任何既有红线。 + +## 5. 硬红线 + +1. 被审核拦下的问题:不扣点(不得调用 `reserve_consultation_usage`)、不进任何模型(分类器、Agent、标题生成都不得先于审核结论)。 +2. 开流前的 HTTP 状态集合与先后顺序不变:401 → 423 → 400(请求体)→ 503(读会话失败)→ 404 → 409(`session_not_consultation`)→ 409(会话模型已变)→ 503(模型不可用)→ 409(旧校正入口)→ 400(`blocksPromptExtraction`)。并行后**按这个顺序**判定返回,不得因为谁先回来就先报谁。 +3. 删号冻结(423)必须在任何扣点、写库之前判定(`account-deletion.test.tsx` 的 `if (frozen) return frozen;`)。 +4. `readVersion` 缓存:只缓存成功解析的结果;TTL ≤ 15 s;`invalidateLanguageModelCatalog()` 同时清它;不缓存 DNS 失败或非公网地址的结果;不改 `assertAllowedModelProviderUrl` 与 `requestAllowedModelProvider`。 +5. 多领域并发开关打开时:`executions` 顺序必须等于计划顺序(不得按完成先后);任一领域失败的处理与串行一致(整次计算失败、错误码不变);429 不得被吞或改码;`MAX_CONSULTATION_DOMAINS`、`domainFitsRunBudget` 的公式与常量不变。 +6. 不动表结构、不加迁移;不改 `scripts/jyotish_api_server.py`(净行数不得增长,`tests/test_api_server_growth_contract.py`);不碰 `frontend/src/app/(app)/page.tsx`;不改 `.gitea/workflows/**`;不升级依赖;不顺手修 warning。 +7. 改既有断言写「原值 / 新值 / 原因」三栏;不得为让测试过而弱化断言。开工先 grep(AGENTS.md §7.8): + ```bash + grep -rn "await moderate(visibleQuestion\|await resolveSessionLanguageModel\|return await readVersion\|refuseWhenAccountDeletionPending\|const runTurn = async\|for (let index = 0; index < domains.length" frontend/tests tests + ``` + 已知会受影响:`frontend/tests/content-moderation.test.ts`(约 242–243 行:`await moderate(visibleQuestion, "input"` 存在且在 `reserve_consultation_usage` 之前)、`frontend/tests/model-catalog.test.ts`(约 48–49 行:`return await readVersion(...)`、`await resolveSessionLanguageModel(`)、`frontend/tests/application-billing-contract.test.ts`(约 160 行)、`frontend/tests/consult-first-frame-and-pacing-20260928.test.tsx`(256–289 行两张清单)、`frontend/tests/account-deletion.test.tsx`(约 111–122 行)、`frontend/tests/consultation-agentic-runtime.test.ts`(领域循环相关用例)。 +8. 输出逐字节一致:开关全关时,`/api/consult` 的 NDJSON 事件序列与 `77057a819` 相同;开关全开时,除新增的 `consultation-prep-v1` 可观测日志外,响应体与落库内容与全关相同(T7 差分验证)。 +9. 公开仓库:A/B 记录、PROGRESS 只写虚构盘或公开名人盘;不写真实用户资料、key、日志原文。 + +## 6. 任务分解 + +所有新开关在 `frontend/src/lib/consult/prep-flags.ts`(新建目录 `frontend/src/lib/consult/`,后续 `consult-route-split` 单沿用)集中读取,每个请求读一次 `process.env`,非法值按默认处理并 `console.warn` 一次。 + +| 开关 | 取值 | 本单代码默认 | A/B 后的默认 | +| --- | --- | --- | --- | +| `CONSULT_PREP_PARALLEL` | `on` / `off` | `off` | 按 T7 判定 | +| `MODEL_VERSION_CACHE_TTL_MS` | `0`~`15000` 的整数,`0` 关 | `0` | 按 T7 判定(开则 15000) | +| `CONSULT_DOMAIN_CONCURRENCY` | `1` / `2` | `1` | 按 T7 判定 | + +> 默认值的改动(若 A/B 达标)只改 `prep-flags.ts` 里的常量,单独一个提交,便于回滚。 + +### T1 · 开流前并行(BUG-1337 的一半)(Grok 自跑 / Claude 验收) + +- 新建 `frontend/src/lib/consult/turn-prep.ts`,导出 `loadConsultTurnHead(deps)`:输入为注入的依赖(`getUser`、`readPendingDeletion`、`readBody`、`readSession`、`resolveModel`),返回 `{ ok: false, response }` 或 `{ ok: true, user, parsed, chatSession, sessionModel }`。`route.ts::POST` 用它替换第 301–377 行的判定段,`NextResponse.json(...)` 的文案、状态码、`code` 原样搬过去(逐字)。 +- `CONSULT_PREP_PARALLEL=on`:拿到 `user` 后同时启动「删号检查」与「读请求体 → 读会话 → 解析模型」两条链;然后**按 §5.2 的顺序**依次检查结果再返回。`off`:保持今天的串行顺序(同一个函数里两条代码路径,便于 A/B)。 +- 注意:并行时请求体非法、会话不存在等情形下仍会多跑一次删号检查(只读),可接受;但**不得**在冻结账户上调用任何写操作。 +- 验收:新增 `frontend/tests/consult-turn-prep-20261013.test.ts`:用假依赖(每个依赖带可控延迟和返回值)覆盖 §5.2 每一种返回,并断言两种开关下状态码与 body **逐字段相同**;`on` 时总耗时 ≈ max(删号, 会话 + 模型) 而非相加(用假时钟或延迟 50/80/80 ms 断言 < 230 ms);冻结账户 + 非法请求体时返回 423。 +- 命令:`./node_modules/.bin/tsx --test tests/consult-turn-prep-20261013.test.ts` + +### T2 · 输入审核不挡首帧,仍在预留前生效(BUG-1337 的另一半)(Grok 自跑 / Claude 验收) + +- `CONSULT_PREP_PARALLEL=on`:在第 424 行原位置(所有开流前检查都通过之后)**启动** `moderate(...)` 但不 `await`,得到 `inputModerationPending`;进入 `runTurn` 后: + - `prepareConsultationRoute` 的 `beforeReserve` 回调第一行 `await inputModerationPending`,非 `pass` 时 `recordModerationEvent`(只记一次),`block` 时抛专用错误 `ConsultationInputBlockedError`; + - `prepareConsultationRoute` 的 `catch` 里**先**看审核结论:若为 `block`,无论 prepare 抛的是什么,都返回今天那个 400(`code: "content_blocked"`、`inputBlockedCopy(...)`)——保持「审核拦截优先于资料错误」的旧优先级; + - legacy 运行时(`shouldUseAgenticRuntime` 为假)同样走这段,仍是直接 HTTP 400。 +- `off`:维持第 424 行 `await` 原样。 +- 首帧之后被拦的请求,客户端收到 `run.failed` / `request_rejected` / `status: 400` / `reason: "content_blocked"`,`use-consultation-run.ts` 的 4xx 回滚已有,不改客户端。自伤类拦截文案(`MODERATION_SELF_HARM_COPY`)必须原样到达用户(断言事件 `message`)。 +- 验收: + - 新测试:审核挂起 1.5 s 时首帧已写出(参照 `consult-first-frame-and-pacing-20260928.test.tsx` 的 `onFirstFrame` 用例);审核 `block` 时 `reserve` 假函数调用 0 次、模型假函数调用 0 次;审核 `block` + 资料读取抛 `ConsultationProfileTruthError` 时返回 `content_blocked`;自伤文案逐字到达。 + - `content-moderation.test.ts` 约 242–243 行按三栏改:原值「源码含 `await moderate(visibleQuestion, "input"` 且位置早于 `reserve_consultation_usage`」→ 新值「`beforeReserve` 内 `await inputModerationPending` 早于 `input.reserve()`(`consultation-route-service.ts` 里 `beforeReserve` 先于 `reserve` 已有保证)+ 行为测试:block 时 reserve 0 次」。原因:审核从开流前挪到流内、预留前。**新值必须比原值更强(有行为断言)**。 + +### T3 · `readVersion` 缓存(BUG-1338)(Grok 自跑 / Claude 验收) + +- `model-catalog.ts`:新增模块级 `Map<"modelId:version", { expiresAt, model }>`(挂 `globalThis`,同 `jyotishaModelCatalogCache` 写法)。`resolveSessionLanguageModel` 绑版本分支:TTL > 0 且命中未过期 → 直接返回;否则 `readVersion`,**只有非 null 成功结果**才写缓存。`invalidateLanguageModelCatalog()` 同时清空它。 +- 不缓存:`readVersion` 抛错(`return null` 路径)、`resolveRow` 返回 null(解不开 key)、`assertAllowedModelProviderUrl` 失败。 +- 验收:`frontend/tests/model-catalog.test.ts` 加行为测试(注入假 `queryCatalog` 与假 DNS):TTL 内第二次不查库不解析 DNS;过期后重查;`invalidateLanguageModelCatalog` 后重查;失败不缓存;TTL=0 每次都查。约 48 行 `return await readVersion(modelId, configVersion)` 按三栏改(原值 / 新值 `readVersion` 结果经缓存写入 / 原因)。管理端 `model-mutation-handler.ts` 的 `invalidateCatalog` 调用点(保存供应商、发布、回滚)不变,断言它们仍在。 +- 说明 SSRF 结论(§2.2)写进 PROGRESS 与 BUG-1338「防复发」:TTL 不得超过目录缓存。 + +### T4 · 流内读标题与分类并行(BUG-1337)(Grok 自跑 / Claude 验收) + +- 第 741 行读 `chat_sessions.title` 改为在 `append_consultation_question` 成功后立即启动,与 `classifyConsultationTurn` 并行,在原使用点 `await`。分类本身的位置**不动**(仍在预留和 append 之后,§4 决策 5)。 +- 验收:源码断言读标题在 append 之后启动;行为不变(`session-title-persist-contract.test.ts`、`consultation-smalltalk.test.ts` 全绿)。 + +### T5 · 多领域并发开关(BUG-1339)(Grok 自跑 / Claude 验收) + +- `createConsultationTools` 的领域循环:`CONSULT_DOMAIN_CONCURRENCY=2` 时,对 `executableDomainPlan` 截断后的领域同时发起 `runWorkflow`,用 `Promise.all` 收齐后**按计划顺序**组装 `executions`;`chart-calculation` 活动句按今天的 `chartCalculationProgressLabel(index + 1, domains.length)` 在发起时依次写出。任一失败 → 与串行一样整次失败、错误码不变。`=1` 时代码路径与今天逐字相同。 +- 并发时 `domainFitsRunBudget` 不再逐个判断(所有领域同时开始),但领域数仍受 `MAX_CONSULTATION_DOMAINS` 截断;在 PROGRESS 里写明这一点。 +- 验收:`consultation-agentic-runtime.test.ts` 加用例:假 `runWorkflow` 让第二个领域先完成,`executions` / 证据卡 / `workflowReceipt` 与串行逐字段相同;一个领域 429 时错误码为 `workflow_rate_limited`(与串行同);开关为 1 时调用顺序严格串行。 +- 写 `frontend/scripts/research/consult-domain-concurrency-ab.py`(或 `.mts`):本地起 Python API(`PYTHONHASHSEED=0`,带 vedastro SDK 的 Docker 镜像一组、不带一组),公开 AA 盘 3 张 × 领域对 2 组(`career,marriage`、`parents,health`)× 冷/热缓存,串行 vs 两路并发,每格 3 次取中位数;另跑「两个用户同时问两领域」的争用场景,统计 429 次数。差分:同盘同分钟两种方式的领域响应,忽略字段清单 = `consult_card_facts.timing.*`、`chart.runtime_cache.*`,并对 `module_execution_audit`、`vargottama` 等集合派生列表排序后比较,要求其余逐字节一致。 + +### T6 · 准备阶段计时(Grok 自跑 / Claude 验收) + +- 在 `route.ts` 现有 `logAgentObservability` 体系里加一个事件 `agentVersion: "consultation-prep-v1"`,`contractPhases` 记:`prep.auth`、`prep.deletion`、`prep.session`、`prep.model`、`prep.moderation`、`prep.subject`(prepare 到 beforeReserve)、`prep.reserve`、`prep.append`、`prep.classify`、`prep.title`,以及开关取值(写进 `phase` 名或已有可用字段,不新增 schema 字段)。只记毫秒和状态,**不含问题原文、出生资料、用户标识以外的任何字段**(用户标识沿用现有规则)。 +- 验收:测试断言事件通过 `agentObservabilityEventSchema`,且植入的问题原文不出现在日志里(参照 BUG-1232 的写法)。 + +### T7 · A/B 验证(Grok 跑能跑的;需要 staging 的由 Claude) + +记录写 `docs/testing/consult-prep-parallel-ab-20261013.md`。 + +| 项 | A | B | 输入 | 度量 | 判定门槛(默认开启的条件) | +| --- | --- | --- | --- | --- | --- | +| 开流前并行 + 审核挪后(`CONSULT_PREP_PARALLEL`) | `off` | `on` | 进程内基准:用 T1/T2 的假依赖,延迟取真实中位数(Grok 在本机 Docker 里对本地 Postgres 量 `getUser`/删号 RPC/读会话/`readVersion` 各 30 次取中位数;阿里云审核无 key 时按 300 ms 与 1,500 ms 两档模拟);每组 30 次 | 首帧毫秒(`onFirstFrame`)、准备总毫秒、各段毫秒 | 首帧中位数降 ≥ 20% 且 §5.2 全部返回逐字段一致、被拦请求 reserve 0 次 → 默认 `on`;否则保持 `off` | +| `readVersion` 缓存 | TTL 0 | TTL 15000 | 同上进程内基准 + 本地 Postgres 真查;每组 30 次 | `prep.model` 毫秒、SQL 次数、DNS 解析次数 | `prep.model` 中位数降 ≥ 50%、TTL 内 SQL 与 DNS 次数为 0、失效/停用 15 s 内生效(测试证明)→ 默认 15000 | +| 多领域并发(`CONSULT_DOMAIN_CONCURRENCY`) | 1 | 2 | T5 的脚本 | 两领域墙钟中位数、Python 进程 CPU 秒、峰值内存、争用场景 429 次数、差分结果 | 带 vedastro SDK 的镜像里墙钟中位数降 ≥ 25%,**且**争用场景 429 次数不多于 A,**且**差分一致 → 默认 2;任何一条不满足保持 1。本机起草实测只降约 10%(§2.3),预计不达标,如实记录即可 | +| staging 实测(Claude 验收时) | 部署前 7 天 `consultation-stream-first-v1` `first_byte` 中位数 | 部署后同口径 + `consultation-prep-v1` 各段 | 真实流量或产品负责人用测试账号 10 轮 | `first_byte`、`prep.*` | 只做核对,不改默认;数字写进同一份记录 | + +每组至少重复 3 次取中位数(上表已写更多次的按表);正确性部分全部要求差分一致,不是「看起来一样」。 + +### T8 · 记录(Grok) + +- `docs/BUG_HISTORY.md`: + - **BUG-1337**:普通对话开流前与流内的准备步骤无依赖却串行(删号检查、读会话、解析模型、开了阿里云时审核挡首帧最多 1.5 s、读标题)。 + - **BUG-1338**:会话模型按版本解析每轮查库 + 解密 + DNS,无缓存(目录路径早有 15 s 缓存)。写清「版本不可变只对了一半」与 SSRF 结论。 + - **BUG-1339**:多领域串行;并发开关 + A/B 结论(若不达标,状态写 `closed_by_design` 或 `investigating` 并附数字,不写 resolved)。 +- `docs/tasks/PROGRESS-consult-prep-parallel-20261013.md`:每个 T 的结果、三栏改断言表、A/B 数字、开关最终默认值及依据。 +- `CHANGELOG.md`:若有默认开启的开关,写一句「发问后更快出现第一行」之类用户可感知变化;全部默认关则不写。 +- `frontend/DESIGN.md`:不改(无 UI 变化)。 +- `BLOCKED.md`:缺阿里云 key、缺 staging 实测时各记一条。 + +## 7. 让步顺序 + +时间不够时按此顺序砍(先砍最后的):T5(多领域并发,预计不达标)→ T4 → T6 的细分段(保留 `prep.moderation` 与 `prep.model`)。T1、T2、T3、T7 中对应 A/B 与 T8 不可砍。 + +## 8. 验收命令分工 + +| 谁 | 命令 | +| --- | --- | +| Grok 自跑 | `cd frontend && ./node_modules/.bin/tsc --noEmit && npm run lint`;单文件:`./node_modules/.bin/tsx --test tests/consult-turn-prep-20261013.test.ts tests/content-moderation.test.ts tests/model-catalog.test.ts tests/application-billing-contract.test.ts tests/consult-first-frame-and-pacing-20260928.test.tsx tests/account-deletion.test.tsx tests/consultation-agentic-runtime.test.ts tests/session-title-persist-contract.test.ts tests/consultation-smalltalk.test.ts`(看 `# fail` 与 `# cancelled` 都为 0、退出码 0);T7 进程内基准与 T5 脚本。前端全量、Python 快速门、整站构建**不跑** | +| Claude 验收 | 上述全部 + 前端全量 `npm test` 与基线逐名比对 + `next build`(`/` 仍 Static,首屏 gzip ±2%)+ 复跑 T7 基准与 T5 脚本 + staging 部署后 `first_byte` 对比 | +| `test:db` | 不需要(不动表、不改数据库访问层查询语句);若 T3 改了 `queryCatalog` 的 SQL 文本则必须跑并按 `tools/agent-loop/windows-known-failures.txt` 比对 | + +## 9. 开工前置命令 + +```bash +git fetch origin --prune +git worktree add -b codex/consult-prep-parallel-20261013 .worktrees/consult-prep-parallel-20261013 origin/staging +cd .worktrees/consult-prep-parallel-20261013 +git log -1 --format=%h # 应为 77057a819 或其后代 +grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -3 # 核对最大号,本单用 1337~1339 +cat frontend/src/lib/personal-report-writer-flag.ts # 确认仍为 false(决策 4 的前提) +grep -rn "await moderate(visibleQuestion\|await resolveSessionLanguageModel\|return await readVersion\|refuseWhenAccountDeletionPending\|const runTurn = async\|for (let index = 0; index < domains.length" frontend/tests tests +``` + +## 10. BUG 编号起点 + +BUG-1337(写作时最大号 BUG-1317;1335、1336 已分配给其它单)。开工时若 1337~1339 已被占用,顺延并在 PROGRESS 写明。 + +## 11. 交付物清单 + +- 代码:`frontend/src/lib/consult/prep-flags.ts`、`frontend/src/lib/consult/turn-prep.ts`(新)、`route.ts`、`model-catalog.ts`、`consultation-tools.ts` +- 测试:`frontend/tests/consult-turn-prep-20261013.test.ts`(新)及 §5.7 列出文件的三栏修改 +- 脚本:`frontend/scripts/research/consult-domain-concurrency-ab.*`、T7 进程内基准脚本(同目录) +- 文档:`docs/testing/consult-prep-parallel-ab-20261013.md`、`docs/tasks/PROGRESS-consult-prep-parallel-20261013.md`、`docs/BUG_HISTORY.md`(BUG-1337~1339)、视结果 `CHANGELOG.md`、`BLOCKED.md` +- 不改:`frontend/DESIGN.md`、数据库迁移、`scripts/jyotish_api_server.py` + +## 12. 环境缺口(预告) + +- 阿里云审核真实延迟需 key,无 key 用 300 / 1,500 ms 模拟,写进 `BLOCKED.md`。 +- staging 前后对比只能在部署后由 Claude 做。 +- 多领域并发的生产收益取决于 vedastro SDK 的网络等待,Grok 的 Docker 镜像要与生产同版本才算数。 diff --git a/docs/tasks/TASK-consult-route-split-20261013.md b/docs/tasks/TASK-consult-route-split-20261013.md new file mode 100644 index 00000000..d3936225 --- /dev/null +++ b/docs/tasks/TASK-consult-route-split-20261013.md @@ -0,0 +1,170 @@ +# TASK · 普通对话路由拆分(第一阶段:开流前加载 + 计费结算)(issue #待定) + +- 日期:2026-10-13 +- 来源:「2026-10-13 全仓屎山与性能排查」· `/api/consult` 路由过大 +- 执行方:**Grok(Windows + Docker)**,分支 `codex/consult-route-split-20261013`,只推这个分支 +- 验收:Claude(Linux) +- BUG 编号:**无 BUG 编号**(重构,无缺陷;记录写 PROGRESS)(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起) +- 开工前置:**`consult-prep-parallel` 与 `answer-effort-ab` 都已合入 `origin/staging`**。前者在 `route.ts` 开流前段落新建了 `frontend/src/lib/consult/`(`prep-flags.ts`、`turn-prep.ts`),本单在此基础上继续;后者改 `consultation-tools.ts` 与 prepareStep。三单串行,不得并行开工。 + +## 1. 基线 + +- 基线 commit:起草时 `origin/staging` `77057a819`;开工时以两单前置合入后的 staging 为准,**行号会变,一律按符号定位**。 +- `frontend/src/app/api/consult/route.ts`:1,840 行;`export async function POST` 从第 296 行到文件末尾(约 1,545 行),内部闭包函数 17 个,鉴权、扣费、校验、编排、流输出混在一个函数作用域里。 +- 起草时 `POST` 内的段落(`77057a819` 行号,仅供定位): + +| 段 | 行 | 内容 | 本单 | +| --- | --- | --- | --- | +| 开流前加载 | 296–443 | 建客户端、`getUser`、删号冻结、请求体、读会话、会话模型、历史窗口、提示词提取拦截、输入审核 | **搬**(与前置单的 `turn-prep.ts` 合并) | +| 预留回调 | 488–503 | `resolveFeaturePricing` + `reserve_consultation_usage` | **搬** | +| 取消 / 追加问题 | 602–668 | `cancel()`、`append_consultation_question` 与 `session_full` | **搬** | +| 寒暄免费结算 | 709–737 | `complete_consultation_free`(smalltalk) | **搬**(结算函数;`streamSmalltalkResponse` 调用留在 route) | +| 上下文摘要 | 748–782 | `checkpointConsultationContext` | **搬** | +| 用量与结算 | 783–962 | `usagePayload`、`moderateAnswer`、`completeModeratedResponse`、`completeResponse`、`completeDirectRectificationTurn`、`settleResult` / `settle` | **搬** | +| 标题副作用 | 970–996 | `titleSideEvent` | 留 | +| 运行编排 | 998–1630 | `runAgenticConsultation`(流选项、重试、续写、窗口预计算) | 留(第二阶段) | +| legacy 与流输出 | 1630–1839 | 旧运行时、`streamTextResponse`、`streamFirstResponse` | 留(第三阶段) | + +## 2. 事故实证 + +- 一个函数作用域内约 1,545 行;计费 RPC(`reserve_` / `cancel_` / `append_` / `complete_consultation_*`)散在 6 处闭包里,靠共享的 `let` 变量(`classificationUsage`、`latencyState`、`outputModerated`、`outputBlocked`、`settlement`)通信。 +- 33 个前端测试文件读 `route.ts` 源码做正则/`indexOf` 断言(`grep -rl "api/consult/route" frontend/tests`),按读源码变量的出现次数估约 400 处;**没有任何测试 import `route.ts` 的 `POST` 跑行为**——计费顺序、冻结、审核先于预留这些红线今天全靠源码文本守。 +- 后果:每次改动都要同步改源码断言(AGENTS.md §7.8 记录的 BUG-933 / 934 / 939 / 992 / 1014 都是这类),而真正的行为没有测试。 + +## 3. 根因 + +不是回归。BUG-1074(先建流)、BUG-976(寒暄免费结算)、BUG-1306(明说要校正免费结算)、09-30 内容审核等一轮轮加在同一个函数里,从未拆分。 + +关联红线(全部原样保留,本单只搬不改): + +| 来源 | 红线 | +| --- | --- | +| BUG-1074 | 开流前只答鉴权 / 请求体 / 会话 / 会话模型;其余在 `runTurn` 内;`streamFirstResponse` 首帧 | +| BUG-976 | reserve 在分类前;寒暄走 `complete_consultation_free` | +| BUG-1306 / 1317 | 直达校正轮免费结算,不走 `complete_consultation_response` | +| BUG-1051 / 1142 | 截断不扣点;答题钟 | +| 09-30 审核 | 输入审核先于预留;输出审核命中走 `complete_consultation_moderated`;三条 agentic 流都带 `moderateOutput: moderateAnswer` | +| 账户注销 | 冻结检查 `if (frozen) return frozen;` 在任何写操作前 | +| `consult-prep-parallel` | §5.2 的状态码优先级;审核在 `beforeReserve` 内等结论 | + +## 4. 决策记录 + +- 产品授权:2026-10-13 产品负责人同意按排查结论全部立单交 Grok 执行;重构只做第一阶段。 +- **纯搬移,行为不变**:不改文案、状态码、RPC 参数、调用顺序、日志字段;不顺手「优化」。 +- 拆分方向(四层):鉴权与会话加载 / 计费预留与结算 / 运行编排 / 流输出。**本单只做前两层**;后两层见 §10。 +- 测试迁移原则:源码断言**跟着代码搬到新文件**(读新文件源码),并为搬出去的计费函数**补行为测试**(新模块可注入假 `accounting` 客户端,今天做不到)。不得把多个文件拼成一个字符串来让跨文件的 `indexOf` 顺序断言继续「通过」——那是伪造顺序。 +- 不推翻任何既有红线。 + +## 5. 硬红线 + +1. 行为不变:同一请求在改前改后的 HTTP 状态、NDJSON 事件序列、RPC 调用序列(名称 + 参数)、落库内容完全相同(T4 差分)。 +2. 测试总数不得低于开工时 `origin/staging` 实测;每条被改的断言写「原值 / 新值 / 原因」三栏;不得删除断言而不替换;替换后的断言不得更弱(同一事实,换文件或换成行为断言)。 +3. 新文件只放在 `frontend/src/lib/consult/`;`route.ts` 净减少;新模块 `import "server-only"`(与现有服务端模块一致)。 +4. 改动规模:diff 合计 < 1,500 行(`git diff --stat origin/staging...HEAD` 的增删之和;纯搬移的增删都算),超了就按 §7 砍。 +5. 不改 `consultation-route-service.ts::prepareConsultationRoute` 的签名与行为;不改 `stream-first-response.ts`、`stream-agent-response.ts`。 +6. 不动表结构与迁移;不改 `scripts/jyotish_api_server.py`;不碰 `frontend/src/app/(app)/page.tsx`;不改 `.gitea/workflows/**`;不升级依赖;不顺手修 warning。 +7. 不改 `frontend/tests/account-deletion.test.tsx` 的「付费路由清单」语义:`route.ts` 若不再直接调用 `refuseWhenAccountDeletionPending`,测试改为断言 `route.ts` 调用 `loadConsultTurnHead`(或前置单定的名字)**且**该模块含 `refuseWhenAccountDeletionPending(` 与 `if (frozen) return frozen;`(或等价的冻结即返回),三栏写明。 + +## 6. 任务分解 + +### T1 · 开流前加载归入 `lib/consult/turn-prep.ts`(Grok 自跑 / Claude 验收) + +- 把前置单留在 `route.ts` 的开流前剩余部分(旧入口 409、历史窗口 `consultationHistoryWindow`、`blocksPromptExtraction`、审核启动与 `moderationContext`)搬进 `turn-prep.ts`,`POST` 开头变成一次调用:失败直接 `return response`,成功拿到 `{ user, parsed, chatSession, sessionModel, historyWindow, storedHistory, moderation }`。 +- 搬移时保留每个 `NextResponse.json(...)` 字面量原文。 +- 验收:`consult-turn-prep-20261013.test.ts`(前置单已建)补「旧入口 409」「提示词提取 400」两种返回;`consult-first-frame-and-pacing-20260928.test.tsx` 第 256–289 行「开流前清单」改为:标记在 `turn-prep.ts` 源码中存在,且 `route.ts` 中对 `loadConsultTurnHead` 的调用位于 `const runTurn = async` 之前(三栏)。 + +### T2 · 计费与结算归入 `lib/consult/settlement.ts`(Grok 自跑 / Claude 验收) + +- 新建工厂 `createConsultSettlement(ctx)`,`ctx` 显式传入今天闭包捕获的值:`accounting`、`supabase`、`userId`、`requestId`、`sessionId`、`visibleQuestion`、`moderationContext`、`sessionContextWindow`、`usageStartedAt`,以及对可变状态的访问器(`getSelectedModel()`、`getClassification()`、`getLatencyState()`)。返回:`reserveCredit(model)`(原 488–503 的回调体)、`cancel`、`appendQuestion`、`completeSmalltalk(reply)`、`checkpointContext`、`usagePayload`、`moderateAnswer`、`completeModeratedResponse`、`completeResponse`、`completeDirectRectificationTurn`、`settleResult`、`settle`。`retryDetachedSettlement`、`first`、`consultationReservationSchema` / `consultationCompletionSchema` / `consultationQuestionAppendSchema` 一起搬。 +- `outputModerated` / `outputBlocked` / `settlement` 变成工厂内部状态(每个请求一个实例),对外行为与今天一致(`completeResponse` 里「未审核则先审、命中则走 moderated」顺序不变)。 +- `route.ts` 中所有调用点改成 `settlement.xxx(...)`,参数与顺序不变。`cancel` 返回值、`AgentSettlementResult` 语义不变。 +- 新增行为测试 `frontend/tests/consult-settlement-20261013.test.ts`(假 `accounting.rpc` 记录调用序列): + - `completeResponse`:正常 → 调 `complete_consultation_response` 一次、成功后触发上下文摘要;RPC 失败 → 抛错且已调 `cancel_consultation_credit`;输出审核 `block` → 只调 `complete_consultation_moderated`,不调 `complete_consultation_response`; + - `completeDirectRectificationTurn` → `complete_consultation_free`,`responseKind: "smalltalk"`; + - `appendQuestion`:`session_full` → 返回给 route 的 409 不变、已调 cancel; + - `cancel` 对 `request_completed` 视为成功;`retryDetachedSettlement` 最多 3 次、退避 150/300 ms; + - `usagePayload` 合并分类用量、费用按百万 token 单价计算(与今天逐字段相同); + - `settleResult` 重复调用只执行一次。 +- 受影响的源码断言(起草时 grep 得到,开工时按 AGENTS.md §7.8 重新 grep 为准),逐条迁到 `settlement.ts` 源码或改为上面的行为断言,三栏记录: + +| 文件 | 断言(约行号) | 迁移方式 | +| --- | --- | --- | +| `consultation-stream-recovery.test.ts` | 16(`rpc("reserve_consultation_usage"…p_session_id`)、96(`await settleResult(action)`)、103–108(`completeResponse` 内 try 包 `complete_consultation_response`、失败后 `await cancel()`)、123、146–147(`cancel` / `completeResponse` 经 `retryDetachedSettlement`) | 文本改读 `settlement.ts`;103–108 的顺序断言换成行为测试「RPC 失败 → cancel 被调」 | +| `consultation-stream-recovery.test.ts` | 22、44、81(`onComplete: … settle(() => completeResponse(`、`onError` 不结算) | 留在 `route.ts`,只改成 `settlement.settle(() => settlement.completeResponse(` 的写法 | +| `content-moderation.test.ts` | 243(输入审核先于预留)、244(三处 `moderateOutput: moderateAnswer`)、245(`if (!outputModerated) await moderateAnswer…`)、246 | 243 沿用前置单的新断言;244 留 route(写法改为 `settlement.moderateAnswer`);245–246 读 `settlement.ts` + 行为测试 | +| `application-billing-contract.test.ts` | 160(`resolveSessionLanguageModel(chatSession.model_id, …)`)、175–178、216(`usagePayload` 与 `p_actual_usage`) | 160 读 `turn-prep.ts`;其余读 `settlement.ts` | +| `chat-session-authority.test.ts` | 66(`session_not_consultation`)、83(`append_consultation_question`) | 66 读 `turn-prep.ts`;83 读 `settlement.ts` | +| `rectification-session-composer-guard.test.ts` | 56 | 读 `turn-prep.ts` | +| `rectification-explicit-entry-20261012.test.ts` | 663–669(`completeDirectRectificationTurn` 只走 free) | 读 `settlement.ts` + 行为测试 | +| `consultation-smalltalk.test.ts` / `consult-first-frame-and-pacing-20260928.test.tsx` | 229 / 288(寒暄分支 `complete_consultation_free`、不调 `streamAgentResponse`) | route 中寒暄分支调用 `settlement.completeSmalltalk`;`complete_consultation_free` 文本读 `settlement.ts` | +| `consult-first-frame-and-pacing-20260928.test.tsx` | 274–275(`reserve_` / `append_` 在流内) | 改为 `route.ts` 中 `settlement.reserveCredit` / `settlement.appendQuestion` 的调用位于 `runTurn` 内 | +| `session-title-persist-contract.test.ts` | 55–56(`async function usagePayload(` 作为顺序锚点) | 锚点改为 route 中寒暄早返之后的标题逻辑,三栏写明 | +| `consultation-context-cache-contract.test.ts` | 14(历史窗口)、37(`void checkpointConsultationContext()`) | 14 读 `turn-prep.ts`;37 读 `settlement.ts` | +| `consult-evidence-card-telemetry-20260927.test.ts` | 96(`settleRun(() => completeResponse(`) | 留 route,改写法 | +| `account-deletion.test.tsx` | 111–122 | 见 §5.7 | +| `model-catalog.test.ts` | 49(`await resolveSessionLanguageModel(chatSession.model_id…`) | 读 `turn-prep.ts` | + +其余 20 个读 `route.ts` 的测试文件(编排、流输出相关)预计不受影响;开工 grep 后若命中,同样三栏。 + +### T3 · `route.ts` 只编排(Grok 自跑 / Claude 验收) + +- 完成 T1、T2 后 `POST` 的结构:`loadConsultTurnHead` → 定义 `runTurn`(prepare + `settlement.reserveCredit` → 拒绝分支 → `settlement.appendQuestion` → 分类 → 寒暄/编排)→ `streamFirstResponse`。编排与流输出原样留着。 +- 验收:`route.ts` 行数净减(目标减少 ≥ 450 行);`grep -c "accounting.rpc(" route.ts` 为 0。 + +### T4 · 行为不变的差分验证(Grok 自跑 / Claude 验收) + +- 写 `frontend/scripts/research/consult-route-split-diff.mts`:在同一进程里分别加载**基线版**(`git show origin/staging:frontend/src/app/api/consult/route.ts` 等文件写到临时目录)与**新版** `POST`,用 `mock.module` 注入同一套假依赖(假 `createServerSupabaseClient` / `createAdminSupabaseClient` 记录每次 `.from()` 与 `.rpc()` 的名称和参数、假模型返回固定流、假审核、假引擎返回 `frontend/tests/fixtures/consult-evidence-card-golden.json` 的真实引擎响应),跑以下场景并逐字节比较「HTTP 状态 + 响应体(NDJSON 全文)+ RPC/查询调用序列」: + 401;冻结 423;请求体 400;会话不存在 404;校正会话 409;模型变更 409;模型不可用 503;提示词提取 400;输入审核拦截;点数不足 402;`session_full` 409;寒暄;普通咨询完成;输出审核命中;生成中失败(取消);直达校正。 +- 若进程内同时加载两版因模块别名(`@/`)冲突做不到,退一步:先在基线检出上跑一遍把结果写成 JSON 快照(`fixtureProvenance` 写命令与基线 SHA),再在新分支上跑同一脚本比对。 +- 验收:16 个场景全部一致,脚本输出贴进 PROGRESS(只贴「一致 / 不一致」与场景名,不贴响应全文)。 + +### T5 · 记录(Grok) + +- `docs/tasks/PROGRESS-consult-route-split-20261013.md`:搬移清单(原位置符号 → 新位置符号)、三栏改断言总表、diff 行数、T4 结果、第二阶段建议。 +- `CHANGELOG.md`:不写(用户不可感知)。`frontend/DESIGN.md`:不改。`docs/BUG_HISTORY.md`:不写(无缺陷)。 + +## 7. 让步顺序 + +时间或 diff 预算不够时:先只做 T2 的「预留 / 取消 / 追加 / 寒暄结算」与 `usagePayload`,`completeResponse` / `completeDirectRectificationTurn` / `moderateAnswer` 留到第二阶段 → 再砍 T1(开流前段落已由前置单部分抽出)。T4 差分与 T5 不可砍。 + +## 8. 验收命令分工 + +| 谁 | 命令 | +| --- | --- | +| Grok 自跑 | `cd frontend && ./node_modules/.bin/tsc --noEmit && npm run lint`;`./node_modules/.bin/tsx --test tests/consult-settlement-20261013.test.ts tests/consult-turn-prep-20261013.test.ts` + §6 T2 表中每个文件(`# fail`、`# cancelled` 都为 0,退出码 0);`./node_modules/.bin/tsx scripts/research/consult-route-split-diff.mts`。前端全量、整站构建**不跑** | +| Claude 验收 | 上述全部 + 前端全量 `npm test` 与基线逐名比对(测试总数不降)+ `next build`(`/` 仍 Static,首屏 gzip ±2%)+ 复跑差分脚本 + 抽查三栏表每一条新断言不弱于原断言 | +| 不需要 | `test:db`(不改 SQL、不改 RPC 参数)、Python 快速门 | + +## 9. 开工前置命令 + +```bash +git fetch origin --prune +git log origin/staging --oneline | grep -i "consult-prep-parallel\|BUG-1337" | head -3 # 前置一 +git log origin/staging --oneline | grep -i "answer-effort" | head -3 # 前置二 +# 任一为空则停,回报「前置未合入」 +git worktree add -b codex/consult-route-split-20261013 .worktrees/consult-route-split-20261013 origin/staging +cd .worktrees/consult-route-split-20261013 +ls frontend/src/lib/consult/ # 应有 prep-flags.ts、turn-prep.ts +wc -l frontend/src/app/api/consult/route.ts # 记下开工基线行数 +grep -rl "api/consult/route" frontend/tests | wc -l +grep -rn "reserve_consultation_usage\|cancel_consultation_credit\|append_consultation_question\|complete_consultation_\|usagePayload\|retryDetachedSettlement\|settleResult\|checkpointConsultationContext\|moderateAnswer\|completeResponse\|completeModeratedResponse\|completeDirectRectificationTurn\|session_not_consultation\|blocksPromptExtraction\|consultationHistoryWindow\|refuseWhenAccountDeletionPending\|resolveSessionLanguageModel" $(grep -rl "api/consult/route" frontend/tests) +``` + +## 10. BUG 编号起点 + +无 BUG 编号(重构,无缺陷)。若搬移中发现真实缺陷,不在本单修,写进 PROGRESS「发现」并回报,由 Claude 另立单。 + +## 11. 交付物清单 + +- 代码:`frontend/src/lib/consult/settlement.ts`(新)、`frontend/src/lib/consult/turn-prep.ts`(扩充)、`route.ts`(净减) +- 测试:`frontend/tests/consult-settlement-20261013.test.ts`(新)、§6 T2 表中各文件的三栏迁移 +- 脚本:`frontend/scripts/research/consult-route-split-diff.mts`(新) +- 文档:`docs/tasks/PROGRESS-consult-route-split-20261013.md` +- 不改:`CHANGELOG.md`、`frontend/DESIGN.md`、`docs/BUG_HISTORY.md`、数据库迁移 + +## 后续阶段(本单不做) + +1. 第二阶段:运行编排 `runAgenticConsultation`(流选项、`streamWithOverflowRetry`、重试 / 续写 / 窗口预计算、`logRun` / `settleRun`)搬到 `lib/consult/run-agentic.ts`。 +2. 第三阶段:legacy 运行时与流输出(`streamTextResponse` 包装、`streamFirstResponse` 接线、响应头)搬到 `lib/consult/stream-output.ts`;评估能否删除 legacy 运行时(需产品拍板,`CONSULTATION_AGENTIC_RUNTIME=legacy` 仍是开关)。 +3. 第四阶段:把剩余约 400 处读 `route.ts` 源码的断言逐步换成对服务函数的行为测试,并加一条 `route.ts` 行数 / 闭包数增长合同(参照 `home-shell-growth-contract.test.ts`)。 +4. 标题副作用 `titleSideEvent` 与上下文摘要的归属(会话服务)。 diff --git a/docs/tasks/TASK-contracts-helpers-dedupe-20261013.md b/docs/tasks/TASK-contracts-helpers-dedupe-20261013.md new file mode 100644 index 00000000..024ea0b5 --- /dev/null +++ b/docs/tasks/TASK-contracts-helpers-dedupe-20261013.md @@ -0,0 +1,155 @@ +# TASK · 前后端契约与小工具函数去重(第一阶段)(issue #待定) + +- 日期:2026-10-13 +- 来源:「2026-10-13 全仓屎山与性能排查」· 重复契约与私有小工具条目 +- 执行方:**Grok(Windows + Docker)**,分支 `codex/contracts-helpers-dedupe-20261013`,只推这个分支 +- 验收:Claude(Linux) +- BUG 编号:无 BUG 编号(纯类型与搬移重构),记录写 PROGRESS。开工时核对 `docs/BUG_HISTORY.md` 最大号(写作时 BUG-1317);若执行中发现真实缺陷,回报「需拍板」并另申请号,不在本单修。(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起) +- 开工前置:**`frontend-dead-code`、`consult-prep-parallel`、`consult-route-split` 三单均已合入 `origin/staging`**(会碰同一批文件:`personal-report-document-view.tsx` 用到 `ClaimStatus`;consult 路由与 route-service 有私有 `record`/`errorMessage`)。第二批。 + +## 1. 基线 + +- 写作基线:`origin/staging` `77057a819`;开工时以三单合入后的 `origin/staging` 为准,§2 的行号需重新 grep 定位(按符号名)。 + +## 2. 事故实证(`77057a819`,按符号定位) + +### 2.1 同一契约两份,已有漂移 + +| 契约 | 份 1 | 份 2 | 差异 | +| --- | --- | --- | --- | +| 报告创建请求 | `lib/personal-report-route-core.ts::personalReportCreateRequestSchema`(zod,`themes` 为 6 值枚举,带 `.default`) | `components/personal-report/generate-personal-report-button.tsx::PersonalReportCreateRequest`(手写 interface,`themes: readonly string[]`) | 客户端可以发出服务端必拒的主题而编译不报;`DEFAULT_REPORT_THEMES` 与 schema 默认值又是两份 | +| 校正 Case 打开响应 | `lib/rectification-entry.ts::OpenRectificationCaseResponse`(`status: string`) | `lib/rectification-agentic/v9/open-request.ts::OpenRectificationCaseResponse`(`status: RectificationCaseStatus`) | 已漂移 | +| 首页校正入口摘要 | `rectification-entry.ts::RectificationEntrySummary`(`status: string`) | `v9/open-request.ts::RectificationEntrySummary`(`status: RectificationCaseStatus`) | 已漂移;**BUG-1041** 就是客户端解析与服务端形状脱节(snake/camel)造成的 | +| 可恢复/终态状态集合 | `rectification-entry.ts::RESUMABLE_STATUSES/TERMINAL_STATUSES`(Set) | `v9/case-status.ts::RESUMABLE_CASE_STATUSES/TERMINAL_CASE_STATUSES` | 内容现在相同,两处维护 | +| `ReportDepth` | `lib/personal-report-plan.ts::REPORT_DEPTHS` | `lib/personal-report-contract.ts::REPORT_DEPTHS` | 再加 `personal-report-route-core.ts` zod 里第三份字面量 | +| `ClaimStatus` | `lib/report-evidence-bundle-v2.ts`(手写联合) | `lib/personal-report-contract.ts::CLAIM_STATUSES` | 内容相同,两处维护 | +| `ChoiceKey` | `v9/choice-card.ts` | `v9/probe-explain.ts` | 都是 `"A"|"B"|"C"|"D"` | + +### 2.2 同名不同义(应改名,不应合并) + +| 名字 | 份 1 | 份 2 | 判断 | +| --- | --- | --- | --- | +| `EvidenceDomain` | `lib/birth-time-question-planner.ts`(6 值:旧引导协议 `legacy-guided-v1` 的提问领域,存量数据在用) | `v9/evidence-model.ts`(13 值:v9 证据账本领域) | **两个概念**。旧的改名 `LegacyGuidedEvidenceDomain`(仅类型名;运行时值不变) | +| `CandidateResult` | `lib/birth-time-evidence.ts`(旧协议 zod 推导) | `lib/rectification-chat-snapshot.ts`(v9 结果或 null) | 两个概念。旧的改名 `LegacyCandidateResult` | + +### 2.3 私有小工具(实测) + +| 名字 | 份数 | 语义变体 | +| --- | ---: | --- | +| `isRecord` | 6 | 5 份「非 null、object、非数组」类型守卫;1 份(`core/compose-receipt.ts`)返回 `Readonly`——同义 | +| `asRecord` | 7 | 全部「非数组对象否则 null」(`value &&` 与 `value !== null &&` 对对象等价) | +| `recordOf` | 3 | 同上 | +| `record` | 21 | **4 个语义变体**:非数组否则 null(13);含数组否则 null(1,`rectification-adopted-chart-tiers.ts`);非数组否则 `{}`(6);含数组否则 `{}`(1,`geoapify-location-service.ts`) | +| `finiteNumber` | 5 | 4 份「有限数否则 null」;1 份(`server-owned-birth-profile.ts`)**接受数字字符串、返回 undefined**——不同义 | +| `errorMessage` | 4 | 3 个变体:`instanceof Error`(`synastry-reports/route.ts`)、鸭子类型 `.message` + fallback(`chart-profiles` 两份)、无 fallback 返回 `""`(`admin/admin-error-response.ts`) | +| sha256 hex | `personal-report-generation.ts::sha256Hex` 已导出;同体包装 `personal-report-longform-appendix.ts::hashLongformMarkdown`、`supabase/codes.ts::hashRedeemCode`、`v9/message-origin.ts` | 全部 `createHash("sha256").update(x[, "utf8"]).digest("hex")`,字符串默认编码即 utf8,等价 | + +复现: + +```bash +cd frontend +grep -rnE "^(export )?(const|function) (isRecord|asRecord|recordOf|record|finiteNumber|errorMessage)\b" src +grep -rnE "(type|interface) (PersonalReportCreateRequest|OpenRectificationCaseResponse|RectificationEntrySummary|EvidenceDomain|ChoiceKey|ClaimStatus|ReportDepth|CandidateResult)\b" src +``` + +## 3. 根因 + +客户端组件不能 import 服务端模块(`personal-report-route-core.ts` 引了 `@/mastra`、计费、权限等服务端代码;`report-evidence-bundle-v2.ts` 引 `node:crypto`),于是每次需要类型就在客户端手抄一份;小工具同理,每个文件各写一个。没有「客户端也能安全 import 的契约目录」。BUG-1041 是这种漂移已经造成的用户可见故障。**不是回归,是从未设计。** + +## 4. 决策记录 + +- 2026-10-13 产品负责人同意按排查结论全部立单交 Grok 执行。 +- 新建 `frontend/src/lib/contracts/`:只放**纯 zod + 类型 + 常量**,禁止 import 任何服务端模块(`node:*`、`server-only`、`@/mastra`、`@/lib/supabase`、`@/lib/db`);客户端只用 `import type`,类型从 schema 推导。 +- 新建 `frontend/src/lib/guards.ts`:只放无依赖的纯函数。**每个语义变体一个名字**,不得把不同语义合并成一个函数: + - `isRecord(v): v is Record`(非 null 非数组对象) + - `asRecord(v): Record | null`(同上,否则 null) + - `recordOrEmpty(v): Record`(同上,否则 `{}`) + - `finiteNumberOrNull(v): number | null` + - 「含数组」两个变体、`finiteNumber` 的字符串版、三种 `errorMessage`:**本单不合并,保留原地私有实现**(只有 1~2 份,合并会改变语义或命名收益小),写进 PROGRESS。 +- sha256:`sha256Hex` 移到 `frontend/src/lib/hash.ts`(含 `node:crypto`,**仅服务端模块可 import**,文件头注释写明);`hashLongformMarkdown`、`hashRedeemCode` 保留领域名,函数体改为调用 `sha256Hex`。哈希值逐字节不变(这些值已落库)。 +- 契约对齐时**行为不变**:客户端解析器(`entrySummaryFromResponse`、`openResponseFromPayload`)照旧接受任意字符串 status(首页缓存与旧响应会带未知值),所以「线上形状」类型 status 仍为 `string`,服务端类型由它派生收窄。 +- 不推翻任何既有红线。 + +## 5. 硬红线 + +1. **纯类型与搬移**:除删除重复定义、改 import、在调用处换成共享函数名外,不改任何逻辑;被替换的私有函数必须与目标函数**逐字语义相同**(按 §2.3 变体表核对),有疑问就不合并。 +2. `src/lib/contracts/**` 与 `src/lib/guards.ts` 不得 import 服务端模块;加一条测试读源码断言这一点。 +3. 客户端组件对 contracts 只能 `import type`(或 zod 已在该组件 bundle 内的情况下例外,需在 PROGRESS 说明);首屏 gzip ±2%。 +4. 持久化值不变:哈希、状态字符串、主题枚举值、`legacy-guided-v1` 存量字段名一概不改。改名只改 TypeScript 类型名。 +5. BUG-1041 回归测试(`starter-home-polish-20260926.test.tsx` 中 camelCase/snake_case 两种形状)必须原样通过。 +6. 测试读源码文本的断言会因搬移失效:开工先 `grep -rnE "function (isRecord|asRecord|recordOf|record|finiteNumber)|PersonalReportCreateRequest|RESUMABLE_STATUSES|REPORT_DEPTHS|CLAIM_STATUSES" frontend/tests tests`,改任何断言写三栏,测试总数不得低于开工基线。 +7. 每个 PR 范围 diff < 1500 行;`frontend/src/app/(app)/page.tsx` 不增长;`scripts/jyotish_api_server.py` 不碰;不改表、迁移、workflow;不升级依赖。 + +## 6. 任务分解 + +### T1 · 报告创建请求契约 + +- 新建 `src/lib/contracts/personal-report-request.ts`:搬入 `reportRequestThemes`、`personalReportCreateRequestSchema`、`DEFAULT_REPORT_THEMES`(与 schema `.default` 同源)、`REPORT_DEPTHS`;导出 `PersonalReportCreateRequest = z.infer<...>`(服务端用)与 `PersonalReportCreateRequestInput = z.input<...>`(客户端发送用——注意有 `.default`,客户端必须用 input 类型)。 +- `personal-report-route-core.ts` 从这里 import(原导出名保留 re-export,避免波及调用方)。 +- `generate-personal-report-button.tsx`:删手写 interface,`buildPersonalReportCreateRequest` 返回 `PersonalReportCreateRequestInput & { reportType: "personal_full"; presentationMode: "default"; depth: "standard" }`(保留现有字面量收窄);`DEFAULT_REPORT_THEMES` 改为 import(若测试从该组件 import 它,保留 re-export)。 +- `personal-report-plan.ts`、`personal-report-contract.ts` 的 `REPORT_DEPTHS` 改为从 contracts 导入并 re-export。 +- 验收:`personal-report-entry.test.ts` 不改即绿(`buildPersonalReportCreateRequest` 请求体逐字段相同);新增一条类型级测试:把 `themes: ["bogus"]` 赋给 `PersonalReportCreateRequestInput` 用 `// @ts-expect-error` 锁定;`tsc --noEmit` 0 错。 + +### T2 · 校正入口契约 + +- 新建 `src/lib/contracts/rectification-entry.ts`:`RECTIFICATION_CASE_STATUSES` 及 resumable/terminal 列表(从 `v9/case-status.ts` 搬入或由其 re-export,二选一,以 import 图无环为准);`OpenRectificationDisposition`;线上形状 `OpenRectificationCaseResponseWire`(`status: string`)与 `RectificationEntrySummaryWire`;服务端类型 `OpenRectificationCaseResponse = Omit & { status: RectificationCaseStatus }`,摘要同理。 +- `lib/rectification-entry.ts`:删本地类型与两个 Set,改用共享常量(`new Set(RESUMABLE_CASE_STATUSES)`),解析器返回 Wire 类型;导出名保留(`RectificationEntrySummary` 作为 Wire 的别名 re-export),14 个引用文件尽量不动。 +- `v9/open-request.ts`:删本地两份类型,改为从 contracts 派生并 re-export。 +- 验收:`starter-home-polish-20260926.test.tsx`、`rectification-surface-contract.test.ts`、`rectification-explicit-entry-20261012.test.ts` 不改即绿;`grep -rn "RESUMABLE_STATUSES\b" frontend/src` 无结果。 + +### T3 · 同名不同义改名 + 同义类型合一 + +- `EvidenceDomain`(birth-time-question-planner.ts)→ `LegacyGuidedEvidenceDomain`;`evidenceDomains` 常量 → `legacyGuidedEvidenceDomains`(运行时值不变)。引用方:`birth-time-journey-turn-persistence.ts`、`birth-time-journey-transitions.ts`、`birth-time-journey-stored-protocol.ts`、`birth-time-journey-turn.ts`、`components/birth-time-evidence-draft-card.tsx`、`tests/birth-time-journey-test-support.ts`。 +- `CandidateResult`(birth-time-evidence.ts)→ `LegacyCandidateResult`。 +- `ChoiceKey`:`probe-explain.ts` 删本地定义,从 `choice-card.ts` import(检查无循环依赖;有则反向)。 +- `ClaimStatus`:`report-evidence-bundle-v2.ts` 删手写联合,改 `import type { ClaimStatus } from "./personal-report-contract"`(若 personal-report-contract 反向依赖 bundle-v2 形成环,改放 `contracts/report-claim.ts`)。 +- 验收:`tsc --noEmit` 0 错;`grep -rnE "type (EvidenceDomain|CandidateResult|ChoiceKey|ClaimStatus)\b" frontend/src` 每个名字恰 1 处。 + +### T4 · `guards.ts` 与 `hash.ts` + +- 按 §4 建 `guards.ts`,把 §2.3 中**同义**的私有副本替换为 import:`isRecord` 6、`asRecord` 7、`recordOf` 3(→ `asRecord`)、`record` 中「非数组否则 null」13 份(→ `asRecord`)与「非数组否则 `{}`」6 份(→ `recordOrEmpty`)、`finiteNumber` 4 份(→ `finiteNumberOrNull`)。函数返回类型有 `Readonly<...>` 的,在调用处保持原有类型(必要时用局部类型别名,不改逻辑)。 +- `hash.ts`:`sha256Hex` 移入;`personal-report-generation.ts` 保留 re-export;`hashLongformMarkdown`、`hashRedeemCode`、`message-origin.ts` 的同体实现改调 `sha256Hex`。 +- 新测试 `frontend/tests/guards-and-hash.test.ts`:每个 guard 对 `null / undefined / 0 / "" / [] / {} / new Date()` 的返回值表(与被替换的旧实现逐一相同——测试里内联一份旧实现作对照);`sha256Hex`、`hashLongformMarkdown`、`hashRedeemCode` 对固定输入(ASCII + 中文 + emoji)给出与 `createHash` 直接计算相同的值;`contracts/**`、`guards.ts` 源码不含 `node:`/`server-only`/`@/mastra`/`@/lib/supabase`/`@/lib/db`。 +- 验收:`grep -rnE "^function (isRecord|asRecord|recordOf)\b" frontend/src` 无结果;`record` 只剩 §4 列出的 2 个含数组变体;PROGRESS 列出替换清单(文件 → 新函数)。 + +### T5 · 差分与记录 + +- 类型层改动文件用 esbuild 转译差分(命令同 `TASK-data-layer-types-20261013` T3),只有 import 行变化的文件在 PROGRESS 标注「仅 import」。 +- `docs/tasks/PROGRESS-contracts-helpers-dedupe-20261013.md`:替换清单、未合并变体及原因、三栏断言、差分结果、开工/完工测试数。 +- 不写 BUG_HISTORY、CHANGELOG(用户不可感知);DESIGN.md 不改。`docs/agents/domain.md` 或 `CONTEXT.md` 若有 EvidenceDomain 术语,补一句两个概念的区别。 + +## 7. 让步顺序 + +时间不够依次砍:T4 的 hash 部分 → T4 的 `record` 21 份(保留 `isRecord/asRecord/recordOf` 16 份)→ T3。**T1、T2 不得砍**(契约漂移有过 BUG-1041)。若 diff 超 1500 行,T4 拆到后续单。 + +## 8. 验收命令分工 + +| 谁 | 命令 | +| --- | --- | +| Grok 自跑 | `tsc --noEmit`、`npm run lint`、新增测试与改到的测试单跑(fail、cancelled 为 0)、T5 差分。`test:db` 不需要(不动数据访问层;若替换波及 `lib/db/**` 或 `supabase/**` 则必须跑并按 `windows-known-failures.txt` 比对) | +| Claude 验收 | 上述 + 前端全量 `npm test` 与基线逐条比对、测试总数 ≥ 开工基线 + `next build`(`/` 仍 Static,首屏 gzip ±2%)+ Python 快速门(确认 Python 侧读前端源码的测试未被搬移打破) | + +## 9. 开工前置命令 + +```bash +git fetch origin --prune +for t in frontend-dead-code consult-prep-parallel consult-route-split; do + git log origin/staging --oneline -60 | grep -qi "$t" && echo "OK $t" || echo "前置未合入:$t —— 停止开工" +done +test ! -e frontend/src/components/personal-report/personal-report-document-view.tsx || echo "frontend-dead-code 未合入" +git worktree add -b codex/contracts-helpers-dedupe-20261013 .worktrees/contracts-helpers-dedupe-20261013 origin/staging +cd .worktrees/contracts-helpers-dedupe-20261013 +grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -3 +grep -rnE "function (isRecord|asRecord|recordOf|record|finiteNumber)|PersonalReportCreateRequest|RESUMABLE_STATUSES|REPORT_DEPTHS|CLAIM_STATUSES" frontend/tests tests +``` + +## 10. 交付物 + +分支 `codex/contracts-helpers-dedupe-20261013`;`src/lib/contracts/*.ts`、`src/lib/guards.ts`、`src/lib/hash.ts`;`tests/guards-and-hash.test.ts`;PROGRESS。无 BUG_HISTORY / CHANGELOG / DESIGN.md。 + +## 11. 后续阶段(本单不做) + +1. 现有 `*-contract.ts`(`chart-view-contract`、`ephemeris-contract`、`location-contract`、`chat-session-write-contract` 等)迁入 `lib/contracts/`。 +2. 剩余变体(含数组的 `record`、字符串版 `finiteNumber`、三种 `errorMessage`)统一命名或消除。 +3. 其余客户端手抄的 API 响应类型逐个换成 `z.input/z.infer`(需要逐接口盘点)。 +4. 加 lint 规则禁止新增私有 `isRecord/asRecord`(需改 eslint 配置,单独拍板)。 diff --git a/docs/tasks/TASK-dasha-unify-20261013.md b/docs/tasks/TASK-dasha-unify-20261013.md new file mode 100644 index 00000000..ff87f0bd --- /dev/null +++ b/docs/tasks/TASK-dasha-unify-20261013.md @@ -0,0 +1,240 @@ +# TASK · 大运实现收口:`/api/dasha` 改走引擎同一套实现,删占位注册表(issue #待定) + +- 日期:2026-10-13 +- 来源:2026-10-13 全仓屎山与性能排查 ·「大运多套实现」条目(`scripts/*dasha*.py` 28 个、两套条件大运、`_birth_lord_and_balance` 多份、Vimshottari 年数表多处硬编码) +- 执行方:**Grok(Windows + Docker)**,分支 `codex/dasha-unify-20261013`,只推这个分支 +- 验收:Claude(Linux) +- BUG 编号:**BUG-1341**(开工时核对 `docs/BUG_HISTORY.md` 最大号;写作时最大号 BUG-1317;号段由主会话统一分配)(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起) +- **开工前置**:`api-chart-subprocess-gate` 已合入 `origin/staging`(它改 `jyotish_api_server.py` 与排盘路径)。与 `python-hygiene`(BUG-1340)同为第二批,两单都改 `scripts/jyotish_api_server.py` 与 `scripts/jyotish_engine.py`:**本单在 `python-hygiene` 合入后开工**,若主会话决定并行,则本单只碰第 6 节列出的函数,合入时由后合的一方 rebase。 +- **主会话补充的串行链(2026-10-14)**:引擎/API 文件链按此顺序逐单合入后开工(`upstream-sync7` 也改 `jyotish_engine.py` / `jyotish_api_server.py`,若在途,后合一方 rebase):`api-disk-cache-retention` + `report-reference-perf` → `api-chart-subprocess-gate` → `pl9-render-split` → `python-hygiene` → `dasha-unify` → `api-server-service-extract`。本单的前置以这条链为准(与上一行不一致时以本行为准)。 +- **后继**:`api-server-service-extract` 依赖本单合入。 + +## 1. 基线 + +- 基线 commit:`origin/staging` `77057a819`(写作时);开工以前置单合入后的 `origin/staging` 为准。 +- 实测脚本:`compare.py`(写作时放在起草方 scratch,下文给出可照抄的要点)。虚构盘:**1990-01-01 12:00,新德里 28.6139 / 77.2090,时区 +5.5,Lahiri,平均交点**。月亮 306.4653°(第 23 宿 Dhanishta 第 4 足),上升 343.9226°(双鱼),太阳 256.8599°。 +- 本机参照:PyJHora 4.8.7(`jhora.horoscope.dhasa.graha.*` / `raasi.narayana`),`drik.set_ayanamsa_mode("LAHIRI")`。PyJHora 只在研究 / 测试中作对照,不进运行时(许可证边界见 AGENTS §9 与 `tests/test_runtime_jhora_import_scan.py`)。 + +## 2. 事故实证(`77057a819`) + +### 2.1 同一张盘,两条入口给出两套大运 + +引擎全量解读(`scripts/jyotish_engine.py::cmd_full_reading` 约第 16860 行起,按字符串表 `__import__` 独立模块)与接口 `POST /api/dasha`(`scripts/jyotish_api_server.py::JyotishAPIHandler._compute_dasha_system` → `scripts/extended_dashas.py::calc_any_dasha`)对比,前 3 段主运(主星 / 起始日): + +| 体系 | `/api/dasha`(`extended_dashas`) | 引擎独立模块 | PyJHora 4.8.7 | +| --- | --- | --- | --- | +| Panchottari | Ketu 1990-01-01、Venus 2002-01-01、Sun 2014-01-01(Vimshottari 的九星,各 12 年) | Jupiter 至 1990-04-10、Sun 1990-04-10、Mercury 2002-04-10、Saturn 2015-04-11 | Jupiter(1972-04-10 起)、Sun 1990-04-10、Mercury 2002-04-10、Saturn 2015-04-11 | +| Satabdika | Ketu 1990-01-01、Venus 2001-01-01、Sun 2012-01-02(各 11 年) | Venus 余额、Mercury 1990-02-25、Jupiter 2000-02-26、Mars 2020-02-26 | Venus(1980 起)、Mercury 1990-02-25、Jupiter 2000-02-26、Mars 2020-02-26 | +| Chaturshitisama(注册键 `chaturaaseeti`) | Ketu 1990-01-01、Venus 1999-01-01、Sun 2008-01-02(各 9 年) | Moon 余额、Mars 1990-03-08、Mercury 2002-03-08、Jupiter 2014-03-08 | Moon(1978 起)、Mars 1990-03-08、Mercury 2002-03-08、Jupiter 2014-03-08 | +| Shastihayani | Moon 1990-01-01、Mars 1998-01-01、Mercury 2008-01-02 | Saturn 余额、Rahu 1990-01-12、Jupiter 1996-01-13、Sun 2006-01-12 | Saturn(1984 起)、Rahu 1990-02-03、Jupiter 1996-02-04、Sun 2006-02-03 | +| Narayana | Pisces 3 年、Capricorn 6、Aquarius 9(`(idx%3+1)*3` 编出来的年数) | `narayana_dasha.py`(旧算法,已标注):Pisces 3、Aries 7、Taurus 8 | Pisces 9、Cancer 5、Scorpio 8 | + +结论: + +- **Panchottari / Satabdika / Chaturshitisama:引擎独立模块与 PyJHora 逐日一致**(主运起始日完全相同);`extended_dashas` 完全对不上。 +- **Shastihayani:引擎模块与 PyJHora 主星顺序一致,起点差约 22 天**,原因是模块按文献用 28 宿(含 Abhijit)切分(`scripts/shastihayani_dasha.py` 模块说明与 `source_locator` 写明出处)。这是有出处的口径选择,**本单不改**。 +- **Narayana:三套各不相同**。引擎用的 `narayana_dasha.py` 是已知旧算法(BUG-1215 已加「旧算法」标注;BUG-1218 的 Rath 版只并列展示);`extended_dashas` 是第三套、比旧算法还假。选哪套为准属于占星裁定,已有 BUG-1215 / 1218 在推进,**本单不裁定**,只要求 `/api/dasha` 与引擎、咨询给出同一份(含旧算法标注)。 + +### 2.2 `extended_dashas` 是占位实现,不是另一种口径 + +`scripts/extended_dashas.py`(408 行)登记 35 个键。逐键用两组完全不同的输入(月宿 3 vs 17、上升 0 vs 7、太阳星座 2 vs 9 …)各算一次: + +| 现象 | 键数 | +| --- | ---: | +| 两组输入输出**完全相同**(不看盘) | 28 / 35(含无实现返回空表的 `vimshottari`、`ashtottari`、`chara`) | +| 输出就是「Vimshottari 九星 × 每段 4 年」同一张表(`calc_generic_dasha` 或 `calc_any_dasha` 的兜底) | 18 | +| 总年数与登记年数不符 | 例:`panchottari` 登记 105 实出 108;`kalachakra` 94 实出 144;`shasti_hayani` 60 实出 84;`dwadashottari` 112 实出 36 | + +- 文件自己写了:`scripts/panchottari_dasha.py` 模块说明「The legacy `extended_dashas` helper is deliberately not used: it is a generic placeholder」。 +- `_compute_dasha_system` 对这些结果回 `precision: 'calculator'`(只要注册函数名不是 `calc_generic_dasha`),把占位结果标成真算。 +- 已有记录:BUG-1262 防复发写「`extended_dashas.py` 的另一套 Yogini 仍按月宿对 8 取余且不裁余额,全盘报告不走它」;BUG-1284(mitigated)写「`'shoola'` 注册项……接口层仍能按这个键调用……本单不得改 `jyotish_api_server.py` 的方法体,所以注册项还在」。**两条都把问题留给了今天这一单**。 + +谁在用: + +- `POST /api/dasha` 非 Vimshottari 键:只有引擎内网接口与 `/api/technique_example`(技法浏览器示例,默认 payload 是 Vimshottari)。前端 `frontend/src` 只调 `/api/dasha/chara`;`/api/daily-starlanguage` 已改走 `/api/daily_guidance`(BUG-265 记录里「并行取 `/api/dasha`」已过时)。**没有用户可见入口**,所以是 P2 级缺陷,但它是对外公开的接口合同。 +- `/api/chart`(`_compute_chart_sync`)返回体里的 `available_dashas`(35 条元数据)与 `dasha_count` 来自 `extended_dashas.DASHA_REGISTRY`;`frontend/tests/fixtures/chara-zero-year-1985-08-03-beijing.golden.json` 与 `tests/golden/consultation_contract_keypaths_v1.json` 含此字段。 +- `tests/run_all.py`(**release 门第 84 行会跑**)有 `extended_dashas` / `conditional_dashas` 导入与「至少 30 / 35 个」断言。 + +### 2.3 `conditional_dashas.py` 只被测试用 + +`scripts/conditional_dashas.py`(193 行,`calc_dwisaptati_dasha` / `calc_shattrimsa_dasha` / `calc_dwadashottari_dasha` 等 7 个函数)全仓只有 `tests/run_all.py` 4 处导入;生产用的是 `dwisaptatisama_dasha.py` / `shattrimshatsama_dasha.py` / `dwadashottari_dasha.py`。 + +### 2.4 `_birth_lord_and_balance` 8 份:6 份同一算法换参数 + +| 文件 | 起算宿 | 结构 | +| --- | --- | --- | +| `panchottari_dasha.py` | Anuradha(16) | 同一算法:`offset=(宿-起算宿)%27`,`lord=SEQ[offset%len]`,`余额=年数×(1-宿内比例)` | +| `satabdika_dasha.py` | Revati(26) | 同上 | +| `chaturshitisama_dasha.py` | Swati(14) | 同上 | +| `dwisaptatisama_dasha.py` | Mula(18) | 同上 | +| `shattrimshatsama_dasha.py` | Sravana(21) | 同上 | +| `shodashottari_dasha.py` | Pushya(7) | 同上 | +| `shastihayani_dasha.py` | 28 宿分组 | 不同算法(关键字参数,组内多宿) | +| `dwadashottari_dasha.py` | — | 不同算法(返回 4 元组) | + +排查报告「8 份互不相同」修正为:6 份是同一算法的参数化副本,2 份是真不同。 + +### 2.5 Vimshottari 年数表硬编码 15 处(排查报告写 7 处) + +`{'Ketu': 7, 'Venus': 20, …, 'Mercury': 17}` 字面量:`jyotish_engine.py`、`dasha_analyzer.py`、`dasha_calculator.py`、`dasha_calculator_enhanced.py`、`vimshottari_subperiod_timeline.py`、`conditional_dashas.py`、`event_prediction_model.py`、`tajika.py`(2)、`prashna.py`、`validate.py`、`nakshatra_dasha.py`、`kp_system.py`、`divisional_charts_extended.py`、`research/offline_research_20260926_lib.py`。复现:`grep -rnE "Rahu['\"]?\s*:\s*18" --include=*.py scripts mcp_server.py jyotish_vedic`。 + +其中 **`dasha_analyzer.py`、`divisional_charts_extended.py`、`narayana_dasha.py` 在校正冻结身份里**(`references/real_case_calibration/minute_rectification_holdout_v3.json` `frozen_scoring.files`;`tests/test_sealed_holdout_contract_freshness.py` 与 `tests/test_rectification_validation_integrity_gate.py` 按字节哈希核对,ERR-110 / ERR-114)。改一个字节就要按研究协议重新冻结。 + +### 2.6 附带发现:全量解读的 Ashtottari 没有出生余额(**本单不修,需拍板**) + +同一虚构盘: + +| 来源 | 前两段主运 | +| --- | --- | +| `ashtottari_dasha.calculate_ashtottari_dasha`(全量解读 `modules.ashtottari_dasha`) | Jupiter **1990-01-01 起满 19 年**、Rahu 2009-01-01 | +| PyJHora `get_ashtottari_dhasa_bhukthi` | Jupiter 1983-10-07 起(出生时已走约 6.2 年)、Rahu **2002-10-07** | +| `nakshatra_dasha.calc_ashtottari_dasha`(全量解读 `modules.nakshatra_dasha` 内) | 起运星 **Mars**(与前两者都不同) | + +原因(代码可见):`ashtottari_dasha.py` 只有在拿到 `moon_longitude` 且适用规则族是「Rahu 从命主」时才算出生余额;`cmd_full_reading` 传的是 `birth_info_for_alt_dasha`(没有 `moon_longitude`),所以第一段从出生满额起算,后续每段都差约 6 年。改它会改报告里 Ashtottari 表的全部日期,且 Ashtottari 规则族的选择牵涉占星口径,**本单只把实证写进 BUG-1341 的「附带发现」并在回报里列为需拍板**,不改。 + +## 3. 根因 + +- `extended_dashas.py`(「冲刺全球第一 · Dasha 总数 10 → 18 → 35」)是早期为凑覆盖数写的占位;之后逐个体系补了有出处的独立模块(2026-09 起),引擎全量解读改走独立模块,但 HTTP 接口方法体受「不得改 `jyotish_api_server.py`」限制一直没换(BUG-1284 原文)。**不是回归,是迁移只做了一半。** +- 条件大运每加一个就复制一份 `_birth_lord_and_balance`;年数表各文件自带。 + +## 4. 决策记录 + +- 2026-10-13 产品负责人同意按排查结论全部立单交 Grok 执行。 +- **权威实现 = 引擎全量解读当前调用的独立模块**(第 6 节 T2 映射表)。依据是代码与对照数据,不是占星判断:Panchottari / Satabdika / Chaturshitisama 与 PyJHora 逐日一致,`extended_dashas` 不读盘。 +- Narayana 权威 = `narayana_dasha.narayana_dasha_full_report` + `narayana_legacy_label.label_legacy_narayana`(与咨询 `_attach_local_consultation_layers` 同一调用)。**不在本单裁定旧算法 vs Rath 版**(BUG-1215 / 1218 红线:Rath 版只并列展示,替换由产品在 T5 试算后决定)。 +- Shastihayani 28 宿口径维持。 +- **没有独立实现的注册键**(`tara`、`sudasa`、`mandooka`、`lagnamsaka`、`yogardha`、`kendradi`、`tithi_ashtottari`、`tithi_yogini`、`patyayini`、`paryaaya`、`rashmi`、`naisargika`、`aayu`、`karaka`、`panchasvara`、`sudarsana` 等,开工重核):`/api/dasha` 改为返回 **400 `dasha system not implemented`**,不再编周期。依据:用户偏好「多余入口宁可删除也不修」;这些键没有任何用户可见调用方。 +- **`/api/chart` 的 `available_dashas` / `dasha_count` 本单保持逐字节不变**(元数据搬到新注册表,内容不改),避免牵动排盘 golden 与排盘缓存;与 `/api/dasha` 能力不一致的问题列入「后续阶段」,需拍板是否裁剪。 +- `conditional_dashas.py`、`extended_dashas.py` 删除。`tests/run_all.py`(release 门)里对应断言按三栏改。 +- 冻结身份内文件(`dasha_analyzer.py`、`divisional_charts_extended.py`、`narayana_dasha.py`、`domain_calculation_service.py` 等)**本单一个字节不改**;它们的年数表留到下次有理由重新冻结时一起收。 +- 2.6 Ashtottari 余额:本单不修,需拍板。 +- 推翻的既有约束:BUG-1284「本单不得改 `jyotish_api_server.py` 的方法体」只是那一单的范围限制,不是长期红线;本单授权改 `_compute_dasha_system` / `_fallback_dasha_periods` / `_compute_chart_sync` 里读注册表的三行,净行数不增。 + +## 5. 硬红线 + +1. **引擎全量解读输出逐字节不变**(`modules.*_dasha` 全部)。本单只改接口路径与去重;若 T3 / T4 去重让任何数值或字段顺序变化,即为搬错,改代码不改断言。 +2. 冻结身份两份清单内的文件一个字节不改(开工打印清单,第 9 节)。 +3. 不改 `narayana_dasha.py`、`narayana_rath.py`、`narayana_legacy_label.py`;不改 BUG-1215 / 1218 的展示与打分口径。 +4. `scripts/jyotish_api_server.py` 净行数不得增长;类方法数不得增长(删 `_fallback_dasha_periods` 后应下降 1,合同测试基线同步下调并写三栏);`JyotishAPIHandler.__new__` 计数不得增长。 +5. `/api/chart` 响应逐字节不变(`available_dashas` 内容与顺序不变)。 +6. 新注册模块不得 import `jyotish_api_server`;不得在运行时 import `jhora`(PyJHora 只在测试里作对照,且测试需在缺 PyJHora 时 skip,镜像不装它——见记忆「验收要屏蔽 PyJHora 模拟生产」)。 +7. 不改 `.gitea/workflows/**`、不动表结构与迁移、不升级依赖。 +8. 改既有断言写「原值 / 新值 / 原因」三栏;golden 只能来自真实引擎输出。 +9. 不碰 `skills/*/versions/**`。 + +## 6. 任务分解 + +### T1 · 实测对照固化为测试(必做,先做) + +- 新测试 `tests/test_dasha_api_engine_parity.py`: + - 用第 1 节虚构盘,对 Panchottari、Satabdika、Chaturshitisama、Shastihayani、Dwisaptatisama、Shattrimshatsama、Shodashottari、Dwadashottari、Yogini、Ashtottari、Kalachakra、Narayana:**`/api/dasha`(handler 方法直调)返回的主运列表 == 引擎全量解读同一模块的主运列表**(主星、起止日期、年数逐项相等)。本条在基线上应当红(证明 BUG-1341),修完转绿;把基线红的输出贴进进度记录。 + - PyJHora 对照(`pytest.importorskip("jhora")`):Panchottari / Satabdika / Chaturshitisama 主运起始日与 PyJHora 逐日相等——锁住「为什么以独立模块为准」。golden 由真实 PyJHora 与真实引擎输出生成,`fixtureProvenance` 写命令。 +- 验收:Grok 在基线上跑一次(应红)、修后跑一次(应绿),两次输出都进进度记录。 + +### T2 · `/api/dasha` 改走权威实现,删 `extended_dashas.py`(必做) + +改什么: +- 新建 `scripts/dasha_registry.py`(不 import `jyotish_api_server`): + - `DASHA_REGISTRY_METADATA`:从 `extended_dashas.DASHA_REGISTRY` **原样搬**(35 个键、顺序、`name` / `years` / `type` 不变),只供 `/api/chart` 的 `available_dashas`。 + - `IMPLEMENTED`:键 → 权威函数映射(写作时,开工重核): + + | 注册键 | 权威实现 | + | --- | --- | + | `vimshottari` | 不变:`domain_calculation_service.compute_vimshottari_timeline`(handler 现有分支) | + | `yogini` | `yogini_dasha.calculate_yogini_dasha` | + | `ashtottari` | `ashtottari_dasha.calculate_ashtottari_dasha`(与全量解读同参,**含 2.6 的问题,原样**) | + | `kalachakra` | `kalachakra_dasha.calculate_kalachakra_dasha` | + | `panchottari` / `satabdika` / `chaturaaseeti` / `shasti_hayani` / `shodasottari` / `dwadashottari` / `dwisaptati` / `shattrimsa` | 对应独立模块的 `calculate_*`,参数与 `cmd_full_reading` 的 `conditional_birth_info` / `extra` 相同 | + | `narayana` | `narayana_dasha_full_report` + `label_legacy_narayana`(与咨询同参) | + | `shoola` | 不接(BUG-1284:报告走 `rashi_dashas_native` 的 `shoola_forward_md_direct_ad_v1`;若开工确认 `rashi_dashas_native.calculate_rashi_dashas` 的 `shoola` 段可直接复用则接它,否则 400) | + | `chara` | 400,消息指向 `/api/dasha/chara` | + | 其余 | 400 `dasha system not implemented` | + + - 一个构造入参的函数:从 `planets`(含 `lon`)与 `ascendant`(含 `lon` 或 `sign_idx`)算出 `conditional_birth_info` 所需字段(上升经度、月亮经度、太阳经度、D9 上升星座、十宫主所在星座、罗睺星座、命主所在星座、白天出生、月宿、月足、太阴半月等)。**这部分逻辑优先从 `cmd_full_reading` 抽成共享函数、两边共用**,保证「同盘同参」;若抽取会改 `jyotish_engine.py` 输出(硬红线 1),则在新模块里实现并用 T1 测试锁住等价。 +- `_compute_dasha_system` 非 Vimshottari 分支改为调用 `dasha_registry.compute(key, ...)`;`precision` 字段:权威实现回 `'engine_module'`,删掉 `'generic'` / `'calculator'` 两个旧值(对外合同变化,写进 CHANGELOG 与 BUG 记录)。 +- 删 `JyotishAPIHandler._fallback_dasha_periods`;`_compute_chart_sync` 改读 `dasha_registry.DASHA_REGISTRY_METADATA`。 +- 删除 `scripts/extended_dashas.py`。`tests/test_rashi_dashas_native.py` 里「不导入 `extended_dashas`」断言保留(自然成立)。 +- 不改:`/api/dasha/chara`;`/api/dasha` Vimshottari 分支;引擎全量解读任何调用。 + +验收: +- T1 测试绿。 +- `/api/chart` 虚构盘响应基线 vs 本分支逐字节一致(同进程无法同时加载两版,用两个工作树各跑一次 `handler._compute_chart_sync` 落盘 JSON 后 `cmp`;排盘缓存先清空 `scratch/local/api_chart_cache`,`VEDASTRO_ENABLE_NETWORK=0`,`PYTHONHASHSEED=0`)。 +- 新测试:未实现键返回 400;`chara` 400 且消息含 `/api/dasha/chara`;响应不再出现 `precision: 'generic'`。 +- `tests/test_api_server_security.py`、`tests/test_api_server_growth_contract.py`(方法数基线 218 → 实测值,三栏)、`tests/test_rashi_dashas_native.py`、`tests/test_narayana_legacy_label.py` 绿。 + +### T3 · 删 `conditional_dashas.py`,改 `tests/run_all.py`(必做) + +- 开工先 `grep -rn "conditional_dashas" --include=*.py --include=*.ts --include=*.json .` 复核只剩 `tests/run_all.py`;`references/**/*.json` 若有路径记录(写作时 `references/oracle/full_technique_invocation_matrix_2026_07_22.json` 等提到 `extended_dashas`),**不改历史证据 JSON**,在进度记录列出。 +- `tests/run_all.py`:`t18`、`t19` 与第 129–130、219–233、402–409、624–630 行附近断言改为对应独立模块或注册表(三栏)。这个文件由 **release 门**执行(`.gitea/workflows/release-quality-gate.yml` 第 84 行),必须本地跑通:`python tests/run_all.py`。 + +### T4 · 去重:条件大运余额函数与 Vimshottari 年数表(必做,限非冻结文件) + +- 新建 `scripts/dasha_common.py`: + - `nakshatra_seeded_birth_balance(moon_longitude, *, anchor_index, sequence) -> (lord, balance_years, nakshatra_index)`,算式与 6 份副本**逐运算相同**(运算顺序不变,保证浮点逐位相等)。 + - `VIMSHOTTARI_YEARS`(插入顺序 Ketu、Venus、Sun、Moon、Mars、Rahu、Jupiter、Saturn、Mercury)与 `VIMSHOTTARI_ORDER`。 +- 第 2.4 节 6 个模块的 `_birth_lord_and_balance` 改为一行调用(保留函数名,测试可能按名调用);`shastihayani`、`dwadashottari` 不动。 +- 第 2.5 节非冻结文件里的字面量年数表改为引用常量;**每处先断言 `list(旧表.items()) == list(VIMSHOTTARI_YEARS.items())`**,顺序或数值不同的不换,在进度记录说明。冻结的 `dasha_analyzer.py`、`divisional_charts_extended.py` 与 `research/` 下的研究脚本不动。 +- 验收: + - 新测试:6 个模块各用 2,000 个随机月亮经度(固定种子)比对新旧函数 `==`(旧函数在测试里以源码副本形式保留作对照,或用基线工作树导出的 golden)。 + - 新测试(只许降):`scripts/` 根目录非冻结文件里 Vimshottari 年数字面量计数 == 本单收尾值(冻结文件列白名单)。 + - **全量解读逐字节不变**:基线工作树与本分支各跑一次虚构盘 `python scripts/jyotish_engine.py full-reading …`(参数见 `--help`),`PYTHONHASHSEED=0`,去掉墙钟字段后 `cmp`。 + +### T5 · 记录(必做) + +- `docs/BUG_HISTORY.md` 新增 **BUG-1341**:`/api/dasha` 非 Vimshottari 体系是占位实现(不读盘、年数自编、标成 `calculator`);修复为走引擎同一套模块;关联 BUG-1262、BUG-1284(把 1284 状态从 mitigated 更新为 resolved 并注明本单)、BUG-1215 / 1218(Narayana 未裁定)。「附带发现」写 2.6 Ashtottari 余额(状态 `investigating`,需拍板)。 +- `docs/tasks/PROGRESS-dasha-unify-20261013.md`:T1 基线红 / 修后绿输出、映射表最终版、`/api/chart` 与全量解读两次逐字节对照、T4 每处替换清单。 +- `CHANGELOG.md`:一条(`/api/dasha` 合同变化:未实现体系 400、`precision` 取值变化)。Skill 版本不 bump。 +- `frontend/DESIGN.md`:不改。 + +## 7. 让步顺序 + +时间不够按此顺序砍:**T4 年数表部分 → T4 余额函数部分**。T1、T2、T3、T5 不可砍(T2 是 BUG-1341 本体)。 + +## 8. 验收命令分工 + +| 谁 | 命令 | +| --- | --- | +| Grok 自跑 | `python -m pytest tests/test_dasha_api_engine_parity.py tests/test_api_server_security.py tests/test_api_server_growth_contract.py tests/test_rashi_dashas_native.py tests/test_narayana_legacy_label.py tests/test_panchottari_dasha.py tests/test_satabdika_dasha.py tests/test_chaturshitisama_dasha.py tests/test_shastihayani_dasha.py tests/test_dwisaptatisama_dasha.py tests/test_shattrimshatsama_dasha.py tests/test_shodashottari_dasha.py tests/test_dwadashottari_dasha.py tests/test_full_reading_conditional_dashas.py tests/test_rectification_validation_integrity_gate.py tests/test_sealed_holdout_contract_freshness.py -q`;`python tests/run_all.py`;两次逐字节对照。快速门、全量 pytest **不跑**,以 Claude 为准 | +| Claude 验收 | 上述 + 快速门 + **全量 `pytest` 与基线逐名比对**(引擎相关改动,必须全量)+ 屏蔽 PyJHora 再跑一遍 T1 以外的测试(模拟生产镜像无 PyJHora)+ 前端 `chart-view-zero-year` 等用到 chara golden 的单文件测试(证明 `/api/chart` 不变) | +| 不需要 | `test:db` | + +## 9. 开工前置命令 + +```bash +git fetch origin --prune +git log --oneline origin/staging | grep -i "api-chart-subprocess-gate\|python-hygiene" # 前置已合入 +git worktree add -b codex/dasha-unify-20261013 .worktrees/dasha-unify-20261013 origin/staging +cd .worktrees/dasha-unify-20261013 +grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -3 +python - <<'EOF' +import json, sys; sys.path[:0] = ["scripts/research", "scripts", "."] +from sealed_holdout_rerun import PRODUCTION_FILES, RESEARCH_FILES +print(PRODUCTION_FILES, RESEARCH_FILES) +print(json.load(open("references/real_case_calibration/minute_rectification_holdout_v3.json"))["frozen_scoring"]["files"]) +EOF +grep -rn "extended_dashas\|conditional_dashas\|_fallback_dasha_periods\|available_dashas\|precision.*generic" tests frontend/tests scripts mcp_server.py +grep -rnE "Rahu['\"]?\s*:\s*18" --include=*.py scripts mcp_server.py jyotish_vedic +wc -l scripts/jyotish_api_server.py +python -m pytest tests -q -p no:cacheprovider > /tmp/baseline-full.txt 2>&1; tail -3 /tmp/baseline-full.txt +``` + +对照脚本要点(写作时用过,可照抄):`swisseph` 设 `SIDM_LAHIRI`,`julday(1990,1,1,12-5.5)` 取月亮 / 太阳 / 平均罗睺 / Placidus 上升;独立模块入参加 `standard_table_profile="pl9_reports_all_dasha_tables_v1"`(与报告一致,强制出表)、`navamsa_ascendant_sign_index`、`tenth_lord_sign_index`;PyJHora 用 `drik.Place("f",28.6139,77.2090,5.5)`、`get_dhasa_bhukthi(dob, tob, place, dhasa_level_index=1)`。 + +## 10. BUG 编号起点 + +BUG-1341(写作时最大号 BUG-1317)。开工核对,被占用则顺延并说明。 + +## 11. 交付物清单 + +- 代码:新 `scripts/dasha_registry.py`、新 `scripts/dasha_common.py`;`scripts/jyotish_api_server.py`(`_compute_dasha_system`、`_compute_chart_sync` 一行、删 `_fallback_dasha_periods`);6 个条件大运模块;第 2.5 节非冻结年数表所在文件;删 `scripts/extended_dashas.py`、`scripts/conditional_dashas.py`;(可能)`scripts/jyotish_engine.py` 抽共享入参函数 +- 测试:`tests/test_dasha_api_engine_parity.py`、去重等价测试、年数表计数测试;`tests/run_all.py`、`tests/test_api_server_growth_contract.py` 三栏改动 +- 文档:`docs/tasks/PROGRESS-dasha-unify-20261013.md`、`docs/BUG_HISTORY.md`(BUG-1341,更新 BUG-1284)、`CHANGELOG.md` +- 不改:`frontend/**`、`frontend/DESIGN.md`、冻结身份文件、Narayana 三个文件 + +## 后续阶段(本单不做) + +1. **2.6 Ashtottari 出生余额与两套 Ashtottari 起运星不一致**(需拍板:改报告 Ashtottari 表全部日期)。 +2. `available_dashas` 裁剪到真实可算的体系(改 `/api/chart` 合同与 golden,需拍板)。 +3. 冻结身份内 `dasha_analyzer.py` / `divisional_charts_extended.py` 的年数表,在下次因其他理由重新冻结时一起收。 +4. Narayana 旧算法 vs Rath 版(BUG-1215 / 1218,等产品看 T5 试算)。 +5. `dasha_calculator.py`、`dasha_calculator_enhanced.py`、`nakshatra_dasha.calc_ashtottari_dasha` 等其余重复实现的归并。 diff --git a/docs/tasks/TASK-data-layer-types-20261013.md b/docs/tasks/TASK-data-layer-types-20261013.md new file mode 100644 index 00000000..cf1c8fa9 --- /dev/null +++ b/docs/tasks/TASK-data-layer-types-20261013.md @@ -0,0 +1,163 @@ +# TASK · 数据访问层改用自己的 DataClient 类型(第一阶段)(issue #待定) + +- 日期:2026-10-13 +- 来源:「2026-10-13 全仓屎山与性能排查」· 数据层冒充 SupabaseClient 条目 +- 执行方:**Grok(Windows + Docker)**,分支 `codex/data-layer-types-20261013`,只推这个分支 +- 验收:Claude(Linux) +- BUG 编号:本单发现 **1 个真实缺陷**(§2.2,`rpc(...).single()` 在自托管运行时必抛 TypeError),编号 **BUG-1343**(写作时最大号 BUG-1317,1323~1342 已被同批任务书占用;开工时核对 `docs/BUG_HISTORY.md` 最大号)。其余为纯类型重构,记 PROGRESS。(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起) +- 开工前置:**`node-runtime-resources` 已合入 `origin/staging`**(两单都改 `frontend/src/lib/db/local-postgres-client-core.ts`)。第二批。 + +## 1. 基线 + +- 基线 commit:`origin/staging` `77057a819`(写作时);开工时以合入 `node-runtime-resources` 之后的 `origin/staging` 为准。 +- 涉及文件: + - `frontend/src/lib/supabase/server.ts::createServerSupabaseClient`(第 22 行 `as unknown as SupabaseClient`) + - `frontend/src/lib/supabase/admin-client-core.ts::createAdminSupabaseClient`(第 21 行同样的 cast) + - `frontend/src/lib/db/local-postgres-client-core.ts`(771 行):`LocalPostgresQueryBuilder`、`LocalPostgresDataClient` + - 显式 `import type { SupabaseClient } from "@supabase/supabase-js"` 的 9 个文件:`rectification-in-chat-server.ts`、`birth-time-journey-case-loader.ts`、`birth-time-journey-turn-persistence.ts`、`birth-time-scoring-job-store.ts`、`birth-time-journey-store.ts`、`rectification-agentic/v9/case-service.ts`、`rectification-agentic/v9/product-domain.ts`、`mastra/rectification-v9-tools.ts`、`rectification-agentic/session.ts`(后者由 `frontend-dead-code` 删除,本单开工时应已不存在) + - 其余 55 个调用 `create(Server|Admin)SupabaseClient` 的文件靠 `ReturnType` 推导类型,**不需要逐个改**。 + +## 2. 事故实证 + +### 2.1 cast 掩盖适配层缺口(历史上已反复出事) + +运行时(`AUTH_PROVIDER=self-hosted`,staging 与生产均如此)拿到的是 `LocalPostgresDataClient`,它只实现了 Supabase 查询构造器的一小部分: + +- 客户端:`auth.getUser`、`from`、`rpc`(`rpc` 是 `async`,返回 Promise,**不是**构造器) +- 构造器:`select insert upsert update delete eq neq gt lte like in is not(eq|is|cs) or order limit abortSignal single maybeSingle then` + +但类型被 `as unknown as SupabaseClient` 冒充成完整的 supabase-js 客户端,调用方用到未实现的能力时**编译器不报,运行时才炸**。历史同类事故: + +| BUG | 缺口 | 发现方式 | +| --- | --- | --- | +| BUG-124 | 关联 select、分页、计数、Admin Auth | staging 后台降级 | +| BUG-130 | nested select、缺 `lte`/`like` | self-hosted 支付查询失败 | +| BUG-576 | `upsert` 缺 `onConflict` 直接抛错 | staging 报告三次重试 failed | +| BUG-926 | `order()` 只保留最后一次 | 会话列表排序错 | + +### 2.2 实测:把 cast 目标换成真实接口后编译器立刻暴露的问题 + +写作时在 scratchpad 拷贝的 `frontend/`(基线 `77057a819`,借用同版本 `node_modules`)做了试验:新建 `DataClient` 接口(只声明上面列出的方法),两个工厂返回 `DataClient`,9 个文件的类型 import 换成 `DataClient`,`LocalPostgresDataClient implements DataClient`、构造器 `implements DataQueryBuilder`。基线 `tsc --noEmit` 0 错。 + +| 方案 | `data` 字段类型 | tsc 错误数 | 说明 | +| --- | --- | ---: | --- | +| U | `unknown` | 122(14 个文件) | 110 条是「对 `{}`/`unknown` 取属性」——调用方当前依赖 supabase-js `any` 推导,属于类型收紧,不是运行缺陷 | +| A | `any`(与现状 `SupabaseClient` 等价) | **2** | 见下 | + +方案 A 的 2 条: + +1. **真实缺陷**:`src/lib/birth-time-scoring-job-store.ts::createSupabaseScoringJobStore.claimScoringJob`(约 94–103 行)写 `supabase.rpc("claim_birth_time_scoring_job", {...}).single()`。本地适配层 `rpc` 返回 Promise,没有 `.single` → **自托管下必抛 `TypeError: ... .single is not a function`**。该函数是 `returns table (claim_state text, algorithm_version text)`(`frontend/supabase/migrations/20260718030000_birth_time_scoring_job_lifecycle.sql`),本地适配层对返回集合的函数给 `data = rows[]`。可达路径:`POST /api/birth-time-journey` `type: "poll_scoring"` → `birth-time-scoring-service.ts::pollScoringJob`(仅 `legacy-guided-v1` 存量 Case 的评分轮询)。现有测试用内存 store(`tests/birth-time-scoring-memory-store.ts`),从未经过真实适配层,所以没被发现。 +2. `src/app/api/chart-profiles/route.ts:58` `(data ?? []).map((row) => ...)` 隐式 any——标注 `row: unknown` 即可(函数体本来就 `row as Record`),不是缺陷。 + +另核实:`src/` 中 `.select("...")` 字面量的列全部是简单标识符(0 个关联/重命名列);`.not()` 运算符只用到 `cs`;未发现 `gte/lt/range/ilike/contains` 调用。`auth.getUser()` 的调用方只读 `id`/`email`。 + +试验复现(在 Grok 自己的分支上照做即可,不需要 scratchpad):做完 T1 后跑 `tsc --noEmit`,应恰好出现上面 2 条。 + +## 3. 根因 + +07~08 月从 Supabase 迁到自托管 PostgreSQL 时,为了让 58 个调用方零改动,写了一个模拟 Supabase 查询构造器的适配层,再用 `as unknown as SupabaseClient` 让类型「看起来一样」。之后每次调用方用到适配层没实现的写法,都要到 staging 运行时才发现(§2.1 四条 BUG)。**不是回归,是迁移时的权宜之计没有收口。** + +## 4. 决策记录 + +- 2026-10-13 产品负责人同意按排查结论全部立单交 Grok 执行。 +- 第一阶段只做类型:定义 `DataClient`(只声明适配层**真实实现**的方法)、把 cast 目标换成它、让适配层 `implements` 它,从而让编译器替我们找缺口。函数改名(`createServerSupabaseClient` → `createServerDataClient` 等)、`data` 字段收紧、依赖移除全部留到后续阶段。 +- `data` 字段第一阶段用方案 A(与现状等价的宽类型),**不得**为了收紧类型去改 14 个文件的业务代码(CLAUDE.md:不为迎合编译器改业务代码);方案 U 的 122 条错误清单写进 PROGRESS,作为第二阶段输入。 +- Supabase 分支(`AUTH_PROVIDER` 非 `self-hosted` 时 `createServerClient`/`createClient`)是迁移的**回滚资产**(AGENTS.md §1:保留到最终对账),本单**不删**,只在该分支返回处同样 cast 为 `DataClient`。删除需产品拍板,见后续阶段。 +- `@supabase/ssr`、`@supabase/supabase-js`、`supabase` CLI 依赖:本单不动(浏览器端 `email-otp-login.tsx` 仍用 `createBrowserSupabaseClient().auth.signInWithOtp/verifyOtp`,并非零引用)。 +- §2.2 第 1 条缺陷在本单修(最小修复),占一个 BUG 号。 +- 本单不属于 P1/P2 性能项,**不做 A/B**;运行时行为除缺陷修复外零变化,用「转译产物差分」证明(T3)。 + +## 5. 硬红线 + +1. 除 `birth-time-scoring-job-store.ts` 缺陷修复与 `chart-profiles/route.ts` 一个参数标注外,**不改任何运行时代码**;所有改动必须是类型层(`import type`、类型注解、`implements`、`as DataClient`)。 +2. `DataClient` / `DataQueryBuilder` 只能声明适配层真实实现的方法与参数形状;不得为了让编译通过而在接口里加适配层没有的方法,也不得给适配层补新能力(补能力 = 后续阶段,另立单)。 +3. 不删 Supabase 回滚分支;不改 `AUTH_PROVIDER` 判断;不删依赖、不升级依赖。 +4. 不改数据库结构与迁移;`frontend/src/app/(app)/page.tsx` 不增长;`scripts/jyotish_api_server.py` 不碰;不改 `.gitea/workflows/**`。 +5. 与 `node-runtime-resources` 在 `local-postgres-client-core.ts` 上的改动不得冲突回退:开工先确认其已合入,只在类声明行加 `implements` 与顶部加 `import type`。 +6. 改既有断言写「原值 / 新值 / 原因」三栏;fixture 不得手造响应形状。 +7. 动了数据访问层:**Grok 必须跑 `npm run test:db`**。 + +## 6. 任务分解 + +### T1 · 定义 DataClient 并替换 cast(Grok 自跑 + Claude 复验) + +- 新建 `frontend/src/lib/db/data-client.ts`(无 `server-only`,纯类型): + - `DataQueryError = Readonly<{ message: string; code?: string }>` + - `DataQueryResult = Readonly<{ data: UntypedData; error: DataQueryError | null; count?: number | null }>`;`UntypedData` 定义为 `any`,上一行写 `// eslint-disable-next-line @typescript-eslint/no-explicit-any -- phase 1: equals SupabaseClient; phase 2 narrows per table`(这是全仓唯一允许的一处)。 + - `DataQueryBuilder extends PromiseLike`:逐个声明 §2.1 列出的方法,签名照抄 `LocalPostgresQueryBuilder`(`single()`/`maybeSingle()` 返回 `Promise`)。 + - `DataClient`:`auth.getUser(): Promise<{ data: { user: { id: string; email?: string | null } | null }; error: unknown }>`、`from(table: string): DataQueryBuilder`、`rpc(fn: string, args?: Readonly>): Promise`。 +- `server.ts`:返回类型 `Promise`;本地分支 `as DataClient`(不再需要 `unknown`);Supabase 分支 `as unknown as DataClient`,注释写明「回滚分支,运行时形状是超集」。`admin-client-core.ts` 同理,返回 `DataClient`。 +- `local-postgres-client-core.ts`:`class LocalPostgresQueryBuilder implements DataQueryBuilder`、`class LocalPostgresDataClient implements DataClient`。 +- 8 个文件(session.ts 已删)`import type { SupabaseClient } from "@supabase/supabase-js"` 改为 `import type { DataClient } from "@/lib/db/data-client"`,并把文件内类型名 `SupabaseClient` 换成 `DataClient`(含 `type AccountingClient = ...`)。 +- `src/lib/rectification-agentic/v9/tool-service.ts` 第 47 行注释「SupabaseClient satisfies this」改为 DataClient(只改注释)。 +- 验收:`tsc --noEmit` 恰好报 §2.2 的 2 条(把输出贴进 PROGRESS),然后进入 T2。`grep -rn "as unknown as SupabaseClient" frontend/src` 无结果;`grep -rln "@supabase/supabase-js" frontend/src` 只剩 `admin-client-core.ts`(`createClient` 回滚分支)。 + +### T2 · 修缺陷与标注(本单 BUG) + +- `birth-time-scoring-job-store.ts::claimScoringJob`:去掉 `.single()`,取 `const row = Array.isArray(data) ? data[0] ?? null : data;` 再交给 `claimSchema.safeParse(row)`。数组与单对象都兼容,回滚分支(supabase-js 不带 `.single()` 时返回数组)行为一致。 +- 回归测试(新增,放 `frontend/tests/database-*.test.ts` 让它进 `test:db`):用 `startPostgresFixture` + `createLocalPostgresDataClient`(service_role)跑真实迁移,建一个虚构 legacy Case 与 scoring job,调用 `createSupabaseScoringJobStore(client).claimScoringJob(...)`,断言返回 `{ kind, algorithmVersion }` 且不抛错;去掉修复时该用例必须红(PROGRESS 记录红→绿)。若建 Case 的前置数据过重,退而写一个直接 `client.rpc("claim_birth_time_scoring_job", …)` 断言 `Array.isArray(data)` 的用例,并在 `claimScoringJob` 上用最小 fake(`rpc` 返回数组)做单元测试——两者都写,DB 用例以 Grok 结果为准。 +- `chart-profiles/route.ts:58`:`(row: unknown) =>`。 +- 验收:`tsc --noEmit` 0 错;`npm run lint` 0 error;新测试通过;**`npm run test:db`** 失败清单与 `tools/agent-loop/windows-known-failures.txt` 逐条一致。 + +### T3 · 运行时零变化证明(差分) + +- 对 T1 改过的每个文件(除 T2 两个文件),用 esbuild 转译新旧两版并比对: + +```bash +cd frontend +for f in $(git diff --name-only origin/staging -- src | grep -v -e birth-time-scoring-job-store -e chart-profiles/route -e data-client.ts); do + git show origin/staging:frontend/$f > /tmp/old.ts 2>/dev/null || continue + ./node_modules/.bin/esbuild /tmp/old.ts --loader=ts --format=esm > /tmp/old.js + ./node_modules/.bin/esbuild $f --loader=ts --format=esm > /tmp/new.js + cmp -s /tmp/old.js /tmp/new.js && echo "SAME $f" || echo "DIFF $f" +done +``` + + (Windows 下用 Git Bash,临时文件放系统临时目录,不入库。) +- 验收:全部 `SAME`;任何 `DIFF` 必须在 PROGRESS 逐条解释(例:仅注释差异)。 + +### T4 · 第二阶段输入清单(只写 PROGRESS,不改代码) + +- 临时把 `UntypedData` 换成 `unknown` 跑一次 `tsc --noEmit`,把错误按文件计数贴进 PROGRESS(写作时:`consult/route.ts` 27、`account/route.ts` 24、`consult/status/route.ts` 16、`payment/epay/status/route.ts` 12、`epay/availability.ts` 7、`v9/product-domain.ts` 5、`sessions/route.ts` 4、`v9/agent-route-request.ts` 3、`chart-profiles/route.ts` 2、`chart-profiles/[id]/route.ts` 2、`sessions/[id]/route.ts` 1、`account/avatar/route.ts` 1,另含 `session-title-guard.ts::GuardedTitleClient` 与 DataClient 不兼容 1 条),然后改回 `any`。 + +### T5 · 记录 + +- `docs/BUG_HISTORY.md` 新号:「自托管下旧版校正评分轮询 `rpc(...).single()` 必抛 TypeError」,影响面、触发条件(legacy-guided-v1 Case 的 `poll_scoring`)、根因(cast 冒充 + 内存 store 测试绕开真实适配层)、修复、验证、防复发(`DataClient` 由适配层 `implements`,新增调用写法必须先过编译器;评分 store 有 DB 级用例),关联 BUG-124/130/576/926。 +- `docs/tasks/PROGRESS-data-layer-types-20261013.md`:T1 tsc 输出、T3 差分结果、T4 清单、`test:db` 比对。 +- `CHANGELOG.md`:一行(旧版校正评分轮询报错修复);不涉及 UI,DESIGN.md 不改。 + +## 7. 让步顺序 + +T4 → T3 可先只做 server.ts / admin-client-core.ts / local-postgres-client-core.ts 三个文件的差分。**T1、T2 不得砍**(T2 是缺陷修复)。 + +## 8. 验收命令分工 + +| 谁 | 命令 | +| --- | --- | +| Grok 自跑 | `tsc --noEmit`、`npm run lint`、新增/改到的测试单跑、**`npm run test:db`(以 Grok 为准,按 `windows-known-failures.txt` 比对,多一条即未通过;Docker 未开写「未跑」)**、T3 差分脚本 | +| Claude 验收 | 上述(`test:db` 除外,记环境缺口或用本机 PG17 替身复跑)+ 前端全量 `npm test` 与基线逐条比对 + `next build`(`/` 仍 Static,首屏 gzip ±2%,预期不变)+ 复跑 T3 | + +## 9. 开工前置命令 + +```bash +git fetch origin --prune +# 前置单已合入的核对(应能看到 node-runtime-resources 的提交) +git log origin/staging --oneline -30 | grep -i "node-runtime-resources" || echo "前置未合入,停止开工" +git worktree add -b codex/data-layer-types-20261013 .worktrees/data-layer-types-20261013 origin/staging +cd .worktrees/data-layer-types-20261013 +grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -3 +grep -rn "as unknown as SupabaseClient\|from \"@supabase/supabase-js\"" frontend/src +test -e frontend/src/lib/rectification-agentic/session.ts && echo "frontend-dead-code 尚未合入:session.ts 仍在,照常一并改类型" +``` + +## 10. 交付物 + +分支 `codex/data-layer-types-20261013`;`frontend/src/lib/db/data-client.ts`;PROGRESS;BUG_HISTORY 新号;CHANGELOG 一行;无 DESIGN.md 改动。 + +## 11. 后续阶段(本单不做) + +1. 第二阶段:`data` 按表收紧为 `unknown` + 每表行类型/zod 解析(T4 清单为输入);修 `GuardedTitleClient` 兼容。 +2. 函数改名:`createServerSupabaseClient` → `createServerDataClient`、`createAdminSupabaseClient` → `createAdminDataClient`,`lib/supabase/` → `lib/db/`(纯搬移,58 个文件 import 变化)。 +3. 删 Supabase 回滚分支与 `@supabase/supabase-js`、`supabase` CLI 依赖——**需产品拍板**(回滚资产保留期限见 `docs/operations/production-server-migration-2026-08.md`);浏览器 OTP 登录仍用 `@supabase/ssr`,需先确认 self-hosted 下该组件是否还可达。 +4. 适配层能力补齐(如需要 `gte/lt/range`)一律另立单,不在类型单里顺手加。 +5. 「数据库适配层往返」性能项(P1)在 `node-runtime-resources` 或另立单中做 A/B,不在本单。 diff --git a/docs/tasks/TASK-frontend-dead-code-20261013.md b/docs/tasks/TASK-frontend-dead-code-20261013.md new file mode 100644 index 00000000..102f03fc --- /dev/null +++ b/docs/tasks/TASK-frontend-dead-code-20261013.md @@ -0,0 +1,193 @@ +# TASK · 前端死代码与无调用方接口清理(issue #待定) + +- 日期:2026-10-13 +- 来源:「2026-10-13 全仓屎山与性能排查」· 前端死代码条目 +- 执行方:**Grok(Windows + Docker)**,分支 `codex/frontend-dead-code-20261013`,只推这个分支 +- 验收:Claude(Linux) +- BUG 编号:本单发现 **1 个真实缺陷**(§2.3,未登录可触发的引擎重计算),编号 **BUG-1342**(写作时最大号 BUG-1317,1323~1341 已被同批任务书占用;开工时核对 `docs/BUG_HISTORY.md` 最大号)。其余删除是重构,无 BUG 号,记 PROGRESS。(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起) +- 开工前置:无(第一批,立即派发)。后续 `contracts-helpers-dedupe` 依赖本单合入。 + +## 1. 基线 + +- 基线 commit:`origin/staging` `77057a819` +- 判定口径:从 Next 约定入口(`src/app/**` 下 `page/layout/route/loading/error/not-found/...`、`src/instrumentation.ts`)出发,沿 `@/`、相对路径、动态 `import()` 做可达性遍历;再补查字符串引用(`readFileSync(new URL(...))`)、Python 测试、`deploy/`、文档对外承诺。 +- 复现命令(写作时用的脚本,Grok 可直接用;在 `frontend/` 下运行): + +```bash +python3 - <<'EOF' +import os,re +imp=re.compile(r'''(?:from\s+|import\s*\(\s*|import\s+|require\(\s*)['"]([^'"]+)['"]''') +files=[os.path.join(d,f) for d,_,fs in os.walk('src') for f in fs if f.endswith(('.ts','.tsx'))] +def res(src,spec): + if spec.startswith('@/'): b=os.path.join('src',spec[2:]) + elif spec.startswith('.'): b=os.path.normpath(os.path.join(os.path.dirname(src),spec)) + else: return None + b2=re.sub(r'\.(ts|tsx|js)$','',b) + for c in [b,b2+'.ts',b2+'.tsx',os.path.join(b,'index.ts'),os.path.join(b,'index.tsx')]: + if os.path.isfile(c): return os.path.normpath(c) +g={f:{r for m in imp.findall(open(f,encoding='utf8').read()) if (r:=res(f,m))} for f in files} +conv=re.compile(r'(^|/)(page|layout|route|loading|error|not-found|global-error|forbidden|template|default|instrumentation|middleware|proxy)\.(ts|tsx)$') +seen=set(); st=[f for f in files if conv.search(f)] +while st: + x=st.pop() + if x not in seen: seen.add(x); st+=g.get(x,()) +for f in sorted(set(files)-seen): print(f, sum(1 for _ in open(f,encoding='utf8'))) +EOF +``` + +## 2. 事故实证(`77057a819`) + +### 2.1 生产入口不可达的源文件(脚本输出,17 个,共 3,028 行) + +| 文件 | 行 | 引用方(全仓) | 结论 | +| --- | ---: | --- | --- | +| `src/mastra/rectification-tools.ts` | 736 | 仅 `rectification-agentic/session.ts` + `tests/rectification-agentic-tools.test.ts` | 删 | +| `src/lib/rectification-agentic/session.ts` | 351 | `tests/rectification-agentic-session.test.ts`、`tests/database-local-business.test.ts`(DB 测试,2 处断言) | 删 | +| `src/lib/rectification-agentic/date-range.ts` | 53 | 仅 `rectification-tools.ts` | 删(传递死) | +| `src/lib/agent-evals.ts` | 796 | `tests/agent-evals.test.ts`(+ fixture `tests/fixtures/agent-golden-dataset-v1.json`) | 删 | +| `src/components/personal-report/personal-report-document-view.tsx` | 630 | `personal-report-view.test.ts`(渲染)、`personal-report-progress.test.ts:208-209`、`personal-report-export.test.ts:49`(读源码) | 删;阅读页 09 月起走 `personal-report-markdown-view.tsx` | +| `src/lib/think-step-gate.ts` | 17 | `public-thinking.test.ts`、`rectification-step-answer.test.ts:64`(读源码) | 删;最后一个生产调用方在 `eef0cb748`(BUG-1053)移除 | +| `src/lib/birth-time-candidate-completion.ts` | 14 | `birth-time-candidate-completion.test.ts` | 删(函数恒返回 null;顺带消掉 BLOCKED.md 登记的 2 条 lint warning) | +| `src/components/birth-time-life-events.tsx` | 207 | 无(BUG-1193 已注明「无引用方」) | 删 | +| `src/components/evidence-audit-panel.tsx` | 64 | `evidence-audit-panel.test.ts` 读源码 | 删,**保留否定断言**(见红线) | +| `src/components/claim-boundary-badge.tsx` | 13 | `claim-boundary-badge.test.ts` 读源码 | 删,**保留否定断言** | +| `src/components/birth-time-soft-notice.tsx` | 36 | 只有否定断言(`doesNotMatch`) | 删 | +| `src/components/personal-report/raw-appendix-download.tsx` | 33 | `personal-report-raw-appendix.test.ts:106` 读源码 | 删(09-24 已授权,见 §4) | +| `src/lib/chart-library-view.ts` | 18 | 无 | 删 | +| `src/lib/db/client.ts` | 18 | 无(仓内唯一 `drizzle-orm` 引用方) | 删;依赖不动,见后续阶段 | +| `src/lib/rectification-agentic/core/index.ts` | 23 | `rectification-decide-next-action.test.ts:587` 读源码 | 删 barrel,对应测试改写 | +| `src/lib/rectification-agentic/v9/index.ts` | 11 | 无 | 删 barrel | +| `src/lib/personal-report-longform-download.ts` | 8 | 4 个测试 | **不删**——见下 | + +**修正排查结论**:`personal-report-longform-download.ts::ordinaryReportDownloadMarkdown` 是 `TASK-report-chapter-export-20260924` D5 明文保留的**逐字节回归锁**(`report-export-blocks.test.ts:55-57`「全选导出 ≡ 旧普通导出」与冻结 sha256),文件头注释也写明「retained as the full-body byte regression lock」。本单不动它。 + +### 2.2 无前端调用方的接口 + +| 接口 | 行 | 现状 | +| --- | ---: | --- | +| `POST /api/birth-rectification`(`src/app/api/birth-rectification/route.ts`) | 49 | 全仓无 fetch;只被 `tests/test_daily_and_rectification_entrypoints.py::test_birth_rectification_api_proxies_active_questionnaire_with_boundary` 读源码钉住(07 月「入口产品化」时代的源码文本断言,首页卡片入口 BUG-1247 后早已删除)。唯一依赖 `src/lib/birth-rectification-payload.ts`(41 行),后者另被 `global-birth-profile-routes.test.ts:43` 一处断言使用 | +| `POST /api/birth-time-candidate-completion` | 20 | 无调用方;恒返回 400/409 的「旧客户端兼容」桩(07-21 起),任何客户端都不会再发 | +| `POST /api/reports/[reportId]/raw-appendix` | 48 | 唯一调用方就是上面的死组件 `raw-appendix-download.tsx`;`releaseRawReportAppendix`(`src/lib/personal-report-raw-appendix.ts`,13 行)只剩这条路由 + 测试使用 | + +对外承诺核查:`deploy/`、`skills/`、`references/`、MCP、文档对外章节都没有这三个路径(`references/oracle/...json` 中的 `birth-rectification` 是无关英文词)。 + +### 2.3 真实缺陷:未登录即可触发约 20 秒的引擎重计算 + +- `/api/birth-rectification` **不鉴权**(`src/` 下没有 middleware/proxy;该 route 不读会话),Caddy 把 `/api/*` 全部反代到 web(`deploy/Caddyfile.staging`、`Caddyfile.production.selfhosted`)。 +- 它把请求转发到 Python `/api/active_rectification_questions`(`uncertainty_minutes: 30, step_minutes: 2`),**该路径不在** `scripts/api_heavy_compute_gate.py::HEAVY_COMPUTE_PATHS` 中,不受并发 2 的闸门限制。 +- 实测(本机,虚构盘 1990-01-01 12:00 新德里,`build_questionnaire` 同参数,3 次):**20.7 s / 25.6 s / 23.6 s**,中位 23.6 s 单核 CPU。 +- Next 侧 2.5 s 后 abort,但 Python 是同步处理,中止不会打断计算(推断,未在 staging 实打)。即:任何人无需登录,每发一次 POST 就消耗生产 API 容器约 20 秒 CPU(主机 2 vCPU),且无并发上限。 +- 复现命令: + +```bash +python3 -c " +import sys,time; sys.path[:0]=['.','scripts'] +from scripts.active_rectification_questions import build_questionnaire +t=time.time(); build_questionnaire('1990-01-01 12:00',uncertainty_minutes=30,step_minutes=2,lat=28.6139,lon=77.2090,tz=5.5,ayanamsa='lahiri',node_mode='mean'); print(round(time.time()-t,1))" +``` + +(运行后若工作树产生 `scratch/` 缓存,它在 `.gitignore` 里,不入库。) + +## 3. 根因 + +- 这些文件是历次改版的残留:旧 agentic 校正(被 v9 取代)、旧结构化报告阅读页(被 Markdown 阅读页取代)、内部审计面板(BUG-011/298 从聊天里摘掉后没删组件)、09-24 原始附录下线时**删除被权限系统拒绝**(BUG-1004 修订条)。每次只摘入口、不删实现,测试又在读源码文本,所以死代码一直「有测试、看起来活着」。 +- `/api/birth-rectification` 是 07 月早期首页卡片的后端;卡片删了,路由没删;写它时还没有鉴权约定与重计算闸门。**不是回归,是遗留入口从未收口。** +- 关联:BUG-011、BUG-298(内部面板不得重新挂载)、BUG-953(think 门保留的来由)、BUG-1004(原始附录下线时删除被拒)、BUG-1053(移除 think 门最后调用方)、BUG-1178(注明旧 `rectification-tools.ts` 仅 legacy 引用)、BUG-1193(`birth-time-life-events.tsx` 无引用方)。 + +## 4. 决策记录 + +- 2026-10-13 产品负责人同意按排查结论全部立单交 Grok 执行;产品偏好「多余入口宁可删除也不修」(CLAUDE.md)。 +- **测试总数下降是产品授权的例外**:AGENTS.md §7.3 规定测试总数不得低于基线;本单删除的是「只测死代码」的测试,授权其随实现一起删除。条件:PROGRESS 里逐条列出删掉的测试名(文件 + 测试标题)与原因;否定合同(「不得再挂载 X」)不在例外内,必须保留或改写成等价断言。 +- 原始附录路由:`TASK-report-reader-actions-20260924` D2 已授权「`/api/reports/[reportId]/raw-appendix` 路由与 `releaseRawReportAppendix` 若全仓再无调用者一并删除(含其测试,三栏说明)」,当时被权限系统拒绝。本单完成该授权,**不新增授权**。 +- `/api/birth-rectification`:删除即修复 §2.3 缺陷,不给它补鉴权(多余入口删除而不修)。Python 端 `/api/active_rectification_questions` 只在 Docker 内网,**不动**(`jyotish_api_server.py` 冻结)。 +- `ordinaryReportDownloadMarkdown` 保留(09-24 D5 回归锁),不推翻。 +- 不在本单:旧协议 `legacy-guided-v1` 全链路、裸 textarea(存量数据还在用);`drizzle-orm` 依赖移除;其它疑似无调用方路由(见后续阶段)。 + +## 5. 硬红线 + +1. **只删不改**:除下面列明的测试改写、DESIGN.md 一行、BLOCKED.md 划线、BUG_HISTORY/PROGRESS 外,不改任何生产文件的逻辑。删一个文件导致的编译错误只能通过「继续删死代码」或「删 import」解决,不得改业务代码去迁就。 +2. 否定合同必须保留:`chat-message-row.tsx` / `chat-message-content.tsx` / `technique-audit` 不得出现 `EvidenceAuditPanel`、`ClaimBoundaryBadge`、`techniqueTruth`/`workflowReceipt` 挂载(BUG-011/298);`" frontend/src frontend/tests`。 +- 验收:Claude `next build` 后首屏 gzip 只减不增。 + +## 7. 让步顺序 + +时间不够依次砍:T5 → T3 中 raw-appendix 部分(退回为 PROGRESS 待办)→ T2 中 `personal-report-view.test.ts` 拆分(保留 document-view 文件,写 PROGRESS)。**T3 的 `/api/birth-rectification` 删除与 T4 BUG 记录不得砍**。 + +## 8. 验收命令分工 + +| 谁 | 命令 | +| --- | --- | +| Grok 自跑 | `tsc --noEmit`;`npm run lint`;改到/删到的前端测试文件逐个单跑(fail、cancelled 均为 0);`.venv/bin/python -m pytest tests/test_daily_and_rectification_entrypoints.py tests/test_repo_privacy_markers.py -q`;**`npm run test:db`**(动了 `database-local-business.test.ts`,以 Grok 为准,按 `windows-known-failures.txt` 比对)。前端全量、Python 快速门、`next build` 不跑 | +| Claude 验收 | 上述全部 + 前端全量 `npm test`:失败清单与基线逐条一致,总数下降量 = PROGRESS 删除测试清单条数(逐名核对)+ Python 快速门 + `next build`(`/` 仍 Static,首屏 gzip 不增)+ 重跑 §1 脚本 | + +## 9. 开工前置命令 + +```bash +git fetch origin --prune +git worktree add -b codex/frontend-dead-code-20261013 .worktrees/frontend-dead-code-20261013 origin/staging +cd .worktrees/frontend-dead-code-20261013 +git log -1 --format=%h # 应为 77057a819 或其后代 +grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -3 # 核对最大号 +# 开工基线:前端测试文件数与测试数(写进 PROGRESS) +ls frontend/tests/*.test.ts frontend/tests/*.test.tsx | wc -l +grep -rn "EvidenceAuditPanel\|ClaimBoundaryBadge\|BirthTimeSoftNotice\|RawAppendixDownload\|think-step-gate\|document-view\|rectification-agentic/core/index" frontend/tests tests +``` + +## 10. 交付物 + +- 分支 `codex/frontend-dead-code-20261013`;`docs/tasks/PROGRESS-frontend-dead-code-20261013.md`;`docs/BUG_HISTORY.md` 新号 + BUG-1004/953 追记;`BLOCKED.md` 划线;`CHANGELOG.md` 一行;`frontend/DESIGN.md` 一行修正(不是 UI 改动)。 + +## 11. 后续阶段(本单不做) + +1. `drizzle-orm` 依赖:`lib/db/client.ts` 删除后仓内零引用,但 `@better-auth/drizzle-adapter` 在 lock 里有 peer 关系,移除前需单独核实,另立单。 +2. 疑似无前端调用方的路由(写作时字面量检索无命中,未核实是否被 Agent 工具/后台/运维使用):`/api/reports/[reportId]/professional-reference`、`/api/rectification/cases/[caseId]/{close,tie-break,upgrade-skill,adopt-skill}`、`/api/locations/search`。另立单逐个核实后再删。 +3. 全站 `app/api/**/route.ts` 鉴权盘点(本单只删一个未鉴权入口;`chart-view`、`birth-sky` 在 service 内部鉴权,未在本单复核)。 +4. `legacy-guided-v1` 旧协议退役(需存量数据迁移决策)。 diff --git a/docs/tasks/TASK-home-sidebar-rerender-20261013.md b/docs/tasks/TASK-home-sidebar-rerender-20261013.md new file mode 100644 index 00000000..88496085 --- /dev/null +++ b/docs/tasks/TASK-home-sidebar-rerender-20261013.md @@ -0,0 +1,142 @@ +# TASK · 流式回答时首页侧栏每帧整栏重渲染(issue #待定) + +- 日期:2026-10-13 +- 来源:「2026-10-13 全仓屎山与性能排查」——P1「首页侧栏重渲染」 +- 执行方:**Grok(Windows + Docker)**,分支 `codex/home-sidebar-rerender-20261013`,只推这个分支 +- 验收:Claude(Linux) +- BUG 编号:**BUG-1329**(开工时核对 `docs/BUG_HISTORY.md` 最大号;写作时最大号 BUG-1317;号段由主会话分配)(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起) +- 开工前置:**无**(第一批立即派发) + +## 1. 基线 + +- 基线 commit:`origin/staging` `77057a819` +- 链路:`frontend/src/app/(app)/page.tsx::Home` → `frontend/src/hooks/use-home-shell-registration.ts::useHomeShellRegistration` → `frontend/src/lib/session-list-context.tsx::SessionListProvider.registerShellControls`(`setRegistration`)→ `ShellRegistrationContext` → `frontend/src/app/(app)/layout.tsx::AppShell` → `frontend/src/components/app-sidebar.tsx::AppSidebar` 与 `frontend/src/components/ui/sidebar.tsx::SidebarProvider`。 +- 未开 React Compiler:`frontend/next.config.ts` 无 `reactCompiler`,无 `babel.config.*` / `.babelrc`,`package.json` 无 `babel-plugin-react-compiler`(eslint 的 react-hooks 编译器规则只做静态检查,不做自动记忆化)。BUG-725 也明确「不启用 React Compiler」。 + +## 2. 事故实证(`77057a819`,源码逐条核实) + +1. `use-home-shell-registration.ts` 第二个 `useEffect`(约 87–146 行)每次运行都 `registerShellControls({...})`,依赖数组含: + - `renameSession / selectSession / startNewChat / togglePinnedSession / shareSession / loadMoreSessions`——它们在 `use-session-management.ts::useSessionManagement`(约 271–420 行)里是普通 `function` 声明,**每次渲染都是新函数**; + - `visibleSessions`——同文件约 184 行 `sortSessions(sessions.filter(...))`,**每次渲染都是新数组**(即使只修回调,这一项也会让 effect 每次重跑); + - `openAccountDialog`(`use-profile-onboarding.ts` 约 139 行,同样每次新建)。 +2. 每次注册都是新对象 → `setRegistration` 必然触发 → `AppShell` 重渲染 → `AppSidebar`(无 `memo`)重渲染,`favoriteSessions / historySessions / groupSessionsByRecency` 重算;注册 effect 里 `visibleSessions.map(toSidebarSessionRow)` 每次全量重建行;`SidebarProvider` 的 `context` 对象(`ui/sidebar.tsx` 约 171 行)未记忆化,`SidebarMenuButton` / `SidebarMenuLink` 等全部 `useSidebar()` 消费者跟着重渲染——侧栏每条会话行都是其中之一。 +3. 流式回答期间 `use-consultation-run.ts` 的 `createStreamFrameBuffer` 每个动画帧 `setStreamingReply(next)` 一次(BUG-473 设计如此),`Home` 每帧重渲染 → 上述整条链**每帧一次**。一条 900 字回答约几十到上百帧,会话列表越长越重。 +4. 没有死循环:BUG-971 已把 registration 拆进只给外壳订阅的 `ShellRegistrationContext`,`Home` 不订阅自己生产的注册,`children` 原样透传。所以是「Home 每渲染一次,外壳陪一次」,不是无限循环。 + +渲染次数「改前」由 T4 的计数测试在基线代码上实测给出(本机无 `node_modules`,起草时未跑;按源码推断:N 个流式帧 → 注册 N 次、`AppSidebar` 提交 N 次)。 + +## 3. 根因 + +`useSessionManagement` 返回的回调与 `visibleSessions` 没有稳定身份,而注册 effect 把它们全部列为依赖、且每次无条件发布新注册对象。BUG-971 修的是「Home 订阅自己的注册 → 死循环」,按其决策**保留了完整依赖与最新回调**,只切断了反馈;「外壳陪 Home 每渲染一次」这一半当时未处理。**不是回归,是 BUG-971 遗留的已知开销**;咨询面(BUG-473)与校正面(BUG-725)各自的消息列表已经做了记忆化边界,侧栏这一侧没人做。 + +关联:BUG-971(侧栏注册反馈循环)、BUG-927(共享外壳)、BUG-744~746(侧栏统一、单一 `SidebarProvider`)、BUG-725(`actionsRef` 做法)、BUG-473(流式按帧提交)。 + +## 4. 决策记录 + +- 2026-10-13 产品负责人同意按排查结论全部立单交 Grok 执行;本单 P1,需 A/B。 +- **AGENTS.md §6 的「参数式 hook 内部保持 0 个 React hook」已于 2026-09-15 被推翻**(`TASK-freeze-metric-change-20260915.md`,`3b17c1b2`;现行 §6:「抽出去的 hook 与子组件应当持有自己的状态,`page.tsx` 只做装配」)。所以本单可以在 `useHomeShellRegistration` / `useSessionManagement` 里用 `useRef` / `useMemo` / `useCallback`。执行方若手里的 AGENTS.md 副本还写着旧句,以 `origin/staging` 的 `AGENTS.md` 为准。 +- **不做运行时开关**(偏离「P1 改动做成开关」的通用要求,理由):这是纯前端渲染次数优化,输出行为(注册内容、点击后调用的回调)要求与基线逐项一致;做开关就得同时保留两套注册路径,违反「不得写第二套」的精神,且 `NEXT_PUBLIC_*` 开关会进前端包。A/B 用「同一测试在基线与新代码上各跑一遍」完成。若主会话 / 产品要求开关,改为本单不做、回报「需拍板」。 +- 不推翻 BUG-971 的任何防复发条款(见红线 2~4)。 + +## 5. 硬红线 + +1. **不得冻结首次闭包**:外壳拿到的回调,被调用时必须执行 `Home` 最新一次渲染的版本(BUG-971)。 +2. **Home 不得订阅 `ShellRegistrationContext`**;`AppShell` 不得重建 `children`;注册仍在 `Home` 卸载时注销(`registerShellControls(null)`)(BUG-971)。 +3. `frontend/tests/session-list-lifecycle.test.tsx` 里 `HomeProbe` **故意每次渲染传新数组、新回调**(注释「Like useSessionManagement, intentionally new array/callbacks each render」)——**不得改成固定常量**来让测试通过(BUG-971 防复发原文)。新代码必须在这种不稳定输入下也做到不重复注册。 +4. 不得在渲染期写 ref(react-hooks 编译器 lint 规则会拦);不得用 `eslint-disable` 绕过。最新回调的保存放在 effect 里做(BUG-725 的 `actionsRef` 思路:组件拿稳定包装函数,包装函数读 `ref.current`,`ref.current` 在 effect 中更新)。 +5. 不改侧栏外观、文案、行顺序、分组、分页触发、菜单行为;单一 `SidebarProvider`、layout 只挂一次(BUG-744~746 合同测试)不变。 +6. `frontend/src/app/(app)/page.tsx`:`Home()` 的 `useState` / `useRef` 数不得增长(`frontend/tests/home-shell-growth-contract.test.ts`),行数在其护栏内;本单预期 `page.tsx` 不需要改,若要改只能是删减或改调用参数。 +7. 不改流式提交节奏(`stream-frame-buffer`,BUG-473);不启用 React Compiler;不升级依赖;不改 `.gitea/workflows/**`;不动数据库与迁移;`scripts/jyotish_api_server.py` 不碰。 +8. 改既有断言写「原值 / 新值 / 原因」三栏。已知必改一条:`session-list-lifecycle.test.tsx` 中 `assert.notEqual(app.shell?.controls.onOpenProfile, initialCallback)`(回调稳定后两者相等)——新断言必须改为证明「调用到的是最新版本」(该用例已有 `callbackRevisions` 断言 `[1]`,保留并加强),不得只删不补。 + +## 6. 任务分解 + +### T1 · 注册只在「数据真的变了」时发布(`use-home-shell-registration.ts`) + +- 新增一个 `latestRef`(`useRef`),在**第一个 effect**里每次渲染后写入本次全部回调与 `sessions`(无依赖数组;不 setState,开销可忽略)。 +- 对外的回调全部改成**稳定包装函数**(`useMemo(() => ({...}), [])` 或 `useCallback(..., [])`),内部转调 `latestRef.current.xxx(...)`:`onLoadMore`、`onMenuSessionChange`、`onTogglePinned`、`onRename`、`onShare`、`onDelete`、`onAccountMenuOpenChange`、`onNewChat`、`onSelectSession`、`onOpenProfile/General/Billing/Logout`。`onRename / onShare / onDelete` 里按 id 查 session 也改读 `latestRef.current.sessions`。 +- 注册 effect 改为「比较后发布」:在 effect 内(不是渲染期)与上次已发布的输入做比较,相同则**不调用** `registerShellControls`: + - 标量 / 引用字段:`hydrated`、`account`(引用)、`profile.name`、`accountMenuOpen`、`modalOpen`、`activeSessionId`、`rectificationOpeningSessionId`、`rectificationSurfaceOpen`、`creatingSession`、`pendingSessionId`、`cancellationPending`、`modelCatalog`(引用)、`sessionMenuId`、`sessionsCursor`、`accountTrigger`(引用)、`chartLibrary`(引用); + - `visibleSessions`:**逐元素引用相等**(长度相同且每个 session 对象 `===`)即视为未变——`setSessions` 只替换改动的那一行对象,未改的行引用不变; + - 未变时连 `toSidebarSessionRow` 都不跑;变了才重建 `sidebarSessions` 并发布。 + - 上次已发布的输入存在一个 effect 内读写的 ref 里;`registerShellControls(null)`(未 hydrated / 无账户 / 卸载)后清空它,保证重新进入 Home 时一定重新发布。 +- 依赖数组可以保留完整(effect 每次跑但大多提前返回),或收窄到数据字段——二选一,以 lint 0 error、无 `eslint-disable` 为准。 +- 不改 `ShellRegistration` 类型与 `session-list-context.tsx` 的 provider 结构。 +- 验收:见 T4;`session-list-lifecycle.test.tsx` 全部用例(含 StrictMode 两遍)通过。 + +### T2 · `useSessionManagement` 的 `visibleSessions` 记忆化(`use-session-management.ts`) + +- `visibleSessions` 用 `useMemo`,依赖 `sessions` 与当前人物 id。当前代码在渲染期调用 `readCurrentSubjectId()`;改为用 `lib/current-subject.ts` 已有的 `useCurrentSubject()` 返回值进依赖(确认该 hook 在人物切换时触发重渲染;`SessionListProvider` 已在用它)。 +- 这一步让真实页面里 T1 的比较几乎总是一次命中;T1 不依赖它(测试里输入故意不稳定)。 +- 不把其余函数改成 `useCallback`(T1 的稳定包装已覆盖外壳用到的那些;全量改造面太大,见「后续」)。 +- 验收:人物切换后侧栏列表正确换人(`tests/chat-profile-picker.test.ts` 全绿,并在 T4 测试里加一条「切换人物后 `sidebarSessions` 换成新人物的会话」);`tsc`、lint 0 error。 + +### T3 · `SidebarProvider` 的 context 值记忆化(`components/ui/sidebar.tsx`,可砍) + +- `context` 用 `useMemo`;`setInsetTrigger` / `setSidebarSurface` 改 `useCallback`(只写 ref,安全)。依赖为 `open / openMobile / viewport / ready / isMobile / setOpen / setOpenMobile / toggleSidebar`。 +- 作用:注册真变化时(如切会话),`SidebarMenuButton` 等消费者不再因为 context 对象换新而额外重渲染。 +- 验收:`sidebar-state.test.ts`、`sidebar-contract.test.ts` 全绿、无断言改动。 + +### T4 · 回归测试 + A/B(渲染计数) + +**新测试** `frontend/tests/home-sidebar-rerender.test.tsx`(用现有 `tests/react-client-lifecycle-test-support.ts::createClientLifecycleHarness`,真实 React reconciler 与 effect): + +- 结构:`SessionListProvider` → 与 `layout.tsx::AppShell` 同构的外壳(`SidebarProvider` + **真实 `AppSidebar`** 包在 `` 里计提交次数 + `SidebarInset`)→ `HomeProbe`。 +- `HomeProbe` 调用 `useHomeShellRegistration`,**每次渲染传新回调、新 `visibleSessions` 数组(元素对象引用不变)**,与 `session-list-lifecycle.test.tsx` 同法;另有一个 `frame` state 模拟流式帧。会话列表放 30 条虚构会话(`Synthetic session N`)。 +- 驱动:hydrated 并 idle 后,清零计数;`harness.update` 连续推进 **60 帧**(每帧 `setFrame(n)`);再推进 1 次「真变化」(改 `activeSessionId`);再 1 次「替换其中一行 session 对象(改标题)」。 +- 计数:`registerShellControls` 调用次数(包一层 spy)、`AppSidebar` Profiler 提交次数、`toSidebarSessionRow` 调用次数(可选,若能不改生产代码地计到)。 +- 断言(B):60 帧期间注册 **0** 次、`AppSidebar` 提交 **0** 次;`activeSessionId` 变化后注册 1 次且外壳拿到新值;改标题后侧栏行标题更新;随后调用外壳的 `onSelectSession` / `onRename`,执行的是**最新一次渲染**传入的回调(记录 revision 断言)。 +- 反向验证:同一测试在基线代码(`origin/staging` `77057a819`)上运行,预期 60 帧对应注册 ≈ 60、提交 ≈ 60 而失败;把失败输出原样贴进 PROGRESS。 + +**A/B 记录** `docs/testing/home-sidebar-rerender-ab-20261013.md`: + +- A = `77057a819`(在基线工作树里临时放入同一测试文件,只读运行,不提交到基线),B = 本分支。同一测试、同一输入,各跑 **3 次**取中位数。 +- 度量:60 帧内注册次数、`AppSidebar` 提交次数、Profiler `actualDuration` 合计(ms);另报 30 条与 100 条会话两档。 +- 正确性:B 的「真变化」后外壳收到的 registration 与 A 同一时刻收到的 registration,在剔除函数字段后 `JSON.stringify` **逐字节一致**;函数字段用「调用后记录的 revision」比对一致。 +- 判定门槛:B 在 60 帧内注册 = 0 且 `AppSidebar` 提交 = 0(A 预期 ≈ 60),且逐字节一致、`session-list-lifecycle` 全绿 → 交付。达不到 0 时写明剩余提交的来源,由 Claude 判断。 +- 浏览器侧(Grok 可选、Windows 本机):`npm run dev`,Chrome 装 React DevTools,Profiler 勾选「Record why each component rendered」,在登录态首页发一条问题,录完整个流式回答,记录 `AppSidebar` 的提交次数,A/B 各一次截图存 PROGRESS(不含任何真实会话内容,截图前把侧栏折叠或用虚构账户)。没有可用账户或模型 key 就写「浏览器 A/B 未跑」,以测试计数为准;同样的步骤写进 `docs/testing/home-sidebar-rerender-20261013.md` 真机清单留给产品负责人。 + +### T5 · 记录 + +- `docs/BUG_HISTORY.md` 新增 **BUG-1329**(流式回答期间首页侧栏每帧整栏重渲染):根因写「BUG-971 只切断反馈、未做发布去重」,关联 BUG-971 / 725 / 473 / 744~746;防复发写「外壳注册须按数据变化发布;任何流式会话面要有侧栏提交次数的按帧断言」。 +- `docs/tasks/PROGRESS-home-sidebar-rerender-20261013.md`:基线失败输出、三栏断言改动表、A/B 结论。 +- `frontend/DESIGN.md`:在侧栏 / 共享外壳条目下补一句「流式期间侧栏不随回答帧重渲染(BUG-1329)」——属于行为约束,不是视觉改动;`CHANGELOG.md` 一句「回答生成时侧栏不再跟着闪动 / 卡顿」。Skill 不 bump。 + +## 7. 让步顺序 + +T1 + T4(必须一起交)→ T2 → T3 → 浏览器侧 A/B。时间不够先砍 T3,再砍 T2;T1 不能脱离 T4 的回归测试单独交付。 + +## 8. 验收命令分工 + +| 谁 | 命令 | +| --- | --- | +| Grok 自跑 | `./node_modules/.bin/tsc --noEmit`;`npm run lint`(0 error,不加 `eslint-disable`);`npx tsx --test tests/home-sidebar-rerender.test.tsx tests/session-list-lifecycle.test.tsx tests/session-list-provider.test.ts tests/sidebar-contract.test.ts tests/sidebar-state.test.ts tests/secondary-page-entry.test.ts tests/account-dialog-inert-20260918.test.ts tests/home-shell-growth-contract.test.ts tests/home-streaming-render-split.test.ts`(fail 与 cancelled 都为 0);A/B 三次。前端全量 `npm test`、`next build` 不跑 | +| Claude 验收 | 上述全部复验 + 在基线上复跑新测试确认会红 + 前端全量 `npm test` 与基线逐名比对 + `next build`(`/` 仍 Static,首屏 gzip ±2%)| +| 不需要 | `test:db`、Python 测试(不碰数据库访问层与引擎) | + +## 9. 开工前置命令 + +```bash +git fetch origin --prune +git worktree add -b codex/home-sidebar-rerender-20261013 .worktrees/home-sidebar-rerender-20261013 origin/staging +cd .worktrees/home-sidebar-rerender-20261013 +git log -1 --format=%h # 应为 77057a819 或其后代 +grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -3 # 核对最大号 +grep -n "参数式\|持有自己的状态" AGENTS.md # 应只看到「抽出去的 hook 与子组件应当持有自己的状态」 +grep -n "reactCompiler\|babel-plugin-react-compiler" next.config.ts package.json frontend/next.config.ts frontend/package.json 2>/dev/null # 应无输出 +grep -rn "notEqual(app.shell?.controls\|intentionally new array" frontend/tests +# A/B 用的基线工作树(只读) +git worktree add --detach .worktrees/base-home-sidebar-rerender origin/staging +``` + +## 10. 交付物清单 + +- 代码:`frontend/src/hooks/use-home-shell-registration.ts`、`frontend/src/hooks/use-session-management.ts`、(可砍)`frontend/src/components/ui/sidebar.tsx` +- 测试:`frontend/tests/home-sidebar-rerender.test.tsx`(新);`frontend/tests/session-list-lifecycle.test.tsx`(一条断言按三栏改) +- 文档:`docs/tasks/PROGRESS-home-sidebar-rerender-20261013.md`、`docs/testing/home-sidebar-rerender-ab-20261013.md`、`docs/testing/home-sidebar-rerender-20261013.md`(真机清单)、`docs/BUG_HISTORY.md`(BUG-1329)、`frontend/DESIGN.md`(一句)、`CHANGELOG.md` + +## 11. 后续阶段(本单不做) + +- `useSessionManagement` 其余函数全面 `useCallback` 化 / 拆分持有状态(属于 Home 状态下沉系列)。 +- `AppSidebar` 用 `memo` 包裹并拆出会话分组子组件——T1 之后外壳只在数据变化时重渲染,收益小,等 A/B 显示仍有必要再立单。 +- 流式期间 `Home` 自身每帧重渲染(只让直播行订阅流式状态)——属于咨询面架构,另立单。 diff --git a/docs/tasks/TASK-node-runtime-resources-20261013.md b/docs/tasks/TASK-node-runtime-resources-20261013.md new file mode 100644 index 00000000..021d9313 --- /dev/null +++ b/docs/tasks/TASK-node-runtime-resources-20261013.md @@ -0,0 +1,249 @@ +# TASK · Node 进程资源:报告 worker 监听泄漏、本地 Postgres 往返、无上限内存表(issue #待定) + +- 日期:2026-10-13 +- 来源:「2026-10-13 全仓屎山与性能排查」——P0「报告 worker 空转泄漏」、P1「数据库适配层往返」、小项「服务端无上限 Map」 +- 执行方:**Grok(Windows + Docker)**,分支 `codex/node-runtime-resources-20261013`,只推这个分支 +- 验收:Claude(Linux) +- BUG 编号:**BUG-1326~1328**(开工时核对 `docs/BUG_HISTORY.md` 最大号;写作时最大号 BUG-1317;号段由主会话分配)(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起) + - BUG-1326:报告 worker 空转每 2 秒挂一个 abort 监听,永不释放(P0) + - BUG-1327:本地 Postgres 适配层每条查询 6 次往返、元数据每次现查(P1,需 A/B) + - BUG-1328:三张服务端内存表只增不减(小) +- 开工前置:**无**(第一批立即派发) + +## 1. 基线 + +- 基线 commit:`origin/staging` `77057a819` +- 涉及文件: + - `frontend/src/lib/personal-report-worker-core.ts`(`defaultSleep`、`runPersonalReportWorkerLoop`) + - `frontend/src/lib/personal-report-worker.ts`(`startPersonalReportWorker`,由 `frontend/src/instrumentation.ts::register` 在 Node 运行时启动) + - `frontend/src/lib/birth-time-guided-polling.ts`(`scoringPollDelay`,同型写法,见 T1) + - `frontend/src/lib/db/local-postgres-client-core.ts`(`inBusinessTransaction`、`columnTypes`、`primaryKeyColumns`、`functionMetadata`、`LocalPostgresQueryBuilder.execute`、`LocalPostgresDataClient.rpc`) + - `frontend/src/lib/rectification-agentic/v9/block-scan-answer.ts`(`rescoreAttempts`) + - `frontend/src/lib/rectification-agentic/v9/delivery-turn-guard.ts`(`delivered`) + - `frontend/src/lib/request-rate-limit.ts`(`consumeRequestRateLimit`) + +## 2. 事故实证(`77057a819`) + +### 2.1 报告 worker 监听泄漏(BUG-1326) + +`personal-report-worker-core.ts::defaultSleep` 每次 sleep 都对传入的 `signal` 执行 `addEventListener("abort", …, { once: true })`。`{ once: true }` 只在 abort **触发时**摘除;计时器正常到点 resolve 后,监听留在 signal 上。 + +`runPersonalReportWorkerLoop` 空闲(`tick()` 返回 `idle`)或 `tick()` 抛错时都会 `await sleep(pollIntervalMs=2000, options.signal)`。`options.signal` 来自 `personal-report-worker.ts::startPersonalReportWorker` 里进程级的 `new AbortController()`,只有 `stop()` 才 abort,生产里从不调用。于是每个 Next.js Node 进程每 2 秒多挂一个闭包(闭包还引用已过期的 timer),一天 43,200 个。 + +复现(虚构负载,与源码同形;Node 22.14.0,`--expose-gc`): + +```bash +node --expose-gc leak-fixed.mjs # 基线写法(脚本见文末附录) +node --expose-gc leak-fixed.mjs fixed # 到点即摘监听 +``` + +| 写法 | sleep 次数 | 残留 abort 监听 | GC 后堆增长 | +| --- | --- | --- | --- | +| 基线 | 43,200(= 空闲一天) | 43,200 | **+22.25 MB** | +| 到点 `removeEventListener` | 43,200 | 0 | +0.19 MB | + +(abort 之后基线堆回落到 +0.36 MB,证明增长全部挂在这个 signal 上。)即每个 Node 进程约 **22 MB/天**单调上涨,直到容器重启;生产 4 GB 主机上与 Python API、Postgres 共用内存。 + +全仓 `frontend/src` 同类写法排查(`addEventListener("abort"` 全部 18 处逐一读过): + +| 位置 | 是否在循环里对长寿命 signal 挂监听不摘 | 结论 | +| --- | --- | --- | +| `personal-report-worker-core.ts::defaultSleep` | 是,signal 进程级 | **泄漏,T1 修** | +| `birth-time-guided-polling.ts::scoringPollDelay` | 是,同型写法;但 signal 是 `use-birth-time-automatic-journey-effects.ts` 里每次 effect 新建的 controller,`maxAttempts: 7`,最多残留 6 个,effect 卸载即随 controller 回收 | 有界、不泄漏;同一提交顺手改成到点摘除(同型写法不留第二份,T1b) | +| `session-title-agent.ts`、`session-context-summary.ts`、`adopt-narration-agent.ts`、`turn-intent-classifier.ts::classifyWithinTimeout`、`agent-run-attempt.ts`、`consultation-smalltalk.ts`、`home-bootstrap-run.ts::runHomeBootstrap`、`home-cloud-sync.ts::waitForUndoWindow` | 否,均在 `finally` / `dispose` / 到点时 `removeEventListener` | 干净 | +| `consultation-recovery-poll.ts::runConsultationRecoveryPoll` | 每次调用挂一个、不摘;signal 是 `page.tsx` 中每个 recovering effect 新建的 controller | 有界、不改 | +| `mastra/consultation-tools.ts::createConsultationRunClock` | 对本次请求新建的 `AbortSignal.timeout` 挂一次 | 有界、不改 | + +另两个进程级 worker(`account-deletion-worker.ts`、`reply-quality-retention-worker.ts`)用 `setInterval`,不碰 signal,干净。 + +### 2.2 本地 Postgres 适配层往返(BUG-1327) + +自托管模式下所有业务读写走 `LocalPostgresDataClient`(`lib/supabase/server.ts::createServerSupabaseClient` 用 `authenticated` + 会话身份;`lib/supabase/admin-client-core.ts::createAdminSupabaseClient` 用 `service_role` + 无身份)。按源码逐条数,一次调用的往返: + +| 调用 | 往返序列 | 次数 | +| --- | --- | --- | +| `.from(t).select()/update()/delete()/insert()` | `begin` → `set local role ` → `select set_config(sub), set_config(email)` → `columnTypes`(查 `information_schema.columns`)→ 业务 SQL → `commit` | **6** | +| `.upsert()` 未传 `onConflict` | 上述 + `primaryKeyColumns`(查 `pg_index`) | **7** | +| `.rpc(fn, args)` | `begin` → `set local role` → `set_config` → `functionMetadata`(查 `pg_proc`)→ 调用 → `commit` | **6** | + +真正干活的只有 1 次;表结构 / 函数签名每次现查,进程内无任何缓存。`information_schema.columns` 是带权限过滤的视图,单次比业务主键查询还重。一个首页启动(账户、人物、会话列表)或一轮咨询落库会串行打十几次这种调用。 + +**注意(影响缓存设计)**:`columnTypes` 在 `set local role` **之后**执行,`information_schema.columns` 只列当前角色有权限的列,所以 `authenticated` 与 `service_role` 看到的列集合可以不同——缓存键必须带角色。`pg_proc` 不按权限过滤。 + +### 2.3 无上限内存表(BUG-1328) + +| 位置 | 键 | 何时删 | 实际有效期 | +| --- | --- | --- | --- | +| `block-scan-answer.ts` 模块级 `rescoreAttempts: Map` | `caseId:askedTurnId:证据指纹` | 从不(只有测试用 `resetStaleMinuteRescoreAttemptsForTests`) | 语义是「同一证据指纹只重算一次」(BUG-578),每道题 / 每次证据变化一条,永久累积 | +| `delivery-turn-guard.ts` 模块级 `delivered: Map` | caseId | 从不(只有 `resetDeliveryTurnGuardForTests`) | 所有读取方都先判 `now - at <= 60_000`,超过 60 s 的条目已无任何作用(BUG-596) | +| `request-rate-limit.ts` 全局 `jyotishaRequestRateLimits: Map` | `bucket:userId` 等 | 从不;只在**同一个 key 再次被访问**时过滤旧时间戳 | 某用户不再访问,该 key 及其旧时间戳数组永久留存;key 数 ≈ 历史活跃用户数 × 桶数(六个具名桶 + `chartView`、`birthSky`) | + +均为进程级,随进程寿命线性增长;单条小,但与 2.1 同属「长寿命进程只增不减」。 + +## 3. 根因 + +- **BUG-1326**:误以为 `{ once: true }` 会在 Promise 结束时自动摘监听;只有 abort 事件触发才摘。worker 自上线起即如此,**从未设计回收,不是回归**。`docs/BUG_HISTORY.md` 检索 `defaultSleep`、`runPersonalReportWorkerLoop`、`addEventListener`、`MaxListeners`、「内存泄漏」无记录。同一提交里已有正确示范(`home-cloud-sync.ts::waitForUndoWindow` 到点时 `removeEventListener`)。 +- **BUG-1327**:适配层是 Supabase → 自托管 Postgres 迁移时按「每次调用一个完整事务 + 现查元数据」写的兼容层,追求正确、未做缓存。不是回归。相关记录:BUG-166(同文件连接池按 URL 关闭)、BUG-926(`order()`)、`local-postgres-not` / `local-postgres-or` 两条算子修复——这些防复发(按 URL 关池、算子语义)本单不得动。 +- **BUG-1328**:三张表都只为「短时间内去重 / 限流」而建,写的人没考虑进程寿命。BUG-578 的「同一证据指纹只重算一次」、BUG-596 的「交付后 60 s 内无用户消息不再开计费轮」是语义来源,本单不得改变其在有效期内的行为。 + +## 4. 决策记录 + +- 2026-10-13 产品负责人同意按排查结论全部立单交 Grok 执行(本单含 P0 + P1 + 小项)。 +- 产品追加要求:P1 改动必须 A/B 并做成开关,默认值按 A/B 结论定——本单 T2 照做;T1、T3 是缺陷修复且行为等价(只回收已无作用的条目 / 监听),不做开关。 +- **只读无身份调用省掉事务:评估结论为不做**(理由见 T2「评估」),不是让步项。 +- **Better Auth `cookieCache`(会话读缓存):本单不做,需拍板。** 它会改变「改密码 / 注销 / 封号后旧会话多久失效」的登录行为,属于产品与安全决策,不是性能改动;排查报告的数字留作拍板依据。 +- 不推翻任何既有红线。 + +## 5. 硬红线 + +1. 不改数据库结构、不加迁移、不动 `frontend/db/**`、`supabase/**`;不改 RLS 策略、角色与授权。 +2. **事务语义不变**:每次 `.from()` / `.rpc()` 调用仍在自己的事务里、仍以同一角色(`set local` 等价物,事务结束即失效)和同一组 `request.jwt.claim.*` 执行;连接归还池子时不得带着角色或 claim。不得改成会话级 `set role`。 +3. 缓存只缓存**元数据**(列类型、主键列、函数签名),绝不缓存业务数据;缓存键必须含连接串与角色;不缓存「查不到」(空列表、`database function not found`)。 +4. BUG-578「同一证据指纹只重算一次」与 BUG-596「60 s 窗口内 `already_delivered`」在有效期内行为逐条不变;`request-rate-limit` 在窗口内的放行 / 拒绝与 `retryAfterSeconds` 逐条不变。 +5. `closeLocalPostgresDataPool(s)` 的按 URL 关池行为(BUG-166)不变,并顺带清掉该 URL 的元数据缓存。 +6. 冻结约束:`scripts/jyotish_api_server.py` 不碰(`tests/test_api_server_growth_contract.py`);不碰 `frontend/src/app/(app)/page.tsx`(`home-shell-growth-contract`);不改 `.gitea/workflows/**`;不升级依赖、不顺手修不在本单里的 warning。 +7. 改既有断言写「原值 / 新值 / 原因」三栏;开工先 grep:`grep -rn "set local role\|information_schema\|resetDeliveryTurnGuardForTests\|resetStaleMinuteRescoreAttemptsForTests\|RequestRateLimitStore" frontend/tests`(已知:`admin-contracts.test.ts` 215 行匹配迁移文本里的 `set local role service_role`,那是迁移 SQL 不是适配层,不受影响)。 +8. 开关只读服务端环境变量,不进 `NEXT_PUBLIC_*`,不入前端包。 + +## 6. 任务分解 + +### T1 · 报告 worker sleep 到点摘监听(BUG-1326,P0) + +- `personal-report-worker-core.ts::defaultSleep` 改为:命名 `onAbort`;计时器到点时先 `signal?.removeEventListener("abort", onAbort)` 再 resolve;abort 时清计时器并 resolve。保持 `unref`、保持「已 abort 直接返回」。 +- 不改 `runPersonalReportWorkerLoop` 的轮询间隔、错误处理与退出条件;不改 `startPersonalReportWorker`。 +- **T1b**:`birth-time-guided-polling.ts::scoringPollDelay` 同样到点摘监听(有界,不是泄漏,只为全仓不留第二份错误写法)。 +- 验收(新测试 `frontend/tests/personal-report-worker-sleep-listener.test.ts`): + - 用 `runPersonalReportWorkerLoop` + `pollIntervalMs: 1` + 一个连续返回 `idle` 的假 worker,第 200 次 `tick` 时用 `node:events` 的 `getEventListeners(controller.signal, "abort").length` 记录监听数,然后 abort。断言 **= 0**(基线为 199);另一条:`tick` 抛错路径同样 = 0。 + - abort 后 loop 在一个轮询间隔内结束(`done` resolve)。 + - `scoringPollDelay`:到点 resolve 后监听数 = 0;abort 时立即 resolve。 + - 写进 PROGRESS:在基线代码上跑新测试的失败输出(证明能抓回归)。 +- 命令:`npx tsx --test tests/personal-report-worker-sleep-listener.test.ts tests/personal-report-worker.test.ts`。Grok 自跑;Claude 复验,并另用 `leak-fixed.mjs` 同形脚本复测(目标:43,200 次 sleep 后监听 0、堆增长 < 1 MB)。 + +### T2 · 本地 Postgres 元数据缓存 + 合并会话设置(BUG-1327,P1,开关 + A/B) + +**T2a 进程级元数据缓存(开关 `JYOTISHA_PG_METADATA_CACHE`,取值 `on` / `off`)** + +- 在 `local-postgres-client-core.ts` 内新增模块级缓存(挂 `globalThis`,与 `jyotishaLocalDataPools` 同法,避免模块重复求值时分裂): + - 列类型:键 `连接串 | 角色(null→"owner") | 表名` → `Map`; + - 主键列:键 `连接串 | 表名`; + - 函数签名:键 `连接串 | 角色 | 函数名 | 排序后的参数名`(`functionMetadata` 的结果依赖传入的参数名集合,必须进键)。 +- 失效: + 1. TTL **300 秒**(迁移在旧容器仍运行时执行,再由部署重启换新进程;TTL 兜住「迁移后、重启前」的窗口)。 + 2. 业务 SQL 抛出 SQLSTATE `42703`(undefined_column)、`42P01`(undefined_table)、`42883`(undefined_function)、`42804`(datatype_mismatch)时,清掉**该连接串**的全部元数据缓存;本次照旧返回错误(**不自动重试**——事务已中止,重试会改变调用方看到的语义),下一次调用重新查。 + 3. `closeLocalPostgresDataPool(url)` 清该 URL;`closeLocalPostgresDataPools()` 全清;导出 `clearLocalPostgresMetadataCacheForTests()`。 + 4. 不缓存空结果与「function not found」。 +- 元数据查询仍在**同一事务、同一角色**下执行(缓存未命中时),保证与现状看到的列集合一致。 +- 开关 `off` 时代码路径与基线逐条一致(每次现查)。 + +**T2b 合并会话设置往返(开关 `JYOTISHA_PG_MERGED_SESSION_SETUP`,`on` / `off`)** + +- `inBusinessTransaction` 把 `set local role ` 与两条 `set_config` 合成一条参数化语句: + `select set_config('role', $1, true), set_config('request.jwt.claim.sub', $2, true), set_config('request.jwt.claim.email', $3, true)`;`role === null` 时省掉第一项。 + `set_config('role', x, true)` 与 `SET LOCAL ROLE x` 等价(PostgREST 用同一做法),角色仍只能是构造函数白名单里的 `authenticated` / `service_role`。 +- `begin`、`commit` 保持独立语句;**不得**用拼字符串的多语句简单查询把 `begin` 也合进去。 +- 结果:select / insert / update / delete / rpc 往返 6 → 4(缓存命中时);未传 `onConflict` 的 upsert 7 → 4。 + +**T2c 评估:只读、无身份时省事务——不做** + +- `set local` / `set_config(..., true)` 只在事务块内生效;不开事务就只能用会话级 `set role`,归还连接前必须再 `reset role`,往返数不降(begin/commit 换成 set/reset),且一旦 reset 失败,连接会带着 `service_role` 回到池子给下一个请求——违反红线 2。无身份的 `authenticated` 读同理。结论:不做,写进 PROGRESS。 + +**T2 验收** + +1. 新单测(不需 Docker)`frontend/tests/local-postgres-roundtrips.test.ts`:用假 `Pool`/`PoolClient` 记录每条 `client.query` 的 SQL 文本,断言: + - 两开关 `off`:select 序列 = `begin, set local role, set_config, information_schema, 业务, commit`(6)——锁住基线; + - 两开关 `on`、同一表第二次调用:`begin, set_config(含 role), 业务, commit`(4);rpc 同理 4; + - 角色不同(`authenticated` vs `service_role`)不共享列缓存;参数名集合不同不共享函数缓存; + - 业务 SQL 抛 `42703` 后下一次调用重新查 `information_schema`;TTL 过期(注入时钟)后重新查;`closeLocalPostgresDataPool(url)` 后重新查; + - 空列结果与 function-not-found 不入缓存。 +2. **`npm run test:db`(Grok 必跑,以 Grok 为准)**:两开关 `on` 跑一遍、`off` 跑一遍。失败清单与 `tools/agent-loop/windows-known-failures.txt` **按测试名逐条比对,多出一条即未通过**;Docker 未开则回报「未跑」,不得写通过。另加一条真库测试(放进现有 `database-local-business.test.ts` 或新建 `database-local-postgres-session-setup.test.ts`):开关 `on` 时,在事务内 `select current_user, current_setting('request.jwt.claim.sub', true)` 分别等于角色与身份;**同一连接归还后再取出**,`current_user` 回到连接用户、claim 为空——证明没有泄漏到池子。 +3. 现有 `local-postgres-not` / `or` / `order` / `query-value` / `pool-close` 单测全绿、无断言改动。 + +### T3 · 三张内存表加上限 / 过期(BUG-1328) + +- `delivery-turn-guard.ts`:`markDeliveryTurn` 写入前清掉 `now - at > WINDOW_MS` 的条目(所有读取方本就把它们视为不存在,行为逐条等价)。为防极端情况,再加硬上限 10,000 条(超出删最旧插入)。 +- `block-scan-answer.ts::rescoreAttempts`:值改为写入时间戳;写入时清掉超过 **6 小时**的条目,并设硬上限 5,000 条(超出删最旧插入)。语义变化仅限:同一 `(case, 题, 证据指纹)` 在 6 小时后或被挤出后可能再尝试重算一次——这比现状「进程重启后会再算」更收敛,写进 PROGRESS。 +- `request-rate-limit.ts::consumeRequestRateLimit`:模块内记录本进程见过的最大 `windowMs`;每 1,000 次调用(或 `store.size > 5,000` 时)扫一遍 store,删除「最新时间戳 ≤ now − 最大窗口」的 key(这些 key 无论属于哪个桶都已出窗,删除不改变任何判定);`recent` 为空时不写回空数组。`RequestRateLimitStore` 类型与导出不变(测试会传自定义 store)。 +- 验收(扩展 `frontend/tests/request-rate-limit.test.ts`,新建 `frontend/tests/delivery-turn-guard-expiry.test.ts`、`frontend/tests/rescore-attempts-bound.test.ts` 或并入现有对应测试): + - 窗口内判定与基线逐条相同(复用现有用例,不改断言); + - 注入 `now` 推进超过窗口后触发清理,`store.size` 回落;不同窗口的桶混用时,1 小时窗口的 `synastry` key 在 61 秒后**不**被删; + - `delivered` 超过 60 s 的条目在下一次 `markDeliveryTurn` 后不在表里;`alreadyDelivered` / `deliveryCarrierNarration` 在 60 s 内结果不变; + - `rescoreAttempts` 写入 5,001 条后 size ≤ 5,000;同一 key 6 小时内第二次调用仍不重算(沿用 `rectification-stale-compare-fix-20260907.test.ts` 等现有用例,全绿)。 +- 现有 `rectification-delivery-ui-simplify-20260908.test.ts`、`rectification-answer-choice.test.ts`、`rectification-post-adopt-card-20261001.test.ts`、`ephemeris-route.test.ts`、`subject-route-birth.test.ts` 全绿、无断言改动。 + +### T4 · A/B 验证(T2 必做;T1 以 2.1 的数字复测代替) + +- 脚本 `frontend/scripts/bench-local-pg-roundtrips.ts`(需 Docker 测试库,复用 `test:db` 的 Postgres fixture 建库与种子,**只用虚构用户**)。固定查询序列,模拟一次首页启动 + 一轮咨询落库: + 1. `authenticated` + 虚构身份:`profiles` 按 id 读 1 行、`chart_profiles` 列表、`chat_sessions` 列表(limit 40, order updated_at)、`chat_sessions` update 一行、一次带 `onConflict` 的 upsert、一次不带 `onConflict` 的 upsert; + 2. `service_role`:一次常用 rpc(从现有 `database-*.test.ts` 已覆盖的 rpc 里挑一个只读或可回滚的)、一次 select。 + - 每次调用包一层计数 `client.query` 的代理,记**往返次数**与**端到端耗时**;整串跑 50 遍为一组。 +- A = 两开关 `off`;B = 两开关 `on`;另跑 B1 = 只开 T2a、B2 = 只开 T2b 以便归因。每组重复 **3 次取中位数**,先各热身 1 遍丢弃。 +- 度量:每串往返总数、每串耗时中位数与 p95、单调用平均耗时;Postgres 侧 `pg_stat_statements`(若 fixture 已启用)或 `pg_stat_database.xact_commit` 差值作旁证。 +- 正确性:A 与 B 每一步返回的 `{ data, error, count }` 做 JSON 序列化后**逐字节一致**(同一进程内先 A 后 B,库在两组之间按 fixture 重置);写进脚本输出。 +- 判定门槛:往返数 B ≤ A 的 70%,**且**耗时中位数降 ≥ 15%,**且**逐字节一致,**且** test:db 两种开关下失败清单都只含已知 13 条 → 两开关默认 `on`;任何一项不满足 → 对应开关默认 `off`,在 PROGRESS 写原因。 +- 记录写 `docs/testing/node-runtime-resources-ab-20261013.md`:环境(Windows + Docker Desktop 版本、Node 版本)、三组原始数字、中位数表、差分结果、默认值结论。 +- Claude 验收:读 A/B 记录、复跑不需 Docker 的往返计数单测;真库数字以 Grok 为准(本机无 Docker,记环境缺口)。 + +### T5 · 记录 + +- `docs/BUG_HISTORY.md` 新增 BUG-1326、1327、1328(1327 状态按 A/B 结论写;部署后的内存曲线未观测前 1326 写 `resolved(代码+回归测试),生产内存曲线待部署后观察`)。 +- `docs/tasks/PROGRESS-node-runtime-resources-20261013.md`:含 T2c 评估、T3 语义变化说明、基线跑新测试的失败输出、A/B 结论。 +- `deploy/README.md` 或 `.env` 示例里登记两个开关(只写名字与默认值,不写任何密钥);若 compose 文件需要透传环境变量,改 `deploy/docker-compose.server.yml` 前先在回报里说明(不改 workflow)。 +- `CHANGELOG.md`:一句「服务器长期运行不再逐日涨内存;数据库访问少走几趟」。Skill 版本不 bump。`frontend/DESIGN.md` 不改。 + +## 7. 让步顺序 + +T1(P0,必须交)→ T3 → T2a → T2b → T4。时间不够时:先砍 T2b(保留开关骨架与单测也可以不交),再砍 T2a;T2 一旦交付就必须带 T4 的 A/B 与 test:db 比对,否则开关默认 `off`。T1 不得因任何原因延后。 + +## 8. 验收命令分工 + +| 谁 | 命令 | +| --- | --- | +| Grok 自跑 | `./node_modules/.bin/tsc --noEmit`;`npm run lint`(0 error);`npx tsx --test` 跑本单新增 / 改到的测试单文件(`personal-report-worker-sleep-listener`、`personal-report-worker`、`local-postgres-roundtrips`、`local-postgres-not/or/order/query-value/pool-close`、`request-rate-limit`、`delivery-turn-guard-expiry`、`rescore-attempts-bound`、`rectification-delivery-ui-simplify-20260908`、`rectification-stale-compare-fix-20260907`、`rectification-answer-choice`、`ephemeris-route`、`subject-route-birth`),看 fail 与 cancelled 都为 0;**`npm run test:db` 两种开关各一次,按 `tools/agent-loop/windows-known-failures.txt` 逐名比对**;T4 A/B 脚本。前端全量 `npm test`、Python 快速门、`next build` 不跑 | +| Claude 验收 | 上述单文件全部复验 + 前端全量 `npm test` 与基线逐名比对 + `next build`(`/` 仍 Static,首屏 gzip ±2%,本单不应有前端包变化)+ 泄漏脚本复测;`test:db` 本机无 Docker,记环境缺口,以 Grok 结果为准 | + +## 9. 开工前置命令 + +```bash +git fetch origin --prune +git worktree add -b codex/node-runtime-resources-20261013 .worktrees/node-runtime-resources-20261013 origin/staging +cd .worktrees/node-runtime-resources-20261013 +git log -1 --format=%h # 应为 77057a819 或其后代 +grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -3 # 核对最大号;1326~1328 若被占用,顺延并在 PROGRESS 写明 +grep -rn 'addEventListener("abort"' frontend/src | grep -v test # 应与 2.1 表一致(18 处) +grep -rn "set local role\|information_schema\|resetDeliveryTurnGuardForTests\|resetStaleMinuteRescoreAttemptsForTests\|RequestRateLimitStore" frontend/tests +cat tools/agent-loop/windows-known-failures.txt +``` + +## 10. 交付物清单 + +- 代码:`personal-report-worker-core.ts`、`birth-time-guided-polling.ts`、`db/local-postgres-client-core.ts`、`delivery-turn-guard.ts`、`block-scan-answer.ts`、`request-rate-limit.ts` +- 测试:§6 列出的新增 / 扩展测试 +- 脚本:`frontend/scripts/bench-local-pg-roundtrips.ts` +- 文档:`docs/tasks/PROGRESS-node-runtime-resources-20261013.md`、`docs/testing/node-runtime-resources-ab-20261013.md`、`docs/BUG_HISTORY.md`(BUG-1326~1328)、`CHANGELOG.md`;`frontend/DESIGN.md` 不改 + +## 11. 本单不做 + +- Better Auth `cookieCache`(登录行为改动,需产品拍板)。 +- 只读无身份调用省事务(T2c 已论证不安全)。 +- 把 `begin` 拼进多语句简单查询(需要手工转义字面量,收益一次往返,不值得冒注入风险)。 +- `createServerSupabaseClient` 每次调用都读一次身份会话(`readIdentitySession`)的往返——与 cookieCache 同属登录链,一起拍板。 + +## 附录 · 2.1 复现脚本(`leak-fixed.mjs`,与源码同形,可放任意临时目录) + +```js +import { getEventListeners } from "node:events"; +const keep = setInterval(() => {}, 100000); +const FIXED = process.argv[2] === "fixed"; +const N = Number(process.argv[3] ?? 43200); +async function sleepOld(ms, signal) { if (signal?.aborted) return; await new Promise((resolve) => { const t = setTimeout(resolve, ms); t.unref?.(); signal?.addEventListener("abort", () => { clearTimeout(t); resolve(); }, { once: true }); }); } +async function sleepNew(ms, signal) { if (signal?.aborted) return; await new Promise((resolve) => { const onAbort = () => { clearTimeout(t); resolve(); }; const t = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms); t.unref?.(); signal?.addEventListener("abort", onAbort, { once: true }); }); } +const sleep = FIXED ? sleepNew : sleepOld; +const c = new AbortController(); +global.gc(); const b = process.memoryUsage().heapUsed; +for (let i = 0; i < N; i++) await sleep(0, c.signal); +global.gc(); const a = process.memoryUsage().heapUsed; +console.log(FIXED ? "fixed" : "baseline", process.version, "sleeps", N, "listeners", getEventListeners(c.signal, "abort").length, "heapMB", ((a - b) / 1048576).toFixed(2)); +clearInterval(keep); +``` + +运行:`node --expose-gc leak-fixed.mjs [fixed] [次数]`(43,200 次约 45 秒,`setTimeout(0)` 每次约 1 ms)。 diff --git a/docs/tasks/TASK-pl9-render-split-20261013.md b/docs/tasks/TASK-pl9-render-split-20261013.md new file mode 100644 index 00000000..ed052019 --- /dev/null +++ b/docs/tasks/TASK-pl9-render-split-20261013.md @@ -0,0 +1,187 @@ +# TASK · 拆分 6,917 行的报告渲染函数(第一阶段:搬出无依赖的内部函数)(issue #待定) + +- 日期:2026-10-13 +- 来源:「2026-10-13 全仓屎山与性能排查」重构条目「PL9 渲染器拆分」 +- 执行方:**Grok(Windows + Docker)**,分支 `codex/pl9-render-split-20261013`,只推这个分支 +- 验收:Claude(Linux) +- BUG 编号:**无 BUG 编号**(纯重构,无缺陷),记录写 `docs/tasks/PROGRESS-pl9-render-split-20261013.md`(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起) +- 开工前置:**`TASK-report-reference-perf-20261013` 已合入 `origin/staging`**(两单都改 `scripts/jyotish_engine.py`,必须串行) +- **主会话补充的串行链(2026-10-14)**:引擎/API 文件链按此顺序逐单合入后开工(`upstream-sync7` 也改 `jyotish_engine.py` / `jyotish_api_server.py`,若在途,后合一方 rebase):`api-disk-cache-retention` + `report-reference-perf` → `api-chart-subprocess-gate` → `pl9-render-split` → `python-hygiene` → `dasha-unify` → `api-server-service-extract`。本单的前置以这条链为准(与上一行不一致时以本行为准)。 + +## 1. 基线 + +- 写作基线:`origin/staging` `77057a819`;开工基线:前置单合入后的 `origin/staging` head(行号会漂移,**一律按符号定位**)。 +- 对象:`scripts/jyotish_engine.py::render_pl9_markdown`(写作时第 3475–10391 行,单参数 `packet`)。 + +## 2. 事故实证(`77057a819`,AST / symtable 统计,脚本见 §9) + +### 2.1 规模 + +| 函数 | 行数 | 嵌套具名函数 | 函数内 import 语句 | +| --- | --- | --- | --- | +| `jyotish_engine.py::render_pl9_markdown` | 6,917 | **148**(直接 18 个,其余在 `if producer:` 分支里) | **7** | +| `jyotish_engine.py::cmd_full_reading` | 1,554 | 4 | 48 | +| `jyotish_engine.py::build_pl9_style_export_packet` | 934 | 6 | 5 | +| `pl9_reader_export.py::render_pl9_parity_markdown` | 2,549 | 23 | 7 | +| `pl9_full_data_export.py::_render_pl9_ai_density_raw_data_markdown_en` / `_zh` | 685 / 84 | 5 / 3 | 4 / 0 | +| `pl9_reader_english.py::render_pl9_user_markdown_en` | 58 | 0 | 2 | +| 擦洗:`_sanitize_pl9_ai_density_markdown` / `_en` / `pl9_reader_export.py::_strip_pl9_user_engineering_markers` | 451 / 389 / 258 | — | 4 / 2 / 6 | + +对排查报告的修正: + +- 「函数内 import 165 处」不成立:`render_pl9_markdown` 内只有 7 条;**整个 `jyotish_engine.py` 所有顶层函数内**合计 156 条。 +- `_auxiliary_dasha_section`(761 行)**不是**同文件的独立函数,而是 `render_pl9_markdown` 内部的嵌套函数(写作时第 7829 行起)。 + +### 2.2 结构 + +- 函数体只有 26 条顶层语句;其中 `if isinstance(producer, dict) and producer:` 一条占 6,582 行、含 130 个嵌套函数,**无 else 分支**。 +- 最外层嵌套函数 119 个,共 5,012 行;其余约 1,900 行是内联拼接语句。 +- 最大的几个嵌套函数:`_auxiliary_dasha_section` 761、`_strength_ashtakavarga_section` 326、`_kp_lord_sub_section` 278、`_chart_atlas_section` 187、`_kp_three_year_monthly_section` 185、`_jaimini_special_section` 161、`_future_annual_focus_sections` 160、`_guided_topic_value_sentence` 151、`_profile_aware_benchmark_appendix` 142。 + +### 2.3 闭包依赖(symtable,148 个嵌套具名函数) + +| 类别 | 个数 | +| --- | --- | +| 无任何自由变量 | 28 | +| 自由变量只有别的嵌套辅助函数 | 43 | +| 依赖外层数据变量 | 77(共 48 个不同变量,最多的是 `worksheets` 17、`advanced_sheet` 13、`packet` 9、`timing_sheet` 9) | +| 对外层变量赋值(`nonlocal`) | 0 | + +- 按「辅助函数也能一起搬」做传递闭包,**最外层可直接搬的有 33 个、共 443 行**(例如 `_unwrap`、`_envelope_list`、`_envelope_dict`、`_bool_text`、`_md_cell`、`_render_compact_payload`、`_workbook_readable_*`、`_section_evidence_boundary`、`_profile_aware_benchmark_appendix`、`_chart_planets_from_positions`、`_kp_house_focus_text`、`_compose_narrative_paragraph`、`_has_executed_payload`、`_append_unique_text` 等)。 +- 被引用最多的辅助函数 `_humanize_reader_token`(72 处)只依赖 `planet_label_cn`(函数体开头的**字面量常量字典**,从不修改)和模块级 `SIGNS_CN`。把 `planet_label_cn` 升成模块常量后,它和依赖它的 `_humanize_reader_group`、`_display_rashi_from_value`、`_humanize_reader_summary_line`、`_sign_text`、`_planet_list_text` 等也变成可搬。 +- **陷阱:`_degree_text` 定义了两次**(写作时第 3672 行与第 6377 行,后者在 `if producer:` 分支内,同属 `render_pl9_markdown` 作用域)。两版行为不同(空值返回 `'-'` vs `_md_cell(value)`)。闭包按**调用时**查名字,所以分支执行到第二个定义之后,所有引用 `_degree_text` 的闭包——包括定义在前面的——都会调用第二版。随手把它们提成两个模块函数,会悄悄改变输出。 + +### 2.4 谁在调用(实证判断:不是死路径,所以第一阶段做搬移,不做删除) + +| 调用方 | 生产是否触达 | 说明 | +| --- | --- | --- | +| `build_professional_report_reference_packet` → `evaluate_full_report(delivered, render_pl9_markdown(delivered))` | **是,每份个人长报告都跑** | 虚构盘实测 0.05 s,`if producer:` 分支执行了 3,692 行;生成的质量门结果不进 `full_data` 响应(见 `TASK-report-reference-perf-20261013` §4 观察项) | +| `professional_report_reference.py` 的 `edition=reference` + `markdown` | 否(前端只发 `full_data`) | 接口仍对外 | +| `pl9_reader_export.py::_reference_markdown` | 非 `full_data` edition 时 | | +| CLI `pl9-export --format markdown` / `--archive-dir`、PDF 导出(`jyotish_engine.py` 中 `render_pl9_markdown(packet)` 后接 reportlab 那个函数)、`mcp_server.py` 的 `pl9-export` | 开发 / 研究工具 | | +| 测试 | — | 8 个测试文件直接调用:`test_report_reader_main`、`test_report_longform_parity`、`test_full_report_quality_gate`、`test_report_chart_block`、`test_report_longform_gaps2`、`test_full_reading_conditional_dashas`、`test_professional_report_reference_api`,另有若干文件把它当桩 | + +结论:删除需要先由产品决定「`full_data` 路径不再跑质量门」并下线 `reference` edition,本单不做(列入后续)。 + +## 3. 根因 + +`render_pl9_markdown` 从上游 PL9 对标报告逐页追加而来,每补一页就在同一个函数里再加一个闭包和一段 `lines.append`,从未拆分。闭包共享约 48 个外层局部变量,导致无法单测单章、无法复用,也让同名重定义(`_degree_text`)这类隐性行为难以察觉。**不是回归,是从未设计。** `docs/BUG_HISTORY.md` 相关:BUG-610(围栏构造失败只丢该张围栏、不得打断整份报告——搬移时这段 `try` 结构必须原样保留)、BUG-693 / 694(质量门依赖这份 markdown 做 `reader_section:*` 检查)。 + +## 4. 决策记录 + +- 2026-10-13 产品负责人同意按排查结论全部立单交 Grok 执行。 +- 本单是**大型重构的第一阶段**:只把**不依赖外层数据**的嵌套函数原样搬到新包 `scripts/report_render/`,不改逻辑、不改输出、不删任何调用方。 +- 不推翻任何既有红线。删除死路径(`reference` edition、`full_data` 路径的质量门)**本单不做,需拍板**。 + +## 5. 硬红线 + +1. **输出逐字节一致**:对 §6 T1 的语料,搬移前后 `render_pl9_markdown` 的输出 sha256 全部相同;`render_full_data_markdown` 中英两版与 `/api/professional_report_reference` 生产请求体的整份响应也必须相同。差一个字节即未通过。 +2. **纯搬移**:函数体逐字复制(只允许把闭包里引用的辅助函数名改为从新模块导入、把 `planet_label_cn` 改为引用模块常量),不改名(保持下划线私有名)、不改参数、不改默认值、不「顺手」修任何逻辑、文案、空白或类型注解。 +3. **`_degree_text` 及其全部调用者本阶段不搬**;同理,任何在 `render_pl9_markdown` 作用域内被定义两次以上的名字都不搬(开工先用 §9 脚本列出所有重名)。 +4. 被搬走的函数如果读模块级全局(如 `SIGNS_CN`、`SIGNS`),从定义它们的叶子模块导入,或改成显式参数;**`scripts/report_render/` 不得在模块顶层 import `jyotish_engine`**(会成环)。不得引入 `jhora` / `mcp_server` 导入(`tests/test_runtime_jhora_import_scan.py`、`tests/test_runtime_import_boundaries.py`)。 +5. 导入写法沿用仓库惯例的双路径(`try: from scripts.report_render.x import …` / `except ModuleNotFoundError: from report_render.x import …`),保证 `scripts/` 直跑与包导入都可用;新包要有 `__init__.py`(`pyproject.toml` 已 `include = ["scripts*"]`,镜像 `COPY scripts` 整目录)。 +6. 改动规模:diff(`git diff --stat` 增删合计)**< 1,500 行**;超出就按让步顺序少搬。 +7. `scripts/jyotish_api_server.py` 不改;不改 `.gitea/workflows/**`;不动表结构与迁移;不升级依赖;不顺手修 warning;不碰前端。 +8. 改既有断言按「原值 / 新值 / 原因」三栏写进 PROGRESS;测试总数不得低于开工时。开工先 grep 按源码文本断言 `jyotish_engine.py` 的测试(已知:`tests/test_calculation_profile_contract.py`、`tests/test_full_reading_saham_contract.py`、`tests/test_full_reading_conditional_dashas.py`、`tests/test_runtime_import_boundaries.py` 读源文件文本),确认被搬走的字符串没有被它们断言。 +9. 差分输入必须是真实引擎现算的虚构盘包,不得手造包形状。 + +## 6. 任务分解 + +### T1 · 差分脚手架(先做) + +- 新增 `scripts/research/report_render_split_diff.py`: + 1. 在当前工作树用真实引擎为语料建包,并 **pickle** 到仓库外临时目录(必须用 pickle 不用 JSON:包里有元组和非字符串键;而且 `shared_full_report_authority` 带墙钟 `generated_at`,会写进 markdown 的「生成时间」行,所以新旧两边必须渲染**同一份**包)。 + 2. 子进程在**基线工作树**(`git worktree add` 一个只读的 `origin/staging` 检出,开工前置命令里建)里加载 pickle,跑基线的 `render_pl9_markdown`,输出每份 sha256;当前进程跑新代码,同样输出;逐项对比,不同即退出码 1,并打印第一处差异附近 5 行。 + 3. 两边都设 `PYTHONHASHSEED=0`;先做一次「基线对基线」对照,两次必须相同,否则脚本报错退出(说明有非确定性来源,必须先查清)。 +- 语料:`tests/test_report_english_edition.py::CASES` 的 day / night / south 三张盘,再加 1990-01-01 12:00 新德里 28.6139/77.2090 +5.5 虚构盘;每张盘渲染三种包:①`build_professional_report_reference_packet` 交付包(`packs=[]`),②同包去掉 `personal_report_producer`(走非 producer 路径),③`_apply_pl9_pack_selection` 选非 full 的一个 pack(若有)。另附 `render_full_data_markdown` 中英两版的 sha(防止共享辅助函数的改动波及)。 +- 屏蔽 PyJHora 跑一轮(生产镜像依赖),不屏蔽跑一轮。 +- 验收:脚本在未搬移的代码上退出码 0;Grok 自跑。 + +### T2 · 建包与常量 + +- 新建 `scripts/report_render/__init__.py`(空或只放说明)与以下模块(按「章节」分,第一阶段只放通用辅助): + - `scripts/report_render/envelope.py`:`_unwrap`、`_envelope_list`、`_envelope_dict`、`_bool_text`、`_source_dict`、`_has_executed_payload` + - `scripts/report_render/text.py`:`PLANET_LABEL_CN`(由 `planet_label_cn` 原字面量升成)、`_md_cell`、`_render_compact_payload`、`_humanize_reader_token` 及只依赖它的 humanize 族、`_workbook_readable_*`、`_compose_narrative_paragraph`、`_append_unique_text`、`_value_text` + - `scripts/report_render/sections_misc.py`:`_status_from_report_pack_section`、`_section_evidence_boundary`、`_profile_aware_benchmark_appendix`、`_domi_pl9_current_gap_appendix`、`_chart_planets_from_positions`、`_kp_house_focus_text`、`_high_value_theme_authority_sentence`、`_kp_conflict_adjudication_sentence`、`_prashna_exact_cusp_boundary_sentence`、`_render_boundary_packet_summary_line` + - 实际清单以 §9 脚本在开工基线上的输出为准(上面是写作时的 33 个可搬函数 + `planet_label_cn` 解锁的那批);`_degree_text`、`_sign_degree_text`、`_longitude_text` 及所有重名或调用重名的函数排除。 +- `render_pl9_markdown` 里删掉被搬走的 `def`,在函数开头(或模块顶部)按红线 5 的双路径导入同名函数;`planet_label_cn` 局部变量改为 `planet_label_cn = PLANET_LABEL_CN`(保留局部名,避免改动其余引用)。 +- 不改:任何依赖外层数据变量的嵌套函数(77 个,后续阶段改显式参数)、内联拼接语句、`if producer:` 分支结构、BUG-610 的 `try` 包裹。 +- 验收: + - T1 脚本全部语料新旧 sha 相同、退出码 0(屏蔽与不屏蔽 PyJHora 各一轮)。 + - `python -m pytest tests/test_report_reader_main.py tests/test_report_longform_parity.py tests/test_full_report_quality_gate.py tests/test_report_chart_block.py tests/test_report_longform_gaps2.py tests/test_full_reading_conditional_dashas.py tests/test_professional_report_reference_api.py tests/test_report_english_edition.py tests/test_report_full_data_fix.py tests/test_reader_appendix_language.py tests/test_runtime_import_boundaries.py tests/test_runtime_jhora_import_scan.py tests/test_calculation_profile_contract.py tests/test_full_reading_saham_contract.py -q` 全绿。 + - 新测试 `tests/test_report_render_package.py`:①`scripts.report_render` 各模块可以在**未导入** `jyotish_engine` 的干净进程里导入(子进程检查 `sys.modules`);②`PLANET_LABEL_CN` 与原字面量逐项相等;③对真实引擎渲染结果里出现过的输入,抽 5 个搬出的函数做「新模块函数 == 引擎内仍可访问的同名引用」等值断言。 + - `render_pl9_markdown` 行数下降(预期约 600~900 行),在 PROGRESS 记下前后行数与嵌套函数数。 + +### T3 · 记录 + +- `docs/tasks/PROGRESS-pl9-render-split-20261013.md`:搬移清单(函数名 → 新模块)、排除清单与原因(尤其重名)、T1 sha 对照表、前后行数、diff 规模。 +- 不写 BUG_HISTORY(无缺陷);`CHANGELOG.md` 不写(用户不可感知);`frontend/DESIGN.md` 不改。 + +## 7. 让步顺序 + +1. 必保:T1 差分脚手架 + `envelope.py`(最小、最稳)。 +2. 再做:`text.py`(含 `PLANET_LABEL_CN` 与 humanize 族,收益最大:`_humanize_reader_token` 被 72 处引用)。 +3. 最先砍:`sections_misc.py`。 +4. diff 将超 1,500 行时停止继续搬,剩余写进后续阶段清单。 +5. 任何一个函数搬移后 sha 不同、一轮内查不清:撤回该函数,写进 PROGRESS 的排除清单,不得改测试或改输出来凑一致。 + +## 8. 验收命令分工 + +| 谁 | 命令 | +| --- | --- | +| Grok 自跑(工作树 `.venv`) | §6 T2 的 pytest 列表 + `tests/test_report_render_package.py`;`python scripts/research/report_render_split_diff.py --baseline <基线工作树> --mask-pyjhora` 与不屏蔽各一次(退出码 0)。Python 快速门、前端全量**不跑** | +| Claude 验收 | 上述全部 + `--profile quick` 快速门 + 全量 `pytest` 与开工基线逐名比对 + Linux 下复跑差分脚本(屏蔽 PyJHora)+ 核对 `git diff --stat` < 1,500 行与搬移逐字(`git diff --color-moved=zebra` 抽查) | +| 不需要 | 前端 `tsc` / `lint` / `npm test`(无前端改动);`test:db` | + +## 9. 开工前置命令 + +```bash +git fetch origin --prune +# 前置单必须已合入(文件存在即视为合入;同时看提交日志) +git cat-file -e origin/staging:docs/tasks/PROGRESS-report-reference-perf-20261013.md && echo "前置已合入" || echo "前置未合入:停止,回报 Claude" +git log origin/staging --oneline | grep -i "report-reference-perf\|BUG-1335" | head -3 +git worktree add -b codex/pl9-render-split-20261013 .worktrees/pl9-render-split-20261013 origin/staging +git worktree add --detach .worktrees/pl9-render-split-baseline origin/staging # 差分用的只读基线,交付后删除 +cd .worktrees/pl9-render-split-20261013 +git log -1 --format=%h +grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -1 # 仅核对,本单不占 BUG 号 +grep -rn "jyotish_engine.py\").read_text\|jyotish_engine.py').read_text\|getsource" tests | head -20 +``` + +开工统计(重新数一遍,写进 PROGRESS;数字与 §2 出入较大时先回报再动手): + +```python +# python - < this (在工作树根目录跑) +import ast, symtable, collections +src = open("scripts/jyotish_engine.py", encoding="utf-8").read() +tree = ast.parse(src) +fn = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == "render_pl9_markdown") +defs = [d for d in ast.walk(fn) if isinstance(d, ast.FunctionDef) and d is not fn] +print("lines", fn.end_lineno - fn.lineno + 1, "nested defs", len(defs), + "imports", sum(isinstance(d, (ast.Import, ast.ImportFrom)) for d in ast.walk(fn))) +print("duplicate names", [k for k, v in collections.Counter(d.name for d in defs).items() if v > 1]) +top = symtable.symtable(src, "jyotish_engine.py", "exec") +render = next(c for c in top.get_children() if c.get_name() == "render_pl9_markdown") +def walk(t): + for c in t.get_children(): + yield c; yield from walk(c) +real = {(d.name, d.lineno) for d in defs} +for c in walk(render): + if (c.get_name(), c.get_lineno()) in real: + print(c.get_name(), c.get_lineno(), sorted(c.get_frees())) +``` + +## 10. 交付物清单 + +- 代码:`scripts/report_render/__init__.py`、`envelope.py`、`text.py`、(可选)`sections_misc.py`;`scripts/jyotish_engine.py`(只删被搬函数并加导入);`scripts/research/report_render_split_diff.py`。 +- 测试:`tests/test_report_render_package.py`。 +- 记录:`docs/tasks/PROGRESS-pl9-render-split-20261013.md`。 +- 回报:分支 head SHA、搬移 / 排除清单、差分 sha 表(屏蔽与不屏蔽 PyJHora)、前后行数、diff 规模。 + +## 11. 后续阶段(本单不做) + +1. **第二阶段**:77 个依赖外层数据的嵌套函数改显式参数(建议引入一个只读上下文对象承载 `packet`、`worksheets`、`advanced_sheet`、`timing_sheet` 等 48 个变量),按章节搬到 `scripts/report_render/sections_*.py`;先处理 `_degree_text` 重名(明确每个调用点在运行时实际调用的版本,拆成两个不同名函数);`_auxiliary_dasha_section`(761 行)单独一轮。 +2. **第三阶段**:渲染器按受众参数统一——`render_pl9_markdown`(参考版)、`pl9_reader_export.py::render_pl9_parity_markdown`、`pl9_full_data_export.py::_render_pl9_ai_density_raw_data_markdown_zh/_en`、`pl9_reader_english.py::render_pl9_user_markdown_en` 同一份数据四套渲染,合并为「一个渲染器 + 受众 / 语言参数」。 +3. **第四阶段**:擦洗函数退役——`_sanitize_pl9_ai_density_markdown`(451 行)、`_sanitize_pl9_ai_density_markdown_en`(389 行)、`_strip_pl9_user_engineering_markers`(258 行)把「先写工程标签再用正则擦」改为渲染时直接写面向读者的文字;逐字节 golden 守门,有意的文案变化单独立单。 +4. **第五阶段**:`cmd_full_reading`(1,554 行、函数内 import 48 条)按计算阶段拆分;`build_pl9_style_export_packet`(934 行)同法。 +5. **需产品拍板后另开单**:`full_data` 路径是否继续跑结果无人读取的质量门(每份报告调用一次 `render_pl9_markdown`);`reference` edition 与 PDF 导出是否下线。两者都定下来后,`render_pl9_markdown` 可能整体降级为开发工具甚至删除。 diff --git a/docs/tasks/TASK-python-hygiene-20261013.md b/docs/tasks/TASK-python-hygiene-20261013.md new file mode 100644 index 00000000..d18f977d --- /dev/null +++ b/docs/tasks/TASK-python-hygiene-20261013.md @@ -0,0 +1,274 @@ +# TASK · Python 卫生:免费层排队开关报反、静默吞异常、功能吉凶兜底三份合一、测试专用脚本归位(issue #待定) + +- 日期:2026-10-13 +- 来源:2026-10-13 全仓屎山与性能排查 ·「Python 卫生」条目(开关重名、吞异常、功能吉凶兜底重复、`scripts/` 根目录 443 个文件) +- 执行方:**Grok(Windows + Docker)**,分支 `codex/python-hygiene-20261013`,只推这个分支 +- 验收:Claude(Linux) +- BUG 编号:**BUG-1340**(开工时核对 `docs/BUG_HISTORY.md` 最大号;写作时最大号 BUG-1317;号段由主会话统一分配)(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起) +- **开工前置**:`api-disk-cache-retention`、`api-chart-subprocess-gate` 两单已合入 `origin/staging`(两单都改 `scripts/jyotish_api_server.py`;本单 T2 也改它,必须串行) +- **主会话补充的串行链(2026-10-14)**:引擎/API 文件链按此顺序逐单合入后开工(`upstream-sync7` 也改 `jyotish_engine.py` / `jyotish_api_server.py`,若在途,后合一方 rebase):`api-disk-cache-retention` + `report-reference-perf` → `api-chart-subprocess-gate` → `pl9-render-split` → `python-hygiene` → `dasha-unify` → `api-server-service-extract`。本单的前置以这条链为准(与上一行不一致时以本行为准)。 +- **后继**:`dasha-unify`(BUG-1341)、`api-server-service-extract` 依赖本单合入 + +## 1. 基线 + +- 基线 commit:`origin/staging` `77057a819`(写作时);开工以前置两单合入后的 `origin/staging` 为准,第 9 节命令会核对。 +- 所有行号以写作基线为准,执行时按符号定位。 + +## 2. 事故实证(`77057a819`,全部可复现) + +### 2.1 BUG-1340:免费层排队「开关」报的状态与实际行为相反 + +排查报告原说法是「`offline_compute_mixins.py:86` 只认三个变量名中的一个,所以配了可能不生效」。**核实后这一说法不成立**:`scripts/offline_compute_mixins.py::_free_tier_queue_enabled_env` 三个名字都读(`VEDASTRO_FREE_TIER_QUEUE` / `_ENABLED` / `VEDASTRO_ENABLE_FREE_TIER_QUEUE`)。真正的问题更糟: + +- 这三个变量**不控制任何行为**。真正的限流在 `scripts/vedastro_service_adapter.py::_acquire_free_tier_slot`,它只看三件事:请求地址是不是官方公共端点、有没有 `VEDASTRO_API_KEY`、`VEDASTRO_FREE_TIER_MAX_REQUESTS` / `VEDASTRO_FREE_TIER_WINDOW_SECONDS`(默认 5 次 / 60 秒)是否大于 0。 +- 三个变量只被三处**报告字段**读取,每处各写一遍解析:`offline_compute_mixins.py::_free_tier_queue_enabled_env`(进 `free_tier_strategy.queue_enabled`)、`vedastro_user_entrypoint.py::_cache_and_queue_report`、`diagnose_vedastro_mode.py`(诊断输出 `free_tier_queue_enabled`)。 +- 结果:审计字段与实际限流**两个方向都会报错**。 + +复现(虚构环境,无网络): + +```bash +cd <工作树> +env -u VEDASTRO_API_KEY -u VEDASTRO_FREE_TIER_QUEUE PYTHONPATH=.:scripts python - <<'EOF' +import os +from offline_compute_mixins import _free_tier_queue_enabled_env +import vedastro_service_adapter as a +url = "https://api.vedastro.org/api/Calculate/x" +print(_free_tier_queue_enabled_env(), a._acquire_free_tier_slot(url)["mode"]) +os.environ["VEDASTRO_FREE_TIER_QUEUE"] = "1"; os.environ["VEDASTRO_FREE_TIER_MAX_REQUESTS"] = "0" +print(_free_tier_queue_enabled_env(), a._acquire_free_tier_slot(url)["mode"]) +EOF +``` + +写作时实测: + +| 配置 | 报告字段 `queue_enabled` | 实际 `_acquire_free_tier_slot` | +| --- | --- | --- | +| 不配任何排队变量、无 key | `False` | `free_tier_queue_active`(**在限流**) | +| `VEDASTRO_FREE_TIER_QUEUE=1`、`MAX_REQUESTS=0` | `True` | `free_tier_queue_disabled`(**没限流**) | + +此外 `scripts/vedastro_gateway.py::build_gateway_config` 的 `queue_enabled` 读的是另一组名字(`VEDASTRO_GATEWAY_QUEUE_ENABLED` / `VEDASTRO_QUEUE_ENABLED`),含义是「网关离线排队作业」,与免费层限流无关,但 `docs/engine/vedastro-gateway.md` 把两者写在同一句里(「`VEDASTRO_GATEWAY_QUEUE_ENABLED=1` / `VEDASTRO_FREE_TIER_QUEUE=1` 用于把昂贵或被限流的外部请求排队」)。`.env.cn.example` 同时配了两者。 + +### 2.2 静默吞异常:29 处 + +口径:`scripts/*.py`(根目录)+ `mcp_server.py`,`except Exception:` / `except BaseException:` / 裸 `except:` 且处理体**只有一句** `pass` / `continue` / `return None`。AST 扫描脚本见第 9 节。写作时 **29 处**(宽泛 `except Exception` 全部 343 处,大多数有降级返回或记录,不在本单范围): + +| 文件 | 处数 | 位置(符号) | +| --- | ---: | --- | +| `scripts/muhurta.py` | 4 | `_swisseph_solar_times_local`(2)、`_jd_for_local_time`、`_swisseph_sun_moon_lon` | +| `scripts/jyotish_engine.py` | 4 | `write_pl9_pdf`(字体循环 continue)、`cmd_planetary_congregation`、`_conflict_arbitration`(Ashtakavarga 失败后冲突仲裁静默少一层)、`cmd_full_reading`(过境月宿) | +| `scripts/yoga_engine.py` | 3 | `_compiled_exec_code`、`_run_custom_expr`(2)——自定义规则表达式求值 | +| `scripts/tajika.py` | 2 | `_year_lord_is_daytime`、`_resolve_external_pyjhora_python` | +| `scripts/pl9_full_data_export.py` | 2 | `_render_pl9_ai_density_raw_data_markdown_en` / `_zh`——**报告「Sudarshana 三参照落宫」整段失败时静默消失** | +| 其余各 1 | 14 | `annual_tajika_pack._iana_timezone_id`、`transit_trigger._get_transit_lon_precise`、`local_oss_observation_inventory.git_commit`、`jyotishganit_shadbala_surface_probe.clean`、`vedastro_python_bridge._serialize_runtime_value`、`nakshatra_dasha.calc_nakshatra_dasha_breakdown`(裸 except)、`solar_return._get_sun_lon_jd`、`oracle_boundary_audit._date_delta_days`、`cmd_nakshatra_adv.cmd_nakshatra_dasha`(裸 except)、`reference_transparency_contract._case_chart`、`daily_guidance_service._current_mahadasha`、`cmd_muhurta._get_sun_moon_lons`、`vedastro_gateway._sdk_version`、`mcp_server.strict_workflow` | + +排查报告写 `vedastro_python_bridge` 2 处,实测 1 处;其余与报告一致。 + +### 2.3 功能吉凶兜底三份 + +核心判定已在 `scripts/functional_benefics.py::derive_functional_benefic_malefic`(未知上升本身就返回 `status: blocked` 的完整形状)。外面又包了三层几乎相同的兜底: + +| 位置 | 形状 | 差异 | +| --- | --- | --- | +| `scripts/jyotish_engine.py::_functional_benefic_malefic_snapshot` | try/except 包 import + 调用,异常时手写 blocked 字典 | `effect_on_confidence` = 「未完成功能性吉凶星判定,需降低高严谨结论置信度: {exc}」 | +| `scripts/jyotish_api_server.py::JyotishAPIHandler._functional_benefic_malefic_snapshot` | 同上,逐字复制 | 同上;`ascendant` 不是 dict 时自己会抛 AttributeError 再被吞 | +| `mcp_server.py::_derive_functional_benefic_malefic` | 先判 `chart.ascendant` 是否 dict | 文案 「Missing chart.ascendant; functional layer blocked.」 | + +调用点:引擎 `cmd_full_reading`(`_functional_benefic_malefic_snapshot(planets, chart.get('ascendant', {}))`)、API `_compute_chart_sync` 与模块级 `_attach_local_consultation_layers`(`handler._functional_benefic_malefic_snapshot`)、MCP 两处 `present["functional_benefic_malefic"] = _derive_functional_benefic_malefic(modules)`。 + +### 2.4 `scripts/` 根目录:443 个 `.py`,约 1/3 不在任何生产路径上 + +方法(脚本见第 9 节 `refs2.py` / `classify2.py` 思路,执行方可重写):对每个 `scripts/.py`,按标识符在全部受控文件里找 `m`;以 `jyotish_api_server`、`mcp_server.py`、`jyotish_vedic/**/*.py`、`frontend/src/**`、`deploy/**`、`.gitea/**` 为根,沿 `scripts/` 内的引用求传递闭包。写作时: + +| 类别 | 个数 | 行数 | +| --- | ---: | ---: | +| 生产可达 | 253 | — | +| 生产不可达 | 190 | 23,438 | +|  其中被 `tests/` 引用 | 184 | 22,625 | +|  其中**只被测试引用、也不被其他脚本引用**(排查报告「138 个、约 14,600 行」的口径) | **140** | **14,981** | +|  零引用(无测试、无脚本) | 6 | 813 | + +140 个里**不能动**的(写作时): + +| 原因 | 个数 | 说明 | +| --- | ---: | --- | +| 被 `references/**/*.json` 证据索引按路径记录 | 96 | 例:`references/oracle/full_technique_invocation_matrix_2026_07_22.json` 记着 `"scripts/answer_quality_audit.py"`。搬走即改写历史证据指针 | +| 运营文档(`AGENTS.md`、`SKILL.md`、`skills/**`、`references/**/*.md`、`docs/testing/**`)写了运行方式 | 3 | 例:`AGENTS.md` §9 的 `scripts/pre_work_check.py` | +| `scripts/run_quality_gate.py` 写死路径 | 1 | | +| golden 生成器 | 1 | `generate_ephemeris_events_golden`(AGENTS §7.4 golden 出处) | +| 校正冻结身份(ERR-110 / ERR-114) | — | `scripts/research/sealed_holdout_rerun.py::PRODUCTION_FILES` / `RESEARCH_FILES` 与 `references/real_case_calibration/minute_rectification_holdout_v3.json` `frozen_scoring.files`;`minute_rectification_*` 多个在其中,**按字节哈希核对,搬动或改一个字节即打红** | + +剩下可搬的「干净集」**42 个、4,353 行**;其中 `niryaana_shoola_dasha` 归 `dasha-unify` 处理,本单不碰,实际 **41 个**。涉及 44 个测试文件,其中 33 个只引用干净集里的模块。**这 44 个测试没有一个在快速门清单里**(`scripts/run_quality_gate.py` 快速门是显式白名单;全量 `tests/` 只在手动 release 门跑),所以「搬到 `tests/audit/` 并从快速门排除」不改变任何门禁实际跑的内容。 + +6 个零引用文件核实结果: + +| 文件 | 结论 | +| --- | --- | +| `build_bright_stars.py` | 生成 `scripts/data/bright_stars.json`,`docs/tasks/PROGRESS-birth-sky-cover-20260928.md` 写了手工运行方式 → **保留** | +| `generate_birth_sky_golden.py` | `frontend/tests/birth-sky-test-support.ts` 与 `frontend/tests/fixtures/birth-sky-golden.json` 记为 golden 出处 → **保留**(AGENTS §7.4) | +| `rectification_prior_calibration.py` | `docs/BUG_HISTORY.md`、两份研究 / 任务文档引用 → **保留** | +| `conversational_minute_rectification_replay.py`、`local_oss_observation_inventory.py`、`vedicastro_kp_surface_locator.py` | 被 `references/oracle/*.json` 证据索引按路径记录 → **保留** | + +结论:**6 个零引用一个都不删**。 + +打包:`pyproject.toml` `[tool.setuptools.packages.find] include = ["jyotish_vedic*", "scripts*"]`,`scripts/__init__.py` 存在,整个 `scripts` 包都打进 wheel;镜像 `deploy/railway-api.Dockerfile` `COPY scripts ./scripts` 全量复制。`deploy/gated-paths.txt` 含 `scripts/**` 与 `tests/**`,搬到子目录不改变门禁触发。 + +## 3. 根因 + +- BUG-1340:免费层限流(BUG-720 引入预算)在 adapter 里自洽,但更早的「排队开关」报告字段从未接到限流实现上,三处各自复制解析;属于**从未接线**,不是回归。BUG-720 的防复发写的是「前台同步路径不得无上限排队」,没有覆盖「报告字段与行为一致」。 +- 吞异常:早期代码风格;BUG-1037 已经记过一次「宽泛异常把调用不存在的函数吞掉」。 +- 功能吉凶兜底:BUG-1181(功能吉凶 v2)只改了核心模块,三个消费层的兜底是更早各自复制的。 +- 根目录膨胀:研究 / 审计 / 探针脚本与生产模块同目录;部分被证据索引按路径钉住,后续只能「不再新增」而不能整体搬。 + +## 4. 决策记录 + +- 2026-10-13 产品负责人同意按排查结论全部立单交 Grok 执行。 +- **BUG-1340 修法选「报告跟随真实行为」**:三个旧变量名**不变成真开关**(变成真开关等于给运维一个关掉免费层限流的口子,违背 BUG-720 防复发「不得靠调大免费层配额绕过第三方额度」)。报告字段改为由 adapter 的同一套条件算出;三个旧变量名保留读取但只用于在诊断里提示「已废弃、无作用」。 +- **不删除任何脚本**(第 2.4 节 6 个零引用全部有出处)。 +- 搬迁只做「干净集」41 个;96 个被证据索引钉住的、冻结身份里的、运营文档里的一律不动。 +- 测试分层:搬走的测试放 `tests/audit/`,**仍在全量 `pytest`(release 门)里跑**;快速门本来就不跑它们,所以不改任何门禁清单。若主会话或产品希望 release 门也排除 `tests/audit/`,那是削弱门禁,**本单不做,需拍板**。 +- 不改 `.gitea/workflows/**`:排查报告「可选:ruff BLE001/S110 只拦新增」改成仓内 pytest 预算测试(第 6 节 T5),不动 workflow 里的 ruff 文件清单。 +- 不推翻任何既有红线。 + +## 5. 硬红线 + +1. `scripts/jyotish_api_server.py` 净行数不得增长;`tests/test_api_server_growth_contract.py` 的类方法数、`JyotishAPIHandler.__new__` 计数不得增长(本单 T2 预期方法数不变、行数下降)。 +2. **校正冻结身份内的文件一个字节都不许改、不许搬**(ERR-110 / ERR-114):开工先打印两份清单(第 9 节命令),本单任何改动命中清单即停下回报。 +3. 不改 `.gitea/workflows/**`、`deploy/gated-paths.txt`、`pyproject.toml` 的打包配置、`deploy/*.Dockerfile`。 +4. 日志**不得包含出生资料、坐标、姓名、请求体、异常消息原文**:异常消息可能带输入值(例如 `strptime` 报错会带出生日期字符串)。WARNING 级只写「模块.函数 + 异常类名」;完整 `exc_info` 只允许在 DEBUG 级。 +5. 加日志不得改变任何返回值、返回形状或控制流;不得把 `except Exception` 收窄到会让原本被吞的异常抛出去的程度(收窄只在能证明只可能抛该类型时做,并附测试)。 +6. 不往 stdout 写任何东西(引擎 CLI 用 stdout 输出 JSON;日志只走 `logging`,默认落 stderr)。 +7. 功能吉凶合一后,**正常路径输出逐字节不变**;新 helper 不得放进 `scripts/functional_benefics.py`(它在冻结身份 `PRODUCTION_FILES` 里)。 +8. 不删除任何脚本;搬迁不改脚本内容(只允许 import 路径与 `ROOT` 层级修正,并逐个列在进度记录)。 +9. 不动表结构与迁移;不顺手升级依赖、不顺手修不在本单的 ruff 告警(`jyotish_api_server.py` 现有 8 条 F401/F841 不在范围)。 +10. 改既有断言写「原值 / 新值 / 原因」三栏;golden 只能来自真实引擎输出。 +11. 不碰 `skills/*/versions/**`(冻结的历史 skill 副本)。 + +## 6. 任务分解 + +### T1 · BUG-1340:排队状态单一来源(必做) + +改什么: +- 在 `scripts/vedastro_service_adapter.py` 新增一个纯函数(建议名 `free_tier_throttle_state(request_url: str | None = None) -> dict`),用与 `_acquire_free_tier_slot` **完全相同的条件**算出 `{"active": bool, "reason": "...", "max_requests": int, "window_seconds": float, "wait_budget_seconds": float}`;`_acquire_free_tier_slot` 的判断改为调用它(行为不变)。`request_url` 缺省时取官方公共端点。 +- `offline_compute_mixins._free_tier_queue_enabled_env`、`vedastro_user_entrypoint._cache_and_queue_report`、`diagnose_vedastro_mode` 三处改为读这个函数;三处各自的 `_bool_env(...)` 三连删除。 +- 三个旧变量名:只在 `diagnose_vedastro_mode` 的输出里多给一个 `deprecated_env_present: [...]`,说明「已废弃,不控制限流;限流由 key 与 `VEDASTRO_FREE_TIER_MAX_REQUESTS` / `_WINDOW_SECONDS` 决定」。 +- `docs/engine/vedastro-gateway.md` 与 `.env.cn.example`:拆开「网关离线排队」(`VEDASTRO_GATEWAY_QUEUE_ENABLED`)与「免费层限流」(key + `MAX_REQUESTS` / `WINDOW_SECONDS` / `WAIT_BUDGET_SECONDS`)两件事,删掉 `VEDASTRO_FREE_TIER_QUEUE=1` 示例或标注废弃。 +- 不改:`_acquire_free_tier_slot` 的等待、预算、降级逻辑(BUG-720);`vedastro_gateway.build_gateway_config`。 + +验收: +- 新测试 `tests/test_vedastro_free_tier_state.py`:第 2.1 节两行表各一条(报告字段 == adapter 实际 `mode == "free_tier_queue_active"`);有 key 时 `active=False`;自建端点时 `active=False`;源码 AST 断言除 `diagnose_vedastro_mode` 外不再出现这三个变量名字符串。 +- 既有 `tests/test_vedastro_runtime_mode_diagnostics.py`、`tests/test_vedastro_user_entrypoint.py`、`tests/test_vedastro_gateway.py`、`tests/test_api_server_security.py`(`VEDASTRO_FREE_TIER_QUEUE` 相关几条)、`tests/test_offline_compute_mixins.py` 全绿;凡断言「设了 `VEDASTRO_FREE_TIER_QUEUE=1` 就 `queue_enabled=True`」的,按三栏改成跟随真实状态。 +- Grok 自跑上述文件;Claude 复验 + 快速门。 + +### T2 · 功能吉凶兜底合一(必做) + +改什么: +- 新建 `scripts/functional_layer.py`(名字可议,**不得**是 `functional_benefics.py`),一个函数 `functional_layer_for_ascendant(ascendant) -> dict`:`ascendant` 是 dict 取 `sign`,否则视为缺失;正常路径直接 `return derive_functional_benefic_malefic(sign)`;import 或计算异常时返回与现有引擎版相同的 blocked 字典(字段顺序一致)。 +- 三处改为调用它:引擎模块级函数改为一行转发(或调用点直接换);`JyotishAPIHandler._functional_benefic_malefic_snapshot` 保留方法名(`_attach_local_consultation_layers` 与测试按名调用),方法体改为一行转发;`mcp_server._derive_functional_benefic_malefic` 改为取 `chart.ascendant` 后调用它。 +- 三处 blocked 文案不一致:统一为引擎版文案;MCP「Missing chart.ascendant」场景改走统一文案。任何断言旧文案的测试按三栏改。 + +验收: +- 新测试:十二个上升星座,三条路径(引擎函数、handler 方法、MCP 函数)返回与 `derive_functional_benefic_malefic` 逐字节一致(`json.dumps(sort_keys=True)` 比较);`ascendant=None` / `{}` / 非法星座三种输入三条路径输出相同;强制 import 失败(monkeypatch)时返回 blocked 且字段齐全。 +- 写一个对照脚本(放 scratch,不入库):基线工作树与本分支各跑一次虚构盘(1990-01-01 12:00 新德里 28.6139/77.2090 +5.5)的引擎全量解读与 MCP `strict_workflow`,`modules.functional_benefic_malefic` 逐字节一致,贴进进度记录。 +- `jyotish_api_server.py` 行数下降、方法数不变;`tests/test_api_server_growth_contract.py` 绿。 +- `tests/test_rectification_validation_integrity_gate.py`、`tests/test_sealed_holdout_contract_freshness.py` 绿(证明没碰冻结文件)。 + +### T3 · 29 处静默吞异常:分类处理(必做) + +每处按下表二选一,进度记录逐条列「位置 / 类别 / 处理」: + +| 类别 | 判定 | 处理 | +| --- | --- | --- | +| A 预期失败 | 可选依赖导入(`import swisseph`、`importlib.metadata.version("vedastro")`)、用户可控字符串解析(时区名、日期)、自定义规则表达式求值(`yoga_engine` 3 处,每条规则都可能失败,高频) | 保持静默;收窄到确定的异常类型(如 `ImportError`、`ValueError`、`ZoneInfoNotFoundError`)**仅当**能证明只会抛这些,否则保留 `Exception` 并加一行注释说明为何静默 + `# noqa: BLE001` | +| B 降级吞掉 | 计算失败导致输出里少一段、少一层而用户 / 调用方看不出:`pl9_full_data_export` 2 处、`jyotish_engine._conflict_arbitration` / `cmd_planetary_congregation` / `cmd_full_reading`、`cmd_nakshatra_adv`、`transit_trigger`、`daily_guidance_service`、`tajika._year_lord_is_daytime`、`solar_return`、`muhurta` 计算类 3 处、`cmd_muhurta`、`reference_transparency_contract`、`mcp_server.strict_workflow` | 加模块级 `logger = logging.getLogger(__name__)`;`logger.warning(". degraded: %s", type(exc).__name__)`,`logger.debug(..., exc_info=True)` 可选;返回值不变 | + +- 裸 `except:` 两处(`nakshatra_dasha`、`cmd_nakshatra_adv`)以及 `jyotish_engine` 两处裸 `except:` 至少改成 `except Exception:`(不再吞 `KeyboardInterrupt` / `SystemExit`),这是唯一允许的控制流变化,写进进度记录。 +- `pl9_full_data_export` 两处另加:在报告该段位置写一行可见的「本段数据缺失」吗?——**本单不做**(改报告正文属于用户可感知变化,需拍板),只加日志。 +- 验收: + - 新测试 `tests/test_silent_except_logging.py`:至少覆盖 `pl9_full_data_export`(造一个让 Sudarshana 段抛错的 packet)、`daily_guidance_service._current_mahadasha`、`jyotish_engine._conflict_arbitration` 三处,用 `caplog` 断言有一条 WARNING、消息里只有模块函数名与异常类名,**不含**测试盘的出生日期字符串、经纬度。 + - 引擎全量解读(虚构盘)在基线与本分支输出逐字节一致(同 T2 的对照脚本,`PYTHONHASHSEED=0`)。 + +### T4 · 测试专用脚本归位(可砍,最后做) + +改什么: +- 新建 `scripts/audit/__init__.py`(一行 docstring:「审计 / 探针 / 研究脚本,不在生产路径上;生产代码不得 import」)。 +- 把第 2.4 节「干净集」41 个脚本 `git mv` 到 `scripts/audit/`。干净集由执行方**开工时重算**(同口径),并把最终名单写进进度记录;与写作时名单(`move.txt`,见附录)差异逐条解释。排除项:冻结身份两份清单、`run_quality_gate.py` 出现的、被 `references/**/*.json` / 运营文档引用的、golden 生成器、`niryaana_shoola_dasha`(归 `dasha-unify`)、`production_smoke`(名字像运维工具,执行方先确认无任何运维文档 / 部署脚本调用,确认不了就留在原处)。 +- 只引用干净集模块的测试(写作时 33 个)`git mv` 到 `tests/audit/`;其余引用了干净集模块的测试留在原处,只改 import。 +- 脚本内 `ROOT = Path(__file__).resolve().parents[1]` 之类层级常量随之 +1;逐个列出。 +- 新测试 `tests/test_audit_scripts_boundary.py`:AST 断言 `scripts/` 根目录、`mcp_server.py`、`jyotish_vedic/**` 不 import `scripts.audit` / `audit.*`。 +- 不改:任何脚本的逻辑;`pyproject.toml`(`scripts*` 已覆盖子包);Dockerfile;门禁清单。 + +验收: +- 被搬脚本的 CLI 仍能跑(每个 `python scripts/audit/.py --help` 或无参运行的退出码与搬前一致,列表格)。 +- 全量 `pytest` 通过条数与基线一致(只是路径变化),失败清单逐名与基线一致。 +- `python scripts/run_quality_gate.py --profile quick` 的 pytest 段条数与基线一致(证明快速门没变)。 + +### T5 · 吞异常与根目录的「只拦新增」守卫(必做,轻量) + +- 新测试 `tests/test_python_hygiene_budget.py`(加入 `scripts/run_quality_gate.py` 快速门清单,这是 `scripts/` 内文件,不是 workflow): + 1. 第 2.2 节口径的静默吞异常计数 ≤ 本单收尾实测值(只许降);`# noqa: BLE001` 注释的 A 类不计入。 + 2. `scripts/` 根目录 `.py` 文件数 ≤ 本单收尾实测值(只许降);新研究脚本应放 `scripts/audit/` 或 `scripts/research/`。 +- 验收:人为加一处 `except Exception: pass` 让 1 变红;人为在根目录加一个空 `.py` 让 2 变红;两次反向验证输出贴进进度记录。 + +### T6 · 记录(必做) + +- `docs/BUG_HISTORY.md` 新增 **BUG-1340**(免费层排队状态报告与实际限流相反;关联 BUG-720)。 +- `docs/tasks/PROGRESS-python-hygiene-20261013.md`:第 2 节各表的收尾实测、T3 逐条分类表、T4 名单与差异、反向验证输出。 +- `CHANGELOG.md`:一条(诊断与审计字段口径变化,运维可见);Skill 版本不 bump。 +- `frontend/DESIGN.md`:不改。 + +## 7. 让步顺序 + +时间不够按此顺序砍:**T4 → T5 第 2 条 → T3 的 A 类收窄(保留注释即可)**。T1、T2、T3 的 B 类、T6 不可砍。 + +## 8. 验收命令分工 + +| 谁 | 命令 | +| --- | --- | +| Grok 自跑 | `python -m pytest tests/test_vedastro_free_tier_state.py tests/test_vedastro_runtime_mode_diagnostics.py tests/test_vedastro_user_entrypoint.py tests/test_vedastro_gateway.py tests/test_offline_compute_mixins.py tests/test_api_server_growth_contract.py tests/test_silent_except_logging.py tests/test_python_hygiene_budget.py tests/test_rectification_validation_integrity_gate.py tests/test_sealed_holdout_contract_freshness.py -q`;T4 涉及的测试文件;T2/T3 对照脚本。快速门、全量 pytest **不跑**,以 Claude 为准 | +| Claude 验收 | 上述 + 快速门 + **全量 `pytest` 与基线逐名比对**(本单碰引擎文件,按「引擎改动跑全量」规则)+ `python -m pytest tests/test_api_server_security.py -q` + 隐私标记测试 `tests/test_repo_privacy_markers.py` | +| 不需要 | `test:db`、前端测试(不碰前端) | + +## 9. 开工前置命令 + +```bash +git fetch origin --prune +git log --oneline origin/staging | grep -i "api-disk-cache-retention\|api-chart-subprocess-gate" # 两单都已合入才开工 +git worktree add -b codex/python-hygiene-20261013 .worktrees/python-hygiene-20261013 origin/staging +cd .worktrees/python-hygiene-20261013 +grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -3 # 核对最大号 +python - <<'EOF' # 冻结身份两份清单 +import json, sys; sys.path[:0] = ["scripts/research", "scripts", "."] +from sealed_holdout_rerun import PRODUCTION_FILES, RESEARCH_FILES +print(PRODUCTION_FILES, RESEARCH_FILES) +print(json.load(open("references/real_case_calibration/minute_rectification_holdout_v3.json"))["frozen_scoring"]["files"]) +EOF +wc -l scripts/jyotish_api_server.py # 记基线 +python -m pytest tests -q -p no:cacheprovider > /tmp/baseline-full.txt 2>&1; tail -3 /tmp/baseline-full.txt # 全量基线(Windows 已知失败按 windows-known-failures.txt 比) +grep -rn "FREE_TIER_QUEUE\|_functional_benefic_malefic_snapshot\|_derive_functional_benefic_malefic" tests scripts mcp_server.py # 找按源码断言的合同测试 +``` + +静默吞异常扫描(AST,口径与第 2.2 节一致):遍历 `scripts/*.py` 与 `mcp_server.py` 的 `ast.ExceptHandler`,类型为空 / `Exception` / `BaseException`,且 `body` 只有一句 `Pass` / `Continue` / `Return(None)`。 + +## 10. BUG 编号起点 + +BUG-1340(写作时最大号 BUG-1317;号段由主会话统一分配)。开工时核对,若被占用顺延并在进度记录说明。 + +## 11. 交付物清单 + +- 代码:`scripts/vedastro_service_adapter.py`、`scripts/offline_compute_mixins.py`、`scripts/vedastro_user_entrypoint.py`、`scripts/diagnose_vedastro_mode.py`、新 `scripts/functional_layer.py`、`scripts/jyotish_engine.py`、`scripts/jyotish_api_server.py`、`mcp_server.py`、T3 涉及的 15 个文件、`scripts/audit/**`、`tests/audit/**`、`scripts/run_quality_gate.py`(只加 T5 测试一行) +- 测试:`tests/test_vedastro_free_tier_state.py`、`tests/test_silent_except_logging.py`、`tests/test_python_hygiene_budget.py`、`tests/test_audit_scripts_boundary.py` +- 文档:`docs/tasks/PROGRESS-python-hygiene-20261013.md`、`docs/BUG_HISTORY.md`(BUG-1340)、`CHANGELOG.md`、`docs/engine/vedastro-gateway.md`、`.env.cn.example` +- 不改:`frontend/DESIGN.md`、`.gitea/**`、`deploy/**`、`pyproject.toml` + +## 附录 · 写作时「干净集」名单(42 个,执行方开工重算) + +archive_vedastro_nuget_probe、configure_vedastro_secret、evidence_packet_index_integrity、high_rigor_closure_snapshot、jyotishyamitra_adapter_probe、marriage_benchmark_summary、minute_rectification_development_eval、minute_rectification_holdout_intake、minute_rectification_source_audit、niryaana_shoola_dasha(归 dasha-unify,排除)、ocr_extract、oracle_batch_closure_pack、oracle_packet_assistant_index、prashna_oracle_queue、prashna_sphuta_candidate_replay、prashna_sphuta_review_result_validator、prepare_oracle_capture_packets、prepare_vedicastro_kp_tmp_env、print_cline_mcp_config、production_smoke(先确认)、public_real_case_negative_controls、rangacharya_readiness、shadbala_component_joined_closure、shadbala_d3_branch_audit、shadbala_oracle_component_cluster_summary、shadbala_sthana_targeted_audit、skill_truth_overlay_view、three_engine_mismatch_arbitrator、traditional_maturity_gap_audit、vedastro_contract_arbitrator、vedastro_divisional_contract_probe、vedastro_ingestion_closure_pack、vedastro_method_catalog_sync、vedastro_official_contract_audit、vedastro_official_source_contract、vedastro_reproducible_build_probe、vedastro_strength_oracle_packet、vp_jain_shadbala_benchmark、western_real_case_calibration、xalen_arbitration_gate、xalen_difference_attribution、xalen_public_case_batch + +`minute_rectification_*` 三个不在写作时的冻结清单里,但执行方必须用第 9 节命令再核一次。 + +## 后续阶段(本单不做) + +1. 96 个被证据索引钉住的测试专用脚本:要么接受留在根目录,要么另开单「证据索引路径迁移」(改历史证据 JSON,需拍板)。 +2. 其余约 314 处宽泛 `except Exception`(有降级返回的)逐个审。 +3. `pl9_full_data_export` 段落失败时在报告里可见提示(用户可感知,需拍板)。 +4. `jyotish_api_server.py` 现有 8 条 ruff F401/F841。 diff --git a/docs/tasks/TASK-rect-scoring-perf-20261013.md b/docs/tasks/TASK-rect-scoring-perf-20261013.md new file mode 100644 index 00000000..d82e0c67 --- /dev/null +++ b/docs/tasks/TASK-rect-scoring-perf-20261013.md @@ -0,0 +1,315 @@ +# TASK · 校正打分提速:去掉重复计算、精简响应(issue #待定) + +- 日期:2026-10-13 +- 来源:「2026-10-13 全仓屎山与性能排查」· 校正打分 `POST /api/rectification/v5/score` 慢与响应过大(P0,确定性计算提速) +- 执行方:**Grok(Windows + Docker)**,分支 `codex/rect-scoring-perf-20261013`,只推这个分支 +- 验收:Claude(Linux) +- BUG 编号:**BUG-1323~BUG-1325**(开工时核对 `docs/BUG_HISTORY.md` 最大号,写作时最大号 BUG-1317)(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起) +- 开工前置:无(第一批立即派发) +- 性质:**输出逐字节不变**的性能改动。算法身份保持 `rectification-v5-matrix-scoring-12`、策略 `rectification-candidate-policy-v4`,不升版本、不写新 golden。 + +## 1. 基线 + +- 基线 commit:`origin/staging` `77057a819` +- 调用链:前端 `frontend/src/lib/rectification-agentic/v9/engine-client.ts::runV9CandidateScore` → `POST /api/rectification/v5/score`(超时 60 s,`busyRetry`)→ `scripts/jyotish_api_server.py::_compute_rectification_v5_score` → `scripts/rectification/api_service.py::score_candidates` → `scoring_service.py::build_event_contribution_matrix`(含 `dasha_transition_proximity.py::merge_transition_proximity`)→ `decision_policy.py::build_decision_receipt` → `refinement_packet.py::build_refinement_packet` → `event_probes.py::guided_collect_windows`。 +- 时段扫描 `block_scan` 也走 `score_candidates`,同样受益。 +- 生产窗口有多大(已核实,修正排查报告的假设): + - `engineRequestBody` 不传 `minute_step`;`contracts.py::normalize_rectification_request` 只在 ≠1 时保留该键,引擎按 `request.get("minute_step") or 1` 取 **1 分钟**。 + - `search-window.ts`:`MINUTE_GRID_MAX_SPAN_MINUTES = 120`,跨度 ≤120 分钟才进分钟阶段(打分接口),所以**生产单次打分最多 121 个候选**;更宽的窗口走 `block_scan`,步长 2/5/10(全天 10 分钟 = 144 候选)。 + - 排查报告里「4 小时 240 候选 12.3 s」「12 小时 721 候选 117 s」在产品流程里到不了,只在直接调用接口时出现(服务端没有候选数上限,本单不做,见 §10)。 + +## 2. 事故实证(`77057a819`,本机 8 核、有其他会话并行负载,数字有 ±20% 抖动) + +复现夹具(虚构事件 + 公开 1990-01-01 北京烟测盘,与 `tests/test_rectification_engine_memoization.py::public_score_request` 同一张盘): + +```python +EVENTS = [ # id 依次 00000000-0000-4000-8000-00000000000{1..8},summary 统一 "虚构" + ("education","education_start","2008-01-01","2008-12-31","year"), + ("career","career_entry","2012-06-01","2012-06-30","month"), + ("relocation","relocation","2014-03-01","2014-03-31","month"), + ("relationship","relationship_commitment","2016-10-01","2016-10-31","month"), + ("career","promotion","2018-01-01","2018-12-31","year"), + ("finance","asset_change","2019-05-01","2019-05-31","month"), + ("family","family_event","2020-08-01","2020-08-31","month"), + ("health","self_health_event","2022-01-01","2022-12-31","year"), +] +# 请求:birth_date 1990-01-01, lat 39.9, lon 116.4, tz 8;normalize_rectification_request(body, today=date(2026,9,16)) +# event_probes.date 打补丁冻结在 2026-09-16(同记忆化测试 _FrozenDate) +``` + +### 2.1 总耗时与响应大小(`score_candidates` 无 profiler,墙钟) + +| 窗口 | 候选 | 耗时 | 响应 JSON | +| --- | --- | --- | --- | +| 12:00–12:20 | 21 | 0.45–0.50 s | 393 KB | +| 11:45–12:15(常见 ±15) | 31 | 0.78 s | — | +| 11:00–13:00(分钟阶段上限) | 121 | **3.8–4.8 s** | **1.91 MB** | +| 10:00–14:00(产品到不了) | 241 | 10.5 s | 3.45 MB | +| `block_scan` 00:00–23:59 步长 10 | 144 | 2.35–2.6 s | 310 KB | + +生产 2 vCPU、重计算闸门 `JYOTISH_HEAVY_COMPUTE_CONCURRENCY=2`:一次 121 候选打分占一个闸门槽 4 s 以上,两人同时答题第三个就 429。 + +### 2.2 121 候选的分段(计时包装,含少量包装开销) + +| 段 | 耗时 | 调用次数 | +| --- | --- | --- | +| `event_probes.py::guided_collect_windows` | 2.3 s(cProfile 下占总时间 68%) | 1 次;内部 `_context_time` **2,782,032 次**、`_guided_year_windows` 22,982 次 | +| `merge_transition_proximity` | 0.8–1.0 s | 1 次;每候选×每个事件年份区间各算一遍大运起点 | +| `_active_narayana` → `get_current_narayana_dasha` | ≈0.5 s | 6,171 / 6,519 次;`_subdivide_narayana_period` 25,350 次 | +| `functional_benefics.derive_functional_benefic_malefic` | ≈0.2 s | 6,519 次(只依赖上升星座,每候选只需 1 次) | +| 候选静态上下文 | 0.5 s | 1 次(BUG-721 已收敛,正常) | + +**更正排查报告**:报告里 `derive_functional_benefic_malefic` ≈1.05 s、`_active_narayana` ≈2.4 s、`merge` 最高 6 s 是 cProfile 放大后的数字(cProfile 下整次 18.8 s)。无 profiler 时这三项合计约 1.5 s,大头是 `guided_collect_windows`。 + +### 2.3 原型验证(scratch 原型,不入库,只证明可行) + +同进程、同一请求,剔除计时字段 `column_compare_ms` 后整份响应逐字节比较: + +| 原型 | 121 候选 | 一致性 | +| --- | --- | --- | +| `guided_collect_windows` 按(轨道, 边界序号)去重 | 1.89 s → 0.37 s(函数本身,5×);`_context_time` 2,782,032 → 1,331(每候选 11 次) | 全候选 / 半数 / 3 个 / 拒答领域 4 种入参全部一致 | +| `merge_transition_proximity` 每候选算一次全量起点再按年份过滤 | 0.79–0.95 s → 0.22–0.25 s | 一致 | +| 四项合并 | 整次 4.05 s → 1.9–2.7 s | 2 小时、跨午夜 23:20–00:40、拒答两领域、`refresh_probes`+`column_times`、步长 2 共 5 个场景全部一致 | + +原型里功能吉凶用了 deepcopy、那罗延只做了 id 记忆,都比正式方案慢;按 §6 T3 的正式方案(进静态上下文、不复制、不算用不到的 PD),预计 121 候选可到 1.5 s 左右。 + +### 2.4 响应体(121 候选,紧凑 JSON 1.91 MB,带空格 2.04 MB) + +| 键 | 大小 | 读取点 | +| --- | --- | --- | +| `event_contribution_matrix` | 804 KB | `frontend/src` **零读取**;`frontend/tests/rectification-v9-status-security.test.ts` 还断言它不得出现在前台投影里。Python:`tests/test_rectification_v5_services.py`(`assertIn`)、`scripts/research/midnight_date_anchor_regression.py::projection` 读 `result["event_contribution_matrix"]` | +| `candidate_decision_receipt` | 219 KB | 与 `decision_receipt` **是同一个 dict 序列化两遍**。`frontend/src` 零读取;`jyotish_api_server.py::_attach_vedastro_minute_sensitive_to_score` 已对缺键容错;`tests/test_rectification_v5_services.py` 两处读取;`diagnostics()` 也返回 | +| `candidate_feature_snapshot` | 417 KB | **有读取点**:`v9/segment-contract.ts::candidateSegmentMetadata` 读 `features[*]`。本单不动 | + +去掉前两项:1.91 MB → 0.91 MB,gzip 89 KB → 65 KB。Python `json.dumps` 只省约 8 ms,所以这一项是卫生(BUG-728 规则),不是提速主力。`mcp_server.py` 不调用 v5 打分。 + +## 3. 根因 + +- **BUG-1323**:`guided_collect_windows`(2026-09-16 `cfb41daf3` 引入,BUG-747/749 期间)对每一对代表候选 × 4 条轨道 × 每个边界序号,都把**全部**候选重新扫一遍取 `_context_time` 再收集日期。收集结果只取决于(轨道, 边界序号),后续同一(轨道, 序号)产出的键都已在 `seen` 里,重复计算没有任何输出。复杂度 O(N²·B)。记忆化测试只用 3 个候选、引导窗口测试只用 5 个,没测到平方增长。**从未设计,不是回归。** +- **BUG-1324**:BUG-721 的防复发条款(「候选分钟不变量必须进 static context,不得留在事件循环里」)没有覆盖到三处: + 1. `_score_event` 每个(候选 × 事件 × 采样日)都调一次 `derive_functional_benefic_malefic`,它只依赖上升星座; + 2. `_active_narayana` 每次调用都通过 `get_current_narayana_dasha` 重新切分当前 MD 的 AD,还多算一层用不到的 PD; + 3. `merge_transition_proximity`(09-01 `8743dcb10` 引入,09-20 改为候选日期)的缓存键带事件年份区间 `(lo, hi)`,同一候选对每个事件重新遍历整条大运时间轴。 + 同属 BUG-721 一族:旧条款还在,只是新代码没照做。 +- **BUG-1325**:`score_candidates` 无条件返回内部矩阵,并把同一个回执挂两个键名,违反 BUG-728「没有读取点的字段不得进入前台响应」。 + +## 4. 决策记录 + +- 2026-10-13 产品负责人同意按排查结论全部立单交 Grok 执行。本单为 P0 确定性提速:**不需要模型 A/B**,需要改前改后耗时对比与差分脚本。 +- **不推翻任何红线**: + - BUG-721:记忆化只用请求内显式传递的 context 或局部字典,**禁止模块级 `lru_cache` / 全局 dict**(即使键只是上升星座也不行)。本单照此执行。 + - BUG-733 / BUG-747:不得对浮点整体 `==` 比较 golden,不得调用 `write_golden()`。记忆化 golden v5 文件一个字节都不改。 + - ERR-110 / BUG-1047:`event_probes.py`、`dasha_transition_proximity.py`、`scoring_service.py`、`api_service.py`、`refinement_packet.py`、`functional_benefics.py` 在 `scripts/research/sealed_holdout_rerun.py::PRODUCTION_FILES` 里;`active_rectification_event_engine.py`、`narayana_dasha.py` 在 dataset `frozen_scoring.files`(12 个遗留文件)里。**输出不变也必须按研究协议重新冻结**(§6 T6),否则不得合入。BUG-1047 当时就是因为没做这一步,同类优化半途放弃。 +- T4 默认不返回两个字段,是执行 BUG-728 的既有规则,不是新决定。Python 内部调用方要拿矩阵就显式传参。HTTP 接口不加开关(前台没有读取点)。 + +## 5. 硬红线 + +1. **输出逐字节不变**:同一请求下,除计时字段 `column_compare_ms` 外,`score_candidates` / `block_scan` / `diagnostics` 的返回值规范化 JSON(`sort_keys=True`)与基线完全相等;T4 删掉的两个键只在显式开关下出现,且出现时与基线逐字节相等。分数、排序、窗口、探针、回执、`candidate_feature_snapshot`(含 `feature_hash`)都不准变。 +2. 不改算法、权重、阈值、采样规则、`GUIDED_COLLECT_LIMIT`、`MIN_BOUNDARY_DAYS`;不升 `ALGORITHM_VERSION` / `POLICY_VERSION`;不写新 golden。 +3. 新的缓存只放进请求内的静态上下文**顶层**(与 `narayana_periods` 同级),**不得**放进 `feature` 负载(会改 `fingerprints` 与 `feature_hash`)。禁止模块级缓存(BUG-721)。 +4. 不修改 `narayana_dasha.py`、`functional_benefics.py`、`dasha_analyzer.py` 的任何函数(共享模块,报告和咨询也在用)。 +5. `scripts/jyotish_api_server.py` 不改(`_attach_vedastro_minute_sensitive_to_score` 已对缺键容错);`tests/test_api_server_growth_contract.py` 必须绿。 +6. 不碰前端业务代码与 `frontend/src/app/(app)/page.tsx`;不改 `.gitea/workflows/**`;不动表结构和迁移;不升级依赖;不顺手修别的 warning。 +7. 改既有断言按 AGENTS §7.3 写「原值 / 新值 / 原因」三栏;开工先 grep:`grep -rn "event_contribution_matrix\|candidate_decision_receipt" tests scripts frontend/tests frontend/src`。 +8. 冻结重跑只能**另存**新记录,不得覆盖 `ephemeris_nutation_2026_10_08` 的四份文件,也不得改历史归档(`test_historical_artifacts_are_byte_preserved_not_refreshed`)。 +9. 跨进程比较一律固定 `PYTHONHASHSEED=0`(ERR-111:Shadbala 指纹随哈希种子变);同进程比较不受影响。 + +## 6. 任务分解 + +### T1 · `guided_collect_windows` 去掉平方扫描(BUG-1323) + +- 改 `scripts/rectification/event_probes.py::guided_collect_windows`: + - 在算完 `starts_by_time` 后,按 `work` 的顺序预取每个候选的轨道表一次,例如 `work_tracks = [starts_by_time.get(_context_time(c) or "") or {} for c in work]`,内层循环改为遍历它。 + - 用局部集合 `done: set[tuple[str, int]]` 记录处理过的(轨道, 边界序号)。只有在通过 `one == two` 检查后才登记;已登记的直接 `continue`。这是等价变换:同一(轨道, 序号)的 `gathered`、`cut`、`split`、`domain` 都一样,重复时产出的键全都已在 `seen` 里。 + - 保留 `_representative_pairs` 的遍历顺序、`seen` 去重、最后的排序与截断,一行都不改。 +- 不改:`_guided_track_starts`、`_guided_year_windows`、`_representative_pairs`、任何常量。 +- 验收: + - 新测试 `tests/test_rect_scoring_perf_20261013.py`: + - 把基线 `guided_collect_windows` 原样复制进 `tests/_baseline_rect_scoring_20261013.py`,文件头写来源 commit `77057a819` 和 `git show 77057a819:scripts/rectification/event_probes.py` 的 sha256。 + - 用 §2 夹具真实引擎的 121 候选 `built`,覆盖全候选 / 隔一取一 / 前 3 个 / `declined_domains=["relationship"]` 四种入参,新旧输出 `==`。 + - **计数断言**:`_context_time` 在一次调用内的次数 ≤ 16 × 候选数;121 候选与 21 候选的次数比 ≤ (121/21) × 1.2,证明是线性的。 + - `tests/test_event_probes_guided_windows.py` 全绿,断言不改。 + - Grok 自跑。 + +### T2 · `merge_transition_proximity` 每候选只遍历一次大运(BUG-1324 之三) + +- 改 `scripts/rectification/dasha_transition_proximity.py::merge_transition_proximity`: + - 每个候选的 key 取 `(candidate_date, round(moon, 6), asc)`,算一次不按年份过滤的 `_vim_start_dates`(AD 层、含 PD 层)和 `_narayana_start_dates`。年份参数传一个覆盖全部的区间,例如 `lo=1, hi=9999`;也可以在本模块里写不带年份参数的私有包装,但**不得改 `event_probes.py` 里这两个函数**。 + - 每个事件再用 `[d for d in xs if lo <= d.year <= hi]` 过滤,保持原顺序。 + - 等价依据:这两个函数只是在固定遍历顺序上按 `start.year` 过滤,返回 `None` 的条件与年份无关。执行方要在 PROGRESS 里引用 `event_probes.py::_vim_start_dates` / `_narayana_start_dates` 的源码行说明这一点;若发现不成立就停下来回报。 + - 局部字典,函数返回即释放。 +- 不改:`score_transition_proximity`、常量、`ad_set` / `pd_only` 的计算方式。 +- 验收: + - 新测试:基线函数原样复制进 `_baseline_rect_scoring_20261013.py`。用 §2 夹具 121 候选,再加跨午夜 23:20–00:40 的矩阵,对 deepcopy 后的同一份矩阵分别跑新旧两版,结果 `==`。 + - **计数**:一次调用内,`_vim_start_dates` 调用 ≤ 2 × 去重候选数,`_narayana_start_dates` ≤ 1 × 去重候选数。 + - `tests/test_dasha_transition_proximity_cross_midnight.py` 全绿。 + - Grok 自跑。 + +### T3 · 功能吉凶与那罗延 AD 表进静态上下文(BUG-1324 之一、之二) + +- `scripts/active_rectification_event_engine.py::build_candidate_static_context` 的返回值**顶层**新增两个键,不进 `feature`: + - `functional_profile`:`functional_benefics.derive_functional_benefic_malefic(chart["ascendant"].get("sign"))`。 + - `narayana_antardasha_by_md`:当 `narayana_periods` 非空时,等于 `{p["sign_idx"]: narayana_dasha.calc_narayana_antardasha(narayana_periods, p["sign_idx"]) for p in narayana_periods}`(键用 `sign_idx`,与 `get_current_narayana_dasha` 内部调用一致);否则 `None`。 +- `_score_event` 增加可选参数 `functional: dict | None = None`,为 `None` 时照旧现算。`_candidate_row` 传 `context.get("functional_profile")`。`event_probes.py` 里调用 `_score_event` 的探针路径(约 809 行,`natal_chart=context["chart"]`)同样传 context 里的值。 +- `_active_narayana` 增加可选参数 `antardasha_by_md`。为 `None` 时走原来的 `get_current_narayana_dasha`;不为 `None` 时: + - 用与 `narayana_dasha.get_current_narayana_dasha` **逐行相同**的 `total_cycle` / `cycle_start` / `age_in_cycle` 与 MD、AD 查找逻辑,只返回 `(md sign_idx, ad sign_idx)`,不算 PD。 + - 在代码注释里注明照抄的源行号。 + - `_candidate_row` 传 `context.get("narayana_antardasha_by_md")`。 + - 探针路径(`event_probes.py` 约 799 行)**本单不改**:它不传 periods,改成传入会让它从「现算」变成「用缓存」,虽然等价,但不在本单范围。 +- 回退路径必须保留:上下文缺这两个键(旧调用方、测试里手搓的上下文)时,结果与现在相同。 +- 不改:`functional_benefics.py`、`narayana_dasha.py`(红线 4);`compute_event_candidate_rows`;事件循环结构。 +- 验收: + - 扩展 `tests/test_rectification_engine_memoization.py` 的同进程 A/B(BUG-733 的做法):`CACHE_LAYER_KEYS` 加上这两个新键。A 带全部缓存、B 把六个键全置 `None`,`compute_event_candidate_rows` 输出严格相等。这是给常量加两项,属于扩展不是放宽,在 PROGRESS 里写三栏。 + - **计数**:一次 `score_candidates` 内,`derive_functional_benefic_malefic` 调用次数 == 静态上下文个数(121 候选即 121,基线 6,519);`narayana_dasha.get_current_narayana_dasha` 在 `active_rectification_event_engine.py` 调用栈内 == 0(沿用该测试 `_count_calls(..., from_engine=True)` 的写法)。 + - 性质测试:对 3 个虚构候选上下文,年龄从 0 到 120 岁、步长 0.01,`_active_narayana(…, antardasha_by_md=表)` 与 `antardasha_by_md=None` 结果全部相等;再加周期边界前后 ±1e-9 的点。 + - `candidate_feature_snapshot` 的 `feature_hash` 与基线同进程相等(证明没进 `feature`)。 + - Grok 自跑。 + +### T4 · 打分响应去掉无读取点字段(BUG-1325) + +- `scripts/rectification/api_service.py::score_candidates(request, *, include_internal: bool = False)`:默认不放 `event_contribution_matrix` 和 `candidate_decision_receipt`;`include_internal=True` 时与基线逐字节一致。用函数参数,**不要**加成请求字段(加成请求字段会进 `fingerprint` / `result_id`)。 +- `diagnostics()` 同样不再返回 `candidate_decision_receipt`(前端 `runV9Diagnostics` 只读 `decision_receipt`)。 +- `block_scan` 本来就不返回这两个键,不动。 +- 需要矩阵的 Python 调用方显式传 `include_internal=True`。至少包括 `scripts/research/midnight_date_anchor_regression.py`;开工时用 grep 找全,每处写进 PROGRESS。 +- `tests/test_rectification_v5_services.py`:约 415 行 `assertIn("event_contribution_matrix", scored)`、约 493 / 595 行读 `candidate_decision_receipt`,按三栏改:默认断言 `assertNotIn`,另加一条 `include_internal=True` 时仍在;读回执的改读 `decision_receipt`(同一个对象)。`tests/test_rectification_v5_vedastro_validation.py` 约 301 行自造结果测服务端容错,保留。 +- 验收: + - 新测试:121 候选默认响应不含两键;`include_internal=True` 的响应剔除计时字段后与基线逐字节相等;默认响应等于开关响应去掉这两个键。 + - 响应大小(紧凑 JSON)记进 PROGRESS,目标 ≤ 基线的 50%(实测预期 1.91 MB → 0.91 MB)。 + - `frontend/tests/rectification-v9-engine-contract.test.ts`、`rectification-v9-status-security.test.ts` 不受影响(Claude 复验)。 + +### T5 · 差分与耗时脚本(交付物,T1–T4 都要用) + +- 新增 `scripts/research/rect_scoring_perf_20261013.py`,内置 §2 夹具,两个子命令: + - `bench`:对 21 / 31 / 121 候选与 `block_scan` 全天步长 10 各跑 5 次取中位数,输出 JSON(窗口、候选数、中位耗时、分段耗时、响应字节数)。分段耗时只包 §2.2 那几段,计次器不得包 `_context_time` 这类热点小函数(包了会严重拖慢、失真)。 + - `dump`:把 §6 场景矩阵的规范化输出(`sort_keys=True`、`ensure_ascii=True`、剔除 `column_compare_ms`、`include_internal=True`)逐场景写成文件,并打印各自的 sha256。 +- 场景矩阵(至少): + 1. 12:00–12:20; + 2. 11:45–12:15; + 3. 11:00–13:00; + 4. 跨午夜 23:20–00:40; + 5. 11:30–12:30 + `declined_domains: ["relationship","career"]`; + 6. 11:30–12:30 + `refresh_probes: true` + `column_times: ["11:40","12:10","12:20"]`; + 7. 10:00–13:00 `minute_step: 2`; + 8. `block_scan` 00:00–23:59 步长 10; + 9. `block_scan` 10:00–14:00 步长 5; + 10. 记忆化测试的 `public_score_request()`; + 11. `diagnostics` 用场景 3 的请求。 +- 跨版本比较: + - 一次性工作树检出基线:`git worktree add --detach ../rsp-base 77057a819`; + - 两边都用 `PYTHONHASHSEED=0` 跑 `dump`; + - 11 个场景 sha256 全部相等; + - 输出写进 PROGRESS 表格。 +- 同进程差分由 T1–T3 的测试承担(基线函数副本 + 缓存键置 `None`)。 +- 跑完删除基线工作树。脚本只读,不写仓库以外的状态。 + +### T6 · 按 ERR-110 重新冻结(必须,否则不合入) + +改了冻结身份文件,`tests/test_rectification_validation_integrity_gate.py`(快速门)必红。照 `docs/tasks/PROGRESS-ephemeris-nutation-20261008.md`「ERR-110」节的做法,标签用 `scoring_perf_2026_10_13`: + +1. 所有代码改动(T1–T4)落定后才开始;之后若再改冻结文件,必须重做本步。 +2. 冻结与重跑: + - `python scripts/research/sealed_holdout_rerun.py --freeze --freeze-path docs/research/sealed_holdout_rerun_scoring_perf_2026_10_13.freeze.json`,报告写到 `docs/research/sealed_holdout_rerun_scoring_perf_2026_10_13.json`(按脚本现有参数,必要时把模块常量 `FREEZE` / `REPORT` 指到新文件); + - `python scripts/research/reported_offset_sweep.py --freeze --freeze-path docs/research/reported_offset_scoring_perf_2026_10_13.freeze.json`,报告写到 `docs/research/reported_offset_scoring_perf_2026_10_13.json`(900 例,上次约 15 分钟)。 +3. **正确性核对**(本单最强的端到端证据): + - 新旧两份报告的 `trials`(规范化 JSON)与 `ephemeris_nutation_2026_10_08` 两份报告**逐字节相等**; + - `metrics` / `summary` 相等; + - `historical_comparison.changed_trial_count` 分别仍是 8 和 581。 + - 有任何一条不等,说明输出变了:停下回报,不得调参,不得推送。 +4. 更新指针: + - `scripts/research/sealed_holdout_rerun.py` 与 `reported_offset_sweep.py` 的 `FREEZE` / `REPORT` 常量; + - `references/rectification_sealed_holdout.v1.json` 的当前树指针,把 nutation 放进 `previous_*`; + - `frontend/tests/rectification-confirmation-gate.test.ts` 的冻结路径; + - `tests/test_sealed_holdout_contract_freshness.py::IDENTITY_FORBIDDEN_PREFIXES` 加两条新前缀。 + - 每一处写三栏,原因写「BUG-1323~1325 改了冻结身份文件,输出不变,ERR-110 另存新记录」。 +5. nutation 四份文件一个字节不改。 +- 验收: + - `pytest tests/test_rectification_validation_integrity_gate.py tests/test_sealed_holdout_contract_freshness.py tests/test_reported_offset_research.py tests/test_rectification_confirmation_and.py -q` 0 failed; + - 前端 `rectification-confirmation-gate.test.ts` 0 fail / 0 cancelled; + - 第 3 条的逐字节核对结果贴进 PROGRESS。 + - Grok 跑;Claude 验收时复跑第 3 条比较。 + +### T7 · 记录 + +- `docs/BUG_HISTORY.md` 新增 **BUG-1323**(引导窗口平方扫描)、**BUG-1324**(候选不变量仍在事件循环:功能吉凶 / 那罗延 AD / 过渡邻近;关联 BUG-721,写明旧条款为何没拦住:新代码没照做、测试只用 3~5 个候选)、**BUG-1325**(打分响应带无读取点的内部矩阵与重复回执;关联 BUG-728)。防复发写进「计数断言随候选数线性」与「新响应字段先找读取点」。 +- `docs/research/pre_work_error_ledger.md`:在 ERR-110 下追加本次另存标签。 +- `docs/tasks/PROGRESS-rect-scoring-perf-20261013.md`:改前 / 改后耗时表(5 次中位数,注明机器与负载)、11 场景 sha256 对照、计数表、三栏清单、冻结核对结果。 +- `CHANGELOG.md`:一句「校正每轮重算更快(2 小时窗口约快一倍以上),结果不变」。Skill 版本不 bump。 +- `frontend/DESIGN.md`:不改(无 UI 变化)。 + +## 7. 目标数字(同一台机器、同一次运行里 A/B 交替各 5 次取中位数) + +| 指标 | 基线(本机实测) | 目标 | +| --- | --- | --- | +| 121 候选 `score_candidates` | 3.8–4.8 s | ≤ 基线中位数的 **45%**(本机约 ≤ 2.0 s) | +| 21 候选 | 0.45–0.5 s | 不慢于基线 +5% | +| `guided_collect_windows`(121) | ≈1.9–2.3 s | ≤ 基线的 25% | +| `merge_transition_proximity`(121) | 0.8–1.0 s | ≤ 基线的 35% | +| `block_scan` 全天步长 10 | 2.35–2.6 s | 不慢于基线;预期快约 25% | +| 121 候选响应(默认) | 1.91 MB | ≤ 0.96 MB | +| 正确性 | — | 11 场景 sha256 全等;冻结重跑 trials 全等 | + +耗时目标没达到但正确性全过:照样交付,在 PROGRESS 里写实测与原因,由 Claude 判断。**正确性有一条不过:不交付。** + +## 8. 让步顺序 + +时间不够时,从后往前砍: + +1. T3 的那罗延 AD 表(收益约 0.3–0.4 s),写进 PROGRESS「未做」; +2. T3 的功能吉凶; +3. T4。 + +**T1、T2、T5、T6、T7 不可砍。** T6 必须在最终代码上做:砍掉任何一项后要重做 T6。 + +## 9. 验收命令分工 + +| 谁 | 命令 | +| --- | --- | +| Grok 自跑(工作树 `.venv`) | `python -m pytest tests/test_rect_scoring_perf_20261013.py tests/test_rectification_engine_memoization.py tests/test_event_probes_guided_windows.py tests/test_dasha_transition_proximity_cross_midnight.py tests/test_rectification_v5_services.py tests/test_rectification_v5_vedastro_validation.py tests/test_rectification_validation_integrity_gate.py tests/test_sealed_holdout_contract_freshness.py tests/test_reported_offset_research.py tests/test_rectification_confirmation_and.py tests/test_api_server_growth_contract.py -q`;`python -m pytest tests/test_rectification_*.py -q`(BUG-747:改回执路径必须跑全量校正测试);T5 `bench` 与跨版本 `dump` 比较;T6 冻结与逐字节核对;前端单文件 `rectification-confirmation-gate.test.ts`。前端全量、Python 快速门**不跑**,以 Claude 为准 | +| Claude 验收 | 上述全部 + `.venv/bin/python scripts/run_quality_gate.py --profile quick` + Python 全量 pytest 与基线逐名比对(feedback:引擎路径改动不能只看快速门)+ 前端全量 `npm test`(看 fail 与 cancelled)+ 独立跑 T5 跨版本 11 场景 sha256 + 复核 T6 trials 逐字节 + `tests/test_repo_privacy_markers.py` | +| 不需要 | `npm run test:db`(不动数据库);`next build` 首屏口径(不动前端源码) | + +## 10. 本单不做(另议) + +- 服务端打分候选数上限:直接调接口可以传 00:00–23:59、步长 1,算出 1440 个候选,占重计算槽数分钟。要不要设上限需要拍板。 +- 探针路径 `_active_vimshottari(birth_date, …)` 用请求日期而不是候选日期,与打分路径口径不同;本单不碰,只记录。 +- `candidate_feature_snapshot` 417 KB 只读 `features[*]` 里三个字段,瘦身要动前端契约,另开单。 +- `guided_collect_windows` 与 `merge_transition_proximity` 共用每候选的大运起点表(还能省约 0.3–0.5 s)。 + +## 11. 开工前置命令 + +```bash +git fetch origin --prune +git worktree add -b codex/rect-scoring-perf-20261013 .worktrees/rect-scoring-perf-20261013 origin/staging +cd .worktrees/rect-scoring-perf-20261013 +git log -1 --format=%h # 应为 77057a819 或其后代 +git diff --stat 77057a819 origin/staging -- scripts/rectification scripts/active_rectification_event_engine.py scripts/functional_benefics.py scripts/narayana_dasha.py docs/research references/rectification_sealed_holdout.v1.json +# ↑ 若非空:有人改过冻结文件或冻结记录,先回报,§2 数字与 T6 的对照基准要重新取 +grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -1 # 核对最大号,写作时为 BUG-1317 +grep -n "PRODUCTION_FILES" -A15 scripts/research/sealed_holdout_rerun.py +grep -rn "event_contribution_matrix\|candidate_decision_receipt" tests scripts frontend/tests frontend/src +``` + +## 12. BUG 编号起点 + +BUG-1323(引导窗口)、BUG-1324(不变量)、BUG-1325(响应)。开工核对最大号;若已被占用,顺延并在 PROGRESS 写明。 + +## 13. 交付物清单 + +- 代码: + - `scripts/rectification/event_probes.py` + - `scripts/rectification/dasha_transition_proximity.py` + - `scripts/active_rectification_event_engine.py` + - `scripts/rectification/api_service.py` + - 读矩阵的研究脚本(显式开关) +- 脚本与测试: + - `scripts/research/rect_scoring_perf_20261013.py` + - `tests/test_rect_scoring_perf_20261013.py` + - `tests/_baseline_rect_scoring_20261013.py` + - 记忆化测试扩展 +- 冻结:`docs/research/{sealed_holdout_rerun,reported_offset}_scoring_perf_2026_10_13{.freeze,}.json` 与 §6 T6 的指针更新 +- 记录: + - `docs/BUG_HISTORY.md`(BUG-1323~1325) + - `docs/tasks/PROGRESS-rect-scoring-perf-20261013.md` + - `CHANGELOG.md` + - `docs/research/pre_work_error_ledger.md`(ERR-110 追加) +- 不改:`frontend/DESIGN.md`、迁移、workflow、`jyotish_api_server.py` diff --git a/docs/tasks/TASK-report-reference-perf-20261013.md b/docs/tasks/TASK-report-reference-perf-20261013.md new file mode 100644 index 00000000..4b78ac45 --- /dev/null +++ b/docs/tasks/TASK-report-reference-perf-20261013.md @@ -0,0 +1,245 @@ +# TASK · 长报告引擎出稿提速(中文渲染、哈希、同盘复用)(issue #待定) + +- 日期:2026-10-13 +- 来源:「2026-10-13 全仓屎山与性能排查」P1 条目「长报告提速」(`/api/professional_report_reference`) +- 执行方:**Grok(Windows + Docker)**,分支 `codex/report-reference-perf-20261013`,只推这个分支 +- 验收:Claude(Linux) +- BUG 编号:**BUG-1335~1336**(开工时核对 `docs/BUG_HISTORY.md` 最大号;写作时最大号 BUG-1317。若已被占用,顺延并在 PROGRESS 写明)(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起) +- 开工前置:无(第一批立即派发) +- **主会话补充的串行链(2026-10-14)**:引擎/API 文件链按此顺序逐单合入后开工(`upstream-sync7` 也改 `jyotish_engine.py` / `jyotish_api_server.py`,若在途,后合一方 rebase):`api-disk-cache-retention` + `report-reference-perf` → `api-chart-subprocess-gate` → `pl9-render-split` → `python-hygiene` → `dasha-unify` → `api-server-service-extract`。本单的前置以这条链为准(与上一行不一致时以本行为准)。 +- 下游依赖:`TASK-pl9-render-split-20261013` 必须等本单合入 staging 才开工(两单都改 `scripts/jyotish_engine.py`) + +## 1. 基线 + +- 基线 commit:`origin/staging` `77057a819` +- 生产调用链(唯一的生产 HTTP 调用方):`frontend/src/lib/personal-report-longform-generate.ts::fetchLongformMarkdown` 以 `{ include_fact_tables: true, edition: "full_data", languages: ["zh", "en"], format: "markdown" }` 调 `/api/professional_report_reference`(前端超时 `ENGINE_TIMEOUT_MS = 180_000`)→ `scripts/jyotish_api_server.py` 薄注册 → `scripts/professional_report_reference.py::build_professional_report_reference`: + 1. `handler._high_rigor_birth_payload(body)` → `handler._compute_full_reading_for_thematic(birth)`(`cmd_full_reading`) + 2. `jyotish_engine.build_professional_report_reference_packet`(建包、`sanitize_professional_report_reference`、`bind_result_to_profile`、`full_report_quality_gate.evaluate_full_report(delivered, render_pl9_markdown(delivered))`、`shared_full_report_authority`) + 3. `_english_edition`:`copy.deepcopy(packet)` → `pl9_full_data_export.render_full_data_markdown`(英文) + 4. `render_full_data_markdown(packet)`(中文) + 5. `report_density_packet(packet)`、`render_dasha_applicability(packet)` +- 该路径受 `JYOTISH_HEAVY_COMPUTE_CONCURRENCY`(默认 2)限流:一份报告占一个重计算名额 15~19 秒。 + +## 2. 事故实证(`77057a819`,Linux 本机,虚构盘 1990-01-01 12:00 新德里 28.6139/77.2090 +5.5,`today=2026-10-13`) + +### 2.1 端到端(进程内直接调 `build_professional_report_reference`,请求体同生产) + +| 条件 | 3 次 | 中位数 | +| --- | --- | --- | +| 装 PyJHora(开发机) | 18.99 / 18.38 / 18.38 s | 18.4 s | +| **屏蔽 PyJHora(=生产镜像依赖)** | 17.89 / 18.59 / 16.94 s | **17.9 s** | + +- 其中 `cmd_full_reading` 只占 0.8~2.1 s;三次输出 `sha256` 全同(中文 markdown `03f4d31d…`,英文 `d52c0f69…`,整份响应同)——**同输入同输出,但每次都从头算**。 +- 响应(服务端 `json.dumps(ensure_ascii=False)` 口径)2.24 MB:中文 markdown 43 万字符、英文 85 万字符;进程峰值 RSS 约 129 MB。 + +### 2.2 分段(屏蔽 PyJHora,进程内单独计时,2 次) + +| 段 | 耗时 | +| --- | --- | +| `render_full_data_markdown` **中文** | **8.03 / 7.77 s** | +| `render_full_data_markdown` 英文 | 1.02 / 0.96 s | +| `build_professional_report_reference_packet`(含 sanitize、哈希、质量门) | 2.7~3.5 s | +| `hash_bound_result`(一次请求共 6 次调用) | 合计 1.3~1.7 s;交付包单次 0.575 s | +| `sanitize_professional_report_reference`(递归 29 万次) | 1.1~1.25 s | +| `_english_edition` 里的 `copy.deepcopy(packet)` | 0.15~0.19 s | +| `evaluate_full_report` + 其中 `render_pl9_markdown` | 0.4~0.5 s(渲染本身 0.05 s) | + +cProfile(屏蔽 PyJHora,有探针开销,只看比例):中文侧 `_sanitize_pl9_ai_density_markdown` 被调 2 次(结构化附录一次、正文一次),每次内部 `normalize_zh_markdown_terms` 跑 3 遍(`pl9_full_data_export.py` 中 `_sanitize_pl9_ai_density_markdown` 内三处调用),整份中文渲染共 6 遍全文术语替换;热点: + +| 热点 | 次数 | 说明 | +| --- | --- | --- | +| `pl9_language_terms.py::_thicken_zh_dasha_density` | 6 遍 × 每行 2 次 `re.search` | 模式以 `(.+?)` 开头且无锚点,长表格行上逐位置回溯;每行还先拼一次 5 行 lookahead 字符串。约 5.5 万次 search,占中文渲染最大头 | +| `pl9_language_terms.py::replace_natural_language_term` | 327 次 × 每段 1 次 `sub` | 每次 `re.compile` + 按反引号切分全文 + 逐段替换,绝大多数术语在全文里根本不存在 | +| `pl9_reader_export.py::_strip_pl9_user_engineering_markers` | 5 次 | 约 190 次整篇 `re.sub` | +| `_sanitize_pl9_ai_density_markdown` 自身 | 2 次 | 78 次整篇 `re.sub` | +| `calculation_profile_contract.py::_drop_key_recursive` | 300 万次 | `excluded_paths` 里 4 条 `**.xxx` 各做一次整包递归复制,再 `canonicalize_result_payload` 第 5 次遍历 | + +### 2.3 原型(只在 scratchpad 里 monkeypatch,未改仓库) + +- 只加两处「字面量预检」:`_thicken_zh_dasha_density` 全文没有「主大运中的」「次子运,会在」就跳过逐行正则;`replace_natural_language_term` 在 `raw not in text` 时原样返回。**端到端 13.65 s → 10.28 / 9.95 s(−26%),中英 markdown 与整份响应逐字节一致。** +- 哈希单遍投影(一次遍历同时去掉 4 个 `**` 键并规范化键名,`ai_and_audit.summary.elapsed_seconds` 仍按原 dotted 路径处理):交付包单次 0.575 s → 0.237 s,`result_hash` 与旧算法、与包内已有 `result_hash` 三者相等。 + +### 2.4 复现命令 + +```bash +# 屏蔽 PyJHora(模拟生产镜像;子进程也生效) +mkdir -p "$SCRATCH/nojhora/jhora" && echo 'raise ImportError("masked")' > "$SCRATCH/nojhora/jhora/__init__.py" +PYTHONPATH="$SCRATCH/nojhora" python - <<'EOF' +import time, json, hashlib +from scripts.jyotish_engine import cmd_full_reading +from scripts.professional_report_reference import build_professional_report_reference +B = {"year":1990,"month":1,"day":1,"hour":12,"minute":0,"second":0,"lat":28.6139,"lon":77.2090,"tz":5.5, + "node_mode":"mean","ayanamsa":"lahiri","today":"2026-10-13","transit_date":"2026-10-13", + "target_year":2026,"age":36,"birth_time_accuracy":"confirmed"} +class H: + def _high_rigor_birth_payload(self, b): return dict(B) + def _compute_full_reading_for_thematic(self, b): return cmd_full_reading(type("A", (), dict(b))()) +body = {"format":"markdown","edition":"full_data","include_fact_tables":True,"languages":["zh","en"]} +for _ in range(3): + t = time.perf_counter(); r = build_professional_report_reference(H(), body) + s = json.dumps(r, ensure_ascii=False, default=str) + print(round(time.perf_counter()-t, 2), len(s.encode()), hashlib.sha256(s.encode()).hexdigest()[:12]) +EOF +``` + +(Windows 下 `$SCRATCH` 用仓库外任意临时目录;跑完删掉工作树里生成的 `scratch/`。) + +## 3. 根因 + +1. **中文擦洗是「整篇文本 × 几百条规则 × 多遍」的累加**(BUG-1335):`_sanitize_pl9_ai_density_markdown`(451 行)与 `normalize_zh_markdown_terms` 历次补丁都以「再加一条全文替换」的方式追加,从未有性能预算;同一篇正文被同一套规则跑 6 遍,其中 `_thicken_zh_dasha_density` 的无锚 `(.+?)` 正则在长表格行上回溯,`replace_natural_language_term` 对不存在的术语也做编译、切分、替换。**不是回归,是从未设计。** +2. **溯源哈希重复遍历**(并入 BUG-1335):`result_payload_for_binding` 对每条 `**.key` 排除路径各做一次整包递归复制;一次请求哈希 6 次(中间态 1 次、交付包 1 次、质量门重算 1 次等)。BUG-693 / BUG-694 只规定了「覆盖什么、排除什么」,实现没考虑大小。 +3. **同盘同日同版本不复用**(BUG-1336):响应对相同输入逐字节确定,但没有任何结果缓存;任务重试(BUG-576 记录过 3 次重试都打到引擎)、同日重生成、英文版重拉都要重算 15~19 s 并占重计算名额。`scripts/consult_full_data_cache.py` 已有「按键单飞 + 落盘 + 两天清理」(BUG-1304),但只缓存咨询卡用的资料包,不覆盖本接口。 + +关联:BUG-693、BUG-694(哈希覆盖与排除集红线)、BUG-576(长报告引擎耗时与重试)、BUG-1300 / BUG-1304(重计算名额与单飞缓存)、BUG-610(`render_pl9_markdown` 围栏失败不得打断报告)。`docs/BUG_HISTORY.md` 检索 `_sanitize_pl9_ai_density_markdown`、`normalize_zh_markdown_terms`、`render_full_data_markdown`、`hash_bound_result` 无记录。 + +### 3.1 对排查结论的修正(执行方按本节,不按排查报告原文) + +| 排查报告原说法 | 实测修正 | +| --- | --- | +| 「术语替换合并为预编译大正则或一次扫描」 | **不做**。这些替换是有先后依赖的链(后一条吃前一条的输出,如「强est_第」「e特殊组合ly」两条就是修前一条的产物),合并成交替正则不保证逐字节一致。改为「字面量预检 + 预编译 + 惰性构造」,见 T2 | +| 「哈希改投影」 | **只许做等值投影**:哈希载荷与覆盖范围不变、`result_hash` 数值不变,只把多次遍历合成一次。任何减少覆盖的投影都违反 BUG-693,本单不做 | +| 「`_english_edition` 整包 deepcopy 1.9 s」 | 实测 0.15~0.19 s(1.9 s 是 cProfile 探针放大),**本单不改** | +| 「`hash_bound_result` 4.2 s」 | 墙钟合计 1.3~1.7 s(同上,探针放大);仍值得做,收益约 0.8~1 s | +| 「响应 1.8 MB、峰值 170 MB」 | 本机该虚构盘 2.24 MB、峰值 RSS 约 129 MB;以 A/B 实测为准 | + +## 4. 决策记录 + +- 2026-10-13 产品负责人同意按排查结论全部立单交 Grok 执行;本单为 P1,**必须做 A/B**(耗时 + 中英两版输出逐字节一致)。 +- 本单**不推翻**任何既有红线。BUG-693 / BUG-694 的覆盖范围与排除集(`ALLOWED_BINDING_EXCLUDED_TOP_KEYS`、`ALLOWED_BINDING_EXCLUDED_PATHS` 及其字面量合同测试)原样保留。 +- 观察项(**本单不做,需拍板**):生产 `full_data` + `markdown` 路径里,`build_professional_report_reference_packet` 算出的 `report_quality_gate` 与 `shared_full_report_authority`(含一次 `render_pl9_markdown`)不进入响应(中文/英文 markdown、`fact_table_packet`、`reader_dasha_applicability` 都不读它们),约 0.5 s 纯开销。跳过它等于不再在这条路径上跑质量门,属于验证边界变化,必须产品拍板后另开单。 + +## 5. 硬红线 + +1. **输出逐字节一致**:对同一输入,新代码的整份响应(`json.dumps(result, ensure_ascii=False, default=str)` 字节)、中文 `markdown`、`markdown_en`、`fact_table_packet`、`reader_dasha_applicability` 必须与基线逐字节相同;`format: "json"`、`edition: "reference"` 两条非生产路径同样不得变。差一个字节即未通过,不得以「只是空白」「只是顺序」放行。 +2. **`result_hash` 数值不变**:同一包新旧算法得出同一个 `result_hash`;`binding_scope` 回执内容不变;`full_report_quality_gate` 的 `provenance:result_binding` / `provenance:result_binding_scope` 结论不变。不得改 `ALLOWED_BINDING_EXCLUDED_*` 常量或其字面量测试。 +3. **不删、不合并、不重排任何一条替换规则**;不改任何文案。只允许:预编译、字面量预检(只对「每个匹配都必然包含」的大小写敏感字面量;带 `re.IGNORECASE` 或字面量不必然出现的模式不得预检)、惰性构造(例如 lookahead 只在命中时拼)、把重复的纯函数调用结果复用(必须证明输入相同)。每处预检旁写一行注释说明为何等价。 +4. **不减少擦洗遍数**,除非能在 PROGRESS 里给出逐条等价论证且 A/B 全语料逐字节一致——默认不做(见让步顺序)。 +5. 缓存只覆盖 `edition=full_data` + `format=markdown` 路径;其他组合(含 `format=json`,其包内有 `generated_at` 墙钟)一律不缓存。缓存命中返回的响应字节必须与未命中时相同。 +6. 缓存键必须包含影响输出的**全部**输入:`_high_rigor_birth_payload` 规范化后的整份 `birth`(含 `today`、`transit_date`、`candidate_range` 等全部字段)、`format`、`edition`、`packs`、`languages`、`include_fact_tables`、`REPORT_VERSION_FULL`、代码版本(`GITHUB_SHA`)。`GITHUB_SHA` 缺失或为 `unknown` 时缓存默认关闭。文件名只用哈希;缓存文件放 `scratch/local/` 下(容器内即 `api_scratch` 卷),与 `consult_full_data_cache` 同样两天清理;日志与 BUG 历史不得写入出生资料或缓存键原文。 +7. `scripts/jyotish_api_server.py` 净行数不增长、类方法数不增长、`JyotishAPIHandler.__new__` 伪造点不增长(`tests/test_api_server_growth_contract.py`);本单预期对该文件**零改动**(缓存放在 `professional_report_reference.py` 内)。 +8. 不改重计算名额逻辑(`scripts/api_heavy_compute_gate.py`)、不改 `.gitea/workflows/**`、不动表结构与迁移、不升级依赖、不顺手修 warning;`frontend/src/app/(app)/page.tsx` 不碰(本单无前端改动)。 +9. 改既有断言按「原值 / 新值 / 原因」三栏写进 PROGRESS;测试总数不得低于开工时。 +10. 新测试的输入包必须由真实引擎现算(虚构盘,沿用 `tests/test_report_english_edition.py::CASES` / `_packet`),不得手造包形状。 +11. 验收必须在**屏蔽 PyJHora** 的环境下至少跑一次(生产镜像不装 PyJHora,见 `requirements-reference-engines.txt`)。 + +## 6. 任务分解 + +### T1 · 开关与差分脚手架(先做,后面每步都靠它) + +- 在一个新模块 `scripts/report_perf_flags.py` 里放两个**调用时读取**环境变量的函数(不得在 import 时缓存值,否则同进程 A/B 切不了): + - `report_render_fast_enabled()` ← `JYOTISH_REPORT_RENDER_FAST`(T2、T3 的新代码路径) + - `report_reference_cache_enabled()` ← `JYOTISH_REPORT_REFERENCE_CACHE`(T4) + - 默认值按 T5 的 A/B 结论定(预期 FAST 默认开、CACHE 默认开但 `GITHUB_SHA` 未知时关);本轮**保留旧代码路径**作为开关关闭分支,旧分支的退役列入「后续」。 +- 新增 `scripts/research/report_reference_perf_ab.py`:同一进程内对每个输入先 A(两个开关关)后 B(开),各重复 3 次,输出 JSON:每次总耗时与分段耗时(`cmd_full_reading`、建包、中文渲染、英文渲染、哈希合计)、`tracemalloc` 峰值、响应字节数,以及五个输出字段各自的 `sha256`;A 与 B 任一字段 sha 不同即退出码 1 并打印第一处差异的上下文(不打印整篇)。 + - 输入:`tests/test_report_english_edition.py::CASES` 的 day / night / south 三张虚构盘 + 本单 §2 的 1990-01-01 新德里虚构盘;请求体同生产。另跑 `format=json`、`edition=reference`+`markdown` 各一张盘只比 sha(这两条有墙钟字段时,用同一个 `full_reading` 并把 `datetime.utcnow` 固定后比;做不到就在 PROGRESS 写明只比除 `generated_at` 外的字节,并说明原因)。 + - 脚本支持 `--mask-pyjhora`:在本进程装 import 拦截,并为子进程设置 `PYTHONPATH` 垫片(同 §2.4),保证年运回放子进程也拿不到 `jhora`。 +- 验收:脚本在基线代码上(两开关都只是空壳)A==B、退出码 0;Grok 自跑。 + +### T2 · 中文擦洗与术语替换的等值提速(BUG-1335 主体) + +改 `scripts/pl9_language_terms.py`、`scripts/pl9_full_data_export.py`、`scripts/pl9_reader_export.py`(只动下列函数): + +1. `_thicken_zh_dasha_density`:两个正则改模块级预编译;每行先做字面量预检(AD 句必含「主大运中的」「子运,会把」「的自然主题带入当前阶段。」;PD 句必含「次子运,会在」「背景下呈现」「的短周期结果。」)再 `search`;`lookahead` 只在预检命中后才拼。英文侧 `_thicken_en_dasha_density` 同法处理(若其模式结构相同)。 +2. `replace_natural_language_term`:`raw not in text` 时直接返回原 `text`(`split` + `join` 不改字节,故等价);按 `(raw, plural)` 缓存编译结果(`functools.lru_cache` 即可)。 +3. `_sanitize_pl9_ai_density_markdown`、`_strip_pl9_user_engineering_markers`、`_clean_pl9_user_table_placeholders`、`normalize_zh_markdown_terms` 内的整篇 `re.sub`:逐条检查,可证明等价的加字面量预检、改预编译;**不改顺序、不改模式、不改替换串**。按 cProfile 的累计耗时从大到小做,做到中文渲染满足 §T5 目标即可停。 +4. 上述新代码只在 `report_render_fast_enabled()` 为真时生效;关闭时走原代码(可以是同函数内 `if` 分支,或保留 `_xxx_legacy` 原函数)。 +- 不改:任何替换规则的内容与先后、`normalize_zh_markdown_terms` 的调用次数、英文擦洗的规则。 +- 验收: + - T1 脚本四张盘 A==B(中英全字段)、退出码 0。 + - 中文渲染(单独计时,屏蔽 PyJHora)中位数较 A 下降 ≥ 40%。 + - `python -m pytest tests/test_report_english_edition.py tests/test_report_full_data_fix.py tests/test_report_chart_blank_columns.py tests/test_reader_appendix_language.py tests/test_report_reader_main.py tests/test_full_data_domain_reader.py tests/test_report_longform_parity.py tests/test_report_longform_gaps2.py tests/test_report_density_facts.py -q` 全绿。 + - 新测试 `tests/test_report_reference_perf.py`:对 `CASES["day"]` 的包,开关开/关两次渲染中英 markdown 逐字节相等;对 `replace_natural_language_term`、`_thicken_zh_dasha_density` 各加「命中」「不命中」「反引号段内不替换」三种小样(输入为真实渲染结果中截取的行,不手造新句式)。 + - Grok 自跑 + Claude 验收复跑。 + +### T3 · 溯源哈希单遍化(并入 BUG-1335) + +改 `scripts/calculation_profile_contract.py::result_payload_for_binding`(及其私有辅助): + +- 开关开时:先按顶层排除键过滤,再按非 `**` 的 dotted 路径删除,然后**一次递归**同时完成「删除所有 `**.key` 指定的键」与 `canonicalize_result_payload` 的键规范化;`hash_bound_result` 用这份载荷做与原来完全相同的 `json.dumps(..., ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)` + sha256。 +- 删除 `**` 键的比较语义与原 `_drop_key_recursive` 相同(只比字符串键;非字符串键先保留、再按原规则规范化)。 +- 可选(只有 T1 脚本证明 `format=json` 响应逐字节不变时才做,否则不做并在 PROGRESS 说明):`build_professional_report_reference_packet` 中第一次 `attach_calculation_profile` 对中间态的绑定会被末尾 `bind_result_to_profile(delivered, profile)` 覆盖。**注意**:跳过它会改变交付包里 `result_hash` / `result_binding` 两个键的插入顺序,`format=json` 的字节会变,所以大概率不能做。 +- 不改:`ALLOWED_BINDING_EXCLUDED_TOP_KEYS`、`ALLOWED_BINDING_EXCLUDED_PATHS`、`RESULT_BINDING_ENVELOPE_KEYS`、`binding_scope` 回执、质量门的重算逻辑、BUG-693 要求的「绑定发生在返回对象上」。 +- 验收: + - 新测试(放 `tests/test_calculation_profile_contract.py` 末尾或新文件):在测试文件里保留一份旧算法的逐字副本作为参照,对真实引擎现算的交付包(`CASES["day"]`)、以及在包里人为插入多层 `elapsed_seconds` / `called_at` / 非字符串键的变体,新旧 `hash_bound_result` 相等,且等于包内 `result_hash`。 + - `tests/test_calculation_profile_contract.py`、`tests/test_full_report_quality_gate.py` 全绿;BUG-694 的字面量锁测试不改。 + - 交付包单次 `hash_bound_result` 中位数下降 ≥ 50%(原型 0.575 → 0.237 s)。 + +### T4 · 同盘同日同版本复用(BUG-1336) + +- `scripts/consult_full_data_cache.py`:把 `get_or_build` 的单飞与落盘泛化——加一个可选参数指定缓存子目录(默认仍是 `consult_full_data_cache`,咨询卡行为不变),或抽出同文件内的通用函数供两处调用。**不改**咨询卡的键、两天清理、预热限流(BUG-1304)。 +- `scripts/professional_report_reference.py::build_professional_report_reference`:仅当 `edition == full_data` 且 `format == markdown` 且 `report_reference_cache_enabled()` 时: + 1. 用 `handler._high_rigor_birth_payload(body)` 的结果 + §5 第 6 条所列字段算键(`json.dumps(sort_keys=True, ensure_ascii=False, default=str)` → sha256),在**调用 `_compute_full_reading_for_thematic` 之前**查缓存; + 2. 未命中时照旧计算,把整份响应以 `json.dumps(result, ensure_ascii=False, default=str)` 落盘(目录 `scratch/local/report_reference_cache/`); + 3. 命中时 `json.loads` 返回;同键并发只算一次(复用单飞);计算抛错不落盘、不缓存错误。 + 4. 只打一行不含资料的日志:`[report-reference-cache] hit|miss elapsed_ms=…`。 +- 不改:`jyotish_api_server.py`(零行)、重计算名额(命中仍走名额,持有时间降为毫秒级)。 +- 验收: + - 新测试:同一请求两次,第二次不调用 `_compute_full_reading_for_thematic`(用计数桩),两次响应 `json.dumps` 字节相同;改 `today`、`languages`、`include_fact_tables`、`candidate_range`、`GITHUB_SHA` 任一项都不命中;`format=json` 不落盘;开关关时不落盘;两个线程同键并发只构建 1 次;构建抛错时第二个调用会重新计算。 + - `tests/test_consult_card_full_source.py`、`tests/test_api_heavy_compute_gate.py`、`tests/test_api_server_growth_contract.py` 全绿。 + - 命中路径端到端(同进程、屏蔽 PyJHora)中位数 ≤ 1.5 s。 + +### T5 · A/B 验证与默认值(P1 必做) + +- A = 两开关关(等同 `origin/staging` 原代码);B = 两开关开。用 T1 脚本,四张虚构盘 × 每组 3 次取中位数,`--mask-pyjhora` 必跑一轮,另跑一轮不屏蔽作参照。 +- 度量:端到端(未命中 / 命中分开)、分段(中文渲染、英文渲染、建包、哈希合计)、`tracemalloc` 峰值、响应字节数。 +- 正确性:五个输出字段 + 整份响应逐字节一致(任一不一致即 B 不得默认开启)。 +- 判定门槛: + - `JYOTISH_REPORT_RENDER_FAST` 默认开 ⇔ 逐字节全一致 且 未命中端到端中位数降 ≥ 35%(参照原型:仅两处预检已 −26%)且 峰值内存不升 > 10%。 + - `JYOTISH_REPORT_REFERENCE_CACHE` 默认开 ⇔ 命中与未命中字节一致 且 命中中位数 ≤ 1.5 s;`GITHUB_SHA` 未知时一律关。 + - 不达标:开关默认关,代码保留,回报写「未达门槛」与差距,不得写通过。 +- 记录写 `docs/testing/report-reference-perf-ab-20261013.md`:机器(Windows / Linux)、Python 版本、是否屏蔽 PyJHora、每组 3 次原始数、中位数、sha 表。本单 A/B **不需要模型 key**。 +- Grok 在 Windows 跑一遍并提交记录;Claude 在 Linux 复跑,两边都要逐字节一致,耗时只比各自机器上的 A/B 相对值。 + +### T6 · 记录 + +- `docs/BUG_HISTORY.md`: + - **BUG-1335** 长报告中文渲染慢:同一篇正文跑 6 遍全文术语替换、无锚正则逐行回溯、不存在的术语也编译替换;溯源哈希每条排除路径一次整包复制。 + - **BUG-1336** 同盘同日同版本的长报告请求不复用,重试与重生成每次重算并占重计算名额。 + - 两条都写关联 BUG-693、BUG-694、BUG-576、BUG-1304;防复发写「T1 A/B 脚本 + 新测试」。 +- `docs/tasks/PROGRESS-report-reference-perf-20261013.md`:每步改动、A/B 摘要、改既有断言三栏表、未做项与原因。 +- `CHANGELOG.md`:一句话「个人长报告引擎出稿提速(同盘同日重试秒回)」,不 bump Skill 版本。 +- `frontend/DESIGN.md`:不改(无界面变化)。 + +## 7. 让步顺序(时间不够从后往前砍) + +1. 先保:T1 → T2 的第 1、2 项(原型已证实,最稳的 −26%)→ T5(A/B 记录)。 +2. 再做:T3 哈希单遍。 +3. 再做:T4 缓存。 +4. 最先砍:T2 第 3 项(其余整篇 `re.sub` 的逐条预检)、T3 的可选项。 +5. 任何一步 A/B 出现字节差异且一轮内查不清:回退该步,保留开关默认关,写进 PROGRESS 与回报,不得为了过 A/B 改测试或改输出。 + +## 8. 验收命令分工 + +| 谁 | 命令 | +| --- | --- | +| Grok 自跑(工作树 `.venv`) | `python -m pytest tests/test_report_reference_perf.py tests/test_calculation_profile_contract.py tests/test_full_report_quality_gate.py tests/test_report_english_edition.py tests/test_professional_report_reference_api.py tests/test_report_full_data_fix.py tests/test_report_chart_blank_columns.py tests/test_reader_appendix_language.py tests/test_report_reader_main.py tests/test_full_data_domain_reader.py tests/test_report_longform_parity.py tests/test_report_longform_gaps2.py tests/test_report_density_facts.py tests/test_consult_card_full_source.py tests/test_api_heavy_compute_gate.py tests/test_api_server_growth_contract.py tests/test_repo_privacy_markers.py -q`;`python scripts/research/report_reference_perf_ab.py --mask-pyjhora`(退出码 0)。Python 快速门、前端全量**不跑** | +| Claude 验收 | 上述全部 + `--profile quick` 快速门 + 全量 `pytest` 与基线逐名比对(本单不应改任何引擎数值,多出一条失败即未通过)+ 在 Linux 屏蔽 PyJHora 复跑 A/B + 用 §2.4 命令对比基线 sha | +| 不需要 | 前端 `tsc` / `lint` / `npm test`(无前端改动;若意外改了前端则必须跑);`test:db`(不动数据库) | + +## 9. 开工前置命令 + +```bash +git fetch origin --prune +git worktree add -b codex/report-reference-perf-20261013 .worktrees/report-reference-perf-20261013 origin/staging +cd .worktrees/report-reference-perf-20261013 +git log -1 --format=%h # 应为 77057a819 或其后代 +grep -n "^## BUG-" docs/BUG_HISTORY.md | tail -3 # 核对最大号(写作时 BUG-1317) +wc -l scripts/jyotish_api_server.py # 记下基线行数,交付时必须 ≤ 此数 +# 开工前先 grep 按源码文本断言的测试(AGENTS.md §7.8): +grep -rn "normalize_zh_markdown_terms\|_thicken_zh_dasha_density\|replace_natural_language_term\|_drop_key_recursive\|result_payload_for_binding\|get_or_build" tests +# 记录基线 sha(四张盘),作为 A 的外部对照: +python scripts/research/report_reference_perf_ab.py --mask-pyjhora --only-a # T1 完成后 +``` + +## 10. 交付物清单 + +- 代码:`scripts/report_perf_flags.py`(新)、`scripts/research/report_reference_perf_ab.py`(新)、`scripts/pl9_language_terms.py`、`scripts/pl9_full_data_export.py`、`scripts/pl9_reader_export.py`(仅擦洗函数)、`scripts/calculation_profile_contract.py`、`scripts/consult_full_data_cache.py`、`scripts/professional_report_reference.py`;`scripts/jyotish_api_server.py` 零改动。 +- 测试:`tests/test_report_reference_perf.py`(新)及 T3 / T4 所述用例。 +- 记录:`docs/tasks/PROGRESS-report-reference-perf-20261013.md`、`docs/testing/report-reference-perf-ab-20261013.md`、`docs/BUG_HISTORY.md`(BUG-1335、BUG-1336)、`CHANGELOG.md`。 +- 回报:分支 head SHA、A/B 中位数表、五字段 sha 对照、两个开关的默认值与依据、未做项。 + +## 11. 后续(本单不做) + +- 下一轮退役开关关闭分支(旧擦洗实现、旧哈希实现),只保留新路径。 +- 观察项(需拍板):`full_data` 路径跳过无人读取的质量门与 authority(约 0.5 s)。 +- 擦洗函数整体退役由 `TASK-pl9-render-split-20261013` 的后续阶段处理。 From 2994588ad4d858dead4835943e5883c5aa2b03f8 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 14 Oct 2026 00:41:02 +0800 Subject: [PATCH 2/5] docs(tasks): fold product decisions into audit briefs (BUG-1344..1346) - dasha-unify: fix Ashtottari birth balance (BUG-1344) - rect-scoring-perf: server-side candidate cap 241 (BUG-1345) - node-runtime-resources: enable user-side session cookie cache, 300 s (BUG-1346) - consult-prep-parallel: classifier/reserve concurrency declined - answer-effort-ab: model key supplied by product at acceptance - drop stricter-than-test line-count red lines (AGENTS.md section 6) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v --- docs/tasks/README.md | 6 +-- docs/tasks/TASK-answer-effort-ab-20261013.md | 1 + ...TASK-api-chart-subprocess-gate-20261013.md | 4 +- .../TASK-api-disk-cache-retention-20261013.md | 4 +- ...ASK-api-server-service-extract-20261013.md | 2 +- .../TASK-consult-prep-parallel-20261013.md | 4 +- docs/tasks/TASK-dasha-unify-20261013.md | 40 ++++++++++++------- .../TASK-node-runtime-resources-20261013.md | 28 ++++++++++--- docs/tasks/TASK-python-hygiene-20261013.md | 2 +- docs/tasks/TASK-rect-scoring-perf-20261013.md | 35 ++++++++++++---- .../TASK-report-reference-perf-20261013.md | 2 +- 11 files changed, 88 insertions(+), 40 deletions(-) diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 6ecd43f3..e9bb15bb 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -463,8 +463,8 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | -| `TASK-rect-scoring-perf-20261013.md` | — | 校正打分提速:引导窗口平方扫描、候选不变量重复算、响应带无读取点字段(BUG-1323~1325) | 待领取 | `codex/rect-scoring-perf-20261013` | -| `TASK-node-runtime-resources-20261013.md` | — | 报告 worker abort 监听泄漏、本地 PG 适配层往返与元数据缓存、无上限内存表(BUG-1326~1328,PG 部分 A/B) | 待领取 | `codex/node-runtime-resources-20261013` | +| `TASK-rect-scoring-perf-20261013.md` | — | 校正打分提速:引导窗口平方扫描、候选不变量重复算、响应带无读取点字段、候选数上限 241(BUG-1323~1325、1345) | 待领取 | `codex/rect-scoring-perf-20261013` | +| `TASK-node-runtime-resources-20261013.md` | — | 报告 worker abort 监听泄漏、本地 PG 适配层往返与元数据缓存、无上限内存表、用户端会话读缓存(BUG-1326~1328、1346,PG 与缓存部分 A/B) | 待领取 | `codex/node-runtime-resources-20261013` | | `TASK-home-sidebar-rerender-20261013.md` | — | 流式回答时侧栏每帧整栏重渲染(BUG-1329,A/B) | 待领取 | `codex/home-sidebar-rerender-20261013` | | `TASK-api-disk-cache-retention-20261013.md` | — | API 磁盘缓存只进不出、VedAstro 审计文件留存出生资料、失败快照久缓存、缓存键规范化(BUG-1330~1332,A/B) | 待领取 | `codex/api-disk-cache-retention-20261013` | | `TASK-report-reference-perf-20261013.md` | — | 长报告出稿提速:术语替换命中预检、哈希单次遍历、同盘复用(BUG-1335~1336,A/B) | 待领取 | `codex/report-reference-perf-20261013` | @@ -475,7 +475,7 @@ | `TASK-consult-route-split-20261013.md` | — | consult POST 拆分第一阶段:开流前加载 + 计费结算 | 待领取(排队:consult-prep-parallel、answer-effort-ab) | `codex/consult-route-split-20261013` | | `TASK-pl9-render-split-20261013.md` | — | render_pl9_markdown 第一阶段:搬出无依赖内部函数 | 待领取(排队:api-chart-subprocess-gate) | `codex/pl9-render-split-20261013` | | `TASK-python-hygiene-20261013.md` | — | 免费层排队开关报反、静默吞异常、功能吉凶兜底合一、测试专用脚本归位(BUG-1340) | 待领取(排队:pl9-render-split) | `codex/python-hygiene-20261013` | -| `TASK-dasha-unify-20261013.md` | — | /api/dasha 改走引擎同一套实现、删占位注册表(BUG-1341) | 待领取(排队:python-hygiene) | `codex/dasha-unify-20261013` | +| `TASK-dasha-unify-20261013.md` | — | /api/dasha 改走引擎同一套实现、删占位注册表、Ashtottari 出生余额(BUG-1341、1344) | 待领取(排队:python-hygiene) | `codex/dasha-unify-20261013` | | `TASK-api-server-service-extract-20261013.md` | — | API 单体第一阶段:拆出不带 HTTP 的计算类,生产伪造 handler 归零 | 待领取(排队:dasha-unify) | `codex/api-server-service-extract-20261013` | | `TASK-data-layer-types-20261013.md` | — | 数据访问层改用自己的 DataClient 类型第一阶段(BUG-1343) | 待领取(排队:node-runtime-resources) | `codex/data-layer-types-20261013` | | `TASK-contracts-helpers-dedupe-20261013.md` | — | 前后端契约与小工具去重第一阶段 | 待领取(排队:frontend-dead-code、consult-route-split) | `codex/contracts-helpers-dedupe-20261013` | diff --git a/docs/tasks/TASK-answer-effort-ab-20261013.md b/docs/tasks/TASK-answer-effort-ab-20261013.md index 76389a02..abf80ebd 100644 --- a/docs/tasks/TASK-answer-effort-ab-20261013.md +++ b/docs/tasks/TASK-answer-effort-ab-20261013.md @@ -48,6 +48,7 @@ ## 4. 决策记录 +- **产品负责人 2026-10-14:模型 key 在本单交付验收时由产品临时提供给 Claude**。Grok 无 key 时只交付开关、请求体拦截测试与 A/B 脚本,T0 第 2 步与 T2 的真实运行由 Claude 验收时执行;回报里写「A/B 未跑:等产品提供 key」,不得写通过。 - 产品授权:2026-10-13 产品负责人同意按排查结论全部立单交 Grok 执行,并**已同意做答题步推理强度 A/B**;默认值等 A/B 结论交产品拍板。 - 本单默认行为**不变**:开关不设时,请求体与 `77057a819` 逐字节相同。 - `consultationSliceGenerationSettings` 的嵌套形状与它的两条断言**本单不改**(没有调用方,改它不影响线上;是否删除或修正交产品,写进 PROGRESS「需拍板」)。 diff --git a/docs/tasks/TASK-api-chart-subprocess-gate-20261013.md b/docs/tasks/TASK-api-chart-subprocess-gate-20261013.md index f4ec6fcd..77ef54eb 100644 --- a/docs/tasks/TASK-api-chart-subprocess-gate-20261013.md +++ b/docs/tasks/TASK-api-chart-subprocess-gate-20261013.md @@ -96,7 +96,7 @@ sys.meta_path.insert(0, _Block()) ## 5. 硬红线 -1. `scripts/jyotish_api_server.py` **净行数不得增长**(`tests/test_api_server_growth_contract.py`);不新增 `JyotishAPIHandler` 类方法。闸门的判定逻辑(含 BUG-1302 的 `consult_card_warm_only` 判断)可以搬进 `scripts/api_heavy_compute_gate.py`,主文件调用点保持一行。 +1. `scripts/jyotish_api_server.py`:行数只按 `tests/test_api_server_growth_contract.py` 的粗护栏(AGENTS.md §6:不另加比测试更严的行数红线,也不得压行凑数),类方法数与 `JyotishAPIHandler.__new__` 伪造点不得增长。闸门的判定逻辑(含 BUG-1302 的 `consult_card_warm_only` 判断)可以搬进 `scripts/api_heavy_compute_gate.py`,主文件调用点保持一行。 2. `frontend/src/app/(app)/page.tsx` 不碰;不写第二套请求封装。 3. 不改 `deploy/**`、`.gitea/workflows/**`、数据库结构与迁移;不升级依赖。 4. 不改引擎数值。所有改动在「模拟生产依赖」与「装了 PyJHora」两种环境下,报告 Markdown(中英)与 `fact_table_packet` **逐字节一致**(去掉生成时间戳等已知易变字段,易变字段清单写进差分脚本并在 PROGRESS 列出)。 @@ -128,7 +128,7 @@ sys.meta_path.insert(0, _Block()) ### T3 · `/api/chart` 不带 skip 时占重计算名额(开关,默认关)(BUG-1333) - `scripts/api_heavy_compute_gate.py` 新增 `acquire_heavy_compute_slot_for_request(path, body)`(或同义名):现有按路径的逻辑原样保留;另加:`path == "/api/chart"` 且 body 不带真值 `skip_vedastro_main_entry_overview` 且 body 不带 `async`/`enqueue` 且开关 `JYOTISH_GATE_CHART_OVERVIEW=1` 时,占一个名额。BUG-1302 的 `consult_card_warm_only` 判断一并搬进来。 -- `jyotish_api_server.py` 调用点改成这一个函数,**净行数 ≤ 0**。 +- `jyotish_api_server.py` 调用点改成这一个函数(新逻辑放新模块,主文件只留调用)。 - `HEAVY_COMPUTE_PATHS` 不加 `/api/chart`。 - 验收:新测试——开关关时任何 `/api/chart` 都不占名额;开关开时只有不带 skip 的同步请求占名额,带 skip 的、`async` 的不占;`test_warm_consultation_does_not_consume_the_user_slot` 等既有测试不改仍绿。 - 分工:Grok 自跑;Claude 复验。 diff --git a/docs/tasks/TASK-api-disk-cache-retention-20261013.md b/docs/tasks/TASK-api-disk-cache-retention-20261013.md index 461759c0..d6e1164f 100644 --- a/docs/tasks/TASK-api-disk-cache-retention-20261013.md +++ b/docs/tasks/TASK-api-disk-cache-retention-20261013.md @@ -101,7 +101,7 @@ sys.meta_path.insert(0, _Block()) ## 5. 硬红线 -1. `scripts/jyotish_api_server.py` **净行数不得增长**(`tests/test_api_server_growth_contract.py`,当前 10,970 行)。新逻辑全部进新模块(建议 `scripts/api_scratch_retention.py`),主文件只做薄调用;`prune_expired_async_jobs` 的函数体可以整体搬进新模块,主文件留同名薄包装以保住 `tests/test_api_async_job_contract.py` 的调用入口。 +1. `scripts/jyotish_api_server.py`:行数只按 `tests/test_api_server_growth_contract.py` 的粗护栏(AGENTS.md §6:不另加比测试更严的行数红线,也不得压行凑数),类方法数与 `JyotishAPIHandler.__new__` 伪造点不得增长。新逻辑全部进新模块(建议 `scripts/api_scratch_retention.py`),主文件只做薄调用;`prune_expired_async_jobs` 的函数体可以整体搬进新模块,主文件留同名薄包装以保住 `tests/test_api_async_job_contract.py` 的调用入口。 2. 不新增 `JyotishAPIHandler` 类方法;不新增端点。 3. 不改 `deploy/**`、`.gitea/workflows/**`、数据库结构与迁移。 4. 不改任何引擎数值、盘面结果、证据取舍;chart 接口在规范化前后**响应逐字节一致**(`runtime_cache` 字段除外)。 @@ -118,7 +118,7 @@ sys.meta_path.insert(0, _Block()) - 一张登记表,每项 = 目录 + 判定「可删」的规则 + 是否含出生资料。至少登记:`api_chart_cache`(`created_at + chart TTL` 已过)、`high_rigor_jobs` 与 `api_chart_response_jobs`(`expires_at_unix` 已过,即现有逻辑)、T2/T4 的各目录。 - `prune_all(now=None) -> dict`:逐目录返回 `scanned / removed / errors / elapsed_ms`,只计数不打印文件名。 - 节流的周期清理:`start_periodic_prune(interval_s)`,后台守护线程,默认间隔 `JYOTISH_SCRATCH_PRUNE_INTERVAL_SECONDS=1800`,`0` 关闭周期清理(启动清理仍跑)。 -- `jyotish_api_server.py`:`start_server` 里把 `prune_expired_async_jobs()` 换成新模块的启动清理 + 启动周期线程(保持打印一行汇总的行为);`prune_expired_async_jobs` 函数体搬走,留薄包装。**净行数 ≤ 0**。 +- `jyotish_api_server.py`:`start_server` 里把 `prune_expired_async_jobs()` 换成新模块的启动清理 + 启动周期线程(保持打印一行汇总的行为);`prune_expired_async_jobs` 函数体搬走,留薄包装。新逻辑放新模块,主文件只留调用。 - chart 缓存新写入的文件改 0600、改为临时文件 + `os.replace` 原子写(与 `_write_async_job_record` 一致)。 - 不改:TTL 默认值、读缓存逻辑、`runtime_cache` 字段。 - 验收标准: diff --git a/docs/tasks/TASK-api-server-service-extract-20261013.md b/docs/tasks/TASK-api-server-service-extract-20261013.md index 49493bed..cffd7d03 100644 --- a/docs/tasks/TASK-api-server-service-extract-20261013.md +++ b/docs/tasks/TASK-api-server-service-extract-20261013.md @@ -94,7 +94,7 @@ 1. **纯移动**:方法体逐字不改;只允许把 15 个 HTTP 侧方法与 `server_version` 从原类剪到新 HTTP 子类、改类声明、调整两个调用方的构造语句。用 `git diff --color-moved=dimmed-zebra` 与 AST 方法体对比(第 6 节 T2)证明。 2. `tests/test_api_server_security.py` 一个字节不改且全绿。 -3. `scripts/jyotish_api_server.py` 净行数不得增长:拆类新增约 3 行(新类声明 + PEP 8 空行),**用同文件真实死代码抵消**——写作时全仓按名零引用的模块级函数有 `_strict_adjudication_bundle_from_contract`(16 行)与 `_high_rigor_job_path`(2 行),开工重核(含 `skills/*/versions/**` 以外的全部 `.py` / `.ts`、字符串引用、`getattr`),确认无引用才删;不得用合并语句、删注释凑行数。 +3. `scripts/jyotish_api_server.py`:行数只按 `tests/test_api_server_growth_contract.py` 的粗护栏(AGENTS.md §6,开工实测基线 + 300);拆类新增的类声明与空行不需要抵消,**不得**为凑行数删代码、合并语句或删注释。顺带发现的零引用函数(写作时 `_strict_adjudication_bundle_from_contract`、`_high_rigor_job_path`)只记进 PROGRESS,不在本单删。 4. 类方法数(合同口径)不得增长;`JyotishAPIHandler.__new__` 计数只许降。 5. 不新增端点、不改任何端点请求 / 响应合同;HTTP 路由、安全检查、限流 429、异常映射行为不变。 6. 新计算类不得继承 `BaseHTTPRequestHandler`,其方法不得引用 HTTP 成员(加 AST 测试钉住)。 diff --git a/docs/tasks/TASK-consult-prep-parallel-20261013.md b/docs/tasks/TASK-consult-prep-parallel-20261013.md index 4648a39c..8fe4c8c6 100644 --- a/docs/tasks/TASK-consult-prep-parallel-20261013.md +++ b/docs/tasks/TASK-consult-prep-parallel-20261013.md @@ -99,7 +99,7 @@ 2. 输入审核:**仍在任何模型调用和预留之前生效**,但不再挡首帧——开流后与资料读取并行,在 `reserve` 之前等它的结论;被拦时仍不扣点、不进模型;报错优先级与今天一致(见 T2)。不推翻 BUG-1074 的开流前清单(审核本来就不在那张清单里)。 3. `readVersion` 缓存:做,TTL 与目录缓存相同(15 s),不缓存失败和 `null`,管理端模型/供应商变更时清除。不延长 TTL。 4. **报告章节并发:本单不做**。目标函数已退役(§2.4),改了不会让任何用户的报告变快;排查报告里这一条作废。若产品以后重新启用 writer 管线,另立单。 -5. **分类器与预留并发:本单不做,需拍板。** 它会让零点用户的问题在预留失败前先送进模型(违反 BUG-976 边界「reserve 仍在分类前」),且分类调用产生供应商成本。可选折中「预留成功后,分类与 `append_consultation_question` 并行;append 失败则丢弃分类结果并取消」也涉及计费顺序,同样留待拍板。 +5. **分类器与预留并发:产品负责人 2026-10-14 决定不做。** 它会让零点用户的问题在预留失败前先送进模型(违反 BUG-976 边界「reserve 仍在分类前」),且分类调用产生供应商成本。可选折中「预留成功后,分类与 `append_consultation_question` 并行;append 失败则丢弃分类结果并取消」也涉及计费顺序,同样留待拍板。 6. 多领域并发:做成开关,**默认关**(串行),A/B 达标才改默认。不改领域上限、不改时钟。 7. 不推翻任何既有红线。 @@ -110,7 +110,7 @@ 3. 删号冻结(423)必须在任何扣点、写库之前判定(`account-deletion.test.tsx` 的 `if (frozen) return frozen;`)。 4. `readVersion` 缓存:只缓存成功解析的结果;TTL ≤ 15 s;`invalidateLanguageModelCatalog()` 同时清它;不缓存 DNS 失败或非公网地址的结果;不改 `assertAllowedModelProviderUrl` 与 `requestAllowedModelProvider`。 5. 多领域并发开关打开时:`executions` 顺序必须等于计划顺序(不得按完成先后);任一领域失败的处理与串行一致(整次计算失败、错误码不变);429 不得被吞或改码;`MAX_CONSULTATION_DOMAINS`、`domainFitsRunBudget` 的公式与常量不变。 -6. 不动表结构、不加迁移;不改 `scripts/jyotish_api_server.py`(净行数不得增长,`tests/test_api_server_growth_contract.py`);不碰 `frontend/src/app/(app)/page.tsx`;不改 `.gitea/workflows/**`;不升级依赖;不顺手修 warning。 +6. 不动表结构、不加迁移;不改 `scripts/jyotish_api_server.py`;不碰 `frontend/src/app/(app)/page.tsx`;不改 `.gitea/workflows/**`;不升级依赖;不顺手修 warning。 7. 改既有断言写「原值 / 新值 / 原因」三栏;不得为让测试过而弱化断言。开工先 grep(AGENTS.md §7.8): ```bash grep -rn "await moderate(visibleQuestion\|await resolveSessionLanguageModel\|return await readVersion\|refuseWhenAccountDeletionPending\|const runTurn = async\|for (let index = 0; index < domains.length" frontend/tests tests diff --git a/docs/tasks/TASK-dasha-unify-20261013.md b/docs/tasks/TASK-dasha-unify-20261013.md index ff87f0bd..e579111a 100644 --- a/docs/tasks/TASK-dasha-unify-20261013.md +++ b/docs/tasks/TASK-dasha-unify-20261013.md @@ -4,7 +4,7 @@ - 来源:2026-10-13 全仓屎山与性能排查 ·「大运多套实现」条目(`scripts/*dasha*.py` 28 个、两套条件大运、`_birth_lord_and_balance` 多份、Vimshottari 年数表多处硬编码) - 执行方:**Grok(Windows + Docker)**,分支 `codex/dasha-unify-20261013`,只推这个分支 - 验收:Claude(Linux) -- BUG 编号:**BUG-1341**(开工时核对 `docs/BUG_HISTORY.md` 最大号;写作时最大号 BUG-1317;号段由主会话统一分配)(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起) +- BUG 编号:**BUG-1341、BUG-1344**(开工时核对 `docs/BUG_HISTORY.md` 最大号;写作时最大号 BUG-1317;号段由主会话统一分配)(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起) - **开工前置**:`api-chart-subprocess-gate` 已合入 `origin/staging`(它改 `jyotish_api_server.py` 与排盘路径)。与 `python-hygiene`(BUG-1340)同为第二批,两单都改 `scripts/jyotish_api_server.py` 与 `scripts/jyotish_engine.py`:**本单在 `python-hygiene` 合入后开工**,若主会话决定并行,则本单只碰第 6 节列出的函数,合入时由后合的一方 rebase。 - **主会话补充的串行链(2026-10-14)**:引擎/API 文件链按此顺序逐单合入后开工(`upstream-sync7` 也改 `jyotish_engine.py` / `jyotish_api_server.py`,若在途,后合一方 rebase):`api-disk-cache-retention` + `report-reference-perf` → `api-chart-subprocess-gate` → `pl9-render-split` → `python-hygiene` → `dasha-unify` → `api-server-service-extract`。本单的前置以这条链为准(与上一行不一致时以本行为准)。 - **后继**:`api-server-service-extract` 依赖本单合入。 @@ -80,7 +80,7 @@ 其中 **`dasha_analyzer.py`、`divisional_charts_extended.py`、`narayana_dasha.py` 在校正冻结身份里**(`references/real_case_calibration/minute_rectification_holdout_v3.json` `frozen_scoring.files`;`tests/test_sealed_holdout_contract_freshness.py` 与 `tests/test_rectification_validation_integrity_gate.py` 按字节哈希核对,ERR-110 / ERR-114)。改一个字节就要按研究协议重新冻结。 -### 2.6 附带发现:全量解读的 Ashtottari 没有出生余额(**本单不修,需拍板**) +### 2.6 附带发现:全量解读的 Ashtottari 没有出生余额(**产品 10-14 决定:修,见 T6,BUG-1344**) 同一虚构盘: @@ -90,7 +90,7 @@ | PyJHora `get_ashtottari_dhasa_bhukthi` | Jupiter 1983-10-07 起(出生时已走约 6.2 年)、Rahu **2002-10-07** | | `nakshatra_dasha.calc_ashtottari_dasha`(全量解读 `modules.nakshatra_dasha` 内) | 起运星 **Mars**(与前两者都不同) | -原因(代码可见):`ashtottari_dasha.py` 只有在拿到 `moon_longitude` 且适用规则族是「Rahu 从命主」时才算出生余额;`cmd_full_reading` 传的是 `birth_info_for_alt_dasha`(没有 `moon_longitude`),所以第一段从出生满额起算,后续每段都差约 6 年。改它会改报告里 Ashtottari 表的全部日期,且 Ashtottari 规则族的选择牵涉占星口径,**本单只把实证写进 BUG-1341 的「附带发现」并在回报里列为需拍板**,不改。 +原因(代码可见):`ashtottari_dasha.py` 只有在拿到 `moon_longitude` 且适用规则族是「Rahu 从命主」时才算出生余额;`cmd_full_reading` 传的是 `birth_info_for_alt_dasha`(没有 `moon_longitude`),所以第一段从出生满额起算,后续每段都差约 6 年。改它会改报告里 Ashtottari 表的全部日期。主会话补充核实(`77057a819`):即便拿到 `moon_longitude`,`_build_major_periods(start_lord_idx, birth_dt)` 仍从出生时刻按满额排第一段,`_calc_candidate_balance_and_birth_chain` 的结果只作附加字段、不平移时间线;默认规则族(月宿 + 月相)路径根本不算余额;「当前大运」也按「第一段从出生开始」推算。**产品 10-14 决定修复**(T6)。 ## 3. 根因 @@ -107,15 +107,15 @@ - **`/api/chart` 的 `available_dashas` / `dasha_count` 本单保持逐字节不变**(元数据搬到新注册表,内容不改),避免牵动排盘 golden 与排盘缓存;与 `/api/dasha` 能力不一致的问题列入「后续阶段」,需拍板是否裁剪。 - `conditional_dashas.py`、`extended_dashas.py` 删除。`tests/run_all.py`(release 门)里对应断言按三栏改。 - 冻结身份内文件(`dasha_analyzer.py`、`divisional_charts_extended.py`、`narayana_dasha.py`、`domain_calculation_service.py` 等)**本单一个字节不改**;它们的年数表留到下次有理由重新冻结时一起收。 -- 2.6 Ashtottari 余额:本单不修,需拍板。 -- 推翻的既有约束:BUG-1284「本单不得改 `jyotish_api_server.py` 的方法体」只是那一单的范围限制,不是长期红线;本单授权改 `_compute_dasha_system` / `_fallback_dasha_periods` / `_compute_chart_sync` 里读注册表的三行,净行数不增。 +- **2.6 Ashtottari 出生余额:产品负责人 2026-10-14 决定修复**(T6,BUG-1344)。这是本单唯一允许改变引擎全量解读输出的项:只许 `modules.ashtottari_dasha`(及 T6 列出的同源 Ashtottari 输出)变化,其余逐字节不变。适用性规则(哪些盘适用 Ashtottari、两种规则族的判定)**不改**,只修「已判定适用时的时间线」。 +- 推翻的既有约束:BUG-1284「本单不得改 `jyotish_api_server.py` 的方法体」只是那一单的范围限制,不是长期红线;本单授权改 `_compute_dasha_system` / `_fallback_dasha_periods` / `_compute_chart_sync` 里读注册表的三行;行数按增长合同粗护栏。 ## 5. 硬红线 -1. **引擎全量解读输出逐字节不变**(`modules.*_dasha` 全部)。本单只改接口路径与去重;若 T3 / T4 去重让任何数值或字段顺序变化,即为搬错,改代码不改断言。 +1. **引擎全量解读输出逐字节不变**(`modules.*_dasha` 全部;唯一例外是 T6 的 Ashtottari 时间线)。本单只改接口路径与去重;若 T3 / T4 去重让任何数值或字段顺序变化,即为搬错,改代码不改断言。 2. 冻结身份两份清单内的文件一个字节不改(开工打印清单,第 9 节)。 3. 不改 `narayana_dasha.py`、`narayana_rath.py`、`narayana_legacy_label.py`;不改 BUG-1215 / 1218 的展示与打分口径。 -4. `scripts/jyotish_api_server.py` 净行数不得增长;类方法数不得增长(删 `_fallback_dasha_periods` 后应下降 1,合同测试基线同步下调并写三栏);`JyotishAPIHandler.__new__` 计数不得增长。 +4. `scripts/jyotish_api_server.py`:行数只按 `tests/test_api_server_growth_contract.py` 的粗护栏(AGENTS.md §6:不另加比测试更严的行数红线,也不得压行凑数),类方法数与 `JyotishAPIHandler.__new__` 伪造点不得增长;类方法数(删 `_fallback_dasha_periods` 后应下降 1,合同测试基线同步下调并写三栏);`JyotishAPIHandler.__new__` 计数不得增长。 5. `/api/chart` 响应逐字节不变(`available_dashas` 内容与顺序不变)。 6. 新注册模块不得 import `jyotish_api_server`;不得在运行时 import `jhora`(PyJHora 只在测试里作对照,且测试需在缺 PyJHora 时 skip,镜像不装它——见记忆「验收要屏蔽 PyJHora 模拟生产」)。 7. 不改 `.gitea/workflows/**`、不动表结构与迁移、不升级依赖。 @@ -179,16 +179,27 @@ - 新测试(只许降):`scripts/` 根目录非冻结文件里 Vimshottari 年数字面量计数 == 本单收尾值(冻结文件列白名单)。 - **全量解读逐字节不变**:基线工作树与本分支各跑一次虚构盘 `python scripts/jyotish_engine.py full-reading …`(参数见 `--help`),`PYTHONHASHSEED=0`,去掉墙钟字段后 `cmp`。 +### T6 · Ashtottari 出生余额(BUG-1344,必做,产品 10-14 决定) + +- 改 `scripts/ashtottari_dasha.py::calculate_ashtottari_dasha`:已判定适用时,按月亮在起运星所辖星宿组内已走过的比例算出生余额,**第一段主运起点 = 出生时刻 − 已走过部分**,后续各段与次级周期、`current` 推算都随之平移。两种规则族都要算余额(默认规则族现在完全不算)。 +- `scripts/jyotish_engine.py::cmd_full_reading` 调用 `calculate_ashtottari_dasha` 时补传 `moon_longitude`(`conditional_birth_info` 已有该值,照它传;不得改 `birth_info_for_alt_dasha` 本身,那会牵动 Yogini / Kalachakra)。`scripts/orchestrator_bridge.py` 等其余调用方开工 grep 全,同样补传。 +- 余额算法以 PyJHora `get_ashtottari_dhasa_bhukthi` 的语义为对照(只对照结果,不复制其代码,注意许可证边界,AGENTS.md §B1)。 +- 全量解读里第二套 `nakshatra_dasha.calc_ashtottari_dasha`(起运星 Mars,与前两者都不同):开工查清它在报告 / 咨询 / 前端哪里被展示。若用户可见,改为直接复用 `ashtottari_dasha.py` 的结果;若无人读取,只在 PROGRESS 记录,留后续阶段。 +- 验收: + - 新测试:至少 3 张盘(第 1 节虚构盘 + 2 张公开名人盘,覆盖两种规则族各至少 1 张;PyJHora 缺失时 skip 对照部分,但保留与固化 golden 的比对)主运起止日期与 PyJHora 相差 ≤ 1 天,起运星一致;出生时刻落在第一段内;`current` 与按日期查表一致。 + - golden:受影响的报告 / 全量解读 golden 只能用真实引擎重新采集,PROGRESS 列出每个变化文件与变化只限 Ashtottari 段(其余段 diff 为空)。 + - Claude 验收跑全量 pytest 与基线逐名比对(引擎数值改动,见验收分工)。 + ### T5 · 记录(必做) -- `docs/BUG_HISTORY.md` 新增 **BUG-1341**:`/api/dasha` 非 Vimshottari 体系是占位实现(不读盘、年数自编、标成 `calculator`);修复为走引擎同一套模块;关联 BUG-1262、BUG-1284(把 1284 状态从 mitigated 更新为 resolved 并注明本单)、BUG-1215 / 1218(Narayana 未裁定)。「附带发现」写 2.6 Ashtottari 余额(状态 `investigating`,需拍板)。 +- `docs/BUG_HISTORY.md` 新增 **BUG-1341**:`/api/dasha` 非 Vimshottari 体系是占位实现(不读盘、年数自编、标成 `calculator`);修复为走引擎同一套模块;关联 BUG-1262、BUG-1284(把 1284 状态从 mitigated 更新为 resolved 并注明本单)、BUG-1215 / 1218(Narayana 未裁定)。另新增 **BUG-1344**:全量解读 Ashtottari 无出生余额、时间线整体偏移(修复 T6,状态按验收证据写)。 - `docs/tasks/PROGRESS-dasha-unify-20261013.md`:T1 基线红 / 修后绿输出、映射表最终版、`/api/chart` 与全量解读两次逐字节对照、T4 每处替换清单。 -- `CHANGELOG.md`:一条(`/api/dasha` 合同变化:未实现体系 400、`precision` 取值变化)。Skill 版本不 bump。 +- `CHANGELOG.md`:两条(`/api/dasha` 合同变化:未实现体系 400、`precision` 取值变化;报告里 Ashtottari 大运日期修正为含出生余额)。Skill 版本不 bump。 - `frontend/DESIGN.md`:不改。 ## 7. 让步顺序 -时间不够按此顺序砍:**T4 年数表部分 → T4 余额函数部分**。T1、T2、T3、T5 不可砍(T2 是 BUG-1341 本体)。 +时间不够按此顺序砍:**T4 年数表部分 → T4 余额函数部分**。T1、T2、T3、T5、T6 不可砍(T2 是 BUG-1341 本体,T6 是产品 10-14 决定)。 ## 8. 验收命令分工 @@ -233,8 +244,7 @@ BUG-1341(写作时最大号 BUG-1317)。开工核对,被占用则顺延并 ## 后续阶段(本单不做) -1. **2.6 Ashtottari 出生余额与两套 Ashtottari 起运星不一致**(需拍板:改报告 Ashtottari 表全部日期)。 -2. `available_dashas` 裁剪到真实可算的体系(改 `/api/chart` 合同与 golden,需拍板)。 -3. 冻结身份内 `dasha_analyzer.py` / `divisional_charts_extended.py` 的年数表,在下次因其他理由重新冻结时一起收。 -4. Narayana 旧算法 vs Rath 版(BUG-1215 / 1218,等产品看 T5 试算)。 -5. `dasha_calculator.py`、`dasha_calculator_enhanced.py`、`nakshatra_dasha.calc_ashtottari_dasha` 等其余重复实现的归并。 +1. `available_dashas` 裁剪到真实可算的体系(改 `/api/chart` 合同与 golden,需拍板)。 +2. 冻结身份内 `dasha_analyzer.py` / `divisional_charts_extended.py` 的年数表,在下次因其他理由重新冻结时一起收。 +3. Narayana 旧算法 vs Rath 版(BUG-1215 / 1218,等产品看 T5 试算)。 +4. `dasha_calculator.py`、`dasha_calculator_enhanced.py`、`nakshatra_dasha.calc_ashtottari_dasha` 等其余重复实现的归并。 diff --git a/docs/tasks/TASK-node-runtime-resources-20261013.md b/docs/tasks/TASK-node-runtime-resources-20261013.md index 021d9313..a6960532 100644 --- a/docs/tasks/TASK-node-runtime-resources-20261013.md +++ b/docs/tasks/TASK-node-runtime-resources-20261013.md @@ -4,10 +4,11 @@ - 来源:「2026-10-13 全仓屎山与性能排查」——P0「报告 worker 空转泄漏」、P1「数据库适配层往返」、小项「服务端无上限 Map」 - 执行方:**Grok(Windows + Docker)**,分支 `codex/node-runtime-resources-20261013`,只推这个分支 - 验收:Claude(Linux) -- BUG 编号:**BUG-1326~1328**(开工时核对 `docs/BUG_HISTORY.md` 最大号;写作时最大号 BUG-1317;号段由主会话分配)(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起) +- BUG 编号:**BUG-1326~1328、BUG-1346**(开工时核对 `docs/BUG_HISTORY.md` 最大号;写作时最大号 BUG-1317;号段由主会话分配)(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起) - BUG-1326:报告 worker 空转每 2 秒挂一个 abort 监听,永不释放(P0) - BUG-1327:本地 Postgres 适配层每条查询 6 次往返、元数据每次现查(P1,需 A/B) - BUG-1328:三张服务端内存表只增不减(小) + - BUG-1346:用户端开启 Better Auth 会话读缓存(产品 10-14 决定,maxAge 300 s,敏感操作绕过) - 开工前置:**无**(第一批立即派发) ## 1. 基线 @@ -91,7 +92,7 @@ node --expose-gc leak-fixed.mjs fixed # 到点即摘监听 - 2026-10-13 产品负责人同意按排查结论全部立单交 Grok 执行(本单含 P0 + P1 + 小项)。 - 产品追加要求:P1 改动必须 A/B 并做成开关,默认值按 A/B 结论定——本单 T2 照做;T1、T3 是缺陷修复且行为等价(只回收已无作用的条目 / 监听),不做开关。 - **只读无身份调用省掉事务:评估结论为不做**(理由见 T2「评估」),不是让步项。 -- **Better Auth `cookieCache`(会话读缓存):本单不做,需拍板。** 它会改变「改密码 / 注销 / 封号后旧会话多久失效」的登录行为,属于产品与安全决策,不是性能改动;排查报告的数字留作拍板依据。 +- **Better Auth `cookieCache`(会话读缓存):产品负责人 2026-10-14 决定开启**(T6,BUG-1346)。代价已向产品说明:改密码 / 封号后,其它设备上的旧登录最多再有效 `maxAge` 这么久。主会话评估取 **`maxAge` = 300 秒**(Better Auth 默认值,与本仓 `twoFactorCookieMaxAge: 300` 同量级),且敏感操作强制绕过缓存(见 T6)。 - 不推翻任何既有红线。 ## 5. 硬红线 @@ -183,16 +184,32 @@ node --expose-gc leak-fixed.mjs fixed # 到点即摘监听 - 记录写 `docs/testing/node-runtime-resources-ab-20261013.md`:环境(Windows + Docker Desktop 版本、Node 版本)、三组原始数字、中位数表、差分结果、默认值结论。 - Claude 验收:读 A/B 记录、复跑不需 Docker 的往返计数单测;真库数字以 Grok 为准(本机无 Docker,记环境缺口)。 +### T6 · 用户端开启会话读缓存(BUG-1346,产品 10-14 决定,开关 + A/B) + +- 现状:`frontend/src/modules/identity/auth-factory.ts` 用户端配置没有 `session.cookieCache`,每个带登录的请求 `getSession` 都查一次库(`readIdentitySession` / `createServerSupabaseClient` 路径)。 +- 改法: + - 只对**用户端**(`cookiePrefix: "jyotisha-user"` 那份配置)加 `session.cookieCache = { enabled, maxAge: 300 }`;**后台(admin 端)配置不加**。 + - 开关:服务端环境变量(例如 `IDENTITY_SESSION_COOKIE_CACHE`,名字执行方定,登记进 deploy 示例),A/B 后定默认值。 + - **敏感操作必须绕过缓存**(每次真查库):改密码、注销账户 / 撤销注销、改邮箱、两步验证设置、任何后台接口、`account-deletion` 相关路由。开工 grep `getSession(`、`readIdentitySession`、`requireIdentityServerSession` 的全部调用点,列表进 PROGRESS;敏感点用 Better Auth 的 `disableCookieCache` 查询参数(以当前锁定版本的文档 / 类型为准,先确认参数名)。 + - 账户删除检查(consult 等路由里的注销 RPC)与后台 `banned` 授权检查(`auth.ts` 查 `banned` / `ban_expires`)保持现状,不得因缓存跳过。 +- 不改:会话有效期、`revokeSessionsOnPasswordReset`、Cookie 属性(`secure` / `httpOnly` / `sameSite`)、表结构。 +- 验收: + - 新测试:开关开时,同一会话连续 3 次普通请求只查库 1 次;敏感路由每次都查库;开关关时与现在完全一致。 + - 新测试:撤销会话(模拟改密码)后,敏感路由立刻 401;普通路由在 `maxAge` 内仍可用、超过后 401(用假时钟)。 + - A/B 并入 T4:同一组带登录的请求序列,比较「每请求查库次数」与耗时中位数;门槛:查库次数下降且耗时不升 → 默认开;否则默认关并在 PROGRESS 写原因。 + - Grok 跑 `test:db` 两种开关各一次,按已知失败清单比对。 + - `docs/testing/node-runtime-resources-20261013.md` 写真机步骤:两台设备登录 → 一台改密码 → 另一台 5 分钟内仍可浏览、5 分钟后被要求重新登录、期间无法执行改密码 / 注销等敏感操作。 + ### T5 · 记录 -- `docs/BUG_HISTORY.md` 新增 BUG-1326、1327、1328(1327 状态按 A/B 结论写;部署后的内存曲线未观测前 1326 写 `resolved(代码+回归测试),生产内存曲线待部署后观察`)。 +- `docs/BUG_HISTORY.md` 新增 BUG-1326、1327、1328、1346(1346 写明产品 10-14 决定与「最多 300 秒」的取舍;1327 状态按 A/B 结论写;部署后的内存曲线未观测前 1326 写 `resolved(代码+回归测试),生产内存曲线待部署后观察`)。 - `docs/tasks/PROGRESS-node-runtime-resources-20261013.md`:含 T2c 评估、T3 语义变化说明、基线跑新测试的失败输出、A/B 结论。 - `deploy/README.md` 或 `.env` 示例里登记两个开关(只写名字与默认值,不写任何密钥);若 compose 文件需要透传环境变量,改 `deploy/docker-compose.server.yml` 前先在回报里说明(不改 workflow)。 - `CHANGELOG.md`:一句「服务器长期运行不再逐日涨内存;数据库访问少走几趟」。Skill 版本不 bump。`frontend/DESIGN.md` 不改。 ## 7. 让步顺序 -T1(P0,必须交)→ T3 → T2a → T2b → T4。时间不够时:先砍 T2b(保留开关骨架与单测也可以不交),再砍 T2a;T2 一旦交付就必须带 T4 的 A/B 与 test:db 比对,否则开关默认 `off`。T1 不得因任何原因延后。 +T1(P0,必须交)→ T3 → T6 → T2a → T2b → T4。T6 是产品决定,不得砍。时间不够时:先砍 T2b(保留开关骨架与单测也可以不交),再砍 T2a;T2 一旦交付就必须带 T4 的 A/B 与 test:db 比对,否则开关默认 `off`。T1 不得因任何原因延后。 ## 8. 验收命令分工 @@ -223,10 +240,9 @@ cat tools/agent-loop/windows-known-failures.txt ## 11. 本单不做 -- Better Auth `cookieCache`(登录行为改动,需产品拍板)。 - 只读无身份调用省事务(T2c 已论证不安全)。 - 把 `begin` 拼进多语句简单查询(需要手工转义字面量,收益一次往返,不值得冒注入风险)。 -- `createServerSupabaseClient` 每次调用都读一次身份会话(`readIdentitySession`)的往返——与 cookieCache 同属登录链,一起拍板。 +- `createServerSupabaseClient` 每次调用都读一次身份会话的往返:T6 开缓存后自然减少,不另做。 ## 附录 · 2.1 复现脚本(`leak-fixed.mjs`,与源码同形,可放任意临时目录) diff --git a/docs/tasks/TASK-python-hygiene-20261013.md b/docs/tasks/TASK-python-hygiene-20261013.md index d18f977d..ea68bebe 100644 --- a/docs/tasks/TASK-python-hygiene-20261013.md +++ b/docs/tasks/TASK-python-hygiene-20261013.md @@ -131,7 +131,7 @@ EOF ## 5. 硬红线 -1. `scripts/jyotish_api_server.py` 净行数不得增长;`tests/test_api_server_growth_contract.py` 的类方法数、`JyotishAPIHandler.__new__` 计数不得增长(本单 T2 预期方法数不变、行数下降)。 +1. `scripts/jyotish_api_server.py`:行数只按增长合同的粗护栏(AGENTS.md §6),`tests/test_api_server_growth_contract.py` 的类方法数、`JyotishAPIHandler.__new__` 计数不得增长(本单 T2 预期方法数不变、行数下降)。 2. **校正冻结身份内的文件一个字节都不许改、不许搬**(ERR-110 / ERR-114):开工先打印两份清单(第 9 节命令),本单任何改动命中清单即停下回报。 3. 不改 `.gitea/workflows/**`、`deploy/gated-paths.txt`、`pyproject.toml` 的打包配置、`deploy/*.Dockerfile`。 4. 日志**不得包含出生资料、坐标、姓名、请求体、异常消息原文**:异常消息可能带输入值(例如 `strptime` 报错会带出生日期字符串)。WARNING 级只写「模块.函数 + 异常类名」;完整 `exc_info` 只允许在 DEBUG 级。 diff --git a/docs/tasks/TASK-rect-scoring-perf-20261013.md b/docs/tasks/TASK-rect-scoring-perf-20261013.md index d82e0c67..e855edf0 100644 --- a/docs/tasks/TASK-rect-scoring-perf-20261013.md +++ b/docs/tasks/TASK-rect-scoring-perf-20261013.md @@ -4,7 +4,7 @@ - 来源:「2026-10-13 全仓屎山与性能排查」· 校正打分 `POST /api/rectification/v5/score` 慢与响应过大(P0,确定性计算提速) - 执行方:**Grok(Windows + Docker)**,分支 `codex/rect-scoring-perf-20261013`,只推这个分支 - 验收:Claude(Linux) -- BUG 编号:**BUG-1323~BUG-1325**(开工时核对 `docs/BUG_HISTORY.md` 最大号,写作时最大号 BUG-1317)(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起) +- BUG 编号:**BUG-1323~BUG-1325、BUG-1345**(开工时核对 `docs/BUG_HISTORY.md` 最大号,写作时最大号 BUG-1317)(另:`TASK-upstream-sync7-20261014` 占 BUG-1318~1322,本批号段整体从 BUG-1323 起) - 开工前置:无(第一批立即派发) - 性质:**输出逐字节不变**的性能改动。算法身份保持 `rectification-v5-matrix-scoring-12`、策略 `rectification-candidate-policy-v4`,不升版本、不写新 golden。 @@ -100,6 +100,7 @@ EVENTS = [ # id 依次 00000000-0000-4000-8000-00000000000{1..8},summary 统 - BUG-721:记忆化只用请求内显式传递的 context 或局部字典,**禁止模块级 `lru_cache` / 全局 dict**(即使键只是上升星座也不行)。本单照此执行。 - BUG-733 / BUG-747:不得对浮点整体 `==` 比较 golden,不得调用 `write_golden()`。记忆化 golden v5 文件一个字节都不改。 - ERR-110 / BUG-1047:`event_probes.py`、`dasha_transition_proximity.py`、`scoring_service.py`、`api_service.py`、`refinement_packet.py`、`functional_benefics.py` 在 `scripts/research/sealed_holdout_rerun.py::PRODUCTION_FILES` 里;`active_rectification_event_engine.py`、`narayana_dasha.py` 在 dataset `frozen_scoring.files`(12 个遗留文件)里。**输出不变也必须按研究协议重新冻结**(§6 T6),否则不得合入。BUG-1047 当时就是因为没做这一步,同类优化半途放弃。 +- **产品 2026-10-14 决定:打分接口设候选数上限**,数值由 Claude 评估定为 241(T4b 表格)。 - T4 默认不返回两个字段,是执行 BUG-728 的既有规则,不是新决定。Python 内部调用方要拿矩阵就显式传参。HTTP 接口不加开关(前台没有读取点)。 ## 5. 硬红线 @@ -109,7 +110,7 @@ EVENTS = [ # id 依次 00000000-0000-4000-8000-00000000000{1..8},summary 统 3. 新的缓存只放进请求内的静态上下文**顶层**(与 `narayana_periods` 同级),**不得**放进 `feature` 负载(会改 `fingerprints` 与 `feature_hash`)。禁止模块级缓存(BUG-721)。 4. 不修改 `narayana_dasha.py`、`functional_benefics.py`、`dasha_analyzer.py` 的任何函数(共享模块,报告和咨询也在用)。 5. `scripts/jyotish_api_server.py` 不改(`_attach_vedastro_minute_sensitive_to_score` 已对缺键容错);`tests/test_api_server_growth_contract.py` 必须绿。 -6. 不碰前端业务代码与 `frontend/src/app/(app)/page.tsx`;不改 `.gitea/workflows/**`;不动表结构和迁移;不升级依赖;不顺手修别的 warning。 +6. 不碰前端业务代码(唯一例外:T4b 在 `engine-client.ts` 错误映射里认出 `too_many_candidates`)与 `frontend/src/app/(app)/page.tsx`;不改 `.gitea/workflows/**`;不动表结构和迁移;不升级依赖;不顺手修别的 warning。 7. 改既有断言按 AGENTS §7.3 写「原值 / 新值 / 原因」三栏;开工先 grep:`grep -rn "event_contribution_matrix\|candidate_decision_receipt" tests scripts frontend/tests frontend/src`。 8. 冻结重跑只能**另存**新记录,不得覆盖 `ephemeris_nutation_2026_10_08` 的四份文件,也不得改历史归档(`test_historical_artifacts_are_byte_preserved_not_refreshed`)。 9. 跨进程比较一律固定 `PYTHONHASHSEED=0`(ERR-111:Shadbala 指纹随哈希种子变);同进程比较不受影响。 @@ -177,6 +178,26 @@ EVENTS = [ # id 依次 00000000-0000-4000-8000-00000000000{1..8},summary 统 - 响应大小(紧凑 JSON)记进 PROGRESS,目标 ≤ 基线的 50%(实测预期 1.91 MB → 0.91 MB)。 - `frontend/tests/rectification-v9-engine-contract.test.ts`、`rectification-v9-status-security.test.ts` 不受影响(Claude 复验)。 +### T4b · 服务端候选数上限(BUG-1345,产品 10-14 决定) + +**上限定为每次请求 241 个候选**(主会话评估,产品已授权设上限): + +| 依据 | 数字 | +| --- | --- | +| 产品流程单次打分最多 | 分钟阶段 121(跨度 ≤120 分钟、步长 1);`block_scan` 全天步长 10 = 144,其余档位(2/5 步长)≤ 91 | +| 上限 241 的来历 | 等于前端已有常量 `search-window.ts::WIDEN_MAX_INCLUSIVE_MINUTES`(4 小时、每分钟一个),是产品最大用量的约 1.7 倍,留给以后放宽窗口的余量 | +| 上限下的最坏耗时 | 基线 241 候选 10.5 s;T1–T3 后预计约 4 s,不超过现在一次正常 121 候选打分 | +| 现在不设上限的最坏情况 | 直接调接口传全天步长 1 = 1,440 个候选,占重计算槽数分钟 | + +- 实现:在 `scripts/rectification/api_service.py` 的 HTTP 入口路径(`score_candidates`、`block_scan`、`diagnostics`)按 `start_time` / `end_time` / 生效步长先算候选数,超过 241 直接拒绝,返回 **400**,错误码 `too_many_candidates`,消息里带上限值和本次候选数。**在开始排盘之前拒绝**,不得算到一半。 +- 上限用模块常量 `MAX_CANDIDATES_PER_REQUEST = 241`;**不得**加成请求字段(会进 `fingerprint` / `result_id`)。研究脚本若需要超过上限,显式传关键字参数关闭;开工 grep 全部 Python 调用方(`scripts/research/**`、`tests/**`),确认 T6 冻结重跑的所有试验都在上限内、结果不变,逐一写进 PROGRESS。 +- 前端:`engine-client.ts` 已有的错误映射里,把 400 `too_many_candidates` 归入「请求参数错」类(不重试、不当成忙);正常流程碰不到,只需保证不会被当成 429 去重试。若需改前端文件,只改错误映射这一处。 +- 验收: + - 新测试:240 / 241 候选放行,242 候选 400;全天步长 1(1,440)400,且耗时 < 50 ms(证明没开始排盘);全天步长 10(144)放行;步长 2/5 的产品档位全部放行。 + - `tests/test_api_server_security.py` 若有对打分接口状态码的断言,按三栏补。 + - Grok 自跑;Claude 复验。 +- T4b 必须在 T6 之前完成(`api_service.py` 在冻结身份清单里)。 + ### T5 · 差分与耗时脚本(交付物,T1–T4 都要用) - 新增 `scripts/research/rect_scoring_perf_20261013.py`,内置 §2 夹具,两个子命令: @@ -230,7 +251,7 @@ EVENTS = [ # id 依次 00000000-0000-4000-8000-00000000000{1..8},summary 统 ### T7 · 记录 -- `docs/BUG_HISTORY.md` 新增 **BUG-1323**(引导窗口平方扫描)、**BUG-1324**(候选不变量仍在事件循环:功能吉凶 / 那罗延 AD / 过渡邻近;关联 BUG-721,写明旧条款为何没拦住:新代码没照做、测试只用 3~5 个候选)、**BUG-1325**(打分响应带无读取点的内部矩阵与重复回执;关联 BUG-728)。防复发写进「计数断言随候选数线性」与「新响应字段先找读取点」。 +- `docs/BUG_HISTORY.md` 新增 **BUG-1323**(引导窗口平方扫描)、**BUG-1324**(候选不变量仍在事件循环:功能吉凶 / 那罗延 AD / 过渡邻近;关联 BUG-721,写明旧条款为何没拦住:新代码没照做、测试只用 3~5 个候选)、**BUG-1325**(打分响应带无读取点的内部矩阵与重复回执;关联 BUG-728)。防复发写进「计数断言随候选数线性」与「新响应字段先找读取点」。另新增 **BUG-1345**(打分接口无候选数上限,直接调用可占重计算槽数分钟;修复为 241 上限、超限 400 且不排盘)。 - `docs/research/pre_work_error_ledger.md`:在 ERR-110 下追加本次另存标签。 - `docs/tasks/PROGRESS-rect-scoring-perf-20261013.md`:改前 / 改后耗时表(5 次中位数,注明机器与负载)、11 场景 sha256 对照、计数表、三栏清单、冻结核对结果。 - `CHANGELOG.md`:一句「校正每轮重算更快(2 小时窗口约快一倍以上),结果不变」。Skill 版本不 bump。 @@ -258,7 +279,7 @@ EVENTS = [ # id 依次 00000000-0000-4000-8000-00000000000{1..8},summary 统 2. T3 的功能吉凶; 3. T4。 -**T1、T2、T5、T6、T7 不可砍。** T6 必须在最终代码上做:砍掉任何一项后要重做 T6。 +**T1、T2、T4b、T5、T6、T7 不可砍。** T6 必须在最终代码上做:砍掉任何一项后要重做 T6。 ## 9. 验收命令分工 @@ -266,11 +287,11 @@ EVENTS = [ # id 依次 00000000-0000-4000-8000-00000000000{1..8},summary 统 | --- | --- | | Grok 自跑(工作树 `.venv`) | `python -m pytest tests/test_rect_scoring_perf_20261013.py tests/test_rectification_engine_memoization.py tests/test_event_probes_guided_windows.py tests/test_dasha_transition_proximity_cross_midnight.py tests/test_rectification_v5_services.py tests/test_rectification_v5_vedastro_validation.py tests/test_rectification_validation_integrity_gate.py tests/test_sealed_holdout_contract_freshness.py tests/test_reported_offset_research.py tests/test_rectification_confirmation_and.py tests/test_api_server_growth_contract.py -q`;`python -m pytest tests/test_rectification_*.py -q`(BUG-747:改回执路径必须跑全量校正测试);T5 `bench` 与跨版本 `dump` 比较;T6 冻结与逐字节核对;前端单文件 `rectification-confirmation-gate.test.ts`。前端全量、Python 快速门**不跑**,以 Claude 为准 | | Claude 验收 | 上述全部 + `.venv/bin/python scripts/run_quality_gate.py --profile quick` + Python 全量 pytest 与基线逐名比对(feedback:引擎路径改动不能只看快速门)+ 前端全量 `npm test`(看 fail 与 cancelled)+ 独立跑 T5 跨版本 11 场景 sha256 + 复核 T6 trials 逐字节 + `tests/test_repo_privacy_markers.py` | -| 不需要 | `npm run test:db`(不动数据库);`next build` 首屏口径(不动前端源码) | +| 前端(仅当 T4b 改了 `engine-client.ts`) | Grok:`tsc --noEmit`、`npm run lint`、`rectification-v9-engine-contract.test.ts` 单文件;Claude:前端全量 `npm test` | +| 不需要 | `npm run test:db`(不动数据库) | ## 10. 本单不做(另议) -- 服务端打分候选数上限:直接调接口可以传 00:00–23:59、步长 1,算出 1440 个候选,占重计算槽数分钟。要不要设上限需要拍板。 - 探针路径 `_active_vimshottari(birth_date, …)` 用请求日期而不是候选日期,与打分路径口径不同;本单不碰,只记录。 - `candidate_feature_snapshot` 417 KB 只读 `features[*]` 里三个字段,瘦身要动前端契约,另开单。 - `guided_collect_windows` 与 `merge_transition_proximity` 共用每候选的大运起点表(还能省约 0.3–0.5 s)。 @@ -291,7 +312,7 @@ grep -rn "event_contribution_matrix\|candidate_decision_receipt" tests scripts f ## 12. BUG 编号起点 -BUG-1323(引导窗口)、BUG-1324(不变量)、BUG-1325(响应)。开工核对最大号;若已被占用,顺延并在 PROGRESS 写明。 +BUG-1323(引导窗口)、BUG-1324(不变量)、BUG-1325(响应)、BUG-1345(候选数上限,产品 10-14 决定)。开工核对最大号;若已被占用,顺延并在 PROGRESS 写明。 ## 13. 交付物清单 diff --git a/docs/tasks/TASK-report-reference-perf-20261013.md b/docs/tasks/TASK-report-reference-perf-20261013.md index 4b78ac45..4942ffed 100644 --- a/docs/tasks/TASK-report-reference-perf-20261013.md +++ b/docs/tasks/TASK-report-reference-perf-20261013.md @@ -116,7 +116,7 @@ EOF 4. **不减少擦洗遍数**,除非能在 PROGRESS 里给出逐条等价论证且 A/B 全语料逐字节一致——默认不做(见让步顺序)。 5. 缓存只覆盖 `edition=full_data` + `format=markdown` 路径;其他组合(含 `format=json`,其包内有 `generated_at` 墙钟)一律不缓存。缓存命中返回的响应字节必须与未命中时相同。 6. 缓存键必须包含影响输出的**全部**输入:`_high_rigor_birth_payload` 规范化后的整份 `birth`(含 `today`、`transit_date`、`candidate_range` 等全部字段)、`format`、`edition`、`packs`、`languages`、`include_fact_tables`、`REPORT_VERSION_FULL`、代码版本(`GITHUB_SHA`)。`GITHUB_SHA` 缺失或为 `unknown` 时缓存默认关闭。文件名只用哈希;缓存文件放 `scratch/local/` 下(容器内即 `api_scratch` 卷),与 `consult_full_data_cache` 同样两天清理;日志与 BUG 历史不得写入出生资料或缓存键原文。 -7. `scripts/jyotish_api_server.py` 净行数不增长、类方法数不增长、`JyotishAPIHandler.__new__` 伪造点不增长(`tests/test_api_server_growth_contract.py`);本单预期对该文件**零改动**(缓存放在 `professional_report_reference.py` 内)。 +7. `scripts/jyotish_api_server.py`:行数只按 `tests/test_api_server_growth_contract.py` 的粗护栏(AGENTS.md §6:不另加比测试更严的行数红线,也不得压行凑数),类方法数与 `JyotishAPIHandler.__new__` 伪造点不得增长(`tests/test_api_server_growth_contract.py`);本单预期对该文件**零改动**(缓存放在 `professional_report_reference.py` 内)。 8. 不改重计算名额逻辑(`scripts/api_heavy_compute_gate.py`)、不改 `.gitea/workflows/**`、不动表结构与迁移、不升级依赖、不顺手修 warning;`frontend/src/app/(app)/page.tsx` 不碰(本单无前端改动)。 9. 改既有断言按「原值 / 新值 / 原因」三栏写进 PROGRESS;测试总数不得低于开工时。 10. 新测试的输入包必须由真实引擎现算(虚构盘,沿用 `tests/test_report_english_edition.py::CASES` / `_packet`),不得手造包形状。 From 48a2faddfa3b3c6cd7e4bdd47035fe3c95352235 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 14 Oct 2026 00:43:48 +0800 Subject: [PATCH 3/5] =?UTF-8?q?docs(agent-loop):=20document=20agent/?= =?UTF-8?q?=E6=8E=92=E9=98=9F=20label=20for=20queued=20briefs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v --- tools/agent-loop/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/agent-loop/README.md b/tools/agent-loop/README.md index a6cf495e..bfc6c4cb 100644 --- a/tools/agent-loop/README.md +++ b/tools/agent-loop/README.md @@ -14,6 +14,7 @@ Claude(开发机,Linux)负责规划与验收,Grok Build(产品负责 | `agent/部署中` | Claude 脚本(不用模型):核对 `/api/health` | `agent/待真机`;3 小时未部署 → `agent/需拍板` | | `agent/待真机` | 产品负责人 | 关 issue,或评论问题并改回 `agent/待规划` | | `agent/需拍板` | 产品负责人 | 回复后改回原状态 | +| `agent/排队` | 无人处理(两侧都跳过) | 任务书已写好、等前置单合入;Claude 验收某单推 staging 后,检查哪些排队单的前置已全部合入,改成 `agent/待执行`(前置写在 issue 正文「前置」与末尾「排队中」行) | 锁标签:`agent/规划中`、`agent/验收中`(Claude)、`agent/执行中`(Grok)。试点期只处理同时带 `agent/试点` 的 issue。 From 13bde11539bc99f76330376145c756da22d149e3 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 14 Oct 2026 00:53:51 +0800 Subject: [PATCH 4/5] =?UTF-8?q?docs(tasks):=20closed-beta=20briefs=20?= =?UTF-8?q?=E2=80=94=20release=20path,=20safety=20net,=20capacity,=20main?= =?UTF-8?q?=20path;=20beta=20smoke=20checklist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HwMNZEzdzmrbPhuRNmZe3C --- docs/tasks/README.md | 4 + docs/tasks/TASK-beta-capacity-20261014.md | 86 +++++++++++ docs/tasks/TASK-beta-main-path-20261014.md | 100 +++++++++++++ docs/tasks/TASK-beta-safety-net-20261014.md | 89 ++++++++++++ .../TASK-release-path-production-20261014.md | 135 ++++++++++++++++++ docs/testing/beta-smoke-20261014.md | 18 +++ 6 files changed, 432 insertions(+) create mode 100644 docs/tasks/TASK-beta-capacity-20261014.md create mode 100644 docs/tasks/TASK-beta-main-path-20261014.md create mode 100644 docs/tasks/TASK-beta-safety-net-20261014.md create mode 100644 docs/tasks/TASK-release-path-production-20261014.md create mode 100644 docs/testing/beta-smoke-20261014.md diff --git a/docs/tasks/README.md b/docs/tasks/README.md index e9bb15bb..4e975da8 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -332,6 +332,10 @@ | `TASK-rectification-refresh-r3-r4-20260913.md` | `PROGRESS-rectification-refresh-r3-r4-20260913.md` | 刷新阶段 R3(MIN_BOUNDARY_DAYS 45→30)+ R4(pratyantar 与 D9/D10 上升 Narayana);首轮出题不变。即使放宽仍有 0 题例子,定向补事另线保留(BUG-664/665) | 已实现 `ab1ade59`,已部署;2026-09-13 验收通过(实现单由执行方自拟,无产品决策记录段;收益口径见研究文档) | `codex/rectification-refresh-r3-r4-20260913` | | `TASK-upstream-sync6-20261007.md` | [PROGRESS-upstream-sync6-20261007.md](PROGRESS-upstream-sync6-20261007.md) | **上游同步第六轮(`23be1807`)**:10-05 五个计算修正一行未合——D5/D6/D8/D11 映射(乔布斯盘四张分盘星座全不同)、Vimsopaka 权重、Yogini 顺序与出生余额、Panchadhikari 宫位参照、Narayana 并列、特殊上升、出生秒数、Neecha Bhanga 两条件、首段大运子运、岁差被覆盖;逐项对照原书核实;最先做 | 已验收(门禁补修 `2df6ee72`,已部署 staging) | [PROGRESS-upstream-sync6-20261007.md](PROGRESS-upstream-sync6-20261007.md) | | `TASK-upstream-sync7-20261014.md` | — | **上游同步第七轮(BUG-1318 起)**:婚恋评级断语改证据档位、DK「婚姻质量 X/100」改对;KP 宫头到宫头判宫;瑜伽宫主排序去重;api_server 大运年长 365.25;星历路径只核查。不动校正打分。Grok 执行(agent 循环) | 待执行 | — | +| `TASK-beta-main-path-20261014.md` | — | **内测主路径**:BUG-1063 他人报告 worker 读档案(逐列只读授权 + test:db);对话与报告「AI 生成,仅供参考」标识与免责;BUG-1080/283 状态;状态板按事实清账;冒烟清单 `docs/testing/beta-smoke-20261014.md`。与发布单并行 | 待执行 | — | +| `TASK-release-path-production-20261014.md` | — | **发布通路 + 生产首发**:产品 10-14 授权改生产工作流(发布 SHA = 最近门禁成功提交 + 其后纯文档);63 个迁移清单与演练脚本、首发 runbook;生产拒绝测试验证码。Grok 不 dispatch 生产工作流 | 待执行 | — | +| `TASK-beta-safety-net-20261014.md` | — | **内测安全网**:每日备份 + 恢复演练、探活告警、本地错误汇总、日志轮转、按 requestId 查日志。**等发布通路单合入、#58 合入后开工** | 待前置 | — | +| `TASK-beta-capacity-20261014.md` | — | **内测容量**:按用户限流、重计算短排队与对话重试、兑换码当邀请码、staging 10/20 并发压测。**等 #58 合入后开工,压测等 #57 合入** | 待前置 | — | | `TASK-special-lagnas-native-20261008.md` | `PROGRESS-special-lagnas-native-20261008.md` | **特殊上升点原生计算(线上缺陷)**:sync6 U6 运行时调 PyJHora,生产镜像没有 PyJHora → staging 上 Hora/Ghati/Sree/Indu/Pranapada 全 blocked;原生实现、PyJHora 只作冻结对照、快速门禁止运行时 import jhora;最先做 | 已验收(已部署 staging `795b8845`,BUG-1273) | `codex/special-lagnas-native-20261008`(未上线;部署以 `/api/health` 为准) | | `TASK-ephemeris-nutation-20261008.md` | `PROGRESS-ephemeris-nutation-20261008.md` | **恒星黄道少扣章动(BUG-1275)**:行星 = 含章动热带视位置 − 平岁差,全体偏 Δψ(≤17″),大运日期差 2–3 天;改标准算法、PyJHora 冻结对照、scoring-12、v5 重跑 | 已验收(已部署 staging `db0134f9`,BUG-1275) | `codex/ephemeris-nutation-20261008`(未推送,未部署) | | `TASK-sync6-gate-red-20261007.md` | — | **sync6 后门禁仍红**:run 3213 快速门已过,「Frontend and database tests」开跑 20 秒后日志中断、15 分钟后判失败,后续步骤 failure 0 秒;疑运行机掉线;先 Re-run,红则本机带 Docker 跑 `CI=1 npm test` 对照 `0ed45a4b` | 已完成(Re-run 通过,BUG-1271,部署 `2df6ee72`) | — | diff --git a/docs/tasks/TASK-beta-capacity-20261014.md b/docs/tasks/TASK-beta-capacity-20261014.md new file mode 100644 index 00000000..f21891e0 --- /dev/null +++ b/docs/tasks/TASK-beta-capacity-20261014.md @@ -0,0 +1,86 @@ +# TASK · 内测容量:按用户限流、重计算满时短排队、10–20 并发冒烟压测(2026-10-14) + +- 日期:2026-10-14 +- 来源:产品 10-14 拍板「内测跑生产」;内测体检(运维一路,`scratchpad/beta-ops-20261014.md`):重计算并发 2、满了立即 429;对话与校正没有按用户限流;仓库里没有任何并发压测结果。 +- 执行方:**Grok(Windows + Docker)**,分支 `codex/beta-capacity-20261014`,只推这个分支 +- 验收:Claude(Linux) +- BUG 编号:开工时核对 `docs/BUG_HISTORY.md` 最大号顺延(写作时最大号 BUG-1317;#56 sync7 从 1318 起占号,#58 已预留 BUG-1328,开工时以实际最大号为准) +- **串行**:本单 T1 改 `frontend/src/lib/request-rate-limit.ts`,#58(`TASK-node-runtime-resources-20261013`)也改这个文件 → **#58 合入 staging 后再开 T1/T2**。T4 压测基线要在 #57(`TASK-rect-scoring-perf-20261013`,校正打分提速)合入后再量,否则测的是旧的打分耗时。T0 可以先做。 + +## 1. 基线 + +- 基线 commit:`origin/staging` `48a2fadd`(开工时以最新 staging 为准,进度记录写明)。 +- staging 主机 `118.26.111.127`,生产 `118.194.235.34`(2 vCPU / 4 GB,`deploy/README.md`);**T0 先核对 staging 规格是否与生产相同**,不同时压测结论要按比例说明,不得直接套到生产。 + +## 2. 事故实证(行号按符号定位) + +| 位置 | 现状 | +| --- | --- | +| `scripts/api_heavy_compute_gate.py` | 进程级 `BoundedSemaphore`,`DEFAULT_CONCURRENCY = 2`,`HeavyComputeBusy` 抛出后服务器立即回 `429 ERR_COMPUTE_BUSY` + `Retry-After: 2`,**不排队**。`HEAVY_COMPUTE_PATHS` 含 `/api/consultation_workflow`(普通对话每轮取证)和全部校正打分端点:**对话与校正抢同两个位** | +| `scripts/jyotish_api_server.py` POST 分发 | `acquire_heavy_compute_slot(path)`;只有 `consult_card_warm_only` 预热不占位 | +| `frontend/src/mastra/consultation-workflow.ts` `httpFailureCode` | 429 → `workflow_rate_limited`,没有按 `Retry-After` 重试,这一轮对话直接失败 | +| `frontend/src/lib/rectification-agentic/v9/engine-client.ts` | 校正已按 `busy` 读 `Retry-After` 退避(BUG-723),不在本单改动范围 | +| `frontend/src/lib/request-rate-limit.ts` `requestRateLimits` | 只有 locationSearch / locationTimezone / dailyStarlanguage / ephemeris / synastry / sessionWrite 六个桶;**`/api/consult`、校正回合、报告生成都没有按用户限流**(报告只有每日上限 `PERSONAL_REPORT_DAILY_LIMIT`,默认 5) | +| 压测 | 仓库只有 6 月的压测计划与 9 月单会话容量 / 首屏性能记录,没有并发结果 | + +推算:内测 20–50 人,只要同一时刻有 3 个以上请求在跑取证或校正打分(单次 2–5 s,#57 之后目标 < 2 s),第三个人的对话就失败。 + +## 3. 根因 + +重计算闸当初是为防止 2 vCPU 被校正扫描打满而设的「宁可拒绝」策略(见文件头注释),后来普通对话的取证也进了同一个闸,但对话侧没有任何等待或重试;按用户限流只给了轻量接口。 + +## 4. 决策记录(产品 2026-10-14) + +1. 内测跑生产(产品 10-14)。容量问题必须在内测前处理,不能只靠「人少」。 +2. 重计算并发上限**保持 2**(主机 2 vCPU,不放大);改为「满了先等一小会儿」,等不到再明确告诉用户稍后再试。 +3. 对话、校正回合、报告生成加按用户限流;阈值见 §6 建议值,**产品可改**,写进 `requestRateLimits` 常量,环境变量不新增(减少配置面)。 +4. 内测发点数用兑换码(`redemption_codes`,一码一次)当邀请码;内测期间不开支付(`EPAY_CHAT_ENABLED=false` 保持)。每码点数由产品定(§10)。 +5. 压测在 staging 做,不压生产;登录用 staging 测试邮箱 + 固定验证码(产品在会话里提供),脚本从环境变量 `STAGING_TEST_OTP` 读,**不得写进任何文件、日志或提交**。 + +## 5. 硬红线 + +1. 不改生时校正打分、`decision_policy`、闸门与题库;不改校正侧 BUG-723 的退避逻辑(服务端排队后它自然少碰 429)。 +2. `scripts/jyotish_api_server.py` 不得增长(`tests/test_api_server_growth_contract.py`):排队逻辑写在 `scripts/api_heavy_compute_gate.py` 内,主文件只允许改调用处一两行。 +3. 排队有上界:等待者数量与等待时长都有上限;等不到仍回 429 + `Retry-After`,**不得无限挂起请求线程**。健康检查与轻量路由不得进闸(文件头约定保持)。 +4. 限流放行 / 拒绝语义与 `retryAfterSeconds` 计算沿用 `consumeRequestRateLimit`;不改 #58 加的过期清理。 +5. 前端红线(AGENTS §7):tsc 0、lint 0 error、npm test 失败名单与基线逐名相同、build `/` Static、首屏 gzip ±2%;新文案对照 `frontend/docs/VOICE.md`,不写「系统繁忙」「请稍后重试」这类客服腔,参照现有「这边没接上……」口吻。 +6. 压测不得读写真实用户数据;只用测试账号;压测结束清理测试账号会话(或写明保留原因);模型调用花费写进进度记录。 +7. 不改数据库结构(本单不需要迁移);不改 workflow;不推 staging。 + +## 6. 任务分解 + +| 项 | 内容 | 验收 | +| --- | --- | --- | +| T0 | 核对 staging 与生产主机规格(vCPU、内存、`JYOTISH_HEAVY_COMPUTE_CONCURRENCY` 实际值);在 staging 用现有代码跑一次 T4 的压测作为**改前基线**(若 #57 未合入,记清楚) | 进度记录有规格对照与改前数字 | +| T1 | 按用户限流:`requestRateLimits` 新增 `consult`(建议 6 次 / 分钟、60 次 / 小时两档,用两个桶)、`rectificationTurn`(建议 10 次 / 分钟)、`reportGenerate`(建议 3 次 / 小时,与每日 5 份并存)。接入 `/api/consult`、校正回合路由(`frontend/src/app/api/rectification/agent/route.ts`,开案 `cases/open` 不计入)、报告生成 `frontend/src/app/api/reports/route.ts` POST。超限回 429 + `code: "rate_limited"` + `Retry-After`,**在扣点 / 预留之前**判断,不扣点 | 单元测试:各桶边界放行 / 拒绝、拒绝时不调用扣点 RPC;路由测试断言 429 体与头 | +| T2 | 重计算闸短排队:`acquire_heavy_compute_slot` 改为带超时等待(建议最多等 8 s,等待者上限 = 4),超时或等待者满仍抛 `HeavyComputeBusy`;`Retry-After` 不变。前端 `/api/consult` 对 `workflow_rate_limited` 按 `Retry-After` 最多重试 2 次、总等待 ≤ 15 s,期间进度行显示一句「人有点多,排一下队」(文案进 VOICE);仍失败时用一句话说明并退回本轮预留点数(沿用现有失败结算路径,核实不重复退款) | Python 测试:并发 3 个请求,第 3 个在前一个释放后拿到位;等待者满时立刻 429;超时 429。前端测试:429 → 重试 → 成功;三次都 429 → 失败文案 + 退点(复用现有结算断言) | +| T3 | 内测点数上限:核实兑换码表能否设单码点数与批量生成(后台「兑换码」页),写一页操作说明 `docs/operations/beta-invite-codes.md`:怎么批量生成邀请码、每码点数、怎么查谁用了、怎么作废。不写真实码 | 文档;若后台缺批量生成或作废能力,写进进度记录与 BLOCKED,不在本单扩功能 | +| T4 | 冒烟压测脚本(放 `frontend/scripts/research/beta-load-smoke.mts`,不进镜像,确认不被 `frontend/src` 引用——参照 BUG-1140 / `runtime-sources-no-test-imports.test.ts`):N 个虚拟用户(10、20 两档),每人:OTP 登录 → 首轮咨询 → 一次追问;其中 3 人改为开一次校正并答 2 题。记录:每请求状态码、首字时间、整轮完成时间、429 比例;同时在服务器侧采样 API / web 容器内存与 CPU(`docker stats` 每 5 s,执行方没有 staging SSH 时写成环境缺口,只报客户端侧) | 进度记录给改前 / 改后两组数字。**通过线**(10 并发):429(最终失败,不含重试成功)≤ 2%;5xx = 0;咨询首字 P95 ≤ 15 s、整轮 P95 ≤ 90 s;API 容器内存峰值 < 1.5 GB。20 并发只记录不设门,用来定内测同时在线上限 | +| T5 | 文档:BUG_HISTORY(每项一个号)、CHANGELOG、VOICE(排队与限流文案)、DESIGN(进度行状态)、`docs/testing/beta-capacity-20261014.md` 真机两条(快速连发触发限流的提示;排队提示出现与消失) | 齐 | + +## 7. 让步顺序 + +T2 后端排队不可让;T1 不可让(防刷与成本)。T2 前端重试文案可简化为不显示「排队」而只多等。T4 20 并发档可让;10 并发档不可让(没有数就不能拉人)。T3 文档可让为「后台已有能力清单」。 + +## 8. 验收门 + +- Python:`ruff`、`py_compile`、新测试、`tests/test_api_server_growth_contract.py`、快速门全量(`failures []` 或与基线同名)。 +- 前端:tsc / lint / npm test(与基线逐名比对,看 `# cancelled` 与退出码)/ build Static / gzip。 +- 压测:脚本可在 Claude 侧(Linux)复跑 10 并发一次,数字量级一致。 + +## 9. 开工前置 + +``` +git fetch origin --prune +git status -sb +git worktree add -b codex/beta-capacity-20261014 .worktrees/beta-capacity-20261014 origin/staging +# 确认 #58 已合入 staging(request-rate-limit.ts 有过期清理)再开 T1/T2;T0 可先做 +``` + +## 10. 需产品提供 / 拍板 + +1. 限流阈值:对话 6 次 / 分钟 + 60 次 / 小时、校正 10 次 / 分钟、报告 3 次 / 小时(+ 每日 5 份)——同意或改数。 +2. 排队上限:最多等 8 s、最多 4 人排队——同意或改。 +3. 每个邀请码给多少点;内测第一批多少人(压测 20 并发的结果出来后再定同时在线上限)。 +4. 压测会花模型调用费用(约 20 人 × 2 轮 × 两次),同意在 staging 跑。 +5. 若 Grok 侧拿不到 staging 主机 SSH,服务器内存 / CPU 采样由谁跑(Claude 也没有主机权限时写成环境缺口)。 diff --git a/docs/tasks/TASK-beta-main-path-20261014.md b/docs/tasks/TASK-beta-main-path-20261014.md new file mode 100644 index 00000000..a635d63b --- /dev/null +++ b/docs/tasks/TASK-beta-main-path-20261014.md @@ -0,0 +1,100 @@ +# TASK · 内测主路径:他人报告读不到资料、AI 生成标识与免责、状态板清账、内测冒烟清单(2026-10-14) + +- 日期:2026-10-14 +- 来源:产品 10-14 拍板「内测跑生产」「给别人出报告内测要开」;内测体检(`scratchpad/beta-compliance-20261014.md`、`beta-defects-20261014.md`)。 +- 执行方:**Grok(Windows + Docker)**,分支 `codex/beta-main-path-20261014`,只推这个分支 +- 验收:Claude(Linux) +- BUG 编号:开工时核对 `docs/BUG_HISTORY.md` 最大号顺延(写作时最大号 BUG-1317;#56 从 1318 起、#58 预留 1328,以实际为准)。T1 沿用 **BUG-1063**;T2 新号;T3–T5 不取新号(状态与文档)。 +- 串行:与 #56(Python 引擎)、#57(校正打分)、#58(Node 资源)、容量单 `TASK-beta-capacity-20261014`(限流 / 排队)**不改同一文件**;T2 改 `frontend/src/components/chat-composer.tsx` 与报告阅读组件,若同期有别的单改这两个文件,以先合入者为准、后者变基。 + +## 1. 基线 + +- 基线 commit:`origin/staging` `48a2fadd`(开工时以最新为准,进度记录写明)。 + +## 2. 事故实证(行号按符号定位) + +**T1 / BUG-1063 他人报告读不到资料**(状态 investigating,09-27 静态证据): + +- `frontend/src/lib/personal-report-worker-subject.ts::loadReportWorkerProfile` → `frontend/src/lib/subject-birth.ts::loadSubjectBirth(client, …)`,用后台 admin 客户端 `from("chart_profiles").select(CHART_SUBJECT_SELECT)`。 +- admin 客户端在自托管模式下是 `set local role service_role`(`admin-client-core.ts`)。 +- `frontend/supabase/migrations/20260718100000_repair_missing_chart_profiles.sql` 第 50 行 `revoke all on table public.chart_profiles from anon, authenticated, service_role;`,之后**没有任何迁移**给 `service_role` 授回 `chart_profiles` 的任何权限(已 grep 全部迁移:只有给 `authenticated` 的 select / 列级 insert / update / delete)。 +- 本人报告不受影响:本人出生资料在 `profiles` 表;只有「人物档案里的其他人」走 `chart_profiles`。 +- 推断后果:他人报告 worker 读资料报权限错 → `subject_unavailable` → `profile_incomplete`(可重试)→ 一直排队或最终失败退款。**未在真实库复现。** + +**T2 AI 生成标识**:只在协议文本里(`frontend/src/lib/legal-documents.ts`「本服务的回答与报告由人工智能生成……」);报告结尾有「不构成医疗、法律或投资建议」(`personal-report-generation.ts`);**对话界面(普通对话、校正对话)与报告阅读页顶部都没有显式标识,对话里也没有免责小字**。依据:《人工智能生成合成内容标识办法》2025-09-01 施行,要求对生成的文本内容在起始、末尾或中间适当位置添加显式提示(来源:国家网信办 https://www.cac.gov.cn/2025-03/14/c_1743654685899683.htm ;解读 https://www.pwccn.com/zh/tmt/method-identifying-synthetic-content-generated-ai-sep2025.pdf )。 + +**T3 / BUG-1080 新账号首次建盘只见兑换弹窗**:修复 `dec36bce` 已在 staging(已核:`git merge-base --is-ancestor dec36bce origin/staging` 成立),状态行仍写「修复在分支,未部署」。只差真机。 + +**T4 / BUG-283 今日星语回退**:状态 regressed,但修复版本写「已被 BUG-299 覆盖」,BUG-299 为 resolved(首屏只调 `/api/daily_guidance`)。实为状态未改。 + +**T5 状态板滞后**(修复已在 staging,状态行仍写「分支 / 未部署」): + +| BUG | 状态行现写 | 事实 | +| --- | --- | --- | +| 906、907 | investigating(分支已修,未部署) | `82c8aeb0` 在 staging | +| 1080 | investigating(分支,未部署) | `dec36bce` 在 staging | +| 1128 | 字体部分 resolved(待部署)/ JS 部分 blocked | `8112f62b` 在 staging;JS 部分 blocked 保持 | +| 1244、1245、1247~1253、1255~1259 | fixed-pending-verify(分支 `codex/…`;部署前不标) | 均已部署 staging(见各自修复版本行或 CHANGELOG);1246 已撤回,不动 | +| 1254 | fixed-pending-verify(需门禁连续通过) | 按门禁记录核实 | + +## 3. 根因 + +- T1:7-18 修 `chart_profiles` 权限时按「只给登录用户」收口,没考虑后台 worker;9-27 BUG-1031 把 worker 改走 `loadSubjectBirth` 后才用到这张表;mock 客户端测不出表权限。 +- T2:合规那一轮(09-30)只做了协议与同意,没有落到界面。 +- T3–T5:多会话并行合入时只改代码不回填状态行。 + +## 4. 决策记录(产品 2026-10-14) + +1. 内测开放「给别人出报告」→ BUG-1063 必修。 +2. **T1 修法选「给 `service_role` 授 `chart_profiles` 逐列只读」**,不选 SECURITY DEFINER 只读函数。理由:① 调用方是只在服务端运行的后台 worker,`service_role` 本来就读得到本人出生资料(`profiles`),把同一组列扩到他人档案不扩大信任边界;② 定义者函数对受信的服务端调用方不提供额外保护(它照样能传任何 user_id),却多一条读路径要维护、要和 `CHART_SUBJECT_SELECT` 同步两处;③ 列级授权能精确限制到 `CHART_SUBJECT_SELECT` 的列,用一条合同测试锁住「select 列 ⊆ 授权列」。只授 SELECT,不授写。 +3. **T2 显式标识放法**:对话——在 `ChatComposer` 组件内输入框下方加一行小字(一个组件,普通对话与校正对话两处同时生效,`page.tsx` 不改);报告——阅读页标题下的元信息行加「AI 生成」,结尾免责保留;分享出去的报告页同样有。**推翻 `frontend/DESIGN.md`「No strip below the composer」的字面**:那条针对的是常驻 44 px 空条;本单是一行说明文字、不占工具位,DESIGN 同步改写这条并写明原因。文案建议:对话「回答由 AI 生成,仅供参考,不构成医疗、法律或投资建议。」;报告「本报告由 AI 生成,仅供参考。」(对照 VOICE,产品可改字)。 +4. 隐式标识(文件元数据):下载的 PDF / Word / Markdown 写入「AI 生成」元数据——列为 T2b,可让。 +5. T4:BUG-283 改为 `superseded`(被 BUG-299 取代),不改代码;首页今日星语是否正常放进冒烟清单,真机若仍失败另立新号。 +6. T5 只按事实改状态行(「已部署 staging ``,真机欠」),**不得**把 fixed-pending-verify 改成 resolved,不改根因与结论。 + +## 5. 硬红线 + +1. 迁移只做加法(`grant select (<列>) on public.chart_profiles to service_role`),对当前已部署代码向后兼容;新迁移文件名时间戳不得与现有重复;动表的轮次**必须真跑 `npm run test:db`**(AGENTS §7.6),Grok 有 Docker。 +2. 不授 `service_role` 任何写权限;不改 RLS;不动 `authenticated` 现有授权。 +3. 前端红线(AGENTS §7):tsc 0、lint 0 error、npm test 失败名单与基线逐名相同(看 `# cancelled` 与退出码)、build `/` Static、首屏 gzip ±2%;`page.tsx` 的 `Home()` 状态数不增长;改 UI 同提交更新 DESIGN;新文案进 VOICE。改既有断言写「原值 / 新值 / 原因」三栏(先 grep `frontend/tests/` 里按 `chat-composer` 源码文本断言的合同测试,AGENTS §7.8)。 +4. 不改生时校正打分、不改对话提示词、不改报告生成内容(只加阅读页与组件上的标识)。 +5. 不写真实用户资料;测试只用公开名人或虚构盘。不改 workflow、不推 staging。 + +## 6. 任务分解 + +| 项 | 内容 | 验收 | +| --- | --- | --- | +| T0 | **先红后绿**:在 `frontend/tests/database-chart-subject.test.ts`(或新建 `database-report-worker-subject.test.ts`)加用例:用后台 admin(`service_role`)客户端对另一个人物档案调用 `loadReportWorkerProfile` / `loadSubjectBirth`,期望读到资料。改前在本地 PostgreSQL(Docker `test:db`)跑出**权限拒绝**并把错误原文写进进度记录 | 进度记录有改前红的输出;若改前竟然是绿的(说明另有授权),停下回报,不做 T1 | +| T1 | 新迁移:`grant select () on table public.chart_profiles to service_role;`。新增合同测试:解析 `CHART_SUBJECT_SELECT` 与迁移里的授权列,断言前者 ⊆ 后者(以后加列忘了授权会红)。BUG-1063 记录补根因确认、修复、验证 | T0 用例转绿;`npm run test:db` 全量与基线比对;合同测试;BUG-1063 → fixed-pending-verify(部署 + 真机给别人出一份报告后才 resolved) | +| T2 | 对话:`ChatComposer` 内输入框下方一行说明(`--type-overline`、`--color-ink-tertiary`,移动端不换行溢出,不挡发送键)。报告阅读页(`personal-report-page.tsx` / `personal-report-document-view.tsx` 标题区)与分享页加「本报告由 AI 生成,仅供参考。」。中英文两版(英文:「AI-generated. For reference only; not medical, legal or financial advice.」/「This report is AI-generated and for reference only.」) | 组件测试:两处对话面都渲染说明;报告中英文都有;移动端快照或断言 class;DESIGN、VOICE 更新;真机清单一条 | +| T2b | 下载文件写入「AI 生成」元数据(PDF Producer/Subject、DOCX core properties、Markdown 首行注释——按现有导出实现能力取舍) | 单元测试读回元数据;做不到的格式写进进度记录 | +| T3 | BUG-1080 状态行改为「修复 `dec36bce` 已部署 staging,真机欠」;冒烟清单收录 | 文档 | +| T4 | BUG-283 状态改 `superseded(由 BUG-299 取代)`;冒烟清单收录「首页今日星语」 | 文档 | +| T5 | 按 §2 表格逐条改状态行;每条写部署证据(提交号在 staging、对应部署记录或 CHANGELOG 行);1254 按门禁记录判断 | 文档;`tests/test_repo_privacy_markers.py` 过 | +| T6 | 冒烟清单:把 `scratchpad/beta-smoke-checklist-20261014.md` 草稿(由 Claude 随单附上)落成 `docs/testing/beta-smoke-20261014.md`,补上 T1/T2 的条目编号 | 文档 | + +## 7. 让步顺序 + +T1 不可让(内测要开他人报告)。T2 对话与报告显式标识不可让(法规);T2b 可让。T3–T6 文档项不可让(产品需要一张真实的状态板),但不阻塞 T1/T2 的合入。 + +## 8. 验收门 + +- `npm run test:db`(Grok,Docker)全量,与 staging 基线比对;Claude 侧用本机 PostgreSQL 替身复跑 T0/T1 相关用例。 +- 前端 tsc / lint / npm test / build / gzip。 +- Python 不动;若碰到 `scripts/`,跑快速门全量。 + +## 9. 开工前置 + +``` +git fetch origin --prune +git status -sb +git worktree add -b codex/beta-main-path-20261014 .worktrees/beta-main-path-20261014 origin/staging +grep -rn "chat-composer" frontend/tests | head # 找按源码文本断言的合同测试 +``` + +## 10. 需产品提供 / 拍板 + +1. 同意 T1 修法(`service_role` 逐列只读)——若更倾向只读函数,告诉我,改单。 +2. 标识文案:对话「回答由 AI 生成,仅供参考,不构成医疗、法律或投资建议。」、报告「本报告由 AI 生成,仅供参考。」——同意或改字;以及同意改 DESIGN「输入框下方不放东西」这条的字面。 +3. 冒烟清单由谁走:产品用 staging 测试邮箱 + 固定验证码真机走,还是先由 Claude 用接口层(无浏览器)走一遍可接口化的条目(他人报告、「你好」不扣点、限流),再由产品补真机部分。 +4. 生产首发后,冒烟清单是否在生产再走一遍(建议走前 5 条,用产品自己的账号)。 diff --git a/docs/tasks/TASK-beta-safety-net-20261014.md b/docs/tasks/TASK-beta-safety-net-20261014.md new file mode 100644 index 00000000..b2751c9d --- /dev/null +++ b/docs/tasks/TASK-beta-safety-net-20261014.md @@ -0,0 +1,89 @@ +# TASK · 内测安全网:每日备份、探活告警、错误汇总、日志轮转与按 requestId 查日志(2026-10-14) + +- 日期:2026-10-14 +- 来源:产品 10-14 内测体检后拍板「内测跑生产」。体检结论:生产没有定时备份、没有探活告警、没有错误上报、容器日志不轮转,出了事既不知道也查不到(BUG-1306 取证就因日志只在容器控制台而失败)。 +- 执行方:**Grok(Windows)**,分支 `codex/beta-safety-net-20261014`,只推这个分支。Grok 碰不到生产主机:主机侧的安装与演练写成脚本 + runbook,由产品执行。 +- 验收:Claude(Linux)。 +- BUG 编号:开工时核对最大号顺延(写作时 BUG-1317)。其他已推任务书在 `docs/tasks/` 里已预占到 BUG-1346,开工时取 `docs/BUG_HISTORY.md` 与 `docs/tasks/` 已引用编号两者的最大号顺延。 +- 串行:**在 `TASK-release-path-production-20261014` 合入 staging 之后开工**(两单都改 `deploy/README.md`,本单的定时工作流也要沿用那一单的发布 SHA 规则)。与 issue #58(`TASK-node-runtime-resources-20261013`,Node 内存泄漏 / Postgres 往返 / 内存表)边界:#58 可能改 `deploy/docker-compose.server.yml` 的环境变量透传;本单改同一文件的 `logging` 段——开工前看 #58 是否已合入,合入则在其之上改,未合入则在回报里写明需 Claude 合并时解冲突。不碰 #57(校正重算)任何文件。 + +## 1. 基线 + +- 基线 commit:`origin/staging` 开工时最新(写作时 `48a2fadd`;需包含发布通路单的合入提交)。 +- 现有备份能力:`.gitea/workflows/create-production-recovery.yml`(只 `workflow_dispatch`)调用 `deploy/run-production-recovery.sh`:`pg_dump` 自定义格式 → AES-256-CBC/PBKDF2 加密 → 还原到一次性库校验行数 → 删一次性库 → 上传 Gitea artifact(30 天);主机上 `/opt/jyotisha-production/backups` 保留加密文件;磁盘使用 ≥70% 拒绝。`deploy/backup-staging-postgres.sh` 只给 staging 手动用。 +- 编排:`deploy/docker-compose.server.yml` 三个服务 `api`、`web`、`caddy`(PostgreSQL 另在 `deploy/docker-compose.postgres.yml`),都有 `restart: unless-stopped` 与 healthcheck,**都没有 `logging` 段**(主机 docker daemon 的默认日志配置未知)。 +- 观测:`frontend/src/lib/agent-observability.ts` 用 `console.info("[agent-observability]", JSON.stringify(event))` 写到 web 容器标准输出;Python API 的错误也只在容器标准输出。 + +## 2. 事故实证 + +| 缺口 | 证据 | 后果 | +| --- | --- | --- | +| 无定时备份 | 仓库内无 `schedule:` 触发的工作流、无 cron / systemd timer 文件;生产恢复点最后一次是 08-16(run 1881) | 内测开始写入真实用户数据后,任何一次误操作或磁盘故障都可能丢失最多「上次手动备份以来」的全部数据 | +| 无探活告警 | 仓库内无 Uptime Kuma / Prometheus / 告警配置;只有容器 healthcheck(只会重启,不会通知人) | 站点挂了只能等用户来说 | +| 无错误上报 | 无 Sentry 类集成;错误只在容器 stdout | 用户遇到报错,团队不知道,也无法统计 | +| 日志不轮转、留存不可控 | compose 无 `logging`;`docker compose logs --tail=100` 是唯一查法(`deploy/README.md`) | 磁盘可能被日志写满;或日志在重建容器时丢失;BUG-1306 取证失败 | +| 不能按 requestId 查 | 观测 JSON 里有 requestId,但没有按 requestId 检索的工具或流程 | 用户报「这一轮不对」时无法还原 | + +## 3. 根因 + +上线基础设施按「单人维护、手动发布」设计,备份只作为迁移前的一次性保险;没有任何「持续运行时」的观测与告警。内测引入真实用户后这些从可选变成必需。 + +## 4. 决策记录(产品 2026-10-14 授权) + +1. 内测跑生产,本单是内测开放前的必需项。 +2. **授权新增定时工作流**(`.gitea/workflows/` 下新增文件,属于 10-14 对 workflow 的授权范围):每日生产备份。新增文件不得改动已有四个生产工作流的语义(那是发布通路单的范围)。 +3. **红线(产品 10-14):错误上报不得把用户资料、出生资料、对话内容、邮箱、Cookie、JWT 发给任何第三方。** 因此本单不接入托管 Sentry 类服务;错误汇总在自己的主机上完成,外发的告警只含「哪个服务、什么错误码、多少次、requestId」,不含正文。 +4. 告警渠道由产品提供(见 §10);本单实现「Webhook 地址从环境 / 主机配置读取」,默认按企业微信 / 钉钉 / 飞书群机器人 Webhook 格式(三者都只要一个 URL),没配置时只写本地日志不外发。 +5. 主机侧安装全部写成脚本 + runbook,由产品执行;Grok 不 SSH、不 dispatch 生产工作流。 + +## 5. 硬红线 + +1. 备份文件始终加密;不打印、不提交加密密钥、`.env.production*`、数据库口令;告警消息与日志汇总不含任何用户内容(只含计数、错误码、路由、requestId、时间)。 +2. 不改现有 `create-production-recovery.yml` / `run-production-recovery.sh` 的加密、还原校验、锁、磁盘检查逻辑;定时备份复用它们,不另写一套 `pg_dump`。 +3. 定时备份不要求 `main == staging == 发布 SHA`(备份不该被发布状态卡住),但仍只从 `main` 运行、只用 `main` 上的脚本。 +4. 日志轮转只改 `logging` 段,不改服务的镜像、环境、端口、healthcheck。 +5. 不新增对外开放的端口;探活从外部访问公网 `https://jyotisha.chat/api/health`,不暴露 5200 / 3000。 +6. 不写真实用户资料;测试用虚构数据。 +7. Grok 不 push main、不推 staging、不 dispatch 生产工作流。 + +## 6. 任务分解 + +| 项 | 内容 | 验收标准 | +| --- | --- | --- | +| **T1 每日备份工作流** | 新增 `.gitea/workflows/backup-production-daily.yml`:`schedule` 每天北京时间 04:00 + `workflow_dispatch`;从 main 取 `deploy/run-production-recovery.sh` 执行(复用恢复点工作流的 SSH、锁、产物上传步骤,能抽成共用脚本就抽);artifact 保留天数可配,默认 14。主机侧本地加密文件保留策略:新增清理步骤只删 `backups/` 下由本工作流生成、超过 N 天(默认 7)的 `*.dump.enc` 与对应 verify 文件,按文件名前缀严格匹配,绝不删其他文件 | 合同测试断言:有 schedule、只从 main 运行、调用同一个 recovery 脚本、清理只匹配本工作流前缀、不打印密钥;清理逻辑写成独立 shell 函数并有 bats 风格或 Python 子进程测试(虚构目录:只删过期的本前缀文件,其他文件不动) | +| **T2 恢复演练 runbook** | `docs/operations/production-restore-drill-20261014.md`:从某一天的备份 artifact 还原到一次性库(不碰 `jyotisha` 主库)→ 核对表行数 → 删除;以及真正整库还原的步骤(停写、还原、核对、恢复服务)与预计停机时间。演练结果模板 `docs/testing/production-restore-drill-20261014.md` | 步骤可照做;演练由产品在内测开放前执行一次,结果回填模板 | +| **T3 探活与告警** | `deploy/monitor/healthcheck-alert.sh`:每分钟 GET `https://jyotisha.chat/api/health`(超时 10s),连续 3 次失败或 `status != ok` 或 `deployment.gitCommit` 与上次不同且非发布窗口时发告警;恢复时发「已恢复」;同时检查主机磁盘 ≥80% 告警。Webhook URL 从 `/opt/jyotisha-production/.monitor.env`(0600)读取。runbook:在哪台机器装(建议 Gitea runner 所在机器,**不在生产主机本身**——主机挂了它也就挂了;磁盘检查例外,需在生产主机上单独跑)、用 cron 还是 systemd timer。不在 Gitea Actions 里每分钟跑 job(runner 负担大、间隔不可靠) | Shell 测试:用本地桩服务模拟 200 / 500 / 超时 / 字段缺失;连续 3 次才告警、恢复只发一次、无 Webhook 时只写本地日志;告警正文只含允许字段 | +| **T4 错误汇总(不外发正文)** | `deploy/monitor/error-digest.sh`:每 5 分钟读最近 5 分钟的 `web` / `api` 容器日志(`docker compose logs --since`),按「服务 + 错误类别 / 状态码 / agent 观测里的 errorCode」计数;超过阈值(默认 5 分钟内同类 ≥10 次,或任何 5xx ≥20 次)发告警,消息里附最多 5 个 requestId;每天 09:00 发一份前一天汇总。需要时在 web / api 的错误日志里补齐结构化字段(`requestId`、`route`、`errorCode`),**不加用户内容** | 用虚构日志样本测试分类与阈值;断言告警文本不含邮箱、出生日期、会话文本(用含这些内容的样本日志验证会被丢弃) | +| **T5 日志轮转与留存** | `deploy/docker-compose.server.yml` 与 `docker-compose.postgres.yml` 每个服务加 `logging: { driver: json-file, options: { max-size: "20m", max-file: "10" } }`(约每服务 200 MB 上限);runbook 说明主机 daemon 级默认值如何确认 | 合同测试:每个服务都有 logging 段且值一致;compose `config` 能解析(本地 `docker compose -f ... config` 跑一次) | +| **T6 按 requestId 查日志** | `deploy/find-request-log.sh [--since 24h]`:在 web / api / caddy 日志里检索并按时间排序输出;只读。文档写进 `deploy/README.md` 运维段 | 用虚构日志样本测试;拒绝非 UUID / 非约定格式的参数(防命令注入) | +| **T7 文档与登记** | `deploy/README.md` 新增「运维与告警」段:备份在哪、保留多久、怎么还原、告警发到哪、怎么查一轮请求;`BLOCKED.md` 登记需产品在主机上执行的安装步骤(解除后划掉) | Claude 逐条对照脚本 | + +## 7. 让步顺序 + +T1、T3、T5 不可让(内测开放的最低线:有备份、挂了有人知道、磁盘不会被日志写满)。T2 runbook 不可让,演练执行可在开放前最后一天完成。T4 可让为「内测第一周补上」,但 T6 不可让(至少能查)。 + +## 8. 验收(执行方自测 + Claude 复验) + +- Shell:`bash -n`;`shellcheck`(有则跑);上表各项测试。 +- 合同测试:放在 `frontend/tests/staging-backend-workflows.test.ts` 同类文件或新增 `tests/test_beta_safety_net.py`。 +- 前端改动(若在 web 错误日志补字段):`tsc --noEmit` 0 错;`npm run lint` 0 error;`npm test` 失败名单与基线逐名一致。 +- Python:快速门 `scripts/run_quality_gate.py --profile quick`。 +- 隐私:`tests/test_repo_privacy_markers.py`;新增测试样本只用虚构数据。 +- 文档:`docs/BUG_HISTORY.md`(无备份 / 无告警 / 日志不轮转各记一条或合并一条,状态写清「代码已交,主机安装待产品」)、`CHANGELOG.md`(运维变化,用户不可见可写一句)、`docs/tasks/PROGRESS-beta-safety-net-20261014.md`、`docs/tasks/README.md` 状态。 + +## 9. 开工前置 + +``` +git fetch origin --prune +git status -sb +# 确认发布通路单已合入:git log origin/staging --oneline | grep -i release-path +git worktree add -b codex/beta-safety-net-20261014 .worktrees/beta-safety-net-20261014 origin/staging +``` + +## 10. 需产品提供 / 拍板 + +1. **告警发到哪**:建议企业微信 / 钉钉 / 飞书任选一个群,建一个群机器人,把 Webhook 地址交给你自己保管、写进主机 `.monitor.env`(不要发到聊天里)。不选的话只写本地日志,等于没有告警。 +2. **探活装在哪台机器**:建议 Gitea runner 所在机器(与生产主机分开);磁盘检查另装在生产主机。 +3. **备份保留**:默认 Gitea artifact 14 天 + 主机本地 7 天;有更长要求(如 30 天)告诉 Claude,注意 Gitea 服务器磁盘。 +4. **恢复演练时间**:内测开放前执行一次,约 30 分钟,不影响线上(还原到一次性库)。 +5. **每日汇总发送时间**:默认 09:00。 diff --git a/docs/tasks/TASK-release-path-production-20261014.md b/docs/tasks/TASK-release-path-production-20261014.md new file mode 100644 index 00000000..1f631e11 --- /dev/null +++ b/docs/tasks/TASK-release-path-production-20261014.md @@ -0,0 +1,135 @@ +# TASK · 生产发布通路改规则 + 内测首发方案(2026-10-14) + +- 日期:2026-10-14 +- 来源:产品 10-14 内测体检后拍板:**内测跑生产**;发布规则**授权修改**(含 `.gitea/workflows/**`)。体检证据见本单 §2。 +- 执行方:**Grok(Windows)**,分支 `codex/release-path-production-20261014`,只推这个分支。Grok 碰不到生产主机、不能 dispatch 生产工作流:本单产出的是 workflow / 脚本 / 测试 / runbook,生产上的每一步由产品在 Gitea 上点按钮执行。 +- 验收:Claude(Linux)。合入 staging 由 Claude 做;**提升 main 与生产 dispatch 只由产品做**。 +- BUG 编号:开工时核对最大号顺延(写作时 BUG-1317)。注意:其他已推任务书在 `docs/tasks/` 里已预占到 BUG-1346,开工时取 `docs/BUG_HISTORY.md` 与 `docs/tasks/` 已引用编号两者的最大号顺延。 +- 串行:与 `TASK-beta-safety-net-20261014` 都可能改 `deploy/README.md`、`AGENTS.md`;**本单先做**,安全网单在本单合入后开工。与 issue #57(校正重算)、#58(Node 进程资源)不改同一文件;若 #58 改了 `deploy/docker-compose.server.yml`,本单不碰该文件。 + +## 1. 基线 + +- 基线 commit:`origin/staging` `48a2fadd`(`0be4cb29` 之后只多文档提交);最近一次含门禁改动且已部署 staging 的提交以开工时 `/api/health` 为准。 +- 生产:`https://jyotisha.chat/api/health` `deployment.gitCommit = 7b620c7a`(08-16);`origin/main = 7b620c7a`;main..staging 约 1674 个提交。 +- 生产相关工作流(全部 `workflow_dispatch`,由产品手动触发):`release-quality-gate.yml`、`create-production-recovery.yml`、`migrate-production-database.yml`、`deploy-production.yml`。Gitea Actions 记录:四者最后一次运行都在 **08-16**(release 1872/1876/1880、recovery 1881、migrate 1882、deploy 1883)。 + +## 2. 事故实证(按符号定位) + +### 2.1 纯文档 head 卡死发布 + +`deploy-production.yml` 步骤「Validate tested revision and gate run」: + +1. 要求 `REQUESTED_SHA` 自己有 `backend-quality-gate.yml` 的 `push / staging / success` 运行。纯文档提交不在 `deploy/gated-paths.txt` 内,**不触发门禁,永远没有这条记录**。 +2. `controller_sha`(= main head)`== staging_head`,否则拒绝。 +3. 非回滚时 `REQUESTED_SHA == staging_head`,否则 `stale production revision refused`。 +4. `controller_sha != REQUESTED_SHA` 时再找 controller 的门禁运行(纯文档 controller 同样找不到)。 +5. 要求 `REQUESTED_SHA` 有 `release-quality-gate.yml` 的 dispatch 成功运行;该工作流 checkout 的是 `gitea.sha`(被 dispatch 的分支 head),所以 head_sha 只能等于分支 head。 +6. 部署前后 `require_current_release_heads`:`current_main == DEPLOY_SHA && current_staging == DEPLOY_SHA`。 + +`create-production-recovery.yml`(`GITEA_SHA == DEPLOY_SHA`,`main_head == staging_head == DEPLOY_SHA`,release 运行 head_sha == DEPLOY_SHA)与 `migrate-production-database.yml`(同样三条 + push 门禁运行)有相同约束。 + +结果:staging 只要在最后一次代码提交之后再推过任何文档(agent 循环每天都会推),四个生产工作流全部无法通过;唯一出路是冻结 staging。产品选择改规则。 + +### 2.2 待上线迁移:63 个,全部是新增文件 + +`git diff --name-status origin/main origin/staging -- frontend/supabase/migrations frontend/db/migrations`:63 个 `A`,`frontend/db/migrations` 0 个。按顶层语句(去掉函数体后)分级: + +| 级别 | 数量 | 迁移 | 说明 | +| --- | --- | --- | --- | +| **A 破坏性 / 改写用户数据** | 2 | `20260925020000_chart_subject_typed_columns.sql`:先 backfill 到类型化列,再顶层 `delete from public.chart_profiles where role = 'self'`(本人资料改由 `profiles` 承载)。`20260921010000_retire_chat_session_archive.sql`:`update public.chat_sessions set archived_at = null where archived_at is not null`(所有已归档会话回到列表) | 不可逆;必须先在恢复点副本上演练 | +| **B 改写数据(范围有限)** | 5 | `20260816010000_accept_exact_family_birth_times`(`profiles` 家人精确时间直接 accepted)、`20260822010000_declared_birth_window_clocks`(按时段回填申报窗口)、`20260823010000_rectification_occupation_dateless`(职业/外貌/胎记证据置 confirmed)、`20260906030000_rectification_block_scan_stage`(minute 阶段未采用 case 改 block_scan)、`20260925030000_chart_subject_trust_and_limit`(**他人档案的 accepted / confirmed 时间全部退回 reported、清空 active_birth_***) | 演练时逐条记行数;`20260925030000` 会让他人档案失去已采用时间,需产品知情 | +| **C 权限收紧** | 约 45 | 绝大多数文件含 `revoke`(函数 / 表 EXECUTE、SELECT 收回) | 上线后 web / worker 角色可能失权(同类问题见 BUG-1063:07-18 迁移收回了 worker 对人物资料表的权限);演练后必须跑主路径冒烟 | +| **D 纯新增** | 其余 | 加表 / 列 / 函数 / 索引 | 低风险 | + +注:A+B 合计 7 条是按「顶层 `delete` / `update`」机械筛出;函数体内的 `delete`(注销、清理)不计。执行方需复核每条分级并补全 C/D 清单。 + +另:重复时间戳仍有 3 组(`20260806010000`、`20260816010000`、`20260920010000`),`frontend/db/migrations` 另有 `20260807010000` 一组。迁移器按文件名记账,功能无害,本单只记录不改名(改名会让已迁移的 staging 重复执行)。 + +### 2.3 生产可以误开测试验证码 + +`frontend/src/modules/identity/config.ts::readTestOtp`:只要设置了 `IDENTITY_TEST_OTP`(6 位数字)就接受固定验证码,不区分环境。`deploy/validate-production-env.sh` 不检查这个变量。生产登录页当前负载 `testOtp: null`(只读 GET 核对),但没有任何防误配机制;内测跑生产后,误配一次即任何人可用固定码登录任意邮箱。 + +## 3. 根因 + +- 生产工作流在 08-16 前设计,当时没有「纯文档提交不触发门禁」(`deploy/gated-paths.txt` 与 `deploy/is-docs-only-range.sh` 是之后才加的),四个工作流都假设「每个 staging head 都有门禁运行」。staging 侧已用 `is-docs-only-range.sh` 解决(AGENTS §2.4),生产侧没跟上。 +- 生产停在 08-16 两个月,63 个迁移在 staging 逐个上线、逐个验证,但从未作为一批在生产数据上演练过。 +- 测试验证码只在 staging 配置文档里出现,生产校验脚本是白名单思路,没把它列为禁止项。 + +## 4. 决策记录(产品 2026-10-14 授权) + +1. **授权修改 `.gitea/workflows/` 下的 `deploy-production.yml`、`create-production-recovery.yml`、`migrate-production-database.yml`、`release-quality-gate.yml`**(只为本单的发布规则;不改 staging 工作流的触发与部署逻辑)。这推翻 AGENTS.md §2.7「不改 `.gitea/workflows/**`」在本单范围内的限制,§2.7 其余(不自行提升 main、不动 DNS)不变。 +2. **新发布规则**(取代「请求 SHA == staging head」): + - 记 `H` = 当前 staging head,`main` 必须快进到 `H`(继续满足 AGENTS §2.5「main 与 staging 同一 SHA」,不得 merge)。 + - 发布 SHA `R` = **最近一次 `backend-quality-gate` push 成功的 staging 提交**,并且 `deploy/is-docs-only-range.sh R H` 退出 0(R 是 H 的祖先,R..H 之间只改了门禁清单之外的路径)。 + - 由于 R..H 不改门禁路径(含 `deploy/**`、`.gitea/**`、`frontend/**`、`scripts/**`),R 与 H 的部署脚本、镜像输入完全相同;controller 一律取 R 的门禁产物。 + - `release-quality-gate` 仍从 main dispatch(跑在 H 上),记录的 head_sha = H;生产三个工作流接受「release 运行的 head_sha ∈ {R, 当前 main},且该 SHA 与 R 之间纯文档」。 + - staging 公网 `/api/health` 的 `deployment.gitCommit` 必须等于 R(与 AGENTS §2.4 一致)。 + - `allow_rollback=true` 的回滚语义不变(回滚目标仍须在 main 历史里、有门禁运行)。 +3. 同步改 AGENTS.md §2.4–§2.5 与 `deploy/README.md` 的生产发布段落,文字与新规则一致。 +4. 生产首发的执行顺序固定为:**release 门 → 恢复点(含还原验证)→ 迁移演练报告确认 → 生产迁移 → 部署 → 核对**。每一步由产品在 Gitea 点按钮;Grok 不 dispatch 任何生产工作流。 +5. 生产运行时拒绝测试验证码(双保险:env 校验脚本 + 运行时)。 +6. 不在本单做:备份定时化、监控告警、日志轮转(见 `TASK-beta-safety-net-20261014`);按用户限流与并发压测(另单);备案 / 主体 / 律师(产品后续自理)。 + +## 5. 硬红线 + +1. 不得放宽任何安全检查:仍要求 R 有 push 门禁成功运行、有 release 门成功运行、有 ≤24h 且 `restore_verified=true` 的恢复点(迁移)、`main == staging`、生产 `previous_sha` 到 R 的单向前进校验、回滚显式确认。新规则只把「等于 staging head」换成「是 staging head 且之后纯文档」,`is-docs-only-range.sh` 返回 1 或 2 一律拒绝。 +2. `is-docs-only-range.sh` 不改语义;若需在工作流里调用,用已 checkout 的 R 或 H 版本,API 模式用现有 `GITEA_TOKEN`,不新增密钥。 +3. 不改 staging 部署工作流、不改 `deploy/gated-paths.txt`(若发现必须改,停下回报)。 +4. 不在任何日志 / 回报里打印密钥、数据库口令、`.env.production*` 内容、恢复点加密密钥。 +5. 迁移文件**不改**(已在 staging 执行过,改内容会造成两库不一致);需要补救就新增迁移,并在回报里说明。 +6. Grok 不 dispatch 任何生产工作流、不 push main、不推 staging。 +7. 不写真实用户资料。演练报告只写行数和表名,不写任何行内容。 + +## 6. 任务分解 + +| 项 | 内容 | 验收标准 | +| --- | --- | --- | +| **T1 发布 SHA 解析脚本** | 新增 `deploy/resolve-release-revision.sh`(名字可调):输入 `REQUESTED_SHA`、读 main / staging head(Gitea API),校验 `main == staging == H`、`REQUESTED_SHA` 有 push 门禁成功运行、`is-docs-only-range.sh REQUESTED_SHA H` 退出 0;输出 `release_sha`、`gate_run_id`、`head_sha` 供后续步骤使用。四个生产工作流共用这一个脚本,不各写一份 jq。 | Python 测试(mock Gitea API 响应:本地 HTTP 桩或把 curl 换成可注入的读文件函数)覆盖:R==H 通过;R` → 记录迁移前 7 条 A/B 迁移涉及表的行数 → 按迁移器顺序执行 63 个迁移 → 记录迁移后行数与每个迁移的 `RAISE NOTICE` → 用 web 运行角色做只读冒烟(能读本人 profile、能读会话列表、worker 角色能读人物资料表——对应 BUG-1063)→ 删除一次性库 → 输出不含任何行内容的报告。复用 `run-production-recovery.sh` 的加密 / 还原 / 锁 / 磁盘检查写法,不另造。另写 `docs/testing/production-migration-rehearsal-20261014.md` 报告模板与通过判据 | 在**本地 PostgreSQL**(Docker 或 Claude 机器的替身)上用 staging 迁移前的 schema 快照跑通脚本(演示数据只能是虚构数据);判据写清:A 类两条的行数变化与预期一致、C 类冒烟全过、无迁移报错 | +| **T6 迁移清单** | `docs/operations/production-migrations-20261014.md`:63 个迁移逐条:文件名、级别(A/B/C/D)、一句话作用、对生产用户的影响、演练要看的指标。§2.2 是初筛,执行方逐条复核 | 63 条齐全;A/B 每条有「产品需知」一句 | +| **T7 禁止生产测试验证码** | `deploy/validate-production-env.sh`:出现 `IDENTITY_TEST_OTP=`(任何值,含空)即失败。`readTestOtp` 或其调用处:当 `AUTH_USER_ORIGIN` 为生产域名(`https://jyotisha.chat`)时,设置了测试码直接抛错拒绝启动(fail closed);staging 行为不变 | Shell 测试:生产 env 含该变量失败、不含通过;TS 测试:生产 origin + 测试码抛错、staging origin + 测试码返回码、未设置返回 null | +| **T8 首发后核对清单** | `docs/testing/production-first-release-20261014.md`:`/api/health` 200 且 `deployment.gitCommit == R`、`apiGitCommit == R`、`swisseph_available == true`(内部 health)、`database.requiredMigrationsPresent == true` 且 `latestMigration` 等于 staging 同字段;`/login` 200 且负载 `testOtp` 为 null;未登录 `/api/account` 401;用受控邮箱走一遍登录 → 建盘 → 一次对话 → 打开报告页;后台 `/admin` 只有管理员能进 | 清单可照做,每条有预期值 | + +## 7. 让步顺序 + +T1、T2、T3、T7 不可让(没有它们发不了版或有安全缺口)。T4 不可让(文档与规则不一致会让下一个人照旧文档操作)。T5 可先交脚本 + 本地验证,主机演练由产品执行后回填。T6、T8 可与 T5 一起交。 + +## 8. 验收(执行方自测 + Claude 复验) + +- 前端:`tsc --noEmit` 0 错;`npm run lint` 0 error;`npm test` 失败名单与开工基线逐名一致(Windows 已知失败按 `tools/agent-loop/windows-known-failures.txt`)。 +- Python:新增测试 + 快速门 `scripts/run_quality_gate.py --profile quick`。 +- Shell:`bash -n` 全部新脚本;`shellcheck`(有则跑)。 +- 工作流:本地无法运行 Gitea Actions——合同测试断言 workflow 文本;Claude 合入 staging 后,`backend-quality-gate` 会跑在含新 workflow 的提交上(`.gitea/**` 是门禁路径)。生产工作流的实跑由产品首发时完成,结果回填 T8 清单。 +- 文档:`docs/BUG_HISTORY.md`(发布卡死、测试验证码各一条)、`CHANGELOG.md`(一句:生产可以在 staging 之后有文档提交时照常发布)、`docs/tasks/PROGRESS-release-path-production-20261014.md`、`docs/tasks/README.md` 状态。 + +## 9. 开工前置 + +``` +git fetch origin --prune +git status -sb +git worktree add -b codex/release-path-production-20261014 .worktrees/release-path-production-20261014 origin/staging +``` + +开工时记录 `origin/staging` SHA 与 staging `/api/health` 的 `deployment.gitCommit`,写进进度记录。 + +## 9a. 生产首发步骤(写进 T4 runbook,由产品执行,本单合入 staging 并部署 staging 之后) + +1. 确认 staging `/api/health` 的 gitCommit = R,且 `deploy/is-docs-only-range.sh R ` 退出 0。 +2. 产品把 main 快进到 staging head:`git push origin :main`(或 Gitea 界面快进),不得 merge。 +3. Gitea → Release Quality Gate,Use workflow from: `main`,等成功。 +4. Gitea → Create Production Recovery Point,`deploy_sha = R`,记下 `recovery_reference`、`recovery_created_at`。 +5. 主机上按 T5 脚本演练迁移(或由 Claude 远程指导),报告填进 `docs/testing/production-migration-rehearsal-20261014.md`;A 类两条行数、C 类冒烟全部符合判据才继续。 +6. Gitea → Migrate Production Database,填 R、恢复点引用、时间、`restore_verified=true`(24 小时内)。 +7. Gitea → Deploy production,`deploy_sha = R`,`allow_rollback=false`,`verification_mode=public`。 +8. 按 T8 清单核对。失败:应用层用 Deploy production `allow_rollback=true` 回到 `7b620c7a`(注意:迁移已执行后旧代码可能读不了新 schema——回滚以「恢复点还原 + 回滚应用」为准,runbook 里写清由谁、怎么还原)。 + +## 10. 需产品提供 / 拍板 + +1. **首发时间窗**:建议选晚上低峰,整个流程约 1–2 小时;首发前一天冻结 agent 循环向 staging 推代码(文档可以推)。 +2. **`20260925030000` 他人档案已采用时间退回 reported**:内测前接受,还是另立补救迁移(例如只退回无校正记录的)——演练报告出来后拍板。 +3. **`20260921010000` 已归档会话全部回到列表**:确认这是想要的(该迁移对应「取消归档功能」的产品决定)。 +4. **迁移演练在哪执行**:主机上由产品执行脚本,或给 Claude 一次受控的主机只读 + 一次性库操作权限(默认前者)。 +5. **回滚口径**:首发失败时是否接受「恢复点整库还原」(会丢失首发后写入的数据);内测开放前失败则无影响。 diff --git a/docs/testing/beta-smoke-20261014.md b/docs/testing/beta-smoke-20261014.md new file mode 100644 index 00000000..d52ff2a5 --- /dev/null +++ b/docs/testing/beta-smoke-20261014.md @@ -0,0 +1,18 @@ +# 内测前冒烟清单(草稿,2026-10-14) + +> 先在 staging 走,生产首发后用产品自己的账号再走前 5 条。登录:staging 任意测试邮箱 + 固定验证码(产品掌握,不写进本文件)。每条填「结果(通过 / 不通过 / 没走)」「日期」「备注(截图路径)」。不通过的写现象原话,Claude 立单。 +> 账号准备:后台「兑换码」页生成测试码,给测试账号兑换点数(新账号默认 0 点)。 + +| # | 功能 | 前置 | 步骤 | 预期 | 对应 BUG / 原清单 | 结果 | 日期 | 备注 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 1 | 新人第一次进站 | 一个从没用过的邮箱;手机 Safari | 打开登录页 → 勾选同意 → 输入邮箱 → 发验证码 → 输入固定码 → 填出生资料(城市、日期、时间) | 不要求设密码;建盘后先看到群星汇聚过场,**不先弹兑换窗**;过场后进入首页 | BUG-1080、1152;`staging-otp-skip-password-20261001.md`、`birth-sky-cover-checklist.md` | | | | +| 2 | 首页 | 第 1 条的账号 | 停留首页、刷新一次 | 不卡在「正在载入账户」;今日星语有当天内容(不是「没写出来」);会话列表正常 | BUG-936、283 / 299;`home-first-load-20260930.md`、`session-list-rebuild-20260921.md` | | | | +| 3 | 寒暄不扣点 | 记下当前点数 | 新对话发「你好」 | 一句简短回应,不排盘、不出长解读;点数不变 | BUG-976、977;`consult-smalltalk-fastpath-20260920.md` | | | | +| 4 | 普通对话 + AI 标识 | 有点数 | 问一个事业问题,再追问一句 | 回答几段人话、不念内部清单;输入框下方有「回答由 AI 生成,仅供参考……」一行;iPhone 上正文右侧无大块空白、不抖;收起键盘页面不上移 | BUG-1252、1253、1255、920、内测主路径单 T2;`consult-readable-20261006.md`、`mobile-viewport-scroll-lock-20260917.md` | | | | +| 5 | 明说要校正 | 档案出生时间是「大约」 | 新对话说「帮我做一下生时纠正」;另开一个对话说「这个解读我不认同」 | 第一句直接进入校正,不先写解读;第二句**不**进入校正,只改读法 | BUG-1306、1317;`rectification-explicit-entry-20261012.md` 第 1、1b、4、9 步 | | | | +| 6 | 校正开场 | 第 5 条进入的校正 | 看开场三句 | 说出填的时间离最近换段差几分钟、两边哪几件事不同;离得近时有「这里连本命上升都换」;不出现秒、「记录时间是错的」 | BUG-1307、1309;`rectification-nearest-boundary-opening-20261012.md` | | | | +| 7 | 人物档案不串人 | 人物档案里加一位家人 | 切换到家人 → 设为默认 → 回到本人 → 再切家人 | 每次显示的资料都是对应的人,名字、出生资料不串 | BUG-1030;`people-archive-p1-20260924.md` | | | | +| 8 | 给别人出报告 | 第 7 条的家人;有点数 | 选家人 → 生成报告 → 等完成 → 打开 | 报告能生成完成(不一直排队、不失败退款),标题下有「本报告由 AI 生成,仅供参考。」 | BUG-1063、内测主路径单 T1/T2 | | | | +| 9 | 本人报告 | 本人 | 生成一份中文完整报告并打开;切英文看一眼 | 开篇重点卡正常;英文版无中文残留;结尾免责在 | BUG-1312~1316;`report-translation-gaps-20261013.md`、`report-focus-card-20261013.md` | | | | +| 10 | 反馈与注销 | 任一测试账号 | 对一条回答点踩并写一句反馈 → 后台「反馈」页查看;账户设置里申请注销 → 再撤销 | 后台能看到反馈;注销申请显示 7 天冷静期,撤销后账户恢复 | `surface-feedback-20260929.md`、`legal-consent-20260930.md`、`compliance-launch-20260930.md` | | | | +| 11 | 限流与排队(容量单合入后) | 两台设备同一账号 | 1 分钟内连发 7 次提问;另找 3 个账号同时做校正 | 第 7 次得到一句「太快了,稍等一下」类提示,不扣点;同时校正的人看到「排一下队」后照常继续,不报错 | 内测容量单 T1/T2 | | | | From 74384be0243b16e7a261c2afdea2ca12bab92285 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 14 Oct 2026 00:58:13 +0800 Subject: [PATCH 5/5] docs(tasks): product answers for the closed-beta briefs (Feishu alerts, restore on failure, rate limits, label copy) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HwMNZEzdzmrbPhuRNmZe3C --- docs/tasks/TASK-beta-capacity-20261014.md | 8 +++++++- docs/tasks/TASK-beta-main-path-20261014.md | 5 +++++ docs/tasks/TASK-beta-safety-net-20261014.md | 5 +++++ docs/tasks/TASK-release-path-production-20261014.md | 6 ++++++ 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/tasks/TASK-beta-capacity-20261014.md b/docs/tasks/TASK-beta-capacity-20261014.md index f21891e0..b226f3d4 100644 --- a/docs/tasks/TASK-beta-capacity-20261014.md +++ b/docs/tasks/TASK-beta-capacity-20261014.md @@ -52,7 +52,7 @@ | 项 | 内容 | 验收 | | --- | --- | --- | | T0 | 核对 staging 与生产主机规格(vCPU、内存、`JYOTISH_HEAVY_COMPUTE_CONCURRENCY` 实际值);在 staging 用现有代码跑一次 T4 的压测作为**改前基线**(若 #57 未合入,记清楚) | 进度记录有规格对照与改前数字 | -| T1 | 按用户限流:`requestRateLimits` 新增 `consult`(建议 6 次 / 分钟、60 次 / 小时两档,用两个桶)、`rectificationTurn`(建议 10 次 / 分钟)、`reportGenerate`(建议 3 次 / 小时,与每日 5 份并存)。接入 `/api/consult`、校正回合路由(`frontend/src/app/api/rectification/agent/route.ts`,开案 `cases/open` 不计入)、报告生成 `frontend/src/app/api/reports/route.ts` POST。超限回 429 + `code: "rate_limited"` + `Retry-After`,**在扣点 / 预留之前**判断,不扣点 | 单元测试:各桶边界放行 / 拒绝、拒绝时不调用扣点 RPC;路由测试断言 429 体与头 | +| T1 | 按用户限流:`requestRateLimits` 新增 `consult`(**10 次 / 分钟、120 次 / 小时**两档,用两个桶)、`rectificationTurn`(**5 次 / 分钟、40 次 / 小时**两档)、`reportGenerate`(建议 3 次 / 小时,与每日 5 份并存)。接入 `/api/consult`、校正回合路由(`frontend/src/app/api/rectification/agent/route.ts`,开案 `cases/open` 不计入)、报告生成 `frontend/src/app/api/reports/route.ts` POST。超限回 429 + `code: "rate_limited"` + `Retry-After`,**在扣点 / 预留之前**判断,不扣点 | 单元测试:各桶边界放行 / 拒绝、拒绝时不调用扣点 RPC;路由测试断言 429 体与头 | | T2 | 重计算闸短排队:`acquire_heavy_compute_slot` 改为带超时等待(建议最多等 8 s,等待者上限 = 4),超时或等待者满仍抛 `HeavyComputeBusy`;`Retry-After` 不变。前端 `/api/consult` 对 `workflow_rate_limited` 按 `Retry-After` 最多重试 2 次、总等待 ≤ 15 s,期间进度行显示一句「人有点多,排一下队」(文案进 VOICE);仍失败时用一句话说明并退回本轮预留点数(沿用现有失败结算路径,核实不重复退款) | Python 测试:并发 3 个请求,第 3 个在前一个释放后拿到位;等待者满时立刻 429;超时 429。前端测试:429 → 重试 → 成功;三次都 429 → 失败文案 + 退点(复用现有结算断言) | | T3 | 内测点数上限:核实兑换码表能否设单码点数与批量生成(后台「兑换码」页),写一页操作说明 `docs/operations/beta-invite-codes.md`:怎么批量生成邀请码、每码点数、怎么查谁用了、怎么作废。不写真实码 | 文档;若后台缺批量生成或作废能力,写进进度记录与 BLOCKED,不在本单扩功能 | | T4 | 冒烟压测脚本(放 `frontend/scripts/research/beta-load-smoke.mts`,不进镜像,确认不被 `frontend/src` 引用——参照 BUG-1140 / `runtime-sources-no-test-imports.test.ts`):N 个虚拟用户(10、20 两档),每人:OTP 登录 → 首轮咨询 → 一次追问;其中 3 人改为开一次校正并答 2 题。记录:每请求状态码、首字时间、整轮完成时间、429 比例;同时在服务器侧采样 API / web 容器内存与 CPU(`docker stats` 每 5 s,执行方没有 staging SSH 时写成环境缺口,只报客户端侧) | 进度记录给改前 / 改后两组数字。**通过线**(10 并发):429(最终失败,不含重试成功)≤ 2%;5xx = 0;咨询首字 P95 ≤ 15 s、整轮 P95 ≤ 90 s;API 容器内存峰值 < 1.5 GB。20 并发只记录不设门,用来定内测同时在线上限 | @@ -84,3 +84,9 @@ git worktree add -b codex/beta-capacity-20261014 .worktrees/beta-capacity-202610 3. 每个邀请码给多少点;内测第一批多少人(压测 20 并发的结果出来后再定同时在线上限)。 4. 压测会花模型调用费用(约 20 人 × 2 轮 × 两次),同意在 staging 跑。 5. 若 Grok 侧拿不到 staging 主机 SSH,服务器内存 / CPU 采样由谁跑(Claude 也没有主机权限时写成环境缺口)。 + +## 11. 产品答复(2026-10-14,覆盖上文对应建议值) + +1. 限流:产品要「对话多一些、校正少一些」→ 对话 10 次 / 分钟 + 120 次 / 小时;校正回合 5 次 / 分钟 + 40 次 / 小时;报告 3 次 / 小时(+ 每日 5 份)不变。T1 表格已按此改。 +2. 压测花模型费:**同意**(staging 10 / 20 并发各一轮,按 T4)。 +3. 其余(排队长度与等待时间、每码点数、首批人数)按任务书建议默认值执行,产品后续可改。 diff --git a/docs/tasks/TASK-beta-main-path-20261014.md b/docs/tasks/TASK-beta-main-path-20261014.md index a635d63b..30713d6e 100644 --- a/docs/tasks/TASK-beta-main-path-20261014.md +++ b/docs/tasks/TASK-beta-main-path-20261014.md @@ -98,3 +98,8 @@ grep -rn "chat-composer" frontend/tests | head # 找按源码文本断言的 2. 标识文案:对话「回答由 AI 生成,仅供参考,不构成医疗、法律或投资建议。」、报告「本报告由 AI 生成,仅供参考。」——同意或改字;以及同意改 DESIGN「输入框下方不放东西」这条的字面。 3. 冒烟清单由谁走:产品用 staging 测试邮箱 + 固定验证码真机走,还是先由 Claude 用接口层(无浏览器)走一遍可接口化的条目(他人报告、「你好」不扣点、限流),再由产品补真机部分。 4. 生产首发后,冒烟清单是否在生产再走一遍(建议走前 5 条,用产品自己的账号)。 + +## 11. 产品答复(2026-10-14,覆盖上文对应建议值) + +1. 标识文案**同意**:对话「回答由 AI 生成,仅供参考,不构成医疗、法律或投资建议。」;报告「本报告由 AI 生成,仅供参考。」 +2. BUG-1063 修法按决策记录 2(逐列只读授权)。 diff --git a/docs/tasks/TASK-beta-safety-net-20261014.md b/docs/tasks/TASK-beta-safety-net-20261014.md index b2751c9d..c3ad81f2 100644 --- a/docs/tasks/TASK-beta-safety-net-20261014.md +++ b/docs/tasks/TASK-beta-safety-net-20261014.md @@ -87,3 +87,8 @@ git worktree add -b codex/beta-safety-net-20261014 .worktrees/beta-safety-net-20 3. **备份保留**:默认 Gitea artifact 14 天 + 主机本地 7 天;有更长要求(如 30 天)告诉 Claude,注意 Gitea 服务器磁盘。 4. **恢复演练时间**:内测开放前执行一次,约 30 分钟,不影响线上(还原到一次性库)。 5. **每日汇总发送时间**:默认 09:00。 + +## 11. 产品答复(2026-10-14,覆盖上文对应建议值) + +1. 告警渠道:**飞书群机器人**(Webhook 地址由产品在主机 / runner 的环境里配置,不进仓库、不进日志)。T3 按飞书机器人消息格式实现(`msg_type: text`,可选签名校验)。 +2. 其余按任务书建议默认值。 diff --git a/docs/tasks/TASK-release-path-production-20261014.md b/docs/tasks/TASK-release-path-production-20261014.md index 1f631e11..ebc39138 100644 --- a/docs/tasks/TASK-release-path-production-20261014.md +++ b/docs/tasks/TASK-release-path-production-20261014.md @@ -133,3 +133,9 @@ git worktree add -b codex/release-path-production-20261014 .worktrees/release-pa 3. **`20260921010000` 已归档会话全部回到列表**:确认这是想要的(该迁移对应「取消归档功能」的产品决定)。 4. **迁移演练在哪执行**:主机上由产品执行脚本,或给 Claude 一次受控的主机只读 + 一次性库操作权限(默认前者)。 5. **回滚口径**:首发失败时是否接受「恢复点整库还原」(会丢失首发后写入的数据);内测开放前失败则无影响。 + +## 11. 产品答复(2026-10-14,覆盖上文对应建议值) + +1. 首发时间窗:首发前一天冻结代码推送(只许纯文档);首发失败**接受整库还原**到首发前恢复点。 +2. 迁移演练由**产品本人**在 Gitea 上点按钮执行(Grok 只交脚本与 runbook);runbook 写成产品可照做的步骤,每步写清看哪个输出判断通过。 +3. `20260925030000`(他人档案时间退回 reported):演练时先数生产受影响行数(预期 0,生产停在 08-16、人物档案 09 月下旬才上线),非 0 则停下报产品。