feat(reports): a line under D9/D10 chart and table sections when the adopted segment left them undetermined (BUG-1139, T3)

Applied before the longform snapshot is hashed, Chinese and English bodies.
The engine payload is unchanged; legacy and unrectified reports store the
engine text byte-for-byte. Chart cards keep their grid (the line becomes the
card caption).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-10-01 10:15:10 +08:00
co-authored by Claude Opus 5.5
parent 7f39c9346d
commit 2004a6edc1
4 changed files with 180 additions and 3 deletions
@@ -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<Record<TargetChart, RegExp>> = {
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");
}
@@ -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();
@@ -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),
@@ -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/);
});