Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
310 lines
18 KiB
TypeScript
310 lines
18 KiB
TypeScript
/**
|
|
* Issue #113 R1 (product decision 2026-10-27 「R1 C1」): a model-written
|
|
* reading in front of the engine report. Fake writers only (no model key in
|
|
* CI); facts and charts are fictional.
|
|
*/
|
|
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
import { fileURLToPath } from "node:url";
|
|
import React from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
|
|
import { PersonalReportMarkdownView } from "../src/components/personal-report/personal-report-markdown-view";
|
|
import { classifyReportEnvelope } from "../src/components/personal-report/personal-report-page";
|
|
import { hashLongformMarkdown } from "../src/lib/personal-report-longform-appendix";
|
|
import { validateLongformSnapshot } from "../src/lib/personal-report-longform-snapshot";
|
|
import {
|
|
REPORT_READING_SECTIONS,
|
|
REPORT_READING_SYSTEM,
|
|
acceptReportReadingDraft,
|
|
writeReportReading,
|
|
type ReportReadingWriter,
|
|
} from "../src/lib/personal-report-reading";
|
|
import { resolveReportRead } from "../src/lib/personal-report-route-core";
|
|
import type { PersonalReportRecord } from "../src/lib/personal-report-service-core";
|
|
import { assembleReportFactTables } from "../src/lib/report-fact-tables";
|
|
import { assembleLongformCharts } from "../src/lib/personal-report-generation";
|
|
|
|
Object.assign(globalThis, { React });
|
|
|
|
const engine = JSON.parse(readFileSync(new URL("./fixtures/report-density-fictional-engine.json", import.meta.url), "utf8"));
|
|
const zhReader = JSON.parse(readFileSync(new URL("./fixtures/report-reader-main-fictional.json", import.meta.url), "utf8"));
|
|
|
|
const PARAGRAPH = "你的上升落在金牛座,因为管你自己的 1 宫(你自己、身体、给人的第一印象)由金星主管,金星在盘上力气中等,你给人的感觉是稳、慢热、讲究分寸。做事先看清楚再动手,一旦认定就不太轻易改主意,这份耐心是你最可靠的本钱。熟悉的人会觉得你靠得住,刚认识的人可能要多处一阵才知道你心里热。";
|
|
|
|
function draft(overrides: Partial<Record<string, string[]>> = {}) {
|
|
return {
|
|
sections: REPORT_READING_SECTIONS.map((section) => ({
|
|
id: section.id,
|
|
paragraphs: overrides[section.id] ?? [PARAGRAPH],
|
|
})),
|
|
};
|
|
}
|
|
|
|
const FACTS = {
|
|
schema: "report-reading-facts-v1",
|
|
minute_confirmed: true,
|
|
ascendant: { sign: "Taurus", lord: "Venus" },
|
|
planets: { Sun: { d1_sign: "Pisces", d1_house: 11 } },
|
|
domains: { career: { varga: "D10", houses: { "10": { lord: "Saturn" } } } },
|
|
timing: { md: "Rahu", mahadasha: { lord: "Rahu", start: "2023-07-08", end: "2041-07-08" } },
|
|
};
|
|
|
|
test("a clean eight-part draft becomes the stored reading with our titles", () => {
|
|
const accepted = acceptReportReadingDraft(draft());
|
|
assert.ok(accepted.ok);
|
|
assert.deepEqual(accepted.reading.sections.map((section) => section.title), ["总评", "性格", "事业", "财富", "感情", "健康", "现在这几年", "几条建议"]);
|
|
assert.equal(accepted.reading.language, "zh");
|
|
});
|
|
|
|
test("「不一定会」 is not a fate claim and is accepted", () => {
|
|
const accepted = acceptReportReadingDraft(draft({ love: [`${PARAGRAPH}这不一定会马上发生。`] }));
|
|
assert.equal(accepted.ok, true);
|
|
});
|
|
|
|
test("drafts with Latin, field names, fate, disease, investment or stacked disclaimers are rejected", () => {
|
|
const cases: Array<[string, unknown]> = [
|
|
["latin", draft({ career: [`${PARAGRAPH}这段是 Rahu 大运。`] })],
|
|
["field_name", draft({ career: [`${PARAGRAPH}mars_house=2`.replace(/[a-z]/g, "")] })],
|
|
["banned_phrase", draft({ love: [`${PARAGRAPH}你注定晚婚。`] })],
|
|
["banned_phrase", draft({ now: [`${PARAGRAPH}这几年先苦后甜。`] })],
|
|
["banned_phrase", draft({ health: [`${PARAGRAPH}要小心糖尿病。`] })],
|
|
["banned_phrase", draft({ wealth: [`${PARAGRAPH}适合炒股。`] })],
|
|
["banned_phrase", draft({ overview: [`${PARAGRAPH}北交点在 10 宫。`] })],
|
|
["banned_phrase", draft({ overview: [`${PARAGRAPH}本仓功能吉凶层这样判。`] })],
|
|
["banned_phrase", draft({ love: [`${PARAGRAPH}明年一定会结婚。`] })],
|
|
["disclaimer", draft({ health: [`${PARAGRAPH}仅供参考。`, `${PARAGRAPH}不构成医疗意见。`] })],
|
|
["shape", { sections: draft().sections.slice(0, 7) }],
|
|
["shape", { sections: [...draft().sections].reverse() }],
|
|
["too_short", draft(Object.fromEntries(REPORT_READING_SECTIONS.map((section) => [section.id, ["很短的一句。"]])))],
|
|
["too_long", draft({ career: [PARAGRAPH.repeat(5)] })],
|
|
];
|
|
for (const [reason, value] of cases) {
|
|
const accepted = acceptReportReadingDraft(value);
|
|
assert.equal(accepted.ok, false, reason);
|
|
assert.equal(!accepted.ok && accepted.reason, reason, JSON.stringify(value).slice(0, 120));
|
|
}
|
|
});
|
|
|
|
test("the prompt carries the C1 rules and the red lines, and only engine facts go in", () => {
|
|
for (const rule of ["因为……", "宫位第一次出现时", "讲时间时最多说三件", "不写病名", "不给投资指令", "「注定」「先苦后甜」", "罗睺、计都", "只输出 JSON"]) {
|
|
assert.ok(REPORT_READING_SYSTEM.includes(rule), rule);
|
|
}
|
|
});
|
|
|
|
function writer(bodies: unknown[], calls: string[] = []): ReportReadingWriter {
|
|
return async (input) => {
|
|
calls.push(input.user);
|
|
const body = bodies.shift();
|
|
if (body instanceof Error) throw body;
|
|
return { body, inputTokens: 1000, outputTokens: 500 };
|
|
};
|
|
}
|
|
|
|
test("one retry after a rejected draft; tokens of both attempts are counted", async () => {
|
|
const calls: string[] = [];
|
|
const outcome = await writeReportReading({ facts: FACTS, birthTimeConfirmed: false, write: writer([draft({ love: ["你注定晚婚。"] }), draft()], calls) });
|
|
assert.ok(outcome.reading);
|
|
assert.equal(outcome.inputTokens, 2000);
|
|
assert.equal(outcome.outputTokens, 1000);
|
|
assert.equal(calls.length, 2);
|
|
assert.match(calls[0]!, /没有确认到分钟/);
|
|
assert.match(calls[0]!, /"Taurus"/);
|
|
});
|
|
|
|
test("no facts, a failing model or a hung model all leave the report without a reading", async () => {
|
|
const calls: string[] = [];
|
|
const noFacts = await writeReportReading({ facts: { schema: "report-reading-facts-v1", gaps: ["reading_facts_unavailable"] }, birthTimeConfirmed: true, write: writer([draft()], calls) });
|
|
assert.deepEqual(noFacts, { reading: null, unavailable: "facts_unavailable", inputTokens: 0, outputTokens: 0 });
|
|
assert.equal(calls.length, 0);
|
|
const failed = await writeReportReading({ facts: FACTS, birthTimeConfirmed: true, write: writer([new Error("500"), new Error("500")]) });
|
|
assert.equal(failed.reading, null);
|
|
assert.equal(failed.unavailable, "model_failed");
|
|
// A writer that ignores its abort signal still loses to the timeout.
|
|
const hung = await writeReportReading({ facts: FACTS, birthTimeConfirmed: true, timeoutMs: 20, write: () => new Promise(() => undefined) });
|
|
assert.equal(hung.reading, null);
|
|
assert.equal(hung.unavailable, "model_timeout");
|
|
const rejected = await writeReportReading({ facts: FACTS, birthTimeConfirmed: true, write: writer([draft({ health: ["要小心糖尿病。"] }), draft({ health: ["要小心糖尿病。"] })]) });
|
|
assert.equal(rejected.unavailable, "rejected_banned_phrase");
|
|
});
|
|
|
|
test("a cancelled job still cancels; the reading never swallows the abort", async () => {
|
|
const controller = new AbortController();
|
|
const pending = writeReportReading({
|
|
facts: FACTS, birthTimeConfirmed: true, signal: controller.signal,
|
|
write: ({ signal }) => new Promise((_, reject) => signal.addEventListener("abort", () => reject(new Error("aborted")))),
|
|
});
|
|
controller.abort(new Error("job_cancelled"));
|
|
await assert.rejects(pending, /job_cancelled/);
|
|
});
|
|
|
|
const identity = {
|
|
reportId: "11111111-1111-4111-8111-111111111111",
|
|
userId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
requestId: "22222222-2222-4222-8222-222222222222",
|
|
};
|
|
|
|
function snapshot(extra: Record<string, unknown> = {}) {
|
|
const markdown = zhReader.markdown as string;
|
|
return {
|
|
kind: "longform_calculation_snapshot", version: 2, ...identity, markdown,
|
|
contentSha256: hashLongformMarkdown(markdown),
|
|
factTables: assembleReportFactTables(engine),
|
|
charts: assembleLongformCharts(engine, "ev-cover-longform"),
|
|
readerDashaApplicability: "x",
|
|
englishUnavailable: "han_leak",
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
test("snapshots: older ones read without a reading; a stored reading must be well formed", () => {
|
|
const reading = (acceptReportReadingDraft(draft()) as { reading: unknown }).reading;
|
|
assert.equal(validateLongformSnapshot(snapshot(), identity).version, 2);
|
|
const stored = validateLongformSnapshot(snapshot({ reading }), identity);
|
|
assert.equal(stored.version === 2 && stored.reading?.sections.length, 8);
|
|
assert.equal(validateLongformSnapshot(snapshot({ readingUnavailable: "model_timeout" }), identity).version, 2);
|
|
assert.throws(() => validateLongformSnapshot(snapshot({ reading: { ...(reading as object), sections: [] } }), identity));
|
|
assert.throws(() => validateLongformSnapshot(snapshot({ readingUnavailable: "Model Timeout!" }), identity));
|
|
});
|
|
|
|
function readyRecord(): PersonalReportRecord {
|
|
return {
|
|
id: identity.reportId, userId: identity.userId, sessionId: null, chartProfileId: null,
|
|
requestId: identity.requestId, requestFingerprint: "f".repeat(64), reportType: "personal_full",
|
|
presentationMode: "default", depth: "standard", requestedThemes: ["career"], status: "ready",
|
|
evidenceHash: null, reportDocument: null, cardSummary: null, failureCode: null,
|
|
createdAt: "2026-10-27T00:00:00.000Z", completedAt: "2026-10-27T00:01:00.000Z", updatedAt: "2026-10-27T00:01:00.000Z",
|
|
} as unknown as PersonalReportRecord;
|
|
}
|
|
|
|
async function read(loadLongformReading?: Parameters<typeof resolveReportRead>[0]["loadLongformReading"]) {
|
|
return resolveReportRead({
|
|
requestUrl: "https://jyotisha.chat/api/reports/x", origin: null, allowedOrigins: [],
|
|
userId: identity.userId, reportId: identity.reportId,
|
|
persistence: { async getOwnedById() { return readyRecord(); } },
|
|
loadLongformAppendix: async () => ({ status: "ready", lastErrorCode: null, markdown: zhReader.markdown }),
|
|
...(loadLongformReading ? { loadLongformReading } : {}),
|
|
validateReadyDocument: () => ({ ok: false }),
|
|
});
|
|
}
|
|
|
|
test("the read API returns the reading when there is one and never fails the report over it", async () => {
|
|
const reading = (acceptReportReadingDraft(draft()) as { reading: unknown }).reading;
|
|
const ready = await read(async () => reading as never);
|
|
assert.equal(ready.status, 200);
|
|
assert.deepEqual(ready.body.reading, reading);
|
|
const none = await read(async () => null);
|
|
assert.equal("reading" in none.body, false);
|
|
const broken = await read(async () => { throw new Error("db down"); });
|
|
assert.equal(broken.status, 200);
|
|
assert.equal("reading" in broken.body, false);
|
|
assert.equal("reading" in (await read()).body, false);
|
|
});
|
|
|
|
test("the page keeps a reading only in the stored shape and shows it before the engine report in Chinese only", () => {
|
|
const reading = (acceptReportReadingDraft(draft()) as { reading: { sections: unknown[] } }).reading;
|
|
const envelope = (extra: Record<string, unknown>) => classifyReportEnvelope(200, {
|
|
report: { id: identity.reportId, status: "ready", createdAt: "2026-10-27T00:00:00.000Z" },
|
|
longformMarkdown: zhReader.markdown, ...extra,
|
|
});
|
|
const ready = envelope({ reading });
|
|
assert.equal(ready.phase === "markdown-ready" && ready.reading?.sections.length, 8);
|
|
const bad = envelope({ reading: { ...reading, sections: reading.sections.slice(1) } });
|
|
assert.equal(bad.phase === "markdown-ready" && "reading" in bad, false);
|
|
|
|
const markdown = "# 印度占星星盘报告\n\n## 出生资料与图盘\n\n引擎正文。\n";
|
|
const zh = renderToStaticMarkup(<PersonalReportMarkdownView markdown={markdown} reading={reading as never} language="zh" />);
|
|
const readingAt = zh.indexOf('id="report-reading"');
|
|
const basisAt = zh.indexOf("计算依据");
|
|
const engineAt = zh.indexOf('id="出生资料与图盘"');
|
|
assert.ok(zh.indexOf('href="#report-reading"') > -1 && zh.indexOf('href="#report-reading"') < zh.indexOf('href="#出生资料与图盘"'), "contents lists the reading first");
|
|
assert.ok(readingAt > zh.indexOf("印度占星星盘报告") && readingAt < basisAt && basisAt < engineAt, zh.slice(0, 400));
|
|
for (const title of ["总评", "现在这几年", "几条建议"]) assert.ok(zh.includes(`<h3>${title}</h3>`), title);
|
|
const en = renderToStaticMarkup(<PersonalReportMarkdownView markdown={markdown} reading={reading as never} language="en" />);
|
|
assert.equal(en.includes("report-reading"), false);
|
|
assert.equal(en.includes("计算依据"), false);
|
|
const legacy = renderToStaticMarkup(<PersonalReportMarkdownView markdown={markdown} language="zh" />);
|
|
assert.equal(legacy.includes("计算依据"), false);
|
|
});
|
|
|
|
test("the reading arrives with the report: no loader of its own, no request after reveal", () => {
|
|
const section = readFileSync(new URL("../src/components/personal-report/report-reading-section.tsx", import.meta.url), "utf8");
|
|
assert.doesNotMatch(section, /use(State|Effect)|fetch\(|Skeleton|Spinner|加载/);
|
|
const page = readFileSync(new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url), "utf8");
|
|
assert.doesNotMatch(page, /fetch\([^)]*reading/);
|
|
// The page bundle reads the contract, never the prompt module.
|
|
for (const file of ["personal-report-page.tsx", "personal-report-markdown-view.tsx", "report-reading-section.tsx"]) {
|
|
const source = readFileSync(new URL(`../src/components/personal-report/${file}`, import.meta.url), "utf8");
|
|
assert.doesNotMatch(source, /lib\/personal-report-reading"/, file);
|
|
}
|
|
});
|
|
|
|
test("the worker writes the reading in the report job and bills its tokens", () => {
|
|
const worker = readFileSync(new URL("../src/lib/personal-report-worker.ts", import.meta.url), "utf8");
|
|
assert.match(worker, /writeReading: createReportReadingWriter\(model\)/);
|
|
assert.match(worker, /inputTokens: generated\.usage\?\.inputTokens \?\? 0/);
|
|
assert.match(worker, /outputTokens: generated\.usage\?\.outputTokens \?\? 0/);
|
|
});
|
|
|
|
for (const mode of ["reading", "model-down", "no-writer"]) {
|
|
test(`report job with the reading: ${mode}`, () => {
|
|
const script = `
|
|
import assert from "node:assert/strict";
|
|
import { mock } from "node:test";
|
|
import { readFileSync } from "node:fs";
|
|
mock.module("server-only", { defaultExport: {} });
|
|
const { generatePersonalReportLongform } = await import("./src/lib/personal-report-longform-generate.ts");
|
|
const { readLongformSnapshot } = await import("./src/lib/personal-report-longform-snapshot.ts");
|
|
const packet = JSON.parse(readFileSync("tests/fixtures/report-density-fictional-engine.json", "utf8"));
|
|
const golden = JSON.parse(readFileSync("tests/fixtures/report-density-fictional-reader.json", "utf8"));
|
|
const mode = ${JSON.stringify(mode)};
|
|
const draft = ${JSON.stringify(draft())};
|
|
const facts = ${JSON.stringify(FACTS)};
|
|
const rows = new Map(); const bodies = [];
|
|
const admin = { async rpc(name, args) {
|
|
const table = "personal_report_sections"; const current = rows.get(table);
|
|
if (name === "ensure_personal_report_section" && !current) rows.set(table, { user_id: args.p_user_id, request_id: args.p_request_id, section_id: args.p_section_id, status: "pending", payload: null });
|
|
if (name === "complete_personal_report_section" && current?.status === "pending") rows.set(table, { ...current, status: "ready", payload: structuredClone(args.p_payload) });
|
|
return { data: [], error: null };
|
|
}, from(table) { return {
|
|
select() { return this; }, eq() { return this; },
|
|
async maybeSingle() { return { data: structuredClone(rows.get(table) ?? null), error: null }; },
|
|
async upsert(row) { rows.set(table, structuredClone(row)); return { error: null }; },
|
|
}; }};
|
|
const report = { id: "123e4567-e89b-12d3-a456-426614174000", userId: "123e4567-e89b-12d3-a456-426614174001", requestId: "123e4567-e89b-12d3-a456-426614174002", reportType: "personal_full", presentationMode: "default", depth: "standard", requestedThemes: ["career"], skillName: "jyotish-vedic-astrology", skillVersion: "6.9.14", skillSourceCommit: null, skillSnapshotSha256: "ab".repeat(32) };
|
|
const writeReading = mode === "no-writer" ? undefined : async () => {
|
|
if (mode === "model-down") throw new Error("model down");
|
|
return { body: draft, inputTokens: 1200, outputTokens: 800 };
|
|
};
|
|
const result = await generatePersonalReportLongform({ report, admin, profile: { birth_date: "2000-01-01", reported_birth_time: "12:00", birth_time_status: "reported", latitude: 0, longitude: 0, timezone_offset: 0, ayanamsa: "lahiri" }, candidateRange: null, displayName: "虚构测试", birthTimeStatus: "reported", now: () => new Date("2026-10-27T12:00:00Z"),
|
|
fetchImpl: async (_url, init) => { bodies.push(JSON.parse(init.body)); return Response.json({ format: "markdown", markdown: golden.markdown, fact_table_packet: packet, reader_dasha_applicability: golden.reader_dasha_applicability, ...(mode === "no-writer" ? {} : { reading_facts: facts }) }); },
|
|
...(writeReading ? { writeReading } : {}) });
|
|
assert.equal(result.status, "ready");
|
|
assert.equal(bodies.length, 1);
|
|
assert.equal(bodies[0].include_reading_facts, mode === "no-writer" ? undefined : true);
|
|
const stored = await readLongformSnapshot(admin, { reportId: report.id, userId: report.userId, requestId: report.requestId });
|
|
assert.equal(stored.status, "ready");
|
|
assert.equal(stored.snapshot.markdown, golden.markdown);
|
|
if (mode === "reading") {
|
|
assert.equal(stored.snapshot.reading.sections.length, 8);
|
|
assert.equal(stored.snapshot.reading.sections[0].title, "总评");
|
|
assert.deepEqual(result.usage, { inputTokens: 1200, outputTokens: 800 });
|
|
} else if (mode === "model-down") {
|
|
assert.equal(stored.snapshot.reading, undefined);
|
|
assert.equal(stored.snapshot.readingUnavailable, "model_failed");
|
|
assert.deepEqual(result.usage, { inputTokens: 0, outputTokens: 0 });
|
|
} else {
|
|
assert.equal(stored.snapshot.reading, undefined);
|
|
assert.equal(stored.snapshot.readingUnavailable, undefined);
|
|
assert.equal(result.usage, undefined);
|
|
}
|
|
`;
|
|
const result = spawnSync(process.execPath, ["--experimental-test-module-mocks", "--import", "tsx", "--input-type=module", "--eval", script], {
|
|
cwd: fileURLToPath(new URL("../", import.meta.url)), encoding: "utf8", timeout: 120_000,
|
|
});
|
|
assert.equal(result.status, 0, result.stderr + result.stdout);
|
|
});
|
|
}
|