Files
Jyotisha/frontend/tests/consultation-session-capacity.test.ts
T
jesse-ux e32ce6247e fix(consult): BUG-945~949 领域截断、思考分片、Pass 4 按模式分流
schema 上限与执行上限解耦;校正思考改分片门;日期观察不删字,保证句与无分钟个人盘退回重写。
2026-09-18 13:17:35 +08:00

325 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 databaseCapacityTest = readFileSync(
new URL("./database-consultation-session-capacity.test.ts", import.meta.url),
"utf8",
);
const postgresFixture = readFileSync(
new URL("./helpers/postgres-fixture.ts", import.meta.url),
"utf8",
);
// 原值:列到 updated_at,pinned,archived_at
// 新值:加上 created_at
// 原因:任务书 T4 侧栏副标题要用创建时间
const SESSION_SELECT =
"id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,created_at,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 D1D60", 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"]));
// 原值:1 域 12002500 / 2 域 18003500 / 3 域 24004500
// 新值:1 域 9001300 / 23 域 11001600(实测 1053 / 1267 / 1267
// 原因:BUG-949,本命思考计划改成四标题后 JSON 变小;12 步上限让 2 域与 3 域同长。
assert.ok(one.length >= 900 && one.length <= 1300, `1-domain sections JSON length ${one.length}`);
assert.ok(two.length >= 1100 && two.length <= 1600, `2-domain sections JSON length ${two.length}`);
assert.ok(three.length >= 1100 && three.length <= 1600, `3-domain sections JSON length ${three.length}`);
assert.ok(two.length >= one.length, "adding a domain must not shrink the plan JSON");
assert.ok(three.length >= two.length, "a third domain may share the 12-step cap");
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);
// 原值:oldCountedChars(19) > 200_000(思考分节计入旧额度)
// 新值:四标题计划更小,19 轮旧口径实测 177,973,落在 170k190k
// 原因:BUG-949,思考计划变小后 19 轮不再必然撞上 20 万旧额度。
assert.ok(
oldCountedChars(oldCap.session.messages) > 170_000 && oldCountedChars(oldCap.session.messages) < 190_000,
`19-round old combined count was ${oldCountedChars(oldCap.session.messages)}`,
);
assert.ok(countedText(newCap.session.messages) >= 200_000, "50 rounds of 100+4000 visible text reach the conversation quota");
// 原值:2026-09-16 夹具 19 轮 562,240 B / 50 轮 1,479,034 B
// 新值:2026-09-18 实测 533,113 B / 1,402,384 B,比例仍约 2.63
// 原因:BUG-949thinkingSections JSON 随四标题计划缩小。
assert.ok(oldBytes > 520_000 && oldBytes < 550_000, `19-round detail JSON was ${oldBytes} bytes`);
assert.ok(newBytes > 1_380_000 && newBytes < 1_430_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})`,
);
});
test("live Postgres capacity assertions use fixture.psql's false→f alphabet (BUG-739)", () => {
assert.match(
postgresFixture,
/\.replace\(\/\(\^|:\)false\(\?=:|\$\)\/gm, "\$1f"\)/,
);
assert.match(databaseCapacityTest, /"f:session_missing"/);
assert.match(databaseCapacityTest, /"f:invalid_question_message"/);
assert.equal((databaseCapacityTest.match(/"f:session_full"/g) ?? []).length, 3);
assert.doesNotMatch(databaseCapacityTest, /"false:session_missing"/);
assert.doesNotMatch(databaseCapacityTest, /"false:invalid_question_message"/);
assert.doesNotMatch(databaseCapacityTest, /"false:session_full"/);
});