diff --git a/frontend/src/lib/personal-report-chart-caveats.ts b/frontend/src/lib/personal-report-chart-caveats.ts new file mode 100644 index 00000000..3c2d6abe --- /dev/null +++ b/frontend/src/lib/personal-report-chart-caveats.ts @@ -0,0 +1,45 @@ +import type { TargetChart } from "./rectification-agentic/core/segment-summary.ts"; + +/** + * Section caveats for charts a segment-v1 adoption could not determine (BUG-1139). + * + * The reader edition prints each divisional chart under its own heading + * ("##### D9 九分盘" + chart fence, "#### D9 九分盘 数值位置表" + table). One plain + * line goes directly under each such heading; the chart and table stay. A chart + * card accepts exactly one paragraph between heading and fence + * (`report-chart-grid-rehype.ts::parseCard`), so the line never splits a grid. + * Headings are matched on the chart code the engine writes, not on prose. + */ + +export const CHART_CAVEAT_ZH = "提示:出生时间范围内这张盘的上升会变,这一节仅供参考。"; +export const CHART_CAVEAT_EN = "Note: this chart's ascendant changes within the birth-time range; treat this section as reference only."; + +const HEADING_RE = /^(#{3,6})\s+(.+?)\s*$/; +// Engine chart labels: "D9 九分盘", "D9 Navamsa", "Navamsha"; "D10 Dasamsa"; "D1 Rashi". +const CHART_PATTERNS: Readonly> = { + D1: /^D1\b/, + D9: /^(?:D9\b|Navamsh?a\b)/, + D10: /^(?:D10\b|Dasamsh?a\b)/, +}; + +export function annotateUnreliableChartSections( + markdown: string, + vargas: readonly TargetChart[] | undefined, + language: "zh" | "en", +): string { + if (!vargas?.length) return markdown; + const caveat = language === "en" ? CHART_CAVEAT_EN : CHART_CAVEAT_ZH; + const lines = markdown.split("\n"); + const out: string[] = []; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]!; + out.push(line); + const heading = HEADING_RE.exec(line); + if (!heading || !vargas.some((varga) => CHART_PATTERNS[varga].test(heading[2]!))) continue; + let next = index + 1; + while (next < lines.length && !lines[next]!.trim()) next += 1; + if (lines[next]?.trim() === caveat) continue; + out.push("", caveat); + } + return out.join("\n"); +} diff --git a/frontend/src/lib/personal-report-longform-generate.ts b/frontend/src/lib/personal-report-longform-generate.ts index 519d7105..daa86ae5 100644 --- a/frontend/src/lib/personal-report-longform-generate.ts +++ b/frontend/src/lib/personal-report-longform-generate.ts @@ -5,6 +5,7 @@ import type { ReportFactTable } from "./report-fact-table-schema"; import { persistLongformSnapshot, readLongformSnapshot, validateLongformSnapshot, type LongformSnapshot } from "./personal-report-longform-snapshot"; import { acceptEnglishEdition, type EnglishOutcome } from "./personal-report-english-edition"; import { hashLongformMarkdown } from "./personal-report-longform-appendix"; +import { annotateUnreliableChartSections } from "./personal-report-chart-caveats"; import { LONGFORM_APPENDIX_TABLE, @@ -231,9 +232,21 @@ export async function generatePersonalReportLongform( fetchImpl: deps.fetchImpl ?? fetch, signal: deps.signal, }); + // Segment-v1 adoptions that left D9/D10 undetermined get a line under those + // chart sections before the snapshot is hashed (BUG-1139); no-op otherwise. + const unreliable = deps.candidateRange?.unreliableVargas; + const annotated = unreliable?.length ? { + ...generated, + markdown: annotateUnreliableChartSections(generated.markdown, unreliable, "zh"), + ...(generated.english ? { english: { + ...generated.english, + markdown: annotateUnreliableChartSections(generated.english.markdown, unreliable, "en"), + contentSha256: hashLongformMarkdown(annotateUnreliableChartSections(generated.english.markdown, unreliable, "en")), + } } : {}), + } : generated; snapshot = validateLongformSnapshot({ - ...generated, ...identity, kind: "longform_calculation_snapshot", version: 2, - contentSha256: hashLongformMarkdown(generated.markdown), + ...annotated, ...identity, kind: "longform_calculation_snapshot", version: 2, + contentSha256: hashLongformMarkdown(annotated.markdown), }, identity); // Durable complete snapshot precedes the shared Markdown cache and final report. deps.signal?.throwIfAborted(); diff --git a/frontend/tests/rectification-chart-tier-caveats-consult.test.ts b/frontend/tests/rectification-chart-tier-caveats-consult.test.ts index 5e58661f..9bb4a5cf 100644 --- a/frontend/tests/rectification-chart-tier-caveats-consult.test.ts +++ b/frontend/tests/rectification-chart-tier-caveats-consult.test.ts @@ -79,7 +79,7 @@ test("chat tool input: segment-v1 carries unreliable_vargas; legacy tool input i const legacy = await prepare(legacyAdoption); assert.deepEqual(segment.serverChart?.toolInput.unreliable_vargas, ["D9", "D10"]); assert.equal("unreliable_vargas" in (legacy.serverChart?.toolInput ?? {}), false); - const { unreliable_vargas: _dropped, ...rest } = segment.serverChart!.toolInput; + const rest = Object.fromEntries(Object.entries(segment.serverChart!.toolInput).filter(([key]) => key !== "unreliable_vargas")); assert.equal(JSON.stringify(rest), JSON.stringify(legacy.serverChart!.toolInput)); // Byte-identical snapshot of the legacy accepted tool input (red line 1). assert.equal(JSON.stringify(legacy.serverChart!.toolInput), diff --git a/frontend/tests/rectification-chart-tier-caveats-report.test.ts b/frontend/tests/rectification-chart-tier-caveats-report.test.ts new file mode 100644 index 00000000..fac46926 --- /dev/null +++ b/frontend/tests/rectification-chart-tier-caveats-report.test.ts @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { unified } from "unified"; +import remarkParse from "remark-parse"; +import remarkGfm from "remark-gfm"; +import remarkRehype from "remark-rehype"; +import { reportChartGrid } from "../src/lib/report-chart-grid-rehype.ts"; +import { annotateUnreliableChartSections, CHART_CAVEAT_EN, CHART_CAVEAT_ZH } from "../src/lib/personal-report-chart-caveats.ts"; + +// Real engine reader output for explicitly fictional births (fixtureProvenance.fictional). +const densityReader = JSON.parse(readFileSync(new URL("./fixtures/report-density-fictional-reader.json", import.meta.url), "utf8")); +const readerMain = JSON.parse(readFileSync(new URL("./fixtures/report-reader-main-fictional.json", import.meta.url), "utf8")); +const readerMainEn = JSON.parse(readFileSync(new URL("./fixtures/report-reader-main-fictional-en.json", import.meta.url), "utf8")); + +function chartCards(markdown: string): { cards: number; captions: string[] } { + const tree = unified().use(remarkParse).use(remarkGfm).use(remarkRehype).use(reportChartGrid) + .runSync(unified().use(remarkParse).use(remarkGfm).parse(markdown)) as { children: unknown[] }; + let cards = 0; + const captions: string[] = []; + const text = (node: { type?: string; value?: string; children?: unknown[] }): string => + node.type === "text" ? String(node.value ?? "") : (node.children ?? []).map((child) => text(child as never)).join(""); + const walk = (node: { properties?: { className?: unknown }; children?: unknown[]; tagName?: string }) => { + const classes = node.properties?.className; + if (Array.isArray(classes) && classes.includes("personal-report-chart-card")) { + cards += 1; + const caption = (node.children ?? []).find((child) => (child as { tagName?: string }).tagName === "p"); + if (caption) captions.push(text(caption as never)); + } + for (const child of node.children ?? []) walk(child as never); + }; + walk(tree as never); + return { cards, captions }; +} + +test("legacy and empty tiers leave reader Markdown byte-identical", () => { + for (const markdown of [densityReader.markdown, readerMain.markdown] as string[]) { + assert.equal(annotateUnreliableChartSections(markdown, undefined, "zh"), markdown); + assert.equal(annotateUnreliableChartSections(markdown, [], "zh"), markdown); + } +}); + +test("D9/D10 chart and table sections get one line each; charts and tables stay; idempotent", () => { + const raw = readerMain.markdown as string; + const annotated = annotateUnreliableChartSections(raw, ["D9", "D10"], "zh"); + const lines = annotated.split("\n"); + const flagged = lines.flatMap((line, index) => line === CHART_CAVEAT_ZH ? [lines[index - 2]] : []); + assert.deepEqual(flagged, ["##### Navamsha", "##### D9 九分盘", "##### D10 Dasamsa", "#### D9 九分盘 数值位置表", "#### D10 Dasamsa 数值位置表"]); + assert.equal(lines.filter((line) => line.startsWith("|")).length, raw.split("\n").filter((line) => line.startsWith("|")).length); + assert.equal(annotateUnreliableChartSections(annotated, ["D9", "D10"], "zh"), annotated); + // Only the named chart is touched: D1 / D10 / D11 / D12 headings stay bare when only D9 is unreliable. + const d9 = annotateUnreliableChartSections(raw, ["D9"], "zh").split("\n"); + assert.deepEqual(d9.flatMap((line, index) => line === CHART_CAVEAT_ZH ? [d9[index - 2]] : []), + ["##### Navamsha", "##### D9 九分盘", "#### D9 九分盘 数值位置表"]); +}); + +test("English edition gets the English line under its own chart labels", () => { + const annotated = annotateUnreliableChartSections(readerMainEn.markdown, ["D9"], "en"); + const lines = annotated.split("\n"); + assert.deepEqual(lines.flatMap((line, index) => line === CHART_CAVEAT_EN ? [lines[index - 2]] : []), + ["##### Navamsha", "##### D9 Navamsa", "#### D9 Navamsa Position Table"]); + assert.ok(!/[㐀-鿿]/.test(CHART_CAVEAT_EN)); +}); + +test("chart cards keep their grid: the line becomes the card's caption, never a split", () => { + const raw = densityReader.markdown as string; + const before = chartCards(raw); + const after = chartCards(annotateUnreliableChartSections(raw, ["D9", "D10"], "zh")); + assert.ok(before.cards >= 3); + assert.equal(after.cards, before.cards); + assert.equal(after.captions.filter((caption) => caption === CHART_CAVEAT_ZH).length, 3); +}); + +test("longform generation: engine payload unchanged; stored body annotated only for segment-v1 tiers", () => { + 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 { CHART_CAVEAT_ZH } = await import("./src/lib/personal-report-chart-caveats.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")); + async function run(candidateRange) { + const rows = new Map(); let body = ""; + 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 result = await generatePersonalReportLongform({ report, admin, candidateRange, displayName: "虚构测试", birthTimeStatus: "accepted", + profile: { birth_date: "2000-01-01", active_birth_time: "09:00", birth_time_status: "accepted", latitude: 0, longitude: 0, timezone_offset: 0, ayanamsa: "lahiri" }, + now: () => new Date("2026-10-01T12:00:00Z"), + fetchImpl: async (_url, init) => { body = init.body; return Response.json({ format: "markdown", markdown: golden.markdown, fact_table_packet: packet, reader_dasha_applicability: golden.reader_dasha_applicability }); } }); + return { body, stored: rows.get("personal_report_longform_appendices").markdown, charts: result.document.charts.length }; + } + const legacy = await run({ startTime: "08:00", endTime: "10:00" }); + const segment = await run({ startTime: "08:00", endTime: "10:00", unreliableVargas: ["D9", "D10"] }); + assert.equal(segment.body, legacy.body, "engine payload is identical"); + assert.equal(legacy.stored, golden.markdown, "legacy stored body is the engine text byte-for-byte"); + assert.equal(segment.stored.split("\\n").filter(line => line === CHART_CAVEAT_ZH).length, 3); + assert.equal(segment.stored.replaceAll("\\n\\n" + CHART_CAVEAT_ZH, ""), golden.markdown); + assert.equal(segment.charts, legacy.charts); + console.log("OK"); + `; + const result = spawnSync(process.execPath, ["--import", "tsx", "--experimental-test-module-mocks", "--input-type=module", "-e", script], { + cwd: fileURLToPath(new URL("..", import.meta.url)), encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr + result.stdout); + assert.match(result.stdout, /OK/); +});