diff --git a/BLOCKED.md b/BLOCKED.md index e632765f..d08c8c52 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -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`。不得写成通过。 diff --git a/CHANGELOG.md b/CHANGELOG.md index e028bfed..cffbbfe2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-16 — 这段对话写满之前,思考过程不再占用额度 + +普通咨询里,模型内部的思考过程不再算进「这段对话已写满」的额度。额度仍按你看得到的正文计算,能问的轮次大约从十几轮提到约五十轮。写满时还是请开一个新对话,提示不变。Skill 版本不变。 + ## 2026-09-16 — 对话写满后开新对话会静默带上之前的摘要 普通咨询写满、点「开新对话」之后,服务端会把上一场的会话摘要拷进新对话,界面上看不出差别。进不了窗口的更早几轮,模型会看到省略说明,不再当成没发生过。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index b6b114c2..5ee3a94a 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -11380,3 +11380,19 @@ - 相关记录: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 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-consultation-session-capacity-20260915.md b/docs/tasks/PROGRESS-consultation-session-capacity-20260915.md new file mode 100644 index 00000000..df55e6df --- /dev/null +++ b/docs/tasks/PROGRESS-consultation-session-capacity-20260915.md @@ -0,0 +1,62 @@ +# PROGRESS · 对话上限一半被思考文本吃掉(2026-09-15) + +工作树:`.worktrees/consultation-session-capacity-20260915` +分支:`codex/consultation-session-capacity-20260915` +任务书基线:`6b3248bf`;开工时 `origin/staging` = **`11893c7f`**。 +本机 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`。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 02ce32af..8629145d 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -240,7 +240,7 @@ | `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** 个,而它们服务的 `` 本来就是 `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 | 待领取 | — | diff --git a/frontend/supabase/migrations/20260916010000_consultation_session_capacity.sql b/frontend/supabase/migrations/20260916010000_consultation_session_capacity.sql new file mode 100644 index 00000000..bbbce039 --- /dev/null +++ b/frontend/supabase/migrations/20260916010000_consultation_session_capacity.sql @@ -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; diff --git a/frontend/tests/consultation-session-capacity.test.ts b/frontend/tests/consultation-session-capacity.test.ts new file mode 100644 index 00000000..c3586d2b --- /dev/null +++ b/frontend/tests/consultation-session-capacity.test.ts @@ -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[]): 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})`, + ); +}); diff --git a/frontend/tests/database-consultation-session-capacity.test.ts b/frontend/tests/database-consultation-session-capacity.test.ts new file mode 100644 index 00000000..1aa1b3eb --- /dev/null +++ b/frontend/tests/database-consultation-session-capacity.test.ts @@ -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, + 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, + userId: string, + requestId: string, + text: string, + sessionId = SESSION_ID, +): string { + return appendQuestionSql(fixture, userId, requestId, `'${sqlLiteral(text)}'`, sessionId); +} + +function seedSession( + fixture: ReturnType, + 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(); + } +}); diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts index f9ddd7bd..b66410e0 100644 --- a/frontend/tests/database-local-business.test.ts +++ b/frontend/tests/database-local-business.test.ts @@ -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(`