feat(report): add dense personal report appendix
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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());
|
||||
|
||||
@@ -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 })}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user