Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b7b45657b | ||
|
|
51a65d927f | ||
|
|
dcfc2f15af | ||
|
|
7227b1ed8d |
@@ -1,5 +1,10 @@
|
||||
# BLOCKED
|
||||
|
||||
## 对话额度拆分(2026-09-16,分支 `codex/consultation-session-capacity-20260915`,BUG-732)
|
||||
|
||||
- **无 Docker:** 本机 `docker` 不在 PATH。`npm run test:db` 与 `npm run db:migrate:check`(需要 `SCHEMA_DATABASE_URL` 连 Postgres)未跑。`frontend/tests/database-consultation-session-capacity.test.ts` 在无 Docker 时 skip,**不得写成通过**。替代证据是 SQL 静态合同:额度求和表达式不含 `thinkingText` / `thinkingSections`,物理上限为 `length(elem::text)`,算式写在迁移注释里。
|
||||
- **详情接口体积(5.3,已量,不改接口):** 用三域本命夹具(每轮正文 4,000 汉字 + 思考 4,000 汉字 + 实测 `thinkingSections` + 典型三个 receipt)构造 `GET /api/sessions/[id]` 的 `{ session }` JSON。19 轮 UTF-8 **562,240 B(0.536 MiB)**;50 轮 **1,479,034 B(1.411 MiB)**,约为 2.63 倍。1.4 MiB 在 2 vCPU 上打开长会话会偏沉,分页 / 按需加载历史不在本单(任务书 §9)。PostgreSQL `length()` 按字节计,汉字正文 4,000 字 ≈ 12,000 字节,线上按字节撞 200,000 额度会早于「50 轮汉字」;50 轮数字是任务书字符口径对照。
|
||||
|
||||
## 只读页验收修复:镜像与浏览器(2026-09-16,分支 `codex/readonly-pages-fix-20260916`)
|
||||
|
||||
- **无 Docker:** 无法验证 API 镜像里 `COPY vendor` 与容器内 `node --version`。不得写成通过。
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-16 — 这段对话写满之前,思考过程不再占用额度
|
||||
|
||||
普通咨询里,模型内部的思考过程不再算进「这段对话已写满」的额度。额度仍按你看得到的正文计算,能问的轮次大约从十几轮提到约五十轮。写满时还是请开一个新对话,提示不变。Skill 版本不变。
|
||||
|
||||
## 2026-09-16 — 对话写满后开新对话会静默带上之前的摘要
|
||||
|
||||
普通咨询写满、点「开新对话」之后,服务端会把上一场的会话摘要拷进新对话,界面上看不出差别。进不了窗口的更早几轮,模型会看到省略说明,不再当成没发生过。Skill 版本不变。
|
||||
|
||||
@@ -11380,3 +11380,35 @@
|
||||
- 相关记录:BUG-464、BUG-555
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-732 | 对话额度把思考文本算进去,十几轮就「已写满」
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-15
|
||||
- 最近更新:2026-09-16
|
||||
- 影响面:`append_consultation_question`、`POST /api/consult` 的 `session_full`、咨询会话详情 `GET /api/sessions/[id]`
|
||||
- 用户现象:普通咨询问大约十几轮就提示「这段对话已写满,开个新对话继续吧」。200 条消息那档永远碰不到。
|
||||
- 触发条件:继续往同一段咨询会话里发问;助手消息带有 `thinkingText` / `thinkingSections`。
|
||||
- 根因:BUG-464 立下的 200,000 字符上限身兼二职却两职都没做好。求和把用户读不到的 `thinkingText`、`thinkingSections` 算进去,却不算同样入库的 `techniqueTruth` / `workflowReceipt` / `agentExecutionReceipt`。这不是回归,是那条上限从一开始就混用了「对话有多长」和「这一行有多大」。
|
||||
- 修复:新迁移 `CREATE OR REPLACE` 该函数。对话额度仍是 200,000,只累加 `elem->>'text'`。另加物理上限 `sum(length(elem::text))`,算式 50 轮 ×(正文约 4,000 + 思考 4,000 + 分节 3,000 + 三个 receipt 约 3,000)≈ 700,000,取 1,000,000。两档都返回既有 `session_full`。签名、返回列、error_code、advisory lock、`request_id` 幂等、200 条上限、单条 16,000 字校验均未改。
|
||||
- 验证:`frontend/tests/consultation-session-capacity.test.ts` 锁定额度求和不含 `thinkingText` / `thinkingSections`、物理上限算式、签名与 `session_full`。`frontend/tests/database-consultation-session-capacity.test.ts` 用真实 Postgres 覆盖思考不占额度、短正文+大 receipt 撞物理上限、额度边界不先撞物理上限;本机无 Docker,该文件 skip,不得写成通过。既有幂等 / 满员用例未改。
|
||||
- 防复发:会话上限必须分成两条各司其职的口径:面向用户的对话额度只数用户读得到的正文;面向存储的物理上限必须把整条消息 JSON 算全。新增会存进 `messages` 的字段时,必须明确它进哪一条,不得默认落进对话额度。
|
||||
- 相关记录:BUG-464
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-733 | 校正记忆化 golden 对全精度浮点做整体 `==`,跨机门禁靠运气绿
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-15
|
||||
- 最近更新:2026-09-16
|
||||
- 影响面:`tests/test_rectification_engine_memoization.py`、`tests/golden/rectification_engine_memoization_v1.json`(文件未改)
|
||||
- 用户现象:用户看不见。门禁 glob `tests/test_rectification_*.py` 在 `CORE_PYTEST_TARGETS` 里,换一台机器或基础镜像会因 `score` / `margin_percent` 尾数差 1.0e-4~1.1e-3 而红,CI 绿只是碰巧和生成 golden 的那台舍入一致。
|
||||
- 触发条件:`test_score_candidates_matches_baseline_golden` 把 live payload 与 golden 做整体 `==`。
|
||||
- 根因:golden 存了全精度浮点并整体相等。这是 BUG-712 的同一形状。BUG-712 的防复发只落在 `ephemeris_events` 那一处(量化到 6 位再比),没有仓库级守卫,BUG-721 新写的 rectification golden 重蹈覆辙。
|
||||
- 修复:只改测试,不动 `scripts/`。主证据改成同进程差分:同一批 static contexts,A 带四层缓存、B 把 `ashtakavarga_result` / `shadbala_result` / `vimshottari_timeline` / `narayana_periods` 置 `None`,`compute_event_candidate_rows` 输出严格相等。golden 改成分档比较:离散字段严格相等;浮点用实测最大漂移 1.1e-3 推出的容差(绝对 2e-3 与相对 5e-4 取更宽者)。禁止调用 `write_golden()` 更新那份 JSON。
|
||||
- 验证:同进程 A/B 逐字相等,且 B 的 `calc_shadbala` 次数显著高于 A(本机 6 vs 0)。反向:把某一层缓存换成全零 shadbala 对象后不再相等。golden 分档比较通过;把 golden 浮点改 1e-2 必须红;把离散字段 `time` 改掉必须红。`pytest tests/test_rectification_engine_memoization.py` 与 `pytest tests/test_rectification_*.py` 0 failed。golden JSON `git diff` 无改动。
|
||||
- 防复发:任何 golden 比较都不得对浮点做整体 `==`;浮点必须量化或带容差,容差数值要有实测依据并写在注释里;能在同进程内做差分证明的命题,不得用跨机 golden 代替。
|
||||
- 相关记录:BUG-712、BUG-721
|
||||
- 复发自:BUG-712
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# PROGRESS · 对话上限一半被思考文本吃掉(2026-09-15)
|
||||
|
||||
工作树:`.worktrees/consultation-session-capacity-20260915`
|
||||
分支:`codex/consultation-session-capacity-20260915`
|
||||
任务书基线:`6b3248bf`;开工时 `origin/staging` = **`11893c7f`**。
|
||||
修复提交:`11829cf7`。
|
||||
本机 Windows。无 Docker。
|
||||
|
||||
未改 `frontend/src/app/api/consult/route.ts`、`page.tsx`、Skill。未 bump Skill。BUG 号核对:该 SHA 最大号仍是 **BUG-720**,本单用预占 **BUG-732**,无冲突。
|
||||
|
||||
| 任务 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 5.1 对话额度只数 `text` | 完成(运行时 DB 为环境缺口) | 新迁移 `20260916010000_consultation_session_capacity.sql` `CREATE OR REPLACE`;`v_chars` 只 `sum(length(elem->>'text'))` |
|
||||
| 5.2 物理上限 `length(elem::text)` | 完成(同上) | 算式见下;两档都返回 `session_full` |
|
||||
| 5.3 详情接口体积 | 完成(夹具量过) | 见下表;1.41 MiB 写入 `BLOCKED.md` 观察,本单不改接口 |
|
||||
| 5.4 BUG-732 | 完成 | 关联 BUG-464;不是回归 |
|
||||
|
||||
## 实现要点
|
||||
|
||||
- 只新增一份迁移。函数签名、返回列、`error_code` 取值、advisory lock、`request_id` 幂等、200 条上限、`char_length(v_text) > 16000` 均未改。
|
||||
- 物理上限算术(迁移注释原文):50 rounds × (~4,000 body + ~4,000 thinkingText + ~3,000 thinkingSections + ~3,000 receipts) ≈ 700,000。Headroom → 1,000,000。
|
||||
- 实测 receipt(典型三域本命:12 行技法审计 + workflow + agentExecutionReceipt)JSON **1,738 字符 / 1,950 字节**,小于任务书 3,000,**不调高** 1,000,000。
|
||||
- 实测 `thinkingSections` JSON 字符:1 域 1,246、2 域 1,968、3 域 2,702(任务书 1,521 / 2,243 / 2,977,同量级,计划文案有漂移)。
|
||||
- 未删、未截断任何已存字段。
|
||||
|
||||
## 详情 JSON 体积(5.3)
|
||||
|
||||
夹具:每轮用户提问 100 汉字 + 助手正文 4,000 汉字 + 思考 4,000 汉字 + 三域 `natalConsultationThinkingPlan` + 上面那份典型 receipt。`GET /api/sessions/[id]` 的 `{ session }`(`sessionSelect` 含 `messages`)。
|
||||
|
||||
| 口径 | 轮次 | UTF-8 字节 | MiB |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| 改前(旧合并额度大约卡住的地方) | 19 | 562,240 | 0.536 |
|
||||
| 改后(任务书字符口径约 50 轮) | 50 | 1,479,034 | 1.411 |
|
||||
|
||||
50 / 19 ≈ **2.63**。1.411 MiB 在 2 vCPU 上打开长会话会偏沉,观察项进 `BLOCKED.md`;分页不在本单。
|
||||
|
||||
PostgreSQL `length()` 按字节,与 BUG-464 相同。4,000 汉字正文 ≈ 12,000 字节,按字节撞 200,000 会早于「50 轮汉字」。上表 50 轮是任务书字符口径对照,不是汉字会话在线上能存到的轮数。物理上限 1,000,000 字节在这组夹具上仍晚于对话额度触发。
|
||||
|
||||
## 既有断言改动
|
||||
|
||||
| 文件 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| `database-local-business.test.ts` 迁移已应用列表 | 最末 `20260915010000_rectification_touch_chat_session.sql` | 另加 `20260916010000_consultation_session_capacity.sql` 及「不得复制进 db/migrations」 | 新迁移必须被 runner 应用到 |
|
||||
|
||||
未改幂等 / advisory lock / `session_missing` / 200 条满员三条既有用例。未改 `chat-session-authority.test.ts`(仍读 20260901010000 原文件,原文件未改)。
|
||||
|
||||
## 测试
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `./node_modules/.bin/tsc --noEmit` | 0 错 |
|
||||
| `npx tsx --test tests/consultation-session-capacity.test.ts` | 6 pass / 0 fail |
|
||||
| `npx tsx --test tests/consultation-*.test.ts tests/chat-session-*.test.ts` | 220 tests / 208 pass / **12 fail**:全部是 Windows `SKILL.md` symlink `EPERM`(`consultation-agentic-runtime`、`consultation-birth-time-mode`、`consultation-workflow-request` 整文件,以及 methodology 读 live skill 的 9 条)。与本单无关,既有环境缺口。本单 6 条与 `chat-session-*` 全绿 |
|
||||
| `npx eslint`(本单三个测试文件 `--max-warnings 0`) | 0 error / 0 warning |
|
||||
| `npm run lint` | **0 error** / 119 warning(既有,未动) |
|
||||
| `npm run db:migrate:check` | **blocked**:`SCHEMA_DATABASE_URL is required` |
|
||||
| `npm run test:db` | **blocked**:39 tests / 7 pass / 27 fail / 5 skip。失败均为 `spawnSync docker ENOENT` 或 Windows symlink EPERM。本单 `database-consultation-session-capacity` **skip**(`docker unavailable on this host`),不得写成通过 |
|
||||
| 全量 `npx tsx --test tests/*.test.ts` | 3089 tests / 3001 pass / 73 fail / 15 skip。失败为无 Docker `ENOENT`、Windows `SKILL.md` symlink `EPERM`、以及 `G:\\G:\\` 路径加倍,与既有环境缺口同类。本单 6 条静态合同在该套件里通过;DB 用例 skip |
|
||||
|
||||
## 收尾限制
|
||||
|
||||
- 运行时 Postgres 合同本机未跑。有 Docker 的验收机必须跑 `npm run test:db`。
|
||||
- 详情接口未分页。50 轮夹具 1.41 MiB,见 `BLOCKED.md`。
|
||||
@@ -0,0 +1,64 @@
|
||||
# PROGRESS · 等价 golden 跨机不稳(2026-09-15 / 执行 2026-09-16)
|
||||
|
||||
- 执行分支:`codex/rectification-engine-memoization-fix-20260915`
|
||||
- 工作树:`.worktrees/rectification-engine-memoization-fix-20260915`
|
||||
- 任务书:`docs/tasks/TASK-rectification-engine-memoization-fix-20260915.md`
|
||||
- 开工 `HEAD` = `origin/staging` = `51a65d92`(用户核对时是 `7227b1ed`,其后合入了 BUG-732)
|
||||
- BUG:`BUG-733`(复发自 BUG-712;落库时最大号 732)
|
||||
|
||||
## 开工前置
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `git status -sb` 第一行 | `## codex/rectification-engine-memoization-fix-20260915...origin/staging` |
|
||||
| `HEAD` | `51a65d92` |
|
||||
| BUG_HISTORY | 最大号 **BUG-732**。本单预占 **BUG-733**。读完 BUG-712 / BUG-721 |
|
||||
| 本机 Python | 主仓 `.venv` 是 25 字节指针文件。本 worktree 用 Anaconda 3.11.7 `--system-site-packages` 建真实 venv,复用 `swisseph 20230604`,未升级依赖 |
|
||||
| 先复现 golden | `test_score_candidates_matches_baseline_golden` **本机绿**。live 与 golden 逐字相同:`score` 8.6274 / 8.6227 / 8.1701,`margin_percent` 5.3006。验收机上的 1.1e-3 漂移在这台 Windows / Anaconda 3.11.7 上复现不出来——golden 就是同类机器生成的。仍按任务书改比较方式,不调用 `write_golden()` |
|
||||
|
||||
未改 `docs/tasks/README.md` 状态列。未推 staging。未改任何 `scripts/` 实现。未改 `tests/golden/rectification_engine_memoization_v1.json`。
|
||||
|
||||
## 做了什么
|
||||
|
||||
| 条 | 做法 |
|
||||
| --- | --- |
|
||||
| 5.1 | 新增 `test_cached_static_context_matches_uncached_fallback`:同一请求、同一进程,A=带四层缓存的 static contexts,B=四键置 `None`。`compute_event_candidate_rows` 输出 `==`。调用计数:A 的 `calc_shadbala` = 0,B = 6(3 候选 × 2 事件)。反向:`test_poisoned_static_cache_diverges_from_live_rows` 把第一候选的 `shadbala_result` 换成全零对象,分数从 12.5125 变成 12.6125,断言不再相等 |
|
||||
| 5.2 | `test_score_candidates_matches_baseline_golden` 改成分档比较。整数 / 布尔 / 字符串 / 列表身份严格相等;浮点容差 `max(2e-3, 5e-4 * \|expected\|)`,注释写明来自实测最大漂移 1.1e-3。反向:`score += 1e-2` 红;`time = "99:99"` 红 |
|
||||
| 5.3 | 未调用 `write_golden()`。`git diff` 里该 JSON 无改动 |
|
||||
| 5.4 | 六份检查结论见下表。同类问题只记录不修 |
|
||||
| 5.5 | `docs/BUG_HISTORY.md` 新增 BUG-733,复发自 BUG-712。证据是 5.1 同进程差分 + 两次反向验证,不是「现在绿了」 |
|
||||
|
||||
## 5.4 `tests/golden/` 检查
|
||||
|
||||
| 文件 | 全精度浮点整体 `==` | 在哪个测试里比 | 怎么比 |
|
||||
| --- | --- | --- | --- |
|
||||
| `consultation_contract_keypaths_v1.json` | 无 | `tests/test_consultation_contract_golden.py` | 只存 keypath 与 JSON 类型(`kind=keypath_types_only`)。值不入库。live 是 keypath 超集 + 类型相同 |
|
||||
| `ephemeris_events_raman_20260915_90d.json` | 无(BUG-712 已改) | `tests/test_ephemeris_events.py` | `longitude` / `speed_longitude` 量化到 6 位再 `==`;`kind` / `date` / `body` / 星座字段严格相等。文件里仍存全精度,但测试不再整体 `==` |
|
||||
| `golden_cases.json` | 无 | `tests/test_cli_smoke.py::test_full_reading_golden_cases_cover_user_ready_output` → `tests/run_golden_cases.py` | 路径非空、模块计数、整数不变量 `ashtakavarga_sav_total == 337`、日期覆盖。不是把整份引擎浮点输出整体 `==` |
|
||||
| `qizheng_stem_branch_19900409.json` | 无(比较已量化) | `tests/test_qizheng_chart_engine.py` | 离散字段(宫、宿、ketuMode)严格相等;`siderealLon` / `mansionDegree` 用 `round(..., 2)`。**文件本身仍存全精度浮点**(如 `177.94225865978694`)。若有人新写 `assert result == golden` 会变成 BUG-712 同类。本单只记录不修 |
|
||||
| `rectification_engine_memoization_v1.json` | **本单之前有** | `tests/test_rectification_engine_memoization.py::test_score_candidates_matches_baseline_golden` | 改前:`candidate_scores` 与 `decision_receipt` 整体 `==`。改后:离散严格、浮点容差。文件未重建 |
|
||||
| `upstream_sync2/` | 无整体浮点 `==` | 见分项 | `relationship_einstein.json`:`test_upstream_sync2_goldens.py` 只比键与类型。`consultation_marriage_evidence_snapshot.json`:shape。`consultation_marriage_1990_fictional.json`:`test_relationship_event_class_evidence.py` 比离散 Dasha 字符串。`pl9_time_system_table.baseline.md`:`test_full_reading_conditional_dashas.py` 比对 markdown 表行文本(前 5 行 `==`),不是 JSON 浮点。`pl9_time_system_table.md` 与 `pl9_export_timing.json`(含 `seconds: 8.647`)**没有任何测试读取** |
|
||||
|
||||
同类观察(不修):qizheng golden 文件仍存全精度;ephemeris golden 文件仍存全精度(测试已量化);`upstream_sync2/pl9_export_timing.json` 是无引用的浮点秒数。
|
||||
|
||||
## 测试
|
||||
|
||||
| 套件 | 结果 |
|
||||
| --- | --- |
|
||||
| `tests/test_rectification_engine_memoization.py` | **14 passed**(原 10 + 新增 4) |
|
||||
| `tests/test_rectification_*.py` | **186 passed, 0 failed**(14.42s) |
|
||||
|
||||
命令使用 `.\.venv\Scripts\python.exe`(任务书写的 `.venv/bin/python` 在 Windows 不存在)。
|
||||
|
||||
未改任何既有断言的期望值。新增 4 条:同进程差分、缓存毒化反向、浮点 1e-2 反向、离散字段反向。原 golden 断言从整体 `==` 改成分档,不是放宽离散字段。未跑 `run_quality_gate.py --profile quick`:该 profile 在 pytest 之后会跑 `npm test`,本机 Windows 缺 `tsx` PATH(与 BUG-721 进度记录同一缺口),本单未改前端。
|
||||
|
||||
## CHANGELOG
|
||||
|
||||
未改。纯测试比较方式,用户可见打分不变。
|
||||
|
||||
## 偏离
|
||||
|
||||
- 任务书写 `.venv/bin/python`,本机换成 `.\.venv\Scripts\python.exe`。
|
||||
- 本机未能复现验收机上的 golden 红灯(舍入与生成机一致)。仍改比较方式,并保留验收机测到的 1.1e-3 作为容差依据。
|
||||
- 未 `git push origin HEAD:staging`。
|
||||
- 5.4 按让步只检查并记录,未加仓库级自动扫描守卫。
|
||||
@@ -240,9 +240,10 @@
|
||||
| `TASK-rectification-request-dossier-cache-20260915.md` | — | **低风险单,串行在 failure-attribution 之后(同改 `route.ts`)**:一轮 Agent 对话实测取 3.44 次整份 Case 档案(点选题 2.07 次),全仓约 40 个调用点、请求内零缓存;档案是「最近 50 轮 turns + 全部 evidence + 合成收据」的大 jsonb。做法是包装 `accounting` 客户端做**写即失效**的请求作用域缓存(两个只读投影命中缓存,其余任何 RPC 先清空再转发),**零调用点改动**。不得做成「请求内只读一次」——档案在请求内会变。BUG 段 726 | 待领取 | — |
|
||||
| `TASK-consultation-external-evidence-cache-20260915.md` | `PROGRESS-consultation-external-evidence-cache-20260915.md` | **普通聊天性能单(Python;2026-09-15 产品拍板改为排在 api-server-decomposition 之前)**:每轮每域同步等外网,cProfile 前三名全是 `api.vedastro.org` 的 HTTPS 往返(0.801 + 0.786 + 0.206 s),本地 swisseph 只有 0.022 s。三个护栏数字凑不齐:前台等 1.5 s、后台跑 8 s、线程池只有 2 个 worker,且超时**不 cancel** → 每 4 秒一轮就长期饱和,之后每轮白等再拿 `official_blocked`(BUG-727)。另 `western_evidence_packet` 122 KB 前端零读取点(BUG-728)。**产品定案**:按「出生数据+岁差+交点+UTC 日期」缓存(与引擎 `_official_snapshot_reference_date` 同键,否决自定 TTL),同日 0 等待 / 跨日先用旧的(≤7 天)后台刷新 / `daily_starlanguage` 要求当天 / 冷启动才走 1.5 s。**不许「干脆不调」——那会重开 BUG-301。** 另含 staging 单域耗时实测单(代码注释里的 21 s 与本机 0.5 s 差 40 倍,三域上限就是从它推的)。BUG 段 727–728 | 待验收 | `codex/consultation-external-evidence-cache-20260915` |
|
||||
| `TASK-consultation-context-memory-20260915.md` | `PROGRESS-consultation-context-memory-20260915.md` | **记忆三缺口(TS,可并行)**:历史超预算时从最老整轮丢弃,`droppedCount` 算了却**全仓零读取点**,模型不知道少看了几轮——单条截断有「省略 N 字」标记,整轮丢弃没有(BUG-729,BUG-555 防复发只写了「头部截断」所以漏网);写摘要阈值写死 16,000,历史预算却是 `clamp((窗口−60k)×1.5, 4k, 40k)`,窗口 < **70,667** 时预算低于阈值 → 每轮静默丢(BUG-730,后台上架中等窗口模型即触发);写满时服务端存着摘要,`continueInNewChat` 只带问题不带摘要,而 `context_summary` 根本不在任何会话接口的列里(BUG-731)。**产品定案:静默继承**,且摘要文本永远不许由客户端提供(`chatSessionCreateSchema` 只收来源会话 uuid)。BUG 段 729–731 | 待验收 | `codex/consultation-context-memory-20260915` |
|
||||
| `TASK-consultation-session-capacity-20260915.md` | — | **对话上限单(一份迁移,可并行;不碰 route.ts)**:`append_consultation_question` 的 200,000 字符额度里,`thinkingText`(≤4,000) + `thinkingSections`(实测 1,521/2,243/2,977) 占一半以上,而 `techniqueTruth`/`workflowReceipt`/`agentExecutionReceipt` 照样入库却不计入——同一条上限身兼二职且两职都没做好,约 **19 轮** 就「已写满」(200 条那档永远碰不到)。**产品定案:思考文本不计入**,额度只数用户读得到的正文(约 19 → 约 50 轮),另设一条按 `length(elem::text)` 把全部字段算全的物理上限(算式取 1,000,000,写进迁移注释)护住数据库行;两档都返回同一个 `session_full`。保留 advisory lock / 幂等 / 满员拒绝(BUG-464 防复发)。BUG 段 732 | 待领取 | — |
|
||||
| `TASK-consultation-session-capacity-20260915.md` | `PROGRESS-consultation-session-capacity-20260915.md` | **对话上限单(一份迁移,可并行;不碰 route.ts)**:`append_consultation_question` 的 200,000 字符额度里,`thinkingText`(≤4,000) + `thinkingSections`(实测 1,521/2,243/2,977) 占一半以上,而 `techniqueTruth`/`workflowReceipt`/`agentExecutionReceipt` 照样入库却不计入——同一条上限身兼二职且两职都没做好,约 **19 轮** 就「已写满」(200 条那档永远碰不到)。**产品定案:思考文本不计入**,额度只数用户读得到的正文(约 19 → 约 50 轮),另设一条按 `length(elem::text)` 把全部字段算全的物理上限(算式取 1,000,000,写进迁移注释)护住数据库行;两档都返回同一个 `session_full`。保留 advisory lock / 幂等 / 满员拒绝(BUG-464 防复发)。BUG 段 732 | 待验收 | `codex/consultation-session-capacity-20260915`(BUG-732);`test:db` 环境缺口 |
|
||||
| `TASK-freeze-metric-change-20260915.md` | — | **规则单(后面两单的前置,无 BUG 号)**:两条增长冻结余量都用完(`page.tsx` 1,951/1,951 余 **0**;`jyotish_api_server.py` 11,334/11,363 余 **29**),冻结从「逼新代码往外走」退化成「拦路」。实证:`page.tsx` 行数砍 59% 但 `Home()` 的 `useState` 从 56 涨到 **66**(拆的是代码不是状态);api server **225 个类方法只有 12 处真碰 HTTP 上下文**,4 处 `__new__` 伪造空壳就是这么来的。**产品拍板换口径**:主门改成「`Home()` 的 useState/useRef 不得增长」与「类方法数 + `__new__` 计数不得增长」,行数降级为粗护栏;**同时推翻 §6「参数式 hook 内部保持 0 个 React hook」**(那正是状态搬不走的原因)。改 `AGENTS.md` §6 + 两个合同测试,不碰业务代码 | 待领取 | — |
|
||||
| `TASK-home-state-lowering-20260915.md` | — | **page.tsx 状态下沉第一簇(串行在 freeze-metric-change + C2 + R3 之后)**:66 个 state 里 `rectification*` 占 **15** 个,而它们服务的 `<ConversationalBirthTimeRectification>` 本来就是 `dynamic()` 懒加载子树、挂着 24 个 props;`useRectificationSurface` 要解构约 56 个参数。把这簇搬进子树,`Home()` 的 useState 从 66 降到 ≤ 53。**零行为变化**;第一步必须先把 15 个逐个分类(只服务子树 / 外壳也要读)。产品否决了 Context Provider 与外部 store 两条路。不占 BUG 号 | 待领取 | — |
|
||||
| `TASK-rectification-engine-memoization-fix-20260915.md` | — | **验收修复单(只改测试,一行实现不许动)**:BUG-721 的实现**等价性成立**(我在改前 `6b3248bf` / 改后 `e4788dfc` 同机跑同一 payload,`candidate_scores` 逐字相同),9 条计数断言全过;但等价 golden 在本机复现不出来——4 处浮点尾数差(score 1.0e-4 ×2、`margin_percent` 1.1e-3 ×2)。**复发自 BUG-712**(「不得对全精度浮点做整体 `==`」,那一单只落在 ephemeris 一处)。而 `tests/test_rectification_*.py` 在 `CORE_PYTEST_TARGETS` 里,**staging 门禁靠机器舍入碰巧一致才是绿的**。修法:主证据换成**同进程差分**(把 static context 的四个缓存键置 `None` 即可回退旧路径,A/B 严格相等),golden 降为离散字段严格相等 + 浮点带容差(容差按实测 1.1e-3 推);**禁止重建 golden 来「修」**。另含六份 golden 的仓库级排查。BUG-733 | 待领取 | — |
|
||||
|
||||
## 命名与归档
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
# TASK · 验收修复单:等价 golden 跨机不稳,门禁靠运气绿
|
||||
|
||||
- 日期:2026-09-15
|
||||
- 基线 commit:`origin/staging` @ `e4788dfc`(实现落在 `53a37ce9`)
|
||||
- 执行分支:`codex/rectification-engine-memoization-fix-20260915`
|
||||
- 来源:Claude 对 `TASK-rectification-engine-memoization-20260915`(BUG-721)的验收
|
||||
- 落点:`tests/test_rectification_engine_memoization.py`、必要时 `tests/golden/`、`docs/BUG_HISTORY.md`
|
||||
- 规模:只改测试。**一行实现代码都不许动。**
|
||||
|
||||
---
|
||||
|
||||
## 1. 验收结论摘要
|
||||
|
||||
BUG-721 的**实现是对的,等价性成立**,我独立复核过。门禁实测(基线 `11893c7f` → 头 `e4788dfc`,含 R1/R2/R3 三个提交):
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `tsc --noEmit` | 0 错 |
|
||||
| `npm run lint` | **0 error** / 119 warning(全部既有) |
|
||||
| 全量前端套件 | 基线 3291(fail 31)→ 头 3304(fail 31),**失败清单逐条一致** |
|
||||
| `next build` | exit 0;`/` 仍 `○ Static` |
|
||||
| 首屏 JS gzip-9 | 基线 130,872 B → 头 130,872 B,**0.00%**(两侧同一种量法) |
|
||||
| `tests/test_rectification_*.py` | **1 failed, 181 passed** |
|
||||
|
||||
唯一那条失败就是本单要修的:`test_score_candidates_matches_baseline_golden`。
|
||||
|
||||
**它不是实现改坏了。** 我在改前(`6b3248bf`,与 golden 自称的 `a8d29d1b` 代码相同)和改后(`e4788dfc`)两个工作树上跑同一份 payload:
|
||||
|
||||
| | 12:00 | 12:01 | 12:02 |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| 本机 **改前** | 8.6273 | 8.6226 | 8.1701 |
|
||||
| 本机 **改后** | 8.6273 | 8.6226 | 8.1701 |
|
||||
|
||||
**改前改后逐字相同。** 本单的其余 9 条断言(shadbala / ashtakavarga / vimshottari / narayana 各等于候选分钟数;过境盘等于去重后事件日期数;探针默认 1 次、refresh 时 2 次)全部通过。
|
||||
|
||||
## 2. 事故实证
|
||||
|
||||
失败来自 golden 文件与**任何一台机器**的实际输出对不上:
|
||||
|
||||
| 字段 | 本机(改前=改后) | `tests/golden/rectification_engine_memoization_v1.json` | 差 |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| `candidate_scores[0].score` | 8.6273 | 8.6274 | 1.0e-4 |
|
||||
| `candidate_scores[1].score` | 8.6226 | 8.6227 | 1.0e-4 |
|
||||
| `decision_receipt.margin_percent` | 5.2995 | 5.3006 | 1.1e-3 |
|
||||
| `decision_receipt.gates.diagnostic_quality.margin_percent` | 5.2995 | 5.3006 | 1.1e-3 |
|
||||
|
||||
一共 4 处,全是浮点尾数。`candidate_scores[*].score` 本身已经被引擎 `round(..., 4)` 过,差值正好是那一位的 1 ulp——也就是求和顺序/libm 舍入的跨机差异,不是算法差异。
|
||||
|
||||
**后果是实打实的**:`tests/test_rectification_*.py` 这个 glob 在 `scripts/run_quality_gate.py` 的 `CORE_PYTEST_TARGETS` 里(注释原文:「Auto staging gate is `--profile quick`…so a stale window_scan assertion in this glob stayed red on origin/staging until listed here」)。也就是说 **staging 自动门禁会跑这条断言**。今天它在 CI 上绿,只是因为那台机器的舍入和执行方的机器一致;换一台机器、换一个基础镜像、换一次 libm 版本就会红。**这条门禁现在是靠运气绿的。**
|
||||
|
||||
## 3. 根因
|
||||
|
||||
golden 存了全精度浮点并做整体 `==`。这正是 **BUG-712** 的形状,那条记录的防复发一字不差地写着:
|
||||
|
||||
> 不得对全精度浮点做整体 `==`。测试不得写 golden。
|
||||
|
||||
BUG-712 修的是 `ephemeris_events` 的 golden(`longitude` / `speed_longitude` 量化到 6 位再比,非浮点字段保持严格相等)。那一单的范围写死在星历端点上,新写的这份 rectification golden 是同样的写法,没被扫到。
|
||||
|
||||
更深一层:**golden 本来就不是证明「记忆化没改结果」的合适工具。** golden 证明的是「今天的输出等于某台机器某一天的输出」,中间夹了一个与被测命题无关的变量(机器)。被测命题其实可以在**同一个进程里**证明——见 §5.1。
|
||||
|
||||
## 4. 决策记录
|
||||
|
||||
产品 2026-09-15 授权本单:
|
||||
|
||||
1. **不回滚 BUG-721 的实现。** 等价性已由改前/改后同机对比独立证实,实现留在 staging 上。
|
||||
2. **本单只改测试。** 一行 `scripts/` 下的实现代码都不许动——不得为了让断言通过去改业务代码(这是产品的既定偏好)。
|
||||
3. **不得用「重新生成 golden」来修。** 在本机重跑一次 `write_golden()` 能让测试变绿,但那只是把不稳定性换个方向藏起来,下一台机器照样红。**这条是硬红线,见 §5.3。**
|
||||
4. **防复发升级为仓库级。** BUG-712 的那句话从单点措施提升成对所有 golden 生效的规则,并配一条能自动发现违例的守卫。
|
||||
|
||||
## 5. 任务分解
|
||||
|
||||
### 5.1 把主证据换成同进程差分(首要,也是本单真正的价值)
|
||||
|
||||
实现已经把四层不变量放进 static context 的具名键(`ashtakavarga_result` / `shadbala_result` / `vimshottari_timeline` / `narayana_periods`),而每个消费点都有 `if result is None:` 的回退分支。**这意味着记忆化可以在同一个进程里关掉**:把 context 里那四个键置 `None`,消费点就会退回逐次计算的老路径。
|
||||
|
||||
新增一条断言:同一份请求、同一个进程,
|
||||
- A:正常的 static contexts(带四层缓存)
|
||||
- B:把那四个键全部置 `None` 的同一批 contexts(回退到逐次计算)
|
||||
|
||||
断言 `compute_event_candidate_rows` 在 A / B 下的输出**严格逐字相等**。
|
||||
|
||||
这条证明不经过任何跨机变量,是比 golden 强得多的等价证据。
|
||||
|
||||
- 验收:新断言通过;人为把某一层的缓存值替换成错误对象后必须红(贴反向验证,证明这条断言不是恒为真)。
|
||||
- 验收:A / B 两条路径确实走了不同分支(用调用计数确认 B 的 `calc_shadbala` 次数显著高于 A),否则等于什么都没测。
|
||||
|
||||
### 5.2 golden 断言改成分档比较
|
||||
|
||||
保留 golden(它仍有价值:能发现跨版本的大幅漂移),但改比较方式:
|
||||
|
||||
| 字段类别 | 比较方式 |
|
||||
| --- | --- |
|
||||
| 离散字段(`time`、`supporting_event_ids`、`conflicting_event_ids`、各 gate 布尔、`overall_confidence`、`representative_time`、排序) | **严格相等** |
|
||||
| 浮点字段 | 容差比较 |
|
||||
|
||||
容差必须由实测漂移推出来,不得拍脑袋:本次实测最大漂移 **1.1e-3**(`margin_percent`)。取一个有余量但仍能发现真回归的值(建议绝对 2e-3 与相对 5e-4 取更宽者),并把「这个数怎么来的」写进测试注释。
|
||||
|
||||
- 验收:把 golden 里任意一个浮点改动 1e-2,断言必须红(贴反向验证)。
|
||||
- 验收:把 golden 里任意一个离散字段改掉,断言必须红。
|
||||
- 验收:`.venv/bin/python -m pytest tests/test_rectification_*.py` 在本机 **0 failed**。
|
||||
|
||||
### 5.3 不得重建 golden
|
||||
|
||||
`write_golden()` 这个辅助函数留着没问题,但**本单不得调用它更新那份 golden 文件**。若执行方认为必须重建,要在进度记录里写明理由并说明为什么不是在掩盖跨机不稳——默认答案是「不重建」。
|
||||
|
||||
- 验收:`git diff` 里 `tests/golden/rectification_engine_memoization_v1.json` **无改动**(若有改动,按上一条给出理由)。
|
||||
|
||||
### 5.4 仓库级防复发守卫
|
||||
|
||||
`tests/golden/` 下现在共 6 份:`consultation_contract_keypaths_v1.json`、`ephemeris_events_raman_20260915_90d.json`、`golden_cases.json`、`qizheng_stem_branch_19900409.json`、`rectification_engine_memoization_v1.json`、`upstream_sync2/`。
|
||||
|
||||
- 逐份检查有没有「全精度浮点 + 整体 `==`」的比较方式,结果列进进度记录(每份写明:有/无、在哪个测试里比的、怎么比的)。
|
||||
- 发现同类问题的,本单**只记录不修**(各自另开单),除非改动小到一眼可见。
|
||||
|
||||
- 验收:六份的检查结论在进度记录里,一份不漏。
|
||||
|
||||
### 5.5 Bug 历史
|
||||
|
||||
同一变更内写进 `docs/BUG_HISTORY.md`,预占 **BUG-733**(721–732 已被两轮审计的七单占用,避让)。必须写明:
|
||||
|
||||
- **复发自 BUG-712**,并说明为什么没拦住:BUG-712 的防复发只落在 `ephemeris_events` 那一处,没有仓库级守卫,新写的 golden 重蹈覆辙。
|
||||
- 状态可写 `resolved`,但**证据必须是 §5.1 的同进程差分 + §5.2 的两次反向验证**,不能只写「现在绿了」。
|
||||
- 防复发升级为:**任何 golden 比较都不得对浮点做整体 `==`;浮点必须量化或带容差,容差数值要有实测依据并写在注释里;能在同进程内做差分证明的命题,不得用跨机 golden 代替。**
|
||||
|
||||
## 6. 让步顺序
|
||||
|
||||
1. 5.1 **不得砍**——它才是这一单的意义;没有它,5.2 只是把红灯调成绿灯。
|
||||
2. 5.2 必须做。
|
||||
3. 5.4 可以只做「检查并记录」,修留到后续单。
|
||||
4. 5.3、5.5 不得砍。
|
||||
|
||||
## 7. 开工前置命令
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git worktree add -b codex/rectification-engine-memoization-fix-20260915 \
|
||||
.worktrees/rectification-engine-memoization-fix-20260915 origin/staging
|
||||
cd .worktrees/rectification-engine-memoization-fix-20260915
|
||||
git status -sb | head -1
|
||||
# 先复现:这一条应当是红的
|
||||
.venv/bin/python -m pytest tests/test_rectification_engine_memoization.py -q
|
||||
```
|
||||
|
||||
开工前必读:`docs/BUG_HISTORY.md` 的 **BUG-712**(同形态前例与它的修法)与 **BUG-721**(本单要保护的那次改动)。
|
||||
|
||||
验收命令:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m pytest tests/test_rectification_engine_memoization.py
|
||||
.venv/bin/python -m pytest tests/test_rectification_*.py # 门禁 glob,须 0 failed
|
||||
.venv/bin/python scripts/run_quality_gate.py --profile quick
|
||||
```
|
||||
|
||||
## 8. BUG 编号起点
|
||||
|
||||
基线 `e4788dfc` 上 `docs/BUG_HISTORY.md` 最大号为 **BUG-725**。721–732 已被两轮审计七单预占(726–732 尚未落库),本单避让,预占 **BUG-733**。开工时核对实际最大号。
|
||||
|
||||
## 9. 不在本单范围
|
||||
|
||||
- BUG-721 的实现(已验收等价,不动)
|
||||
- 其余五份 golden 的修复(5.4 只检查并记录)
|
||||
- `CORE_PYTEST_TARGETS` 的构成(`tests/test_rectification_*.py` 这条 glob 保留,本单是让它变得可靠,不是把它摘掉)
|
||||
@@ -0,0 +1,135 @@
|
||||
-- BUG-732: split append_consultation_question into two caps.
|
||||
-- Conversation quota (200,000) counts only user-visible `text`.
|
||||
-- thinkingText / thinkingSections stay stored but must not enter that sum.
|
||||
-- Physical cap counts the whole message JSON, including receipts.
|
||||
-- Arithmetic: 50 rounds × (~4,000 body + ~4,000 thinkingText + ~3,000
|
||||
-- thinkingSections + ~3,000 receipts) ≈ 700,000. Headroom → 1,000,000.
|
||||
-- Signature, return columns, error_code values, advisory lock, request_id
|
||||
-- idempotency, 200-message cap, and 16,000-char question check are unchanged
|
||||
-- (BUG-464). CREATE OR REPLACE is backward compatible with deployed callers.
|
||||
|
||||
begin;
|
||||
|
||||
do $migration$
|
||||
begin
|
||||
if current_user <> 'schema_owner' then
|
||||
raise exception 'consultation_session_capacity_requires_schema_owner'
|
||||
using errcode = '42501';
|
||||
end if;
|
||||
end
|
||||
$migration$;
|
||||
|
||||
create or replace function public.append_consultation_question(
|
||||
p_user_id uuid,
|
||||
p_request_id text,
|
||||
p_session_id uuid,
|
||||
p_question_message jsonb
|
||||
)
|
||||
returns table(success boolean, error_code text)
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_session public.chat_sessions%rowtype;
|
||||
v_message jsonb;
|
||||
v_text text;
|
||||
v_request_id text;
|
||||
v_count integer;
|
||||
v_chars integer;
|
||||
v_new_chars integer;
|
||||
v_physical_chars bigint;
|
||||
v_new_physical bigint;
|
||||
begin
|
||||
v_request_id := btrim(coalesce(p_request_id, ''));
|
||||
if p_user_id is null or p_session_id is null or v_request_id = '' then
|
||||
return query select false, 'invalid_request'::text;
|
||||
return;
|
||||
end if;
|
||||
if jsonb_typeof(p_question_message) <> 'object'
|
||||
or p_question_message->>'role' <> 'user' then
|
||||
return query select false, 'invalid_question_message'::text;
|
||||
return;
|
||||
end if;
|
||||
v_text := btrim(coalesce(p_question_message->>'text', ''));
|
||||
if v_text = '' or char_length(v_text) > 16000 then
|
||||
return query select false, 'invalid_question_message'::text;
|
||||
return;
|
||||
end if;
|
||||
|
||||
perform pg_advisory_xact_lock(hashtextextended(p_user_id::text || ':' || v_request_id, 0));
|
||||
|
||||
select session.* into v_session
|
||||
from public.chat_sessions as session
|
||||
where session.id = p_session_id
|
||||
and session.user_id = p_user_id
|
||||
and session.session_type = 'consultation'
|
||||
for update;
|
||||
|
||||
if not found then
|
||||
return query select false, 'session_missing'::text;
|
||||
return;
|
||||
end if;
|
||||
|
||||
if exists (
|
||||
select 1
|
||||
from jsonb_array_elements(coalesce(v_session.messages, '[]'::jsonb)) as elem
|
||||
where elem->>'requestId' = v_request_id
|
||||
) then
|
||||
return query select true, null::text;
|
||||
return;
|
||||
end if;
|
||||
|
||||
v_message := p_question_message || jsonb_build_object('requestId', v_request_id);
|
||||
|
||||
v_count := jsonb_array_length(coalesce(v_session.messages, '[]'::jsonb));
|
||||
select
|
||||
coalesce(sum(length(coalesce(elem->>'text', ''))), 0),
|
||||
coalesce(sum(length(elem::text)), 0)
|
||||
into v_chars, v_physical_chars
|
||||
from jsonb_array_elements(coalesce(v_session.messages, '[]'::jsonb)) as elem;
|
||||
|
||||
v_new_chars := length(v_text);
|
||||
v_new_physical := length(v_message::text);
|
||||
if v_count >= 200
|
||||
or (v_chars + v_new_chars) > 200000
|
||||
or (v_physical_chars + v_new_physical) > 1000000 then
|
||||
return query select false, 'session_full'::text;
|
||||
return;
|
||||
end if;
|
||||
|
||||
update public.chat_sessions as session
|
||||
set messages = coalesce(session.messages, '[]'::jsonb) || jsonb_build_array(v_message),
|
||||
title = case
|
||||
when coalesce(btrim(session.title), '') in ('', '新对话') then
|
||||
case
|
||||
when char_length(v_text) > 14 then left(v_text, 14) || '…'
|
||||
else v_text
|
||||
end
|
||||
else session.title
|
||||
end,
|
||||
updated_at = clock_timestamp()
|
||||
where session.id = p_session_id
|
||||
and session.user_id = p_user_id
|
||||
and session.session_type = 'consultation';
|
||||
if not found then
|
||||
return query select false, 'session_missing'::text;
|
||||
return;
|
||||
end if;
|
||||
|
||||
return query select true, null::text;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.append_consultation_question(uuid, text, uuid, jsonb)
|
||||
from public, anon, authenticated;
|
||||
grant execute on function public.append_consultation_question(uuid, text, uuid, jsonb)
|
||||
to service_role;
|
||||
do $$ begin
|
||||
if exists(select 1 from pg_roles where rolname = 'admin_runtime') then
|
||||
grant execute on function public.append_consultation_question(uuid, text, uuid, jsonb)
|
||||
to admin_runtime;
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
commit;
|
||||
@@ -0,0 +1,288 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import { natalConsultationThinkingPlan } from "../src/lib/consultation-thinking-plan.ts";
|
||||
|
||||
const migrationsDir = new URL("../supabase/migrations/", import.meta.url);
|
||||
const originalSql = readFileSync(
|
||||
new URL("../supabase/migrations/20260901010000_append_consultation_question.sql", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const sql = readFileSync(
|
||||
new URL("../supabase/migrations/20260916010000_consultation_session_capacity.sql", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const itemRoute = readFileSync(
|
||||
new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const consultRoute = readFileSync(
|
||||
new URL("../src/app/api/consult/route.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const SESSION_SELECT =
|
||||
"id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at,pinned,archived_at";
|
||||
|
||||
function functionBody(source: string): string {
|
||||
const match = source.match(/as \$\$\r?\n([\s\S]*?)\r?\n\$\$;/);
|
||||
assert.ok(match?.[1], "function body must be present");
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function withoutLineComments(source: string): string {
|
||||
return source.replace(/--[^\n]*/g, "");
|
||||
}
|
||||
|
||||
function utf8Bytes(value: string): number {
|
||||
return Buffer.byteLength(value, "utf8");
|
||||
}
|
||||
|
||||
function typicalReceipts(domains: readonly string[]) {
|
||||
const workflowReceipt = {
|
||||
route: "multi-domain",
|
||||
status: "ready",
|
||||
preciseTiming: "allowed",
|
||||
missingLayers: [] as string[],
|
||||
domains,
|
||||
};
|
||||
const agentExecutionReceipt = {
|
||||
runId: "run-typical",
|
||||
runtime: "mastra-agentic" as const,
|
||||
skill: {
|
||||
name: "jyotish-vedic-astrology" as const,
|
||||
loaded: true,
|
||||
version: "1.0.0",
|
||||
referenceReads: 2,
|
||||
methodologySections: 4,
|
||||
},
|
||||
steps: [
|
||||
{ sequence: 1, kind: "skill" as const, name: "jyotish-vedic-astrology", status: "completed" as const, durationMs: 120 },
|
||||
{ sequence: 2, kind: "tool" as const, name: "run-jyotish-consultation", status: "completed" as const, durationMs: 1800 },
|
||||
],
|
||||
stepBudget: { planned: 8, used: 2, remaining: 6, truncated: false },
|
||||
workflow: workflowReceipt,
|
||||
techniqueTruth: "verified",
|
||||
techniqueAuditTable: [
|
||||
{ technique: "VedAstro Cloud State", status: "blocked" as const, note: "未经核验的官方云证据,置信度封顶" },
|
||||
{ technique: "Functional Benefic/Malefic", status: "executed" as const, note: "功能属性与自然属性冲突时降置信" },
|
||||
{ technique: "Formal Vargas D1–D60", status: "executed" as const, note: "20/20 传统命名分盘" },
|
||||
{ technique: "Vimshottari sub-periods", status: "executed" as const, note: "本轮已纳入证据计划" },
|
||||
{ technique: "Narayana Dasha", status: "executed" as const, note: "本轮无独立当前读数" },
|
||||
{ technique: "Yogas", status: "executed" as const, note: "成盘与落空均列出" },
|
||||
{ technique: "Ashtakavarga", status: "executed" as const, note: "本轮已纳入证据计划" },
|
||||
{ technique: "Shadbala components", status: "executed" as const, note: "本轮已纳入证据计划" },
|
||||
{ technique: "MEVG / Global Web Evidence", status: "blocked" as const, note: "本轮未闭合" },
|
||||
{ technique: "Real Case Calibration", status: "blocked" as const, note: "本轮未闭合" },
|
||||
{ technique: "Timing Precision Gate", status: "executed" as const, note: "按声明精度封顶" },
|
||||
{ technique: "Prashna chart", status: "not_applicable" as const, note: "本轮为本命咨询" },
|
||||
],
|
||||
};
|
||||
return {
|
||||
techniqueTruth: "verified",
|
||||
workflowReceipt,
|
||||
agentExecutionReceipt,
|
||||
};
|
||||
}
|
||||
|
||||
function thinkingPlan(domains: readonly ("career" | "wealth" | "marriage")[]) {
|
||||
return natalConsultationThinkingPlan({
|
||||
domains,
|
||||
requiredBlocks: [
|
||||
"raw_structure",
|
||||
"raman_six_step",
|
||||
"yoga_table",
|
||||
"timing",
|
||||
"synthesis",
|
||||
"technique_audit_table",
|
||||
"modern_wrap",
|
||||
],
|
||||
mustUseLayers: ["D1", "run-jyotish-consultation", "D10", "skill_read"],
|
||||
});
|
||||
}
|
||||
|
||||
function assistantMessage(_index: number, domains: readonly ("career" | "wealth" | "marriage")[]) {
|
||||
const receipts = typicalReceipts(domains);
|
||||
return {
|
||||
role: "assistant" as const,
|
||||
text: "正".repeat(4000),
|
||||
thinkingText: "思".repeat(4000),
|
||||
thinkingSections: thinkingPlan(domains),
|
||||
...receipts,
|
||||
};
|
||||
}
|
||||
|
||||
function userMessage(index: number) {
|
||||
return {
|
||||
role: "user" as const,
|
||||
text: `第${String(index + 1).padStart(2, "0")}问`.padEnd(100, "问"),
|
||||
requestId: `capacity-req-${index + 1}`,
|
||||
};
|
||||
}
|
||||
|
||||
function sessionDetailPayload(rounds: number, domains: readonly ("career" | "wealth" | "marriage")[]) {
|
||||
const messages = Array.from({ length: rounds }, (_, index) => [
|
||||
userMessage(index),
|
||||
assistantMessage(index, domains),
|
||||
]).flat();
|
||||
return {
|
||||
session: {
|
||||
id: "00000000-0000-4000-8000-000000000001",
|
||||
title: "事业方向",
|
||||
theme: "career",
|
||||
model_id: "test-model",
|
||||
messages,
|
||||
session_type: "consultation",
|
||||
rectification_case_id: null,
|
||||
chart_profile_id: null,
|
||||
chart_profile_name: null,
|
||||
chart_profile_role: null,
|
||||
updated_at: "2026-09-15T00:00:00.000Z",
|
||||
pinned: false,
|
||||
archived_at: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function countedText(messages: readonly { text?: string }[]): number {
|
||||
return messages.reduce((sum, message) => sum + (message.text ?? "").length, 0);
|
||||
}
|
||||
|
||||
function oldCountedChars(messages: readonly Record<string, unknown>[]): number {
|
||||
return messages.reduce((sum, message) => {
|
||||
const text = typeof message.text === "string" ? message.text.length : 0;
|
||||
const thinking = typeof message.thinkingText === "string" ? message.thinkingText.length : 0;
|
||||
const sections = "thinkingSections" in message
|
||||
? JSON.stringify(message.thinkingSections).length
|
||||
: 0;
|
||||
return sum + text + thinking + sections;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
test("the capacity migration is the latest replace of append_consultation_question", () => {
|
||||
const names = readdirSync(fileURLToPath(migrationsDir))
|
||||
.filter((name) => name.endsWith(".sql"))
|
||||
.sort();
|
||||
const replaces = names.filter((name) =>
|
||||
name.includes("append_consultation_question") || name.includes("consultation_session_capacity"),
|
||||
);
|
||||
assert.deepEqual(replaces.at(-1), "20260916010000_consultation_session_capacity.sql");
|
||||
assert.equal(
|
||||
existsSync(fileURLToPath(new URL("../db/migrations/20260916010000_consultation_session_capacity.sql", import.meta.url))),
|
||||
false,
|
||||
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
|
||||
);
|
||||
});
|
||||
|
||||
test("CREATE OR REPLACE keeps the BUG-464 signature, lock, idempotency, and error codes", () => {
|
||||
assert.match(
|
||||
sql,
|
||||
/create or replace function public\.append_consultation_question\(\s*p_user_id uuid,\s*p_request_id text,\s*p_session_id uuid,\s*p_question_message jsonb\s*\)/,
|
||||
);
|
||||
assert.match(sql, /returns table\(success boolean, error_code text\)/);
|
||||
const body = functionBody(sql);
|
||||
assert.match(body, /pg_advisory_xact_lock/);
|
||||
assert.match(body, /elem->>'requestId' = v_request_id/);
|
||||
assert.match(body, /return query select true, null::text;/);
|
||||
assert.match(body, /'session_missing'::text/);
|
||||
assert.match(body, /'session_full'::text/);
|
||||
assert.match(body, /'invalid_request'::text/);
|
||||
assert.match(body, /'invalid_question_message'::text/);
|
||||
assert.match(body, /char_length\(v_text\) > 16000/);
|
||||
assert.match(body, /v_count >= 200/);
|
||||
assert.match(body, /\(v_chars \+ v_new_chars\) > 200000/);
|
||||
assert.match(
|
||||
sql,
|
||||
/revoke all on function public\.append_consultation_question\(uuid, text, uuid, jsonb\)\s+from public, anon, authenticated/,
|
||||
);
|
||||
assert.match(
|
||||
sql,
|
||||
/grant execute on function public\.append_consultation_question\(uuid, text, uuid, jsonb\)\s+to service_role/,
|
||||
);
|
||||
assert.doesNotMatch(body, /session_physically_full|quota_exceeded|storage_full/);
|
||||
});
|
||||
|
||||
test("conversation quota sum counts only text; physical cap counts whole JSON", () => {
|
||||
assert.match(originalSql, /length\(coalesce\(elem->>'thinkingText', ''\)\)/);
|
||||
assert.match(originalSql, /elem \? 'thinkingSections'/);
|
||||
|
||||
const body = functionBody(sql);
|
||||
const code = withoutLineComments(body);
|
||||
assert.doesNotMatch(code, /thinkingText/);
|
||||
assert.doesNotMatch(code, /thinkingSections/);
|
||||
assert.match(code, /coalesce\(sum\(length\(coalesce\(elem->>'text', ''\)\)\), 0\)/);
|
||||
assert.match(code, /coalesce\(sum\(length\(elem::text\)\), 0\)/);
|
||||
assert.match(code, /\(v_physical_chars \+ v_new_physical\) > 1000000/);
|
||||
assert.match(sql, /50 rounds × \(~4,000 body \+ ~4,000 thinkingText \+ ~3,000/);
|
||||
assert.match(sql, /thinkingSections \+ ~3,000 receipts\) ≈ 700,000\. Headroom → 1,000,000\./);
|
||||
});
|
||||
|
||||
test("both caps still surface as session_full; consult route is unchanged", () => {
|
||||
const body = functionBody(sql);
|
||||
const fullReturns = body.match(/return query select false, 'session_full'::text;/g) ?? [];
|
||||
assert.equal(fullReturns.length, 1);
|
||||
assert.match(consultRoute, /error_code === "session_full"/);
|
||||
assert.match(consultRoute, /code: "session_full"/);
|
||||
assert.doesNotMatch(consultRoute, /session_physically_full|physical_cap|storage_full/);
|
||||
assert.match(itemRoute, new RegExp(`sessionSelect = "${SESSION_SELECT}"`));
|
||||
});
|
||||
|
||||
test("measured thinkingSections and receipts stay inside the physical-cap arithmetic", () => {
|
||||
const one = JSON.stringify(thinkingPlan(["career"]));
|
||||
const two = JSON.stringify(thinkingPlan(["career", "wealth"]));
|
||||
const three = JSON.stringify(thinkingPlan(["career", "wealth", "marriage"]));
|
||||
assert.ok(one.length >= 1200 && one.length <= 2500, `1-domain sections JSON length ${one.length}`);
|
||||
assert.ok(two.length >= 1800 && two.length <= 3500, `2-domain sections JSON length ${two.length}`);
|
||||
assert.ok(three.length >= 2400 && three.length <= 4500, `3-domain sections JSON length ${three.length}`);
|
||||
|
||||
const receipts = typicalReceipts(["career", "wealth", "marriage"]);
|
||||
const receiptJson = JSON.stringify({
|
||||
techniqueTruth: receipts.techniqueTruth,
|
||||
workflowReceipt: receipts.workflowReceipt,
|
||||
agentExecutionReceipt: receipts.agentExecutionReceipt,
|
||||
});
|
||||
const receiptChars = receiptJson.length;
|
||||
assert.ok(
|
||||
receiptChars <= 4000,
|
||||
`typical three-receipt JSON is ${receiptChars} chars; update the 3,000 term and 1,000,000 cap if this is a real stored shape`,
|
||||
);
|
||||
|
||||
const perRound =
|
||||
4000
|
||||
+ 4000
|
||||
+ three.length
|
||||
+ receiptChars;
|
||||
const fiftyRoundPhysical = 50 * perRound;
|
||||
assert.ok(
|
||||
fiftyRoundPhysical < 1_000_000,
|
||||
`50 × typical stored round (${perRound} chars) = ${fiftyRoundPhysical}; must stay under the physical cap`,
|
||||
);
|
||||
});
|
||||
|
||||
test("session-detail JSON at the old ~19-round cap versus the new ~50-round cap", () => {
|
||||
const domains = ["career", "wealth", "marriage"] as const;
|
||||
const oldCap = sessionDetailPayload(19, domains);
|
||||
const newCap = sessionDetailPayload(50, domains);
|
||||
const oldJson = JSON.stringify(oldCap);
|
||||
const newJson = JSON.stringify(newCap);
|
||||
const oldBytes = utf8Bytes(oldJson);
|
||||
const newBytes = utf8Bytes(newJson);
|
||||
|
||||
assert.equal(oldCap.session.messages.length, 38);
|
||||
assert.equal(newCap.session.messages.length, 100);
|
||||
assert.ok(oldCountedChars(oldCap.session.messages) > 200_000, "19 full rounds already exceeded the old combined quota");
|
||||
assert.ok(countedText(newCap.session.messages) >= 200_000, "50 rounds of 100+4000 visible text reach the conversation quota");
|
||||
// Measured 2026-09-16 on this fixture: 19-round 562,240 B (0.536 MiB);
|
||||
// 50-round 1,479,034 B (1.411 MiB); ratio 2.631.
|
||||
assert.ok(oldBytes > 540_000 && oldBytes < 590_000, `19-round detail JSON was ${oldBytes} bytes`);
|
||||
assert.ok(newBytes > 1_450_000 && newBytes < 1_510_000, `50-round detail JSON was ${newBytes} bytes`);
|
||||
assert.ok(newBytes > oldBytes, "50-round detail JSON must be larger than 19-round");
|
||||
|
||||
const ratio = newBytes / oldBytes;
|
||||
assert.ok(
|
||||
ratio > 2.5 && ratio < 2.8,
|
||||
`50/19 size ratio was ${ratio.toFixed(3)} (utf8 ${oldBytes} → ${newBytes})`,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
|
||||
|
||||
const runnerPath = fileURLToPath(
|
||||
new URL("../scripts/db-migrate.mjs", import.meta.url),
|
||||
);
|
||||
|
||||
function dockerAvailable(): boolean {
|
||||
return spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], {
|
||||
encoding: "utf8",
|
||||
stdio: "ignore",
|
||||
}).status === 0;
|
||||
}
|
||||
|
||||
const skipWithoutDocker = dockerAvailable() ? false : "docker unavailable on this host";
|
||||
|
||||
const SESSION_ID = "99999999-9999-4999-8999-999999999999";
|
||||
|
||||
function sqlLiteral(value: string): string {
|
||||
return value.replaceAll("'", "''");
|
||||
}
|
||||
|
||||
function appendQuestionSql(
|
||||
fixture: ReturnType<typeof startPostgresFixture>,
|
||||
userId: string,
|
||||
requestId: string,
|
||||
textSql: string,
|
||||
sessionId = SESSION_ID,
|
||||
): string {
|
||||
return fixture.psql(`
|
||||
select coalesce(success::text, 'null') || ':' || coalesce(error_code, 'null')
|
||||
from public.append_consultation_question(
|
||||
'${userId}'::uuid,
|
||||
'${sqlLiteral(requestId)}',
|
||||
'${sessionId}'::uuid,
|
||||
jsonb_build_object('role', 'user', 'text', ${textSql})
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
function appendQuestion(
|
||||
fixture: ReturnType<typeof startPostgresFixture>,
|
||||
userId: string,
|
||||
requestId: string,
|
||||
text: string,
|
||||
sessionId = SESSION_ID,
|
||||
): string {
|
||||
return appendQuestionSql(fixture, userId, requestId, `'${sqlLiteral(text)}'`, sessionId);
|
||||
}
|
||||
|
||||
function seedSession(
|
||||
fixture: ReturnType<typeof startPostgresFixture>,
|
||||
userId: string,
|
||||
messagesSql: string,
|
||||
): void {
|
||||
fixture.psql(`
|
||||
insert into public.chat_sessions (
|
||||
id, user_id, title, theme, model_id, messages, session_type, updated_at
|
||||
) values (
|
||||
'${SESSION_ID}', '${userId}', '新对话', 'general',
|
||||
'test-model', ${messagesSql}, 'consultation', now()
|
||||
)
|
||||
on conflict (id) do update
|
||||
set messages = excluded.messages,
|
||||
title = excluded.title,
|
||||
updated_at = now();
|
||||
`);
|
||||
}
|
||||
|
||||
test("append_consultation_question ignores thinking fields and enforces the physical JSON cap", { skip: skipWithoutDocker }, () => {
|
||||
const fixture = startPostgresFixture();
|
||||
const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password");
|
||||
|
||||
try {
|
||||
const migration = spawnSync(process.execPath, [runnerPath], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl },
|
||||
});
|
||||
assert.equal(migration.status, 0, migration.stderr);
|
||||
assert.match(migration.stdout, /applied 20260916010000_consultation_session_capacity\.sql/);
|
||||
|
||||
fixture.psqlAs(
|
||||
"identity_runtime",
|
||||
"identity-runtime-test-password",
|
||||
`
|
||||
insert into identity.users (name, email, email_verified, email_verified_at)
|
||||
values ('Capacity User', 'capacity@example.com', true, now());
|
||||
`,
|
||||
);
|
||||
const userId = fixture.psql(
|
||||
"select id from identity.users where email = 'capacity@example.com'",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
fixture.psql(`
|
||||
select
|
||||
has_function_privilege(
|
||||
'service_role',
|
||||
'public.append_consultation_question(uuid, text, uuid, jsonb)',
|
||||
'execute'
|
||||
) || ':' ||
|
||||
has_function_privilege(
|
||||
'authenticated',
|
||||
'public.append_consultation_question(uuid, text, uuid, jsonb)',
|
||||
'execute'
|
||||
) || ':' ||
|
||||
has_function_privilege(
|
||||
'anon',
|
||||
'public.append_consultation_question(uuid, text, uuid, jsonb)',
|
||||
'execute'
|
||||
)
|
||||
`),
|
||||
"true:f:f",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
appendQuestion(fixture, userId, "missing-session", "会话不存在"),
|
||||
"false:session_missing",
|
||||
);
|
||||
|
||||
seedSession(fixture, userId, "'[]'::jsonb");
|
||||
assert.equal(appendQuestion(fixture, userId, "append-request-1", "第一问会不会丢"), "true:null");
|
||||
assert.equal(appendQuestion(fixture, userId, "append-request-1", "不该写入的重复提问"), "true:null");
|
||||
assert.equal(
|
||||
fixture.psql(`select jsonb_array_length(messages) from public.chat_sessions where id = '${SESSION_ID}'`),
|
||||
"1",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
appendQuestionSql(fixture, userId, "too-long", "repeat('x', 16001)"),
|
||||
"false:invalid_question_message",
|
||||
);
|
||||
|
||||
seedSession(fixture, userId, `
|
||||
(
|
||||
select coalesce(jsonb_agg(elem order by n), '[]'::jsonb)
|
||||
from (
|
||||
select n,
|
||||
case when n % 2 = 1 then
|
||||
jsonb_build_object(
|
||||
'role', 'user',
|
||||
'text', '短问题',
|
||||
'requestId', 'think-seed-' || n::text
|
||||
)
|
||||
else
|
||||
jsonb_build_object(
|
||||
'role', 'assistant',
|
||||
'text', '短回答',
|
||||
'thinkingText', repeat('T', 4000),
|
||||
'thinkingSections', jsonb_build_object('pad', repeat('S', 3000)),
|
||||
'techniqueTruth', 'verified',
|
||||
'workflowReceipt', jsonb_build_object(
|
||||
'route', 'career',
|
||||
'status', 'ready',
|
||||
'preciseTiming', 'allowed',
|
||||
'missingLayers', jsonb_build_array()
|
||||
)
|
||||
)
|
||||
end as elem
|
||||
from generate_series(1, 60) as n
|
||||
) as seeded
|
||||
)
|
||||
`);
|
||||
|
||||
const thinkingSums = fixture.psql(`
|
||||
select
|
||||
coalesce(sum(length(coalesce(elem->>'text', ''))), 0)::text
|
||||
|| ':' ||
|
||||
coalesce(sum(
|
||||
length(coalesce(elem->>'text', ''))
|
||||
+ length(coalesce(elem->>'thinkingText', ''))
|
||||
+ case when elem ? 'thinkingSections'
|
||||
then length((elem->'thinkingSections')::text) else 0 end
|
||||
), 0)::text
|
||||
|| ':' ||
|
||||
coalesce(sum(length(elem::text)), 0)::text
|
||||
from public.chat_sessions,
|
||||
jsonb_array_elements(coalesce(messages, '[]'::jsonb)) as elem
|
||||
where id = '${SESSION_ID}'
|
||||
`);
|
||||
const [textOnly, oldFormula, physical] = thinkingSums.split(":").map(Number);
|
||||
assert.ok(textOnly < 50_000, `visible text was ${textOnly}`);
|
||||
assert.ok(oldFormula > 200_000, `old combined formula was ${oldFormula}`);
|
||||
assert.ok(physical < 1_000_000, `physical JSON was ${physical}`);
|
||||
assert.equal(appendQuestion(fixture, userId, "after-thinking", "思考不该占额度"), "true:null");
|
||||
assert.equal(
|
||||
fixture.psql(`select jsonb_array_length(messages) from public.chat_sessions where id = '${SESSION_ID}'`),
|
||||
"61",
|
||||
);
|
||||
|
||||
seedSession(fixture, userId, `
|
||||
(
|
||||
select coalesce(jsonb_agg(
|
||||
jsonb_build_object('role', 'assistant', 'text', repeat('x', 19900), 'requestId', n::text)
|
||||
order by n
|
||||
), '[]'::jsonb)
|
||||
from generate_series(1, 10) as n
|
||||
)
|
||||
`);
|
||||
const quotaBoundary = fixture.psql(`
|
||||
select
|
||||
coalesce(sum(length(coalesce(elem->>'text', ''))), 0)::text
|
||||
|| ':' ||
|
||||
coalesce(sum(length(elem::text)), 0)::text
|
||||
from public.chat_sessions,
|
||||
jsonb_array_elements(coalesce(messages, '[]'::jsonb)) as elem
|
||||
where id = '${SESSION_ID}'
|
||||
`);
|
||||
const [quotaText, quotaPhysical] = quotaBoundary.split(":").map(Number);
|
||||
assert.equal(quotaText, 199_000);
|
||||
assert.ok(quotaPhysical < 1_000_000, `quota-boundary physical JSON was ${quotaPhysical}`);
|
||||
assert.equal(
|
||||
appendQuestionSql(fixture, userId, "quota-full", "repeat('y', 1001)"),
|
||||
"false:session_full",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select jsonb_array_length(messages) from public.chat_sessions where id = '${SESSION_ID}'`),
|
||||
"10",
|
||||
);
|
||||
|
||||
seedSession(fixture, userId, `
|
||||
jsonb_build_array(
|
||||
jsonb_build_object(
|
||||
'role', 'assistant',
|
||||
'text', '短',
|
||||
'techniqueTruth', 'verified',
|
||||
'workflowReceipt', jsonb_build_object('route', 'career', 'status', 'ready', 'preciseTiming', 'allowed', 'missingLayers', jsonb_build_array()),
|
||||
'agentExecutionReceipt', jsonb_build_object('pad', repeat('R', 1000001))
|
||||
)
|
||||
)
|
||||
`);
|
||||
const physicalOnly = fixture.psql(`
|
||||
select
|
||||
coalesce(sum(length(coalesce(elem->>'text', ''))), 0)::text
|
||||
|| ':' ||
|
||||
coalesce(sum(length(elem::text)), 0)::text
|
||||
from public.chat_sessions,
|
||||
jsonb_array_elements(coalesce(messages, '[]'::jsonb)) as elem
|
||||
where id = '${SESSION_ID}'
|
||||
`);
|
||||
const [shortText, hugePhysical] = physicalOnly.split(":").map(Number);
|
||||
assert.ok(shortText < 100, `short body was ${shortText}`);
|
||||
assert.ok(hugePhysical > 1_000_000, `receipt pad physical JSON was ${hugePhysical}`);
|
||||
assert.equal(
|
||||
appendQuestion(fixture, userId, "physical-full", "正文很短但行已经胀了"),
|
||||
"false:session_full",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select jsonb_array_length(messages) from public.chat_sessions where id = '${SESSION_ID}'`),
|
||||
"1",
|
||||
);
|
||||
|
||||
seedSession(fixture, userId, `
|
||||
(
|
||||
select coalesce(jsonb_agg(
|
||||
jsonb_build_object('role', 'user', 'text', 'x', 'requestId', n::text)
|
||||
order by n
|
||||
), '[]'::jsonb)
|
||||
from generate_series(1, 200) as n
|
||||
)
|
||||
`);
|
||||
assert.equal(
|
||||
appendQuestion(fixture, userId, "count-full", "满了就不能再写"),
|
||||
"false:session_full",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`select jsonb_array_length(messages) from public.chat_sessions where id = '${SESSION_ID}'`),
|
||||
"200",
|
||||
);
|
||||
} finally {
|
||||
fixture.stop();
|
||||
}
|
||||
});
|
||||
@@ -94,6 +94,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
|
||||
assert.match(migration.stdout, /applied 20260901020000_chat_session_pin_archive\.sql/);
|
||||
assert.match(migration.stdout, /applied 20260905010000_personal_report_longform_appendices\.sql/);
|
||||
assert.match(migration.stdout, /applied 20260915010000_rectification_touch_chat_session\.sql/);
|
||||
assert.match(migration.stdout, /applied 20260916010000_consultation_session_capacity\.sql/);
|
||||
assert.equal(
|
||||
existsSync(fileURLToPath(new URL("../db/migrations/20260901020000_chat_session_pin_archive.sql", import.meta.url))),
|
||||
false,
|
||||
@@ -104,6 +105,11 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
|
||||
false,
|
||||
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
|
||||
);
|
||||
assert.equal(
|
||||
existsSync(fileURLToPath(new URL("../db/migrations/20260916010000_consultation_session_capacity.sql", import.meta.url))),
|
||||
false,
|
||||
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
|
||||
);
|
||||
fixture.psql(pinArchiveMigration);
|
||||
assert.equal(
|
||||
fixture.psql(`
|
||||
|
||||
@@ -2,17 +2,25 @@
|
||||
|
||||
Golden payload in tests/golden/rectification_engine_memoization_v1.json was
|
||||
produced from origin/staging @ a8d29d1b before any memoization landed.
|
||||
|
||||
Do not compare that payload with a whole-structure ``==``. Cross-machine
|
||||
libm / pyswisseph rounding already drifted ``margin_percent`` by 1.1e-3
|
||||
(TASK-rectification-engine-memoization-fix-20260915). Equivalence of the
|
||||
four cached layers is proven in-process instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import math
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.active_rectification_event_engine import (
|
||||
_candidate_datetimes,
|
||||
_controlled_transit_rules,
|
||||
@@ -35,6 +43,20 @@ GOLDEN_PATH = ROOT / "tests" / "golden" / "rectification_engine_memoization_v1.j
|
||||
FROZEN_TODAY = date(2026, 9, 16)
|
||||
TIMING_KEYS = frozenset({"column_compare_ms"})
|
||||
SOURCE_COMMIT = "a8d29d1b6cc37ff865ddec6c8bccdf9aa889ee53"
|
||||
CACHE_LAYER_KEYS = (
|
||||
"ashtakavarga_result",
|
||||
"shadbala_result",
|
||||
"vimshottari_timeline",
|
||||
"narayana_periods",
|
||||
)
|
||||
# TASK-rectification-engine-memoization-fix-20260915 §2 measured max
|
||||
# cross-machine drift of 1.1e-3 on decision_receipt.margin_percent
|
||||
# (5.2995 vs golden 5.3006). Candidate scores, already rounded to 4
|
||||
# decimals, drifted by 1 ulp (1.0e-4). Tolerance is the wider of
|
||||
# abs=2e-3 and rel=5e-4 so 1.1e-3 still passes while a 1e-2 mutation
|
||||
# (the reverse test below) fails.
|
||||
GOLDEN_FLOAT_ABS = 2e-3
|
||||
GOLDEN_FLOAT_REL = 5e-4
|
||||
|
||||
|
||||
def public_score_request() -> dict[str, Any]:
|
||||
@@ -117,11 +139,102 @@ def _count_calls(monkeypatch, owner: Any, name: str, *, from_engine: bool = Fals
|
||||
return counter
|
||||
|
||||
|
||||
def _is_json_number(value: Any) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
|
||||
|
||||
def _assert_tiered_equal(actual: Any, expected: Any, *, path: str) -> None:
|
||||
if isinstance(expected, dict):
|
||||
assert isinstance(actual, dict), path
|
||||
assert set(actual) == set(expected), f"{path} keys {set(actual)!r} != {set(expected)!r}"
|
||||
for key in expected:
|
||||
_assert_tiered_equal(actual[key], expected[key], path=f"{path}.{key}")
|
||||
return
|
||||
if isinstance(expected, list):
|
||||
assert isinstance(actual, list), path
|
||||
assert len(actual) == len(expected), f"{path} length {len(actual)} != {len(expected)}"
|
||||
for index, (left, right) in enumerate(zip(actual, expected)):
|
||||
_assert_tiered_equal(left, right, path=f"{path}[{index}]")
|
||||
return
|
||||
if isinstance(expected, int) and isinstance(actual, int) and not isinstance(expected, bool) and not isinstance(actual, bool):
|
||||
assert actual == expected, f"{path}: {actual!r} != {expected!r}"
|
||||
return
|
||||
if _is_json_number(expected) or _is_json_number(actual):
|
||||
left = float(actual)
|
||||
right = float(expected)
|
||||
tolerance = max(GOLDEN_FLOAT_ABS, GOLDEN_FLOAT_REL * abs(right))
|
||||
assert math.isclose(left, right, rel_tol=0.0, abs_tol=tolerance), (
|
||||
f"{path}: {left!r} vs {right!r} exceeds {tolerance}"
|
||||
)
|
||||
return
|
||||
assert actual == expected, f"{path}: {actual!r} != {expected!r}"
|
||||
|
||||
|
||||
def _assert_memoization_payloads(actual: dict[str, Any], expected: dict[str, Any]) -> None:
|
||||
_assert_tiered_equal(actual["candidate_scores"], expected["candidate_scores"], path="candidate_scores")
|
||||
_assert_tiered_equal(actual["decision_receipt"], expected["decision_receipt"], path="decision_receipt")
|
||||
|
||||
|
||||
def _event_engine_request() -> dict[str, Any]:
|
||||
"""Dated events for compute_event_candidate_rows (not the v5 score payload)."""
|
||||
return {
|
||||
"birth_date": "1990-01-01",
|
||||
"start_time": "12:00",
|
||||
"end_time": "12:02",
|
||||
"lat": 39.9,
|
||||
"lon": 116.4,
|
||||
"tz": 8,
|
||||
"events": [
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000001",
|
||||
"domain": "education",
|
||||
"event_kind": "education_start",
|
||||
"date": "2008-07-01",
|
||||
"precision": "day",
|
||||
"summary": "入学",
|
||||
},
|
||||
{
|
||||
"id": "00000000-0000-4000-8000-000000000002",
|
||||
"domain": "career",
|
||||
"event_kind": "career_entry",
|
||||
"date": "2012-06-15",
|
||||
"precision": "day",
|
||||
"summary": "入职",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _contexts_with_layer_cache_cleared(contexts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
cleared: list[dict[str, Any]] = []
|
||||
for context in contexts:
|
||||
clone = dict(context)
|
||||
for key in CACHE_LAYER_KEYS:
|
||||
clone[key] = None
|
||||
cleared.append(clone)
|
||||
return cleared
|
||||
|
||||
|
||||
def test_score_candidates_matches_baseline_golden() -> None:
|
||||
expected = json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
|
||||
actual = json.loads(json.dumps(_golden_payload(), ensure_ascii=True))
|
||||
assert actual["candidate_scores"] == expected["candidate_scores"]
|
||||
assert actual["decision_receipt"] == expected["decision_receipt"]
|
||||
_assert_memoization_payloads(actual, expected)
|
||||
|
||||
|
||||
def test_golden_float_shift_of_1e_minus_2_fails() -> None:
|
||||
expected = json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
|
||||
mutated = json.loads(json.dumps(expected))
|
||||
mutated["candidate_scores"][0]["score"] += 1e-2
|
||||
with pytest.raises(AssertionError):
|
||||
_assert_memoization_payloads(expected, mutated)
|
||||
|
||||
|
||||
def test_golden_discrete_field_mismatch_fails() -> None:
|
||||
expected = json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
|
||||
mutated = json.loads(json.dumps(expected))
|
||||
mutated["candidate_scores"][0]["time"] = "99:99"
|
||||
with pytest.raises(AssertionError):
|
||||
_assert_memoization_payloads(expected, mutated)
|
||||
|
||||
|
||||
def test_shadbala_verified_fields_match_with_and_without_birth_minute() -> None:
|
||||
@@ -372,3 +485,51 @@ def test_compute_event_candidate_rows_reuses_static_context_without_mutating_it(
|
||||
assert len(rows) == 2
|
||||
assert [frozenset(context) for context in contexts] == original_keys
|
||||
assert all("_transit_chart_cache" not in context for context in contexts)
|
||||
|
||||
|
||||
def test_cached_static_context_matches_uncached_fallback(monkeypatch) -> None:
|
||||
request = _event_engine_request()
|
||||
cached = [
|
||||
build_candidate_static_context(request, candidate)
|
||||
for candidate in _candidate_datetimes(request)
|
||||
]
|
||||
assert len(cached) >= 2
|
||||
assert len(request["events"]) >= 2
|
||||
for context in cached:
|
||||
for key in CACHE_LAYER_KEYS:
|
||||
assert context.get(key) is not None
|
||||
uncached = _contexts_with_layer_cache_cleared(cached)
|
||||
for context in uncached:
|
||||
for key in CACHE_LAYER_KEYS:
|
||||
assert context[key] is None
|
||||
calls = _count_calls(monkeypatch, event_engine.shadbala, "calc_shadbala")
|
||||
rows_cached = compute_event_candidate_rows(request, static_contexts=cached)
|
||||
cached_calls = calls[0]
|
||||
rows_uncached = compute_event_candidate_rows(request, static_contexts=uncached)
|
||||
uncached_calls = calls[0] - cached_calls
|
||||
assert rows_cached == rows_uncached
|
||||
assert cached_calls == 0
|
||||
assert uncached_calls > cached_calls
|
||||
assert uncached_calls >= len(cached) * len(request["events"])
|
||||
|
||||
|
||||
def test_poisoned_static_cache_diverges_from_live_rows() -> None:
|
||||
request = _event_engine_request()
|
||||
cached = [
|
||||
build_candidate_static_context(request, candidate)
|
||||
for candidate in _candidate_datetimes(request)
|
||||
]
|
||||
rows_cached = compute_event_candidate_rows(request, static_contexts=cached)
|
||||
poisoned = [dict(context) for context in cached]
|
||||
poisoned[0]["shadbala_result"] = {
|
||||
"planets": {
|
||||
name: {
|
||||
"sthana_bala": {"total": 0.0},
|
||||
"drik_bala": 0.0,
|
||||
"naisargika_bala": 0.0,
|
||||
}
|
||||
for name in ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
|
||||
}
|
||||
}
|
||||
rows_poisoned = compute_event_candidate_rows(request, static_contexts=poisoned)
|
||||
assert rows_poisoned != rows_cached
|
||||
|
||||
Reference in New Issue
Block a user