@@ -370,6 +380,7 @@ export function PersonalReportMarkdownView({
{lead}
) : null}
+ {safeChapter === 0 && shownReading ?
: null}
{printing
? chapters.map((section, index) => (
@@ -394,13 +405,14 @@ export function PersonalReportMarkdownView({
return (
-
+
{lead ? (
) : null}
+ {shownReading ? : null}
{/* Each section is one chapter heading, in the same order as the article's h2s. */}
{outline.sections.map((section, index) => (section.eager ? null : (
{state.calculationCharts && }
diff --git a/frontend/src/components/personal-report/report-reading-section.tsx b/frontend/src/components/personal-report/report-reading-section.tsx
new file mode 100644
index 00000000..6babff65
--- /dev/null
+++ b/frontend/src/components/personal-report/report-reading-section.tsx
@@ -0,0 +1,30 @@
+import {
+ REPORT_READING_ANCHOR,
+ REPORT_READING_BASIS_LABEL,
+ REPORT_READING_HEADING,
+ type ReportReading,
+} from "@/lib/personal-report-reading-contract";
+
+/**
+ * The model-written reading in front of the engine report (issue #113 R1).
+ * Plain paragraphs under one heading; the engine report follows under the
+ * 「计算依据」 label. Rendered with the report, never loaded after it.
+ */
+export function ReportReadingSection({ reading }: { reading: ReportReading }) {
+ return (
+ <>
+
+ {REPORT_READING_HEADING}
+ {reading.sections.map((section) => (
+
+
{section.title}
+ {section.paragraphs.map((paragraph, index) => (
+
{paragraph}
+ ))}
+
+ ))}
+
+ {REPORT_READING_BASIS_LABEL}
+ >
+ );
+}
diff --git a/frontend/src/lib/personal-report-longform-generate.ts b/frontend/src/lib/personal-report-longform-generate.ts
index 33b0dec0..90972d3c 100644
--- a/frontend/src/lib/personal-report-longform-generate.ts
+++ b/frontend/src/lib/personal-report-longform-generate.ts
@@ -7,6 +7,7 @@ import { acceptEnglishEdition, type EnglishOutcome } from "./personal-report-eng
import { hashLongformMarkdown } from "./personal-report-longform-appendix";
import { annotateUnreliableChartSections } from "./personal-report-chart-caveats";
import { asRecord as record } from "@/lib/guards";
+import { writeReportReading, type ReportReadingWriter } from "./personal-report-reading";
import {
LONGFORM_APPENDIX_TABLE,
@@ -69,6 +70,8 @@ export type LongformGenerateDeps = Readonly<{
fetchImpl?: typeof fetch;
now?: () => Date;
signal?: AbortSignal;
+ /** Writes the reading in front of the engine report (issue #113 R1). Absent: no reading is requested. */
+ writeReading?: ReportReadingWriter;
}>;
function text(value: unknown): string | null {
@@ -135,7 +138,8 @@ async function fetchLongformMarkdown(input: Readonly<{
apiBase: string;
fetchImpl: typeof fetch;
signal?: AbortSignal;
-}>): Promise<{ markdown: string; factTables: ReportFactTable[]; charts: ReportDocumentV2["charts"]; readerDashaApplicability: string } & EnglishOutcome> {
+ includeReadingFacts?: boolean;
+}>): Promise<{ markdown: string; factTables: ReportFactTable[]; charts: ReportDocumentV2["charts"]; readerDashaApplicability: string; readingFacts?: unknown } & EnglishOutcome> {
const timeout = AbortSignal.timeout(ENGINE_TIMEOUT_MS);
const signal = input.signal
? AbortSignal.any([input.signal, timeout])
@@ -146,7 +150,10 @@ async function fetchLongformMarkdown(input: Readonly<{
const upstream = await input.fetchImpl(`${input.apiBase.replace(/\/$/, "")}/api/professional_report_reference`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
- body: JSON.stringify({ ...input.payload, include_fact_tables: true, edition: "full_data", languages: ["zh", "en"] }),
+ body: JSON.stringify({
+ ...input.payload, include_fact_tables: true, edition: "full_data", languages: ["zh", "en"],
+ ...(input.includeReadingFacts ? { include_reading_facts: true } : {}),
+ }),
cache: "no-store",
signal,
});
@@ -158,13 +165,14 @@ async function fetchLongformMarkdown(input: Readonly<{
upstream.status === 429 ? "upstream_busy" : "upstream_unavailable",
);
}
- const result = await upstream.json().catch(() => null) as { format?: unknown; markdown?: unknown; markdown_en?: unknown; english_unavailable?: unknown; fact_table_packet?: unknown; reader_dasha_applicability?: unknown } | null;
+ const result = await upstream.json().catch(() => null) as { format?: unknown; markdown?: unknown; markdown_en?: unknown; english_unavailable?: unknown; fact_table_packet?: unknown; reader_dasha_applicability?: unknown; reading_facts?: unknown } | null;
if (result?.format !== "markdown" || typeof result.markdown !== "string" || !result.markdown.trim()) {
throw new LongformGenerateError("calculation_unavailable", true, "empty_markdown");
}
return { markdown: result.markdown, factTables: assembleReportFactTables(result.fact_table_packet),
charts: assembleLongformCharts(result.fact_table_packet, "ev-cover-longform"),
readerDashaApplicability: typeof result.reader_dasha_applicability === "string" ? result.reader_dasha_applicability : "",
+ ...(input.includeReadingFacts ? { readingFacts: result.reading_facts } : {}),
...acceptEnglishEdition(result.markdown_en, result.english_unavailable, result.fact_table_packet) };
} catch (error) {
throw error;
@@ -206,6 +214,7 @@ export async function generatePersonalReportLongform(
}
let markdown = current?.status === "ready" && current.markdown ? current.markdown : null;
let snapshot: LongformSnapshot | null = null;
+ let readingUsage: { inputTokens: number; outputTokens: number } | null = null;
try {
const stored = await readLongformSnapshot(deps.admin, identity);
if (stored?.status === "ready") snapshot = stored.snapshot;
@@ -226,21 +235,38 @@ export async function generatePersonalReportLongform(
apiBase: deps.apiBase ?? process.env.JYOTISH_API_BASE ?? DEFAULT_API_BASE,
fetchImpl: deps.fetchImpl ?? fetch,
signal: deps.signal,
+ includeReadingFacts: Boolean(deps.writeReading),
});
+ const { readingFacts, ...engine } = generated;
+ // The reading is written before the snapshot, so the report reveals once with it (or without it).
+ const reading = deps.writeReading
+ ? await writeReportReading({
+ facts: readingFacts,
+ birthTimeConfirmed: deps.birthTimeStatus === "confirmed" || deps.birthTimeStatus === "accepted",
+ write: deps.writeReading,
+ signal: deps.signal,
+ })
+ : null;
+ if (reading) {
+ readingUsage = { inputTokens: reading.inputTokens, outputTokens: reading.outputTokens };
+ console.info(`[personal-report] reading=${reading.reading ? "ready" : reading.unavailable}`);
+ }
// 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")),
+ ...engine,
+ markdown: annotateUnreliableChartSections(engine.markdown, unreliable, "zh"),
+ ...(engine.english ? { english: {
+ ...engine.english,
+ markdown: annotateUnreliableChartSections(engine.english.markdown, unreliable, "en"),
+ contentSha256: hashLongformMarkdown(annotateUnreliableChartSections(engine.english.markdown, unreliable, "en")),
} } : {}),
- } : generated;
+ } : engine;
snapshot = validateLongformSnapshot({
...annotated, ...identity, kind: "longform_calculation_snapshot", version: 2,
+ ...(reading?.reading ? { reading: reading.reading } : {}),
+ ...(reading && !reading.reading ? { readingUnavailable: reading.unavailable ?? "model_failed" } : {}),
contentSha256: hashLongformMarkdown(annotated.markdown),
}, identity);
// Durable complete snapshot precedes the shared Markdown cache and final report.
@@ -282,6 +308,7 @@ export async function generatePersonalReportLongform(
status: "ready",
document,
evidenceHash: document.provenance.evidenceHash,
+ ...(readingUsage ? { usage: readingUsage } : {}),
};
} catch {
throw new LongformGenerateError("report_schema_invalid", false, "final_parse_rejected");
diff --git a/frontend/src/lib/personal-report-longform-snapshot.ts b/frontend/src/lib/personal-report-longform-snapshot.ts
index 1445c7ef..16401eea 100644
--- a/frontend/src/lib/personal-report-longform-snapshot.ts
+++ b/frontend/src/lib/personal-report-longform-snapshot.ts
@@ -2,6 +2,7 @@ import { z } from "zod";
import { hashLongformMarkdown } from "./personal-report-longform-appendix";
import { CHART_IDS, reportDocumentV2Schema } from "./personal-report-contract";
import { reportFactTablesSchema } from "./report-fact-table-schema";
+import { reportReadingSchema } from "./personal-report-reading-contract";
import type { AppendixClient } from "./personal-report-longform-generate";
/** Reserved server-owned cache, never a writer chapter or reader progress row. */
@@ -40,6 +41,9 @@ const snapshotSchema = z.union([
version: z.literal(2),
english: longformEnglishEditionSchema.optional(),
englishUnavailable: z.string().regex(/^[a-z_]{1,64}$/).optional(),
+ /** The model-written reading in front of the engine report (issue #113 R1). Older snapshots carry neither field. */
+ reading: reportReadingSchema.optional(),
+ readingUnavailable: z.string().regex(/^[a-z_]{1,64}$/).optional(),
}).refine(value => (value.english === undefined) !== (value.englishUnavailable === undefined), {
message: "a v2 snapshot carries exactly one of english / englishUnavailable",
}),
diff --git a/frontend/src/lib/personal-report-reading-contract.ts b/frontend/src/lib/personal-report-reading-contract.ts
new file mode 100644
index 00000000..1ff44b1f
--- /dev/null
+++ b/frontend/src/lib/personal-report-reading-contract.ts
@@ -0,0 +1,49 @@
+/**
+ * Shape of the report reading (issue #113 R1) shared by the server and the
+ * report page. No prompt text here, so the page bundle stays small.
+ */
+import { z } from "zod";
+
+export const REPORT_READING_SECTIONS = [
+ { id: "overview", title: "总评" },
+ { id: "character", title: "性格" },
+ { id: "career", title: "事业" },
+ { id: "wealth", title: "财富" },
+ { id: "love", title: "感情" },
+ { id: "health", title: "健康" },
+ { id: "now", title: "现在这几年" },
+ { id: "advice", title: "几条建议" },
+] as const;
+
+export type ReportReadingSectionId = (typeof REPORT_READING_SECTIONS)[number]["id"];
+export const SECTION_IDS = REPORT_READING_SECTIONS.map((section) => section.id) as [ReportReadingSectionId, ...ReportReadingSectionId[]];
+
+/** Label above the engine report once a reading sits in front of it. */
+export const REPORT_READING_BASIS_LABEL = "计算依据";
+export const REPORT_READING_HEADING = "解读";
+/** Heading id of the reading; also its entry in the report contents. */
+export const REPORT_READING_ANCHOR = "report-reading";
+export const MAX_PARAGRAPH_CHARS = 420;
+export const MIN_TOTAL_CHARS = 900;
+export const MAX_TOTAL_CHARS = 9_000;
+
+/** What the model returns. Titles are ours, not the model's. */
+export const reportReadingDraftSchema = z.object({
+ sections: z.array(z.object({
+ id: z.enum(SECTION_IDS),
+ paragraphs: z.array(z.string().min(1)).min(1).max(5),
+ }).strict()).length(REPORT_READING_SECTIONS.length),
+}).strict();
+export type ReportReadingDraft = z.infer;
+
+/** What is stored in the calculation snapshot and returned by the read API. */
+export const reportReadingSchema = z.strictObject({
+ version: z.literal(1),
+ language: z.literal("zh"),
+ sections: z.array(z.strictObject({
+ id: z.enum(SECTION_IDS),
+ title: z.string().min(1).max(12),
+ paragraphs: z.array(z.string().min(1).max(MAX_PARAGRAPH_CHARS)).min(1).max(5),
+ })).length(REPORT_READING_SECTIONS.length),
+});
+export type ReportReading = z.infer;
diff --git a/frontend/src/lib/personal-report-reading.ts b/frontend/src/lib/personal-report-reading.ts
new file mode 100644
index 00000000..b8f98d31
--- /dev/null
+++ b/frontend/src/lib/personal-report-reading.ts
@@ -0,0 +1,202 @@
+/**
+ * The model-written reading at the front of the personal report (issue #113 R1,
+ * product decision 2026-10-27 「R1 C1」).
+ *
+ * The engine report stays as it is and moves behind this reading as the
+ * calculation basis. The reading is one structured model call over the
+ * engine's own facts (`reading_facts` from /api/professional_report_reference,
+ * the same leaves the chat card uses). It is optional: any failure, timeout or
+ * rejected draft leaves the report without a reading, never failed.
+ */
+import {
+ CHART_REASON_RULE,
+ HOUSE_LIFE_AREA_RULE,
+ PLAIN_SPEECH_RULE,
+ TIMING_FOCUS_RULE,
+} from "./consultation-thinking-plan";
+import {
+ MAX_PARAGRAPH_CHARS,
+ MAX_TOTAL_CHARS,
+ MIN_TOTAL_CHARS,
+ REPORT_READING_SECTIONS,
+ reportReadingDraftSchema,
+ type ReportReading,
+} from "./personal-report-reading-contract";
+
+export {
+ REPORT_READING_BASIS_LABEL,
+ REPORT_READING_HEADING,
+ REPORT_READING_SECTIONS,
+ reportReadingSchema,
+ type ReportReading,
+} from "./personal-report-reading-contract";
+export const REPORT_READING_TIMEOUT_MS = 120_000;
+
+/** Never in the reading: deterministic fate, medical, investment (AGENTS Part B4). */
+const BANNED_PHRASES = [
+ "注定", "先苦后甜", "宿命", "命中注定", "必然会", "一定会",
+ "癌", "肿瘤", "糖尿病", "高血压", "心脏病", "抑郁症", "焦虑症", "不孕", "流产", "早逝", "寿命", "死亡", "去世",
+ "股票", "基金", "炒股", "期货", "比特币", "加密货币", "买入", "卖出", "抄底", "投资建议",
+ // Internal wording the engine report leaked (research §4.1).
+ "本仓", "检测器", "字段", "口径",
+ // One name per thing, same as the chat (罗睺 / 计都).
+ "北交点", "南交点", "克图", "凯图",
+];
+const DISCLAIMER = /仅供参考|不构成|请咨询专业|不能替代/g;
+
+export type ReadingRejection =
+ | "shape"
+ | "latin"
+ | "field_name"
+ | "banned_phrase"
+ | "too_long"
+ | "too_short"
+ | "disclaimer";
+
+/** The draft becomes the stored reading only when every check passes. */
+export function acceptReportReadingDraft(value: unknown):
+ | { ok: true; reading: ReportReading }
+ | { ok: false; reason: ReadingRejection } {
+ const parsed = reportReadingDraftSchema.safeParse(value);
+ if (!parsed.success) return { ok: false, reason: "shape" };
+ const sections = parsed.data.sections;
+ if (sections.some((section, index) => section.id !== REPORT_READING_SECTIONS[index]!.id)) {
+ return { ok: false, reason: "shape" };
+ }
+ let total = 0;
+ for (const section of sections) {
+ let disclaimers = 0;
+ for (const raw of section.paragraphs) {
+ const paragraph = raw.trim();
+ if (!paragraph) return { ok: false, reason: "shape" };
+ if (paragraph.length > MAX_PARAGRAPH_CHARS) return { ok: false, reason: "too_long" };
+ if (/[A-Za-z]/.test(paragraph)) return { ok: false, reason: "latin" };
+ if (/[_={}<>#*|`]/.test(paragraph)) return { ok: false, reason: "field_name" };
+ if (BANNED_PHRASES.some((phrase) => paragraph.includes(phrase))) return { ok: false, reason: "banned_phrase" };
+ disclaimers += paragraph.match(DISCLAIMER)?.length ?? 0;
+ total += paragraph.length;
+ }
+ if (disclaimers > 1) return { ok: false, reason: "disclaimer" };
+ }
+ if (total < MIN_TOTAL_CHARS) return { ok: false, reason: "too_short" };
+ if (total > MAX_TOTAL_CHARS) return { ok: false, reason: "too_long" };
+ return {
+ ok: true,
+ reading: {
+ version: 1,
+ language: "zh",
+ sections: sections.map((section, index) => ({
+ id: section.id,
+ title: REPORT_READING_SECTIONS[index]!.title,
+ paragraphs: section.paragraphs.map((paragraph) => paragraph.trim()),
+ })),
+ },
+ };
+}
+
+/** Facts are usable only in the schema the engine promises and without a gap marker. */
+export function acceptReadingFacts(value: unknown): Record | null {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
+ const facts = value as Record;
+ if (facts.schema !== "report-reading-facts-v1" || Array.isArray(facts.gaps)) return null;
+ if (!facts.planets || !facts.domains || !facts.timing) return null;
+ return facts;
+}
+
+export const REPORT_READING_SYSTEM = [
+ "你在给一份个人星盘报告写开头的「解读」。读者不懂占星。你只用用户消息里给的盘面事实,不编事实,不另起算法。",
+ "写法:像一个懂行、说话温和的姐姐,对「你」说话,语气笃定但不夸张。每段一条链:先点出盘上的一两个配置,用半句白话说它为什么这样起作用,再落到你在工作、家里、钱上、感情里会是什么样子。几条因素合起来说成一个人,不逐条罗列。",
+ CHART_REASON_RULE,
+ HOUSE_LIFE_AREA_RULE,
+ TIMING_FOCUS_RULE,
+ PLAIN_SPEECH_RULE,
+ "八节固定,按顺序:总评(三五句说清这张盘最突出的两三点)、性格、事业、财富、感情、健康、现在这几年(当前大运和接下来两段子运,写起止年月)、几条建议(两到四条,和盘面直接相关的具体做法)。每节一到四段,每段不超过四百字,全篇两千到六千字。",
+ "术语只用中文,同一个东西一个叫法:罗睺、计都;大运、子运、小运。不写任何英文字母或梵文音译,不写分盘代号(写九分盘、事业分盘),不写字段名、分数原值、「本仓」「检测器」这类内部说法。力量只说强、中、弱。",
+ "红线:不写病名、诊断和身体部位的疾病;健康只说哪段时间要多休息、哪方面的作息要留意。不给投资指令,不点名股票、基金、币。不写「注定」「先苦后甜」「宿命」「一定会」。婚姻、生死不下确定结论。每节最多一句提醒(例如「这只是倾向,实际怎样以你的经历为准」),不逐句免责,不写「可能」一连串。",
+ "出生时间如果不是确认过的,不把上升、宫位说成定论,也不写「校正」「确认」。",
+ "只输出 JSON:{\"sections\":[{\"id\":\"overview\",\"paragraphs\":[\"……\"]}, …]},八节的 id 依次是 overview、character、career、wealth、love、health、now、advice。",
+].join("\n");
+
+export function reportReadingUserMessage(facts: Record, birthTimeConfirmed: boolean): string {
+ return [
+ birthTimeConfirmed ? "出生时间:已确认。" : "出生时间:用户填报,没有确认到分钟。",
+ "盘面事实(引擎已算好,只能用这些):",
+ JSON.stringify(facts),
+ ].join("\n");
+}
+
+export type ReportReadingWriter = (input: Readonly<{
+ system: string;
+ user: string;
+ signal: AbortSignal;
+}>) => Promise<{ body: unknown; inputTokens: number; outputTokens: number }>;
+
+export type ReportReadingOutcome = Readonly<{
+ reading: ReportReading | null;
+ /** Why there is no reading; absent when there is one. */
+ unavailable?: string;
+ inputTokens: number;
+ outputTokens: number;
+}>;
+
+function timedSignal(parent: AbortSignal | undefined, timeoutMs: number) {
+ const controller = new AbortController();
+ const timer = globalThis.setTimeout(() => controller.abort(new Error("reading_timeout")), timeoutMs);
+ const onParent = () => controller.abort(parent?.reason ?? new Error("aborted"));
+ if (parent) {
+ if (parent.aborted) onParent();
+ else parent.addEventListener("abort", onParent, { once: true });
+ }
+ // A writer that ignores its signal must still lose the race.
+ const lost = new Promise((_, reject) => {
+ if (controller.signal.aborted) reject(controller.signal.reason);
+ else controller.signal.addEventListener("abort", () => reject(controller.signal.reason), { once: true });
+ });
+ lost.catch(() => undefined);
+ return {
+ signal: controller.signal,
+ lost,
+ dispose: () => {
+ globalThis.clearTimeout(timer);
+ parent?.removeEventListener("abort", onParent);
+ },
+ };
+}
+
+/** One attempt plus one retry. Never throws, except when the job itself is aborted. */
+export async function writeReportReading(input: Readonly<{
+ facts: unknown;
+ birthTimeConfirmed: boolean;
+ write: ReportReadingWriter;
+ signal?: AbortSignal;
+ timeoutMs?: number;
+ attempts?: number;
+}>): Promise {
+ const facts = acceptReadingFacts(input.facts);
+ if (!facts) return { reading: null, unavailable: "facts_unavailable", inputTokens: 0, outputTokens: 0 };
+ const user = reportReadingUserMessage(facts, input.birthTimeConfirmed);
+ let inputTokens = 0;
+ let outputTokens = 0;
+ let unavailable = "model_failed";
+ for (let attempt = 0; attempt < (input.attempts ?? 2); attempt += 1) {
+ input.signal?.throwIfAborted();
+ const timed = timedSignal(input.signal, input.timeoutMs ?? REPORT_READING_TIMEOUT_MS);
+ try {
+ const result = await Promise.race([
+ input.write({ system: REPORT_READING_SYSTEM, user, signal: timed.signal }),
+ timed.lost,
+ ]);
+ inputTokens += Math.max(0, result.inputTokens);
+ outputTokens += Math.max(0, result.outputTokens);
+ const accepted = acceptReportReadingDraft(result.body);
+ if (accepted.ok) return { reading: accepted.reading, inputTokens, outputTokens };
+ unavailable = `rejected_${accepted.reason}`;
+ } catch {
+ if (input.signal?.aborted) throw input.signal.reason ?? new Error("aborted");
+ unavailable = timed.signal.aborted ? "model_timeout" : "model_failed";
+ } finally {
+ timed.dispose();
+ }
+ }
+ return { reading: null, unavailable, inputTokens, outputTokens };
+}
diff --git a/frontend/src/lib/personal-report-route-core.ts b/frontend/src/lib/personal-report-route-core.ts
index 0b2c662d..6326f41c 100644
--- a/frontend/src/lib/personal-report-route-core.ts
+++ b/frontend/src/lib/personal-report-route-core.ts
@@ -1,5 +1,6 @@
import type { ReportFactTable } from "./report-fact-table-schema";
import type { EnglishEditionStatus } from "./report-language";
+import type { ReportReading } from "./personal-report-reading-contract";
import { parseReportCandidateRange, type ReportCandidateClockRange } from "./report-candidate-range";
/**
* Personal report route handlers as a dependency-injected core (no
@@ -707,6 +708,12 @@ export type ReportReadCoreDeps = Readonly<{
reportId: string;
requestId: string;
}>) => Promise;
+ /** The model-written reading in front of the report (issue #113 R1); null when the report has none. */
+ loadLongformReading?: (input: Readonly<{
+ userId: string;
+ reportId: string;
+ requestId: string;
+ }>) => Promise;
}>;
export type EnglishEditionRead =
@@ -775,10 +782,16 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise ({ status: "unavailable" }))
: { status: "legacy" };
+ // The reading is optional like the English edition; a failed read leaves it out.
+ const reading = deps.loadLongformReading
+ ? await deps.loadLongformReading({ userId: deps.userId, reportId: deps.reportId, requestId: row.requestId })
+ .catch(() => null)
+ : null;
return {
status: 200,
body: {
report: reportView(row, job),
+ ...(reading ? { reading } : {}),
englishEdition: english.status,
...(english.status === "ready" ? {
longformMarkdownEn: projectOrdinaryReportMarkdown(english.markdown, "en"),
diff --git a/frontend/src/lib/personal-report-worker.ts b/frontend/src/lib/personal-report-worker.ts
index 2f2442ef..6162f860 100644
--- a/frontend/src/lib/personal-report-worker.ts
+++ b/frontend/src/lib/personal-report-worker.ts
@@ -6,6 +6,7 @@ import { asRecord as record, finiteNumberOrNull as finiteNumber } from "@/lib/gu
import { runConsultationWorkflow } from "@/mastra";
import { createPersonalReportAgent } from "@/mastra/personal-report";
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
+import { createReportReadingWriter } from "@/mastra/personal-report-reading";
import {
buildReportEvidenceBundleV2,
generatePersonalReport,
@@ -220,13 +221,15 @@ async function generateLongformReport(
displayName,
birthTimeStatus: usableBirth.status,
signal: context.signal,
+ writeReading: createReportReadingWriter(model),
});
if (generated.status !== "ready") return generated;
return {
...generated,
usage: {
- inputTokens: 0,
- outputTokens: 0,
+ // Only the reading (issue #113 R1) calls a model; the engine report costs no tokens.
+ inputTokens: generated.usage?.inputTokens ?? 0,
+ outputTokens: generated.usage?.outputTokens ?? 0,
actualModelId: model.id,
modelConfigVersion: model.configVersion,
},
diff --git a/frontend/src/mastra/personal-report-reading.ts b/frontend/src/mastra/personal-report-reading.ts
new file mode 100644
index 00000000..2f4d80ad
--- /dev/null
+++ b/frontend/src/mastra/personal-report-reading.ts
@@ -0,0 +1,34 @@
+import { Agent } from "@mastra/core/agent";
+import type { z } from "zod";
+
+import type { ResolvedLanguageModel } from "./model.ts";
+import { reportReadingDraftSchema } from "@/lib/personal-report-reading-contract";
+import type { ReportReadingWriter } from "@/lib/personal-report-reading";
+
+/** One structured call for the report reading (issue #113 R1). Same pattern as the synastry section writer. */
+export function createReportReadingWriter(model: ResolvedLanguageModel): ReportReadingWriter {
+ const agent = new Agent({
+ id: `personal-report-reading-${model.id}`,
+ name: "Personal Report Reading",
+ model: model.model,
+ instructions: "Follow the system message. Return only the requested JSON.",
+ });
+ return async function writeReading(input) {
+ const result = await agent.generate(
+ [
+ { role: "system", content: input.system },
+ { role: "user", content: input.user },
+ ],
+ {
+ abortSignal: input.signal,
+ structuredOutput: { schema: reportReadingDraftSchema as z.ZodType, jsonPromptInjection: "inline" },
+ },
+ );
+ const usage = await Promise.resolve((result as { usage?: { inputTokens?: number; outputTokens?: number } }).usage);
+ return {
+ body: (result as { object?: unknown }).object,
+ inputTokens: Math.max(0, usage?.inputTokens ?? 0),
+ outputTokens: Math.max(0, usage?.outputTokens ?? 0),
+ };
+ };
+}
diff --git a/frontend/tests/personal-report-view.test.ts b/frontend/tests/personal-report-view.test.ts
index 31b2e3df..5ecedecf 100644
--- a/frontend/tests/personal-report-view.test.ts
+++ b/frontend/tests/personal-report-view.test.ts
@@ -238,7 +238,11 @@ test("the report TOC is a persistent right-hand rail that leaves the paper palet
// 原值:`//`
// 新值:同一 headings 之外允许并只允许多一个 `language` 属性
// 原因:TASK-report-english-edition-20260929 E6——目录标签随报告语言切换;目录仍只从同一份 outline 的 heading id 构建
- assert.match(viewSource, //);
+ // 原值(2):`//`
+ // 新值(2):`tocHeadings` = 有解读时在 outline.headings 前加一条「解读」,其余仍是同一份 outline 的 heading id
+ // 原因(2):issue #113 R1(产品 2026-10-27 拍板「R1 C1」):报告前面的解读要进目录
+ assert.match(viewSource, //);
+ assert.match(viewSource, /\[\{ id: READING_ANCHOR, level: 2, title: REPORT_READING_HEADING \}, \.\.\.outline\.headings\] : outline\.headings/);
assert.match(viewSource, /aria-current=\{item\.id === activeId \? "location" : undefined\}/);
assert.match(viewSource, /rehypePlugins=\{\[reportChartGrid\]\}/, "BUG-616/617 grid stays wired");
diff --git a/frontend/tests/personal-report-worker.test.ts b/frontend/tests/personal-report-worker.test.ts
index 95524f31..64d0e484 100644
--- a/frontend/tests/personal-report-worker.test.ts
+++ b/frontend/tests/personal-report-worker.test.ts
@@ -872,7 +872,10 @@ test("instrumentation retains the Skill guard and starts a singleton Node worker
assert.match(productionAdapter, /loadReportCandidateRange/);
assert.match(productionAdapter, /generatePersonalReportLongform/);
assert.match(productionAdapter, /PERSONAL_REPORT_WRITER_ENABLED/);
- assert.match(productionAdapter, /inputTokens: 0/);
+ // 原值: assert.match(productionAdapter, /inputTokens: 0/)
+ // 新值: 报告 token 取解读那一次模型调用的用量,没有解读时为 0
+ // 原因: issue #113 R1(产品 2026-10-27 拍板「R1 C1」):报告前面多一次模型写的解读,用量按实际 token 记账
+ assert.match(productionAdapter, /inputTokens: generated\.usage\?\.inputTokens \?\? 0/);
assert.doesNotMatch(productionAdapter, /actualModelId: "unknown"/);
assert.doesNotMatch(productionAdapter, /from\("agentic_rectification_cases"\)/);
assert.doesNotMatch(productionAdapter, /from\("birth_time_rectification_cases"\)/);
diff --git a/frontend/tests/report-reading-20261027.test.tsx b/frontend/tests/report-reading-20261027.test.tsx
new file mode 100644
index 00000000..07d7ade1
--- /dev/null
+++ b/frontend/tests/report-reading-20261027.test.tsx
@@ -0,0 +1,303 @@
+/**
+ * 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> = {}) {
+ 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("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}本仓功能吉凶层这样判。`] })],
+ ["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 = {}) {
+ 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[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) => 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();
+ 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(`${title}
`), title);
+ const en = renderToStaticMarkup();
+ assert.equal(en.includes("report-reading"), false);
+ assert.equal(en.includes("计算依据"), false);
+ const legacy = renderToStaticMarkup();
+ 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);
+ });
+}
diff --git a/scripts/professional_report_reference.py b/scripts/professional_report_reference.py
index 7bf14215..0b78e06b 100644
--- a/scripts/professional_report_reference.py
+++ b/scripts/professional_report_reference.py
@@ -195,6 +195,18 @@ def _export_args(birth: dict[str, Any]) -> SimpleNamespace:
return SimpleNamespace(**payload)
+def _reading_facts(packet: dict, full_reading: dict, minute_confirmed: bool, today: str | None) -> dict[str, Any]:
+ """Facts for the model-written reading (issue #113 R1). Never fails the report."""
+ try:
+ try:
+ from scripts.report_render.reading_facts import build_report_reading_facts
+ except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution path
+ from report_render.reading_facts import build_report_reading_facts
+ return build_report_reading_facts(packet, full_reading, minute_confirmed=minute_confirmed, today=today)
+ except Exception as exc: # noqa: BLE001 - the reading is optional, the report is not
+ return {"schema": "report-reading-facts-v1", "gaps": ["reading_facts_unavailable"], "error_type": type(exc).__name__}
+
+
def _load_report_cache():
try:
from scripts import consult_full_data_cache as cache
@@ -215,6 +227,7 @@ def _report_cache_identity(
languages: list[str],
include_fact_tables: bool,
sha: str,
+ reading: dict[str, Any] | None = None,
) -> dict[str, Any]:
return {
"birth": birth,
@@ -225,6 +238,8 @@ def _report_cache_identity(
"include_fact_tables": include_fact_tables,
"report_version": REPORT_VERSION_FULL,
"github_sha": sha,
+ "include_reading_facts": reading is not None,
+ **({"reading": reading} if reading is not None else {}),
}
@@ -237,6 +252,7 @@ def _assemble_professional_report_reference(
languages: list[str],
include_fact_tables: bool,
engine,
+ reading: dict[str, Any] | None = None,
) -> dict[str, Any]:
full_reading = handler._compute_full_reading_for_thematic(birth)
resolved_engine = engine or _load_engine()
@@ -270,6 +286,9 @@ def _assemble_professional_report_reference(
"reader_dasha_applicability": clean_reader_appendix_markdown(render_dasha_applicability(packet))}
if include_fact_tables else {}),
**(english if edition == FULL_DATA_EDITION and "en" in languages else {}),
+ **({"reading_facts": _reading_facts(
+ packet, full_reading, reading["minute_confirmed"], reading.get("today"),
+ )} if reading is not None and edition == FULL_DATA_EDITION else {}),
}
return {
"format": "json",
@@ -278,6 +297,21 @@ def _assemble_professional_report_reference(
}
+def _reading_request(body: dict[str, Any]) -> dict[str, Any] | None:
+ """`include_reading_facts: true` asks for the reading facts; off by default."""
+ if body.get("include_reading_facts") is not True:
+ return None
+ try:
+ from scripts.consult_card_domain_facts import minute_confirmed_from_body
+ except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution path
+ from consult_card_domain_facts import minute_confirmed_from_body
+ today = body.get("today")
+ return {
+ "minute_confirmed": minute_confirmed_from_body(body),
+ "today": today if isinstance(today, str) else None,
+ }
+
+
def build_professional_report_reference(handler, body: dict[str, Any], *, engine=None) -> dict[str, Any]:
"""Build one JSON or Markdown response from one reused full-reading result."""
if not isinstance(body, dict):
@@ -291,6 +325,7 @@ def build_professional_report_reference(handler, body: dict[str, Any], *, engine
birth = handler._high_rigor_birth_payload(body)
include_fact_tables = body.get("include_fact_tables") is True
+ reading = _reading_request(body) if edition == FULL_DATA_EDITION and output_format == "markdown" else None
sha = _report_cache_sha()
use_cache = (
report_reference_cache_enabled()
@@ -300,16 +335,16 @@ def build_professional_report_reference(handler, body: dict[str, Any], *, engine
)
if not use_cache:
return _assemble_professional_report_reference(
- handler, birth, output_format, edition, packs, languages, include_fact_tables, engine,
+ handler, birth, output_format, edition, packs, languages, include_fact_tables, engine, reading,
)
identity = _report_cache_identity(
- birth, output_format, edition, packs, languages, include_fact_tables, sha,
+ birth, output_format, edition, packs, languages, include_fact_tables, sha, reading,
)
started = time.perf_counter()
def builder(_identity: dict[str, Any]) -> dict[str, Any]:
return _assemble_professional_report_reference(
- handler, birth, output_format, edition, packs, languages, include_fact_tables, engine,
+ handler, birth, output_format, edition, packs, languages, include_fact_tables, engine, reading,
)
cached = _load_report_cache().get_or_build(
diff --git a/scripts/report_render/reading_facts.py b/scripts/report_render/reading_facts.py
new file mode 100644
index 00000000..4b24ae3d
--- /dev/null
+++ b/scripts/report_render/reading_facts.py
@@ -0,0 +1,126 @@
+"""Facts for the model-written reading at the front of the report (issue #113 R1).
+
+The reading is written by a model, but only from what the engine already
+computed. This module copies the consult-card leaves (the same facts the chat
+uses) from the report's own full-data packet, drops paths and gaps, and adds
+the current dasha dates. It computes nothing new.
+"""
+
+from __future__ import annotations
+
+import copy
+from typing import Any
+
+try:
+ from scripts.consult_card_domain_facts import assert_fact_leaves, build_consult_card_facts, dig
+ from scripts.consult_full_data_cache import _attach_graha_drishti
+except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution path
+ from consult_card_domain_facts import assert_fact_leaves, build_consult_card_facts, dig
+ from consult_full_data_cache import _attach_graha_drishti
+
+READING_FACTS_SCHEMA = "report-reading-facts-v1"
+READING_DOMAINS = ("general", "career", "wealth", "marriage", "health")
+DASHA = "worksheets.timing_and_predictive_systems.dasha.current_dasha"
+UPCOMING_SUB_PERIODS = 2
+
+
+def _compact(node: Any) -> Any:
+ """Leaf values only. Gaps, withheld and blocked leaves are left out."""
+ if isinstance(node, dict):
+ if "status" in node and "check" in node:
+ return node.get("value") if node["status"] == "value" else None
+ out = {}
+ for key, value in node.items():
+ kept = _compact(value)
+ if kept is not None and kept != {} and kept != []:
+ out[key] = kept
+ return out
+ if isinstance(node, list):
+ return [item for item in (_compact(entry) for entry in node) if item is not None]
+ return node
+
+
+def _period(row: Any) -> dict[str, Any] | None:
+ if not isinstance(row, dict) or not isinstance(row.get("lord"), str):
+ return None
+ out = {"lord": row["lord"]}
+ for key in ("start", "end"):
+ if isinstance(row.get(key), str):
+ out[key] = row[key]
+ return out
+
+
+def _dasha_dates(packet: dict) -> dict[str, Any]:
+ current = dig(packet, DASHA)
+ if not isinstance(current, dict):
+ return {}
+ dates: dict[str, Any] = {}
+ md = _period(current)
+ if md:
+ dates["mahadasha"] = md
+ rows = current.get("antardasha_timeline")
+ if isinstance(rows, list):
+ index = next((i for i, row in enumerate(rows) if isinstance(row, dict) and row.get("is_current") is True), None)
+ if index is not None:
+ now = _period(rows[index])
+ if now:
+ dates["antardasha"] = now
+ upcoming = [_period(row) for row in rows[index + 1:index + 1 + UPCOMING_SUB_PERIODS]]
+ dates["next_antardashas"] = [row for row in upcoming if row]
+ return dates
+
+
+def _domain_factors(row: dict) -> list[dict[str, Any]]:
+ factors = []
+ for factor in row.get("factors") or []:
+ if isinstance(factor, dict) and factor.get("kind") == "planet" and isinstance(factor.get("planet"), str):
+ factors.append({"role": factor.get("role"), "planet": factor["planet"]})
+ return factors
+
+
+def build_report_reading_facts(
+ packet: dict,
+ full_reading: dict,
+ *,
+ minute_confirmed: bool,
+ today: str | None = None,
+) -> dict[str, Any]:
+ """Compact, packet-sourced facts for the reading. Raises on any non-packet leaf."""
+ working = _attach_graha_drishti(copy.deepcopy(packet), full_reading)
+ raw = build_consult_card_facts(working, list(READING_DOMAINS), minute_confirmed=minute_confirmed)
+ rows = {row["domain"]: row for row in raw["domains"]}
+ for row in rows.values():
+ assert_fact_leaves(working, row)
+ general = rows["general"]
+ planets = {}
+ for name, record in general["planets"].items():
+ kept = _compact({key: value for key, value in record.items() if key != "domain_varga_house"})
+ if kept.get("present"):
+ kept.pop("present")
+ planets[name] = kept
+ domains = {}
+ for name, row in rows.items():
+ domains[name] = {
+ "varga": row["domain_varga"],
+ "houses": _compact(row["houses"]),
+ "planets_in_varga": {
+ planet: house
+ for planet, record in row["planets"].items()
+ if (house := _compact(record.get("domain_varga_house"))) is not None
+ },
+ "significators": _domain_factors(row),
+ }
+ ascendant = dig(packet, "core_chart.ascendant")
+ return {
+ "schema": READING_FACTS_SCHEMA,
+ "minute_confirmed": minute_confirmed,
+ **({"today": today} if today else {}),
+ "ascendant": _compact({
+ key: {"status": "value", "check": "path", "value": ascendant.get(key)}
+ for key in ("sign", "lord", "nakshatra")
+ if isinstance(ascendant, dict) and ascendant.get(key) is not None
+ }),
+ "planets": planets,
+ "domains": domains,
+ "timing": {**_compact(general["timing"]), **_dasha_dates(packet)},
+ }
diff --git a/tests/test_report_reading_facts.py b/tests/test_report_reading_facts.py
new file mode 100644
index 00000000..8f849cbc
--- /dev/null
+++ b/tests/test_report_reading_facts.py
@@ -0,0 +1,167 @@
+"""Reading facts for the model-written report reading (issue #113 R1).
+
+Clearly fictional chart only. The facts must be copies of the report's own
+packet (no new algorithm), the flag must default off with the response
+unchanged, and the flag must be part of the report cache key.
+"""
+
+from __future__ import annotations
+
+import copy
+import json
+
+import pytest
+
+import scripts.professional_report_reference as reference
+from scripts.consult_card_domain_facts import dig
+from scripts.jyotish_engine import build_professional_report_reference_packet, cmd_full_reading
+from scripts.professional_report_reference import _export_args, build_professional_report_reference
+from scripts.report_render.reading_facts import READING_DOMAINS, build_report_reading_facts
+from tests.test_report_reader_main import FICTIONAL
+
+
+@pytest.fixture(scope="module")
+def chart():
+ case = dict(FICTIONAL)
+ reading = cmd_full_reading(type("Args", (), case)())
+ packet = dict(build_professional_report_reference_packet(reading, _export_args(case), []))
+ packet["report_version"] = "pl9_personal_long_report.v3"
+ return case, reading, packet
+
+
+def test_reading_facts_are_packet_leaves(chart) -> None:
+ _case, reading, packet = chart
+ before = json.dumps(packet, ensure_ascii=False, sort_keys=True, default=str)
+ facts = build_report_reading_facts(packet, reading, minute_confirmed=True, today="2026-04-07")
+ # Building the facts never touches the report's packet.
+ assert json.dumps(packet, ensure_ascii=False, sort_keys=True, default=str) == before
+ assert facts["schema"] == "report-reading-facts-v1"
+ assert sorted(facts["domains"]) == sorted(READING_DOMAINS)
+ for name, planet in facts["planets"].items():
+ assert planet["d1_sign"] == dig(packet, f"core_chart.planets.{name}.sign")
+ assert planet["d1_house"] == dig(packet, f"core_chart.planets.{name}.house")
+ assert planet["d1_dignity"] == dig(packet, f"core_chart.planets.{name}.status")
+ for key in ("sign", "lord", "nakshatra"):
+ assert facts["ascendant"][key] == dig(packet, f"core_chart.ascendant.{key}")
+ current = dig(packet, "worksheets.timing_and_predictive_systems.dasha.current_dasha")
+ assert facts["timing"]["mahadasha"] == {"lord": current["lord"], "start": current["start"], "end": current["end"]}
+ rows = current["antardasha_timeline"]
+ index = next(i for i, row in enumerate(rows) if row.get("is_current") is True)
+ assert facts["timing"]["antardasha"]["lord"] == rows[index]["lord"]
+ assert [row["lord"] for row in facts["timing"]["next_antardashas"]] == [row["lord"] for row in rows[index + 1:index + 3]]
+ # Paths, checks and gaps stay out of what the model reads.
+ encoded = json.dumps(facts, ensure_ascii=False)
+ for marker in ('"path"', '"check"', '"status"', '"citation"', "references/"):
+ assert marker not in encoded
+ assert len(encoded) < 20_000
+
+
+def test_withheld_varga_houses_stay_out_without_a_confirmed_minute(chart) -> None:
+ _case, reading, packet = chart
+ facts = build_report_reading_facts(packet, reading, minute_confirmed=False)
+ assert facts["minute_confirmed"] is False
+ assert "出生时间精度不够" not in json.dumps(facts, ensure_ascii=False)
+
+
+class _Handler:
+ def __init__(self, case: dict, reading: dict) -> None:
+ self.case = case
+ self.reading = reading
+ self.calls = 0
+
+ def _high_rigor_birth_payload(self, body: dict) -> dict:
+ payload = {key: value for key, value in self.case.items() if key != "name"}
+ if body.get("today"):
+ payload["today"] = body["today"]
+ return payload
+
+ def _compute_full_reading_for_thematic(self, _birth: dict) -> dict:
+ self.calls += 1
+ return self.reading
+
+
+@pytest.fixture
+def stubbed(chart, monkeypatch, tmp_path):
+ import scripts.consult_full_data_cache as cache
+
+ case, reading, packet = chart
+
+ def _dir(name: str = "consult_full_data_cache"):
+ path = tmp_path / name
+ path.mkdir(parents=True, exist_ok=True)
+ return path
+
+ class _Engine:
+ def build_professional_report_reference_packet(self, _reading, _args, _packs, **_kwargs):
+ return copy.deepcopy(packet)
+
+ def render_pl9_markdown(self, _packet):
+ return "reference"
+
+ monkeypatch.setattr(cache, "cache_dir", _dir)
+ monkeypatch.setattr(reference, "render_full_data_markdown", lambda p: "en-body" if p.get("report_language") == "en" else "zh-body")
+ monkeypatch.setattr(reference, "report_density_packet", lambda _packet: {"facts": 1})
+ monkeypatch.setattr(reference, "render_dasha_applicability", lambda _packet, language="zh": f"app-{language}")
+ monkeypatch.setattr(reference, "clean_reader_appendix_markdown", lambda text: text)
+ monkeypatch.setenv("JYOTISH_REPORT_RENDER_FAST", "0")
+ monkeypatch.setenv("JYOTISH_REPORT_REFERENCE_CACHE", "0")
+ return _Handler(case, reading), _Engine()
+
+
+def _body(**extra) -> dict:
+ body = {"format": "markdown", "edition": "full_data", "include_fact_tables": True, "languages": ["zh"], "today": "2026-04-07"}
+ body.update(extra)
+ return body
+
+
+def _dumps(result: dict) -> str:
+ return json.dumps(result, ensure_ascii=False, sort_keys=True, default=str)
+
+
+def test_flag_off_response_is_unchanged(stubbed) -> None:
+ handler, engine = stubbed
+ absent = build_professional_report_reference(handler, _body(), engine=engine)
+ off = build_professional_report_reference(handler, _body(include_reading_facts=False), engine=engine)
+ assert _dumps(absent) == _dumps(off)
+ assert "reading_facts" not in absent
+ assert sorted(absent) == ["edition", "fact_table_packet", "format", "markdown", "reader_dasha_applicability", "report_version"]
+
+
+def test_flag_on_adds_reading_facts_and_keeps_the_report(stubbed) -> None:
+ handler, engine = stubbed
+ plain = build_professional_report_reference(handler, _body(), engine=engine)
+ with_facts = build_professional_report_reference(
+ handler, _body(include_reading_facts=True, birth_time_accuracy="confirmed", hour=9, minute=15), engine=engine,
+ )
+ facts = with_facts.pop("reading_facts")
+ assert _dumps(with_facts) == _dumps(plain)
+ assert facts["schema"] == "report-reading-facts-v1"
+ assert facts["minute_confirmed"] is True
+ assert facts["today"] == "2026-04-07"
+ assert "gaps" not in facts
+
+
+def test_reading_facts_failure_never_fails_the_report(stubbed, monkeypatch) -> None:
+ import scripts.report_render.reading_facts as module
+
+ handler, engine = stubbed
+
+ def broken(*_args, **_kwargs):
+ raise RuntimeError("boom")
+
+ monkeypatch.setattr(module, "build_report_reading_facts", broken)
+ result = build_professional_report_reference(handler, _body(include_reading_facts=True), engine=engine)
+ assert result["markdown"] == "zh-body"
+ assert result["reading_facts"]["gaps"] == ["reading_facts_unavailable"]
+
+
+def test_reading_flag_is_part_of_the_report_cache_key(stubbed, monkeypatch) -> None:
+ handler, engine = stubbed
+ monkeypatch.setenv("JYOTISH_REPORT_REFERENCE_CACHE", "1")
+ monkeypatch.setenv("GITHUB_SHA", "reading-cache-test")
+ build_professional_report_reference(handler, _body(), engine=engine)
+ build_professional_report_reference(handler, _body(include_reading_facts=True), engine=engine)
+ assert handler.calls == 2
+ cached = build_professional_report_reference(handler, _body(include_reading_facts=True), engine=engine)
+ assert handler.calls == 2
+ assert "reading_facts" in cached