Files
Jyotisha/frontend/tests/rectification-grounding-prompt-20260927.test.ts
T
Jesse_ChenandClaude Opus 5.5 61d6f248e4
Independent Staging Quality Gate / validate (push) Successful in 13m45s
Independent Staging Quality Gate / publish (push) Successful in 3m34s
feat(rectification): warm older-sister voice; opening and read-only turns get only the Skill sections they use (BUG-1257)
Both rectification prompts now share RECTIFICATION_VOICE_HEAD, the same warm
voice as the consultation answer (no mind-reading here: these turns collect
facts). Opening and read-only turns used to send the whole bound Skill (11,812
characters for 10.0.33); they now send the sections they act on, plus the
product-identity section when the bound Skill has it. 9.0.0 still goes out
whole (BUG-621). Rule bodies, scoring, cards and adoption are unchanged.

Opening input 7,433 -> 3,892 tokens in a DeepSeek replay. Full suite 4970 /
fail 24, identical to c76fdda7; build keeps / Static.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-07 11:08:25 +08:00

243 lines
12 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.
/**
* TASK-rectification-grounding-20260927 T6 (R4 + Skill slicing): what a real
* rectification Agent sends the model on an evidence turn. Real
* `runV9AgentTurn` + real `getRectificationV9Agent` (real bound Skill, real
* tools) + a prompt-recording scripted model; case data is a real local
* engine response for a public AA chart (9 candidates).
*
* Token estimate (for the size table): one token per CJK character plus one
* per four other characters (`estimateTokens`). "Fixed overhead" is what every
* call of the turn re-sends regardless of tool results: the system messages
* plus the tool schemas.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
CASE_ID,
SESSION_ID,
TURN_ID,
USER_ID,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
import {
AA_EVIDENCE_ROWS,
AA_RANGE,
AA_TURNS,
buildGoldenLatest,
contentText,
estimateTokens,
scriptedModel,
setFocusHandler,
stubGoldenFetch,
type RecordedCall,
} from "./rectification-grounding-support.ts";
import { getRectificationV9Agent } from "../src/mastra/agentic-rectification.ts";
import { runV9AgentTurn } from "../src/lib/rectification-agentic/v9/agent-run.ts";
import {
RECTIFICATION_SKILL_SECTIONS_BY_ACTION,
sliceRectificationSkill,
} from "../src/lib/rectification-agentic/v9/skill-slice.ts";
import {
resolveActiveSkillPackage,
resolveExactSkillPackage,
} from "../src/lib/skill-package-registry.ts";
import { resolve } from "node:path";
import { productDeliveryForDomain } from "../src/lib/rectification-agentic/v9/product-delivery.ts";
export type CallSize = Readonly<{
call: number;
systemChars: number;
systemTokens: number;
toolsBytes: number;
toolsTokens: number;
toolNames: string[];
fixedTokens: number;
totalTokens: number;
}>;
export function callSizes(calls: readonly RecordedCall[]): CallSize[] {
return calls.map((call, index) => {
const system = call.prompt.filter((message) => message.role === "system").map((message) => contentText(message.content)).join("\n");
const tools = JSON.stringify(call.tools ?? []);
const all = call.prompt.map((message) => contentText(message.content)).join("\n");
return {
call: index + 1,
systemChars: system.length,
systemTokens: estimateTokens(system),
toolsBytes: Buffer.byteLength(tools, "utf8"),
toolsTokens: estimateTokens(tools),
toolNames: Array.isArray(call.tools) ? (call.tools as Array<{ name: string }>).map((tool) => tool.name) : [],
fixedTokens: estimateTokens(system) + estimateTokens(tools),
totalTokens: estimateTokens(all) + estimateTokens(tools),
};
});
}
/** One evidence turn: read-case, then one short body (no write claimed). */
export async function recordEvidenceTurn(productDomain?: string) {
const { latest, compute } = await buildGoldenLatest();
const { model, calls } = scriptedModel([
{ parts: [{ tool: { name: "rectification-read-case", input: { caseId: CASE_ID } } }], finish: "tool-calls" },
{ parts: [{ text: "2014 年 8 月结婚这件事拿去和星盘对照了。" }], finish: "stop" },
]);
const active = resolveActiveSkillPackage("jyotish-birth-time-rectification");
const accounting = fakeAccounting({
...receiptHandlers,
// The case is bound to the active Skill (10.0.x layout), as a new case is.
get_agentic_rectification_skill_identity: () => ({
skill_name: active.name,
skill_version: active.version,
skill_sha256: active.sha256,
skill_source_commit: active.sourceCommit,
}),
insert_agentic_rectification_skill_run_receipt: () => ({
receipt_id: "88888888-8888-4888-8888-888888888888",
skill_name: active.name,
skill_version: active.version,
skill_sha256: active.sha256,
source_commit: active.sourceCommit,
}),
get_agentic_rectification_case_dossier: () => {
const dossier = dossierFixture({
evidence: AA_EVIDENCE_ROWS,
latestResult: latest,
turns: AA_TURNS as never,
candidateRange: AA_RANGE as never,
}) as { case: Record<string, unknown> };
return { ...dossier, case: { ...dossier.case, skill_version: active.version,
...(productDomain ? { rectification_domain: productDomain } : {}),
} };
},
get_agentic_rectification_case_compute: () => compute,
set_agentic_rectification_conversation_focus: setFocusHandler,
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, should_execute: true, status: "pending" }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
}, { fallback: () => null });
const restore = stubGoldenFetch();
try {
await runV9AgentTurn({
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
requestId: "req-grounding-prompt",
action: "evidence",
message: "2014 年 8 月结婚。",
modelName: "fake",
accounting: accounting.client as never,
billing: { reserve: async () => ({ success: true, status: 200 }), complete: async () => true, release: async () => true },
emit: () => {},
expectedWrite: "none",
buildAgent: async (turnId, skillPackage, attemptId) => getRectificationV9Agent(
{ id: "fake", label: "fake", description: "", creditCost: 1, isDefault: true, mode: "compatible", model: model as never } as never,
{ userId: USER_ID, caseId: CASE_ID, turnId, attemptId, userMessage: "2014 年 8 月结婚。", accounting: accounting.client as never,
...(productDomain ? { productDelivery: productDeliveryForDomain(productDomain) } : {}),
},
skillPackage,
),
});
} finally {
restore();
}
return calls;
}
function activeSkillBody(): string {
const skill = resolveActiveSkillPackage("jyotish-birth-time-rectification");
const raw = readFileSync(resolve(skill.resolvedPath, "SKILL.md"), "utf8");
return raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "").trim();
}
test("R4: an evidence-turn call carries no <available_skills> block and no skill tools", async () => {
const calls = await recordEvidenceTurn();
assert.ok(calls.length >= 2);
for (const call of calls) {
const system = call.prompt.filter((message) => message.role === "system").map((message) => contentText(message.content)).join("\n");
assert.doesNotMatch(system, /<available_skills>/);
assert.doesNotMatch(system, /Skills are NOT tools|call the `skill` tool/);
const names = Array.isArray(call.tools) ? (call.tools as Array<{ name: string }>).map((tool) => tool.name) : [];
assert.equal(names.includes("skill"), false);
assert.equal(names.includes("skill_search"), false);
}
});
test("T6: the evidence-turn bootstrap carries Skill §5 and §7 only", async () => {
const calls = await recordEvidenceTurn();
const system = calls[0]!.prompt.filter((message) => message.role === "system").map((message) => contentText(message.content)).join("\n");
assert.match(system, /## 5\. ConversationFocus 与意图承接/);
assert.match(system, /## 7\. 批量证据与日期真实性/);
for (const heading of ["## 1. ", "## 2. ", "## 3. ", "## 4. ", "## 6. ", "## 8. ", "## 9. ", "## 10. ", "## 11. "]) {
assert.equal(system.includes(heading), false, `${heading} in ${system.slice(system.indexOf(heading) - 200, system.indexOf(heading) + 200)}`);
}
// full_diagnostics-only fields described in §6 are not in the evidence turn.
assert.doesNotMatch(system, /guided_collect_windows|method_followup_plan/);
assert.match(system, /本轮是证据轮/);
assert.match(system, /# Jyotish 生时校正(V10)/);
});
test("T6: fixed overhead per evidence-turn call is under 8K estimated tokens", async () => {
const sizes = callSizes(await recordEvidenceTurn());
console.log(JSON.stringify({ scope: "T6 evidence turn sizes", sizes }));
for (const size of sizes) assert.ok(size.fixedTokens < 8_000, JSON.stringify(size));
});
test("segment-product real model calls keep target identity in the evidence slice within the original 8K budget", async () => {
const calls = await recordEvidenceTurn("career");
assert.ok(calls.length >= 2);
for (const call of calls) {
const system = call.prompt.filter(message => message.role === "system").map(message => contentText(message.content)).join("\n");
assert.match(system, /服务端持久产品身份/);
assert.match(system, /segment-product-v1/);
assert.match(system, /"targets":\["D1","D10"\]/);
assert.match(system, /没有摘要仍是新产品/);
assert.match(system, /代表候选签名只是诊断/);
assert.match(system, /## 5\. ConversationFocus 与意图承接/);
assert.match(system, /## 7\. 批量证据与日期真实性/);
for (const heading of ["## 1. ", "## 2. ", "## 3. ", "## 4. ", "## 6. ", "## 8. ", "## 9. ", "## 10. ", "## 11. "]) assert.equal(system.includes(heading), false);
assert.doesNotMatch(system, /<available_skills>/);
}
for (const size of callSizes(calls)) assert.ok(size.fixedTokens < 8_000, JSON.stringify(size));
});
test("T6: the slice rule is a code constant; other actions and unknown layouts get the whole Skill", () => {
assert.deepEqual(RECTIFICATION_SKILL_SECTIONS_BY_ACTION.evidence, ["ConversationFocus 与意图承接", "批量证据与日期真实性"]);
// 原值: opening / read_only 都是 "all",开场轮发整本 Skill
// 新值: 开场轮只附「必须先读、OpeningPolicy、ConversationFocus、可调用工具」,只读轮只附「必须先读、Case 状态、可调用工具、候选语言、输出与停止」,两者有「持久产品身份」时一并附上
// 原因: 2026-10-07 校正口吻统一为知心姐姐、开场 / 只读轮只附用得上的 Skill 章节(BUG-1257)
assert.deepEqual(RECTIFICATION_SKILL_SECTIONS_BY_ACTION.opening, ["必须先读与服务器权威", "OpeningPolicy", "ConversationFocus 与意图承接", "可调用工具与输入边界"]);
assert.deepEqual(RECTIFICATION_SKILL_SECTIONS_BY_ACTION.read_only, ["必须先读与服务器权威", "Case 状态与只读边界", "可调用工具与输入边界", "candidate / accepted / confirmed 语言边界", "输出与停止条件"]);
const body = activeSkillBody();
const opening = sliceRectificationSkill(body, "opening");
assert.ok(opening.text.length < body.length / 2, `${opening.text.length} of ${body.length}`);
assert.match(opening.text, /本轮是开场轮/);
// Dropped as sections (their names may still be mentioned inside kept text).
for (const dropped of ["CaseConversationSummary 与长会话记忆", "上游同步边界", "触发条件与方法学归属"]) {
const heading = new RegExp(`^## (?:\\d+\\. )?${dropped}`, "m");
assert.doesNotMatch(opening.text, heading, dropped);
assert.doesNotMatch(sliceRectificationSkill(body, "read_only").text, heading, dropped);
}
if (body.includes("持久产品身份与盘型交付")) {
assert.match(opening.text, /持久产品身份与盘型交付/);
assert.match(sliceRectificationSkill(body, "read_only").text, /持久产品身份与盘型交付/);
}
const evidence = sliceRectificationSkill(body, "evidence");
assert.deepEqual(evidence.sections, ["## 5. ConversationFocus 与意图承接", "## 7. 批量证据与日期真实性"]);
assert.ok(evidence.text.length < body.length / 2);
const oldLayout = "# 旧版\n\n## 概述\n\n没有编号的章节。";
assert.equal(sliceRectificationSkill(oldLayout, "evidence").text, oldLayout);
// BUG-621: the oldest deprecated snapshot still resolves and still slices safely.
const oldest = resolveExactSkillPackage(
"jyotish-birth-time-rectification",
"9.0.0",
"5acb3103e80993ea611b93d2c1746e70b74aff8f8636bcffc80a837b954b470d",
);
const oldestBody = readFileSync(resolve(oldest.resolvedPath, "SKILL.md"), "utf8");
// 9.0.0 numbers its sections differently (its §5 is the candidate language):
// no title match, so the whole bound body goes out unchanged.
assert.equal(sliceRectificationSkill(oldestBody, "evidence").text, oldestBody);
assert.equal(sliceRectificationSkill(oldestBody, "opening").text, oldestBody);
assert.equal(sliceRectificationSkill(oldestBody, "read_only").text, oldestBody);
});