Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a04e590961 | ||
|
|
5cb85800e5 | ||
|
|
9cb0ce1a1a | ||
|
|
cb8004a188 | ||
|
|
f4c53e7f9f | ||
|
|
6a94edf200 | ||
|
|
ad1dc89664 | ||
|
|
69c739fbee | ||
|
|
81e2e1cfed | ||
|
|
6350943152 |
@@ -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
|
||||
|
||||
@@ -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
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` 在每次调用时删掉两天前的文件。没有改两个缓存模块的行为。
|
||||
@@ -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 在多次重算里仍然逐字节相同。
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
@@ -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=(",", ":"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user