Compare commits

...
Author SHA1 Message Date
jesse-uxandGrok (agent loop) 9e29310941 合并 staging,并让发问准备的检查跟上新位置
两条按源码原文查找的检查改认搬走后的写法,意思没有放宽。对照脚本挪到测试目录,镜像构建不再去找测试文件。业务代码和三个开关的默认值没改。

自测:相关前端测试 40 通过,失败 0,取消 0。假时钟仍是并行 160、串行 210。tsc 0 错,lint 0 error。

Co-Authored-By: Grok (agent loop)
2026-10-14 10:56:17 +08:00
Jesse_ChenandClaude Opus 5.5 5cb85800e5 docs(tasks): consult prep parallel fix brief (agent loop, issue #62, bounce 1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-14 10:20:22 +08:00
Jesse_ChenandClaude Opus 5.5 9cb0ce1a1a docs(tasks): report reference perf fix brief (agent loop, issue #61, bounce 1)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-14 09:56:22 +08:00
Claude cb8004a188 merge origin/staging into api disk cache retention (issue #60)
Independent Staging Quality Gate / validate (push) Successful in 13m18s
Independent Staging Quality Gate / publish (push) Successful in 3m37s
Docs-only conflicts resolved by keeping both; agent-loop rule A.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-14 09:11:01 +08:00
Claude f4c53e7f9f merge origin/staging into home sidebar rerender (issue #59)
Independent Staging Quality Gate / validate (push) Successful in 14m30s
Independent Staging Quality Gate / publish (push) Successful in 3m43s
Docs-only conflicts resolved by keeping both; agent-loop rule A.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
2026-10-14 08:09:22 +08:00
jesse-uxandGrok (agent loop) 6a94edf200 让充值入口的旧检查认侧栏的新写法
账户菜单里点充值,仍然打开充值面板,来源仍是账户菜单。只改了这一条按源码对照的检查,业务代码没动。tsc 通过,lint 0 个错误(125 条原有警告),这条检查 13 通过、0 失败。

Co-Authored-By: Grok (agent loop)
2026-10-14 07:53:38 +08:00
jesse-uxandGrok (agent loop) ad1dc89664 合并最新 staging 到侧栏重画这支
校正提速已经在测试线上。更新日志、缺陷记录和任务索引两边都留下。这单自己的修复还没开始。

Co-Authored-By: Grok (agent loop)
2026-10-14 07:50:24 +08:00
jesse-uxandGrok (agent loop) a0e223e20c 发问准备可以并行,三个开关先保持关闭(BUG-1337~1339)
开流前的注销检查可以和读会话、解析模型同时进行;审核改到扣点前等待,不再挡住第一行;模型版本可以短缓存;两个领域可以一起算。本机没有数据库中位数,也没有带星盘网络库的镜像,所以默认仍是关闭、缓存 0、领域逐个算。

自测:tsc 0 错;lint 0 error;相关测试失败 0、取消 0。test:db 未跑。

Co-Authored-By: Grok (agent loop)
2026-10-14 07:23:58 +08:00
jesse-uxandGrok (agent loop) 81e2e1cfed 服务器缓存按期限清理,审计快照默认不再落地
排盘缓存、官方快照、负缓存、网关任务和分盘段过期后会删掉。同一张盘的数字写法合成一把缓存键。失败快照只用短期限。自测留存、异步任务、增长合同、安全缓存和隐私标记;一万个文件的清理慢于 2 秒,命中率提升不到 2.5 倍,都按实测写入记录。

Co-Authored-By: Grok (agent loop)
2026-10-14 04:53:30 +08:00
jesse-uxandGrok (agent loop) 6350943152 回答生成时侧栏不再跟着每一帧重画
外壳只在会话、当前项或账户这些数据变了才更新。60 帧计数从注册 60 次、提交 60 次降到 0 次,点下去仍是最新一次操作。BUG-1329。

Co-Authored-By: Grok (agent loop)
2026-10-14 04:12:02 +08:00
48 changed files with 21596 additions and 18195 deletions
+2
View File
@@ -10,6 +10,8 @@ VEDASTRO_SELF_HOST_ENDPOINT=https://your-domain.example.com/vedastro
# VEDASTRO_ENABLE_NETWORK=0
# Cache / TTL / free-tier queue. These keep ordinary users from calling upstream directly.
# 失败结果另有短 TTL(VEDASTRO_OFFICIAL_FULL_SNAPSHOT_NEGATIVE_TTL_SECONDS,默认 300 秒,且不超过成功 TTL)。
# 这些缓存含出生资料,7 天为上限。
VEDASTRO_CACHE_TTL_SECONDS=604800
VEDASTRO_OFFICIAL_FULL_SNAPSHOT_CACHE_TTL_SECONDS=604800
VEDASTRO_GATEWAY_QUEUE_ENABLED=1
+7
View File
@@ -1,5 +1,12 @@
# BLOCKED
## TASK-consult-prep-parallel-20261013:审核真实延迟、Postgres 中位数、多领域镜像(2026-10-14)
- **阿里云审核**:本机没有审核密钥。1500 毫秒挡首帧的结论来自测试里的假等待,不是真实供应商。开关 `CONSULT_PREP_PARALLEL` 保持关闭。
- **本机 Postgres**:没有量 `getUser`、注销检查、读会话、按版本读模型各 30 次的中位数。因此不能判断首帧是否真的快了两成,也不能判断模型准备是否快了一半。两个相关开关保持关闭(并行关、版本缓存 0 毫秒)。
- **多领域并发**:没有拿带星盘网络库的镜像重跑。任务书里的起草试算大约只快一成,不到两成五的开启线。`CONSULT_DOMAIN_CONCURRENCY` 保持 1。
- **staging 前后对比**:要等这支分支部署之后才能看真实首行时间。本轮不改默认值。
## TASK-rectification-explicit-entry-20261012:T0 日志、T5 模型(BUG-1306,2026-10-12)
- **T0**:本 worktree 没有 staging 主机,读不到 web 容器 `docker logs`,也没有数据库。无法核对 10-11 那一轮是否调用了 `offer-birth-time-rectification`。不阻塞实现。产品可照做的查法写在 `docs/tasks/PROGRESS-rectification-explicit-entry-20261012.md`,只看工具名、status,以及该会话 messages 里有没有 `rectificationOffer`。
+9
View File
@@ -1,5 +1,14 @@
# 印度占星 Skill 更新日志
## 2026-10-14 — 服务器不再长期保留出生资料副本
- 排盘缓存、官方快照和相关临时文件会按期限删掉,不再一直堆在服务器上。审计用的快照文件默认不再落地。失败的官方快照只短时间复用。
- Skill 版本不变(BUG-1330~1332)。
## 2026-10-14 — 回答生成时侧栏不再跟着闪动 / 卡顿(待合入 staging)
- 回答生成时侧栏不再跟着闪动 / 卡顿。点会话、改名、换人仍是当前这一下的操作。Skill 版本不变(BUG-1329)。
## 2026-10-14 — 服务器长期运行不再逐日涨内存;数据库访问少走几趟(待合入 staging)
- 服务器长期运行不再逐日涨内存;数据库访问少走几趟。Skill 版本不变(BUG-1326~1328、BUG-1346)。
+18022 -17917
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
# PROGRESS · API 磁盘缓存留存(2026-10-14)
- 分支:`codex/api-disk-cache-retention-20261013`
- 任务书基线写的是 `77057a819`。本分支从当时的 `origin/staging` `66cd60081f37a57680274633fcb3fb942d01bb2f` 拉出。A/B 的父提交用的是这个 SHA,不是任务书里的旧基线。
- 接口文件行数:10946(验收口径 ≤ 10970)。类方法数和伪造点没有增加。
## 做了什么
- T1:新增 `scripts/api_scratch_retention.py`。启动时清理全部已登记目录,并按 `JYOTISH_SCRATCH_PRUNE_INTERVAL_SECONDS`(默认 1800,0 表示不启后台线程)周期清理。排盘缓存新文件改为仅本人可读,先写临时文件再换上。`prune_expired_async_jobs` 只留同名薄包装。
- T2:`VEDASTRO_WRITE_AUDIT_ARTIFACTS` 默认关。关掉时不写审计文件,`artifact_path` 为 `None`。打开时写紧凑 JSON,仅本人可读,先写临时文件再换上。成功快照仍用原 TTL;失败、不完整、被挡住的用短 TTL,默认 300 秒,并且不超过成功 TTL。旧缓存没有 `negative` 标记时仍按成功的长期限读。状态接口改报含回落的成功期限,并多报失败期限。`.env.cn.example` 只加了注释,两个 604800 没改。
- 成功的判据:`available is True` 且 `status == "ok"`。`partial` 即使 `available` 为真也算失败缓存。
- T3:年月日能整除才收成整数,不截断 1990.5。时分秒和经纬度、时区能解析成有限数字就收成浮点,不四舍五入。解析不了的保持原值。日期取前 10 位,空值仍是空值。缓存格式版本 5 → 6。快速排盘和完整排盘仍是两把键。
- T4:前台快照、负缓存、网关任务、分盘段都进了登记表。只删符合原文件名的过期文件。网关排队中和进行中的不删。读写逻辑和键没改。
- T5:脚本在 `scripts/research/api_disk_cache_ab.py`,逐字节比较在 `scripts/research/api_chart_cache_byte_diff.py`。记录在 `docs/testing/api-disk-cache-retention-ab-20261013.md`。
- T6:没有改咨询卡片缓存。数字用任务书 §2.5,没有重测:未命中构建 1.9 秒;文件 5097751 字节;命中时解析中位 68.5 毫秒;整次命中 82.5 毫秒;两天大约每个活跃用户 10 MB。可选方向仍是两条:按领域切片只读需要的部分;清理改为每隔几分钟一次,而不是每次读取都扫全目录。没有做进程内已解析包缓存。
- T7:BUG-1330、BUG-1331、BUG-1332。变更说明一句。任务索引改为待验收。没有改界面说明。
## 命令和结果
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 新留存、异步任务、增长合同 | `pytest tests/test_api_scratch_retention.py tests/test_api_async_job_contract.py tests/test_api_server_growth_contract.py -q` | 26 通过 |
| 安全缓存 | `pytest tests/test_api_server_security.py -q -k cache` | 6 通过 |
| 任务书列出的 VedAstro / 咨询套件 | 见任务书 §8 那一条 | 4 失败,见下。其余通过 |
| 需要审计文件的执行器测试 | `pytest tests/test_vedastro_service_adapter_executor.py::test_vedastro_range_scan_records_hashes_and_artifact_path -q` | 1 通过 |
| 逐字节比较与 A/B | `python scripts/research/api_disk_cache_ab.py` | 退出码 0。命中率 B/A = 1.333。七天后 B 剩 40 个文件,等于当天键数。审计文件 0。负缓存剩 1。一万个文件的清理 20.174 秒 |
| 接口行数 | 按换行计数 | 10946 |
既有断言改动只有一处,在 `tests/test_vedastro_service_adapter_executor.py`:原值是子进程沿用进程环境;新值是显式打开审计落盘;原因是默认关闭后这条测试仍要检查文件存在。这是测试前置,不是放宽断言。
## 未达到的目标(目标不改)
- 混合写法再重复 3 次时,命中率只是 A 的 1.333 倍,不是 2.5 倍。重复本身已经让 A 命中三分之二。
- 一万个过期文件的清理用了 20.174 秒,不是 2 秒内。清理不在请求线程。默认间隔和失败短期限没有因此改掉。
## 本机套件里原先就会红的三条
- `tests/test_vedastro_gateway.py` 要求原始响应文件权限是 0600。Windows 报出来的是可写,不是 0600。这条断言没改。新测试在 Linux 上仍检查 0600,Windows 上只检查文件写出来了。
- `tests/test_vedastro_runtime_ops.py` 要求已安装的 VedAstro 是 1.23.25,本机是 1.23.30。与这次缓存改动无关。
- `tests/test_three_engine_parity_runner.py` 的公开盘回放把 PyJHora 记成 raw_imported,不是 structured_captured,同时在下载星历。运行器没改。
## 环境缺口
- 生产和测试环境里成功缓存的实际秒数,这份仓库读不到。部署后看状态接口的两个期限数字即可。
- 生产卷上已经堆了多少文件,这里不知道。
- 主测量挡住了 VedAstro 导入。本机有 SDK,官方端口能连通。不挡 SDK 的探针仍关闭外网取数,一次完整排盘 4.167 秒,没有报错,没有拉真实快照。
## 未做
- 没有改部署文件、生产环境变量、数据库,也没有手工清生产卷。
- 没有把咨询卡片缓存并进周期清理。
@@ -0,0 +1,70 @@
# PROGRESS · 发问准备并行(2026-10-14)
- 分支:`codex/consult-prep-parallel-20261013`
- 基线:`origin/staging` `66cd60081f37a57680274633fcb3fb942d01bb2f`(任务书写的 `77057a819` 的后代;计划评论里的 `2994588ad` 更早,不因此停工)
- 个人长报告写作开关仍是关闭,本轮没有改报告章节
- 编号:本分支上的 `docs/BUG_HISTORY.md` 最大号是 BUG-1322。BUG-1323~1336 被其它未合并任务书占用。本单用 BUG-1337~1339
## 开关
三个都保持代码里的默认,没有单独提交去打开:
| 开关 | 默认 | 原因 |
| --- | --- | --- |
| `CONSULT_PREP_PARALLEL` | 关 | 没有本机 Postgres 中位数,不满足首帧快两成 |
| `MODEL_VERSION_CACHE_TTL_MS` | 0 | 没有量到「准备模型」快一半 |
| `CONSULT_DOMAIN_CONCURRENCY` | 1 | 没有带星盘网络库的镜像重测;起草试算预计达不到快两成五 |
因此没有写 `CHANGELOG.md`,也没有改 `frontend/DESIGN.md`。
## 做了什么
- T1:开流前的登录、注销检查、请求体、会话、模型收进 `loadConsultTurnHead`。并行时注销检查和后面三条同时开始,失败仍按旧的先后返回。423 仍在任何扣点之前。
- T2:并行打开时,审核在开流前启动、在扣点前等待。拦住时不扣点,并且比资料错误优先。关掉时仍是开流前等待审核。
- T3:按模型和版本缓存成功的解析,最长 15 秒;失败和空结果不缓存;管理端清目录缓存时一并清掉。地址检查函数本身没改。已发布版本的供应商代码、类型和地址不可变,但启用开关和密钥仍可变,所以不能超过目录缓存的 15 秒。
- T4:问题保存成功后立刻读标题,和分类同时进行,用到标题时再等。分类仍在扣点和保存之后。
- T5:领域并发开关。为 1 时仍是原来的逐个循环,含时间预算。为 2 时一起算,结果按计划顺序组装,不再逐个套时间预算,领域个数上限不变。
- T6:准备阶段记一条 `consultation-prep-v1`,只有阶段名、毫秒和状态,没有问题原文。
- T7:见 `docs/testing/consult-prep-parallel-ab-20261013.md`。假时钟并行 160、串行 210。这不是开启依据。
- 测试注入:模型目录增加 `setModelCatalogIoForTests`,只给测试替换查库、解密和地址检查。生产路径仍调用原来的函数。
## 改过的既有断言
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `content-moderation.test.ts` | 源码含 `await moderate(visibleQuestion, "input"` 且早于 `reserve_consultation_usage` | `await settleInputModeration()` 早于扣点;准备服务里 `beforeReserve` 早于 `input.reserve()`;拦住时扣点次数是 0 | 审核挪到流内、扣点前。关掉开关时开流前的等待还在 |
| `model-catalog.test.ts` | `return await readVersion(modelId, configVersion)` | `const model = await readVersion(...)`,只有非空才写入短缓存 | 绑版本的解析可以缓存,失败和空结果不写 |
| `model-catalog.test.ts` | 咨询路由里 `if (!sessionModel)` 后面是 503 | 准备函数里 `if (!sessionModel)` 返回 `modelUnavailable()`,状态 503 | 模型不可用仍是开流前的 503,文案随准备步骤搬走 |
| `consult-first-frame-and-pacing-20260928.test.tsx` | 401、请求体、会话不存在、会话类型、模型已变这些字面量在路由的 `runTurn` 之前 | 字面量在 `turn-prep.ts`;路由在 `runTurn` 之前调用 `loadConsultTurnHead` 并返回失败 | 开流前的判断收进同一个函数,状态码仍在开流前 |
| `rectification-session-composer-guard.test.ts` | 咨询路由里会话缺失返回 404,类型不对返回 `session_not_consultation` | 同样两句在 `turn-prep.ts` | 会话判断搬走,文案和状态码没变 |
`consultation-agentic-runtime.test.ts` 的领域循环帮助函数现在也接受异步的假计算,以便断言「后完成的领域仍排在计划后面」。原有断言没有改松。
## 并发为 2 时的时间预算
领域数仍先被原来的上限截断(现在是 2)。开关为 2 时,这两个领域一起开始,不再逐个问「剩下的时间还够不够下一个」。开关为 1 时,原来的逐个判断还在。
## 自测
test:db 未跑:本轮没有改查询语句,任务书也不要求。前端全量和 Python 快速门留给验收。`npm run lint` 0 error,原有 warning 没动。
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | `node frontend/node_modules/typescript/bin/tsc --noEmit -p frontend` | 0 错 |
| 规范 | `npm run lint --prefix frontend` | 0 error,125 条原有 warning |
| 准备与审核 | `tsx --test` 下列文件:`consult-turn-prep-20261013`、`consult-prep-gate-20261013`、`content-moderation`、`model-catalog`、`consult-first-frame-and-pacing-20260928`、`rectification-session-composer-guard` | 准备函数 3 通过;门禁文件 5 通过;其余在同一次 41 例里通过(当时缓存测试还在改,后来单文件 5 通过、失败 0、取消 0) |
| 计费、注销、标题、寒暄、领域 | 同上命令:`application-billing-contract`、`account-deletion`、`session-title-persist-contract`、`consultation-smalltalk`、`consultation-agentic-runtime` | 136 通过,失败 0,取消 0 |
| 假时钟 | `consult-prep-parallel-ab.mts` | 并行 160,串行 210,同时步数 2 对 1 |
| 领域镜像 | `consult-domain-concurrency-ab.py` | 退出 0,写明没有跑镜像 |
## 第 1 次打回
合并最新 `origin/staging`。业务代码没改,三个开关默认值没改。
| 文件 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `chat-session-authority.test.ts` | 在 `route.ts` 里找 `if (!chatSession)` 后的 404 | 在 `turn-prep.ts` 里锁 `sessionMissing()` 的 404,和分开的 `notConsultation()` 409 | 判定搬走了。仍不许并成一个判断 |
| `consultation-workflow-contract.test.ts` | `beforeReserve: ({ consultationMode }) => createConsultationPlan({`,以及 `reserve: () => reserveConsultationModel(` | `beforeReserve` 的 async 函数体里调用 `createConsultationPlan({`,并且早于 `reserve`;`reserve` 的 async 函数体里调用 `reserveConsultationModel(` | 改成了 async。没有放宽成「文件里出现过函数名」 |
| `consult-prep-parallel-ab.mts` | `frontend/scripts/research/`,从 `frontend/tests` 导入 | `frontend/tests/consult-prep-parallel-ab.mts` | 镜像会复制 scripts、不复制 tests。另一个新增的领域脚本是 Python,不导入测试目录 |
这次自测:上述合同测试加出生时间模式、镜像导入检查,40 通过,失败 0,取消 0。假时钟脚本挪走后仍是并行 160、串行 210。`tsc --noEmit` 0 错。`npm run lint` 0 error,125 条原有 warning。全量 `npm test` 留给验收。`git diff a0e223e2` 到本修复会带上合并进来的测试环境前端文件;相对那次合并的 `origin/staging`,`frontend/src` 没有新增改动。
@@ -0,0 +1,70 @@
# PROGRESS · 流式回答时侧栏不再跟着每帧重画(issue #59)
- 分支:`codex/home-sidebar-rerender-20261013`
- 实际基线:`origin/staging` `66cd60081f37a57680274633fcb3fb942d01bb2f`
- 任务书基线写成 `77057a819`,计划评论里的 base 是 `2994588ad4d858dead4835943e5883c5aa2b03f8`。开工时远端 staging 已是上面的 `66cd6008`。A/B 就跑在这个提交上,没有另开只读工作树(本轮不能离开当前工作树)。方向没有改,不需要拍板。
- `docs/BUG_HISTORY.md` 在这个基线上停在 BUG-1322。BUG-1323~1328、1345、1346 属于还没合入的别的分支,本单只用 BUG-1329。
- 未改 `page.tsx`。没有运行时开关。没有启用 React Compiler。Skill 版本不变。浏览器 A/B 未跑。
## 做了什么
外壳拿到的点击函数不再每次渲染换新,点下去仍执行首页刚渲染的那一版。注册只在数据变了才发给侧栏:会话行要逐条还是同一个对象才算没变,没变就不重建行。可见会话按当前人物记忆。侧栏自己的上下文对象也不再每次新建。卸载首页时仍会清掉注册。
## 断言三栏
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `frontend/tests/session-list-lifecycle.test.tsx` 外壳「打开资料」函数 | `assert.notEqual(onOpenProfile, initialCallback)` | `assert.equal(...)`;再改一版后仍是同一个函数,调用记录从 `[1]` 加强到 `[1, 2]` | 包装函数身份稳定。调用必须落到最新一次渲染,不能只删掉「不相等」 |
`HomeProbe` 仍是每次渲染新回调、新数组,没有改成常量。
## 基线失败(改代码之前,同一测试,3 次,退出码都是 1)
失败停在 `assert.equal(duringFrames.registers, 0)`,实际是 60。60 帧里侧栏提交也是 60。换当前项、改标题、换人各注册 1 次;只改回调版本也会再注册 1 次。剔除函数后的内容摘要:
| 档 | 60 帧注册 | 60 帧提交 | 60 帧耗时三次(毫秒) | 中位 | 内容摘要 |
| --- | --- | --- | --- | --- | --- |
| 30 条虚构会话 | 60 | 60 | 2774.964 / 2778.961 / 4202.085 | 2778.961 | `b18f05a3d83be5b41584787b7c0495f1fcec8ef7b8ecfbdbb3c083c40796c103` |
| 100 条虚构会话 | 60 | 60 | 10530.828 / 11799.253 / 12284.103 | 11799.253 | `cef0a63d6f3fe72e541f9ac882b8eaf305b6933fdd07ca3002e8cd03bb139d25` |
更早一次探针把注册函数也做成每次渲染新建,60 帧数到 120 次注册、60 次提交。那是清理函数跟着新函数又清了一次,不是产品路径。交付用的测试和生产一样,用提供者给出的稳定注册函数,所以改前是 60 而不是 120。
## 改后(同一测试,3 次,退出码都是 0)
| 档 | 60 帧注册 | 60 帧提交 | 60 帧耗时中位 | 换当前项 | 改标题 | 只改回调版本 | 换人 | 内容摘要 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 30 | 0 | 0 | 0 | 注册 1、提交 1 | 注册 1,标题更新 | 注册 0,调用记到第 4 版 | 注册 1,列表换成另一人 | 与改前相同 |
| 100 | 0 | 0 | 0 | 注册 1、提交 1 | 注册 1,标题更新 | 注册 0,调用记到第 4 版 | 注册 1,列表换成另一人 | 与改前相同 |
三次摘要都与上表相同。门槛达到:60 帧内注册 0、侧栏提交 0,内容摘要逐字节一致。
## 自测
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | `node frontend/node_modules/typescript/bin/tsc --noEmit -p frontend` | 退出码 0 |
| 规范 | `npm run lint --prefix frontend` | 0 error,126 条原有 warning。本单文件没有新增 warning,没有 `eslint-disable` |
| 定向测试 | `tsx --test` 下表 10 个文件 | tests 94,pass 94,fail 0,cancelled 0 |
| 数据库 | 未跑 | 本单不碰数据库 |
| 浏览器 | 未跑 | 浏览器 A/B 未跑。步骤在 `docs/testing/home-sidebar-rerender-20261013.md` |
| 隐私标记 | `.venv\Scripts\python.exe -m pytest tests/test_repo_privacy_markers.py -q` | 通过 |
定向文件:`home-sidebar-rerender`、`session-list-lifecycle`、`session-list-provider`、`sidebar-contract`、`sidebar-state`、`secondary-page-entry`、`account-dialog-inert-20260918`、`home-shell-growth-contract`、`home-streaming-render-split`、`chat-profile-picker`。
全量 `npm test`、`next build`、Python 快速门未跑,留给验收。
## 2026-10-14 修复单
按修复单合并了最新 `origin/staging`。只改了 `frontend/tests/chat-navigation-a11y-contract.test.ts` 里账户菜单「充值」那一条源码断言,改成认稳定包装的写法,仍然要求打开 billing 且来源是 `account-menu`。业务代码没有改。
又搜了一遍 `onOpenProfile`、`onOpenGeneral`、`onOpenLogout`、`onNewChat`、`onSelectSession`、`onLoadMore`。测试里其余命中的是侧栏组件自己的入口,或校正点数不足时打开充值,不是这条旧写法,没有改。
和上一版 `63509431` 比,网页业务目录里多出来的只有合并测试线带入的校正提速(`engine-client.ts`),不是这单写的。这单自己的提交不碰 `frontend/src`。
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | `tsc --noEmit -p frontend` | 通过,退出码 0 |
| 规范 | `npm run lint`(在 frontend) | 0 error,125 条原有 warning。本单改的测试文件没有新增 warning |
| 这条旧检查 | `tsx --test tests/chat-navigation-a11y-contract.test.ts` | 13 通过,0 失败,0 取消 |
全量 `npm test` 仍留给验收。
+6 -4
View File
@@ -470,11 +470,13 @@
| `TASK-rect-scoring-perf-20261013.md` | `PROGRESS-rect-scoring-perf-20261013.md` | 校正打分提速:引导窗口平方扫描、候选不变量重复算、响应带无读取点字段、候选数上限 241(BUG-1323~1325、1345) | 待验收 | `codex/rect-scoring-perf-20261013` |
| `TASK-node-runtime-resources-20261013.md` | `PROGRESS-node-runtime-resources-20261013.md` | 报告 worker abort 监听泄漏、本地 PG 适配层往返与元数据缓存、无上限内存表、用户端会话读缓存(BUG-1326~1328、1346,PG 与缓存部分 A/B) | 待验收 | `codex/node-runtime-resources-20261013` |
| `TASK-node-runtime-resources-fix-20261014.md` | `PROGRESS-node-runtime-resources-20261013.md` | **#58 第 1 次打回**:`4c3485bc` 其余全过;压测脚本放在 `frontend/scripts/` 却从 `frontend/tests/` 导入,镜像构建会失败(BUG-1140 形状,任务书指定的路径有误)。挪到 `frontend/tests/` 下非 `.test` 文件名 | 待验收 | `codex/node-runtime-resources-20261013` |
| `TASK-home-sidebar-rerender-20261013.md` | — | 流式回答时侧栏每帧整栏重渲染(BUG-1329,A/B) | 待领取 | `codex/home-sidebar-rerender-20261013` |
| `TASK-home-sidebar-rerender-fix-20261014.md` | — | **#59 第 1 次打回**:`63509431` 其余全过;`chat-navigation-a11y-contract` 按源码文本断言 `onOpenBilling` 旧写法未跟改(§7.8) | 待领取 | `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-home-sidebar-rerender-20261013.md` | `PROGRESS-home-sidebar-rerender-20261013.md` | 流式回答时侧栏每帧整栏重渲染(BUG-1329,A/B) | 待验收 | `codex/home-sidebar-rerender-20261013` |
| `TASK-home-sidebar-rerender-fix-20261014.md` | `PROGRESS-home-sidebar-rerender-20261013.md` | **#59 第 1 次打回**:`63509431` 其余全过;`chat-navigation-a11y-contract` 按源码文本断言 `onOpenBilling` 旧写法未跟改(§7.8) | 待验收 | `codex/home-sidebar-rerender-20261013` |
| `TASK-api-disk-cache-retention-20261013.md` | `PROGRESS-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-report-reference-perf-fix-20261014.md` | — | **#61 第 1 次打回**:`69c739fb` 本单全过(Linux 复跑逐字节一致、命中 0.03 s);与 #60 合并后新缓存目录未登记进 `api_scratch_retention`,合同测试红 | 待领取 | `codex/report-reference-perf-20261013` |
| `TASK-consult-prep-parallel-20261013.md` | `PROGRESS-consult-prep-parallel-20261013.md` | 普通对话发问前准备步骤并行、模型版本短缓存、多领域并发开关(BUG-1337~1339,A/B) | 待验收 | `codex/consult-prep-parallel-20261013` |
| `TASK-consult-prep-parallel-fix-20261014.md` | `PROGRESS-consult-prep-parallel-20261013.md` | **#62 第 1 次打回**:两条源码文本检查改认新位置,对照脚本挪进测试目录 | 待验收 | `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` |
@@ -0,0 +1,41 @@
# TASK · 发问准备并行修复单(issue #62,第 1 次打回)
- 日期:2026-10-14
- 原任务书:`docs/tasks/TASK-consult-prep-parallel-20261013.md`
- 被验 SHA:`a0e223e20cebcce3479accb60f6aec44d1caadda`(`codex/consult-prep-parallel-20261013`)
- 执行方:Grok,同一分支续做(先 merge 最新 `origin/staging`,不改写已推历史)
- BUG 编号:沿用 BUG-1337~1339
## 1. 验收结论:业务逻辑通过;前端全量多出 3 条真失败
| 检查 | 结果 | 证据 |
| --- | --- | --- |
| 开关全关时的顺序 | 通过 | `turn-prep.ts` 串行分支与原 `route.ts` 的 401→423→400→503→404→409→409→503 逐条同序 |
| 开关开时 | 通过 | 删号判定仍最先返回、在任何写库扣点之前;审核结论在 `beforeReserve` 内等齐,被拦不 reserve |
| `readVersion` 缓存 | 通过 | 只缓存成功结果,`invalidateLanguageModelCatalog` 同时清;默认 TTL 0 |
| tsc / lint | 通过 | 0 错 / 0 error |
| 前端全量 `npm test`(与 staging 逐名比对) | **未通过:新增 3 条** | 见下 |
1. `frontend/tests/chat-session-authority.test.ts`「consult POST on a rectification session is 409, a missing id is 404」:第 65 行在 `route.ts` 源码里找 `/if \(!chatSession\) \{[\s\S]*status: 404/`——这段判定挪进了 `frontend/src/lib/consult/turn-prep.ts`(`sessionMissing()`),断言没跟着挪。
2. `frontend/tests/consultation-workflow-contract.test.ts`「consultation plans are server-owned and bounded」:第 160 行找 `/beforeReserve: \(\{ consultationMode \}\) => createConsultationPlan\(\{/`,第 161 行找 `reserve: () => reserveConsultationModel(`——两处都改成了 `async` 函数体。
3. `frontend/tests/runtime-sources-no-test-imports.test.ts`:`frontend/scripts/research/consult-prep-parallel-ab.mts` 第 9 行从 `../../tests/support/stub-server-only.mjs` 导入。web 镜像不复制 `frontend/tests`,推上 staging 后镜像构建会失败(BUG-1140;#58 刚因同一形状打回过一次)。
原任务书 §5 第 7 条已经列出要先 grep 的源码文本断言;这次漏的两条不在那份清单里,但属于同一类(AGENTS.md §7.8)。
## 2. 修法要求
1. **1、2 两条**:按 §7.3 写「原值 / 新值 / 原因」三栏改断言,**意图不得放宽**:
- 1:改为在 `turn-prep.ts` 里锁「查不到会话 → 404」「类型不是 consultation → 409 `session_not_consultation`」,并保留第 70 行那条「不得合并成一个判断」的反向断言(针对新文件)。
- 2:改为锁「`beforeReserve` 里调用 `createConsultationPlan`」「`beforeReserve` 在 `reserve` 之前」「`reserve` 调用 `reserveConsultationModel`」,允许 `async` 写法,但不得只查函数名存在。
2. **第 3 条**:把 `consult-prep-parallel-ab.mts` 挪到 `frontend/tests/` 下(文件名不得以 `.test.ts` / `.test.tsx` 结尾),导入改相对路径;同步改 PROGRESS 与 `docs/testing/consult-prep-parallel-ab-20261013.md` 里的命令路径。`frontend/scripts/research/` 下其它新增文件同样检查。
3. 再 grep 一遍 `route.ts` 被挪走 / 改写的语句在 `frontend/tests`、`tests` 里还有没有按源码文本断言的:`grep -rn "chatSession\.session_type\|getUser()\|refuseWhenAccountDeletionPending\|resolveSessionLanguageModel(\|reserveConsultationModel(\|createConsultationPlan(" frontend/tests tests`,命中的逐条确认。
4. 业务代码不改;三个开关默认值不变。
## 3. 验收标准
- 前端全量 `npm test` 失败清单与 staging 基线逐名相同(本机另有 Docker 类失败,以基线为准);
- `git diff a0e223e2..<新 SHA> -- frontend/src` 为空。
## 4. 让步顺序
无。第 1 次打回。
@@ -0,0 +1,36 @@
# TASK · 长报告提速修复单(issue #61,第 1 次打回)
- 日期:2026-10-14
- 原任务书:`docs/tasks/TASK-report-reference-perf-20261013.md`
- 被验 SHA:`69c739fbee3f5af6deed911482d82e49ed429bf7`(`codex/report-reference-perf-20261013`)
- 执行方:Grok,同一分支续做(先 merge 最新 `origin/staging`,不改写已推历史)
- BUG 编号:沿用 BUG-1335、1336
## 1. 验收结论:本单自身全部通过;与 #60 合并后一条合同测试红
| 检查 | 结果 | 证据 |
| --- | --- | --- |
| 与最新 staging 合并后的定向测试 | **未通过** | `tests/test_api_scratch_retention.py::test_new_scratch_local_dirs_are_registered_or_whitelisted`:`AssertionError: ['report_reference_cache']` |
| 逐字节一致(Claude 在 Linux、屏蔽 PyJHora 复跑 A/B) | 通过 | 四张虚构盘 A / B 未命中 / B 命中 / 缓存首算的五个字段 sha 全部相同;`format=json`、`edition=reference` 两条 sha 相同 |
| 耗时 | 与回报一致 | 未命中中位数 21.1→15.6 s(day)等,降约 22–26%,未到 35% 门槛 → 渲染提速默认关正确;命中 0.02–0.03 s |
| 缓存键含 `today` | 通过 | `_high_rigor_birth_payload` 在缺省时把当天日期写进 `birth`,跨天不会命中 |
| `GITHUB_SHA` | 通过 | 部署编排已传入;缺失或 `unknown` 时不缓存 |
| `jyotish_api_server.py` | 通过 | 零改动 |
**根因**:#60(BUG-1330~1332,已在 staging)新增了合同测试:`scripts/` 里写 `scratch/local/<子目录>` 的地方,必须登记在 `scripts/api_scratch_retention.py` 的 `REGISTRY`(服务器周期清理)或 `OFFLINE_SCRATCH_NAMES`(自己会清理的缓存)里。本单新增的 `scratch/local/report_reference_cache/` 两边都没登记。两单各自都对,合在一起才红——这不是你的疏漏,是两单同时在做。
## 2. 修法要求
1. 合并最新 `origin/staging`(含 #60)。
2. 在 `scripts/api_scratch_retention.py::OFFLINE_SCRATCH_NAMES` 加 `"report_reference_cache"`,旁边写一行注释:它与 `consult_full_data_cache` 同走 `consult_full_data_cache.get_or_build`,每次调用先 `prune_cache(name=…)` 删两天前的文件,自己会清;含出生资料,保留期 2 天。
3. 不改两个模块的任何逻辑。
4. PROGRESS 末尾记一行。
## 3. 验收标准
- `python -m pytest tests/test_api_scratch_retention.py tests/test_report_reference_perf.py tests/test_consult_card_full_source.py -q` 全绿;
- Claude 全量 pytest 与 staging 基线逐条比对无新增。
## 4. 让步顺序
无。第 1 次打回。
@@ -0,0 +1,66 @@
# 磁盘缓存留存 A/B(2026-10-14)
对照的是本分支的父提交 `66cd60081f37a57680274633fcb3fb942d01bb2f`(建分支时的 `origin/staging`)。任务书里写的 `77057a819` 已经不是当前测试线头。A 用 `git show` 把父提交的接口文件载入同一进程,并把它的仓库路径指回本工作树,否则临时副本找不到引擎。B 是本分支。
演练用虚构盘,不写真实出生资料。文件都在系统临时目录,不写工作树里的 `scratch/`。
主测量挡住了 `jhora` 和 `vedastro` 的导入。本机装了 VedAstro SDK,并且能连上官方端口;另有一组不挡 SDK、但仍关闭外网取数,见文末。
## 命令
```powershell
.venv\Scripts\python.exe scripts\research\api_chart_cache_byte_diff.py
.venv\Scripts\python.exe scripts\research\api_disk_cache_ab.py
.venv\Scripts\python.exe scripts\research\api_disk_cache_ab.py --with-sdk --sdk-probe
```
## 命中率
20 张虚构盘 × 3 种写法 × 快速/完整 × 7 天 × 每种重复 3 次 = 2520 次取键。取键用的是真实缓存键函数,没有把 2520 次都做成完整排盘。
| | 改前 A | 改后 B | 变化 |
| --- | --- | --- | --- |
| 不同缓存键 | 840 | 280 | 少 560 |
| 命中次数 | 1680 | 2240 | 多 560 |
| 命中率 | 0.6667 | 0.8889 | B / A = 1.333 |
目标是混合写法下 B 至少为 A 的 2.5 倍。按任务书规定的「重复 3 次」算,没有达到:A 自己靠重复就已经有三分之二命中,写法合并只能再抬到约 89%。若不算这 3 次重复、只看三种写法各问一次,A 的命中率是 0,B 是 0.6667。目标数字不改。
## 七天后的文件
留存演练写的是短记录,用来数文件,不是完整盘面。期限 900 秒,只有当天的键还没过期。
| | 改前 A | 改后 B |
| --- | --- | --- |
| 清理前文件数 | 840 | 280 |
| 清理前字节 | 109200 | 36400 |
| 清理后文件数 | 840(旧代码没有这份清理) | 40 |
| 当天仍有效的键 | 120 | 40 |
B 清理后的文件数等于当天有效键数,达到「不超过当天活跃键」的目标。
审计文件:B 在默认关闭时写出 0 个。达到目标。
负缓存:种入当天和前一天各一份,清理后剩 1 份(当天)。达到目标。
## 清理耗时
| 场景 | 结果 |
| --- | --- |
| 预置 10000 个已过期排盘缓存后,整次清理 | 20.174 秒,排盘目录 20170.902 毫秒,删掉 10000,剩下 0 |
| 上面七天演练里的排盘目录 | 370.161 毫秒 |
目标是 10000 个文件在 2 秒内、且不在请求线程。本次在后台函数里测,不在请求线程;耗时没有进 2 秒。这是 Windows 上逐个读文件再删除的实测,目标不改。默认清理间隔仍是 1800 秒,失败快照短期限仍是 300 秒,不因为这次没进 2 秒就改默认值。
## 排盘耗时(一次虚构盘,快速排盘,外网关闭)
| | 中位数 |
| --- | --- |
| 未命中 3 次 | 23.036 毫秒 |
| 命中 3 次 | 5.249 毫秒 |
## 响应是否还是同一张盘
数值等价的写法(小时 12 / 12.0 / "12",纬度数字和字符串,时区 5.5 和 "5.5",秒缺省和 0)在快速排盘下去掉 `runtime_cache` 后逐字节相同。完整排盘只差官方快照缓存上的写入时刻和到期时刻,盘面没有别的差异。父提交和本分支对同一张快速排盘、去掉 `runtime_cache` 后逐字节相同。因此这些字段可以合并进同一把缓存键。
不挡 SDK 的一组:本机有 VedAstro SDK,官方端口能连通。探针仍关闭外网取数。一次完整排盘 4.167 秒,没有报错。没有拉取真实快照。
@@ -0,0 +1,36 @@
# 发问准备并行:本轮量到的和没量到的(2026-10-14)
开关都还关着。用户发问后的第一行,这轮不会变快。
## 进程内假时钟
命令:`node node_modules/tsx/dist/cli.mjs tests/consult-prep-parallel-ab.mts`(在 `frontend` 目录)。
假延迟:注销检查 50 毫秒,读会话 80 毫秒,解析模型 80 毫秒。这不是数据库实测。
| | 逻辑耗时 | 同时进行的步数 |
| --- | --- | --- |
| 串行 | 210 | 1 |
| 并行 | 160 | 2 |
并行比这三条假延迟的相加更短。它不能代替「首帧中位数下降两成」的开启条件,因为本机没有量 Postgres。
审核密钥不在这台机器上。测试里把审核挂起 1500 毫秒,第一小段在 400 毫秒内已经写出,然后才收到拦截。这是假等待,不是供应商的真实时间。
## 版本缓存
行为测试:15 秒内第二次解析不再查库、不再做地址检查;清缓存后会再查;缓存关掉后每次都查;查库失败和空结果不会被记住。没有量到真实「准备模型」那一段快了多少,所以缓存时间保持 0。
## 两个领域一起算
命令:`.venv\Scripts\python.exe frontend\scripts\research\consult-domain-concurrency-ab.py`
本轮没有启动计算服务,也没有带星盘网络库的镜像。任务书起草时的试算大约只快一成,热缓存时反而更慢,达不到快两成五。默认仍是一个一个算。
## 仍然关闭的原因
| 开关 | 现在 | 要打开还缺什么 |
| --- | --- | --- |
| 发问准备并行 | 关 | 本机 Postgres 上鉴权、注销检查、读会话、读模型各 30 次的中位数,并且首帧中位数至少快两成 |
| 模型版本缓存 | 0 毫秒 | 同上,准备模型那一段至少快一半 |
| 两个领域一起算 | 1 | 带星盘网络库的镜像上墙钟至少快两成五,争用时 429 不增加,响应除约定字段外一致 |
@@ -0,0 +1,12 @@
# 真机走查 · 回答生成时侧栏不再跟着闪(BUG-1329,issue #59)
浏览器 A/B 未跑。部署到 staging 后,在 `https://staging.jyotisha.chat` 登录一个没有真实会话内容需要露出的账号,按顺序看:
1. 电脑浏览器打开首页。左侧会话列表的样子、顺序、分组和以前一样。
2. 发一个普通问题,等回答一个字一个字出来。这段时间左侧列表不跟着闪、不跟着整栏重画。回答本身仍是逐字出现。
3. 回答过程中点另一条会话,能打开那一条。点当前这条的菜单,改名、置顶、分享、删除仍可用,改的是刚刚点的那一条。
4. 右上角账户菜单仍能打开资料、通用、账单和退出。
5. 换一个人物后,左侧列表换成那个人的会话,不串到别人的。
6. 手机宽度再看一遍第 2 步。侧栏抽屉的开关和以前一样。
任何一步不对,在 issue #59 里写是哪一步、哪台设备,并把标签改回 `agent/待规划`。不要贴出真实会话内容。
@@ -0,0 +1,18 @@
# A/B · 回答生成时侧栏重画(BUG-1329,issue #59)
同一测试 `frontend/tests/home-sidebar-rerender.test.tsx`,同一输入。A 是改代码前的 `66cd60081f37a57680274633fcb3fb942d01bb2f`(任务书写的 `77057a819` 已被后续提交盖过)。B 是分支 `codex/home-sidebar-rerender-20261013`。各跑 3 次。会话都是虚构的「Synthetic session N」。没有真实会话内容。
度量是 60 帧里的注册次数、侧栏提交次数、这 60 帧的 Profiler 耗时合计。内容用剔除函数字段后的 SHA-256 比较。
| 档 | A 注册 / 提交 / 耗时中位 | B 注册 / 提交 / 耗时中位 | 内容 |
| --- | --- | --- | --- |
| 30 条 | 60 / 60 / 2778.961 毫秒 | 0 / 0 / 0 | 两边都是 `b18f05a3d83be5b41584787b7c0495f1fcec8ef7b8ecfbdbb3c083c40796c103` |
| 100 条 | 60 / 60 / 11799.253 毫秒 | 0 / 0 / 0 | 两边都是 `cef0a63d6f3fe72e541f9ac882b8eaf305b6933fdd07ca3002e8cd03bb139d25` |
A 的三次 30 条耗时:2774.964、2778.961、4202.085。100 条:10530.828、11799.253、12284.103。B 的三次都是 0。
正确性:换当前项后 B 注册 1 次,外壳看到新的当前项。改一行标题后标题更新。换成另一人的会话后列表换掉。只改回调版本不再注册,点「打开会话」和「改名」记到的都是第 4 版。A 在只改回调版本时还会再注册 1 次;B 不注册,但调用结果相同。
判定:B 在 60 帧内注册 0、侧栏提交 0,内容摘要一致,`session-list-lifecycle` 全绿。可以交付。
浏览器 A/B 未跑。
+1
View File
@@ -551,6 +551,7 @@ one skeleton, none of them carrying the sidebar.
`children` through unchanged. Registering fresh controls/rows must not render
their producer again: that feedback loop can starve client navigation.
Business changes still publish current callbacks; Home unmount clears them.
流式期间侧栏不随回答帧重渲染(BUG-1329)。
- **One sidebar component, two modes.** `AppSidebar` is the only sidebar in the
product. `/` passes `controls`; the secondary layout does not, and without it
the same markup renders read-only. There used to be a second component for
@@ -0,0 +1,24 @@
"""Two-domain concurrency bench.
The vedastro image and the local API were not started in this round.
The task book's draft on one public chart was about 10 percent faster when
cold and slower when the chart was already cached. The switch opens only
when the vedastro image is at least 25 percent faster, contention 429s do
not rise, and the byte diff matches. Default stays 1.
"""
from __future__ import annotations
def main() -> int:
print("CONSULT_DOMAIN_CONCURRENCY default stays 1")
print("vedastro image bench: not run")
print("local API bench: not run")
print("contention 429 count: not run")
print("byte diff: not run")
print("reason: this round has no vedastro measurement; the published draft missed the 25 percent gate")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+158 -88
View File
@@ -10,7 +10,15 @@ import {
runConsultationWorkflow,
} from "@/mastra";
import { blocksPromptExtraction } from "@/lib/consult-safety";
import { consultDomainConcurrency, consultPrepParallelEnabled, modelVersionCacheTtlMs } from "@/lib/consult/prep-flags";
import {
ConsultationInputBlockedError,
consultPrepObservabilityEvent,
type ConsultPrepPhase,
} from "@/lib/consult/prep-log";
import { loadConsultTurnHead } from "@/lib/consult/turn-prep";
import { inputBlockedCopy, moderate, MODERATION_OUTPUT_REPLACED_COPY } from "@/lib/moderation";
import type { ModerationResult } from "@/lib/moderation";
import { recordModerationEvent } from "@/lib/moderation/log";
import { consultationDomainSchema } from "@/lib/consultation-domain-registry";
import { parseAgentReply } from "@/lib/agent-reply";
@@ -304,78 +312,43 @@ export async function POST(request: Request) {
return jsonForSupabaseSetupFailure(error, "POST /api/consult");
}
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json(
{ error: "请先登录", message: "登录后才能开始咨询。" },
{ status: 401 },
);
const prepParallel = consultPrepParallelEnabled();
const prepPhases: ConsultPrepPhase[] = [];
const head = await loadConsultTurnHead({
parallel: prepParallel,
getUser: () => supabase.auth.getUser(),
readPendingDeletion: (userId) => refuseWhenAccountDeletionPending(userId),
readBody: async () => chatRequestSchema.safeParse(await request.json().catch(() => null)),
readSession: async (userId, parsed) => await supabase
.from("chat_sessions")
.select("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_id,chart_profile_name,chart_profile_role,context_summary")
.eq("id", parsed.sessionId)
.eq("user_id", userId)
.maybeSingle(),
resolveModel: async (chatSession) => {
if (!chatSession.model_id) return null;
return await resolveSessionLanguageModel(
chatSession.model_id,
chatSession.model_config_version,
);
},
});
if (!head.ok) {
// A pending account deletion freezes the account: nothing new is charged.
const frozen = head.frozen ? head.response : null;
if (frozen) return frozen;
return head.response;
}
// A pending account deletion freezes the account: nothing new is charged.
const frozen = await refuseWhenAccountDeletionPending(user.id);
if (frozen) return frozen;
const parsed = chatRequestSchema.safeParse(
await request.json().catch(() => null),
const { user, parsed, chatSession, sessionModel } = head;
prepPhases.push(
{ phase: "prep.auth", durationMs: head.timings.authMs, status: "completed" },
{ phase: "prep.deletion", durationMs: head.timings.deletionMs, status: "completed" },
{ phase: "prep.session", durationMs: head.timings.sessionMs, status: "completed" },
{ phase: "prep.model", durationMs: head.timings.modelMs, status: "completed" },
{ phase: prepParallel ? "prep.flag.parallel-on" : "prep.flag.parallel-off", durationMs: 0, status: "completed" },
{ phase: modelVersionCacheTtlMs() > 0 ? "prep.flag.cache-on" : "prep.flag.cache-off", durationMs: 0, status: "completed" },
{ phase: consultDomainConcurrency() === 2 ? "prep.flag.domains-2" : "prep.flag.domains-1", durationMs: 0, status: "completed" },
);
if (!parsed.success) {
return NextResponse.json(
{ error: "出生资料或问题格式不正确", details: parsed.error.flatten() },
{ status: 400 },
);
}
const { data: chatSession, error: chatSessionError } = await supabase
.from("chat_sessions")
.select("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_id,chart_profile_name,chart_profile_role,context_summary")
.eq("id", parsed.data.sessionId)
.eq("user_id", user.id)
.maybeSingle();
if (chatSessionError) {
return NextResponse.json(
{ error: "暂时无法读取咨询会话", message: "请稍后重试。" },
{ status: 503 },
);
}
if (!chatSession) {
return NextResponse.json(
{ error: "咨询会话不存在", message: "请重新进入咨询。" },
{ status: 404 },
);
}
if (chatSession.session_type !== "consultation") {
return NextResponse.json(
{
error: "这是生时校正会话",
code: "session_not_consultation",
message: "请在生时校正里继续。",
},
{ status: 409 },
);
}
if (!chatSession.model_id || chatSession.model_id !== parsed.data.modelId) {
return NextResponse.json(
{ error: "会话模型已经变化", message: "请刷新会话后重新发送,本次不会扣点。" },
{ status: 409 },
);
}
const sessionModel = await resolveSessionLanguageModel(
chatSession.model_id,
chatSession.model_config_version,
);
if (!sessionModel) {
return NextResponse.json(
{
error: "模型暂不可用",
message: "当前会话绑定的数据库模型配置不可用,请联系管理员,本次不会扣点。",
},
{ status: 503 },
);
}
const sessionContextWindow = sessionModel.contextWindow;
if (parsed.data.entrypoint === "birth_time_rectification") {
@@ -421,14 +394,58 @@ export async function POST(request: Request) {
// Content moderation, input side (2026-09-30): before any reservation, so a
// blocked question is never sent to a model and never charged.
const moderationContext = { route: "consult", userId: user.id, requestId: parsed.data.requestId, modelId: chatSession.model_id };
const inputModeration = await moderate(visibleQuestion, "input", moderationContext);
if (inputModeration.verdict !== "pass") await recordModerationEvent(accounting, inputModeration, "input", moderationContext);
if (inputModeration.verdict === "block") {
let inputModerationPending: Promise<ModerationResult> | null = null;
let inputModerationRecorded = false;
let moderationTimed = false;
let prepLogged = false;
const moderationStartedAt = Date.now();
function logPrep() {
if (prepLogged) return;
prepLogged = true;
logAgentObservability(consultPrepObservabilityEvent({
requestId,
sessionId,
phases: prepPhases,
}));
}
async function settleInputModeration(): Promise<ModerationResult | null> {
if (!inputModerationPending) return null;
const result = await inputModerationPending;
if (!moderationTimed) {
moderationTimed = true;
prepPhases.push({
phase: "prep.moderation",
durationMs: Math.max(0, Date.now() - moderationStartedAt),
status: result.verdict === "block" ? "failed" : "completed",
});
}
if (result.verdict !== "pass" && !inputModerationRecorded) {
inputModerationRecorded = true;
await recordModerationEvent(accounting, result, "input", moderationContext);
}
return result;
}
function contentBlockedResponse(result: ModerationResult) {
return NextResponse.json(
{ error: "无法处理该请求", code: "content_blocked", message: inputBlockedCopy(inputModeration) },
{ error: "无法处理该请求", code: "content_blocked", message: inputBlockedCopy(result) },
{ status: 400 },
);
}
if (!prepParallel) {
const inputModeration = await moderate(visibleQuestion, "input", moderationContext);
prepPhases.push({
phase: "prep.moderation",
durationMs: Math.max(0, Date.now() - moderationStartedAt),
status: inputModeration.verdict === "block" ? "failed" : "completed",
});
if (inputModeration.verdict !== "pass") await recordModerationEvent(accounting, inputModeration, "input", moderationContext);
if (inputModeration.verdict === "block") {
logPrep();
return contentBlockedResponse(inputModeration);
}
} else {
inputModerationPending = moderate(visibleQuestion, "input", moderationContext);
}
type SelectedModel = NonNullable<Awaited<ReturnType<typeof resolveSessionLanguageModel>>>;
type ReservationResult = { success: boolean; credits: number | null; error_code: string | null };
type ModelSelection = Awaited<ReturnType<typeof reserveConsultationModel<SelectedModel, ReservationResult>>>;
@@ -443,6 +460,7 @@ export async function POST(request: Request) {
// pre-stream order because its text/plain replies cannot ride an NDJSON stream.
const runTurn = async (): Promise<Response> => {
let prepared: PreparedConsultationRoute<ModelSelection, ConsultationPlan>;
const prepareStarted = Date.now();
try {
prepared = await prepareConsultationRoute<ModelSelection, ConsultationPlan>({
userId,
@@ -475,13 +493,25 @@ export async function POST(request: Request) {
return data;
},
loadCandidateRange: (profileUserId) => loadReportCandidateRange(accounting, { userId: profileUserId }),
beforeReserve: ({ consultationMode }) => createConsultationPlan({
userIntent: parsed.data.question,
theme: consultationTheme,
consultationMode,
modelCreditCost: sessionModel.creditCost,
}),
reserve: () => reserveConsultationModel(
beforeReserve: async ({ consultationMode }) => {
prepPhases.push({
phase: "prep.subject",
durationMs: Math.max(0, Date.now() - prepareStarted),
status: "completed",
});
const inputModeration = await settleInputModeration();
if (inputModeration?.verdict === "block") throw new ConsultationInputBlockedError(inputModeration);
return createConsultationPlan({
userIntent: parsed.data.question,
theme: consultationTheme,
consultationMode,
modelCreditCost: sessionModel.creditCost,
});
},
reserve: async () => {
const reserveStarted = Date.now();
try {
return await reserveConsultationModel(
chatSession.model_id,
(modelId) => sessionModel?.id === modelId ? sessionModel : null,
async (model) => {
@@ -503,9 +533,22 @@ export async function POST(request: Request) {
error_code: reservation.data.reason,
};
},
),
);
} finally {
prepPhases.push({
phase: "prep.reserve",
durationMs: Math.max(0, Date.now() - reserveStarted),
status: "completed",
});
}
},
});
} catch (error) {
const blocked = await settleInputModeration();
if (blocked?.verdict === "block") {
logPrep();
return contentBlockedResponse(blocked);
}
if (error instanceof FeaturePricingError) {
console.error(
`[billing] reservation failed request=${requestId} reason=${error.code}`,
@@ -626,6 +669,7 @@ export async function POST(request: Request) {
}
let appendedQuestion: { success: boolean; error_code?: string | null };
const appendStarted = Date.now();
try {
appendedQuestion = await retryDetachedSettlement(async () => {
const { data, error } = await accounting.rpc("append_consultation_question", {
@@ -640,7 +684,18 @@ export async function POST(request: Request) {
}
return parsedAppend.data;
});
prepPhases.push({
phase: "prep.append",
durationMs: Math.max(0, Date.now() - appendStarted),
status: "completed",
});
} catch {
prepPhases.push({
phase: "prep.append",
durationMs: Math.max(0, Date.now() - appendStarted),
status: "failed",
});
logPrep();
await cancel();
return NextResponse.json(
{ error: "暂时无法保存问题", message: "请稍后重试。" },
@@ -670,6 +725,14 @@ export async function POST(request: Request) {
let classificationOutcome: string | undefined;
let classificationDurationMs: number | null = null;
let latencyState: ReturnType<typeof createConsultationRuntimeState> | null = null;
const titleStarted = Date.now();
const titleRead = supabase
.from("chat_sessions")
.select("title")
.eq("id", sessionId)
.eq("user_id", userId)
.maybeSingle();
const classifyStarted = Date.now();
const turn = parsed.data.entrypoint === undefined
? await classifyConsultationTurn({
model: selectedModel,
@@ -705,8 +768,14 @@ export async function POST(request: Request) {
},
})
: { kind: "consult" as const };
prepPhases.push({
phase: "prep.classify",
durationMs: classificationDurationMs ?? Math.max(0, Date.now() - classifyStarted),
status: "completed",
});
if (turn.kind === "smalltalk") {
logPrep();
return streamSmalltalkResponse({
requestId,
reply: turn.reply,
@@ -738,12 +807,13 @@ export async function POST(request: Request) {
}
const expectedTitle = typeof chatSession.title === "string" ? chatSession.title : "";
const { data: titleRow } = await supabase
.from("chat_sessions")
.select("title")
.eq("id", sessionId)
.eq("user_id", userId)
.maybeSingle();
const { data: titleRow } = await titleRead;
prepPhases.push({
phase: "prep.title",
durationMs: Math.max(0, Date.now() - titleStarted),
status: "completed",
});
logPrep();
async function checkpointConsultationContext() {
try {
+14 -4
View File
@@ -8,6 +8,7 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ComponentProps,
@@ -169,7 +170,13 @@ export function SidebarProvider({
else setOpen(!open);
}, [isMobile, open, openMobile, setOpen, setOpenMobile]);
const context: SidebarProviderContextValue = {
const setInsetTrigger = useCallback((element: HTMLButtonElement | null) => {
insetTriggerRef.current = element;
}, []);
const setSidebarSurface = useCallback((element: HTMLElement | null) => {
sidebarSurfaceRef.current = element;
}, []);
const context = useMemo<SidebarProviderContextValue>(() => ({
state: open ? "expanded" : "collapsed",
open,
setOpen,
@@ -179,9 +186,12 @@ export function SidebarProvider({
ready,
isMobile,
toggleSidebar,
setInsetTrigger: (element) => { insetTriggerRef.current = element; },
setSidebarSurface: (element) => { sidebarSurfaceRef.current = element; },
};
setInsetTrigger,
setSidebarSurface,
}), [
open, openMobile, viewport, ready, isMobile, setOpen, setOpenMobile, toggleSidebar,
setInsetTrigger, setSidebarSurface,
]);
return (
<SidebarContext.Provider value={context}>
+152 -30
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, type Dispatch, type RefObject, type SetStateAction } from "react";
import { useEffect, useMemo, useRef, type Dispatch, type RefObject, type SetStateAction } from "react";
import type { ChatSession } from "@/lib/home-types";
import type { Account } from "@/lib/home-types";
@@ -43,6 +43,68 @@ type HomeShellRegistrationParams = {
visibleSessions: readonly ChatSession[];
};
type LatestShellActions = {
sessions: readonly ChatSession[];
loadMoreSessions: () => void;
renameSession: (session: ChatSession) => void | Promise<void>;
selectSession: (sessionId: string) => void;
shareSession: (session: ChatSession) => void | Promise<void>;
startNewChat: () => void | Promise<unknown>;
togglePinnedSession: (sessionId: string) => void;
openAccountDialog: (dialog: AccountDialogName, options?: { source?: string }) => void;
setSessionMenuId: Dispatch<SetStateAction<string | null>>;
setAccountMenuOpen: Dispatch<SetStateAction<boolean>>;
setPendingSessionDeletion: Dispatch<SetStateAction<ChatSession | null>>;
};
type PublishedShellInputs = {
hydrated: boolean;
account: Account;
profileName: string;
accountMenuOpen: boolean;
modalOpen: boolean;
activeSessionId: string | undefined;
rectificationOpeningSessionId: string | null;
rectificationSurfaceOpen: boolean;
creatingSession: boolean;
pendingSessionId: string | null;
cancellationPending: boolean;
modelCatalog: PublicLanguageModelCatalog | null;
sessionMenuId: string | null;
sessionsCursor: string | null;
accountTrigger: RefObject<HTMLButtonElement | null>;
chartLibrary: readonly ChartLibraryRecord[];
visibleSessions: readonly ChatSession[];
};
function sameVisibleSessions(left: readonly ChatSession[], right: readonly ChatSession[]) {
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) return false;
}
return true;
}
function samePublishedInputs(previous: PublishedShellInputs, next: PublishedShellInputs) {
return previous.hydrated === next.hydrated
&& previous.account === next.account
&& previous.profileName === next.profileName
&& previous.accountMenuOpen === next.accountMenuOpen
&& previous.modalOpen === next.modalOpen
&& previous.activeSessionId === next.activeSessionId
&& previous.rectificationOpeningSessionId === next.rectificationOpeningSessionId
&& previous.rectificationSurfaceOpen === next.rectificationSurfaceOpen
&& previous.creatingSession === next.creatingSession
&& previous.pendingSessionId === next.pendingSessionId
&& previous.cancellationPending === next.cancellationPending
&& previous.modelCatalog === next.modelCatalog
&& previous.sessionMenuId === next.sessionMenuId
&& previous.sessionsCursor === next.sessionsCursor
&& previous.accountTrigger === next.accountTrigger
&& previous.chartLibrary === next.chartLibrary
&& sameVisibleSessions(previous.visibleSessions, next.visibleSessions);
}
export function useHomeShellRegistration(params: HomeShellRegistrationParams) {
const {
account,
@@ -75,6 +137,27 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) {
togglePinnedSession,
visibleSessions,
} = params;
const profileName = profile.name;
const latestRef = useRef<LatestShellActions | null>(null);
const publishedRef = useRef<PublishedShellInputs | null>(null);
useEffect(() => {
// No dependency list: this has to land after every render and before the
// publish effect, so a later click runs this render's callbacks (BUG-725).
latestRef.current = {
sessions,
loadMoreSessions,
renameSession,
selectSession,
shareSession,
startNewChat,
togglePinnedSession,
openAccountDialog,
setSessionMenuId,
setAccountMenuOpen,
setPendingSessionDeletion,
};
});
useEffect(() => {
// The inline dead-screen script stands down only after the app has revealed.
@@ -84,12 +167,59 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) {
document.documentElement.dataset.hydrated = "1";
}, [hydrated]);
const stableControls = useMemo(() => ({
onLoadMore: () => { latestRef.current?.loadMoreSessions(); },
onMenuSessionChange: (sessionId: string | null) => { latestRef.current?.setSessionMenuId(sessionId); },
onTogglePinned: (sessionId: string) => { latestRef.current?.togglePinnedSession(sessionId); },
onRename: (sessionId: string) => {
const session = latestRef.current?.sessions.find((candidate) => candidate.id === sessionId);
if (session) void latestRef.current?.renameSession(session);
},
onShare: (sessionId: string) => {
const session = latestRef.current?.sessions.find((candidate) => candidate.id === sessionId);
if (session) void latestRef.current?.shareSession(session);
},
onDelete: (sessionId: string) => {
const session = latestRef.current?.sessions.find((candidate) => candidate.id === sessionId);
if (session) latestRef.current?.setPendingSessionDeletion(session);
},
onAccountMenuOpenChange: (open: boolean) => { latestRef.current?.setAccountMenuOpen(open); },
onNewChat: () => { void latestRef.current?.startNewChat(); },
onSelectSession: (sessionId: string) => { latestRef.current?.selectSession(sessionId); },
onOpenProfile: () => { latestRef.current?.openAccountDialog("profile"); },
onOpenGeneral: () => { latestRef.current?.openAccountDialog("general"); },
onOpenBilling: () => { latestRef.current?.openAccountDialog("billing", { source: "account-menu" }); },
onOpenLogout: () => { latestRef.current?.openAccountDialog("logout"); },
}), []);
useEffect(() => {
if (!hydrated || !account) {
publishedRef.current = null;
registerShellControls(null);
return;
}
const name = profile.name.trim();
const next: PublishedShellInputs = {
hydrated,
account,
profileName,
accountMenuOpen,
modalOpen,
activeSessionId,
rectificationOpeningSessionId,
rectificationSurfaceOpen,
creatingSession,
pendingSessionId,
cancellationPending,
modelCatalog,
sessionMenuId,
sessionsCursor,
accountTrigger,
chartLibrary,
visibleSessions,
};
if (publishedRef.current && samePublishedInputs(publishedRef.current, next)) return;
publishedRef.current = next;
const name = profileName.trim();
const email = account.user.email || "尚未读取邮箱";
registerShellControls({
escapeBlocked: accountMenuOpen || modalOpen,
@@ -112,42 +242,34 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) {
accountTriggerRef: accountTrigger,
sessionControls: {
hasMore: Boolean(sessionsCursor),
onLoadMore: loadMoreSessions,
onLoadMore: stableControls.onLoadMore,
menuSessionId: sessionMenuId,
disabled: Boolean(pendingSessionId) || cancellationPending,
onMenuSessionChange: setSessionMenuId,
onTogglePinned: togglePinnedSession,
onRename: (sessionId) => {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (session) void renameSession(session);
},
onShare: (sessionId) => {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (session) void shareSession(session);
},
onDelete: (sessionId) => {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (session) setPendingSessionDeletion(session);
},
onMenuSessionChange: stableControls.onMenuSessionChange,
onTogglePinned: stableControls.onTogglePinned,
onRename: stableControls.onRename,
onShare: stableControls.onShare,
onDelete: stableControls.onDelete,
},
onAccountMenuOpenChange: setAccountMenuOpen,
onNewChat: () => { void startNewChat(); },
onSelectSession: selectSession,
onOpenProfile: () => openAccountDialog("profile"),
onOpenGeneral: () => openAccountDialog("general"),
onOpenBilling: () => openAccountDialog("billing", { source: "account-menu" }),
onOpenLogout: () => openAccountDialog("logout"),
onAccountMenuOpenChange: stableControls.onAccountMenuOpenChange,
onNewChat: stableControls.onNewChat,
onSelectSession: stableControls.onSelectSession,
onOpenProfile: stableControls.onOpenProfile,
onOpenGeneral: stableControls.onOpenGeneral,
onOpenBilling: stableControls.onOpenBilling,
onOpenLogout: stableControls.onOpenLogout,
},
});
}, [
account, accountMenuOpen, accountTrigger, activeSessionId, cancellationPending,
chartLibrary, creatingSession, hydrated, loadMoreSessions, modalOpen, modelCatalog,
openAccountDialog, pendingSessionId, profile.name,
chartLibrary, creatingSession, hydrated, modalOpen, modelCatalog,
pendingSessionId, profileName,
rectificationOpeningSessionId, rectificationSurfaceOpen,
registerShellControls, renameSession, selectSession, sessionMenuId, sessions,
sessionsCursor, setAccountMenuOpen, setPendingSessionDeletion, setSessionMenuId,
shareSession, startNewChat, togglePinnedSession, visibleSessions,
registerShellControls, sessionMenuId, sessionsCursor, stableControls, visibleSessions,
]);
useEffect(() => () => registerShellControls(null), [registerShellControls]);
useEffect(() => () => {
publishedRef.current = null;
registerShellControls(null);
}, [registerShellControls]);
}
+5 -4
View File
@@ -1,7 +1,7 @@
"use client";
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
import { writeChatSession } from "@/lib/chat-session-write-contract";
@@ -12,7 +12,7 @@ import {
writeSessionUrl,
} from "@/lib/chat-session-url";
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
import { currentSubjectCatalogReady, readCurrentSubjectId, readCurrentSubjectScope, subscribeCurrentSubject, subscribeSubjectCatalog } from "@/lib/current-subject";
import { currentSubjectCatalogReady, readCurrentSubjectId, readCurrentSubjectScope, subscribeCurrentSubject, subscribeSubjectCatalog, useCurrentSubject } from "@/lib/current-subject";
import { mergeSubjectSessionRows } from "@/lib/subject-session-merge";
import { consultationReportMarkdown } from "@/lib/consultation-report-export";
import { fallbackSessionId } from "@/lib/rectification-session-composer-guard";
@@ -181,8 +181,9 @@ export function useSessionManagement(params: SessionManagementParams) {
if (!isUnsavedEmptyConsultation(session)) cloudCreatedIds.current.add(session.id);
}
const visibleSessions = sortSessions(sessions.filter((session) => isListedSidebarSession(session)
&& sessionMatchesSubject(session, readCurrentSubjectId())));
const subjectId = useCurrentSubject();
const visibleSessions = useMemo(() => sortSessions(sessions.filter((session) => isListedSidebarSession(session)
&& sessionMatchesSubject(session, subjectId))), [sessions, subjectId]);
function updateSession(sessionId: string, change: (session: ChatSession) => ChatSession) {
setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session)));
+62
View File
@@ -0,0 +1,62 @@
/** Call-time switches for consult preparation. Read on each request, not at import. */
const warned = new Set<string>();
function warnOnce(name: string, raw: string): void {
if (warned.has(name)) return;
warned.add(name);
console.warn(`[consult-prep] ${name} 的值无法识别,按默认处理`);
void raw;
}
function readRaw(env: Readonly<Record<string, string | undefined>>, name: string): string | undefined {
const raw = env[name];
if (raw === undefined) return undefined;
const trimmed = raw.trim();
return trimmed ? trimmed : undefined;
}
export function consultPrepParallelEnabled(
env: Readonly<Record<string, string | undefined>> = process.env,
): boolean {
const raw = readRaw(env, "CONSULT_PREP_PARALLEL");
if (raw === undefined) return false;
const value = raw.toLowerCase();
if (value === "on") return true;
if (value === "off") return false;
warnOnce("CONSULT_PREP_PARALLEL", raw);
return false;
}
/** 0 closes the cache. Anything outside 0–15000 stays on the default, which is off. */
export function modelVersionCacheTtlMs(
env: Readonly<Record<string, string | undefined>> = process.env,
): number {
const raw = readRaw(env, "MODEL_VERSION_CACHE_TTL_MS");
if (raw === undefined) return 0;
if (!/^\d+$/.test(raw)) {
warnOnce("MODEL_VERSION_CACHE_TTL_MS", raw);
return 0;
}
const parsed = Number(raw);
if (parsed > 15_000) {
warnOnce("MODEL_VERSION_CACHE_TTL_MS", raw);
return 0;
}
return parsed;
}
/** 1 is today's serial loop. 2 starts the capped domains together. */
export function consultDomainConcurrency(
env: Readonly<Record<string, string | undefined>> = process.env,
): 1 | 2 {
const raw = readRaw(env, "CONSULT_DOMAIN_CONCURRENCY");
if (raw === undefined || raw === "1") return 1;
if (raw === "2") return 2;
warnOnce("CONSULT_DOMAIN_CONCURRENCY", raw);
return 1;
}
export function resetConsultPrepFlagWarningsForTests(): void {
warned.clear();
}
+34
View File
@@ -0,0 +1,34 @@
import type { ModerationResult } from "@/lib/moderation";
export const CONSULT_PREP_AGENT_VERSION = "consultation-prep-v1";
export class ConsultationInputBlockedError extends Error {
readonly result: ModerationResult;
constructor(result: ModerationResult) {
super("content_blocked");
this.name = "ConsultationInputBlockedError";
this.result = result;
}
}
export type ConsultPrepPhase = {
phase: string;
durationMs: number;
status: "completed" | "failed" | "skipped";
};
/** Closed observability event. The question text is not a field. */
export function consultPrepObservabilityEvent(input: {
requestId: string;
sessionId: string;
phases: readonly ConsultPrepPhase[];
}) {
return {
requestId: input.requestId,
sessionId: input.sessionId,
agentVersion: CONSULT_PREP_AGENT_VERSION,
contractPhases: input.phases.map((phase) => ({ ...phase })),
billingSettlementResult: "not_applicable" as const,
};
}
+195
View File
@@ -0,0 +1,195 @@
import { NextResponse } from "next/server";
export type ConsultPrepTimings = {
authMs: number;
deletionMs: number;
sessionMs: number;
modelMs: number;
};
type AuthResult<User> = {
data: { user: User | null };
error: unknown;
};
type BodyResult<Parsed> =
| { success: true; data: Parsed }
| { success: false; error: { flatten: () => unknown } };
type SessionResult<Session> = {
data: Session | null;
error: unknown;
};
type SessionRow = {
model_id: string | null;
session_type: string | null;
model_config_version?: number | null;
messages?: unknown;
title?: unknown;
theme?: unknown;
chart_profile_id?: unknown;
chart_profile_name?: unknown;
chart_profile_role?: unknown;
context_summary?: unknown;
};
type ParsedRequest = {
sessionId: string;
modelId: string;
};
export type ConsultTurnHeadDeps<User extends { id: string }, Parsed extends ParsedRequest, Session extends SessionRow, Model> = {
parallel: boolean;
getUser: () => Promise<AuthResult<User>>;
readPendingDeletion: (userId: string) => Promise<Response | null>;
readBody: () => Promise<BodyResult<Parsed>>;
readSession: (userId: string, parsed: Parsed) => Promise<SessionResult<Session>>;
resolveModel: (chatSession: Session) => Promise<Model | null>;
};
export type ConsultTurnHead<User, Parsed, Session, Model> =
| { ok: false; response: Response; frozen: boolean }
| {
ok: true;
user: User;
parsed: { success: true; data: Parsed };
chatSession: Session & { model_id: string };
sessionModel: Model;
timings: ConsultPrepTimings;
};
function elapsedSince(started: number): number {
return Math.max(0, Date.now() - started);
}
function loginRequired(): Response {
return NextResponse.json(
{ error: "请先登录", message: "登录后才能开始咨询。" },
{ status: 401 },
);
}
function badBody(error: { flatten: () => unknown }): Response {
return NextResponse.json(
{ error: "出生资料或问题格式不正确", details: error.flatten() },
{ status: 400 },
);
}
function sessionUnreadable(): Response {
return NextResponse.json(
{ error: "暂时无法读取咨询会话", message: "请稍后重试。" },
{ status: 503 },
);
}
function sessionMissing(): Response {
return NextResponse.json(
{ error: "咨询会话不存在", message: "请重新进入咨询。" },
{ status: 404 },
);
}
function notConsultation(): Response {
return NextResponse.json(
{
error: "这是生时校正会话",
code: "session_not_consultation",
message: "请在生时校正里继续。",
},
{ status: 409 },
);
}
function modelChanged(): Response {
return NextResponse.json(
{ error: "会话模型已经变化", message: "请刷新会话后重新发送,本次不会扣点。" },
{ status: 409 },
);
}
function modelUnavailable(): Response {
return NextResponse.json(
{
error: "模型暂不可用",
message: "当前会话绑定的数据库模型配置不可用,请联系管理员,本次不会扣点。",
},
{ status: 503 },
);
}
type Chain<Parsed, Session, Model> =
| { ok: false; response: Response }
| {
ok: true;
parsed: { success: true; data: Parsed };
chatSession: Session & { model_id: string };
sessionModel: Model;
};
/**
* Auth, then either the old serial checks or deletion in parallel with
* body → session → model. Failures are still reported in the old order.
*/
export async function loadConsultTurnHead<
User extends { id: string },
Parsed extends ParsedRequest,
Session extends SessionRow,
Model,
>(
deps: ConsultTurnHeadDeps<User, Parsed, Session, Model>,
): Promise<ConsultTurnHead<User, Parsed, Session, Model>> {
const timings: ConsultPrepTimings = { authMs: 0, deletionMs: 0, sessionMs: 0, modelMs: 0 };
const authStarted = Date.now();
const auth = await deps.getUser();
timings.authMs = elapsedSince(authStarted);
const user = auth.data.user;
if (auth.error || !user) return { ok: false, response: loginRequired(), frozen: false };
const readChain = async (): Promise<Chain<Parsed, Session, Model>> => {
const body = await deps.readBody();
if (!body.success) return { ok: false, response: badBody(body.error) };
const sessionStarted = Date.now();
const session = await deps.readSession(user.id, body.data);
timings.sessionMs = elapsedSince(sessionStarted);
if (session.error) return { ok: false, response: sessionUnreadable() };
if (!session.data) return { ok: false, response: sessionMissing() };
const chatSession = session.data;
if (chatSession.session_type !== "consultation") return { ok: false, response: notConsultation() };
if (!chatSession.model_id || chatSession.model_id !== body.data.modelId) {
return { ok: false, response: modelChanged() };
}
const modelStarted = Date.now();
const sessionModel = await deps.resolveModel(chatSession);
timings.modelMs = elapsedSince(modelStarted);
if (!sessionModel) return { ok: false, response: modelUnavailable() };
return {
ok: true,
parsed: body,
chatSession: chatSession as Session & { model_id: string },
sessionModel,
};
};
if (!deps.parallel) {
const deletionStarted = Date.now();
const frozen = await deps.readPendingDeletion(user.id);
timings.deletionMs = elapsedSince(deletionStarted);
if (frozen) return { ok: false, response: frozen, frozen: true };
const chain = await readChain();
if (!chain.ok) return { ok: false, response: chain.response, frozen: false };
return { ...chain, user, timings };
}
const deletionStarted = Date.now();
const deletion = deps.readPendingDeletion(user.id).then((frozen) => {
timings.deletionMs = elapsedSince(deletionStarted);
return frozen;
});
const chain = readChain();
const [frozen, settled] = await Promise.all([deletion, chain]);
if (frozen) return { ok: false, response: frozen, frozen: true };
if (!settled.ok) return { ok: false, response: settled.response, frozen: false };
return { ...settled, user, timings };
}
+47 -6
View File
@@ -3,8 +3,22 @@ import "server-only";
import type { MastraModelConfig } from "@mastra/core/llm";
import { queryAdminRows } from "@/lib/admin/database";
import { modelVersionCacheTtlMs } from "@/lib/consult/prep-flags";
import { assertAllowedModelProviderUrl } from "@/lib/epay/gateway-policy";
import { decryptModelProviderApiKey, type ModelProviderType } from "@/lib/model-provider-policy";
/** Tests replace the database, decrypt and DNS steps. Production leaves these as the real functions. */
const catalogIo = {
queryAdminRows,
assertAllowedModelProviderUrl,
decryptModelProviderApiKey,
};
export function setModelCatalogIoForTests(next: Partial<typeof catalogIo> | null): void {
catalogIo.queryAdminRows = next?.queryAdminRows ?? queryAdminRows;
catalogIo.assertAllowedModelProviderUrl = next?.assertAllowedModelProviderUrl ?? assertAllowedModelProviderUrl;
catalogIo.decryptModelProviderApiKey = next?.decryptModelProviderApiKey ?? decryptModelProviderApiKey;
}
import type { LanguageModelCatalog, ResolvedLanguageModel } from "@/mastra/model";
const cacheTtlMs = 15_000;
@@ -17,7 +31,11 @@ type PublishedModelRow = {
context_window: number | null;
};
type Cache = { expiresAt: number; catalog: LanguageModelCatalog };
const state = globalThis as typeof globalThis & { jyotishaModelCatalogCache?: Cache };
type VersionHit = { expiresAt: number; model: ResolvedLanguageModel };
const state = globalThis as typeof globalThis & {
jyotishaModelCatalogCache?: Cache;
jyotishaModelVersionCache?: Map<string, VersionHit>;
};
function isSecretSettingKey(key: string) {
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, "");
@@ -37,7 +55,7 @@ function publicModel(model: ResolvedLanguageModel) {
return { id: model.id, label: model.label, description: model.description, creditCost: model.creditCost, isDefault: model.isDefault };
}
async function resolveRow(row: PublishedModelRow): Promise<ResolvedLanguageModel | null> {
const apiKey = decryptModelProviderApiKey({ encryptedApiKey: row.encrypted_api_key });
const apiKey = catalogIo.decryptModelProviderApiKey({ encryptedApiKey: row.encrypted_api_key });
if (!apiKey) return null;
let model: MastraModelConfig;
if (row.provider_type === "openai") {
@@ -45,7 +63,7 @@ async function resolveRow(row: PublishedModelRow): Promise<ResolvedLanguageModel
} else if (row.provider_type === "anthropic") {
model = { providerId: "anthropic", modelId: row.provider_model.replace(/^anthropic\//, ""), apiKey };
} else {
model = { providerId: row.provider_code, modelId: row.provider_model, url: (await assertAllowedModelProviderUrl(row.base_url ?? "")).url.toString(), apiKey };
model = { providerId: row.provider_code, modelId: row.provider_model, url: (await catalogIo.assertAllowedModelProviderUrl(row.base_url ?? "")).url.toString(), apiKey };
}
return {
id: row.model_id, label: row.label, description: row.description, creditCost: row.credit_cost,
@@ -55,7 +73,7 @@ async function resolveRow(row: PublishedModelRow): Promise<ResolvedLanguageModel
};
}
async function queryCatalog(where: string, values: readonly unknown[] = []) {
return queryAdminRows<PublishedModelRow>(`
return catalogIo.queryAdminRows<PublishedModelRow>(`
select c.model_id,v.version,v.label,v.description,v.provider_model,v.credit_cost,v.is_default,
p.code provider_code,p.provider_type,p.base_url,p.encrypted_api_key,
v.input_cost_microusd_per_million input_cost,v.output_cost_microusd_per_million output_cost,
@@ -86,9 +104,32 @@ async function readVersion(modelId: string, version: number) {
const rows = await queryCatalog("c.model_id=$1 and v.version=$2 and v.status in ('published','retired') and v.enabled and p.enabled", [modelId, version]);
return rows[0] ? resolveRow(rows[0]) : null;
}
function versionCache(): Map<string, VersionHit> {
return state.jyotishaModelVersionCache ??= new Map();
}
export async function resolveSessionLanguageModel(modelId: string, configVersion: number | null | undefined) {
if (configVersion !== null && configVersion !== undefined) { try { return await readVersion(modelId, configVersion); } catch { return null; } }
if (configVersion !== null && configVersion !== undefined) {
const ttl = modelVersionCacheTtlMs();
const key = `${modelId}:${configVersion}`;
const cache = versionCache();
if (ttl > 0) {
const hit = cache.get(key);
if (hit && hit.expiresAt > Date.now()) return hit.model;
}
try {
const model = await readVersion(modelId, configVersion);
// Failures and null stay uncached so a disabled provider is not pinned.
if (model && ttl > 0) cache.set(key, { expiresAt: Date.now() + ttl, model });
return model;
} catch {
return null;
}
}
const catalog = await loadLanguageModelCatalog();
return catalog.models.find((model) => model.id === modelId) ?? null;
}
export function invalidateLanguageModelCatalog() { delete state.jyotishaModelCatalogCache; }
export function invalidateLanguageModelCatalog() {
delete state.jyotishaModelCatalogCache;
state.jyotishaModelVersionCache?.clear();
}
+108 -50
View File
@@ -27,6 +27,7 @@ import { normalizeTechniqueAuditRows } from "../lib/consultation-technique-audit
import type { AgentModelFinishReason, EvidenceCardTelemetry } from "../lib/agent-observability.ts";
import { agentGenerationSettings, AGENT_SLICE_ANSWER_OUTPUT_TOKENS, AGENT_SLICE_THINKING_OUTPUT_TOKENS } from "../lib/agent-generation-settings.ts";
import { chartCalculationProgressLabel, evidenceLookupActivityLabel } from "../lib/consultation-activity-labels.ts";
import { consultDomainConcurrency } from "../lib/consult/prep-flags.ts";
import {
buildEvidenceCard,
citedEvidenceCardFields,
@@ -1227,57 +1228,114 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
try {
const userIntent = ctx.plan?.userIntent ?? input.question;
const executions: DomainExecution[] = [];
for (let index = 0; index < domains.length; index += 1) {
const domain = domains[index];
if (!domain) continue;
// Every domain shares the run's single abort deadline, so a plan
// that runs long would abort mid-loop and lose the domains already
// calculated. Stop while there is still time to answer instead.
if (!domainFitsRunBudget(now() - startedAt, executions.length)) break;
await context.writer?.custom({
type: "data-jyotish-activity",
data: {
phase: "chart-calculation",
label: chartCalculationProgressLabel(index + 1, domains.length),
},
});
const domainPlan = ctx.plan
&& domains.length === 1
&& ctx.plan.requestedDomains.length === 1
&& ctx.plan.requestedDomains[0] === domain
? ctx.plan
: createConsultationPlan({
userIntent,
theme: domain,
consultationMode: ctx.consultationMode,
...(ctx.plan ? { modelCreditCost: ctx.plan.maxCreditCost, depth: ctx.plan.depth } : {}),
...(domain === "timing" || domain === "annual"
? { timingHorizon: ctx.plan?.timingHorizon ?? undefined }
: { timingHorizon: null }),
if (consultDomainConcurrency() === 1) {
for (let index = 0; index < domains.length; index += 1) {
const domain = domains[index];
if (!domain) continue;
// Every domain shares the run's single abort deadline, so a plan
// that runs long would abort mid-loop and lose the domains already
// calculated. Stop while there is still time to answer instead.
if (!domainFitsRunBudget(now() - startedAt, executions.length)) break;
await context.writer?.custom({
type: "data-jyotish-activity",
data: {
phase: "chart-calculation",
label: chartCalculationProgressLabel(index + 1, domains.length),
},
});
const toolInput = consultationInputSchema.parse({
...ctx.serverChart.toolInput,
entryMode: "direct_chart",
question: userIntent,
theme: domain,
});
const workflow = await (ctx.runWorkflow ?? runConsultationWorkflow)(toolInput, {
foreground: true,
signal: context.abortSignal ?? ctx.abortSignal,
plan: domainPlan,
requestId: ctx.requestId,
entrypoint: ctx.entrypoint,
});
const guarded = applyBirthTimeModeToWorkflowContext(workflow, ctx.consultationMode, {
birthTimeSource: ctx.serverChart.truth.birthTimeSource,
});
const agentContext = toAgentConsultationContext(guarded);
executions.push({
domain,
context: agentContext,
modelOutput: toModelOutput(agentContext, domainPlan, domain),
receipt: consultationWorkflowReceipt(guarded),
});
const domainPlan = ctx.plan
&& domains.length === 1
&& ctx.plan.requestedDomains.length === 1
&& ctx.plan.requestedDomains[0] === domain
? ctx.plan
: createConsultationPlan({
userIntent,
theme: domain,
consultationMode: ctx.consultationMode,
...(ctx.plan ? { modelCreditCost: ctx.plan.maxCreditCost, depth: ctx.plan.depth } : {}),
...(domain === "timing" || domain === "annual"
? { timingHorizon: ctx.plan?.timingHorizon ?? undefined }
: { timingHorizon: null }),
});
const toolInput = consultationInputSchema.parse({
...ctx.serverChart.toolInput,
entryMode: "direct_chart",
question: userIntent,
theme: domain,
});
const workflow = await (ctx.runWorkflow ?? runConsultationWorkflow)(toolInput, {
foreground: true,
signal: context.abortSignal ?? ctx.abortSignal,
plan: domainPlan,
requestId: ctx.requestId,
entrypoint: ctx.entrypoint,
});
const guarded = applyBirthTimeModeToWorkflowContext(workflow, ctx.consultationMode, {
birthTimeSource: ctx.serverChart.truth.birthTimeSource,
});
const agentContext = toAgentConsultationContext(guarded);
executions.push({
domain,
context: agentContext,
modelOutput: toModelOutput(agentContext, domainPlan, domain),
receipt: consultationWorkflowReceipt(guarded),
});
}
} else {
for (let index = 0; index < domains.length; index += 1) {
const domain = domains[index];
if (!domain) continue;
await context.writer?.custom({
type: "data-jyotish-activity",
data: {
phase: "chart-calculation",
label: chartCalculationProgressLabel(index + 1, domains.length),
},
});
}
const parallelExecutions = await Promise.all(domains.map(async (domain) => {
if (!domain) return null;
const domainPlan = ctx.plan
&& domains.length === 1
&& ctx.plan.requestedDomains.length === 1
&& ctx.plan.requestedDomains[0] === domain
? ctx.plan
: createConsultationPlan({
userIntent,
theme: domain,
consultationMode: ctx.consultationMode,
...(ctx.plan ? { modelCreditCost: ctx.plan.maxCreditCost, depth: ctx.plan.depth } : {}),
...(domain === "timing" || domain === "annual"
? { timingHorizon: ctx.plan?.timingHorizon ?? undefined }
: { timingHorizon: null }),
});
const toolInput = consultationInputSchema.parse({
...ctx.serverChart.toolInput,
entryMode: "direct_chart",
question: userIntent,
theme: domain,
});
const workflow = await (ctx.runWorkflow ?? runConsultationWorkflow)(toolInput, {
foreground: true,
signal: context.abortSignal ?? ctx.abortSignal,
plan: domainPlan,
requestId: ctx.requestId,
entrypoint: ctx.entrypoint,
});
const guarded = applyBirthTimeModeToWorkflowContext(workflow, ctx.consultationMode, {
birthTimeSource: ctx.serverChart.truth.birthTimeSource,
});
const agentContext = toAgentConsultationContext(guarded);
return {
domain,
context: agentContext,
modelOutput: toModelOutput(agentContext, domainPlan, domain),
receipt: consultationWorkflowReceipt(guarded),
};
}));
for (const execution of parallelExecutions) {
if (execution) executions.push(execution);
}
}
// Domains the cap refused and domains the clock ran out on are the
// same disclosure to the caller: requested but not calculated.
@@ -39,7 +39,10 @@ test("in-app destinations navigate client-side so the chat survives the round tr
assert.match(sidebarSource, /<SidebarMenuLink\n\s*className="report-nav-button"/);
assert.doesNotMatch(sidebarSource, /window\.location/);
assert.match(pageSource, /onOpenBilling: \(\) => openAccountDialog\("billing", \{ source: "account-menu" \}\)/);
// 原值: /onOpenBilling: \(\) => openAccountDialog\("billing", \{ source: "account-menu" \}\)/
// 新值: /onOpenBilling: \(\) => \{ latestRef\.current\?\.openAccountDialog\("billing", \{ source: "account-menu" \}\); \}/
// 原因: 侧栏按钮改成稳定包装,点下去仍打开充值,来源仍是账户菜单。不得放宽成只查 "billing"。
assert.match(pageSource, /onOpenBilling: \(\) => \{ latestRef\.current\?\.openAccountDialog\("billing", \{ source: "account-menu" \}\); \}/);
assert.match(pageSource, /openAccountDialog\("billing", \{ returnTarget: event\.currentTarget, source: "credits" \}\)/);
assert.match(pageSource, /openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/);
// 原值: /if \(response\.status === 402\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/
+12 -7
View File
@@ -7,6 +7,7 @@ const listRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", impor
const listFilter = readFileSync(new URL("../src/lib/session-list-filter.ts", import.meta.url), "utf8");
const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
const consultRoute = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const turnPrep = readFileSync(new URL("../src/lib/consult/turn-prep.ts", import.meta.url), "utf8");
const sql = readFileSync(
new URL("../supabase/migrations/20260901010000_append_consultation_question.sql", import.meta.url),
"utf8",
@@ -59,14 +60,18 @@ test("session list GET omits messages while detail GET returns them", () => {
});
test("consult POST on a rectification session is 409, a missing id is 404", () => {
// 原值:!chatSession || session_type !== consultation 一律 404「咨询会话不存在」
// 新值:查不到 404;类型不对 409 session_not_consultation
// 原因:BUG-925 前端无法区分不存在与类型不对
assert.match(consultRoute, /if \(!chatSession\) \{[\s\S]*status: 404/);
assert.match(consultRoute, /code: "session_not_consultation"/);
assert.match(consultRoute, /status: 409/);
// 原值:consult route 里 /if (!chatSession) {[\s\S]*status: 404/,以及同文件的 session_not_consultation 与 409
// 新值:turn-prep.ts 里 sessionMissing() 为 404,notConsultation() 为 409 session_not_consultation,两处调用分开
// 原因:判定从 route.ts 挪进 turn-prep.ts;仍是查不到 404、类型不对 409,不得并成一个判断
assert.match(turnPrep, /function sessionMissing\(\)[\s\S]*status: 404/);
assert.match(turnPrep, /if \(!session\.data\) return \{ ok: false, response: sessionMissing\(\) \}/);
assert.match(turnPrep, /function notConsultation\(\)[\s\S]*code: "session_not_consultation"[\s\S]*status: 409/);
assert.match(
turnPrep,
/if \(chatSession\.session_type !== "consultation"\) return \{ ok: false, response: notConsultation\(\) \}/,
);
assert.doesNotMatch(
consultRoute,
turnPrep,
/if \(!chatSession \|\| chatSession\.session_type !== "consultation"\)/,
);
});
@@ -255,18 +255,23 @@ test("a turn that throws before producing any response ends the stream with a ge
test("the consult route opens the stream before subject reads, reservation, question append and classification", () => {
const route = source("../src/app/api/consult/route.ts");
const head = source("../src/lib/consult/turn-prep.ts");
const openIndex = route.indexOf("const runTurn = async (): Promise<Response> =>");
assert.ok(openIndex > 0);
// Stays in front of the stream: auth, body, session existence and type, the session model.
// 原值: 401、请求体、会话不存在、会话类型、模型已变这些字面量都在 route.ts 的 runTurn 之前。
// 新值: 这些字面量在 turn-prep.ts;route.ts 在 runTurn 之前调用 loadConsultTurnHead 并返回失败响应。提示词拦截仍在 route.ts。
// 原因: 开流前的独立读取收进同一个函数,两种开关的状态码仍在开流前返回。
assert.ok(route.indexOf("loadConsultTurnHead") < openIndex);
assert.ok(route.indexOf("if (!head.ok)") < openIndex);
assert.ok(route.indexOf("if (blocksPromptExtraction(userControlledPrompt))") < openIndex);
for (const marker of [
"status: 401",
'{ error: "出生资料或问题格式不正确", details: parsed.error.flatten() }',
'{ error: "出生资料或问题格式不正确", details: error.flatten() }',
'error: "咨询会话不存在"',
'code: "session_not_consultation"',
'error: "会话模型已经变化"',
"if (blocksPromptExtraction(userControlledPrompt))",
]) {
assert.ok(route.indexOf(marker) < openIndex, `${marker} must answer with an HTTP status`);
assert.ok(head.includes(marker), `${marker} stays an HTTP response before the stream`);
}
// Inside the stream: the slow part, and every rejection it can raise.
for (const marker of [
@@ -0,0 +1,164 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { register } from "node:module";
import test from "node:test";
import { agentObservabilityEventSchema } from "../src/lib/agent-observability.ts";
import { ConsultationInputBlockedError, consultPrepObservabilityEvent } from "../src/lib/consult/prep-log.ts";
import { inputBlockedCopy, MODERATION_SELF_HARM_COPY } from "../src/lib/moderation/index.ts";
import type { ModerationResult } from "../src/lib/moderation/types.ts";
import { streamFirstResponse } from "../src/lib/stream-first-response.ts";
const blocked: ModerationResult = {
verdict: "block",
categories: ["self_harm"],
ruleIds: ["lexicon"],
providerId: "local",
};
test("a blocked question records once and never reaches reserve", async () => {
let reserves = 0;
let records = 0;
const pending = Promise.resolve(blocked);
async function beforeReserve() {
const result = await pending;
if (result.verdict !== "pass") records += 1;
if (result.verdict === "block") throw new ConsultationInputBlockedError(result);
reserves += 1;
}
await assert.rejects(beforeReserve, (error: unknown) => {
assert.ok(error instanceof ConsultationInputBlockedError);
assert.equal(inputBlockedCopy(error.result), MODERATION_SELF_HARM_COPY);
return true;
});
assert.equal(reserves, 0);
assert.equal(records, 1);
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const blockedAt = route.indexOf('blocked?.verdict === "block"');
const profileAt = route.indexOf("error instanceof ConsultationProfileTruthError");
assert.ok(blockedAt > 0 && blockedAt < profileAt);
});
test("moderation that takes 1.5s does not hold the first frame", async () => {
let release: (result: ModerationResult) => void = () => {};
const pending = new Promise<ModerationResult>((resolve) => {
release = resolve;
});
const started = Date.now();
const response = streamFirstResponse({
requestId: "prep-frame",
run: async () => {
const result = await pending;
return Response.json(
{ error: "无法处理该请求", code: "content_blocked", message: inputBlockedCopy(result) },
{ status: 400 },
);
},
});
const reader = response.body!.getReader();
const first = await reader.read();
assert.ok(Date.now() - started < 400, "first frame waited for moderation");
assert.ok(first.value && first.value.byteLength > 0);
setTimeout(() => release(blocked), 1_500);
const chunks: string[] = [];
const decoder = new TextDecoder();
while (true) {
const next = await reader.read();
if (next.value) chunks.push(decoder.decode(next.value));
if (next.done) break;
}
const events = chunks.join("").trim().split("\n").map((line) => JSON.parse(line));
const rejected = events.find((event) => event.type === "run.failed");
assert.equal(rejected.message, MODERATION_SELF_HARM_COPY);
assert.equal(rejected.reason, "content_blocked");
assert.equal(rejected.status, 400);
});
test("prep observability accepts the closed schema and drops the question", () => {
const question = "这句问题原文不该出现在日志里";
const event = consultPrepObservabilityEvent({
requestId: "req-prep",
sessionId: "sess-prep",
phases: [
{ phase: "prep.model", durationMs: 3, status: "completed" },
{ phase: "prep.moderation", durationMs: 12, status: "completed" },
],
});
assert.deepEqual(agentObservabilityEventSchema.parse(event), event);
assert.equal(JSON.stringify(event).includes(question), false);
assert.equal(event.agentVersion, "consultation-prep-v1");
});
test("readVersion cache skips a second database and DNS lookup inside the ttl", async () => {
await register(new URL("./support/stub-server-only.mjs", import.meta.url).href, import.meta.url);
let queries = 0;
let dns = 0;
const row = {
model_id: "model-a",
version: 4,
label: "Model A",
description: "fixture",
provider_model: "demo",
credit_cost: 1,
is_default: false,
provider_code: "demo",
provider_type: "openai-compatible",
base_url: "https://example.com/v1",
encrypted_api_key: "enc",
input_cost: 1,
output_cost: 1,
context_window: 8000,
};
const catalog = await import("../src/lib/model-catalog.ts");
const flags = await import("../src/lib/consult/prep-flags.ts");
catalog.setModelCatalogIoForTests({
queryAdminRows: (async () => {
queries += 1;
if (queries === 3) throw new Error("database down");
if (queries >= 4) return [];
return [row];
}) as typeof import("../src/lib/admin/database.ts").queryAdminRows,
assertAllowedModelProviderUrl: (async (value: string) => {
dns += 1;
return { url: new URL(value), addresses: [] };
}) as typeof import("../src/lib/epay/gateway-policy.ts").assertAllowedModelProviderUrl,
decryptModelProviderApiKey: (() => "test-key") as typeof import("../src/lib/model-provider-policy.ts").decryptModelProviderApiKey,
});
process.env.MODEL_VERSION_CACHE_TTL_MS = "15000";
try {
catalog.invalidateLanguageModelCatalog();
const first = await catalog.resolveSessionLanguageModel("model-a", 4);
const second = await catalog.resolveSessionLanguageModel("model-a", 4);
assert.equal(first?.id, "model-a");
assert.equal(second, first);
assert.equal(queries, 1);
assert.equal(dns, 1);
catalog.invalidateLanguageModelCatalog();
await catalog.resolveSessionLanguageModel("model-a", 4);
assert.equal(queries, 2);
process.env.MODEL_VERSION_CACHE_TTL_MS = "0";
await catalog.resolveSessionLanguageModel("model-a", 4);
assert.ok(queries >= 3);
const failed = await catalog.resolveSessionLanguageModel("model-a", 4);
const empty = await catalog.resolveSessionLanguageModel("model-a", 4);
assert.equal(failed, null);
assert.equal(empty, null);
const queriesAfterMiss = queries;
await catalog.resolveSessionLanguageModel("model-a", 4);
assert.ok(queries > queriesAfterMiss);
} finally {
flags.resetConsultPrepFlagWarningsForTests();
delete process.env.MODEL_VERSION_CACHE_TTL_MS;
catalog.invalidateLanguageModelCatalog();
catalog.setModelCatalogIoForTests(null);
}
});
test("the title read starts after the question is saved and is awaited at the old use site", () => {
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const append = route.indexOf('accounting.rpc("append_consultation_question"');
const title = route.indexOf('.select("title")');
const classify = route.indexOf("await classifyConsultationTurn({");
const awaited = route.indexOf("await titleRead");
assert.ok(append > 0 && title > append && classify > title && awaited > classify);
});
@@ -0,0 +1,79 @@
/**
* In-process overlap check for consult preparation.
*
* This does not measure local Postgres. Those medians were not collected,
* so the switches stay at their code defaults.
*/
import { register } from "node:module";
await register(new URL("./support/stub-server-only.mjs", import.meta.url).href, import.meta.url);
const { loadConsultTurnHead } = await import("../src/lib/consult/turn-prep.ts");
const delays = { deletion: 50, session: 80, model: 80 };
async function run(parallel: boolean) {
let now = 0;
const waiters: Array<{ at: number; resolve: () => void }> = [];
let finished = false;
let inFlight = 0;
let maxInFlight = 0;
function sleep(ms: number) {
return new Promise<void>((resolve) => {
waiters.push({ at: now + ms, resolve });
});
}
async function occupy(ms: number) {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await sleep(ms);
inFlight -= 1;
}
const pending = loadConsultTurnHead({
parallel,
getUser: async () => ({ data: { user: { id: "user-1" } }, error: null }),
readPendingDeletion: () => occupy(delays.deletion).then(() => null),
readBody: async () => ({ success: true as const, data: { sessionId: "s", modelId: "m" } }),
readSession: () => occupy(delays.session).then(() => ({
data: { model_id: "m", model_config_version: 2, session_type: "consultation" },
error: null,
})),
resolveModel: () => occupy(delays.model).then(() => ({ id: "m" })),
}).then((head) => {
finished = true;
return head;
});
for (let guard = 0; guard < 40 && !finished; guard += 1) {
await Promise.resolve();
await Promise.resolve();
if (!waiters.length) continue;
waiters.sort((left, right) => left.at - right.at);
now = waiters[0]!.at;
const due: Array<() => void> = [];
while (waiters.length && waiters[0]!.at <= now) due.push(waiters.shift()!.resolve);
for (const resolve of due) resolve();
}
if (!finished) throw new Error("clock did not settle");
const head = await pending;
if (!head.ok) throw new Error("head failed");
return { elapsed: now, maxInFlight };
}
const on = await run(true);
const off = await run(false);
const report = {
kind: "logical-clock",
delays,
parallelElapsed: on.elapsed,
serialElapsed: off.elapsed,
parallelMaxInFlight: on.maxInFlight,
serialMaxInFlight: off.maxInFlight,
postgresMedians: "not-measured",
aliyunKey: "absent",
moderationSimulationMs: [300, 1500],
decision: {
CONSULT_PREP_PARALLEL: "off",
MODEL_VERSION_CACHE_TTL_MS: 0,
reason: "开启条件要求本机 Postgres 中位数。本轮没有量到,默认保持关闭。",
},
};
console.log(JSON.stringify(report));
@@ -0,0 +1,152 @@
import assert from "node:assert/strict";
import test from "node:test";
import { loadConsultTurnHead } from "../src/lib/consult/turn-prep.ts";
type User = { id: string };
type Parsed = { sessionId: string; modelId: string };
type Session = { model_id: string | null; model_config_version: number | null; session_type: string | null };
type Model = { id: string };
function okSession(): Session {
return { model_id: "m", model_config_version: 2, session_type: "consultation" };
}
function deps(overrides: Record<string, unknown> = {}) {
const calls = { deletion: 0, session: 0, model: 0 };
const base = {
parallel: false,
getUser: async () => ({ data: { user: { id: "user-1" } as User }, error: null }),
readPendingDeletion: async () => {
calls.deletion += 1;
return null;
},
readBody: async () => ({ success: true as const, data: { sessionId: "session-1", modelId: "m" } as Parsed }),
readSession: async () => {
calls.session += 1;
return { data: okSession(), error: null };
},
resolveModel: async () => {
calls.model += 1;
return { id: "m" } as Model;
},
...overrides,
};
return { calls, deps: base };
}
async function snapshot(response: Response) {
return { status: response.status, body: await response.json() };
}
async function both(overrides: Record<string, unknown> = {}) {
const off = deps({ ...overrides, parallel: false });
const on = deps({ ...overrides, parallel: true });
const [left, right] = await Promise.all([
loadConsultTurnHead(off.deps),
loadConsultTurnHead(on.deps),
]);
assert.equal(left.ok, false);
assert.equal(right.ok, false);
if (left.ok || right.ok) return;
assert.deepEqual(await snapshot(left.response), await snapshot(right.response));
}
test("serial and parallel heads return the same status for each gate", async () => {
const anonymous = deps({
getUser: async () => ({ data: { user: null }, error: null }),
});
const missingUser = await loadConsultTurnHead({ ...anonymous.deps, parallel: false });
assert.equal(missingUser.ok, false);
if (!missingUser.ok) assert.equal(missingUser.response.status, 401);
assert.equal(anonymous.calls.deletion, 0);
await both({
readPendingDeletion: async () => Response.json({ error: "账号正在注销" }, { status: 423 }),
readBody: async () => ({ success: false as const, error: { flatten: () => ({ formErrors: ["bad"] }) } }),
});
await both({
readBody: async () => ({ success: false as const, error: { flatten: () => ({ formErrors: ["bad"] }) } }),
});
await both({ readSession: async () => ({ data: null, error: { message: "down" } }) });
await both({ readSession: async () => ({ data: null, error: null }) });
await both({
readSession: async () => ({ data: { ...okSession(), session_type: "rectification" }, error: null }),
});
await both({
readSession: async () => ({ data: { ...okSession(), model_id: "other" }, error: null }),
});
await both({ resolveModel: async () => null });
});
test("a frozen account wins over a bad request body", async () => {
const frozen = deps({
parallel: true,
readPendingDeletion: async () => Response.json({ error: "账号正在注销" }, { status: 423 }),
readBody: async () => ({ success: false as const, error: { flatten: () => ({}) } }),
});
const head = await loadConsultTurnHead(frozen.deps);
assert.equal(head.ok, false);
if (!head.ok) {
assert.equal(head.frozen, true);
assert.equal(head.response.status, 423);
}
});
test("parallel head overlaps deletion with session and model", async () => {
const delays = { deletion: 50, session: 80, model: 80 };
async function run(parallel: boolean) {
let now = 0;
const waiters: Array<{ at: number; resolve: () => void }> = [];
function sleep(ms: number) {
return new Promise<void>((resolve) => {
waiters.push({ at: now + ms, resolve });
});
}
let deletionStarted = 0;
let chainStarted = 0;
let inFlight = 0;
let maxInFlight = 0;
let finished = false;
async function occupy(ms: number, mark: () => void) {
mark();
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await sleep(ms);
inFlight -= 1;
}
const pending = loadConsultTurnHead({
parallel,
getUser: async () => ({ data: { user: { id: "user-1" } }, error: null }),
readPendingDeletion: () => occupy(delays.deletion, () => { deletionStarted = now; }).then(() => null),
readBody: async () => ({ success: true as const, data: { sessionId: "s", modelId: "m" } }),
readSession: () => occupy(delays.session, () => { chainStarted = now; }).then(() => ({ data: okSession(), error: null })),
resolveModel: () => occupy(delays.model, () => {}).then(() => ({ id: "m" })),
}).then((head) => {
finished = true;
return head;
});
for (let guard = 0; guard < 40 && !finished; guard += 1) {
await Promise.resolve();
await Promise.resolve();
if (!waiters.length) continue;
waiters.sort((left, right) => left.at - right.at);
now = waiters[0]!.at;
const due: Array<() => void> = [];
while (waiters.length && waiters[0]!.at <= now) due.push(waiters.shift()!.resolve);
for (const resolve of due) resolve();
}
if (!finished) throw new Error("clock did not settle");
const head = await pending;
assert.equal(head.ok, true);
return { elapsed: now, deletionStarted, chainStarted, maxInFlight };
}
const on = await run(true);
const off = await run(false);
assert.ok(on.elapsed < 230, `parallel elapsed ${on.elapsed}`);
assert.equal(on.maxInFlight, 2);
assert.equal(off.maxInFlight, 1);
assert.equal(on.deletionStarted, on.chainStarted);
assert.ok(off.chainStarted >= delays.deletion);
assert.ok(off.elapsed > on.elapsed);
});
@@ -138,7 +138,7 @@ type PlanResult = Record<string, unknown> & {
async function runDomainPlan(
domains: string[],
runWorkflow: (theme: string) => ReturnType<typeof workflow>,
runWorkflow: (theme: string) => ReturnType<typeof workflow> | Promise<ReturnType<typeof workflow>>,
options: { now?: () => number; requestId?: string } = {},
) {
const state = createConsultationRuntimeState();
@@ -623,6 +623,63 @@ test("the single-value domain form stays available to callers without the model
assert.throws(() => canonicalDomainPlan({ theme: "prashna" }, {}), /unsupported_consultation_domain/);
});
test("two domains stay in plan order when concurrency is 2 and the second finishes first", async () => {
const previous = process.env.CONSULT_DOMAIN_CONCURRENCY;
process.env.CONSULT_DOMAIN_CONCURRENCY = "2";
const finished: string[] = [];
try {
const { result } = await runDomainPlan(["career", "marriage"], async (theme) => {
await new Promise((resolve) => setTimeout(resolve, theme === "career" ? 40 : 5));
finished.push(theme);
return workflow(theme);
});
assert.deepEqual(finished, ["marriage", "career"]);
assert.deepEqual(result.domains, ["career", "marriage"]);
} finally {
if (previous === undefined) delete process.env.CONSULT_DOMAIN_CONCURRENCY;
else process.env.CONSULT_DOMAIN_CONCURRENCY = previous;
}
});
test("a 429 from one parallel domain keeps workflow_rate_limited", async () => {
const previous = process.env.CONSULT_DOMAIN_CONCURRENCY;
process.env.CONSULT_DOMAIN_CONCURRENCY = "2";
try {
await assert.rejects(
() => runDomainPlan(["career", "marriage"], async () => {
throw new ConsultationWorkflowError("workflow_rate_limited", "busy");
}),
(error: unknown) => {
assert.equal(consultationToolFailureCode(error), "workflow_rate_limited");
return true;
},
);
} finally {
if (previous === undefined) delete process.env.CONSULT_DOMAIN_CONCURRENCY;
else process.env.CONSULT_DOMAIN_CONCURRENCY = previous;
}
});
test("concurrency 1 still runs domains one at a time", async () => {
const previous = process.env.CONSULT_DOMAIN_CONCURRENCY;
process.env.CONSULT_DOMAIN_CONCURRENCY = "1";
let active = 0;
let maxActive = 0;
try {
await runDomainPlan(["career", "marriage"], async (theme) => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => setTimeout(resolve, 20));
active -= 1;
return workflow(theme);
});
assert.equal(maxActive, 1);
} finally {
if (previous === undefined) delete process.env.CONSULT_DOMAIN_CONCURRENCY;
else process.env.CONSULT_DOMAIN_CONCURRENCY = previous;
}
});
test("daily entrypoint ignores a model domain rewrite instead of executing it", async () => {
const calls: string[] = [];
const state = createConsultationRuntimeState();
@@ -157,8 +157,20 @@ test("consultation plans are server-owned and bounded", () => {
const modelToolInput = tools.slice(tools.indexOf("const consultationToolInputSchema"), tools.indexOf("const MAX_RECORDED_STEPS"));
assert.doesNotMatch(modelToolInput, /birth|latitude|longitude|engine|script|credit/i);
const prepareCall = route.slice(route.indexOf("prepared = await prepareConsultationRoute"), route.indexOf("const modelSelection"));
assert.match(prepareCall, /beforeReserve: \(\{ consultationMode \}\) => createConsultationPlan\(\{/);
assert.ok(prepareCall.indexOf("beforeReserve:") < prepareCall.indexOf("reserve: () => reserveConsultationModel("));
// 原值:beforeReserve: ({ consultationMode }) => createConsultationPlan({,且早于 reserve: () => reserveConsultationModel(
// 新值:beforeReserve 的 async 函数体里调用 createConsultationPlan({,并且整段早于 reserve;reserve 的 async 函数体里调用 reserveConsultationModel(
// 原因:两处改成 async 函数体。仍锁调用发生在对应回调里,并且 beforeReserve 在 reserve 之前
const beforeReserveAt = prepareCall.indexOf("beforeReserve:");
const reserveAt = prepareCall.indexOf("reserve: async");
assert.ok(beforeReserveAt >= 0 && reserveAt > beforeReserveAt);
assert.match(
prepareCall.slice(beforeReserveAt, reserveAt),
/beforeReserve:\s*async\s*\(\{\s*consultationMode\s*\}\)[\s\S]*createConsultationPlan\(\{/,
);
assert.match(
prepareCall.slice(reserveAt),
/reserve:\s*async\s*\(\)\s*=>[\s\S]*reserveConsultationModel\(/,
);
assert.ok(routeService.indexOf("const preReserveResult = input.beforeReserve") < routeService.indexOf("const reservation = await input.reserve()"));
assert.match(route, /plan: prepared\.preReserveResult,[\s\S]*theme: consultationTheme/);
assert.match(route, /runConsultationWorkflow\(toolInput, \{[\s\S]*plan: prepared\.preReserveResult/);
+9 -2
View File
@@ -239,8 +239,15 @@ test("a blocked answer is replaced in place and onComplete settles the notice",
test("routes check input before any charge and output before settlement", () => {
const consult = read("../src/app/api/consult/route.ts");
assert.ok(consult.indexOf('await moderate(visibleQuestion, "input"') > 0);
assert.ok(consult.indexOf('await moderate(visibleQuestion, "input"') < consult.indexOf("reserve_consultation_usage"), "input check precedes the reservation");
const prepare = read("../src/lib/consultation-route-service.ts");
// 原值: 源码含 await moderate(visibleQuestion, "input" 且位置早于 reserve_consultation_usage
// 新值: beforeReserve 里 await settleInputModeration(),而 prepare 里 beforeReserve 早于 input.reserve()
// 原因: 审核从开流前挪到流内、预留前;开关关掉时仍在开流前 await moderate
assert.match(consult, /await settleInputModeration\(\)/);
assert.ok(consult.indexOf("await settleInputModeration()") < consult.indexOf("reserve_consultation_usage"));
const beforeReserve = prepare.indexOf("input.beforeReserve");
const reserveCall = prepare.indexOf("input.reserve()");
assert.ok(beforeReserve > 0 && beforeReserve < reserveCall);
assert.equal((consult.match(/moderateOutput: moderateAnswer/g) ?? []).length, 3, "every agentic answer stream is checked");
assert.match(consult, /if \(!outputModerated\) await moderateAnswer\(reply\.text\);\n\s+if \(outputBlocked\) return await completeModeratedResponse\(usage\);/);
assert.match(consult, /complete_consultation_moderated/);
@@ -0,0 +1,304 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import test from "node:test";
import { Profiler, useCallback, useEffect, useState, type ProfilerOnRenderCallback, type ReactNode } from "react";
import { AppSidebar } from "../src/components/app-sidebar.tsx";
import { SidebarInset, SidebarProvider } from "../src/components/ui/sidebar.tsx";
import { useHomeShellRegistration } from "../src/hooks/use-home-shell-registration.ts";
import { resetSubjectCatalogForTests } from "../src/lib/current-subject.ts";
import { emptyProfile, type Account, type ChatSession } from "../src/lib/home-types.ts";
import {
SessionListProvider,
sessionListSidebarModel,
useSessionList,
useShellRegistration,
type ShellRegistration,
} from "../src/lib/session-list-context.tsx";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
const account: Account = {
user: { id: "11111111-1111-4111-8111-111111111111", email: null },
profile: { ...emptyProfile, name: "Synthetic" },
avatar: null, credits: 10, isAdmin: false, adminUrl: null,
rectificationPriceCredits: 0, activeSubscription: null,
hasConfirmedBirthTime: false, hasUsableBirthTime: false,
};
const trigger = { current: null };
const charts: never[] = [];
function syntheticSession(index: number, title = `Synthetic session ${index}`): ChatSession {
return {
id: `session-${index}`,
title,
theme: "general",
modelId: "test",
messages: [],
createdAt: 1,
updatedAt: 1_700_000_000_000 + index,
sessionType: "consultation",
rectificationCaseId: null,
chartProfileId: null,
chartProfileName: null,
chartProfileRole: null,
pinned: false,
archivedAt: null,
messagesHydrated: false,
};
}
function registrationData(registration: ShellRegistration | null) {
if (!registration) return null;
return {
activeSessionId: registration.activeSessionId,
openingSessionId: registration.openingSessionId,
escapeBlocked: registration.escapeBlocked,
insetClassName: registration.insetClassName,
insetInert: registration.insetInert,
account: registration.sidebarAccount ?? null,
sessions: registration.sidebarSessions ?? [],
newChatDisabled: registration.controls.newChatDisabled,
creatingSession: registration.controls.creatingSession,
accountMenuOpen: registration.controls.accountMenuOpen,
hasMore: registration.controls.sessionControls.hasMore ?? false,
menuSessionId: registration.controls.sessionControls.menuSessionId,
disabled: registration.controls.sessionControls.disabled,
};
}
function payloadSha(data: ReturnType<typeof registrationData>) {
return createHash("sha256").update(JSON.stringify(data)).digest("hex");
}
async function runProbe(sessionCount: number) {
const harness = createClientLifecycleHarness();
resetSubjectCatalogForTests();
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
const storageValues = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => storageValues.get(key) ?? null,
setItem: (key: string, value: string) => { storageValues.set(key, String(value)); },
removeItem: (key: string) => { storageValues.delete(key); },
clear: () => { storageValues.clear(); },
key: (index: number) => Array.from(storageValues.keys())[index] ?? null,
get length() { return storageValues.size; },
} satisfies Storage,
});
const originalFetch = globalThis.fetch;
const originalIntersection = Object.getOwnPropertyDescriptor(globalThis, "IntersectionObserver");
class FakeIntersectionObserver {
observe() {}
disconnect() {}
unobserve() {}
}
Object.defineProperty(globalThis, "IntersectionObserver", {
configurable: true,
writable: true,
value: FakeIntersectionObserver,
});
globalThis.fetch = (async (input: unknown) => {
const url = String(input);
const payload = url === "/api/account" ? account
: url === "/api/chart-profiles" ? { profiles: [] }
: { sessions: [], nextCursor: null };
return new Response(JSON.stringify(payload), { status: 200 });
}) as typeof fetch;
const metrics = { registers: 0, commits: 0, actualDuration: 0, recording: false };
const selectRevisions: number[] = [];
const renameRevisions: number[] = [];
const driver: {
setFrame: (frame: number) => void;
setActive: (id: string) => void;
retitle: () => void;
switchPerson: () => void;
setRevision: (revision: number) => void;
} = {
setFrame: () => {},
setActive: () => {},
retitle: () => {},
switchPerson: () => {},
setRevision: () => {},
};
let latestShell: ShellRegistration | null = null;
const currentShell = (): ShellRegistration | null => latestShell;
const initialSessions = Array.from({ length: sessionCount }, (_, index) => syntheticSession(index));
const otherPerson = [syntheticSession(sessionCount + 1, "Other person session")];
function HomeProbe() {
const list = useSessionList();
const [frame, setFrame] = useState(0);
const [activeSessionId, setActive] = useState(initialSessions[0]?.id ?? null);
const [sessions, setSessions] = useState(initialSessions);
const [revision, setRevision] = useState(0);
// Production passes SessionListProvider's stable registrar. A fresh wrapper
// every render would rerun the unmount cleanup and publish twice per frame.
const registerShellControls = useCallback((registration: ShellRegistration | null) => {
if (metrics.recording) metrics.registers += 1;
list.registerShellControls(registration);
}, [list]);
useHomeShellRegistration({
account: list.account,
accountMenuOpen: false,
accountTrigger: trigger,
activeSessionId: activeSessionId ?? undefined,
cancellationPending: false,
chartLibrary: charts,
creatingSession: false,
hydrated: list.settled,
loadMoreSessions: () => {},
modalOpen: false,
modelCatalog: null,
openAccountDialog: () => {},
pendingSessionId: null,
profile: { ...emptyProfile, name: "Synthetic" },
rectificationOpeningSessionId: null,
rectificationSurfaceOpen: false,
registerShellControls,
renameSession: () => { renameRevisions.push(revision); },
selectSession: () => { selectRevisions.push(revision); },
sessionMenuId: null,
sessions,
sessionsCursor: null,
setAccountMenuOpen: () => {},
setPendingSessionDeletion: () => {},
setSessionMenuId: () => {},
shareSession: () => {},
startNewChat: () => {},
togglePinnedSession: () => {},
// New array every render. Element identity stays until a real edit.
visibleSessions: sessions.filter(() => true),
});
void frame;
useEffect(() => {
driver.setFrame = setFrame;
driver.setActive = setActive;
driver.retitle = () => setSessions((current) => current.map((session, index) => (
index === 0 ? { ...session, title: "Renamed synthetic" } : session
)));
driver.switchPerson = () => setSessions(otherPerson);
driver.setRevision = setRevision;
}, []);
return null;
}
const onSidebarRender: ProfilerOnRenderCallback = (_id, phase, actualDuration) => {
if (!metrics.recording) return;
if (phase !== "mount" && phase !== "update") return;
metrics.commits += 1;
metrics.actualDuration += actualDuration;
};
function Shell({ children }: { children: ReactNode }) {
const list = useSessionList();
const registration = useShellRegistration();
const model = sessionListSidebarModel(list, registration);
useEffect(() => { latestShell = registration; });
return (
<SidebarProvider escapeBlocked={registration?.escapeBlocked ?? false}>
<Profiler id="sidebar" onRender={onSidebarRender}>
<AppSidebar
sessions={model.sessions}
activeSessionId={registration?.activeSessionId ?? null}
openingSessionId={registration?.openingSessionId ?? null}
account={model.account}
settled={list.settled}
signedOut={list.signedOut}
controls={registration?.controls}
/>
</Profiler>
<SidebarInset inert={registration?.insetInert}>{children}</SidebarInset>
</SidebarProvider>
);
}
const restore = () => {
globalThis.fetch = originalFetch;
resetSubjectCatalogForTests();
if (originalStorage) Object.defineProperty(globalThis, "localStorage", originalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
if (originalIntersection) Object.defineProperty(globalThis, "IntersectionObserver", originalIntersection);
else Reflect.deleteProperty(globalThis, "IntersectionObserver");
};
try {
await harness.render(
<SessionListProvider>
<Shell><HomeProbe /></Shell>
</SessionListProvider>,
);
await harness.idle();
assert.equal(harness.errors.length, 0);
assert.ok(latestShell, "hydrated home registers once before the frame run");
metrics.recording = true;
metrics.registers = 0;
metrics.commits = 0;
metrics.actualDuration = 0;
for (let frame = 1; frame <= 60; frame += 1) {
await harness.update(() => driver.setFrame(frame));
}
const duringFrames = {
registers: metrics.registers,
commits: metrics.commits,
actualDuration: Number(metrics.actualDuration.toFixed(3)),
};
metrics.registers = 0;
metrics.commits = 0;
await harness.update(() => driver.setActive("session-active"));
const afterActiveRegisters = metrics.registers;
const afterActiveCommits = metrics.commits;
const afterActive = registrationData(currentShell());
metrics.registers = 0;
await harness.update(() => driver.retitle());
const afterTitleRegisters = metrics.registers;
const renamedTitle = currentShell()?.sidebarSessions?.[0]?.title ?? null;
metrics.registers = 0;
await harness.update(() => driver.setRevision(4));
const afterRevisionRegisters = metrics.registers;
currentShell()?.controls.onSelectSession("session-0");
currentShell()?.controls.sessionControls.onRename("session-0");
metrics.registers = 0;
await harness.update(() => driver.switchPerson());
const afterSwitchRegisters = metrics.registers;
const otherTitle = currentShell()?.sidebarSessions?.[0]?.title ?? null;
const report = {
sessions: sessionCount,
duringFrames,
afterActiveRegisters,
afterActiveCommits,
afterActiveSha: payloadSha(afterActive),
afterActive,
afterTitleRegisters,
renamedTitle,
afterRevisionRegisters,
selectRevisions,
renameRevisions,
afterSwitchRegisters,
otherTitle,
};
console.log(`SIDEBAR_AB ${JSON.stringify(report)}`);
assert.equal(duringFrames.registers, 0);
assert.equal(duringFrames.commits, 0);
assert.equal(afterActiveRegisters, 1);
assert.equal(afterActive?.activeSessionId, "session-active");
assert.equal(afterTitleRegisters, 1);
assert.equal(renamedTitle, "Renamed synthetic");
assert.equal(afterRevisionRegisters, 0);
assert.deepEqual(selectRevisions, [4]);
assert.deepEqual(renameRevisions, [4]);
assert.equal(afterSwitchRegisters, 1);
assert.equal(otherTitle, "Other person session");
return report;
} finally {
await harness.close();
restore();
}
}
for (const sessionCount of [30, 100]) {
test(`streaming frames do not republish the sidebar (${sessionCount} sessions)`, async () => {
await runProbe(sessionCount);
});
}
+11 -2
View File
@@ -45,9 +45,18 @@ test("catalog cache is short-lived and failure stays fail-closed without env fal
test("session model resolution is async and pins published or retired database versions", () => {
assert.match(catalog, /export async function resolveSessionLanguageModel/);
assert.match(catalog, /c\.model_id=\$1 and v\.version=\$2 and v\.status in \('published','retired'\)/);
assert.match(catalog, /return await readVersion\(modelId, configVersion\)/);
// 原值: return await readVersion(modelId, configVersion)
// 新值: const model = await readVersion(modelId, configVersion),只有非空结果写入短缓存
// 原因: 绑版本的解析与目录共用 15 秒上限,失败和空结果不缓存
assert.match(catalog, /const model = await readVersion\(modelId, configVersion\)/);
assert.match(catalog, /if \(model && ttl > 0\) cache\.set\(key, \{ expiresAt: Date\.now\(\) \+ ttl, model \}\)/);
assert.match(routes.consult, /await resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version/);
assert.match(routes.consult, /if \(!sessionModel\)[\s\S]*status: 503/);
// 原值: route.ts 里 if (!sessionModel) 之后是 status 503
// 新值: turn-prep.ts 里 if (!sessionModel) 返回 modelUnavailable(),正文 status 503
// 原因: 模型不可用仍是开流前的 503,文案随准备步骤一起搬走
const head = readFileSync(new URL("../src/lib/consult/turn-prep.ts", import.meta.url), "utf8");
assert.match(head, /if \(!sessionModel\) return \{ ok: false, response: modelUnavailable\(\) \}/);
assert.match(head, /function modelUnavailable\(\)[\s\S]*status: 503/);
assert.doesNotMatch(routes.consult, /languageModelConfigurationMessage/);
});
@@ -18,7 +18,6 @@ import {
const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), "utf8");
const run = read("../src/hooks/use-consultation-run.ts");
const sessions = read("../src/hooks/use-session-management.ts");
const consultRoute = read("../src/app/api/consult/route.ts");
const pageSource = read("../src/app/(app)/page.tsx");
const sendFn = run.slice(run.indexOf("async function send("), run.indexOf('await fetch("/api/consult"'));
@@ -50,13 +49,16 @@ test("pending consult replay pointing at a rectification session is dropped", ()
});
test("consult POST splits missing session from wrong session type", () => {
assert.match(consultRoute, /if \(!chatSession\) \{[\s\S]*咨询会话不存在[\s\S]*status: 404/);
assert.match(
consultRoute,
/if \(chatSession\.session_type !== "consultation"\) \{[\s\S]*session_not_consultation[\s\S]*status: 409/,
);
assert.match(consultRoute, /这是生时校正会话/);
assert.match(consultRoute, /请在生时校正里继续。/);
const head = readFileSync(new URL("../src/lib/consult/turn-prep.ts", import.meta.url), "utf8");
// 原值: consult route 里 if (!chatSession) 返回 404,类型不对返回 session_not_consultation。
// 新值: 同样两句在 turn-prep.ts,route 把读会话的结果交给它。
// 原因: 开流前的会话判断收进准备函数,文案和状态码没变。
assert.match(head, /if \(!session\.data\) return \{ ok: false, response: sessionMissing\(\) \}/);
assert.match(head, /function sessionMissing\(\)[\s\S]*咨询会话不存在[\s\S]*status: 404/);
assert.match(head, /function notConsultation\(\)[\s\S]*session_not_consultation[\s\S]*status: 409/);
assert.match(head, /session_type !== "consultation"/);
assert.match(head, /这是生时校正会话/);
assert.match(head, /请在生时校正里继续。/);
assert.equal(SESSION_NOT_CONSULTATION_CODE, "session_not_consultation");
assert.match(run, /caught\.code === SESSION_NOT_CONSULTATION_CODE/);
});
@@ -182,9 +182,16 @@ test("shell receives current callbacks, account and session state without freezi
assert.equal(app.shell?.activeSessionId, "revision-1");
assert.equal(app.shell?.insetInert, true);
assert.equal(app.shell?.escapeBlocked, true);
assert.notEqual(app.shell?.controls.onOpenProfile, initialCallback);
// 原值:assert.notEqual(onOpenProfile, initialCallback),每次注册都是新函数。
// 新值:包装函数身份不变;再改一版后仍是同一个函数,调用记到的是当前 revision。
// 原因:外壳拿稳定包装,点下去执行 Home 最新一次渲染的回调(BUG-971 / BUG-1329)。
assert.equal(app.shell?.controls.onOpenProfile, initialCallback);
app.shell?.controls.onOpenProfile();
assert.deepEqual(app.callbackRevisions, [1]);
await app.revise(2);
assert.equal(app.shell?.controls.onOpenProfile, initialCallback);
app.shell?.controls.onOpenProfile();
assert.deepEqual(app.callbackRevisions, [1, 2]);
await app.harness.update(() => {
app.list.setAccount({ ...account, credits: 27 });
app.list.setSessions([row]);
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,8 @@
const empty = new URL("./server-only-empty.mjs", import.meta.url).href;
export async function resolve(specifier, context, nextResolve) {
if (specifier === "server-only") {
return { url: empty, shortCircuit: true };
}
return nextResolve(specifier, context);
}
+609
View File
@@ -0,0 +1,609 @@
"""Retention rules for scratch/local caches.
Counts only. Never prints file names or cache payloads.
"""
from __future__ import annotations
import json
import math
import os
import re
import secrets
import threading
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Callable
ROOT = Path(__file__).resolve().parents[1]
UNREADABLE_KEEP_SECONDS = 24 * 60 * 60
_HEX64_JSON = re.compile(r"^[0-9a-f]{64}\.json$")
_ARTIFACT_JSON = re.compile(r"^[A-Za-z0-9_]+-[0-9a-f]{12}-[0-9a-f]{12}\.json$")
_GATEWAY_JOB_JSON = re.compile(r"^vgw_[0-9a-f]{20}\.json$")
_PRUNE_THREAD_NAME = "jyotish-scratch-prune"
# Directory names under scratch/local that this module already knows how to prune.
# Subdirectories of vedastro_adapter are listed so a future writer is not a new top-level dir.
REGISTERED_DIR_NAMES = frozenset(
{
"api_chart_cache",
"high_rigor_jobs",
"api_chart_response_jobs",
"async_jobs.sqlite3",
"vedastro_adapter",
"vedastro_snapshot_cache",
"vedastro_snapshot_negative_cache",
"vedastro_gateway_jobs",
"varga_segments",
}
)
# Offline tools or caches that already prune themselves. Not a new retention rule.
OFFLINE_SCRATCH_NAMES = frozenset(
{
"consult_full_data_cache",
"ocr_extract",
"three_engine_parity",
"public_release_deny_patterns.txt",
"pdf_review_123456",
}
)
REGISTRY: tuple[dict[str, Any], ...] = (
{
"name": "api_chart_cache",
"holds_birth_data": True,
"deletable": "created_at plus chart TTL, or unreadable json older than 24h",
},
{
"name": "high_rigor_jobs",
"holds_birth_data": True,
"deletable": "expires_at_unix has passed; bad json is kept",
},
{
"name": "api_chart_response_jobs",
"holds_birth_data": True,
"deletable": "expires_at_unix has passed; bad json is kept",
},
{
"name": "async_jobs.sqlite3",
"holds_birth_data": True,
"deletable": "sqlite rows whose expires_at has passed",
},
{
"name": "vedastro_adapter",
"holds_birth_data": True,
"deletable": "audit artifacts, response_cache, and official_full_snapshot_cache",
},
{
"name": "vedastro_snapshot_cache",
"holds_birth_data": True,
"deletable": "reference_date before today UTC minus MAX_STALE_DAYS minus 1",
},
{
"name": "vedastro_snapshot_negative_cache",
"holds_birth_data": True,
"deletable": "reference_date before today UTC",
},
{
"name": "vedastro_gateway_jobs",
"holds_birth_data": True,
"deletable": "completed or failed jobs older than the keep window, plus their raw archive",
},
{
"name": "varga_segments",
"holds_birth_data": True,
"deletable": "mtime older than JYOTISH_VARGA_SEGMENTS_KEEP_DAYS",
},
)
def write_private_json(path: Path, payload: Any) -> None:
"""Atomic UTF-8 JSON, mode 0600. Compact, stable key order."""
path.parent.mkdir(parents=True, exist_ok=True)
temp = path.with_name(f"{path.name}.{secrets.token_hex(8)}.tmp")
temp.write_text(
json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")),
encoding="utf-8",
)
try:
os.chmod(temp, 0o600)
except OSError:
pass
os.replace(temp, path)
try:
os.chmod(path, 0o600)
except OSError:
pass
def _finite_float(value: Any) -> float | None:
if isinstance(value, bool) or value is None:
return None
if isinstance(value, (int, float)):
number = float(value)
elif isinstance(value, str):
text = value.strip()
if not text:
return None
try:
number = float(text)
except ValueError:
return None
else:
return None
if not math.isfinite(number):
return None
return number
def _exact_int(value: Any) -> int | None:
if isinstance(value, bool) or value is None:
return None
if isinstance(value, int):
return value
if isinstance(value, float):
if not math.isfinite(value) or not value.is_integer():
return None
return int(value)
if isinstance(value, str) and re.fullmatch(r"[+-]?\d+", value.strip()):
return int(value.strip())
return None
def normalize_chart_cache_birth(body: dict | None) -> dict[str, Any]:
"""Cache-key birth fields. Unparseable values stay as they arrived."""
payload = body if isinstance(body, dict) else {}
normalized: dict[str, Any] = {}
for key in ("year", "month", "day"):
parsed = _exact_int(payload.get(key))
normalized[key] = payload.get(key) if parsed is None else parsed
for key in ("hour", "minute", "lat", "lon", "tz"):
raw = payload.get(key)
parsed = _finite_float(raw)
normalized[key] = raw if parsed is None else parsed
second_raw = payload.get("second", 0)
second = _finite_float(second_raw)
normalized["second"] = second_raw if second is None else second
return normalized
def normalize_chart_cache_day(value: Any) -> Any:
if value is None or value == "":
return value
return str(value)[:10]
def _blank() -> dict[str, int]:
return {"scanned": 0, "removed": 0, "errors": 0}
def _older_than(path: Path, moment: float, seconds: float) -> bool:
try:
return moment - path.stat().st_mtime > seconds
except OSError:
return False
def _unlink(path: Path, stats: dict[str, int]) -> None:
try:
path.unlink()
stats["removed"] += 1
except OSError:
stats["errors"] += 1
def _repo_root() -> Path:
return ROOT
def _api_module(api_module=None):
if api_module is not None:
return api_module
import jyotish_api_server as api
return api
def prune_async_job_records(now: float | None = None, api_module=None) -> dict[str, Any]:
"""Same file and sqlite rules as the old startup cleanup. Bad job JSON is kept."""
api = _api_module(api_module)
moment = time.time() if now is None else float(now)
scanned = 0
removed = 0
errors = 0
directories: dict[str, dict[str, int]] = {}
if api._async_job_backend() == "sqlite":
try:
with api._sqlite_job_connection() as connection:
scanned = int(connection.execute("SELECT COUNT(*) FROM async_jobs").fetchone()[0])
deleted = connection.execute(
"DELETE FROM async_jobs WHERE expires_at IS NOT NULL AND expires_at <= ?",
(moment,),
).rowcount
removed = int(deleted or 0)
except OSError:
errors += 1
directories["high_rigor_jobs"] = _blank()
directories["api_chart_response_jobs"] = _blank()
directories["async_jobs_sqlite"] = {"scanned": scanned, "removed": removed, "errors": errors}
return {
"scope": "async_job_cleanup",
"scanned": scanned,
"removed": removed,
"errors": errors,
"directories": directories,
}
for scope, label in (
(api._HIGH_RIGOR_JOB_SCOPE, "high_rigor_jobs"),
(api._API_CHART_CACHE_SCOPE, "api_chart_response_jobs"),
):
stats = _blank()
try:
directory = api._async_job_dir(scope)
except OSError:
stats["errors"] += 1
directories[label] = stats
errors += stats["errors"]
continue
if directory.is_dir():
for path in directory.glob("*.json"):
stats["scanned"] += 1
try:
record = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
continue
except OSError:
stats["errors"] += 1
continue
expires_at = record.get("expires_at_unix") if isinstance(record, dict) else None
if isinstance(expires_at, (int, float)) and not isinstance(expires_at, bool) and moment >= float(expires_at):
_unlink(path, stats)
directories[label] = stats
scanned += stats["scanned"]
removed += stats["removed"]
errors += stats["errors"]
return {
"scope": "async_job_cleanup",
"scanned": scanned,
"removed": removed,
"errors": errors,
"directories": directories,
}
def _prune_chart_cache(moment: float, api_module=None) -> dict[str, int]:
api = _api_module(api_module)
stats = _blank()
try:
directory = api._api_chart_cache_dir()
ttl = api._api_chart_cache_ttl_seconds()
except OSError:
stats["errors"] += 1
return stats
if not directory.is_dir():
return stats
for path in directory.glob("*.json"):
if not _HEX64_JSON.match(path.name):
continue
stats["scanned"] += 1
try:
record = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
if _older_than(path, moment, UNREADABLE_KEEP_SECONDS):
_unlink(path, stats)
continue
except OSError:
stats["errors"] += 1
continue
created_at = record.get("created_at") if isinstance(record, dict) else None
if not isinstance(created_at, (int, float)) or isinstance(created_at, bool):
if _older_than(path, moment, UNREADABLE_KEEP_SECONDS):
_unlink(path, stats)
continue
if moment - float(created_at) > ttl:
_unlink(path, stats)
return stats
def _adapter():
import vedastro_service_adapter as adapter
return adapter
def _prune_created_at_dir(directory: Path, moment: float, ttl_for_record: Callable[[dict], float]) -> dict[str, int]:
stats = _blank()
if not directory.is_dir():
return stats
for path in directory.glob("*.json"):
if not _HEX64_JSON.match(path.name):
continue
stats["scanned"] += 1
try:
record = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
if _older_than(path, moment, UNREADABLE_KEEP_SECONDS):
_unlink(path, stats)
continue
except OSError:
stats["errors"] += 1
continue
created_at = record.get("created_at") if isinstance(record, dict) else None
if not isinstance(created_at, (int, float)) or isinstance(created_at, bool):
if _older_than(path, moment, UNREADABLE_KEEP_SECONDS):
_unlink(path, stats)
continue
try:
ttl = float(ttl_for_record(record))
except (TypeError, ValueError):
stats["errors"] += 1
continue
if moment - float(created_at) > ttl:
_unlink(path, stats)
return stats
def _prune_response_cache(moment: float) -> dict[str, int]:
adapter = _adapter()
return _prune_created_at_dir(
Path(adapter.ARTIFACT_DIR) / "response_cache",
moment,
lambda _record: adapter._cache_ttl_seconds(),
)
def _prune_snapshot_semantic_cache(moment: float) -> dict[str, int]:
adapter = _adapter()
def ttl_for(record: dict) -> float:
if record.get("negative") is True:
return adapter._official_full_snapshot_negative_ttl_seconds()
return adapter._official_full_snapshot_cache_ttl_seconds()
return _prune_created_at_dir(
Path(adapter.ARTIFACT_DIR) / "official_full_snapshot_cache",
moment,
ttl_for,
)
def _audit_keep_seconds() -> float:
raw = os.environ.get("VEDASTRO_AUDIT_ARTIFACT_KEEP_DAYS", "7").strip()
try:
days = float(raw) if raw else 7.0
except ValueError:
days = 7.0
return max(0.0, days) * 86400.0
def _prune_audit_artifacts(moment: float) -> dict[str, int]:
adapter = _adapter()
stats = _blank()
directory = Path(adapter.ARTIFACT_DIR)
if not directory.is_dir():
return stats
enabled = adapter._audit_artifacts_enabled()
max_age = _audit_keep_seconds() if enabled else UNREADABLE_KEEP_SECONDS
for path in directory.glob("*.json"):
if not path.is_file() or not _ARTIFACT_JSON.match(path.name):
continue
stats["scanned"] += 1
if _older_than(path, moment, max_age):
_unlink(path, stats)
return stats
def _parse_day(value: Any):
if not isinstance(value, str):
return None
raw = value[:10]
try:
return datetime.strptime(raw, "%Y-%m-%d").date()
except ValueError:
return None
def _prune_reference_dated(directory: Path, moment: float, *, earliest_kept) -> dict[str, int]:
stats = _blank()
if not directory.is_dir():
return stats
for path in directory.glob("*.json"):
if not _HEX64_JSON.match(path.name):
continue
stats["scanned"] += 1
try:
record = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
if _older_than(path, moment, UNREADABLE_KEEP_SECONDS):
_unlink(path, stats)
continue
except OSError:
stats["errors"] += 1
continue
ref = _parse_day(record.get("reference_date") if isinstance(record, dict) else None)
if ref is None:
if _older_than(path, moment, UNREADABLE_KEEP_SECONDS):
_unlink(path, stats)
continue
if ref < earliest_kept:
_unlink(path, stats)
return stats
def _prune_vedastro_snapshot_cache(moment: float) -> dict[str, int]:
from vedastro_snapshot_cache import MAX_STALE_DAYS
today = datetime.fromtimestamp(moment, timezone.utc).date()
earliest = today - timedelta(days=int(MAX_STALE_DAYS) + 1)
raw = os.environ.get("JYOTISH_VEDASTRO_SNAPSHOT_CACHE_DIR", "").strip()
directory = Path(raw) if raw else _repo_root() / "scratch" / "local" / "vedastro_snapshot_cache"
return _prune_reference_dated(directory, moment, earliest_kept=earliest)
def _prune_vedastro_negative_cache(moment: float) -> dict[str, int]:
today = datetime.fromtimestamp(moment, timezone.utc).date()
raw = os.environ.get("JYOTISH_VEDASTRO_SNAPSHOT_NEGATIVE_CACHE_DIR", "").strip()
directory = Path(raw) if raw else _repo_root() / "scratch" / "local" / "vedastro_snapshot_negative_cache"
return _prune_reference_dated(directory, moment, earliest_kept=today)
def _parse_updated_at(value: Any) -> float | None:
if not isinstance(value, str) or not value.strip():
return None
text = value.strip().replace("Z", "+00:00")
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.timestamp()
def _gateway_keep_seconds() -> float:
raw = os.environ.get("VEDASTRO_GATEWAY_JOB_KEEP_SECONDS", "86400").strip()
try:
return max(0.0, float(raw) if raw else 86400.0)
except ValueError:
return 86400.0
def _prune_gateway_jobs(moment: float) -> dict[str, int]:
stats = _blank()
raw = os.environ.get("VEDASTRO_GATEWAY_QUEUE_DIR", "").strip()
directory = Path(raw).expanduser() if raw else _repo_root() / "scratch" / "local" / "vedastro_gateway_jobs"
if not directory.is_dir():
return stats
keep = _gateway_keep_seconds()
for path in directory.glob("*.json"):
if not _GATEWAY_JOB_JSON.match(path.name):
continue
stats["scanned"] += 1
try:
record = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
continue
except OSError:
stats["errors"] += 1
continue
if not isinstance(record, dict):
continue
status = str(record.get("status") or "")
if status not in {"completed", "failed"}:
continue
updated = _parse_updated_at(record.get("updated_at"))
if updated is None or moment - updated <= keep:
continue
companion = directory / f"{path.stem}.official_raw_response.json"
_unlink(path, stats)
if companion.is_file():
stats["scanned"] += 1
_unlink(companion, stats)
return stats
def _varga_keep_seconds() -> float:
raw = os.environ.get("JYOTISH_VARGA_SEGMENTS_KEEP_DAYS", "7").strip()
try:
days = float(raw) if raw else 7.0
except ValueError:
days = 7.0
return max(0.0, days) * 86400.0
def _prune_varga_segments(moment: float) -> dict[str, int]:
stats = _blank()
raw = os.environ.get("JYOTISH_CACHE_DIR", "").strip()
root = Path(raw) if raw else _repo_root() / "scratch" / "local"
directory = root / "varga_segments"
if not directory.is_dir():
return stats
max_age = _varga_keep_seconds()
for path in directory.glob("*.json"):
if not _HEX64_JSON.match(path.name):
continue
stats["scanned"] += 1
if _older_than(path, moment, max_age):
_unlink(path, stats)
return stats
def _stamp(stats: dict[str, int], started: float) -> dict[str, Any]:
return {
"scanned": int(stats.get("scanned", 0) or 0),
"removed": int(stats.get("removed", 0) or 0),
"errors": int(stats.get("errors", 0) or 0),
"elapsed_ms": round((time.perf_counter() - started) * 1000, 3),
}
def prune_all(now: float | None = None, api_module=None) -> dict[str, Any]:
"""Prune every registered directory. Exceptions become error counts."""
moment = time.time() if now is None else float(now)
api = _api_module(api_module)
started = time.perf_counter()
directories: dict[str, dict[str, Any]] = {}
def _run(name: str, func: Callable[[float], dict[str, int]]) -> None:
t0 = time.perf_counter()
try:
stats = func(moment)
except Exception:
stats = {"scanned": 0, "removed": 0, "errors": 1}
directories[name] = _stamp(stats, t0)
_run("api_chart_cache", lambda moment: _prune_chart_cache(moment, api))
job_started = time.perf_counter()
try:
job = prune_async_job_records(moment, api)
job_dirs = job.get("directories") or {}
except Exception:
job_dirs = {
"high_rigor_jobs": {"scanned": 0, "removed": 0, "errors": 1},
"api_chart_response_jobs": _blank(),
}
job_elapsed = round((time.perf_counter() - job_started) * 1000, 3)
for name, stats in job_dirs.items():
stamped = _stamp(stats, job_started)
stamped["elapsed_ms"] = job_elapsed
directories[name] = stamped
_run("vedastro_response_cache", _prune_response_cache)
_run("vedastro_official_full_snapshot_cache", _prune_snapshot_semantic_cache)
_run("vedastro_audit_artifacts", _prune_audit_artifacts)
_run("vedastro_snapshot_cache", _prune_vedastro_snapshot_cache)
_run("vedastro_snapshot_negative_cache", _prune_vedastro_negative_cache)
_run("vedastro_gateway_jobs", _prune_gateway_jobs)
_run("varga_segments", _prune_varga_segments)
return {
"scanned": sum(item["scanned"] for item in directories.values()),
"removed": sum(item["removed"] for item in directories.values()),
"errors": sum(item["errors"] for item in directories.values()),
"elapsed_ms": round((time.perf_counter() - started) * 1000, 3),
"directories": directories,
}
def start_periodic_prune(interval_s: float | None = None):
"""Daemon thread. interval <= 0 starts nothing. Startup prune is separate."""
if interval_s is None:
raw = os.environ.get("JYOTISH_SCRATCH_PRUNE_INTERVAL_SECONDS", "1800").strip()
try:
interval_s = float(raw) if raw else 1800.0
except ValueError:
interval_s = 1800.0
if interval_s <= 0:
return None
def _loop() -> None:
while True:
time.sleep(interval_s)
try:
prune_all()
except Exception:
continue
thread = threading.Thread(target=_loop, name=_PRUNE_THREAD_NAME, daemon=True)
thread.start()
return thread
+23 -47
View File
@@ -2590,24 +2590,17 @@ def _vedastro_runtime_fingerprint() -> dict:
def _build_api_chart_cache_payload(body: dict) -> dict:
import api_scratch_retention as retention
today = body.get('today') or body.get('current_date')
return {
'cache_schema_version': 5,
'birth': {
'year': body.get('year'),
'month': body.get('month'),
'day': body.get('day'),
'hour': body.get('hour'),
'minute': body.get('minute'),
'second': body.get('second', 0),
'lat': body.get('lat'),
'lon': body.get('lon'),
'tz': body.get('tz'),
},
'cache_schema_version': 6,
'birth': retention.normalize_chart_cache_birth(body),
'calculation': {
'ayanamsa': _request_ayanamsa(body),
'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')),
'today': body.get('today') or body.get('current_date'),
'transit_date': body.get('transit_date'),
'today': retention.normalize_chart_cache_day(today),
'transit_date': retention.normalize_chart_cache_day(body.get('transit_date')),
'skip_vedastro_main_entry_overview': bool(body.get('skip_vedastro_main_entry_overview')),
},
'vedastro_runtime': _vedastro_runtime_fingerprint(),
@@ -2678,14 +2671,15 @@ def _store_api_chart_response_cache(cache_payload: dict, payload: dict) -> dict:
)
ttl_seconds = _api_chart_cache_ttl_seconds()
if ttl_seconds > 0:
record = {
'cache_key': cache_key,
'created_at': created_at_unix,
'payload': payload_with_cache,
}
_api_chart_cache_path(cache_key).write_text(
json.dumps(record, ensure_ascii=False, sort_keys=True),
encoding='utf-8',
import api_scratch_retention as retention
retention.write_private_json(
_api_chart_cache_path(cache_key),
{
'cache_key': cache_key,
'created_at': created_at_unix,
'payload': payload_with_cache,
},
)
return payload_with_cache
@@ -2738,30 +2732,9 @@ def _sqlite_job_connection() -> sqlite3.Connection:
def prune_expired_async_jobs() -> dict:
"""Best-effort startup cleanup for local job records; never reads payloads."""
removed = 0
scanned = 0
if _async_job_backend() == "sqlite":
with _sqlite_job_connection() as connection:
scanned = connection.execute("SELECT COUNT(*) FROM async_jobs").fetchone()[0]
removed = connection.execute(
"DELETE FROM async_jobs WHERE expires_at IS NOT NULL AND expires_at <= ?", (time.time(),)
).rowcount
return {'scope': 'async_job_cleanup', 'scanned': scanned, 'removed': removed}
for scope in (_HIGH_RIGOR_JOB_SCOPE, _API_CHART_CACHE_SCOPE):
directory = _async_job_dir(scope)
if not directory.is_dir():
continue
for path in directory.glob('*.json'):
scanned += 1
try:
record = json.loads(path.read_text(encoding='utf-8'))
expires_at = record.get('expires_at_unix') if isinstance(record, dict) else None
if isinstance(expires_at, (int, float)) and time.time() >= float(expires_at):
path.unlink()
removed += 1
except (OSError, json.JSONDecodeError):
continue
return {'scope': 'async_job_cleanup', 'scanned': scanned, 'removed': removed}
import api_scratch_retention as retention
return retention.prune_async_job_records(api_module=sys.modules[__name__])
def _new_async_job_identity(prefix: str) -> dict:
@@ -10902,7 +10875,10 @@ def _parse_allowed_hosts(value):
def start_server(port=5200, host='127.0.0.1', allowed_origins=None, allowed_hosts=None):
cleanup = prune_expired_async_jobs()
import api_scratch_retention as retention
cleanup = retention.prune_all(api_module=sys.modules[__name__])
retention.start_periodic_prune()
server = ThreadingHTTPServer((host, port), JyotishAPIHandler)
server.daemon_threads = True
server.allowed_origins = allowed_origins or DEFAULT_ALLOWED_ORIGINS
@@ -0,0 +1,28 @@
"""Byte-compare numeric chart-cache spellings. See api_disk_cache_ab.py."""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import api_disk_cache_ab as ab
def main() -> dict:
parent_error = None
try:
parent = ab._load_parent()
except Exception as exc:
parent = None
parent_error = exc.__class__.__name__
report = ab.run_byte_diff(parent or ab.current)
report["parent_import"] = parent_error or "ok"
report["parent"] = ab.PARENT
return report
if __name__ == "__main__":
json.dump(main(), sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
+442
View File
@@ -0,0 +1,442 @@
"""A/B for chart-cache keys, retention, and response bytes.
Parent functions come from git show of the branch base (origin/staging at
branch creation), not from the task text's older baseline. Scratch files
stay in a temporary directory.
"""
from __future__ import annotations
import importlib.util
import json
import os
import socket
import statistics
import subprocess
import sys
import tempfile
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
PARENT = "66cd60081f37a57680274633fcb3fb942d01bb2f"
os.environ.setdefault("JYOTISH_SKIP_LOCAL_ENV", "1")
os.environ.setdefault("VEDASTRO_ENABLE_NETWORK", "0")
_SDK_PRESENT = importlib.util.find_spec("vedastro") is not None
def _network_reachable() -> bool:
try:
sock = socket.create_connection(("api.vedastro.org", 443), 3)
except OSError:
return False
sock.close()
return True
_NETWORK = _network_reachable()
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
if "--with-sdk" not in sys.argv:
sys.meta_path.insert(0, _Block())
sys.path.insert(0, str(ROOT / "scripts"))
sys.path.insert(0, str(ROOT))
import api_scratch_retention as retention # noqa: E402
import jyotish_api_server as current # noqa: E402
import vedastro_service_adapter as adapter # noqa: E402
def _load_parent():
raw = subprocess.check_output(["git", "show", f"{PARENT}:scripts/jyotish_api_server.py"], text=True)
raw = raw.replace(
"SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))",
f"SCRIPTS_DIR = {str(ROOT / 'scripts')!r}",
)
raw = raw.replace(
"REPO_ROOT = os.path.abspath(os.path.join(SCRIPTS_DIR, '..'))",
f"REPO_ROOT = {str(ROOT)!r}",
)
directory = Path(tempfile.mkdtemp(prefix="jyotish-cache-a-"))
path = directory / "jyotish_api_server_parent.py"
path.write_text(raw, encoding="utf-8")
spec = importlib.util.spec_from_file_location("jyotish_api_server_parent", path)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def _handler(module):
cls = module.JyotishAPIHandler
return cls.__new__(cls)
def _strip(payload: dict) -> str:
cloned = json.loads(json.dumps(payload, default=str))
cloned.pop("runtime_cache", None)
return json.dumps(cloned, ensure_ascii=False, sort_keys=True, default=str)
def _diff_paths(left: str, right: str, limit: int = 12) -> list[str]:
a = json.loads(left)
b = json.loads(right)
found: list[str] = []
def walk(one, two, prefix: str) -> None:
if len(found) >= limit:
return
if type(one) is not type(two):
found.append(prefix or "$")
return
if isinstance(one, dict):
for key in sorted(set(one) | set(two)):
if key not in one or key not in two:
found.append(f"{prefix}.{key}")
continue
walk(one[key], two[key], f"{prefix}.{key}")
elif isinstance(one, list):
if len(one) != len(two):
found.append(f"{prefix}[]")
return
for index, (item, other) in enumerate(zip(one, two)):
walk(item, other, f"{prefix}[{index}]")
elif one != two:
found.append(prefix or "$")
walk(a, b, "")
return found
def _body(hour, lat, **extra) -> dict:
payload = {
"year": 1990,
"month": 1,
"day": 1,
"minute": 0,
"lon": 20.0,
"tz": 5.5,
"hour": hour,
"lat": lat,
"today": "2026-10-14",
}
payload.update(extra)
return payload
def run_byte_diff(parent=None) -> dict:
os.environ["JYOTISH_API_CHART_CACHE_TTL_SECONDS"] = "0"
os.environ["VEDASTRO_WRITE_AUDIT_ARTIFACTS"] = "0"
parent = parent or current
groups = {
"hour": [_body(12, 10.0), _body(12.0, 10.0), _body("12", 10.0)],
"lat": [_body(12, 10.0), _body(12, "10.0")],
"tz": [_body(12, 10.0), _body(12, 10.0, tz="5.5")],
"second": [_body(12, 10.0), _body(12, 10.0, second=0)],
}
report = {"groups": {}, "stable_repeat": None}
handler = _handler(current)
started = time.perf_counter()
try:
sample = handler._compute_chart_sync(_body(12, 10.0, skip_vedastro_main_entry_overview=True))
except Exception as exc:
return {"ok": False, "error": exc.__class__.__name__, "elapsed_s": round(time.perf_counter() - started, 3)}
sample_s = round(time.perf_counter() - started, 3)
repeat = handler._compute_chart_sync(_body(12, 10.0, skip_vedastro_main_entry_overview=True))
report["stable_repeat"] = _strip(sample) == _strip(repeat)
report["sample_seconds"] = sample_s
if not report["stable_repeat"]:
report["volatile_paths"] = _diff_paths(_strip(sample), _strip(repeat))
for name, bodies in groups.items():
for skip in (True, False):
rendered = []
for body in bodies:
item = dict(body)
if skip:
item["skip_vedastro_main_entry_overview"] = True
rendered.append(_strip(handler._compute_chart_sync(item)))
same = len(set(rendered)) == 1
entry = {"skip": skip, "same": same}
if not same:
entry["paths"] = _diff_paths(rendered[0], rendered[1])
clocks = ("cache_created_at", "cache_expires_at", "called_at")
entry["only_clock_fields"] = bool(entry["paths"]) and all(
path.endswith(clocks) for path in entry["paths"]
)
else:
entry["only_clock_fields"] = False
report["groups"][f"{name}:skip={skip}"] = entry
if parent is not current:
a_handler = _handler(parent)
b_handler = handler
body = _body(12, 10.0, skip_vedastro_main_entry_overview=True)
a_text = _strip(a_handler._compute_chart_sync(body))
b_text = _strip(b_handler._compute_chart_sync(body))
report["a_vs_b_same"] = a_text == b_text
if a_text != b_text:
report["a_vs_b_paths"] = _diff_paths(a_text, b_text)
report["ok"] = all(item["same"] or item.get("only_clock_fields") for item in report["groups"].values())
return report
def _requests():
charts = []
for index in range(20):
charts.append(
{
"year": 1990,
"month": 1,
"day": 1 + (index % 27),
"minute": index % 60,
"lon": float(index),
"tz": 5.5,
}
)
spellings = (
{"hour": 12, "lat": 10.0},
{"hour": 12.0, "lat": "10.0"},
{"hour": "12", "lat": 10.0},
)
days = [f"2026-10-{day:02d}" for day in range(8, 15)]
rows = []
for chart in charts:
for spelling in spellings:
for skip in (False, True):
for day in days:
body = dict(chart)
body.update(spelling)
body["today"] = day
if skip:
body["skip_vedastro_main_entry_overview"] = True
rows.append(body)
return rows
def _key(module, body) -> str:
return module._api_chart_cache_key(module._build_api_chart_cache_payload(body))
def _hit_rate(module, rows) -> dict:
seen = set()
hits = 0
for body in rows:
for _repeat in range(3):
key = _key(module, body)
if key in seen:
hits += 1
else:
seen.add(key)
total = len(rows) * 3
return {"requests": total, "unique_keys": len(seen), "hits": hits, "hit_rate": round(hits / total, 4)}
def _seed_and_prune(module, rows) -> dict:
now = time.time()
os.environ["JYOTISH_API_CHART_CACHE_TTL_SECONDS"] = "900"
scratch = Path(tempfile.mkdtemp(prefix="jyotish-cache-b-"))
chart = scratch / "api_chart_cache"
jobs = scratch / "jobs"
adapter_dir = scratch / "adapter"
chart.mkdir()
jobs.mkdir()
current._api_chart_cache_dir = lambda: chart
current._async_job_dir = lambda scope: jobs
current._async_job_backend = lambda: "file"
adapter.ARTIFACT_DIR = adapter_dir
os.environ["JYOTISH_VEDASTRO_SNAPSHOT_CACHE_DIR"] = str(scratch / "snap")
os.environ["JYOTISH_VEDASTRO_SNAPSHOT_NEGATIVE_CACHE_DIR"] = str(scratch / "neg")
os.environ["VEDASTRO_GATEWAY_QUEUE_DIR"] = str(scratch / "gw")
os.environ["JYOTISH_CACHE_DIR"] = str(scratch / "cache")
days = sorted({body["today"] for body in rows})
newest = days[-1]
written = {}
for body in rows:
key = _key(module, body)
age_days = days.index(body["today"])
age_days = len(days) - 1 - age_days
written[key] = now - age_days * 86400
for key, created in written.items():
path = chart / f"{key}.json"
path.write_text(json.dumps({"cache_key": key, "created_at": created, "payload": {}}), encoding="utf-8")
before = len(list(chart.glob("*.json")))
before_bytes = sum(path.stat().st_size for path in chart.glob("*.json"))
today_keys = len({_key(module, body) for body in rows if body["today"] == newest})
if module is current:
prune = retention.prune_all(now=now)
after = len(list(chart.glob("*.json")))
elapsed = prune["directories"]["api_chart_cache"]["elapsed_ms"]
else:
after = before
elapsed = None
return {
"files_before": before,
"bytes_before": before_bytes,
"files_after": after,
"today_active_keys": today_keys,
"chart_prune_ms": elapsed,
}
def _artifact_counts() -> dict:
scratch = Path(tempfile.mkdtemp(prefix="jyotish-artifact-ab-"))
adapter.ARTIFACT_DIR = scratch
os.environ.pop("VEDASTRO_WRITE_AUDIT_ARTIFACTS", None)
result = {
"operation": "official_full_snapshot",
"source_metadata": {
"operation": "official_full_snapshot",
"request_hash": "a" * 64,
"response_hash": "b" * 64,
},
}
adapter._write_artifact(result)
return {"b_artifact_files": len(list(scratch.glob("*.json")))}
def _negative_cache_after_prune() -> dict:
scratch = Path(tempfile.mkdtemp(prefix="jyotish-neg-ab-"))
os.environ["JYOTISH_VEDASTRO_SNAPSHOT_NEGATIVE_CACHE_DIR"] = str(scratch)
os.environ["JYOTISH_VEDASTRO_SNAPSHOT_CACHE_DIR"] = str(scratch / "unused-snap")
os.environ["VEDASTRO_GATEWAY_QUEUE_DIR"] = str(scratch / "unused-gw")
os.environ["JYOTISH_CACHE_DIR"] = str(scratch / "unused-cache")
current._api_chart_cache_dir = lambda: scratch / "unused-chart"
current._async_job_dir = lambda scope: scratch / "unused-jobs"
(scratch / "unused-chart").mkdir()
(scratch / "unused-jobs").mkdir()
today = time.strftime("%Y-%m-%d", time.gmtime())
yesterday = time.strftime("%Y-%m-%d", time.gmtime(time.time() - 86400))
for label, day in (("a" * 64, today), ("b" * 64, yesterday)):
path = scratch / f"{label}.json"
path.write_text(json.dumps({"reference_date": day}), encoding="utf-8")
retention.prune_all(now=time.time())
return {"files_after": len(list(scratch.glob("*.json")))}
def _prune_10k() -> dict:
scratch = Path(tempfile.mkdtemp(prefix="jyotish-prune-10k-"))
chart = scratch / "chart"
chart.mkdir()
current._api_chart_cache_dir = lambda: chart
current._async_job_dir = lambda scope: scratch / "jobs"
(scratch / "jobs").mkdir()
current._async_job_backend = lambda: "file"
adapter.ARTIFACT_DIR = scratch / "adapter"
os.environ["JYOTISH_API_CHART_CACHE_TTL_SECONDS"] = "900"
os.environ["JYOTISH_VEDASTRO_SNAPSHOT_CACHE_DIR"] = str(scratch / "snap")
os.environ["JYOTISH_VEDASTRO_SNAPSHOT_NEGATIVE_CACHE_DIR"] = str(scratch / "neg")
os.environ["VEDASTRO_GATEWAY_QUEUE_DIR"] = str(scratch / "gw")
os.environ["JYOTISH_CACHE_DIR"] = str(scratch / "cache")
for index in range(10000):
path = chart / f"{index:064x}.json"
path.write_text('{"created_at":1,"payload":{}}', encoding="utf-8")
started = time.perf_counter()
result = retention.prune_all(now=time.time())
elapsed = time.perf_counter() - started
return {
"prune_all_seconds": round(elapsed, 3),
"chart_removed": result["directories"]["api_chart_cache"]["removed"],
"chart_elapsed_ms": result["directories"]["api_chart_cache"]["elapsed_ms"],
"files_left": len(list(chart.glob("*.json"))),
}
def _one_chart_timing() -> dict:
os.environ["JYOTISH_API_CHART_CACHE_TTL_SECONDS"] = "0"
handler = _handler(current)
body = _body(12, 10.0, skip_vedastro_main_entry_overview=True)
misses = []
for _index in range(3):
started = time.perf_counter()
handler._compute_chart_sync(body)
misses.append((time.perf_counter() - started) * 1000)
os.environ["JYOTISH_API_CHART_CACHE_TTL_SECONDS"] = "900"
scratch = Path(tempfile.mkdtemp(prefix="jyotish-hit-time-"))
current._api_chart_cache_dir = lambda: scratch
cold = handler._compute_chart_sync(body)
hits = []
for _index in range(3):
started = time.perf_counter()
hit = handler._compute_chart_sync(body)
hits.append((time.perf_counter() - started) * 1000)
assert hit.get("runtime_cache", {}).get("cache_hit") is True
return {
"miss_median_ms": round(statistics.median(misses), 3),
"hit_median_ms": round(statistics.median(hits), 3),
"cold_has_runtime_cache": isinstance(cold, dict) and "runtime_cache" in cold,
}
def main() -> dict:
parent_error = None
try:
parent = _load_parent()
except Exception as exc:
parent = None
parent_error = exc.__class__.__name__
rows = _requests()
side_a = _hit_rate(parent, rows) if parent is not None else None
side_b = _hit_rate(current, rows)
report = {
"parent": PARENT,
"parent_import": parent_error or "ok",
"vedastro_sdk": _SDK_PRESENT,
"vedastro_network": _NETWORK,
"hit_rate_a": side_a,
"hit_rate_b": side_b,
"artifacts": _artifact_counts(),
"negative_cache_files_after_prune": _negative_cache_after_prune(),
"prune_10k": _prune_10k(),
}
if side_a and side_a["hit_rate"]:
report["hit_rate_ratio_b_over_a"] = round(side_b["hit_rate"] / side_a["hit_rate"], 3)
try:
report["files_b"] = _seed_and_prune(current, rows)
if parent is not None:
report["files_a"] = _seed_and_prune(parent, rows)
except Exception as exc:
report["files_error"] = exc.__class__.__name__
try:
report["timing"] = _one_chart_timing()
except Exception as exc:
report["timing_error"] = exc.__class__.__name__
try:
report["byte_diff"] = run_byte_diff(parent or current)
except Exception as exc:
report["byte_diff"] = {"ok": False, "error": exc.__class__.__name__}
return report
def _sdk_probe() -> dict:
os.environ["JYOTISH_API_CHART_CACHE_TTL_SECONDS"] = "0"
os.environ["VEDASTRO_ENABLE_NETWORK"] = "0"
os.environ["VEDASTRO_WRITE_AUDIT_ARTIFACTS"] = "0"
handler = _handler(current)
body = _body(12, 10.0)
started = time.perf_counter()
try:
handler._compute_chart_sync(body)
error = None
except Exception as exc:
error = type(exc).__name__
return {
"blocked_vedastro": False,
"vedastro_sdk": _SDK_PRESENT,
"vedastro_network": _NETWORK,
"network_enabled": False,
"elapsed_s": round(time.perf_counter() - started, 3),
"error": error,
}
if __name__ == "__main__":
payload = _sdk_probe() if "--sdk-probe" in sys.argv else main()
json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
+26 -1
View File
@@ -340,6 +340,30 @@ def probe_official_rest_health(*, force_refresh: bool = True) -> dict[str, Any]:
return result
def _gateway_snapshot_ttl_helpers():
try:
from vedastro_service_adapter import (
_official_full_snapshot_cache_ttl_seconds,
_official_full_snapshot_negative_ttl_seconds,
)
except ModuleNotFoundError:
from scripts.vedastro_service_adapter import (
_official_full_snapshot_cache_ttl_seconds,
_official_full_snapshot_negative_ttl_seconds,
)
return _official_full_snapshot_cache_ttl_seconds, _official_full_snapshot_negative_ttl_seconds
def _gateway_official_snapshot_ttl_seconds() -> float:
positive, _negative = _gateway_snapshot_ttl_helpers()
return positive()
def _gateway_official_snapshot_negative_ttl_seconds() -> float:
_positive, negative = _gateway_snapshot_ttl_helpers()
return negative()
def gateway_status(*, force_refresh: bool = True) -> dict[str, Any]:
from scripts.diagnose_vedastro_mode import build_report as build_vedastro_mode_report
@@ -368,7 +392,8 @@ def gateway_status(*, force_refresh: bool = True) -> dict[str, Any]:
),
"sdk_version": _sdk_version(),
"cache_ttl_seconds": config["cache_ttl_seconds"],
"official_full_snapshot_cache_ttl_seconds": _int_env("VEDASTRO_OFFICIAL_FULL_SNAPSHOT_CACHE_TTL_SECONDS", 0),
"official_full_snapshot_cache_ttl_seconds": _gateway_official_snapshot_ttl_seconds(),
"official_full_snapshot_negative_cache_ttl_seconds": _gateway_official_snapshot_negative_ttl_seconds(),
"queue_enabled": config["queue_enabled"],
"fail_open_local": config["fail_open_local"],
"official_readiness": {
+61 -16
View File
@@ -425,14 +425,23 @@ def _repo_relative(path: Path) -> str:
return str(path)
def _write_artifact(result: dict[str, Any]) -> str:
def _audit_artifacts_enabled() -> bool:
raw = os.environ.get("VEDASTRO_WRITE_AUDIT_ARTIFACTS", "0").strip().lower()
return raw in {"1", "true", "yes", "on"}
def _write_artifact(result: dict[str, Any]) -> str | None:
if not _audit_artifacts_enabled():
return None
metadata = result.get("source_metadata") or {}
artifact = _artifact_path(
str(metadata.get("operation") or result.get("operation") or "calculation"),
str(metadata.get("request_hash") or "no-request-hash"),
str(metadata.get("response_hash") or "no-response-hash"),
)
artifact.write_text(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
from api_scratch_retention import write_private_json
write_private_json(artifact, result)
return _repo_relative(artifact)
@@ -486,6 +495,22 @@ def _official_full_snapshot_cache_ttl_seconds() -> float:
return 0.0
def _official_full_snapshot_negative_ttl_seconds() -> float:
normal = _official_full_snapshot_cache_ttl_seconds()
if normal <= 0:
return 0.0
raw = os.environ.get("VEDASTRO_OFFICIAL_FULL_SNAPSHOT_NEGATIVE_TTL_SECONDS", "300").strip()
try:
negative = max(0.0, float(raw)) if raw else 300.0
except ValueError:
negative = 300.0
return min(negative, normal)
def _official_full_snapshot_result_is_success(payload: dict[str, Any]) -> bool:
return payload.get("available") is True and payload.get("status") == "ok"
def _free_tier_max_requests() -> int:
raw = os.environ.get(FREE_TIER_MAX_REQUESTS_ENV, "").strip()
if not raw:
@@ -1719,9 +1744,11 @@ def _attach_official_full_snapshot_semantic_cache_metadata(
cache_key: str,
created_at_unix: float,
cache_hit: bool,
ttl_seconds: float | None = None,
) -> dict[str, Any]:
payload_copy = json.loads(json.dumps(payload))
ttl_seconds = _official_full_snapshot_cache_ttl_seconds()
if ttl_seconds is None:
ttl_seconds = _official_full_snapshot_cache_ttl_seconds()
created_at = datetime.utcfromtimestamp(created_at_unix).strftime("%Y-%m-%dT%H:%M:%SZ")
expires_at = datetime.utcfromtimestamp(created_at_unix + ttl_seconds).strftime("%Y-%m-%dT%H:%M:%SZ")
metadata = dict(payload_copy.get("source_metadata") or {})
@@ -1745,9 +1772,15 @@ def _rebind_official_full_snapshot_case_identity(payload: dict[str, Any], case_i
return payload_copy
def _semantic_cache_ttl_seconds(record: dict[str, Any]) -> float:
# Records written before the negative flag are successes and keep the long TTL.
if record.get("negative") is True:
return _official_full_snapshot_negative_ttl_seconds()
return _official_full_snapshot_cache_ttl_seconds()
def _load_official_full_snapshot_semantic_cache(case: dict[str, Any], case_id: str) -> dict[str, Any] | None:
ttl_seconds = _official_full_snapshot_cache_ttl_seconds()
if ttl_seconds <= 0:
if _official_full_snapshot_cache_ttl_seconds() <= 0:
return None
cache_key = _official_full_snapshot_cache_key(case, case_id)
cache_path = _official_full_snapshot_cache_path(cache_key)
@@ -1763,7 +1796,8 @@ def _load_official_full_snapshot_semantic_cache(case: dict[str, Any], case_id: s
payload = record.get("payload")
if not isinstance(created_at_raw, (int, float)) or not isinstance(payload, dict):
return None
if time.time() - float(created_at_raw) > ttl_seconds:
ttl_seconds = _semantic_cache_ttl_seconds(record)
if ttl_seconds <= 0 or time.time() - float(created_at_raw) > ttl_seconds:
return None
rebound = _rebind_official_full_snapshot_case_identity(payload, case_id)
return _attach_official_full_snapshot_semantic_cache_metadata(
@@ -1771,28 +1805,37 @@ def _load_official_full_snapshot_semantic_cache(case: dict[str, Any], case_id: s
cache_key=cache_key,
created_at_unix=float(created_at_raw),
cache_hit=True,
ttl_seconds=ttl_seconds,
)
def _store_official_full_snapshot_semantic_cache(case: dict[str, Any], case_id: str, payload: dict[str, Any]) -> dict[str, Any]:
cache_key = _official_full_snapshot_cache_key(case, case_id)
created_at_unix = time.time()
negative = not _official_full_snapshot_result_is_success(payload)
ttl_seconds = (
_official_full_snapshot_negative_ttl_seconds()
if negative
else _official_full_snapshot_cache_ttl_seconds()
)
payload_copy = _attach_official_full_snapshot_semantic_cache_metadata(
payload,
cache_key=cache_key,
created_at_unix=created_at_unix,
cache_hit=False,
ttl_seconds=ttl_seconds,
)
ttl_seconds = _official_full_snapshot_cache_ttl_seconds()
if ttl_seconds > 0:
record = {
"cache_key": cache_key,
"created_at": created_at_unix,
"payload": payload_copy,
}
_official_full_snapshot_cache_path(cache_key).write_text(
json.dumps(record, ensure_ascii=False, sort_keys=True),
encoding="utf-8",
from api_scratch_retention import write_private_json
write_private_json(
_official_full_snapshot_cache_path(cache_key),
{
"cache_key": cache_key,
"created_at": created_at_unix,
"negative": negative,
"payload": payload_copy,
},
)
return payload_copy
@@ -1882,7 +1925,9 @@ def _store_cached_payload(cache_key: str, payload: dict[str, Any]) -> dict[str,
"created_at": created_at_unix,
"payload": payload_copy,
}
cache_path.write_text(json.dumps(record, ensure_ascii=False, sort_keys=True), encoding="utf-8")
from api_scratch_retention import write_private_json
write_private_json(cache_path, record)
return payload_copy
+314
View File
@@ -0,0 +1,314 @@
"""Retention, chart-cache keys, and VedAstro audit switch."""
from __future__ import annotations
import hashlib
import json
import os
import re
import threading
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
import api_scratch_retention as retention
import jyotish_api_server as api
import vedastro_service_adapter as adapter
ROOT = Path(__file__).resolve().parents[1]
def _hex(label: str) -> str:
return hashlib.sha256(label.encode("utf-8")).hexdigest() + ".json"
def _isolate(monkeypatch, tmp_path: Path) -> None:
chart = tmp_path / "chart"
jobs = tmp_path / "jobs"
chart.mkdir()
jobs.mkdir()
monkeypatch.setattr(api, "_api_chart_cache_dir", lambda: chart)
monkeypatch.setattr(api, "_async_job_dir", lambda scope: jobs)
monkeypatch.setattr(api, "_async_job_backend", lambda: "file")
monkeypatch.setattr(adapter, "ARTIFACT_DIR", tmp_path / "adapter")
monkeypatch.setenv("JYOTISH_VEDASTRO_SNAPSHOT_CACHE_DIR", str(tmp_path / "snap"))
monkeypatch.setenv("JYOTISH_VEDASTRO_SNAPSHOT_NEGATIVE_CACHE_DIR", str(tmp_path / "neg"))
monkeypatch.setenv("VEDASTRO_GATEWAY_QUEUE_DIR", str(tmp_path / "gw"))
monkeypatch.setenv("JYOTISH_CACHE_DIR", str(tmp_path / "cache"))
monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "900")
monkeypatch.delenv("VEDASTRO_WRITE_AUDIT_ARTIFACTS", raising=False)
def _write(path: Path, payload) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload), encoding="utf-8")
def test_prune_all_drops_expired_and_old_bad_chart_cache_only(monkeypatch, tmp_path):
_isolate(monkeypatch, tmp_path)
chart = api._api_chart_cache_dir()
now = time.time()
fresh = chart / _hex("fresh")
expired = chart / _hex("expired")
bad_old = chart / _hex("bad-old")
bad_young = chart / _hex("bad-young")
stranger = chart / "notes.json"
_write(fresh, {"cache_key": "a", "created_at": now, "payload": {}})
_write(expired, {"cache_key": "b", "created_at": now - 5000, "payload": {}})
bad_old.write_text("{", encoding="utf-8")
bad_young.write_text("{", encoding="utf-8")
stranger.write_text("{}", encoding="utf-8")
old = now - (25 * 3600)
os.utime(bad_old, (old, old))
os.utime(stranger, (old, old))
result = retention.prune_all(now=now)
assert fresh.exists()
assert bad_young.exists()
assert stranger.exists()
assert not expired.exists()
assert not bad_old.exists()
assert result["directories"]["api_chart_cache"]["removed"] == 2
assert result["errors"] == 0
def test_job_cleanup_still_removes_only_expired_records(monkeypatch, tmp_path):
_isolate(monkeypatch, tmp_path)
jobs = api._async_job_dir("any")
expired = jobs / "expired.json"
active = jobs / "active.json"
bad = jobs / "bad.json"
expired.write_text(json.dumps({"expires_at_unix": time.time() - 1}), encoding="utf-8")
active.write_text(json.dumps({"expires_at_unix": time.time() + 60}), encoding="utf-8")
bad.write_text("{", encoding="utf-8")
result = api.prune_expired_async_jobs()
assert result["removed"] == 1
assert not expired.exists()
assert active.exists()
assert bad.exists()
def test_interval_zero_does_not_start_a_thread():
before = [item.name for item in threading.enumerate()]
assert retention.start_periodic_prune(0) is None
assert [item.name for item in threading.enumerate()] == before
def test_prune_counts_unlink_errors_without_raising(monkeypatch, tmp_path):
_isolate(monkeypatch, tmp_path)
chart = api._api_chart_cache_dir()
target = chart / _hex("stuck")
_write(target, {"created_at": time.time() - 5000, "payload": {}})
real_unlink = Path.unlink
def _boom(self, *args, **kwargs):
if self == target:
raise OSError("denied")
return real_unlink(self, *args, **kwargs)
monkeypatch.setattr(Path, "unlink", _boom)
result = retention.prune_all(now=time.time())
assert result["errors"] > 0
assert target.exists()
def _chart_key(body):
return api._api_chart_cache_key(api._build_api_chart_cache_payload(body))
def test_numeric_birth_spellings_share_one_chart_cache_key():
base = {
"year": 1990,
"month": 1,
"day": 1,
"minute": 0,
"lat": 28.6139,
"lon": 77.2090,
"tz": 5.5,
"today": "2026-10-14",
}
keys = {_chart_key(dict(base, hour=hour)) for hour in (12, 12.0, "12")}
assert len(keys) == 1
assert _chart_key(dict(base, hour=12, second=0)) in keys
assert _chart_key(dict(base, hour=12, lat="28.6139")) in keys
assert _chart_key(dict(base, hour=12, tz="5.5")) in keys
assert _chart_key(dict(base, hour=12, tz="Asia/Kolkata")) not in keys
skipped = _chart_key(dict(base, hour=12, skip_vedastro_main_entry_overview=True))
assert skipped not in keys
assert api._build_api_chart_cache_payload(base)["cache_schema_version"] == 6
def test_audit_artifact_default_off_and_compact_when_on(monkeypatch, tmp_path):
monkeypatch.setattr(adapter, "ARTIFACT_DIR", tmp_path)
monkeypatch.delenv("VEDASTRO_WRITE_AUDIT_ARTIFACTS", raising=False)
manifest = {"requests": [{"section": "chart_core"}], "reference_date": "2026-10-14"}
quiet = adapter._normalize_official_full_snapshot_success(
"https://example.test/api",
manifest,
{"chart_core": {"Status": "Pass"}},
{"chart_core": "ok"},
1,
[],
)
assert quiet["source_metadata"]["artifact_path"] is None
assert list(tmp_path.rglob("*.json")) == []
monkeypatch.setenv("VEDASTRO_WRITE_AUDIT_ARTIFACTS", "1")
written = adapter._normalize_official_full_snapshot_success(
"https://example.test/api",
manifest,
{"chart_core": {"Status": "Pass"}},
{"chart_core": "ok"},
1,
[],
)
rel = written["source_metadata"]["artifact_path"]
path = Path(rel)
if not path.is_absolute():
path = adapter.ROOT / rel
assert path.exists()
# Linux reports the mode bit. Windows keeps the file writable and does not surface 0600.
if os.name != "nt":
assert path.stat().st_mode & 0o777 == 0o600
text = path.read_text(encoding="utf-8")
assert "\n" not in text
assert json.loads(text)["status"] == "ok"
def test_failed_snapshot_expires_before_success(monkeypatch, tmp_path):
monkeypatch.setattr(adapter, "ARTIFACT_DIR", tmp_path)
monkeypatch.setenv("VEDASTRO_OFFICIAL_FULL_SNAPSHOT_CACHE_TTL_SECONDS", "604800")
monkeypatch.delenv("VEDASTRO_OFFICIAL_FULL_SNAPSHOT_NEGATIVE_TTL_SECONDS", raising=False)
calls = {"fail": 0, "ok": 0}
def fake_run(case, case_id="user_chart"):
if case.get("day") == 2:
calls["fail"] += 1
return {"available": False, "status": "timeout", "source_metadata": {}}
calls["ok"] += 1
return {"available": True, "status": "ok", "source_metadata": {}}
monkeypatch.setattr(adapter, "_run_official_full_snapshot_case", fake_run)
fail_case = {"year": 1990, "month": 1, "day": 2, "hour": 12, "lat": 1.0, "lon": 2.0, "tz": 5.5}
ok_case = {"year": 1990, "month": 1, "day": 3, "hour": 12, "lat": 1.0, "lon": 2.0, "tz": 5.5}
adapter.run_official_full_snapshot_for_case(fail_case, case_id="fail")
adapter.run_official_full_snapshot_for_case(ok_case, case_id="ok")
cache_dir = tmp_path / "official_full_snapshot_cache"
aged = time.time() - 301
for path in cache_dir.glob("*.json"):
record = json.loads(path.read_text(encoding="utf-8"))
record["created_at"] = aged
path.write_text(json.dumps(record), encoding="utf-8")
if record.get("negative") is True:
assert record["negative"] is True
adapter.run_official_full_snapshot_for_case(fail_case, case_id="fail")
adapter.run_official_full_snapshot_for_case(ok_case, case_id="ok")
assert calls["fail"] == 2
assert calls["ok"] == 1
def test_gateway_status_reports_cache_ttl_fallback(monkeypatch):
from scripts import vedastro_gateway
monkeypatch.setenv("JYOTISH_SKIP_LOCAL_ENV", "1")
monkeypatch.delenv("VEDASTRO_OFFICIAL_FULL_SNAPSHOT_CACHE_TTL_SECONDS", raising=False)
monkeypatch.setenv("VEDASTRO_CACHE_TTL_SECONDS", "604800")
status = vedastro_gateway.gateway_status()
assert status["official_full_snapshot_cache_ttl_seconds"] == 604800
assert status["official_full_snapshot_negative_cache_ttl_seconds"] == 300
def _day(offset: int) -> str:
return (datetime.now(timezone.utc).date() + timedelta(days=offset)).isoformat()
def test_snapshot_negative_gateway_and_varga_prune_rules(monkeypatch, tmp_path):
_isolate(monkeypatch, tmp_path)
now = time.time()
today = datetime.fromtimestamp(now, timezone.utc).date()
snap = tmp_path / "snap"
neg = tmp_path / "neg"
gw = tmp_path / "gw"
varga = tmp_path / "cache" / "varga_segments"
keep_ref = (today - timedelta(days=7)).isoformat()
drop_ref = (today - timedelta(days=9)).isoformat()
_write(snap / _hex("snap-keep"), {"schema": "vedastro_snapshot_cache.v1", "reference_date": keep_ref})
_write(snap / _hex("snap-drop"), {"schema": "vedastro_snapshot_cache.v1", "reference_date": drop_ref})
_write(snap / "notes.json", {"reference_date": "1990-01-01"})
_write(neg / _hex("neg-today"), {"schema": "vedastro_snapshot_negative_cache.v1", "reference_date": today.isoformat()})
_write(neg / _hex("neg-yesterday"), {"schema": "vedastro_snapshot_negative_cache.v1", "reference_date": (today - timedelta(days=1)).isoformat()})
_write(neg / "stray.json", {"reference_date": "1990-01-01"})
job_id = "vgw_" + "ab" * 10
old_stamp = datetime.fromtimestamp(now - 90000, timezone.utc).isoformat()
fresh_stamp = datetime.fromtimestamp(now, timezone.utc).isoformat()
_write(gw / f"{job_id}.json", {"job_id": job_id, "status": "completed", "updated_at": old_stamp})
(gw / f"{job_id}.official_raw_response.json").write_text("{}", encoding="utf-8")
running = "vgw_" + "cd" * 10
_write(gw / f"{running}.json", {"job_id": running, "status": "running", "updated_at": old_stamp})
failed = "vgw_" + "ef" * 10
_write(gw / f"{failed}.json", {"job_id": failed, "status": "failed", "updated_at": fresh_stamp})
(gw / "notes.json").write_text("{}", encoding="utf-8")
old_varga = varga / _hex("old-segment")
young_varga = varga / _hex("young-segment")
_write(old_varga, {"window_key": "x"})
_write(young_varga, {"window_key": "y"})
_write(varga / "notes.json", {"window_key": "z"})
os.utime(old_varga, (now - 8 * 86400, now - 8 * 86400))
monkeypatch.setenv("VEDASTRO_WRITE_AUDIT_ARTIFACTS", "0")
artifact = tmp_path / "adapter" / "official_full_snapshot-0123456789ab-0123456789ab.json"
artifact.parent.mkdir(parents=True, exist_ok=True)
artifact.write_text("{}", encoding="utf-8")
os.utime(artifact, (now - 25 * 3600, now - 25 * 3600))
kept_artifact = tmp_path / "adapter" / "method_catalog_snapshot.json"
kept_artifact.write_text("{}", encoding="utf-8")
os.utime(kept_artifact, (now - 25 * 3600, now - 25 * 3600))
retention.prune_all(now=now)
assert (snap / _hex("snap-keep")).exists()
assert not (snap / _hex("snap-drop")).exists()
assert (snap / "notes.json").exists()
assert (neg / _hex("neg-today")).exists()
assert not (neg / _hex("neg-yesterday")).exists()
assert (neg / "stray.json").exists()
assert not (gw / f"{job_id}.json").exists()
assert not (gw / f"{job_id}.official_raw_response.json").exists()
assert (gw / f"{running}.json").exists()
assert (gw / f"{failed}.json").exists()
assert (gw / "notes.json").exists()
assert not old_varga.exists()
assert young_varga.exists()
assert (varga / "notes.json").exists()
assert not artifact.exists()
assert kept_artifact.exists()
def _scratch_local_names() -> set[str]:
names: set[str] = set()
slash = re.compile(r"scratch/local/([A-Za-z0-9_.]+)")
joined = re.compile(
r"""['\"]scratch['\"]\s*/\s*['\"]local['\"]\s*/\s*(?:f)?['\"]([A-Za-z0-9_.]+)['\"]"""
)
after_local = re.compile(
r"""['\"]local['\"]\s*\)*\s*/\s*['\"]([A-Za-z0-9_.]+)['\"]"""
)
for path in (ROOT / "scripts").rglob("*.py"):
text = path.read_text(encoding="utf-8")
names.update(slash.findall(text))
names.update(joined.findall(text))
names.update(after_local.findall(text))
return names
def test_new_scratch_local_dirs_are_registered_or_whitelisted():
allowed = set(retention.REGISTERED_DIR_NAMES) | set(retention.OFFLINE_SCRATCH_NAMES)
found = _scratch_local_names()
unexpected = sorted(found - allowed)
assert unexpected == [], unexpected
assert "api_chart_cache" in found
assert "consult_full_data_cache" in retention.OFFLINE_SCRATCH_NAMES
@@ -769,6 +769,10 @@ def test_vedastro_range_scan_records_hashes_and_artifact_path() -> None:
env = os.environ.copy()
env["VEDASTRO_API_ENDPOINT"] = f"http://127.0.0.1:{server.server_port}/vedastro"
env["VEDASTRO_ENABLE_NETWORK"] = "1"
# 原值:沿用进程环境,没有 VEDASTRO_WRITE_AUDIT_ARTIFACTS。
# 新值:显式设为 1。
# 原因:审计落盘默认关闭后,这条测试仍要断言文件存在,所以打开开关。这是测试前置,不是放宽断言。
env["VEDASTRO_WRITE_AUDIT_ARTIFACTS"] = "1"
completed = subprocess.run(
[
sys.executable,