Compare commits

..
Author SHA1 Message Date
jesse-uxandGrok (agent loop) a04e590961 合并 staging,并把长报告缓存目录登记进自行清理名单
Independent Staging Quality Gate / validate (push) Successful in 14m32s
Independent Staging Quality Gate / publish (push) Successful in 4m18s
与最新 origin/staging 合并。report_reference_cache 写入 OFFLINE_SCRATCH_NAMES。清理仍由每次读取前删掉两天前的文件完成,两个缓存模块的行为没改。

自测:test_api_scratch_retention 10、test_report_reference_perf 15、test_consult_card_full_source 12,共 37 通过。

Co-Authored-By: Grok (agent loop)
2026-10-14 10:40:43 +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) 69c739fbee 个人长报告同盘同日重试改为秒回
完整数据版在版本号明确时复用当天的结果。渲染快捷算法留下但默认关闭,本机只快了大约两成。

Co-Authored-By: Grok (agent loop)
2026-10-14 06:37:40 +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
35 changed files with 21622 additions and 18059 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
+15
View File
@@ -1,5 +1,20 @@
# 印度占星 Skill 更新日志
## 2026-10-14 — 个人长报告引擎出稿提速(同盘同日重试秒回)
- 同一张盘、同一天、同一个已发布版本,再次生成完整数据版长报告时直接用刚才的结果,本机大约 0.06 秒。第一次生成仍走原来的计算。版本号缺失时不缓存。
- 中文替换和溯源哈希的快捷算法已留下,但本机只快了大约两成,没有达到开通门槛,默认仍走原算法。
- Skill 版本不变(BUG-1335、BUG-1336)。
## 2026-10-14 — 服务器不再长期保留出生资料副本
- 排盘缓存、官方快照和相关临时文件会按期限删掉,不再一直堆在服务器上。审计用的快照文件默认不再落地。失败的官方快照只短时间复用。
- Skill 版本不变(BUG-1330~1332)。
## 2026-10-14 — 回答生成时侧栏不再跟着闪动 / 卡顿(待合入 staging)
- 回答生成时侧栏不再跟着闪动 / 卡顿。点会话、改名、换人仍是当前这一下的操作。Skill 版本不变(BUG-1329)。
## 2026-10-14 — 服务器长期运行不再逐日涨内存;数据库访问少走几趟(待合入 staging)
- 服务器长期运行不再逐日涨内存;数据库访问少走几趟。Skill 版本不变(BUG-1326~1328、BUG-1346)。
+18007 -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 · 流式回答时侧栏不再跟着每帧重画(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` 仍留给验收。
@@ -0,0 +1,44 @@
# PROGRESS · 长报告出稿提速(2026-10-14)
- 分支:`codex/report-reference-perf-20261013`
- 实际基线:`origin/staging` `66cd60081f37a57680274633fcb3fb942d01bb2f`(任务书写的 `77057a819`、计划评论里的 `2994588ad` 都更早,按当前 staging 开工)
- `scripts/jyotish_api_server.py`:10335 行,零改动
- 历史最大号在本基线上是 BUG-1322。BUG-1323~1334 已被其它未合入任务书占用。本单用任务书指定的 BUG-1335、BUG-1336。
## 做了什么
1. 两个开关在调用时读环境变量。渲染快捷路径默认关,同盘缓存默认开。
2. 中文和英文的大运密度补句:正则预编译,行内先看必出现的字面量,没有才不搜索;向前看的五行只在可能命中时才拼。术语替换:正文里没有这个词就原样返回,编译结果按词缓存。
3. 溯源哈希在开关打开时,一次遍历同时删掉 `**.键` 并做键名规范化。覆盖范围和排除集没变。没有跳过中间那次绑定,因为那会改 `format=json` 的键顺序。
4. 咨询卡缓存的单飞和两天清理多了一个子目录参数,咨询卡自己的键和目录不变。长报告只在完整数据版 + markdown、开关开、并且 `GITHUB_SHA` 是具体版本号时,才把整份响应放进 `scratch/local/report_reference_cache/`。查缓存发生在重计算之前。命中打一行不含资料的日志。
## 自测
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 任务书列出的 pytest | `.venv\Scripts\python.exe -m pytest` 后接任务书第 8 节那一串文件 `-q` | 退出码 0。缓存默认改成开之后又跑了一遍,仍是退出码 0 |
| 屏蔽 PyJHora 的 A/B | `python scripts/research/report_reference_perf_ab.py --mask-pyjhora` | 四张盘五字段 sha 相同;端到端下降 20%~23%,未达 35%;缓存命中中位约 0.06 秒 |
| 同一份解盘的 json / reference | 同上脚本 `--side-only` | 退出码 0。时钟钉死且复用同一次解盘后,两版 sha 相同 |
| 不屏蔽 | 未跑 | 本机没有 PyJHora,和屏蔽走同一条导入失败路径 |
数字见 `docs/testing/report-reference-perf-ab-20261013.md`。
## 改既有断言
无。原值 / 新值 / 原因:没有改旧断言。
## 未做
- 整篇替换逐条预检(让步顺序里最先砍的一项)。中文渲染只降了 20%~28%,不到 40%,没有为了过线改输出。
- 跳过中间绑定。
- 关掉没人读的质量门。任务书写明这要另开单,本单不动。
- 旧路径退役。开关还在。
## 默认值
- `JYOTISH_REPORT_RENDER_FAST` 默认关。字节一致,内存还降了约 12%,但端到端没到 35%。
- `JYOTISH_REPORT_REFERENCE_CACHE` 默认开。命中与未命中字节一致,命中中位约 0.06 秒。版本号缺失或是 `unknown` 时不缓存。
## 第 1 次打回
合并最新 `origin/staging` 后,把 `report_reference_cache` 登记进 `OFFLINE_SCRATCH_NAMES`。清理逻辑没改:这个目录仍由 `consult_full_data_cache.get_or_build` 在每次调用时删掉两天前的文件。没有改两个缓存模块的行为。
+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-report-reference-perf-20261013.md` | — | 长报告出稿提速:术语替换命中预检、哈希单次遍历、同盘复用(BUG-1335~1336,A/B) | 待领取 | `codex/report-reference-perf-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` | `PROGRESS-report-reference-perf-20261013.md` | 长报告出稿提速:术语替换命中预检、哈希单次遍历、同盘复用(BUG-1335~1336,A/B) | 待验收 | `codex/report-reference-perf-20261013` |
| `TASK-report-reference-perf-fix-20261014.md` | `PROGRESS-report-reference-perf-20261013.md` | **#61 第 1 次打回**:`69c739fb` 本单全过;与清理规则合并后,长报告缓存目录补进自行清理名单 | 待验收 | `codex/report-reference-perf-20261013` |
| `TASK-consult-prep-parallel-20261013.md` | — | 普通对话发问前准备步骤并行、模型版本短缓存、多领域并发开关(BUG-1337~1339,A/B) | 待领取 | `codex/consult-prep-parallel-20261013` |
| `TASK-consult-prep-parallel-fix-20261014.md` | — | **#62 第 1 次打回**:`a0e223e2` 业务逻辑通过;两条源码文本断言未跟改(§7.8)、A/B 脚本从 `frontend/tests` 导入(BUG-1140) | 待领取 | `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,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 未跑。
@@ -0,0 +1,61 @@
# 长报告出稿 A/B(2026-10-14)
Windows 本机,Python 3.11.7,`Windows-10-10.0.26200-SP0`。屏蔽 PyJHora(生产镜像不装这个库)。本机本身也没有 PyJHora,所以没有再跑一轮不屏蔽的重复计时。计时开了 tracemalloc,绝对秒数比任务书里的 Linux 基线高,只比较同一进程里的 A 和 B。
A = 两个开关关。B = 两个开关开。四张虚构盘,每组 3 次。生产请求是完整数据版、markdown、中英都要、带事实表。没有模型钥匙。
## 端到端(未命中,秒)
| 盘 | A 三次 | A 中位 | B 三次 | B 中位 | 下降 |
| --- | --- | --- | --- | --- | --- |
| day | 71.31 / 64.03 / 64.01 | 64.03 | 51.05 / 50.77 / 51.74 | 51.05 | 20.3% |
| night | 66.25 / 66.11 / 66.66 | 66.25 | 53.32 / 52.57 / 50.43 | 52.57 | 20.7% |
| south | 66.03 / 66.00 / 62.69 | 66.00 | 49.97 / 52.79 / 52.68 | 52.68 | 20.2% |
| section2 | 71.01 / 108.37 / 74.19 | 74.19 | 57.42 / 57.12 / 71.80 | 57.42 | 22.6% |
门槛是未命中端到端下降至少 35%。四张盘都没到。渲染快捷路径默认关闭。
## 分段中位数(秒)
| 盘 | 中文渲染 A → B | 英文渲染 A → B | 建包 A → B |
| --- | --- | --- | --- |
| day | 23.55 → 17.04(−27.6%) | 8.27 → 8.67 | 22.55 → 16.08 |
| night | 23.42 → 17.19(−26.6%) | 8.75 → 8.41 | 23.70 → 17.21 |
| south | 23.89 → 17.39(−27.2%) | 8.66 → 8.74 | 22.90 → 16.87 |
| section2 | 22.26 → 17.80(−20.0%) | 8.14 → 8.54 | 31.24 → 21.08 |
中文渲染门槛是下降至少 40%。没到。没有继续加整篇替换的预检。
## 内存与字节
tracemalloc 峰值:B 约为 A 的 0.87 到 0.88,没有升高。
五份输出(整份响应、中文、英文、事实表、适用性)A、B 未命中、缓存命中的 sha256 全部相同。
| 盘 | 响应字节 | 响应 sha256 前 12 位 |
| --- | --- | --- |
| day | 2289867 | 5b542359d606 |
| night | 2321638 | 89baa960c054 |
| south | 2314369 | 250bf1ab246e |
| section2 | 2238092 | 1d7afd481b1a |
## 同盘缓存(命中,秒)
| 盘 | 三次 | 中位 |
| --- | --- | --- |
| day | 0.059 / 0.054 / 0.057 | 0.057 |
| night | 0.057 / 0.054 / 0.058 | 0.057 |
| south | 0.059 / 0.054 / 0.057 | 0.057 |
| section2 | 0.063 / 0.066 / 0.057 | 0.063 |
命中字节与未命中相同,中位数远低于 1.5 秒。缓存开关默认开。`GITHUB_SHA` 缺失或为 `unknown` 时仍然关。
## 单次溯源哈希(交付包,秒)
关:1.132 / 1.099 / 1.115,中位 1.115。开:0.402 / 0.466 / 0.388,中位 0.402。下降 64%,过了 50% 那一档。它跟渲染快捷路径用同一个开关,所以默认也是关。
## json 与 reference 版
同一份解盘、时钟钉死后,`format=json` 和 reference 版 markdown 在快捷路径关与开时 sha256 相同(`aa1352619b7a`、`cbadf95f549a`)。对比的是整份响应,没有丢掉字段。
连续两次重新解盘会对不齐:有的特质列表挂在类上,第二次会再追加。那不是这条快捷路径改出来的。生产要的完整数据 markdown 在多次重算里仍然逐字节相同。
+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
+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)));
@@ -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" \}\)/
@@ -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);
});
}
@@ -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]);
+611
View File
@@ -0,0 +1,611 @@
"""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",
# 与 consult_full_data_cache 同走 get_or_build,每次调用先 prune_cache(name=…) 删两天前的文件,自己会清;含出生资料,保留期 2 天。
"report_reference_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
+76 -9
View File
@@ -486,15 +486,26 @@ def _drop_dotted_path(payload: Any, path: tuple[str, ...]) -> Any:
return cloned
def result_payload_for_binding(
def _render_fast() -> bool:
try:
from scripts.report_perf_flags import report_render_fast_enabled
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution
from report_perf_flags import report_render_fast_enabled
return report_render_fast_enabled()
def _excluded_binding_top(binding_scope: dict[str, Any]) -> set[str]:
return RESULT_BINDING_ENVELOPE_KEYS | {
str(key) for key in (binding_scope.get("excluded_top_keys") or ())
}
def _result_payload_for_binding_legacy(
result: dict[str, Any],
scope: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Business payload that participates in result_hash."""
binding_scope = scope if isinstance(scope, dict) else default_result_binding_scope()
excluded_top = RESULT_BINDING_ENVELOPE_KEYS | {
str(key) for key in (binding_scope.get("excluded_top_keys") or ())
}
excluded_top = _excluded_binding_top(binding_scope)
payload = {key: value for key, value in result.items() if key not in excluded_top}
for raw_path in binding_scope.get("excluded_paths") or ():
parts = tuple(part for part in str(raw_path).split(".") if part)
@@ -503,17 +514,73 @@ def result_payload_for_binding(
return payload
def _drop_star_keys_and_canonicalize(payload: Any, star_keys: frozenset[str]) -> Any:
"""Drop ``**.key`` names and canonicalize keys in one walk.
String keys use the same inequality as ``_drop_key_recursive``. Non-string
keys are kept, then renamed with the same rule as ``canonicalize_result_payload``.
"""
if isinstance(payload, dict):
normalized: dict[str, Any] = {}
for key, child in payload.items():
if isinstance(key, str):
if key in star_keys:
continue
normalized_key = key
else:
normalized_key = f"__{type(key).__name__}__:{key}"
normalized[normalized_key] = _drop_star_keys_and_canonicalize(child, star_keys)
return normalized
if isinstance(payload, (list, tuple)):
return [_drop_star_keys_and_canonicalize(item, star_keys) for item in payload]
return payload
def _result_payload_for_binding_fast(
result: dict[str, Any],
scope: dict[str, Any] | None = None,
) -> dict[str, Any]:
binding_scope = scope if isinstance(scope, dict) else default_result_binding_scope()
excluded_top = _excluded_binding_top(binding_scope)
payload = {key: value for key, value in result.items() if key not in excluded_top}
star_keys: set[str] = set()
for raw_path in binding_scope.get("excluded_paths") or ():
parts = tuple(part for part in str(raw_path).split(".") if part)
if len(parts) == 2 and parts[0] == "**":
star_keys.add(parts[1])
continue
if parts:
payload = _drop_dotted_path(payload, parts)
return _drop_star_keys_and_canonicalize(payload, frozenset(star_keys))
def result_payload_for_binding(
result: dict[str, Any],
scope: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Business payload that participates in result_hash."""
if _render_fast():
return _result_payload_for_binding_fast(result, scope)
return _result_payload_for_binding_legacy(result, scope)
def hash_bound_result(
result: dict[str, Any],
input_hash: str,
scope: dict[str, Any] | None = None,
) -> str:
"""SHA-256 of the canonical bound payload. Does not mutate ``result``."""
encoded = json.dumps(
canonicalize_result_payload({
payload = result_payload_for_binding(result, scope)
if _render_fast():
# The fast payload is already canonical, including the star-key drop.
canonical = {"input_hash": input_hash, "result": payload}
else:
canonical = canonicalize_result_payload({
"input_hash": input_hash,
"result": result_payload_for_binding(result, scope),
}),
"result": payload,
})
encoded = json.dumps(
canonical,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
+20 -11
View File
@@ -57,8 +57,8 @@ def _release_warm_slot() -> None:
_warm_inflight -= 1
def cache_dir() -> Path:
path = ROOT / "scratch" / "local" / "consult_full_data_cache"
def cache_dir(name: str = "consult_full_data_cache") -> Path:
path = ROOT / "scratch" / "local" / name
path.mkdir(parents=True, exist_ok=True)
return path
@@ -118,15 +118,15 @@ def cache_key(identity: dict[str, Any]) -> str:
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def _cache_path(identity: dict[str, Any]) -> Path:
return cache_dir() / f"{cache_key(identity)}.json"
def _cache_path(identity: dict[str, Any], name: str = "consult_full_data_cache") -> Path:
return cache_dir(name) / f"{cache_key(identity)}.json"
def prune_cache(now: float | None = None) -> int:
def prune_cache(now: float | None = None, *, name: str = "consult_full_data_cache") -> int:
"""Drop cache files last written more than two days ago."""
cutoff = (time.time() if now is None else now) - KEEP_SECONDS
removed = 0
folder = cache_dir()
folder = cache_dir(name)
for path in folder.glob("*.json"):
try:
if path.stat().st_mtime < cutoff:
@@ -147,15 +147,21 @@ def get_or_build(
builder: PacketBuilder | None = None,
*,
clock: Callable[[], float] | None = None,
cache_name: str = "consult_full_data_cache",
json_default: Callable[..., Any] | None = None,
) -> dict[str, Any]:
"""Return the packet. Concurrent callers for one key share one build."""
prune_cache()
"""Return the packet. Concurrent callers for one key share one build.
``cache_name`` selects the scratch subdirectory. The consult card keeps the
default. A non-default name does not change the consult key or its files.
"""
prune_cache(name=cache_name)
now = clock or time.perf_counter
started = now()
path = _cache_path(identity)
path = _cache_path(identity, cache_name)
if path.is_file():
return _read_cached(path, started, now)
key = cache_key(identity)
key = f"{cache_name}\n{cache_key(identity)}"
with _flights_guard:
flight = _flights.get(key)
owner = flight is None
@@ -177,7 +183,10 @@ def get_or_build(
builder = build_full_data_packet
packet = builder(identity)
temporary = path.with_suffix(".json.tmp")
temporary.write_text(json.dumps(packet, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
dump_kwargs: dict[str, Any] = {"ensure_ascii": False, "separators": (",", ":")}
if json_default is not None:
dump_kwargs["default"] = json_default
temporary.write_text(json.dumps(packet, **dump_kwargs), encoding="utf-8")
temporary.replace(path)
elapsed = now() - started
return {
+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
+160 -1
View File
@@ -7,9 +7,15 @@ It only changes reader-facing labels; it does not alter calculation payloads.
"""
from __future__ import annotations
import functools
import re
from typing import Any
try:
from scripts.report_perf_flags import report_render_fast_enabled
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution
from report_perf_flags import report_render_fast_enabled
# Bilingual planet tokens from yoga combination text, longest first.
# Dignity is glued on with no space ("木星Jupiterexalted"), so the long form
@@ -131,6 +137,11 @@ def translate_zh_glued_phrases(text: str) -> str:
return text
@functools.lru_cache(maxsize=512)
def _natural_language_term_pattern(raw: str, plural: str) -> re.Pattern:
return re.compile(rf"(?<![A-Za-z0-9_]){re.escape(raw)}{plural}(?![A-Za-z0-9_])")
def replace_natural_language_term(text: str, raw: str, public: str) -> str:
"""Replace one glossary term in prose only.
@@ -141,7 +152,14 @@ def replace_natural_language_term(text: str, raw: str, public: str) -> str:
if not text or not raw:
return text
plural = "s?" if re.search(r"[A-Za-z]$", raw) and not raw.endswith("s") else ""
pattern = re.compile(rf"(?<![A-Za-z0-9_]){re.escape(raw)}{plural}(?![A-Za-z0-9_])")
if report_render_fast_enabled():
# Case-sensitive literal. If it is absent, split-and-join would return
# the same bytes, so the compile and the scan can be skipped.
if raw not in text:
return text
pattern = _natural_language_term_pattern(raw, plural)
else:
pattern = re.compile(rf"(?<![A-Za-z0-9_]){re.escape(raw)}{plural}(?![A-Za-z0-9_])")
parts = re.split(r"(`[^`]*`)", text)
return "".join(
part if index % 2 else pattern.sub(public, part)
@@ -555,12 +573,98 @@ def _restore_zh_nakshatra_glosses(markdown: str) -> str:
)
# Every AD/PD hit contains these literals. Patterns stay case-sensitive.
_ZH_AD_DENSITY_RE = re.compile(
r"(.+?)主大运中的(.+?)子运,会把(.+?)的自然主题带入当前阶段。"
)
_ZH_PD_DENSITY_RE = re.compile(
r"(.+?)子运中的(.+?)次子运,会在(.+?)背景下呈现(.+?)的短周期结果。"
)
_ZH_AD_ANCHORS = ("主大运中的", "子运,会把", "的自然主题带入当前阶段。")
_ZH_PD_ANCHORS = ("次子运,会在", "背景下呈现", "的短周期结果。")
_EN_MD_HEADING_RE = re.compile(r"^#{2,4}\s+(.+?\bMaha Dasha\b.*)$")
_EN_AD_DENSITY_RE = re.compile(
r"The Antar Dasha of (.+?) activates (.+?)'s natural themes inside the broader Maha Dasha of (.+?)\."
)
_EN_PD_DENSITY_RE = re.compile(
r"In the Pratyantar Dasha of (.+?) in the Antar Dasha of (.+?), (.+?)'s significations give the short-period result inside the (.+?) background\."
)
_EN_AD_ANCHORS = (
"The Antar Dasha of ",
" activates ",
"'s natural themes inside the broader Maha Dasha of ",
)
_EN_PD_ANCHORS = (
"In the Pratyantar Dasha of ",
" in the Antar Dasha of ",
"'s significations give the short-period result inside the ",
" background.",
)
def _line_has_anchors(line: str, anchors: tuple[str, ...]) -> bool:
return all(anchor in line for anchor in anchors)
def _thicken_zh_dasha_density_fast(markdown: str) -> str:
"""Same AD/PD inserts as the legacy scan, without searching inert lines."""
lines = markdown.splitlines()
out: list[str] = []
for index, line in enumerate(lines):
out.append(line)
# Both patterns are case-sensitive and every hit contains these literals.
ad_possible = _line_has_anchors(line, _ZH_AD_ANCHORS)
pd_possible = _line_has_anchors(line, _ZH_PD_ANCHORS)
if not ad_possible and not pd_possible:
continue
lookahead = "\n".join(lines[index + 1:index + 6])
if "这一子运不是单独结论" in lookahead or "这个次子运只承担短周期细化" in lookahead:
continue
if ad_possible:
ad_match = _ZH_AD_DENSITY_RE.search(line)
if ad_match:
md_lord, ad_lord, theme_lord = ad_match.groups()
out.extend([
"",
(
f"这一子运不是单独结论,而是把{theme_lord}的自然象征放进{md_lord}主大运的背景里筛选。"
f"阅读时先看{ad_lord}本身的落宫、守护宫、尊贵状态与星宿,再看它和主大运星之间是否互相支持。"
),
"",
(
"若同一宫位或同一行星在分盘、Ashtakavarga、年度盘和行运中重复出现,"
"该主题的可见度会提高;若这些层彼此冲突,则应把结果视为阶段性倾向,而不是孤立断语。"
),
])
continue
if pd_possible:
pd_match = _ZH_PD_DENSITY_RE.search(line)
if pd_match:
ad_lord, pd_lord, md_lord, theme_lord = pd_match.groups()
out.extend([
"",
(
f"这个次子运只承担短周期细化:{pd_lord}会把{theme_lord}的具体征象带入{ad_lord}子运,"
f"并受{md_lord}主背景限制。"
),
"",
(
"因此这里优先用于判断事情推进的节奏、触发点和轻重缓急;实际领域仍需回到本命落宫、"
"分盘重复、年度盘和当前行运共同核对。"
),
])
continue
return "\n".join(out)
def _thicken_zh_dasha_density(markdown: str) -> str:
"""Restore reader-facing density for translated AD/PD prose.
This adds reading instructions tied to already-present fields. It does not
introduce new predictions or alter dates.
"""
if report_render_fast_enabled():
return _thicken_zh_dasha_density_fast(markdown)
lines = markdown.splitlines()
out: list[str] = []
for index, line in enumerate(lines):
@@ -941,9 +1045,64 @@ def normalize_en_markdown_terms(markdown: str) -> str:
return apply_en_residual_han(_thicken_en_dasha_density(text))
def _thicken_en_dasha_density_fast(markdown: str) -> str:
"""Same English AD inserts as the legacy scan, without searching inert lines."""
lines = markdown.splitlines()
out: list[str] = []
current_md = None
ad_context_added = False
for index, line in enumerate(lines):
md_heading = _EN_MD_HEADING_RE.match(line)
if md_heading:
next_md = md_heading.group(1)
if next_md != current_md:
current_md = next_md
ad_context_added = False
out.append(line)
# Both patterns are case-sensitive and every hit contains these literals.
ad_possible = _line_has_anchors(line, _EN_AD_ANCHORS)
pd_possible = _line_has_anchors(line, _EN_PD_ANCHORS)
if not ad_possible and not pd_possible:
continue
lookahead = "\n".join(lines[index + 1:index + 6])
if (
"This Antar Dasha is not read as a standalone verdict" in lookahead
or "This Pratyantar Dasha is the short-cycle refinement" in lookahead
):
continue
if ad_possible:
ad_match = _EN_AD_DENSITY_RE.search(line)
if ad_match and not ad_context_added:
ad_lord, theme_lord, md_lord = ad_match.groups()
out.extend([
"",
(
f"This Antar Dasha is not read as a standalone verdict. It places {theme_lord}'s "
f"natural significations inside the background of the {md_lord} Maha Dasha, then "
f"filters them through {ad_lord}'s house placement, house ownership, dignity, "
"nakshatra lord, conjunctions, and divisional-chart repetition."
),
"",
(
"When the same planet or house repeats through divisional charts, Ashtakavarga, "
"annual factors, and transits, the theme becomes more visible. When those layers "
"conflict, the result should be read as a staged tendency rather than an isolated statement."
),
])
ad_context_added = True
continue
if pd_possible and _EN_PD_DENSITY_RE.search(line):
# The row already contains the concrete PD lord and timing context.
# Keep it, but do not append the same methodology paragraph per node.
continue
return "\n".join(out)
def _thicken_en_dasha_density(markdown: str) -> str:
"""Add chapter-level AD context without repeating boilerplate on every PD row."""
if report_render_fast_enabled():
return _thicken_en_dasha_density_fast(markdown)
lines = markdown.splitlines()
out: list[str] = []
current_md = None
+98 -13
View File
@@ -8,6 +8,9 @@ calculation, and delegates packet assembly/rendering to ``jyotish_engine``.
from __future__ import annotations
import copy
import os
import sys
import time
from datetime import datetime
from importlib import import_module
from types import SimpleNamespace
@@ -32,6 +35,11 @@ except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution path
from reader_dasha_applicability import render_dasha_applicability
from report_density_packet import report_density_packet
try:
from scripts.report_perf_flags import report_reference_cache_enabled
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution path
from report_perf_flags import report_reference_cache_enabled
class ProfessionalReportReferenceInputError(ValueError):
"""The professional-reference request is outside the public contract."""
@@ -187,18 +195,49 @@ def _export_args(birth: dict[str, Any]) -> SimpleNamespace:
return SimpleNamespace(**payload)
def build_professional_report_reference(handler, body: dict[str, Any], *, engine=None) -> dict[str, Any]:
"""Build one JSON or Markdown response from one reused full-reading result."""
if not isinstance(body, dict):
raise ProfessionalReportReferenceInputError("request body must be an object")
output_format = _normalize_format(body.get("format"))
edition = _normalize_edition(body.get("edition"))
packs = _normalize_packs(body.get("packs"))
languages = _normalize_languages(body.get("languages"))
if languages and (output_format != "markdown" or edition != FULL_DATA_EDITION):
raise ProfessionalReportReferenceInputError("languages requires format markdown and edition full_data")
def _load_report_cache():
try:
from scripts import consult_full_data_cache as cache
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution path
import consult_full_data_cache as cache
return cache
birth = handler._high_rigor_birth_payload(body)
def _report_cache_sha() -> str:
return os.environ.get("GITHUB_SHA") or ""
def _report_cache_identity(
birth: dict[str, Any],
output_format: str,
edition: str,
packs: list[str],
languages: list[str],
include_fact_tables: bool,
sha: str,
) -> dict[str, Any]:
return {
"birth": birth,
"format": output_format,
"edition": edition,
"packs": list(packs),
"languages": list(languages),
"include_fact_tables": include_fact_tables,
"report_version": REPORT_VERSION_FULL,
"github_sha": sha,
}
def _assemble_professional_report_reference(
handler,
birth: dict[str, Any],
output_format: str,
edition: str,
packs: list[str],
languages: list[str],
include_fact_tables: bool,
engine,
) -> dict[str, Any]:
full_reading = handler._compute_full_reading_for_thematic(birth)
resolved_engine = engine or _load_engine()
try:
@@ -217,7 +256,7 @@ def build_professional_report_reference(handler, body: dict[str, Any], *, engine
if edition == FULL_DATA_EDITION:
# English renders from its own deep copy first, so nothing the Chinese
# renderer does to the packet can reach it (and vice versa).
english = _english_edition(packet, body.get("include_fact_tables") is True) if "en" in languages else {}
english = _english_edition(packet, include_fact_tables) if "en" in languages else {}
markdown = render_full_data_markdown(packet)
else:
markdown = resolved_engine.render_pl9_markdown(packet)
@@ -228,7 +267,7 @@ def build_professional_report_reference(handler, body: dict[str, Any], *, engine
"markdown": markdown,
**({"fact_table_packet": report_density_packet(packet),
"reader_dasha_applicability": clean_reader_appendix_markdown(render_dasha_applicability(packet))}
if body.get("include_fact_tables") is True else {}),
if include_fact_tables else {}),
**(english if edition == FULL_DATA_EDITION and "en" in languages else {}),
}
return {
@@ -236,3 +275,49 @@ def build_professional_report_reference(handler, body: dict[str, Any], *, engine
"edition": edition,
"report": packet,
}
def build_professional_report_reference(handler, body: dict[str, Any], *, engine=None) -> dict[str, Any]:
"""Build one JSON or Markdown response from one reused full-reading result."""
if not isinstance(body, dict):
raise ProfessionalReportReferenceInputError("request body must be an object")
output_format = _normalize_format(body.get("format"))
edition = _normalize_edition(body.get("edition"))
packs = _normalize_packs(body.get("packs"))
languages = _normalize_languages(body.get("languages"))
if languages and (output_format != "markdown" or edition != FULL_DATA_EDITION):
raise ProfessionalReportReferenceInputError("languages requires format markdown and edition full_data")
birth = handler._high_rigor_birth_payload(body)
include_fact_tables = body.get("include_fact_tables") is True
sha = _report_cache_sha()
use_cache = (
report_reference_cache_enabled()
and output_format == "markdown"
and edition == FULL_DATA_EDITION
and sha not in ("", "unknown")
)
if not use_cache:
return _assemble_professional_report_reference(
handler, birth, output_format, edition, packs, languages, include_fact_tables, engine,
)
identity = _report_cache_identity(
birth, output_format, edition, packs, languages, include_fact_tables, sha,
)
started = time.perf_counter()
def builder(_identity: dict[str, Any]) -> dict[str, Any]:
return _assemble_professional_report_reference(
handler, birth, output_format, edition, packs, languages, include_fact_tables, engine,
)
cached = _load_report_cache().get_or_build(
identity,
builder,
cache_name="report_reference_cache",
json_default=str,
)
elapsed_ms = int(round((time.perf_counter() - started) * 1000))
state = "hit" if cached["hit"] else "miss"
print(f"[report-reference-cache] {state} elapsed_ms={elapsed_ms}", file=sys.stderr)
return cached["packet"]
+40
View File
@@ -0,0 +1,40 @@
"""Call-time switches for long-report rendering and same-chart reuse.
Values are read on every call so one process can measure the old path and the
new path back to back. The old path stays in place when a switch is off.
"""
from __future__ import annotations
import os
# Masked Windows A/B on 2026-10-14. Render shortcuts are byte-identical and use
# less memory, but the end-to-end drop was 20–23%, short of the 35% gate, so
# the fast path stays off. Cache hits matched the miss bytes and returned in
# about 0.06s, under the 1.5s gate. A missing or unknown GITHUB_SHA still
# disables the cache at the call site.
RENDER_FAST_DEFAULT = False
REFERENCE_CACHE_DEFAULT = True
_ON = {"1", "true", "yes", "on"}
def _enabled(name: str, default: bool) -> bool:
raw = os.environ.get(name)
if raw is None or not raw.strip():
return default
return raw.strip().lower() in _ON
def report_render_fast_enabled() -> bool:
"""True when the equivalent render and hash shortcuts may run."""
return _enabled("JYOTISH_REPORT_RENDER_FAST", RENDER_FAST_DEFAULT)
def report_reference_cache_enabled() -> bool:
"""True when a full_data markdown response may be reused from disk.
A missing or ``unknown`` ``GITHUB_SHA`` still disables the cache at the
call site. This function only reads the switch.
"""
return _enabled("JYOTISH_REPORT_REFERENCE_CACHE", REFERENCE_CACHE_DEFAULT)
@@ -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")
@@ -0,0 +1,428 @@
"""A/B timing for the professional long report.
A is both switches off. B is both on. Same process, fictional charts only.
A field sha mismatch exits 1 and prints a short diff context, not the report.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import platform
import sys
import tempfile
import time
import tracemalloc
from pathlib import Path
from types import SimpleNamespace
ROOT = Path(__file__).resolve().parents[2]
for _folder in (ROOT / "scripts", ROOT / "jyotish_vedic", ROOT):
_text = str(_folder)
if _text not in sys.path:
sys.path.insert(0, _text)
PRODUCTION_BODY = {
"format": "markdown",
"edition": "full_data",
"include_fact_tables": True,
"languages": ["zh", "en"],
}
SECTION2_CHART = {
"year": 1990,
"month": 1,
"day": 1,
"hour": 12,
"minute": 0,
"second": 0,
"lat": 28.6139,
"lon": 77.2090,
"tz": 5.5,
"node_mode": "mean",
"ayanamsa": "lahiri",
"today": "2026-10-13",
"transit_date": "2026-10-13",
"target_year": 2026,
"age": 36,
"birth_time_accuracy": "confirmed",
}
FIELDS = (
"response",
"markdown",
"markdown_en",
"fact_table_packet",
"reader_dasha_applicability",
)
def _mask_pyjhora() -> str:
root = Path(tempfile.mkdtemp(prefix="nojhora-"))
package = root / "jhora"
package.mkdir()
(package / "__init__.py").write_text('raise ImportError("masked")\n', encoding="utf-8")
sys.path.insert(0, str(root))
prior = os.environ.get("PYTHONPATH", "")
os.environ["PYTHONPATH"] = str(root) + (os.pathsep + prior if prior else "")
for name in list(sys.modules):
if name == "jhora" or name.startswith("jhora."):
del sys.modules[name]
return str(root)
def _sha(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def _canonical(value) -> str:
if isinstance(value, str):
return value
return json.dumps(value, ensure_ascii=False, default=str)
def _field_texts(result: dict) -> dict[str, str]:
whole = json.dumps(result, ensure_ascii=False, default=str)
return {
"response": whole,
"markdown": _canonical(result.get("markdown", "")),
"markdown_en": _canonical(result.get("markdown_en", "")),
"fact_table_packet": _canonical(result.get("fact_table_packet")),
"reader_dasha_applicability": _canonical(result.get("reader_dasha_applicability", "")),
}
def _field_shas(result: dict) -> dict[str, str]:
return {name: _sha(text) for name, text in _field_texts(result).items()}
def _diff_context(left: str, right: str) -> str:
limit = min(len(left), len(right))
index = next((i for i in range(limit) if left[i] != right[i]), limit)
start = max(0, index - 40)
stop = index + 40
return (
f"at {index} len {len(left)} vs {len(right)} "
f"A={left[start:stop]!r} B={right[start:stop]!r}"
)
def _median(values: list[float]) -> float:
ordered = sorted(values)
return ordered[len(ordered) // 2]
class _Bucket:
def __init__(self) -> None:
self.cmd = 0.0
self.packet = 0.0
self.zh = 0.0
self.en = 0.0
self.hash = 0.0
def reset(self) -> None:
self.cmd = self.packet = self.zh = self.en = self.hash = 0.0
def as_dict(self) -> dict[str, float]:
return {
"cmd_full_reading_s": round(self.cmd, 4),
"packet_s": round(self.packet, 4),
"zh_render_s": round(self.zh, 4),
"en_render_s": round(self.en, 4),
"hash_s": round(self.hash, 4),
}
def _install_timers(bucket: _Bucket) -> None:
import scripts.calculation_profile_contract as contract
import scripts.full_report_quality_gate as gate
import scripts.jyotish_engine as engine
import scripts.professional_report_reference as reference
orig_packet = engine.build_professional_report_reference_packet
def timed_packet(*args, **kwargs):
started = time.perf_counter()
try:
return orig_packet(*args, **kwargs)
finally:
bucket.packet += time.perf_counter() - started
engine.build_professional_report_reference_packet = timed_packet
orig_render = reference.render_full_data_markdown
def timed_render(packet, *args, **kwargs):
started = time.perf_counter()
try:
return orig_render(packet, *args, **kwargs)
finally:
elapsed = time.perf_counter() - started
if isinstance(packet, dict) and packet.get("report_language") == "en":
bucket.en += elapsed
else:
bucket.zh += elapsed
reference.render_full_data_markdown = timed_render
orig_hash = contract.hash_bound_result
def timed_hash(*args, **kwargs):
started = time.perf_counter()
try:
return orig_hash(*args, **kwargs)
finally:
bucket.hash += time.perf_counter() - started
contract.hash_bound_result = timed_hash
gate.hash_bound_result = timed_hash
def _set_flags(fast: bool, cache: bool) -> None:
os.environ["JYOTISH_REPORT_RENDER_FAST"] = "1" if fast else "0"
os.environ["JYOTISH_REPORT_REFERENCE_CACHE"] = "1" if cache else "0"
class _Handler:
def __init__(self, birth: dict, bucket: _Bucket) -> None:
self.birth = birth
self.bucket = bucket
def _high_rigor_birth_payload(self, _body: dict) -> dict:
return dict(self.birth)
def _compute_full_reading_for_thematic(self, birth: dict):
from scripts.jyotish_engine import cmd_full_reading
started = time.perf_counter()
try:
return cmd_full_reading(SimpleNamespace(**birth))
finally:
self.bucket.cmd += time.perf_counter() - started
def _run_once(birth: dict, body: dict, bucket: _Bucket, handler=None) -> dict:
from scripts.professional_report_reference import build_professional_report_reference
bucket.reset()
tracemalloc.start()
started = time.perf_counter()
result = build_professional_report_reference(handler or _Handler(birth, bucket), body)
elapsed = time.perf_counter() - started
_current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
texts = _field_texts(result)
return {
"elapsed_s": round(elapsed, 4),
"peak_bytes": peak,
"response_bytes": len(texts["response"].encode("utf-8")),
"segments": bucket.as_dict(),
"sha": _field_shas(result),
"_texts": texts,
}
def _repeat(birth: dict, body: dict, bucket: _Bucket, times: int) -> list[dict]:
return [_run_once(birth, body, bucket) for _ in range(times)]
def _public_row(row: dict) -> dict:
return {key: value for key, value in row.items() if key != "_texts"}
def _compare(label: str, left: dict, right: dict, problems: list[str]) -> None:
left_texts = left["_texts"]
right_texts = right["_texts"]
for name in FIELDS:
if left["sha"][name] != right["sha"][name]:
problems.append(f"{label} {name} {_diff_context(left_texts[name], right_texts[name])}")
def _freeze_clocks():
"""Pin datetime.now / utcnow in every already imported module.
``format=json`` and the reference edition embed wall-clock fields. Freezing
only the engine module leaves other imports on the real clock.
"""
import datetime as dt_mod
real = dt_mod.datetime
fixed_utc = real(2026, 10, 13, 0, 0, 0)
fixed_local = real(2026, 10, 13, 8, 0, 0)
class Frozen(real):
@classmethod
def now(cls, tz=None):
if tz is not None:
return fixed_utc.replace(tzinfo=tz)
return fixed_local
@classmethod
def utcnow(cls):
return fixed_utc
saved = []
for module in list(sys.modules.values()):
if module is None:
continue
current = getattr(module, "datetime", None)
if isinstance(current, type) and current.__name__ == "datetime":
saved.append((module, current))
module.datetime = Frozen
return saved
def _restore_clocks(saved) -> None:
for module, current in saved:
module.datetime = current
def _strip_generated_at(value):
if isinstance(value, dict):
return {key: _strip_generated_at(item) for key, item in value.items() if key != "generated_at"}
if isinstance(value, list):
return [_strip_generated_at(item) for item in value]
return value
def _sha_pair(birth: dict, body: dict, bucket: _Bucket) -> dict:
"""Compare one frozen clock on one reused full reading.
A second ``cmd_full_reading`` appends onto shared trait lists, so two live
engine calls are not a byte test of the render switch.
"""
import copy
from scripts.jyotish_engine import cmd_full_reading
_set_flags(False, False)
reading = cmd_full_reading(SimpleNamespace(**birth))
class _Reuse:
def _high_rigor_birth_payload(self, _body: dict) -> dict:
return dict(birth)
def _compute_full_reading_for_thematic(self, _birth: dict):
return copy.deepcopy(reading)
_set_flags(False, False)
left = _run_once(birth, body, bucket, _Reuse())
_set_flags(True, False)
right = _run_once(birth, body, bucket, _Reuse())
problems = []
_compare("side", left, right, problems)
compared = "full"
if problems:
left_stripped = _sha(_canonical(_strip_generated_at(json.loads(left["_texts"]["response"]))))
right_stripped = _sha(_canonical(_strip_generated_at(json.loads(right["_texts"]["response"]))))
if left_stripped == right_stripped:
problems.clear()
compared = "without_generated_at"
else:
compared = "mismatch"
return {
"compared": compared,
"sha_a": left["sha"]["response"],
"sha_b": right["sha"]["response"],
"problems": problems,
}
def _charts() -> dict[str, dict]:
from tests.test_report_english_edition import CASES
return {
"day": {key: value for key, value in CASES["day"].items() if key != "name"},
"night": {key: value for key, value in CASES["night"].items() if key != "name"},
"south": {key: value for key, value in CASES["south"].items() if key != "name"},
"section2": dict(SECTION2_CHART),
}
def _clear_report_cache() -> None:
folder = ROOT / "scratch" / "local" / "report_reference_cache"
if not folder.is_dir():
return
for path in folder.glob("*.json"):
path.unlink(missing_ok=True)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--mask-pyjhora", action="store_true")
parser.add_argument("--only-a", action="store_true")
parser.add_argument("--side-only", action="store_true")
args = parser.parse_args()
mask_path = _mask_pyjhora() if args.mask_pyjhora else ""
bucket = _Bucket()
_install_timers(bucket)
os.environ.pop("GITHUB_SHA", None)
report: dict = {
"python": platform.python_version(),
"platform": platform.platform(),
"mask_pyjhora": bool(args.mask_pyjhora),
"mask_path": mask_path,
"only_a": args.only_a,
"charts": {},
}
problems: list[str] = []
chart_items = () if args.side_only else _charts().items()
for label, birth in chart_items:
_set_flags(False, False)
arm_a = _repeat(birth, PRODUCTION_BODY, bucket, 3)
entry: dict = {"A": [_public_row(row) for row in arm_a]}
if not args.only_a:
_set_flags(True, False)
arm_b = _repeat(birth, PRODUCTION_BODY, bucket, 3)
for row in arm_b:
_compare(f"{label} B-miss", arm_a[0], row, problems)
os.environ["GITHUB_SHA"] = "report-reference-perf-ab"
_set_flags(True, True)
_clear_report_cache()
miss = _run_once(birth, PRODUCTION_BODY, bucket)
_compare(f"{label} cache-miss", arm_a[0], miss, problems)
hits = _repeat(birth, PRODUCTION_BODY, bucket, 3)
for row in hits:
_compare(f"{label} cache-hit", miss, row, problems)
entry["B_miss"] = [_public_row(row) for row in arm_b]
entry["cache_miss"] = _public_row(miss)
entry["B_hit"] = [_public_row(row) for row in hits]
os.environ.pop("GITHUB_SHA", None)
report["charts"][label] = entry
print(f"{label} done", file=sys.stderr)
if not args.only_a:
saved_clocks = _freeze_clocks()
try:
sample = _charts()["section2"]
report["format_json"] = _sha_pair(sample, {"format": "json", "edition": "full_data"}, bucket)
report["edition_reference"] = _sha_pair(
sample, {"format": "markdown", "edition": "reference"}, bucket,
)
finally:
_restore_clocks(saved_clocks)
problems.extend(report["format_json"]["problems"])
problems.extend(report["edition_reference"]["problems"])
_set_flags(False, False)
from scripts.calculation_profile_contract import hash_bound_result
from scripts.jyotish_engine import build_professional_report_reference_packet, cmd_full_reading
from scripts.professional_report_reference import _export_args
reading = cmd_full_reading(SimpleNamespace(**sample))
packet = build_professional_report_reference_packet(reading, _export_args(sample), [])
input_hash = packet["calculation_profile"]["input_hash"]
def _time_hash(fast: bool) -> float:
_set_flags(fast, False)
started = time.perf_counter()
hash_bound_result(packet, input_hash)
return time.perf_counter() - started
off = [_time_hash(False) for _ in range(3)]
on = [_time_hash(True) for _ in range(3)]
report["hash_once_s"] = {"off": [round(item, 4) for item in off], "on": [round(item, 4) for item in on]}
report["problems"] = problems
print(json.dumps(report, ensure_ascii=False))
return 1 if problems else 0
if __name__ == "__main__":
raise SystemExit(main())
+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
+374
View File
@@ -0,0 +1,374 @@
"""Byte-identical report shortcuts and the full_data markdown cache."""
from __future__ import annotations
import copy
import hashlib
import json
import os
import re
import threading
import time
from typing import Any
import pytest
from scripts.calculation_profile_contract import hash_bound_result
from scripts.pl9_full_data_export import render_full_data_markdown
from scripts.pl9_language_terms import _thicken_zh_dasha_density, replace_natural_language_term
from scripts.report_perf_flags import (
REFERENCE_CACHE_DEFAULT,
RENDER_FAST_DEFAULT,
report_reference_cache_enabled,
report_render_fast_enabled,
)
from scripts.professional_report_reference import build_professional_report_reference
from tests.test_report_english_edition import CASES, _packet
def _legacy_drop_key_recursive(payload: Any, key_name: str) -> Any:
if isinstance(payload, dict):
return {
key: _legacy_drop_key_recursive(value, key_name)
for key, value in payload.items()
if key != key_name
}
if isinstance(payload, list):
return [_legacy_drop_key_recursive(item, key_name) for item in payload]
return payload
def _legacy_drop_dotted_path(payload: Any, path: tuple[str, ...]) -> Any:
if len(path) == 2 and path[0] == "**":
return _legacy_drop_key_recursive(payload, path[1])
if not path or not isinstance(payload, dict) or path[0] not in payload:
return payload
cloned = dict(payload)
key, *rest = path
if not rest:
cloned.pop(key, None)
return cloned
cloned[key] = _legacy_drop_dotted_path(payload[key], tuple(rest))
return cloned
def _legacy_canonicalize(value: Any) -> Any:
if isinstance(value, dict):
normalized: dict[str, Any] = {}
for key, child in value.items():
normalized_key = key if isinstance(key, str) else f"__{type(key).__name__}__:{key}"
normalized[normalized_key] = _legacy_canonicalize(child)
return normalized
if isinstance(value, (list, tuple)):
return [_legacy_canonicalize(item) for item in value]
return value
def _legacy_payload(result: dict, scope: dict) -> dict:
excluded_top = {"result_hash", "result_binding", "calculation_profile", "calculation_profile_id"} | {
str(key) for key in (scope.get("excluded_top_keys") or ())
}
payload = {key: value for key, value in result.items() if key not in excluded_top}
for raw_path in scope.get("excluded_paths") or ():
parts = tuple(part for part in str(raw_path).split(".") if part)
if parts:
payload = _legacy_drop_dotted_path(payload, parts)
return payload
def _legacy_hash(result: dict, input_hash: str, scope: dict) -> str:
encoded = json.dumps(
_legacy_canonicalize({"input_hash": input_hash, "result": _legacy_payload(result, scope)}),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
)
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
@pytest.fixture(scope="module")
def day_views():
"""One real day packet. ``delivered`` is the bound object; render uses a copy."""
from scripts.jyotish_engine import build_professional_report_reference_packet, cmd_full_reading
from scripts.professional_report_reference import _export_args
os.environ["JYOTISH_REPORT_RENDER_FAST"] = "0"
os.environ["JYOTISH_REPORT_REFERENCE_CACHE"] = "0"
try:
case = CASES["day"]
reading = cmd_full_reading(type("Args", (), dict(case))())
delivered = build_professional_report_reference_packet(reading, _export_args(dict(case)), [])
packet = dict(delivered)
packet["report_version"] = "pl9_personal_long_report.v3"
zh = render_full_data_markdown(copy.deepcopy(packet))
english = copy.deepcopy(packet)
english["report_language"] = "en"
en = render_full_data_markdown(english)
finally:
os.environ.pop("JYOTISH_REPORT_RENDER_FAST", None)
os.environ.pop("JYOTISH_REPORT_REFERENCE_CACHE", None)
return delivered, packet, zh, en
def test_flags_follow_the_environment_at_call_time(monkeypatch) -> None:
monkeypatch.delenv("JYOTISH_REPORT_RENDER_FAST", raising=False)
monkeypatch.delenv("JYOTISH_REPORT_REFERENCE_CACHE", raising=False)
assert report_render_fast_enabled() is RENDER_FAST_DEFAULT
assert report_reference_cache_enabled() is REFERENCE_CACHE_DEFAULT
monkeypatch.setenv("JYOTISH_REPORT_RENDER_FAST", "0")
monkeypatch.setenv("JYOTISH_REPORT_REFERENCE_CACHE", "0")
assert report_render_fast_enabled() is False
assert report_reference_cache_enabled() is False
monkeypatch.setenv("JYOTISH_REPORT_RENDER_FAST", "1")
monkeypatch.setenv("JYOTISH_REPORT_REFERENCE_CACHE", "1")
assert report_render_fast_enabled() is True
assert report_reference_cache_enabled() is True
def test_fast_render_matches_the_legacy_bytes(day_views, monkeypatch) -> None:
_delivered, packet, zh_off, en_off = day_views
monkeypatch.setenv("JYOTISH_REPORT_RENDER_FAST", "1")
monkeypatch.setenv("JYOTISH_REPORT_REFERENCE_CACHE", "0")
zh_on = render_full_data_markdown(copy.deepcopy(packet))
english = copy.deepcopy(packet)
english["report_language"] = "en"
en_on = render_full_data_markdown(english)
assert zh_on == zh_off
assert en_on == en_off
def _both(function, monkeypatch, *args):
monkeypatch.setenv("JYOTISH_REPORT_RENDER_FAST", "0")
off = function(*args)
monkeypatch.setenv("JYOTISH_REPORT_RENDER_FAST", "1")
on = function(*args)
assert on == off
return off
def test_dasha_density_samples_come_from_the_real_render(day_views, monkeypatch) -> None:
_delivered, _packet_view, zh, _en = day_views
lines = [line for line in zh.splitlines() if line.strip()]
# This fictional chart never emits the density sentence. Fill the existing
# sanitizer sentence with lords that do appear in the render.
lords = [token for token in ("木星", "北交点", "土星", "金星") if token in zh]
assert len(lords) >= 2
hit = f"{lords[0]}主大运中的{lords[1]}子运,会把{lords[0]}的自然主题带入当前阶段。"
miss = next(line for line in lines if "子运" not in line and "`" not in line)
quoted = next(line for line in lines if "`" in line)
hit_out = _both(_thicken_zh_dasha_density, monkeypatch, hit + "\n")
assert "这一子运不是单独结论" in hit_out or "这个次子运只承担短周期细化" in hit_out
miss_out = _both(_thicken_zh_dasha_density, monkeypatch, miss + "\n")
assert miss_out == miss
quoted_out = _both(_thicken_zh_dasha_density, monkeypatch, quoted + "\n")
assert "`" in quoted_out
assert quoted in quoted_out
def test_term_samples_come_from_the_real_render(day_views, monkeypatch) -> None:
_delivered, _packet_view, zh, _en = day_views
lines = zh.splitlines()
raw = "Ashtakavarga"
hit = next(line for line in lines if raw in re.sub(r"`[^`]*`", "", line))
miss = next(line for line in lines if line.strip() and raw not in line)
span = next(span for span in re.findall(r"`[^`]*`", zh) if raw not in span)
hit_out = _both(replace_natural_language_term, monkeypatch, hit, raw, "探针词")
assert "探针词" in hit_out or hit_out == hit
if re.search(rf"(?<![A-Za-z0-9_]){raw}(?![A-Za-z0-9_])", re.sub(r"`[^`]*`", "", hit)):
assert "探针词" in hit_out
miss_out = _both(replace_natural_language_term, monkeypatch, miss, raw, "探针词")
assert miss_out == miss
quoted_out = _both(replace_natural_language_term, monkeypatch, span, raw, "探针词")
assert quoted_out == span
def test_fast_hash_matches_the_legacy_algorithm_on_a_real_packet(day_views, monkeypatch) -> None:
packet, _render_packet, _zh, _en = day_views
scope = packet["result_binding"]["binding_scope"]
input_hash = packet["calculation_profile"]["input_hash"]
monkeypatch.setenv("JYOTISH_REPORT_RENDER_FAST", "1")
assert hash_bound_result(packet, input_hash, scope) == _legacy_hash(packet, input_hash, scope)
assert hash_bound_result(packet, input_hash, scope) == packet["result_hash"]
variant = copy.deepcopy(packet)
variant["probe_layer"] = {
"elapsed_seconds": 1.5,
"called_at": "2001-01-01T00:00:00Z",
"cache_created_at": "created",
"cache_expires_at": "expires",
"nested": {"elapsed_seconds": 9, (1, 2): {"called_at": "inside"}},
}
summary = variant.setdefault("ai_and_audit", {}).setdefault("summary", {})
summary["elapsed_seconds"] = 99
summary["kept"] = "yes"
assert hash_bound_result(variant, input_hash, scope) == _legacy_hash(variant, input_hash, scope)
monkeypatch.setenv("JYOTISH_REPORT_RENDER_FAST", "0")
assert hash_bound_result(packet, input_hash) == packet["result_hash"]
class _CountingHandler:
def __init__(self, birth: dict, packet: dict) -> None:
self.birth = birth
self.packet = packet
self.calls = 0
self.fail_once = False
self.delay = 0.0
self.lock = threading.Lock()
def _high_rigor_birth_payload(self, body: dict) -> dict:
payload = dict(self.birth)
if body.get("today"):
payload["today"] = body["today"]
payload["candidate_range"] = body.get("candidate_range")
return payload
def _compute_full_reading_for_thematic(self, _birth: dict) -> dict:
with self.lock:
self.calls += 1
call = self.calls
if self.delay:
time.sleep(self.delay)
if self.fail_once and call == 1:
raise RuntimeError("report_build_failed")
return {"stub": True}
def _body(**extra) -> dict:
body = {
"format": "markdown",
"edition": "full_data",
"include_fact_tables": True,
"languages": ["zh", "en"],
"today": "2026-04-07",
}
body.update(extra)
return body
@pytest.fixture
def cached_report(monkeypatch, tmp_path, day_views):
import scripts.consult_full_data_cache as cache
import scripts.professional_report_reference as reference
def _dir(name: str = "consult_full_data_cache"):
path = tmp_path / name
path.mkdir(parents=True, exist_ok=True)
return path
monkeypatch.setattr(cache, "cache_dir", _dir)
monkeypatch.setattr(reference, "render_full_data_markdown", lambda packet: "en-body" if packet.get("report_language") == "en" else "zh-body")
monkeypatch.setattr(reference, "report_density_packet", lambda _packet: {"facts": 1})
monkeypatch.setattr(reference, "render_dasha_applicability", lambda _packet, language="zh": f"app-{language}")
monkeypatch.setattr(reference, "clean_reader_appendix_markdown", lambda text: text)
packet = day_views[1]
birth = {key: value for key, value in CASES["day"].items() if key != "name"}
handler = _CountingHandler(birth, packet)
class _Engine:
def build_professional_report_reference_packet(self, _reading, _args, _packs):
return copy.deepcopy(packet)
def render_pl9_markdown(self, _packet):
return "reference"
monkeypatch.setenv("JYOTISH_REPORT_REFERENCE_CACHE", "1")
monkeypatch.setenv("JYOTISH_REPORT_RENDER_FAST", "0")
monkeypatch.setenv("GITHUB_SHA", "report-cache-test")
return handler, _Engine(), _dir("report_reference_cache")
def _dumps(result: dict) -> str:
return json.dumps(result, ensure_ascii=False, default=str)
def test_second_call_reuses_the_cached_response(cached_report) -> None:
handler, engine, folder = cached_report
first = build_professional_report_reference(handler, _body(), engine=engine)
second = build_professional_report_reference(handler, _body(), engine=engine)
assert handler.calls == 1
assert _dumps(first) == _dumps(second)
assert list(folder.glob("*.json"))
@pytest.mark.parametrize("extra", [
{"today": "2026-05-01"},
{"languages": ["zh"]},
{"include_fact_tables": False},
{"candidate_range": {"kind": "probe"}},
])
def test_changed_inputs_miss_the_report_cache(cached_report, extra) -> None:
handler, engine, _folder = cached_report
build_professional_report_reference(handler, _body(), engine=engine)
build_professional_report_reference(handler, _body(**extra), engine=engine)
assert handler.calls == 2
def test_github_sha_is_part_of_the_report_cache_key(cached_report, monkeypatch) -> None:
handler, engine, _folder = cached_report
build_professional_report_reference(handler, _body(), engine=engine)
monkeypatch.setenv("GITHUB_SHA", "report-cache-other")
build_professional_report_reference(handler, _body(), engine=engine)
assert handler.calls == 2
def test_json_format_and_disabled_cache_write_nothing(cached_report, monkeypatch) -> None:
handler, engine, folder = cached_report
for path in folder.glob("*.json"):
path.unlink()
build_professional_report_reference(
handler,
{"format": "json", "edition": "full_data", "today": "2026-04-07"},
engine=engine,
)
assert list(folder.glob("*.json")) == []
monkeypatch.setenv("JYOTISH_REPORT_REFERENCE_CACHE", "0")
build_professional_report_reference(handler, _body(), engine=engine)
assert list(folder.glob("*.json")) == []
monkeypatch.setenv("JYOTISH_REPORT_REFERENCE_CACHE", "1")
monkeypatch.setenv("GITHUB_SHA", "unknown")
build_professional_report_reference(handler, _body(), engine=engine)
assert list(folder.glob("*.json")) == []
def test_concurrent_same_key_builds_once(cached_report) -> None:
handler, engine, _folder = cached_report
handler.delay = 0.2
barrier = threading.Barrier(2)
errors = []
def run() -> None:
try:
barrier.wait()
build_professional_report_reference(handler, _body(), engine=engine)
except Exception as exc: # pragma: no cover - recorded for the assertion
errors.append(exc)
threads = [threading.Thread(target=run) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert errors == []
assert handler.calls == 1
def test_failed_build_is_not_cached(cached_report) -> None:
handler, engine, folder = cached_report
handler.fail_once = True
with pytest.raises(RuntimeError):
build_professional_report_reference(handler, _body(), engine=engine)
assert list(folder.glob("*.json")) == []
build_professional_report_reference(handler, _body(), engine=engine)
assert handler.calls == 2
build_professional_report_reference(handler, _body(), engine=engine)
assert handler.calls == 2
def test_report_cache_directory_prunes_old_files(cached_report) -> None:
handler, engine, folder = cached_report
stale = folder / "stale-probe.json"
stale.write_text("{}", encoding="utf-8")
old = time.time() - (3 * 24 * 60 * 60)
os.utime(stale, (old, old))
build_professional_report_reference(handler, _body(), engine=engine)
assert not stale.exists()
@@ -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,