feat(report): add dense personal report appendix

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
jesse-ux
2026-09-23 10:42:17 +08:00
co-authored by Claude Code
parent a7f82dc2a6
commit bbd96d3b6b
44 changed files with 48833 additions and 50 deletions
@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { LONGFORM_APPENDIX_TABLE, parseLongformAppendixRow } from "@/lib/personal-report-longform-appendix";
import { checkSameOrigin, resolveAllowedReportOrigins } from "@/lib/personal-report-entitlement";
import { createSupabasePersonalReportService } from "@/lib/personal-report-service";
import { releaseRawReportAppendix } from "@/lib/personal-report-raw-appendix";
import { readLongformSnapshot, type LongformSnapshotReader } from "@/lib/personal-report-longform-snapshot";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const privateHeaders = { "Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff" };
const json = (body: unknown, status = 200) => NextResponse.json(body, { status, headers: privateHeaders });
type RouteContext = { params: Promise<{ reportId: string }> };
/** Cache-only attachment: no birth input, recalculation, or alternate subject selection. */
export async function POST(request: Request, context: RouteContext) {
try {
const supabase = await createServerSupabaseClient();
const { data: { user }, error } = await supabase.auth.getUser();
if (error || !user) return json({ error: "请先登录" }, 401);
const origin = checkSameOrigin(request.url, request.headers.get("origin"), resolveAllowedReportOrigins(process.env), request.headers);
if (!origin.ok) return json({ error: "此处无法下载,请回到报告页重试。" }, 403);
const { reportId } = await context.params;
if (!uuidPattern.test(reportId)) return json({ error: "报告不存在" }, 404);
const report = await createSupabasePersonalReportService(supabase).getOwnedById(user.id, reportId);
if (!report) return json({ error: "报告不存在" }, 404);
if (report.status !== "ready") return json({ error: "报告尚未完成" }, 409);
const result = await supabase.from(LONGFORM_APPENDIX_TABLE)
.select("report_id,user_id,request_id,status,markdown,content_sha256,attempt_count,last_error_code")
.eq("report_id", reportId).eq("user_id", user.id).eq("request_id", report.requestId)
.maybeSingle();
const cached = result.error ? null : parseLongformAppendixRow(result.data);
if (!cached || cached.reportId !== reportId || cached.userId !== user.id || cached.requestId !== report.requestId
|| cached.status !== "ready" || !cached.markdown) {
return json({ error: "这份报告暂无原始附录。" }, 410);
}
const stored = await readLongformSnapshot(supabase as unknown as LongformSnapshotReader, { reportId, userId: user.id, requestId: report.requestId });
if (stored && (stored.status !== "ready" || stored.snapshot.markdown !== cached.markdown)) {
return json({ error: "原始附录暂时无法下载,请稍后重试。" }, 503);
}
return json(releaseRawReportAppendix(cached.markdown, stored?.status === "ready" ? stored.snapshot.readerDashaApplicability : undefined));
} catch (error) {
return json({ error: "原始附录暂时无法下载,请稍后重试。" }, isSupabaseConfigurationError(error) ? 503 : 500);
}
}
+3 -2
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { LONGFORM_SNAPSHOT_SECTION_ID } from "@/lib/personal-report-longform-snapshot";
import { resolveMissingBirthTimezoneOffset } from "@/lib/birth-profile-timezone";
import { runConsultationWorkflow } from "@/mastra";
import { createPersonalReportAgent } from "@/mastra/personal-report";
@@ -108,14 +109,14 @@ export async function GET() {
if (failed.length > 0) {
const { data: sections, error: sectionError } = await supabase
.from("personal_report_sections")
.select("request_id, status, last_error_code")
.select("request_id, section_id, status, last_error_code")
.eq("user_id", user.id)
.in("request_id", failed.map((report) => report.requestId));
if (sectionError) throw sectionError;
const grouped = new Map<string, Array<{ status: string; lastErrorCode: string | null }>>();
for (const row of Array.isArray(sections) ? sections : []) {
const requestId = typeof row.request_id === "string" ? row.request_id : "";
if (!requestId) continue;
if (!requestId || row.section_id === LONGFORM_SNAPSHOT_SECTION_ID) continue;
const list = grouped.get(requestId) ?? [];
list.push({
status: typeof row.status === "string" ? row.status : "pending",
+8
View File
@@ -1,6 +1,14 @@
@import "tailwindcss";
@import "tw-animate-css";
.personal-report-fact-tables { margin-block: 2rem; min-width: 0; }
.personal-report-fact-tables details { margin-block: 1rem; padding: 1rem; border: 1px solid var(--color-border); border-radius: 0.5rem; }
.personal-report-fact-tables summary { cursor: pointer; min-height: 2.75rem; }
.personal-report-fact-tables table { width: 100%; border-collapse: collapse; }
.personal-report-fact-tables th, .personal-report-fact-tables td { padding: 0.5rem; text-align: start; border-bottom: 1px solid var(--color-border); overflow-wrap: anywhere; }
@media print { .personal-report-fact-tables details { break-inside: auto; } }
@theme inline {
--color-background: var(--color-canvas);
--color-foreground: var(--color-ink);
@@ -19,6 +19,10 @@ import { InlineSpinner } from "@/components/inline-spinner";
import { SecondaryHeader } from "@/components/secondary-header";
import { ReportActions } from "./report-actions";
import { ReportFactTables } from "./report-fact-tables";
import { ReportCalculationCharts } from "./report-calculation-charts";
import { reportDocumentV2Schema, type ReportDocumentV2 } from "@/lib/personal-report-contract";
import { reportFactTablesSchema, type ReportFactTable } from "@/lib/report-fact-table-schema";
import { PersonalReportMarkdownView } from "./personal-report-markdown-view";
import { PersonalReportProgressPanel } from "./personal-report-progress-panel";
import {
@@ -50,7 +54,7 @@ export type ReportLoadState =
| { phase: "failed"; failureCode: string | null; failureSummary?: string | null; appendixLastErrorCode?: string | null }
| { phase: "invalid"; message: string }
| { phase: "network-error" }
| { phase: "markdown-ready"; markdown: string; reportId: string; createdAt: string }
| { phase: "markdown-ready"; markdown: string; reportId: string; createdAt: string; factTables?: ReportFactTable[]; calculationCharts?: ReportDocumentV2["charts"] }
| { phase: "legacy-unavailable" };
/** GET /api/reports/:id envelope view (mirrors the API route's reportView). */
@@ -125,7 +129,11 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
const stored = typeof json.longformMarkdown === "string" ? json.longformMarkdown : "";
const markdown = projectOrdinaryReportMarkdown(stored).trim();
if (markdown.length > 0) {
const facts = reportFactTablesSchema.safeParse(json.factTables);
const charts = reportDocumentV2Schema.shape.charts.safeParse(json.calculationCharts);
return {
...(facts.success && facts.data.length ? { factTables: facts.data } : {}),
...(charts.success ? { calculationCharts: charts.data } : {}),
phase: "markdown-ready",
markdown,
reportId: typeof view.id === "string" ? view.id : "",
@@ -475,6 +483,8 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
/>
<div className="personal-report-reader-body">
<PersonalReportMarkdownView markdown={state.markdown} />
{state.calculationCharts && <ReportCalculationCharts charts={state.calculationCharts} />}
{state.factTables && <ReportFactTables tables={state.factTables} />}
</div>
</div>
</>
@@ -0,0 +1,35 @@
"use client";
import { useState } from "react";
import { FileText } from "lucide-react";
import { Button } from "@/components/ui/button";
import { downloadMarkdownReport } from "@/lib/consultation-report-export";
export function RawAppendixDownload({ reportId }: { reportId: string }) {
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
async function download() {
if (busy) return;
setBusy(true);
setNotice(null);
try {
const response = await fetch(`/api/reports/${encodeURIComponent(reportId)}/raw-appendix`, {
method: "POST", credentials: "same-origin", headers: { Accept: "application/json" },
});
const result = await response.json();
if (!response.ok || result.documentKind !== "raw_appendix" || result.format !== "markdown"
|| typeof result.markdown !== "string" || !result.markdown.trim()) throw new Error("unavailable");
downloadMarkdownReport("个人报告-原始附录", result.markdown);
} catch {
setNotice("原始附录暂时无法下载,请稍后重试。");
} finally {
setBusy(false);
}
}
return <>
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => void download()}>
<FileText aria-hidden="true" />{busy ? "正在准备…" : "下载原始附录"}
</Button>
{notice && <span role="status">{notice}</span>}
</>;
}
@@ -20,6 +20,7 @@ import {
PERSONAL_REPORT_PRINT_LABEL,
} from "@/lib/personal-report-longform-copy";
import { Button } from "@/components/ui/button";
import { RawAppendixDownload } from "./raw-appendix-download";
interface ReportActionsProps {
reportId: string;
@@ -83,6 +84,7 @@ export function ReportActions({
<ArrowLeft aria-hidden="true" />返回报告中心
</Link>
<div className="personal-report-action-end">
<RawAppendixDownload reportId={reportId} />
{notice && <span role="status"><TriangleAlert aria-hidden="true" />{notice}</span>}
<Button type="button" size="sm" variant="ghost" onClick={() => void handlePrint()} disabled={printBusy || exportBusy || !printSupported}>
<Printer aria-hidden="true" />{printBusy ? "正在准备…" : PERSONAL_REPORT_PRINT_LABEL}
@@ -0,0 +1,20 @@
"use client";
import { memo } from "react";
import type { ChartV1, ReportDocumentV2 } from "@/lib/personal-report-contract";
import { hasRealChartData, VedicChartSvg } from "./vedic-chart-svg";
export const ReportCalculationCharts = memo(function ReportCalculationCharts({ charts }: { charts: ReportDocumentV2["charts"] }) {
const actual = charts.filter(chart => hasRealChartData(chart as ChartV1));
if (!actual.length) return null;
return <section className="personal-report-section" aria-label="分盘核对">
<h2>分盘核对</h2>
<p>原始计算供核对,分盘对出生时间敏感,不单独增加结论的确定性。</p>
<div className="personal-report-chart-grid">
{actual.map(chart => <article className="personal-report-chart-card" key={chart.id}>
<h3>{chart.title}</h3>
<VedicChartSvg chart={chart as ChartV1} ariaLabel={`${chart.title},十二宫数据`} />
</article>)}
</div>
</section>;
});
@@ -0,0 +1,41 @@
"use client";
import { memo } from "react";
import type { ReportFactTable } from "@/lib/report-fact-table-schema";
const STATUS_LABELS = {
multi_system_consensus: "多系统共识",
single_system_inference: "单一系统推断",
parameter_sensitive: "参数敏感",
unclosed_divisional_chart: "分盘尚未核验完整",
user_history_verification_required: "需要过往经历核对",
blocked: "暂不可用",
} as const;
/** Server-owned facts, separate from the ordinary Markdown and its TOC state. */
export const ReportFactTables = memo(function ReportFactTables({ tables }: { tables: readonly ReportFactTable[] }) {
return (
<section className="personal-report-fact-tables" aria-label="计算数据核对表">
<h2>计算数据核对表</h2>
{tables.map((table) => (
<details key={table.id}>
<summary>{table.title} · {STATUS_LABELS[table.claimStatus]}</summary>
<p>{table.note}</p>
{table.rows.length > 0 && (
<div className="overflow-x-auto" role="region" aria-label={table.title} tabIndex={0}>
<table>
<caption className="sr-only">{table.title}</caption>
<thead><tr>{table.columns.map((column) => <th scope="col" key={column}>{column}</th>)}</tr></thead>
<tbody>{table.rows.map((row) => (
<tr key={row.sourcePath}>{row.cells.map((cell, index) => (
<td key={index}>{cell === null ? "—" : typeof cell === "boolean" ? (cell ? "是" : "否") : String(cell)}</td>
))}</tr>
))}</tbody>
</table>
</div>
)}
</details>
))}
</section>
);
});
+3 -1
View File
@@ -14,6 +14,7 @@
*/
import { z } from "zod";
import { reportFactTablesSchema } from "./report-fact-table-schema";
export const LEGACY_REPORT_DOCUMENT_SCHEMA_VERSION = "report_document.v1" as const;
export const REPORT_DOCUMENT_SCHEMA_VERSION = "report_document.v2" as const;
@@ -45,7 +46,7 @@ export const TECHNIQUE_STATUSES = ["verified", "partial", "blocked"] as const;
export const CONFLICT_STATUSES = ["unresolved", "partial", "resolved"] as const;
export const REPORT_ACTION_PRIORITIES = ["now", "next", "watch"] as const;
export const REPORT_DOCUMENT_V1_CHART_IDS = ["D1", "D9", "D10"] as const;
export const CHART_IDS = ["D1", "D2", "D6", "D8", "D9", "D10", "D11", "D24", "D30"] as const;
export const CHART_IDS = ["D1", "D2", "D3", "D4", "D7", "D9", "D10", "D11", "D12", "D16", "D20", "D24", "D27", "D30", "D40", "D60"] as const;
export const REPORT_DOCUMENT_V2_ACTION_NOTES_MAX = 24;
const claimStatusSchema = z.enum(CLAIM_STATUSES);
@@ -284,6 +285,7 @@ export const reportDocumentV2Schema = z.strictObject({
currentPhase: currentPhaseSchema.nullable(),
actionNotes: z.array(actionNoteSchema).min(1).max(REPORT_DOCUMENT_V2_ACTION_NOTES_MAX),
charts: z.array(reportDocumentV2ChartSchema).min(1).max(CHART_IDS.length),
factTables: reportFactTablesSchema.optional(),
thematicNarrative: z.array(thematicSectionV2Schema).max(12),
blockedConflictDisclosure: z.array(blockedConflictDisclosureSchema).max(12),
evidenceAppendix: evidenceAppendixSchema,
+42 -3
View File
@@ -1,4 +1,5 @@
import { createHash } from "node:crypto";
import { assembleReportFactTables } from "./report-fact-tables";
import {
computeEvidenceHash,
safeParseServerReportDocument,
@@ -330,8 +331,15 @@ const DOCUMENT_VARGA_CHART_IDS = CHART_IDS.filter(
const VARGA_KEY_ALIASES: Readonly<Record<DocumentVargaChartId, readonly string[]>> = {
D2: ["D2_Hora", "D2"],
D6: ["D6_Shashthamsa", "D6"],
D8: ["D8_Ashtamsa", "D8"],
D3: ["D3_Drekkana", "D3"],
D4: ["D4_Chaturthamsa", "D4"],
D7: ["D7_Saptamsa", "D7"],
D12: ["D12_Dvadasamsa", "D12"],
D16: ["D16_Shodasamsa", "D16"],
D20: ["D20_Vimsamsa", "D20"],
D27: ["D27_Bhamsa", "D27"],
D40: ["D40_Khavedamsa", "D40"],
D60: ["D60_Shashtiamsa", "D60"],
D9: ["D9_Navamsa", "D9"],
D10: ["D10_Dasamsa", "D10"],
D11: ["D11_Rudramsa", "D11"],
@@ -340,7 +348,7 @@ const VARGA_KEY_ALIASES: Readonly<Record<DocumentVargaChartId, readonly string[]
};
function canonicalDocumentVargaChartId(rawId: string): DocumentVargaChartId | null {
const match = /^(D(?:30|24|11|10|9|8|6|2))(?:[_\s-].*)?$/.exec(rawId.trim().toUpperCase());
const match = /^(D(?:60|40|30|27|24|20|16|12|11|10|9|7|4|3|2))(?:[_\s-].*)?$/.exec(rawId.trim().toUpperCase());
return match ? match[1] as DocumentVargaChartId : null;
}
@@ -450,6 +458,26 @@ function readVargaHouses(
return result;
}
/** Actual packet charts for the default longform path; no placeholder promotion. */
export function assembleLongformCharts(packet: unknown, evidenceId: string): ReportDocumentV2["charts"] {
const root = record(packet);
const worksheets = record(root?.worksheets);
const divisional = record(worksheets?.divisional_and_special_charts);
const vargas = record(divisional?.varga_full);
const core = record(root?.core_chart);
const charts: ReportDocumentV2["charts"] = [];
for (const id of CHART_IDS) {
const entry = id === "D1" ? core : record(Object.entries(vargas ?? {}).find(([key]) => canonicalDocumentVargaChartId(key) === id)?.[1]);
if (!entry) continue;
const derived = deriveVargaHousesFromEngine(entry);
if (!derived) continue;
charts.push({ id, title: `${id} 分盘`, houses: derived.houses.map(house => ({
houseNumber: house.number, sign: house.sign, occupants: [...house.occupants],
})), claimStatus: "parameter_sensitive", evidenceRefs: [evidenceId] });
}
return charts;
}
function readDashaPeriods(rows: unknown): ReportEvidencePacket["chart"]["vimshottari"] {
if (!Array.isArray(rows)) return null;
const periods: { lord: string; start: string; end: string }[] = [];
@@ -810,6 +838,7 @@ const SAFE_CELESTIAL_NAMES = new Map([
const SAFE_WORKFLOW_TECHNIQUES = new Map<string, string>([
["d1", "D1"], ["d2", "D2"], ["d4", "D4"], ["d6", "D6"], ["d7", "D7"], ["d8", "D8"],
["d9", "D9"], ["d10", "D10"], ["d11", "D11"], ["d12", "D12"],
["d3", "D3"], ["d16", "D16"], ["d20", "D20"], ["d27", "D27"], ["d40", "D40"], ["d60", "D60"],
["d24", "D24"], ["d30", "D30"], ["a7", "A7"], ["a10", "A10"],
["ul", "UL"], ["upapada", "UL"], ["dk", "DK"], ["darakaraka", "DK"],
["amk", "AmK"], ["amatyakaraka", "AmK"], ["karma_pada", "A10"],
@@ -2170,6 +2199,7 @@ export function buildReportEvidenceBundleV2(
},
skill: input.skillSnapshot,
charts: [...chartsById.values()],
factTables: assembleReportFactTables(base.workflow),
claimCards,
interpretiveFacts,
themeNarrativeSeeds,
@@ -2504,6 +2534,14 @@ export function validatePersonalReportAgentOutputAgainstPlan(
bundle: ReportEvidenceBundleV2,
options: Readonly<{ allowIncompleteThematic?: boolean }> = {},
): PersonalReportAgentOutput {
const hasWriterTable = (value: unknown): boolean => {
if (typeof value === "string") return /(?:^|\n)\s*\|[^\n]+\||<table\b/i.test(value);
if (!value || typeof value !== "object") return false;
return Object.entries(value).some(([key, entry]) => key === "factTables" || hasWriterTable(entry));
};
if (hasWriterTable(output)) {
throw new Error("report_writer_fact_tables_forbidden");
}
const writePlans = plan.sections.filter((section) => (
section.kind === "thematic" && section.disposition === "write"
));
@@ -2839,6 +2877,7 @@ export function assembleReportDocumentV2(
currentPhase,
actionNotes,
charts,
...(bundle.factTables ? { factTables: structuredClone(bundle.factTables) } : {}),
thematicNarrative,
blockedConflictDisclosure,
evidenceAppendix: appendix,
@@ -42,6 +42,8 @@ export type LongformCoverInput = Readonly<{
birthPlaceLabel: string;
}>;
markdown: string;
factTables?: ReportDocumentV2["factTables"];
charts?: ReportDocumentV2["charts"];
generatedAt?: string;
skillSnapshot?: CoverSkillSnapshot;
}>;
@@ -121,13 +123,14 @@ export function buildLongformCoverDocument(input: LongformCoverInput): ReportDoc
priority: "now" as const,
evidenceRefs: [COVER_EVIDENCE_ID],
}],
charts: [{
charts: input.charts?.some(chart => chart.id === "D1") ? input.charts : [{
id: "D1" as const,
title: "D1 占位",
houses: coverHouses(),
claimStatus: "parameter_sensitive" as const,
evidenceRefs: [COVER_EVIDENCE_ID],
}],
...(input.factTables?.length ? { factTables: input.factTables } : {}),
thematicNarrative: [],
blockedConflictDisclosure: input.report.requestedThemes.map((theme) => ({
theme,
@@ -1,5 +1,9 @@
import type { ReportCandidateClockRange } from "./report-candidate-range";
import "server-only";
import { assembleReportFactTables } from "./report-fact-tables";
import type { ReportFactTable } from "./report-fact-table-schema";
import { persistLongformSnapshot, readLongformSnapshot, validateLongformSnapshot, type LongformSnapshot } from "./personal-report-longform-snapshot";
import { hashLongformMarkdown } from "./personal-report-longform-appendix";
import {
LONGFORM_APPENDIX_TABLE,
@@ -9,7 +13,7 @@ import {
} from "./personal-report-longform-appendix";
import { buildLongformBirthPayload, utcToday } from "./personal-report-longform-birth";
import { buildLongformCoverDocument } from "./personal-report-longform-cover";
import type { GeneratePersonalReportResult } from "./personal-report-generation";
import { assembleLongformCharts, type GeneratePersonalReportResult } from "./personal-report-generation";
import type { PersonalReportRecord } from "./personal-report-service-core";
import type { ReportDocumentV2 } from "./personal-report-contract";
@@ -33,6 +37,7 @@ export class LongformGenerateError extends Error {
type JsonRecord = Record<string, unknown>;
export type AppendixClient = {
rpc(name: string, args: Readonly<Record<string, unknown>>): PromiseLike<{ data: unknown; error: { message?: string } | null }>;
from(table: string): {
select(columns: string): {
eq(column: string, value: unknown): AppendixFilter;
@@ -85,8 +90,10 @@ export async function readOwnedLongformAppendix(
.eq("report_id", reportId)
.eq("user_id", userId)
.maybeSingle();
if (result.error) return null;
return parseLongformAppendixRow(result.data);
if (result.error) throw new LongformGenerateError("calculation_unavailable", true, "appendix_read_failed");
const parsed = parseLongformAppendixRow(result.data);
if (result.data !== null && !parsed) throw new LongformGenerateError("report_schema_invalid", false, "appendix_invalid");
return parsed;
}
export async function persistLongformAppendix(input: Readonly<{
@@ -130,7 +137,7 @@ async function fetchLongformMarkdown(input: Readonly<{
apiBase: string;
fetchImpl: typeof fetch;
signal?: AbortSignal;
}>): Promise<string> {
}>): Promise<{ markdown: string; factTables: ReportFactTable[]; charts: ReportDocumentV2["charts"]; readerDashaApplicability: string }> {
const timeout = AbortSignal.timeout(ENGINE_TIMEOUT_MS);
const signal = input.signal
? AbortSignal.any([input.signal, timeout])
@@ -141,7 +148,7 @@ 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),
body: JSON.stringify({ ...input.payload, include_fact_tables: true }),
cache: "no-store",
signal,
});
@@ -153,11 +160,13 @@ async function fetchLongformMarkdown(input: Readonly<{
upstream.status === 429 ? "upstream_busy" : "upstream_unavailable",
);
}
const result = await upstream.json().catch(() => null) as { format?: unknown; markdown?: unknown } | null;
const result = await upstream.json().catch(() => null) as { format?: unknown; markdown?: unknown; fact_table_packet?: unknown; reader_dasha_applicability?: unknown } | null;
if (result?.format !== "markdown" || typeof result.markdown !== "string" || !result.markdown.trim()) {
throw new LongformGenerateError("calculation_unavailable", true, "empty_markdown");
}
return result.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 : "" };
} catch (error) {
throw error;
} finally {
@@ -191,38 +200,58 @@ export async function generatePersonalReportLongform(
throw new LongformGenerateError("birth_time_not_usable", false);
}
const current = await readOwnedLongformAppendix(deps.admin, deps.report.id, deps.report.userId);
const identity = { reportId: deps.report.id, userId: deps.report.userId, requestId: deps.report.requestId };
const current = await readOwnedLongformAppendix(deps.admin, identity.reportId, identity.userId);
if (current && (current.reportId !== identity.reportId || current.userId !== identity.userId || current.requestId !== identity.requestId)) {
throw new LongformGenerateError("report_schema_invalid", false, "appendix_binding_invalid");
}
let markdown = current?.status === "ready" && current.markdown ? current.markdown : null;
if (!markdown) {
try {
markdown = await fetchLongformMarkdown({
let snapshot: LongformSnapshot | null = null;
try {
const stored = await readLongformSnapshot(deps.admin, identity);
if (stored?.status === "ready") snapshot = stored.snapshot;
// A pending marker proves this is not a historical Markdown-only report.
if (stored?.status === "pending" && markdown) throw new Error("longform_snapshot_missing");
if (snapshot && markdown !== null && markdown !== snapshot.markdown) throw new Error("longform_snapshot_markdown_mismatch");
} catch {
throw new LongformGenerateError("report_schema_invalid", false, "longform_snapshot_invalid");
}
if (!markdown && !snapshot) {
// Mark new-format work before calculation; never confuse a torn new cache with legacy data.
const ensured = await persistLongformSnapshot(deps.admin, identity, null);
if (ensured.status === "ready") snapshot = ensured.snapshot;
deps.signal?.throwIfAborted();
if (!snapshot) try {
const generated = await fetchLongformMarkdown({
payload,
apiBase: deps.apiBase ?? process.env.JYOTISH_API_BASE ?? DEFAULT_API_BASE,
fetchImpl: deps.fetchImpl ?? fetch,
signal: deps.signal,
});
snapshot = validateLongformSnapshot({
...generated, ...identity, kind: "longform_calculation_snapshot", version: 1,
contentSha256: hashLongformMarkdown(generated.markdown),
}, identity);
// Durable complete snapshot precedes the shared Markdown cache and final report.
deps.signal?.throwIfAborted();
const completed = await persistLongformSnapshot(deps.admin, identity, snapshot);
if (completed.status !== "ready") throw new Error("longform_snapshot_persist_failed");
snapshot = completed.snapshot;
} catch (error) {
if (deps.signal?.aborted) throw error;
await persistLongformAppendix({
admin: deps.admin,
reportId: deps.report.id,
userId: deps.report.userId,
requestId: deps.report.requestId,
current,
errorCode: error instanceof LongformGenerateError ? error.message.slice(0, 80) : "generation_failed",
}).catch(() => undefined);
// Do not write a failed appendix here: another worker may already have
// committed its successful snapshot and appendix while this calculation failed.
if (error instanceof LongformGenerateError) throw error;
throw new LongformGenerateError("calculation_unavailable", true);
throw new LongformGenerateError("calculation_unavailable", true, "longform_snapshot_generation_failed");
}
await persistLongformAppendix({
admin: deps.admin,
reportId: deps.report.id,
userId: deps.report.userId,
requestId: deps.report.requestId,
current,
successMarkdown: markdown,
});
}
if (!markdown && snapshot) {
markdown = snapshot.markdown;
await persistLongformAppendix({ admin: deps.admin, ...identity, current, successMarkdown: markdown });
}
if (!markdown) throw new LongformGenerateError("report_schema_invalid", false);
const factTables = snapshot?.factTables ?? [];
const charts = snapshot?.charts ?? [];
try {
const document = buildLongformCoverDocument({
@@ -233,6 +262,8 @@ export async function generatePersonalReportLongform(
birthPlaceLabel: text(profile.birth_place_label) ?? "未知出生地",
},
markdown,
factTables,
charts,
generatedAt: (deps.now?.() ?? new Date()).toISOString(),
});
return {
@@ -0,0 +1,67 @@
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 type { AppendixClient } from "./personal-report-longform-generate";
/** Reserved server-owned cache, never a writer chapter or reader progress row. */
export const LONGFORM_SNAPSHOT_SECTION_ID = "longform-calculation-snapshot-v1";
export const LONGFORM_SNAPSHOT_TABLE = "personal_report_sections";
export type LongformSnapshotIdentity = { reportId: string; userId: string; requestId: string };
const snapshotSchema = z.strictObject({
kind: z.literal("longform_calculation_snapshot"),
version: z.literal(1),
reportId: z.string().uuid(), userId: z.string().uuid(), requestId: z.string().uuid(),
markdown: z.string().min(1), contentSha256: z.string().regex(/^[a-f0-9]{64}$/),
factTables: reportFactTablesSchema.refine(tables => tables.length === 8),
charts: reportDocumentV2Schema.shape.charts.refine(charts =>
CHART_IDS.every(id => charts.filter(chart => chart.id === id).length === 1)
&& charts.every(chart => chart.houses.length === 12 && chart.houses.some(house => house.occupants.length > 0))),
readerDashaApplicability: z.string().min(1),
});
export type LongformSnapshot = z.infer<typeof snapshotSchema>;
export function validateLongformSnapshot(value: unknown, identity: LongformSnapshotIdentity): LongformSnapshot {
const snapshot = snapshotSchema.parse(value);
if (snapshot.reportId !== identity.reportId || snapshot.userId !== identity.userId
|| snapshot.requestId !== identity.requestId || snapshot.contentSha256 !== hashLongformMarkdown(snapshot.markdown)) {
throw new Error("longform_snapshot_binding_invalid");
}
return snapshot;
}
type SnapshotFilter = {
eq(column: string, value: unknown): SnapshotFilter;
maybeSingle(): PromiseLike<{ data: unknown; error: unknown }>;
};
export type LongformSnapshotReader = { from(table: string): { select(columns: string): { eq(column: string, value: unknown): SnapshotFilter } } };
export async function readLongformSnapshot(client: LongformSnapshotReader, identity: LongformSnapshotIdentity) {
const result = await client.from(LONGFORM_SNAPSHOT_TABLE)
.select("user_id,request_id,section_id,status,payload")
.eq("user_id", identity.userId).eq("request_id", identity.requestId)
.eq("section_id", LONGFORM_SNAPSHOT_SECTION_ID).maybeSingle();
if (result.error) throw new Error("longform_snapshot_read_failed");
if (result.data === null) return null;
const row = result.data as Record<string, unknown>;
if (!row || row.user_id !== identity.userId || row.request_id !== identity.requestId
|| row.section_id !== LONGFORM_SNAPSHOT_SECTION_ID) throw new Error("longform_snapshot_binding_invalid");
if (row.status === "pending" && row.payload === null) return { status: "pending" as const };
if (row.status !== "ready") throw new Error("longform_snapshot_invalid");
return { status: "ready" as const, snapshot: validateLongformSnapshot(row.payload, identity) };
}
export async function persistLongformSnapshot(client: AppendixClient, identity: LongformSnapshotIdentity, snapshot: LongformSnapshot | null) {
if (snapshot) validateLongformSnapshot(snapshot, identity);
const args = { p_user_id: identity.userId, p_request_id: identity.requestId, p_section_id: LONGFORM_SNAPSHOT_SECTION_ID };
// Existing SQL: INSERT ON CONFLICT DO NOTHING; completion UPDATE ... WHERE status='pending'.
// Neither an in-flight ensure nor a late completion can overwrite a committed winner.
const result = snapshot
? await client.rpc("complete_personal_report_section", { ...args, p_payload: snapshot })
: await client.rpc("ensure_personal_report_section", { ...args, p_max_attempts: 2 });
if (result.error) throw new Error("longform_snapshot_persist_failed");
const stored = await readLongformSnapshot(client, identity);
if (!stored || (snapshot && stored.status !== "ready")) throw new Error("longform_snapshot_persist_failed");
// Always return the committed winner, never the losing worker's local calculation.
return stored;
}
+2
View File
@@ -11,6 +11,7 @@ export const REPORT_SECTION_KINDS = [
"current_phase",
"action_notes",
"charts",
"fact_tables",
"claim_evidence_appendix",
"blocked_conflict_disclosure",
"provenance",
@@ -206,6 +207,7 @@ export function buildPersonalReportSectionPlan(
: []),
fixedSection("action-notes", "action_notes", depth),
fixedSection("charts", "charts", depth),
...(bundle.factTables?.length ? [fixedSection("fact-tables", "fact_tables", depth)] : []),
fixedSection("claim-evidence-appendix", "claim_evidence_appendix", depth, bundle.evidenceRefs.map((entry) => entry.id)),
fixedSection("blocked-conflict-disclosure", "blocked_conflict_disclosure", depth, [
...bundle.blockedSections.flatMap((section) => section.missingTechniqueRefs),
@@ -0,0 +1,13 @@
import { cleanReaderAppendixMarkdown } from "./reader-appendix-language";
import { sanitizeRawAppendixMarkdown } from "./report-public-projection";
/** Explicit independent channel. Never used to project ordinary report content. */
export const MISSING_DASHA_APPLICABILITY = "\n\n## 辅助时间轴适用性(参数敏感)\n\n缺少本次计算的结构化适用性证据,无法确认各辅助时间轴是否适用;不补算、不补写日期。\n";
export function releaseRawReportAppendix(markdown: string, readerDashaApplicability?: string) {
return {
documentKind: "raw_appendix" as const,
format: "markdown" as const,
markdown: cleanReaderAppendixMarkdown(sanitizeRawAppendixMarkdown(markdown + (readerDashaApplicability ?? (markdown.endsWith(MISSING_DASHA_APPLICABILITY) ? "" : MISSING_DASHA_APPLICABILITY)))),
};
}
@@ -741,6 +741,11 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
body: {
report: reportView(row, job),
longformMarkdown: projectOrdinaryReportMarkdown(markdown),
...(validated.ok && validated.document && typeof validated.document === "object" && "charts" in validated.document
? { calculationCharts: validated.document.charts } : {}),
...(validated.ok && validated.document && typeof validated.document === "object"
&& "factTables" in validated.document && validated.document.factTables
? { factTables: validated.document.factTables } : {}),
...(validated.ok ? { reportDocument: projectOrdinaryReportDocument(validated.document) } : {}),
},
};
@@ -1,3 +1,4 @@
import { LONGFORM_SNAPSHOT_SECTION_ID } from "./personal-report-longform-snapshot";
import type { PersonalReportAgentOutput } from "@/mastra/personal-report";
export type PersonalReportSectionPayload = PersonalReportAgentOutput["thematicNarrative"][number];
@@ -116,7 +117,7 @@ export function createPersonalReportSectionService(client: DataClient): Personal
const result = await client.from("personal_report_sections").select(columns).eq("user_id", userId).eq("request_id", requestId).order("section_id", { ascending: true });
const value = requireData(result);
if (!Array.isArray(value)) throw new Error("section list returned invalid data");
return value.map(row);
return value.filter(value => (value as DbRow)?.section_id !== LONGFORM_SNAPSHOT_SECTION_ID).map(row);
},
async start(input) {
const result = await client.rpc("start_personal_report_section", { p_user_id: input.userId, p_request_id: input.requestId, p_section_id: input.sectionId });
@@ -0,0 +1,27 @@
/** Cache compatibility for the Python reader-only vocabulary projection. */
const STATUS_LABELS: Readonly<Record<string, string>> = {
parameter_sensitive: "参数敏感",
pyjhora_behavior_only: "仅单一外部参照,未做多引擎核对",
not_multiengine_parity: "仅单一外部参照,未做多引擎核对",
unresolved_external_tuple_boundary: "外部边界未对齐",
partial_verified: "部分核验",
raw_appendix_only: "仅原始附录可见",
missing_in_local: "本地暂无",
internal_reference_omitted: "内部参照已省略",
};
const CELL_LABELS: Readonly<Record<string, string>> = {
blocked: "暂不可用", executed: "已执行", available: "可用", computed: "已计算",
};
export function cleanReaderAppendixMarkdown(markdown: string): string {
return markdown.split(/(^[ \t]*```jyotish-chart[^\n]*\n[\s\S]*?^[ \t]*```[^\n]*(?:\n|$))/m)
.map((part, index) => index % 2 ? part : part
.replace(new RegExp(`\\b(${Object.keys(STATUS_LABELS).join("|")})\\b`, "g"), (value) => STATUS_LABELS[value])
.replace(/\b(?:jyotish_engine\.)?(?:cmd_[a-z0-9_]+|render_pl9_markdown|build_professional_report_reference_packet)\b/g, "本地计算")
.replace(/\b(?:[a-z0-9_]+pl9[a-z0-9_]*|pl9_[a-z0-9_]+)(?:\.v\d+)?\b/gi, "外部参照资料")
.replace(/\bPL9\b(?:\.pdf)?(?:[ \t]*(?:第[ \t]*\d+(?:[ \t]*[–—/-][ \t]*\d+)*[ \t]*页|(?:pages?|p)[ \t-]*\d+(?:[ \t]*[–—/-][ \t]*\d+)*))?/gi, "外部参照资料")
.replace(/\b(?:PyJHora|JHora)\b/gi, "外部参照引擎")
.replace(/\|([ \t]*)(`?)(blocked|executed|available|computed)\2([ \t]*)(?=\|)/g,
(_match, before: string, quote: string, value: string, after: string) => `|${before}${quote}${CELL_LABELS[value]}${quote}${after}`))
.join("");
}
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import { z } from "zod";
import { reportFactTablesSchema } from "./report-fact-table-schema";
export type ClaimStatus =
| "multi_system_consensus"
@@ -235,6 +236,7 @@ export type ReportEvidenceBundleV2 = Readonly<{
calculationProfile: CalculationProfileReceipt;
skill: SkillPackageIdentity;
charts: readonly ReportChartFact[];
factTables?: import("./report-fact-table-schema").ReportFactTable[];
claimCards: readonly ReportClaimCard[];
interpretiveFacts: ReportInterpretiveFacts;
themeNarrativeSeeds: readonly ReportThemeNarrativeSeed[];
@@ -286,13 +288,13 @@ const deterministicClaimBoundarySchema = z.enum([
"relationship_combinations",
]);
const allowedTechniqueNames = new Set([
"D1", "D2", "D4", "D6", "D7", "D8", "D9", "D10", "D11", "D12", "D24", "D30",
"D1", "D2", "D3", "D4", "D6", "D7", "D8", "D9", "D10", "D11", "D12", "D16", "D20", "D24", "D27", "D30", "D40", "D60",
"A7", "A10", "UL", "DK", "AmK",
"Vimshottari", "Narayana", "Transit", "Yoga", "Ashtakavarga",
"Functional Benefic/Malefic", "Planet Degrees", "House Degrees",
"Planetary Friendship",
]);
const missingTechniquePattern = /^(?:career|marriage|wealth|education|migration_home|family|health_pressure|timing|general):(?:D1|D2|D4|D6|D6\/D30|D7\/D12|D8|D9|D10|D11|D12|D24|D30|A7|A10|UL|DK|AmK|Vimshottari|Narayana|Transit|Yoga|Ashtakavarga)$/;
const missingTechniquePattern = /^(?:career|marriage|wealth|education|migration_home|family|health_pressure|timing|general):(?:D1|D2|D4|D6|D6\/D30|D7\/D12|D8|D9|D10|D11|D12|D16|D20|D24|D27|D30|D40|D60|A7|A10|UL|DK|AmK|Vimshottari|Narayana|Transit|Yoga|Ashtakavarga)$/;
const techniqueSchema = z.string().max(80).refine(
(value) => allowedTechniqueNames.has(value) || missingTechniquePattern.test(value),
"report_bundle_technique_not_allowlisted",
@@ -474,6 +476,7 @@ export const reportEvidenceBundleV2Schema = z.object({
houses: z.array(houseSchema).max(12),
planets: z.array(planetSchema).max(20),
}).strict()).min(1).max(24),
factTables: reportFactTablesSchema.optional(),
claimCards: z.array(z.object({
id: evidenceIdSchema,
theme: idSchema,
@@ -0,0 +1,27 @@
import { z } from "zod";
export const FACT_TABLE_GROUPS = ["vimshottari", "ashtakavarga", "shadbala", "functional_roles", "avasthas", "special_points", "sade_sati", "annual"] as const;
const sourcePath = z.string().min(1).max(400).regex(/^[A-Za-z0-9_.\[\]-]+$/);
/** Immutable server facts; never part of a writer output schema. */
export const reportFactTableSchema = z.object({
id: z.enum(FACT_TABLE_GROUPS),
title: z.string().min(1).max(120),
claimStatus: z.enum(["multi_system_consensus", "single_system_inference", "parameter_sensitive", "unclosed_divisional_chart", "user_history_verification_required", "blocked"]),
sourcePath,
note: z.string().max(500),
columns: z.array(z.string().min(1).max(80)).min(1).max(16),
rows: z.array(z.object({
sourcePath,
cells: z.array(z.union([z.string().max(300), z.number().finite(), z.boolean(), z.null()])).min(1).max(16),
}).strict()).max(2000),
}).strict().superRefine((table, context) => {
table.rows.forEach((row, index) => {
if (row.cells.length !== table.columns.length) context.addIssue({ code: "custom", path: ["rows", index], message: "fact_table_cell_count" });
if (row.sourcePath !== table.sourcePath && !row.sourcePath.startsWith(`${table.sourcePath}.`) && !row.sourcePath.startsWith(`${table.sourcePath}[`)) context.addIssue({ code: "custom", path: ["rows", index, "sourcePath"], message: "fact_table_source_mismatch" });
});
});
export const reportFactTablesSchema = z.array(reportFactTableSchema).max(8).superRefine((tables, context) => {
if (new Set(tables.map((table) => table.id)).size !== tables.length) context.addIssue({ code: "custom", message: "duplicate_fact_table" });
});
export type ReportFactTable = z.infer<typeof reportFactTableSchema>;
+128
View File
@@ -0,0 +1,128 @@
import { findForbiddenContent } from "./personal-report-contract";
import { reportFactTablesSchema, type ReportFactTable } from "./report-fact-table-schema";
type RecordValue = Record<string, unknown>;
const record = (value: unknown): RecordValue => value && typeof value === "object" && !Array.isArray(value) ? value as RecordValue : {};
const at = (value: unknown, path: string): unknown => path.split(".").reduce((next, key) => record(next)[key], value);
const safeCell = (value: unknown): string | number | boolean | null => {
if (typeof value === "number") return Number.isFinite(value) ? value : null;
if (typeof value === "boolean") return value;
if (typeof value === "string" && value.length <= 300 && !findForbiddenContent(value).length) return value;
return null;
};
const SOURCES = [
["vimshottari", "Vimshottari 主运与分运", "timing_and_predictive_systems.dasha", "dasha"],
["ashtakavarga", "SAV / BAV 八分力", "strengths_and_scores.ashtakavarga", "ashtakavarga"],
["shadbala", "Shadbala 六分量", "strengths_and_scores.shadbala", "shadbala"],
["functional_roles", "功能吉凶与宫主", "strengths_and_scores.functional_benefic_malefic", "functional_benefic_malefic"],
["avasthas", "Avastha 行星状态", "advanced_systems.avasthas", "avasthas"],
["special_points", "附属星点与特殊上升点", "divisional_and_special_charts", ""],
["sade_sati", "Sade Sati 阶段框架", "advanced_systems.yogas_doshas.sade_sati", "yogas_doshas.sade_sati"],
["annual", "年度回归与 Saham", "timing_and_predictive_systems.annual_tajika_pack", "annual_tajika_pack"],
] as const;
/** Explicit leaves only: no interpretations, remedies, profile, audit or producer text. */
const SCALAR_KEYS = new Set([
"longitude", "degree", "degree_in_sign", "sign_degree", "sign", "sign_idx", "sign_index", "house",
"state", "degree_range", "strength", "lord", "start", "end", "is_current", "years",
"dig_bala", "chesta_bala", "naisargika_bala", "drik_bala", "total", "total_virupas", "total_rupas",
"is_active", "phase", "moon_sign", "rising", "peak", "setting", "jd_ut", "dt_ut", "dt_local", "sun_lon",
]);
export function assembleReportFactTables(packet: unknown): ReportFactTable[] {
const root = record(packet);
const worksheets = record(root.worksheets);
const modules = record(record(root.chart).modules ?? root.modules);
const tables: ReportFactTable[] = [];
for (const [id, title, key, moduleKey] of SOURCES) {
const worksheetValue = at(worksheets, key);
const rootValue = at(root, key);
const source = worksheetValue ?? rootValue ?? (moduleKey ? at(modules, moduleKey) : modules);
if (!source || !Object.keys(record(source)).length) continue;
const sourcePath = worksheetValue !== undefined ? `worksheets.${key}` : rootValue !== undefined ? key
: `${root.chart ? "chart." : ""}modules${moduleKey ? `.${moduleKey}` : ""}`;
const raw = record(source);
const declared = raw.claimStatus ?? raw.claim_status;
// No new certainty: retain declared limitations; unspecified calculations stay sensitive.
const claimStatus = raw.status === "blocked" || raw.status === "missing_in_local" || declared === "blocked" ? "blocked"
: declared === "unclosed_divisional_chart" || declared === "user_history_verification_required"
|| declared === "single_system_inference" || declared === "multi_system_consensus" ? declared : "parameter_sensitive";
const rows: ReportFactTable["rows"] = [];
const add = (path: string, label: string, value: unknown) => {
const cell = safeCell(value);
if (cell !== null) rows.push({ sourcePath: path, cells: [label, cell] });
};
const walk = (value: unknown, path: string, label: string, allScalars = false, depth = 0) => {
if (depth > 10) return;
if (Array.isArray(value)) {
value.forEach((entry, index) => walk(entry, `${path}[${index}]`, `${label}[${index}]`, allScalars, depth + 1));
} else if (value && typeof value === "object") {
Object.entries(record(value)).forEach(([name, entry]) => {
if (!/^[A-Za-z0-9_-]+$/.test(name)) return;
if (SCALAR_KEYS.has(name) || allScalars || name === "avasthas" || ["Bala", "Jagrat", "Deeptadi", "Lajjitadi", "Shayanadi", "sthana_bala", "kala_bala"].includes(name)) {
walk(entry, `${path}.${name}`, `${label}.${name}`, allScalars, depth + 1);
}
});
} else add(path, label, value);
};
const pick = (path: string, allScalars = false) => {
const value = at(source, path);
if (value !== undefined) walk(value, `${sourcePath}.${path}`, path, allScalars);
};
if (claimStatus !== "blocked") {
if (id === "vimshottari") {
const timeline = Array.isArray(raw.timeline) ? raw.timeline : [];
timeline.forEach((period, index) => {
const path = `${sourcePath}.timeline[${index}]`;
walk(period, path, `MD[${index}]`);
if (record(period).is_current === true) {
const ad = record(period).antardasha_timeline;
if (Array.isArray(ad)) ad.forEach((entry, ai) => {
walk(entry, `${path}.antardasha_timeline[${ai}]`, `AD[${ai}]`);
const pd = record(entry).pratyantar_dasha_timeline;
if (Array.isArray(pd)) pd.forEach((item, pi) => walk(item, `${path}.antardasha_timeline[${ai}].pratyantar_dasha_timeline[${pi}]`, `AD[${ai}].PD[${pi}]`));
});
}
});
} else if (id === "ashtakavarga") {
pick("sav.scores", true);
Object.keys(record(raw.bav)).forEach((planet) => pick(`bav.${planet}.bindus`, true));
} else if (id === "shadbala") {
Object.keys(record(raw.planets)).forEach((planet) => {
for (const component of ["sthana_bala.total", "dig_bala", "kala_bala.total", "chesta_bala", "naisargika_bala", "drik_bala"]) pick(`planets.${planet}.${component}`);
});
} else if (id === "functional_roles") {
for (const bucket of ["functional_benefics", "functional_malefics", "functional_neutrals", "yogakarakas", "owned_houses"]) pick(bucket, true);
} else if (id === "avasthas") {
Object.keys(raw).forEach((planet) => pick(`${planet}.avasthas`));
} else if (id === "special_points") {
for (const kind of ["upagrahas.raw", "special_lagnas", "sensitive_points"]) {
Object.keys(record(at(source, kind))).forEach((name) => pick(`${kind}.${name}`));
}
} else if (id === "sade_sati") {
for (const field of ["is_active", "phase", "moon_sign", "phases"]) pick(field);
} else if (id === "annual") {
pick("solar_return.data");
for (const field of ["asc_sign", "asc_sign_idx", "age"]) {
const path = `annual_chart.data.${field}`;
add(`${sourcePath}.${path}`, path, at(source, path));
}
Object.keys(record(at(source, "annual_chart.data.planets"))).forEach((planet) => pick(`annual_chart.data.planets.${planet}`));
Object.keys(record(at(source, "sahams.data"))).forEach((point) => pick(`sahams.data.${point}`));
const sahamPath = worksheetValue !== undefined ? "worksheets.advanced_systems.sahams"
: rootValue !== undefined ? "advanced_systems.sahams" : `${root.chart ? "chart." : ""}modules.sahams`;
const sahams = record(rootValue !== undefined && worksheetValue === undefined ? undefined : at(root, sahamPath));
Object.entries(sahams).forEach(([name, point]) => {
if (/^[A-Za-z0-9_]+_saham$/.test(name)) walk(point, `${sahamPath}.${name}`, `sahams.${name}`);
});
}
}
const tableSourcePath = id === "annual" ? (worksheetValue !== undefined ? "worksheets"
: rootValue !== undefined ? "timing_and_predictive_systems" : `${root.chart ? "chart." : ""}modules`) : sourcePath;
tables.push({ id, title, claimStatus, sourcePath: tableSourcePath,
note: id === "sade_sati" ? "这里只列本次计算返回的阶段框架;尚无三轮起止日期,不能据此判断时间。"
: rows.length ? "原始计算供核对,不增加结论的确定性。" : "本次没有可展示的数据,不补算、不补写。",
columns: ["原始字段", "数值 / 状态"], rows });
}
return reportFactTablesSchema.parse(tables);
}
@@ -430,6 +430,18 @@ function projectProseLine(line: string): { keep: boolean; text: string; limitati
return { keep: true, text: cleaned, limitations };
}
/** Safety-only boundary for the separately authorized raw attachment, not prose projection. */
export function sanitizeRawAppendixMarkdown(markdown: string): string {
return parseBlocks(markdown).map((block) => {
if (block.type === "fence" && block.lang === "jyotish-chart") {
return fenceIsPublic(block.raw) ? block.raw : "";
}
return stripSecrets(stripHtml(block.raw)).split(/(\r?\n)/).map((line) => (
/^\r?\n$/.test(line) ? line : projectInline(line)
)).join("");
}).join("");
}
function projectInline(text: string): string {
let next = text.replace(/<!--[\s\S]*?-->/g, "");
next = next.replace(/!\[([^\]]*)\]\([^)]*\)/g, (_match, alt: string) => stripSecrets(stripHtml(String(alt))).trim());
+9 -3
View File
@@ -39,7 +39,7 @@ export const claimStatusSchema = z.enum([
]);
export type ReportVargaHouses = Readonly<{
id: "D2" | "D6" | "D8" | "D9" | "D10" | "D11" | "D24" | "D30";
id: Exclude<(typeof import("@/lib/personal-report-contract").CHART_IDS)[number], "D1">;
houses: readonly ReportChartHouse[];
}>;
@@ -238,12 +238,18 @@ export class PersonalReportAgentOutputError extends Error {
* text are structurally excluded by the agent definition (no skills, no tools,
* no memory).
*/
function writerBundle(bundle: ReportEvidenceBundleV2) {
const { factTables, ...facts } = bundle;
void factTables;
return facts;
}
export function buildReportPrompt(
bundle: ReportEvidenceBundleV2,
plan: PersonalReportSectionPlan,
): string {
return `请根据以下唯一的已验证 ReportEvidenceBundleV2 和 PersonalReportSectionPlan 生成个人报告 JSON。只输出 plan 中 disposition=write 的 thematic section;blocked section 由服务器确定性生成,不得补写。只使用 claimCards 中的结论,不得提升 assertionLevel。每个 section 的 id、theme 和 evidenceRefs 必须严格匹配 plan。严格按输出 schema 返回 JSON。
${JSON.stringify({ bundle, plan })}`;
${JSON.stringify({ bundle: writerBundle(bundle), plan })}`;
}
export type ReportAgentGenerateOptions = Readonly<{
@@ -344,7 +350,7 @@ function sectionPrompt(
section: ReportSectionPlanEntry,
completedTitles: readonly string[],
): string {
return `请只生成以下一个个人报告 thematicNarrative 条目。严格输出单个 JSON 对象,不要数组、Markdown 或额外文字。只使用给定证据;section id、theme、evidenceRefs 必须与 plan 完全一致。evidenceRefs 必须逐字复制 plan.evidenceRefs 数组,不得增删。已完成章节标题仅用于避免重复,不要复述正文。\n${JSON.stringify({ bundle, plan: section, completedSectionTitles: completedTitles })}`;
return `请只生成以下一个个人报告 thematicNarrative 条目。严格输出单个 JSON 对象,不要数组、Markdown 或额外文字。只使用给定证据;section id、theme、evidenceRefs 必须与 plan 完全一致。evidenceRefs 必须逐字复制 plan.evidenceRefs 数组,不得增删。已完成章节标题仅用于避免重复,不要复述正文。\n${JSON.stringify({ bundle: writerBundle(bundle), plan: section, completedSectionTitles: completedTitles })}`;
}
/**