feat: add persistent personal report document v2

This commit is contained in:
Jesse_Chen
2026-08-15 05:10:37 +08:00
parent 9d2d79801d
commit 5012ff7212
48 changed files with 9301 additions and 932 deletions
+5 -22
View File
@@ -1,4 +1,4 @@
import { after, NextResponse } from "next/server";
import { NextResponse } from "next/server";
import { runConsultationWorkflow } from "@/mastra";
import { createPersonalReportAgent } from "@/mastra/personal-report";
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
@@ -20,19 +20,20 @@ import {
createSupabasePersonalReportService,
type PersonalReportService,
} from "@/lib/personal-report-service";
import { createSupabasePersonalReportJobService } from "@/lib/personal-report-job-service";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
export const maxDuration = 120;
const STALE_GENERATION_MS = 15 * 60 * 1000;
const REPORT_LIST_COLUMNS = [
"id",
"request_id",
"report_type",
"presentation_mode",
"depth",
"requested_themes",
"status",
"failure_code",
@@ -56,6 +57,7 @@ function listReportView(value: unknown) {
requestId: typeof row.request_id === "string" ? row.request_id : "",
reportType: typeof row.report_type === "string" ? row.report_type : "personal_full",
presentationMode: typeof row.presentation_mode === "string" ? row.presentation_mode : "default",
depth: typeof row.depth === "string" ? row.depth : "standard",
themes: Array.isArray(row.requested_themes)
? row.requested_themes.filter((theme): theme is string => typeof theme === "string")
: [],
@@ -113,25 +115,6 @@ export async function POST(request: Request) {
const persistence: PersonalReportService = createSupabasePersonalReportService(admin);
const adminDataClient = createPersonalReportDataClient(admin);
// `after()` is bounded by the route duration and can be interrupted by a
// process restart. Reclaim only rows far beyond that bound so a killed
// task never blocks the user's next explicit generation forever.
if (userId) {
const now = new Date();
const staleBefore = new Date(now.getTime() - STALE_GENERATION_MS).toISOString();
const { error } = await admin
.from("personal_reports")
.update({
status: "failed",
failure_code: REPORT_STABLE_CODES.calculationUnavailable,
updated_at: now.toISOString(),
})
.eq("user_id", userId)
.eq("status", "generating")
.lte("updated_at", staleBefore);
if (error) throw error;
}
let profile: unknown = null;
let profileError: unknown = null;
if (userId) {
@@ -208,7 +191,7 @@ export async function POST(request: Request) {
runWorkflow: (input) => runConsultationWorkflow(input),
createAgent: (model) => createPersonalReportAgent(model as Parameters<typeof createPersonalReportAgent>[0]),
skillSnapshot: resolveSkillSnapshot(),
deferGeneration: (task) => after(task),
jobs: createSupabasePersonalReportJobService(admin),
};
const response = await resolveReportCreate(deps);
@@ -26,6 +26,7 @@ export interface PersonalReportCreateRequest {
requestId: string;
reportType: "personal_full";
presentationMode: "default";
depth: "standard";
themes: readonly string[];
}
@@ -51,6 +52,7 @@ export function buildPersonalReportCreateRequest(
requestId,
reportType: "personal_full",
presentationMode: "default",
depth: "standard",
themes: [...DEFAULT_REPORT_THEMES],
};
}
@@ -11,6 +11,7 @@ type ReportListItem = Readonly<{
id: string;
reportType: string;
presentationMode: string;
depth: "concise" | "standard" | "deep" | "research";
themes: readonly string[];
status: "generating" | "ready" | "failed";
failureCode: string | null;
@@ -44,6 +45,9 @@ function readReports(value: unknown): ReportListItem[] {
id: row.id,
reportType: typeof row.reportType === "string" ? row.reportType : "personal_full",
presentationMode: typeof row.presentationMode === "string" ? row.presentationMode : "default",
depth: ["concise", "standard", "deep", "research"].includes(String(row.depth))
? row.depth as ReportListItem["depth"]
: "standard",
themes: Array.isArray(row.themes)
? row.themes.filter((theme): theme is string => typeof theme === "string")
: [],
@@ -187,7 +191,7 @@ export function PersonalReportCenter() {
</div>
<h3>{report.reportType === "personal_thematic" ? "个人主题报告" : "个人完整报告"}</h3>
<p>{copy.description}</p>
<small>{formatDate(report.createdAt)} · {report.themes.join(" / ") || "综合主题"}</small>
<small>{formatDate(report.createdAt)} · {report.depth} · {report.themes.join(" / ") || "综合主题"}</small>
</div>
{report.status === "ready" ? (
<Button render={<Link href={`/reports/${encodeURIComponent(report.id)}`} />} nativeButton={false} variant="outline">
@@ -1,28 +1,29 @@
/**
* Editorial personal-report reader inspired by a research memo:
*
* 1. Subject, metadata and four fast facts
* 2. Executive judgement and priorities
* 3. D1 chart with a compact evidence table
* 4. Additional charts and thematic narratives
* 5. Evidence appendix and disclaimer
* 1. Subject, metadata and fast facts
* 2. Executive judgement and v2 report scope
* 3. Natal foundation and structured chart evidence
* 4. Thematic/current-phase narratives and action notes
* 5. Blocked disclosures, evidence appendix, provenance and disclaimer
*
* The visual order is deliberately answer-first. All content still comes from
* the validated ReportDocument v1; no report text, chart or evidence is
* fabricated in the reader.
* The visual order is deliberately answer-first. All content comes from the
* validated stored v1 or current v2 ReportDocument; no report text, chart,
* HTML, CSS or SVG is fabricated from model output in the reader.
*/
"use client";
import { useMemo } from "react";
import { findChart, VedicChartSvg } from "./vedic-chart-svg";
import type {
ChartV1,
ClaimStatus,
EvidenceAppendix,
ReportDocumentV1,
ThematicSectionV1,
import { hasRealChartData, VedicChartSvg } from "./vedic-chart-svg";
import {
isReportDocumentV2,
type ChartV1,
type ClaimStatus,
type EvidenceAppendix,
type ReportDocument,
type ReportDocumentV2,
} from "@/lib/personal-report-contract";
const CLAIM_STATUS_LABELS: Record<ClaimStatus, string> = {
@@ -53,11 +54,36 @@ const CONFLICT_STATUS_LABELS: Record<string, string> = {
resolved: "已解决",
};
const DEPTH_LABELS: Record<ReportDocumentV2["depth"], string> = {
concise: "精简",
standard: "标准",
deep: "深入",
research: "研究",
};
const THEME_LABELS: Record<string, string> = {
general: "综合",
career: "事业",
wealth: "财富",
marriage: "婚恋",
health: "健康",
education: "教育",
timing: "应期",
};
const ACTION_PRIORITY_LABELS: Record<ReportDocumentV2["actionNotes"][number]["priority"], string> = {
now: "现在",
next: "下一步",
watch: "持续观察",
};
const PLANET_ALIASES = {
sun: ["太阳", "Sun", "Surya"],
moon: ["月亮", "月球", "Moon", "Chandra"],
} as const;
type ReportChart = ReportDocument["charts"][number];
function claimStatusLabel(status: string): string {
return CLAIM_STATUS_LABELS[status as ClaimStatus] ?? status;
}
@@ -125,7 +151,39 @@ function planetPlacement(chart: ChartV1 | undefined, aliases: readonly string[])
return planet ? `${planet.sign} · 第${planet.houseNumber}` : "暂无数据";
}
function ReportCover({ document, d1 }: { document: ReportDocumentV1; d1: ChartV1 | undefined }) {
function themeLabel(theme: string): string {
return THEME_LABELS[theme] ?? theme;
}
function chartEvidenceRefs(chart: ReportChart): readonly string[] {
return "evidenceRefs" in chart ? chart.evidenceRefs : [];
}
function structuredChart(chart: ReportChart): ChartV1 {
// V2 only widens the allowed chart id set and adds evidenceRefs; the
// structured houses/planets consumed by VedicChartSvg are unchanged.
return chart as ChartV1;
}
function hasStructuredChartData(chart: ReportChart): boolean {
return hasRealChartData(structuredChart(chart));
}
function EvidenceLinks({ refs, knownEvidenceIds }: {
refs: readonly string[];
knownEvidenceIds: ReadonlySet<string>;
}) {
if (refs.length === 0) return null;
return (
<ul className="personal-report-evidence-links">
{refs.map((ref) => knownEvidenceIds.has(ref) ? (
<li key={ref}><a href={`#evidence-${ref}`}> · {ref}</a></li>
) : <li key={ref}> · {ref}</li>)}
</ul>
);
}
function ReportCover({ document, d1 }: { document: ReportDocument; d1: ChartV1 | undefined }) {
const risingSign = d1?.houses.find((house) => house.houseNumber === 1)?.sign ?? "暂无数据";
const facts = [
{ value: risingSign, label: "上升星座" },
@@ -133,6 +191,7 @@ function ReportCover({ document, d1 }: { document: ReportDocumentV1; d1: ChartV1
{ value: planetPlacement(d1, PLANET_ALIASES.moon), label: "月亮落点" },
{ value: claimStatusLabel(document.executiveSummary.overallClaimStatus), label: "证据状态" },
];
const v2 = isReportDocumentV2(document) ? document : null;
return (
<header className="personal-report-cover personal-report-section">
@@ -145,6 +204,8 @@ function ReportCover({ document, d1 }: { document: ReportDocumentV1; d1: ChartV1
<div><dt></dt><dd>{document.subject.birthPlaceLabel}</dd></div>
<div><dt></dt><dd>{BIRTH_TIME_STATUS_LABELS[document.subject.birthTimeStatus] ?? document.subject.birthTimeStatus}</dd></div>
<div><dt></dt><dd>{document.presentationMode === "research" ? "研究模式" : "标准模式"}</dd></div>
{v2 && <div><dt></dt><dd>{DEPTH_LABELS[v2.depth]}</dd></div>}
{v2 && <div><dt></dt><dd>{v2.requestedThemes.map(themeLabel).join("、")}</dd></div>}
<div><dt></dt><dd>{formatGeneratedAt(document.generatedAt)}</dd></div>
</dl>
</div>
@@ -160,8 +221,12 @@ function ReportCover({ document, d1 }: { document: ReportDocumentV1; d1: ChartV1
);
}
function ExecutiveSummarySection({ document }: { document: ReportDocumentV1 }) {
function ExecutiveSummarySection({ document, knownEvidenceIds }: {
document: ReportDocument;
knownEvidenceIds: ReadonlySet<string>;
}) {
const summary = document.executiveSummary;
const evidenceRefs = "evidenceRefs" in summary ? summary.evidenceRefs : [];
return (
<section aria-labelledby="report-summary" className="personal-report-section personal-report-summary">
<div className="personal-report-section-heading">
@@ -182,6 +247,36 @@ function ExecutiveSummarySection({ document }: { document: ReportDocumentV1 }) {
)}
</>
)}
<EvidenceLinks refs={evidenceRefs} knownEvidenceIds={knownEvidenceIds} />
</section>
);
}
function NatalFoundationSection({ document, knownEvidenceIds }: {
document: ReportDocumentV2;
knownEvidenceIds: ReadonlySet<string>;
}) {
const section = document.natalFoundation;
return (
<section aria-labelledby="report-natal-foundation" className="personal-report-section personal-report-themes">
<div className="personal-report-section-heading">
<h2 id="report-natal-foundation">{section.title}</h2>
</div>
<article className="personal-report-theme">
<header><h3></h3><ClaimStatus status={section.claimStatus} /></header>
<NarrativeText text={section.narrative} />
{(section.keyFactors.length > 0 || section.caveats.length > 0) && (
<div className="personal-report-theme-details">
{section.keyFactors.length > 0 && (
<div><h4></h4><ul>{section.keyFactors.map((factor, index) => <li key={index}>{factor}</li>)}</ul></div>
)}
{section.caveats.length > 0 && (
<div><h4></h4><ul>{section.caveats.map((caveat, index) => <li key={index}>{caveat}</li>)}</ul></div>
)}
</div>
)}
<EvidenceLinks refs={section.evidenceRefs} knownEvidenceIds={knownEvidenceIds} />
</article>
</section>
);
}
@@ -217,7 +312,10 @@ function D1EvidenceTable({ chart }: { chart: ChartV1 }) {
);
}
function LeadChartSection({ chart }: { chart: ChartV1 | undefined }) {
function LeadChartSection({ chart, knownEvidenceIds }: {
chart: ReportChart | undefined;
knownEvidenceIds: ReadonlySet<string>;
}) {
if (!chart) return null;
return (
<section aria-labelledby="report-charts" className="personal-report-section personal-report-lead-chart">
@@ -227,10 +325,11 @@ function LeadChartSection({ chart }: { chart: ChartV1 | undefined }) {
<h2 id="report-charts"></h2>
<span>{chart.title}</span>
</div>
<VedicChartSvg chart={chart} ariaLabel={`${chart.title}命盘,共 ${chart.houses.length} 宫数据`} />
<VedicChartSvg chart={structuredChart(chart)} ariaLabel={`${chart.title}命盘,共 ${chart.houses.length} 宫数据`} />
<figcaption> 1 · {chart.title}</figcaption>
<EvidenceLinks refs={chartEvidenceRefs(chart)} knownEvidenceIds={knownEvidenceIds} />
</figure>
<D1EvidenceTable chart={chart} />
<D1EvidenceTable chart={structuredChart(chart)} />
</div>
</section>
);
@@ -256,7 +355,10 @@ function PlanetTable({ chart }: { chart: ChartV1 }) {
);
}
function AdditionalChartsSection({ charts }: { charts: ChartV1[] }) {
function AdditionalChartsSection({ charts, knownEvidenceIds }: {
charts: ReportChart[];
knownEvidenceIds: ReadonlySet<string>;
}) {
if (charts.length === 0) return null;
return (
<section aria-labelledby="report-additional-charts" className="personal-report-section personal-report-additional-charts">
@@ -267,8 +369,9 @@ function AdditionalChartsSection({ charts }: { charts: ChartV1[] }) {
{charts.map((chart) => (
<article key={chart.id} className="personal-report-chart-card">
<header><h3>{chart.title}</h3><ClaimStatus status={chart.claimStatus} /></header>
<VedicChartSvg chart={chart} ariaLabel={`${chart.title}命盘,共 ${chart.houses.length} 宫数据`} />
<PlanetTable chart={chart} />
<VedicChartSvg chart={structuredChart(chart)} ariaLabel={`${chart.title}命盘,共 ${chart.houses.length} 宫数据`} />
<PlanetTable chart={structuredChart(chart)} />
<EvidenceLinks refs={chartEvidenceRefs(chart)} knownEvidenceIds={knownEvidenceIds} />
</article>
))}
</div>
@@ -276,24 +379,10 @@ function AdditionalChartsSection({ charts }: { charts: ChartV1[] }) {
);
}
function EvidenceChips({ section, knownEvidenceIds }: { section: ThematicSectionV1; knownEvidenceIds: Set<string> }) {
if (section.evidenceRefs.length === 0) return null;
return (
<ul className="personal-report-evidence-links">
{section.evidenceRefs.map((ref) => knownEvidenceIds.has(ref) ? (
<li key={ref}><a href={`#evidence-${ref}`}> · {ref}</a></li>
) : <li key={ref}> · {ref}</li>)}
</ul>
);
}
function ThematicNarrativeSection({ document }: { document: ReportDocumentV1 }) {
const knownEvidenceIds = useMemo(() => new Set([
...document.evidenceAppendix.techniqueAudit.map((row) => row.id),
...document.evidenceAppendix.conflicts.map((row) => row.id),
...document.evidenceAppendix.calculationEvidence.map((row) => row.id),
]), [document]);
function ThematicNarrativeSection({ document, knownEvidenceIds }: {
document: ReportDocument;
knownEvidenceIds: ReadonlySet<string>;
}) {
if (document.thematicNarrative.length === 0) return null;
return (
<section aria-labelledby="report-themes" className="personal-report-section personal-report-themes">
@@ -310,14 +399,93 @@ function ThematicNarrativeSection({ document }: { document: ReportDocumentV1 })
{(section.actions.length > 0 || section.caveats.length > 0) && (
<div className="personal-report-theme-details">
{section.actions.length > 0 && (
<div><h4></h4><ul>{section.actions.map((action, actionIndex) => <li key={actionIndex}>{action}</li>)}</ul></div>
<div><h4></h4><ul>{section.actions.map((action, index) => <li key={index}>{action}</li>)}</ul></div>
)}
{section.caveats.length > 0 && (
<div><h4></h4><ul>{section.caveats.map((caveat, caveatIndex) => <li key={caveatIndex}>{caveat}</li>)}</ul></div>
<div><h4></h4><ul>{section.caveats.map((caveat, index) => <li key={index}>{caveat}</li>)}</ul></div>
)}
</div>
)}
<EvidenceChips section={section} knownEvidenceIds={knownEvidenceIds} />
<EvidenceLinks refs={section.evidenceRefs} knownEvidenceIds={knownEvidenceIds} />
</article>
))}
</section>
);
}
function CurrentPhaseSection({ document, knownEvidenceIds }: {
document: ReportDocumentV2;
knownEvidenceIds: ReadonlySet<string>;
}) {
const section = document.currentPhase;
if (!section) return null;
return (
<section aria-labelledby="report-current-phase" className="personal-report-section personal-report-themes">
<div className="personal-report-section-heading">
<p>{section.phaseLabel}</p>
<h2 id="report-current-phase">{section.title}</h2>
</div>
<article className="personal-report-theme">
<header><h3></h3><ClaimStatus status={section.claimStatus} /></header>
<NarrativeText text={section.narrative} />
{(section.timingNotes.length > 0 || section.caveats.length > 0) && (
<div className="personal-report-theme-details">
{section.timingNotes.length > 0 && (
<div><h4></h4><ul>{section.timingNotes.map((note, index) => <li key={index}>{note}</li>)}</ul></div>
)}
{section.caveats.length > 0 && (
<div><h4></h4><ul>{section.caveats.map((caveat, index) => <li key={index}>{caveat}</li>)}</ul></div>
)}
</div>
)}
<EvidenceLinks refs={section.evidenceRefs} knownEvidenceIds={knownEvidenceIds} />
</article>
</section>
);
}
function ActionNotesSection({ document, knownEvidenceIds }: {
document: ReportDocumentV2;
knownEvidenceIds: ReadonlySet<string>;
}) {
if (document.actionNotes.length === 0) return null;
return (
<section aria-labelledby="report-action-notes" className="personal-report-section personal-report-themes">
<div className="personal-report-section-heading">
<h2 id="report-action-notes"></h2>
</div>
{document.actionNotes.map((note) => (
<article key={note.id} className="personal-report-theme">
<header><h3>{note.title}</h3><span className="personal-report-claim-status">{ACTION_PRIORITY_LABELS[note.priority]}</span></header>
<NarrativeText text={note.note} />
<EvidenceLinks refs={note.evidenceRefs} knownEvidenceIds={knownEvidenceIds} />
</article>
))}
</section>
);
}
function BlockedConflictDisclosureSection({ document, knownEvidenceIds }: {
document: ReportDocumentV2;
knownEvidenceIds: ReadonlySet<string>;
}) {
if (document.blockedConflictDisclosure.length === 0) return null;
return (
<section aria-labelledby="report-blocked-disclosures" className="personal-report-section personal-report-themes">
<div className="personal-report-section-heading">
<h2 id="report-blocked-disclosures"></h2>
</div>
{document.blockedConflictDisclosure.map((section, index) => (
<article key={`${section.theme}-${index}`} className="personal-report-theme">
<header><h3>{themeLabel(section.theme)} · {section.title}</h3><ClaimStatus status={section.claimStatus} /></header>
<NarrativeText text={section.reason} />
<div className="personal-report-theme-details">
<div><h4></h4><ul>{section.missingEvidence.map((item, itemIndex) => <li key={itemIndex}>{item}</li>)}</ul></div>
{section.conflictNotes.length > 0 && (
<div><h4></h4><ul>{section.conflictNotes.map((note, noteIndex) => <li key={noteIndex}>{note}</li>)}</ul></div>
)}
</div>
<EvidenceLinks refs={section.evidenceRefs} knownEvidenceIds={knownEvidenceIds} />
</article>
))}
</section>
@@ -345,7 +513,7 @@ function AuditTable({ appendix }: { appendix: EvidenceAppendix }) {
);
}
function EvidenceAppendixSection({ document }: { document: ReportDocumentV1 }) {
function EvidenceAppendixSection({ document }: { document: ReportDocument }) {
const appendix = document.evidenceAppendix;
const expandedByDefault = appendix.expandedByDefault || document.presentationMode === "research";
const hasContent = appendix.techniqueAudit.length > 0 || appendix.conflicts.length > 0
@@ -395,28 +563,52 @@ function EvidenceAppendixSection({ document }: { document: ReportDocumentV1 }) {
);
}
function DisclaimerSection({ document }: { document: ReportDocumentV1 }) {
function ProvenanceDisclaimerSection({ document }: { document: ReportDocument }) {
const provenance = document.provenance;
const skillLabel = provenance.skillName
? `${provenance.skillName}${provenance.skillVersion ? ` · ${provenance.skillVersion}` : ""}`
: "旧版报告未记录命名技能";
return (
<section aria-labelledby="report-disclaimer" className="personal-report-section personal-report-disclaimer">
<h2 id="report-disclaimer"></h2><p>{document.disclaimer}</p>
<h2 id="report-disclaimer"></h2>
<dl className="personal-report-metadata personal-report-avoid-break">
<div><dt></dt><dd>{skillLabel}</dd></div>
<div><dt></dt><dd>{provenance.reportContractVersion}</dd></div>
<div><dt></dt><dd>{provenance.skillSourceCommit ?? "未记录"}</dd></div>
<div><dt></dt><dd>{provenance.skillSnapshotSha256}</dd></div>
<div><dt></dt><dd>{provenance.calculationHash}</dd></div>
<div><dt></dt><dd>{provenance.evidenceHash}</dd></div>
</dl>
<p>{document.disclaimer}</p>
</section>
);
}
export function PersonalReportDocumentView({ document }: { document: ReportDocumentV1 }) {
const d1 = findChart(document.charts, "D1");
const additionalCharts = [findChart(document.charts, "D9"), findChart(document.charts, "D10")]
.filter((chart): chart is ChartV1 => chart !== undefined);
export function PersonalReportDocumentView({ document }: { document: ReportDocument }) {
const charts: ReportChart[] = [...document.charts];
const d1 = charts.find((chart) => chart.id === "D1" && hasStructuredChartData(chart));
const additionalCharts = charts.filter((chart) => chart.id !== "D1" && hasStructuredChartData(chart));
const knownEvidenceIds = useMemo(() => new Set([
...document.evidenceAppendix.techniqueAudit.map((row) => row.id),
...document.evidenceAppendix.conflicts.map((row) => row.id),
...document.evidenceAppendix.calculationEvidence.map((row) => row.id),
]), [document]);
const v2 = isReportDocumentV2(document) ? document : null;
return (
<article className="personal-report-document">
<ReportCover document={document} d1={d1} />
<ExecutiveSummarySection document={document} />
<LeadChartSection chart={d1} />
<AdditionalChartsSection charts={additionalCharts} />
<ThematicNarrativeSection document={document} />
<article className="personal-report-document" data-report-schema-version={document.schemaVersion}>
<ReportCover document={document} d1={d1 ? structuredChart(d1) : undefined} />
<ExecutiveSummarySection document={document} knownEvidenceIds={knownEvidenceIds} />
{v2 && <NatalFoundationSection document={v2} knownEvidenceIds={knownEvidenceIds} />}
<LeadChartSection chart={d1} knownEvidenceIds={knownEvidenceIds} />
<AdditionalChartsSection charts={additionalCharts} knownEvidenceIds={knownEvidenceIds} />
<ThematicNarrativeSection document={document} knownEvidenceIds={knownEvidenceIds} />
{v2 && <CurrentPhaseSection document={v2} knownEvidenceIds={knownEvidenceIds} />}
{v2 && <ActionNotesSection document={v2} knownEvidenceIds={knownEvidenceIds} />}
{v2 && <BlockedConflictDisclosureSection document={v2} knownEvidenceIds={knownEvidenceIds} />}
<EvidenceAppendixSection document={document} />
<DisclaimerSection document={document} />
<ProvenanceDisclaimerSection document={document} />
<footer className="personal-report-footer">
<span> · {document.presentationMode === "research" ? "研究模式" : "标准模式"}</span>
<span> {document.reportId}</span>
@@ -22,7 +22,7 @@ import { LoaderCircle, TriangleAlert } from "lucide-react";
import { ReportActions } from "./report-actions";
import { PersonalReportDocumentView } from "./personal-report-document-view";
import { safeParseReportDocument } from "@/lib/personal-report-contract";
import type { ReportDocumentV1 } from "@/lib/personal-report-contract";
import type { ReportDocument } from "@/lib/personal-report-contract";
import { Button } from "@/components/ui/button";
export type ReportLoadState =
@@ -33,7 +33,7 @@ export type ReportLoadState =
| { phase: "failed"; failureCode: string | null }
| { phase: "invalid"; message: string }
| { phase: "network-error" }
| { phase: "ready"; document: ReportDocumentV1 };
| { phase: "ready"; document: ReportDocument };
/** GET /api/reports/:id envelope view (mirrors the API route's reportView). */
export interface ReportEnvelopeView {
+3
View File
@@ -10,4 +10,7 @@ export async function register(): Promise<void> {
const { verifyAllActiveSkillPackages } = await import("./lib/skill-package-registry");
verifyAllActiveSkillPackages();
const { startPersonalReportWorker } = await import("./lib/personal-report-worker");
startPersonalReportWorker();
}
@@ -4,23 +4,22 @@ import {
safeParseReportDocument,
ReportDocumentValidationError,
type EvidenceAppendix,
type ReportDocument,
type ReportDocumentParseResult,
type ReportDocumentV1,
} from "./personal-report-contract.ts";
/**
* Server hash core for ReportDocument v1.
* Server hash core for ReportDocument v1/v2.
*
* Pure Node implementation (node:crypto) without the server-only marker so
* tests can import it directly; the production entry
* personal-report-contract.server.ts adds `import "server-only"` and
* re-exports this module. Client bundles must never import this file: besides
* the marker on the production entry, node:crypto fails Next.js client builds.
* re-exports this module. Client bundles must never import this file.
*
* Flow per the architecture ruling: canonical isomorphic parse first, then
* verify provenance.evidenceHash against the recomputed hash. The hash is
* never trusted as a model self-report. The Python validator
* (scripts/personal_report_contract.py) performs the same recomputation.
* The canonical isomorphic parse runs first, then provenance.evidenceHash is
* recomputed over the evidence appendix. Stored documents are never trusted to
* self-report their evidence hash. The Python validator performs the same
* recomputation for both schema versions.
*/
export function computeEvidenceHash(appendix: EvidenceAppendix): string {
@@ -35,16 +34,18 @@ export function safeParseServerReportDocument(input: unknown): ReportDocumentPar
if (parsed.document.provenance.evidenceHash !== recomputed) {
return {
ok: false,
errors: [{
path: "provenance.evidenceHash",
message: `does not match recomputed evidence hash ${recomputed}`,
}],
errors: [
{
path: "provenance.evidenceHash",
message: `does not match recomputed evidence hash ${recomputed}`,
},
],
};
}
return parsed;
}
export function parseServerReportDocument(input: unknown): ReportDocumentV1 {
export function parseServerReportDocument(input: unknown): ReportDocument {
const result = safeParseServerReportDocument(input);
if (!result.ok) throw new ReportDocumentValidationError(result.errors);
return result.document;
+311 -145
View File
@@ -1,25 +1,29 @@
/**
* ReportDocument v1 contract (isomorphic Zod side).
* ReportDocument v1/v2 contract (isomorphic Zod side).
*
* This file is importable from server and client bundles: it contains no
* node:crypto and no hash recomputation. Semantics are shared with:
* - contracts/personal-report/report-document.v1.schema.json (JSON Schema)
* - scripts/personal_report_contract.py (stdlib Python validator)
* - frontend/src/lib/personal-report-contract.server-core.ts (server hash)
* ReportDocument v2 is the canonical current contract. The v1 schema and
* compatibility type alias remain readable while the existing producer and
* renderer migrate independently. This file contains no node:crypto; evidence
* hashes are recomputed only by the server entry and the Python validator.
*
* JSON Schema draft-07 cannot express every rule; the runtime-enforced
* semantics below (chart-set invariants, evidenceRefs existence, blocked
* non-determinism, forbidden content, evidence-id uniqueness, serialization
* cap) are implemented identically in this file and in the Python validator,
* with tests on both sides. The cryptographic evidence hash is verified only
* by the server runtime (personal-report-contract.server.ts) and the Python
* validator; it is never part of this isomorphic parse.
* JSON Schema draft-07 cannot express every semantic rule. The runtime guards
* below are mirrored by scripts/personal_report_contract.py: chart integrity,
* requested-theme coverage, evidence-reference closure, evidence-id
* uniqueness, blocked-claim safety, unsupported date rejection, content
* safety, and the serialization cap.
*/
import { z } from "zod";
export const REPORT_DOCUMENT_SCHEMA_VERSION = "report_document.v1" as const;
export const REPORT_CONTRACT_VERSION = "1" as const;
export const LEGACY_REPORT_DOCUMENT_SCHEMA_VERSION = "report_document.v1" as const;
export const REPORT_DOCUMENT_SCHEMA_VERSION = "report_document.v2" as const;
export const REPORT_DOCUMENT_V1_SCHEMA_VERSION = LEGACY_REPORT_DOCUMENT_SCHEMA_VERSION;
export const REPORT_DOCUMENT_V2_SCHEMA_VERSION = REPORT_DOCUMENT_SCHEMA_VERSION;
export const CURRENT_REPORT_DOCUMENT_SCHEMA_VERSION = REPORT_DOCUMENT_SCHEMA_VERSION;
export const REPORT_CONTRACT_V1_VERSION = "1" as const;
export const REPORT_CONTRACT_V2_VERSION = "2" as const;
/** @deprecated Existing v1 producer compatibility. */
export const REPORT_CONTRACT_VERSION = REPORT_CONTRACT_V1_VERSION;
export const REPORT_DOCUMENT_MAX_BYTES = 1_572_864; // 1.5 MiB hard cap.
export const CLAIM_STATUSES = [
@@ -34,12 +38,18 @@ export type ClaimStatus = (typeof CLAIM_STATUSES)[number];
export const REPORT_TYPES = ["personal_full", "personal_thematic"] as const;
export const PRESENTATION_MODES = ["default", "research"] as const;
export const REPORT_DEPTHS = ["concise", "standard", "deep", "research"] as const;
export type ReportDepth = (typeof REPORT_DEPTHS)[number];
export const BIRTH_TIME_STATUSES = ["reported", "candidate", "accepted", "confirmed"] as const;
export const TECHNIQUE_STATUSES = ["verified", "partial", "blocked"] as const;
export const CONFLICT_STATUSES = ["unresolved", "partial", "resolved"] as const;
export const CHART_IDS = ["D1", "D9", "D10"] 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", "D9", "D10", "D11", "D24"] as const;
const claimStatusSchema = z.enum(CLAIM_STATUSES);
const themeIdSchema = z.string().regex(/^[a-z][a-z0-9_.-]{0,95}$/, "invalid theme id");
const sectionIdSchema = z.string().regex(/^[a-z][a-z0-9_-]{0,95}$/, "invalid section id");
const evidenceIdSchema = z.string().regex(/^ev-[a-z0-9_-]{1,63}$/, "invalid evidence id");
const sha256HexSchema = z.string().regex(/^[0-9a-f]{64}$/, "invalid sha256 hex");
const skillNameSchema = z.string()
@@ -52,7 +62,9 @@ const iso8601Schema = z.string()
.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/, "invalid ISO-8601 timestamp");
const text = (maxLength: number, minLength = 1) => z.string().min(minLength).max(maxLength);
const textArray = (maxItems: number, maxLength: number) => z.array(text(maxLength)).max(maxItems);
const textArray = (maxItems: number, maxLength: number, minItems = 0) =>
z.array(text(maxLength)).min(minItems).max(maxItems);
const evidenceRefsSchema = (minItems = 0) => z.array(evidenceIdSchema).min(minItems).max(24);
const houseSchema = z.strictObject({
houseNumber: z.number().int().min(1).max(12),
@@ -68,22 +80,42 @@ const planetSchema = z.strictObject({
retrograde: z.boolean(),
});
const chartSchema = z.strictObject({
id: z.enum(CHART_IDS),
const reportDocumentV1ChartSchema = z.strictObject({
id: z.enum(REPORT_DOCUMENT_V1_CHART_IDS),
title: text(120),
houses: z.array(houseSchema).max(12),
planets: z.array(planetSchema).max(12).optional(),
claimStatus: claimStatusSchema,
});
const thematicSectionSchema = z.strictObject({
const reportDocumentV2ChartSchema = z.strictObject({
id: z.enum(CHART_IDS),
title: text(120),
houses: z.array(houseSchema).max(12),
planets: z.array(planetSchema).max(12).optional(),
claimStatus: claimStatusSchema,
evidenceRefs: evidenceRefsSchema(1),
});
const thematicSectionV1Schema = z.strictObject({
id: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/, "invalid section id"),
title: text(160),
narrative: text(4000),
actions: textArray(12, 400),
caveats: textArray(12, 400),
claimStatus: claimStatusSchema,
evidenceRefs: z.array(evidenceIdSchema).max(24),
evidenceRefs: evidenceRefsSchema(),
});
const thematicSectionV2Schema = z.strictObject({
id: sectionIdSchema,
theme: themeIdSchema,
title: text(160),
narrative: text(4000),
actions: textArray(12, 400),
caveats: textArray(12, 400),
claimStatus: claimStatusSchema,
evidenceRefs: evidenceRefsSchema(1),
});
const techniqueAuditRowSchema = z.strictObject({
@@ -117,46 +149,139 @@ const evidenceAppendixSchema = z.strictObject({
blockedTechniques: textArray(100, 120),
});
const reportDocumentShape = {
schemaVersion: z.literal(REPORT_DOCUMENT_SCHEMA_VERSION),
const subjectSchema = z.strictObject({
displayName: text(120),
birthTimeStatus: z.enum(BIRTH_TIME_STATUSES),
birthPlaceLabel: text(200),
});
const provenanceV1Schema = z.strictObject({
// Optional only so legacy v1 rows generated before named/versioned Skill
// provenance remain readable. Current v2 generation requires both.
skillName: skillNameSchema.optional(),
skillVersion: skillVersionSchema.optional(),
skillSourceCommit: z.string().regex(/^[0-9a-f]{40}$/, "invalid commit sha").nullable(),
skillSnapshotSha256: sha256HexSchema,
calculationHash: sha256HexSchema,
evidenceHash: sha256HexSchema,
reportContractVersion: z.literal(REPORT_CONTRACT_V1_VERSION),
});
const provenanceV2Schema = z.strictObject({
skillName: skillNameSchema,
skillVersion: skillVersionSchema,
skillSourceCommit: z.string().regex(/^[0-9a-f]{40}$/, "invalid commit sha").nullable(),
skillSnapshotSha256: sha256HexSchema,
calculationHash: sha256HexSchema,
evidenceHash: sha256HexSchema,
reportContractVersion: z.literal(REPORT_CONTRACT_V2_VERSION),
});
const executiveSummaryV1Schema = z.strictObject({
headline: text(200),
summary: text(2000),
priorities: textArray(8, 200),
overallClaimStatus: claimStatusSchema,
});
const executiveSummaryV2Schema = z.strictObject({
headline: text(200),
summary: text(2000),
priorities: textArray(8, 200),
overallClaimStatus: claimStatusSchema,
evidenceRefs: evidenceRefsSchema(1),
});
const natalFoundationSchema = z.strictObject({
title: text(160),
narrative: text(4000),
keyFactors: textArray(12, 400),
caveats: textArray(12, 400),
claimStatus: claimStatusSchema,
evidenceRefs: evidenceRefsSchema(1),
});
const currentPhaseSchema = z.strictObject({
title: text(160),
phaseLabel: text(200),
narrative: text(4000),
timingNotes: textArray(12, 400),
caveats: textArray(12, 400),
claimStatus: claimStatusSchema,
evidenceRefs: evidenceRefsSchema(1),
});
const actionNoteSchema = z.strictObject({
id: sectionIdSchema,
title: text(160),
note: text(1000),
priority: z.enum(REPORT_ACTION_PRIORITIES),
evidenceRefs: evidenceRefsSchema(1),
});
const blockedConflictDisclosureSchema = z.strictObject({
theme: themeIdSchema,
title: text(160),
reason: text(2000),
missingEvidence: textArray(24, 400, 1),
conflictNotes: textArray(24, 500),
evidenceRefs: evidenceRefsSchema(),
claimStatus: z.literal("blocked"),
});
export const reportDocumentV1Schema = z.strictObject({
schemaVersion: z.literal(REPORT_DOCUMENT_V1_SCHEMA_VERSION),
reportId: z.string().uuid(),
reportType: z.enum(REPORT_TYPES),
presentationMode: z.enum(PRESENTATION_MODES),
generatedAt: iso8601Schema,
subject: z.strictObject({
displayName: text(120),
birthTimeStatus: z.enum(BIRTH_TIME_STATUSES),
birthPlaceLabel: text(200),
}),
provenance: z.strictObject({
// Optional only so ReportDocument v1 rows generated before named/versioned
// Skill provenance remain readable. Current generation always emits both.
skillName: skillNameSchema.optional(),
skillVersion: skillVersionSchema.optional(),
skillSourceCommit: z.string().regex(/^[0-9a-f]{40}$/, "invalid commit sha").nullable(),
skillSnapshotSha256: sha256HexSchema,
calculationHash: sha256HexSchema,
evidenceHash: sha256HexSchema,
reportContractVersion: z.literal(REPORT_CONTRACT_VERSION),
}),
executiveSummary: z.strictObject({
headline: text(200),
summary: text(2000),
priorities: textArray(8, 200),
overallClaimStatus: claimStatusSchema,
}),
charts: z.array(chartSchema).min(1).max(3),
thematicNarrative: z.array(thematicSectionSchema).max(12),
subject: subjectSchema,
provenance: provenanceV1Schema,
executiveSummary: executiveSummaryV1Schema,
charts: z.array(reportDocumentV1ChartSchema).min(1).max(3),
thematicNarrative: z.array(thematicSectionV1Schema).max(12),
evidenceAppendix: evidenceAppendixSchema,
disclaimer: text(2000),
};
});
export const reportDocumentSchema = z.strictObject(reportDocumentShape);
export const reportDocumentV2Schema = z.strictObject({
schemaVersion: z.literal(REPORT_DOCUMENT_V2_SCHEMA_VERSION),
reportId: z.string().uuid(),
reportType: z.enum(REPORT_TYPES),
presentationMode: z.enum(PRESENTATION_MODES),
depth: z.enum(REPORT_DEPTHS),
requestedThemes: z.array(themeIdSchema).min(1).max(12),
generatedAt: iso8601Schema,
subject: subjectSchema,
provenance: provenanceV2Schema,
executiveSummary: executiveSummaryV2Schema,
natalFoundation: natalFoundationSchema,
currentPhase: currentPhaseSchema.nullable(),
actionNotes: z.array(actionNoteSchema).min(1).max(24),
charts: z.array(reportDocumentV2ChartSchema).min(1).max(6),
thematicNarrative: z.array(thematicSectionV2Schema).max(12),
blockedConflictDisclosure: z.array(blockedConflictDisclosureSchema).max(12),
evidenceAppendix: evidenceAppendixSchema,
disclaimer: text(2000),
});
export type ReportDocumentV1 = z.infer<typeof reportDocumentSchema>;
export type EvidenceAppendix = ReportDocumentV1["evidenceAppendix"];
export type ChartV1 = ReportDocumentV1["charts"][number];
export type ThematicSectionV1 = ReportDocumentV1["thematicNarrative"][number];
/** Canonical reader accepts current v2 plus stored v1 rows during migration. */
export const reportDocumentSchema = z.union([reportDocumentV2Schema, reportDocumentV1Schema]);
export type LegacyReportDocumentV1 = z.infer<typeof reportDocumentV1Schema>;
export type ReportDocumentV2 = z.infer<typeof reportDocumentV2Schema>;
export type ReportDocument = LegacyReportDocumentV1 | ReportDocumentV2;
/** @deprecated Compatibility name retained for existing producer/renderer imports. */
export type ReportDocumentV1 = LegacyReportDocumentV1;
export type EvidenceAppendix = ReportDocument["evidenceAppendix"];
export type ChartV1 = LegacyReportDocumentV1["charts"][number];
export type ChartV2 = ReportDocumentV2["charts"][number];
export type ThematicSectionV1 = LegacyReportDocumentV1["thematicNarrative"][number];
export type ThematicSectionV2 = ReportDocumentV2["thematicNarrative"][number];
export type NatalFoundationV2 = ReportDocumentV2["natalFoundation"];
export type CurrentPhaseV2 = ReportDocumentV2["currentPhase"];
export type ActionNoteV2 = ReportDocumentV2["actionNotes"][number];
export type BlockedConflictDisclosureV2 = ReportDocumentV2["blockedConflictDisclosure"][number];
export type ReportDocumentParseError = Readonly<{
path: string;
@@ -173,6 +298,10 @@ export class ReportDocumentValidationError extends Error {
}
}
export function isReportDocumentV2(document: ReportDocument): document is ReportDocumentV2 {
return document.schemaVersion === REPORT_DOCUMENT_V2_SCHEMA_VERSION;
}
/** Canonical evidence object used by the evidence hash on both language sides. */
export function canonicalEvidence(appendix: EvidenceAppendix): Record<string, unknown> {
return {
@@ -199,18 +328,18 @@ export function canonicalEvidence(appendix: EvidenceAppendix): Record<string, un
};
}
export function serializedReportDocumentBytes(document: ReportDocumentV1): number {
export function serializedReportDocumentBytes(document: ReportDocument): number {
return new TextEncoder().encode(JSON.stringify(document)).length;
}
/**
* Forbidden content patterns. Keep byte-for-byte equivalent to
* FORBIDDEN_PATTERNS in scripts/personal_report_contract.py.
*/
/** Keep regex semantics equivalent to FORBIDDEN_PATTERNS on the Python side. */
export const FORBIDDEN_CONTENT_PATTERNS: readonly Readonly<{ name: string; pattern: RegExp }>[] = [
{ name: "html_tag_open", pattern: /<\s*(?:script|iframe|object|embed|style|link|meta|form|svg|img|video|audio|source|template|base|applet)\b/i },
{ name: "html_tag_close", pattern: /<\/\s*(?:script|iframe|object|embed|style|link|meta|form|svg|img|video|audio|source|template|base|applet)\s*>/i },
{ name: "html_tag", pattern: /<\s*\/?\s*[a-z][^>]*>/i },
{ name: "event_handler", pattern: /\bon(?:load|error|click|mouseover|mouseout|submit|focus|blur|change|dblclick|keydown|keyup|pointerdown|pointerup)\s*=/i },
{ name: "style_attribute", pattern: /\bstyle\s*=/i },
{ name: "css_at_rule", pattern: /@(?:import|media|supports|font-face|keyframes)\b/i },
{ name: "css_rule", pattern: /(?:^|[}\s])(?:[.#]?[a-z][a-z0-9_-]*)(?:\s+[.#]?[a-z][a-z0-9_-]*)*\s*\{[^{}]*\}/i },
{ name: "css_declaration", pattern: /(?:^|[;{\s])(?:color|background(?:-color)?|font(?:-family|-size|-weight)?|display|position|margin|padding|width|height|grid|flex|border|transform|animation)\s*:\s*[^;\n{}]+[;}]?/i },
{ name: "executable_url", pattern: /\b(?:javascript|vbscript|data:text\/html|data:text\/javascript|file):/i },
{ name: "processing_instruction", pattern: /<\?/i },
{ name: "template_literal", pattern: /\$\{/i },
@@ -223,71 +352,92 @@ export const FORBIDDEN_CONTENT_PATTERNS: readonly Readonly<{ name: string; patte
{ name: "secret_marker", pattern: /\b(?:SUPABASE_SERVICE_ROLE_KEY|AUTH_SECRET|BEGIN RSA PRIVATE KEY|BEGIN EC PRIVATE KEY|BEGIN OPENSSH PRIVATE KEY)\b/i },
{ name: "tool_trace", pattern: /\b(?:tool_call_id|tool_result|assistant_tool_calls|system_prompt)\b/i },
{ name: "chain_of_thought", pattern: /\bchain[\s_-]?of[\s_-]?thought\b/i },
{ name: "medical_diagnosis", pattern: /(?:你|命主)(?:已经|已|必将|一定会|确定)?(?:患有|罹患|得了)(?:癌症|糖尿病|抑郁症|双相情感障碍|心脏病|精神疾病)|(?:确诊为|诊断为)(?:癌症|糖尿病|抑郁症|双相情感障碍|心脏病|精神疾病)|\b(?:you|the native)\s+(?:definitely\s+)?(?:have|has|will develop)\s+(?:cancer|diabetes|depression|bipolar disorder|heart disease)\b|\bdiagnosed with\s+(?:cancer|diabetes|depression|bipolar disorder|heart disease)\b/i },
{ name: "deterministic_financial_promise", pattern: /(?:保证收益|保本保收益|稳赚不赔|稳赚|必赚|确定(?:盈利|获利|回报)|一定会(?:赚钱|盈利|获利))|\b(?:guaranteed|certain|risk-free)\s+(?:profit|return|gain)s?\b|\bwill definitely\s+(?:profit|earn|make money)\b/i },
];
/**
* Deterministic-prediction phrases forbidden inside blocked sections.
* Keep equivalent to DETERMINISTIC_PHRASES in the Python validator.
*/
export const BLOCKED_DETERMINISTIC_PHRASES: readonly string[] = [
"必然", "必定", "一定会", "肯定会", "绝对会", "保证会", "无疑将", "百分之百", "确定无疑",
"guaranteed", "definitely will", "certainly will", "will certainly", "is certain to",
];
const DATE_CLAIM_PATTERN = /(?:^|[^0-9])(?:19|20)\d{2}(?:年(?:0?[1-9]|1[0-2])月(?:(?:0?[1-9]|[12]\d|3[01])日)?|[-/.](?:0?[1-9]|1[0-2])(?:[-/.](?:0?[1-9]|[12]\d|3[01]))?)(?:[^0-9]|$)/i;
export function findForbiddenContent(value: string): readonly string[] {
return FORBIDDEN_CONTENT_PATTERNS
.filter(({ pattern }) => pattern.test(value))
.map(({ name }) => name);
}
function blockedTexts(document: ReportDocumentV1): readonly Readonly<{ path: string; text: string }>[] {
function textLeaves(value: unknown, path = ""): readonly Readonly<{ path: string; text: string }>[] {
if (typeof value === "string") return [{ path: path || "(root)", text: value }];
if (Array.isArray(value)) return value.flatMap((entry, index) => textLeaves(entry, `${path}[${index}]`));
if (value && typeof value === "object") {
return Object.entries(value).flatMap(([key, entry]) => textLeaves(entry, path ? `${path}.${key}` : key));
}
return [];
}
function blockedTexts(document: ReportDocument): readonly Readonly<{ path: string; text: string }>[] {
const entries: { path: string; text: string }[] = [];
if (document.executiveSummary.overallClaimStatus === "blocked") {
entries.push({ path: "executiveSummary.headline", text: document.executiveSummary.headline });
entries.push({ path: "executiveSummary.summary", text: document.executiveSummary.summary });
document.executiveSummary.priorities.forEach((priority, index) => {
entries.push({ path: `executiveSummary.priorities[${index}]`, text: priority });
});
entries.push(...textLeaves(document.executiveSummary, "executiveSummary"));
}
document.charts.forEach((chart, index) => {
if (chart.claimStatus === "blocked") {
entries.push({ path: `charts[${index}].title`, text: chart.title });
}
if (chart.claimStatus === "blocked") entries.push(...textLeaves(chart, `charts[${index}]`));
});
document.thematicNarrative.forEach((section, index) => {
if (section.claimStatus !== "blocked") return;
entries.push({ path: `thematicNarrative[${index}].title`, text: section.title });
entries.push({ path: `thematicNarrative[${index}].narrative`, text: section.narrative });
section.actions.forEach((action, actionIndex) => {
entries.push({ path: `thematicNarrative[${index}].actions[${actionIndex}]`, text: action });
});
section.caveats.forEach((caveat, caveatIndex) => {
entries.push({ path: `thematicNarrative[${index}].caveats[${caveatIndex}]`, text: caveat });
});
if (section.claimStatus === "blocked") entries.push(...textLeaves(section, `thematicNarrative[${index}]`));
});
if (isReportDocumentV2(document)) {
if (document.natalFoundation.claimStatus === "blocked") {
entries.push(...textLeaves(document.natalFoundation, "natalFoundation"));
}
if (document.currentPhase?.claimStatus === "blocked") {
entries.push(...textLeaves(document.currentPhase, "currentPhase"));
}
document.blockedConflictDisclosure.forEach((section, index) => {
entries.push(...textLeaves(section, `blockedConflictDisclosure[${index}]`));
});
}
return entries;
}
export function findBlockedDeterministicClaims(document: ReportDocumentV1): readonly string[] {
export function findBlockedDeterministicClaims(document: ReportDocument): readonly string[] {
const phrases = BLOCKED_DETERMINISTIC_PHRASES.map((phrase) => new RegExp(phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"));
return blockedTexts(document)
.filter(({ text }) => phrases.some((pattern) => pattern.test(text)))
.map(({ path }) => path);
}
export function findDanglingEvidenceRefs(document: ReportDocumentV1): readonly string[] {
function evidenceRefEntries(document: ReportDocument): readonly Readonly<{ path: string; refs: readonly string[] }>[] {
const entries: { path: string; refs: readonly string[] }[] = document.thematicNarrative.map((section, index) => ({
path: document.schemaVersion === REPORT_DOCUMENT_V1_SCHEMA_VERSION ? section.id : `thematicNarrative[${index}].evidenceRefs`,
refs: section.evidenceRefs,
}));
if (!isReportDocumentV2(document)) return entries;
entries.push({ path: "executiveSummary.evidenceRefs", refs: document.executiveSummary.evidenceRefs });
entries.push({ path: "natalFoundation.evidenceRefs", refs: document.natalFoundation.evidenceRefs });
if (document.currentPhase) entries.push({ path: "currentPhase.evidenceRefs", refs: document.currentPhase.evidenceRefs });
document.actionNotes.forEach((note, index) => entries.push({ path: `actionNotes[${index}].evidenceRefs`, refs: note.evidenceRefs }));
document.charts.forEach((chart, index) => entries.push({ path: `charts[${index}].evidenceRefs`, refs: chart.evidenceRefs }));
document.blockedConflictDisclosure.forEach((section, index) => entries.push({ path: `blockedConflictDisclosure[${index}].evidenceRefs`, refs: section.evidenceRefs }));
return entries;
}
export function findDanglingEvidenceRefs(document: ReportDocument): readonly string[] {
const knownIds = new Set<string>([
...document.evidenceAppendix.techniqueAudit.map((row) => row.id),
...document.evidenceAppendix.conflicts.map((row) => row.id),
...document.evidenceAppendix.calculationEvidence.map((row) => row.id),
]);
return document.thematicNarrative.flatMap((section) =>
section.evidenceRefs.filter((ref) => !knownIds.has(ref)).map((ref) => `${section.id}:${ref}`),
return evidenceRefEntries(document).flatMap(({ path, refs }) =>
refs.filter((ref) => !knownIds.has(ref)).map((ref) => `${path}:${ref}`),
);
}
/** Evidence ids must be globally unique across the whole appendix. */
export function findDuplicateEvidenceIds(document: ReportDocumentV1): readonly string[] {
export function findDuplicateEvidenceIds(document: ReportDocument): readonly string[] {
const locations = new Map<string, string>();
const duplicates: string[] = [];
const rows: Readonly<{ key: string; index: number; id: string }>[] = [
@@ -298,16 +448,13 @@ export function findDuplicateEvidenceIds(document: ReportDocumentV1): readonly s
for (const { key, index, id } of rows) {
const location = `${key}[${index}]`;
const first = locations.get(id);
if (first !== undefined) {
duplicates.push(`evidence id ${id} used in both ${first} and ${location}`);
} else {
locations.set(id, location);
}
if (first !== undefined) duplicates.push(`evidence id ${id} used in both ${first} and ${location}`);
else locations.set(id, location);
}
return duplicates;
}
export function findChartSetViolations(document: ReportDocumentV1): readonly string[] {
export function findChartSetViolations(document: ReportDocument): readonly string[] {
const violations: string[] = [];
const ids = document.charts.map((chart) => chart.id);
const d1Count = ids.filter((id) => id === "D1").length;
@@ -317,71 +464,90 @@ export function findChartSetViolations(document: ReportDocumentV1): readonly str
if (seen.has(id)) violations.push(`duplicate chart id ${id}`);
seen.add(id);
}
document.charts.forEach((chart) => {
const numbers = chart.houses.map((house) => house.houseNumber);
if (new Set(numbers).size !== numbers.length) violations.push(`${chart.id} chart contains duplicate house numbers`);
});
const d1 = document.charts.find((chart) => chart.id === "D1");
if (d1) {
const numbers = d1.houses.map((house) => house.houseNumber);
const unique = new Set(numbers);
if (unique.size !== numbers.length) violations.push("D1 chart contains duplicate house numbers");
const unique = new Set(d1.houses.map((house) => house.houseNumber));
const expected = Array.from({ length: 12 }, (_, index) => index + 1);
if (numbers.length !== 12 || expected.some((number) => !unique.has(number))) {
if (d1.houses.length !== 12 || expected.some((number) => !unique.has(number))) {
violations.push("D1 chart must contain all twelve house numbers 1..12 exactly once");
}
}
return violations;
}
export function validateReportDocumentGuards(document: ReportDocumentV1): readonly string[] {
const REQUIRED_THEME_CHARTS: Readonly<Record<string, readonly (typeof CHART_IDS)[number][]>> = {
career: ["D10"],
wealth: ["D2", "D11"],
marriage: ["D9"],
education: ["D24"],
};
export function findThemeCoverageViolations(document: ReportDocumentV2): readonly string[] {
const violations: string[] = [];
const requestedCounts = new Map<string, number>();
document.requestedThemes.forEach((theme) => requestedCounts.set(theme, (requestedCounts.get(theme) ?? 0) + 1));
requestedCounts.forEach((count, theme) => {
if (count !== 1) violations.push(`requestedThemes must contain ${theme} exactly once, found ${count}`);
});
const coverageCounts = new Map<string, number>();
const requested = new Set(document.requestedThemes);
for (const section of document.thematicNarrative) {
coverageCounts.set(section.theme, (coverageCounts.get(section.theme) ?? 0) + 1);
if (!requested.has(section.theme)) violations.push(`thematic section covers unrequested theme ${section.theme}`);
}
for (const section of document.blockedConflictDisclosure) {
coverageCounts.set(section.theme, (coverageCounts.get(section.theme) ?? 0) + 1);
if (!requested.has(section.theme)) violations.push(`blocked disclosure covers unrequested theme ${section.theme}`);
}
for (const theme of requested) {
const count = coverageCounts.get(theme) ?? 0;
if (count !== 1) violations.push(`requested theme ${theme} must have exactly one thematic section or blocked disclosure, found ${count}`);
}
const chartIds = new Set(document.charts.map((chart) => chart.id));
for (const section of document.thematicNarrative) {
for (const requiredChart of REQUIRED_THEME_CHARTS[section.theme] ?? []) {
if (!chartIds.has(requiredChart)) {
violations.push(`thematic section ${section.theme} requires structured ${requiredChart} chart data or a blocked disclosure`);
}
}
}
return violations;
}
export function findUnsupportedDateClaims(document: ReportDocument): readonly string[] {
if (!isReportDocumentV2(document)) return [];
const sections: Readonly<{ path: string; value: unknown; refs: readonly string[] }>[] = [
{ path: "executiveSummary", value: document.executiveSummary, refs: document.executiveSummary.evidenceRefs },
{ path: "natalFoundation", value: document.natalFoundation, refs: document.natalFoundation.evidenceRefs },
...(document.currentPhase ? [{ path: "currentPhase", value: document.currentPhase, refs: document.currentPhase.evidenceRefs }] : []),
...document.thematicNarrative.map((section, index) => ({ path: `thematicNarrative[${index}]`, value: section, refs: section.evidenceRefs })),
...document.actionNotes.map((note, index) => ({ path: `actionNotes[${index}]`, value: note, refs: note.evidenceRefs })),
...document.blockedConflictDisclosure.map((section, index) => ({ path: `blockedConflictDisclosure[${index}]`, value: section, refs: section.evidenceRefs })),
];
return sections
.filter(({ value, refs }) => refs.length === 0 && textLeaves(value).some(({ text }) => DATE_CLAIM_PATTERN.test(text)))
.map(({ path }) => `${path}: date claim requires evidenceRefs`);
}
export function validateReportDocumentGuards(document: ReportDocument): readonly string[] {
const errors: string[] = [];
errors.push(...findChartSetViolations(document));
errors.push(...findDuplicateEvidenceIds(document));
errors.push(...findBlockedDeterministicClaims(document).map((path) => `${path}: blocked section contains deterministic prediction`));
errors.push(...findDanglingEvidenceRefs(document).map((ref) => `thematicNarrative.evidenceRefs: unknown evidence id ${ref}`));
errors.push(...findDanglingEvidenceRefs(document).map((ref) => `evidenceRefs: unknown evidence id ${ref}`));
errors.push(...findUnsupportedDateClaims(document));
if (isReportDocumentV2(document)) errors.push(...findThemeCoverageViolations(document));
const forbidden: { path: string; hits: readonly string[] }[] = [];
const collectTexts = (path: string, value: string) => {
for (const { path, text: value } of textLeaves(document)) {
const hits = findForbiddenContent(value);
if (hits.length > 0) forbidden.push({ path, hits });
};
collectTexts("subject.displayName", document.subject.displayName);
collectTexts("subject.birthPlaceLabel", document.subject.birthPlaceLabel);
collectTexts("executiveSummary.headline", document.executiveSummary.headline);
collectTexts("executiveSummary.summary", document.executiveSummary.summary);
document.executiveSummary.priorities.forEach((priority, index) => collectTexts(`executiveSummary.priorities[${index}]`, priority));
document.charts.forEach((chart, chartIndex) => {
collectTexts(`charts[${chartIndex}].title`, chart.title);
chart.houses.forEach((house, houseIndex) => {
collectTexts(`charts[${chartIndex}].houses[${houseIndex}].sign`, house.sign);
house.occupants.forEach((occupant, occupantIndex) => collectTexts(`charts[${chartIndex}].houses[${houseIndex}].occupants[${occupantIndex}]`, occupant));
});
(chart.planets ?? []).forEach((planet, planetIndex) => {
collectTexts(`charts[${chartIndex}].planets[${planetIndex}].name`, planet.name);
collectTexts(`charts[${chartIndex}].planets[${planetIndex}].sign`, planet.sign);
});
});
document.thematicNarrative.forEach((section, sectionIndex) => {
collectTexts(`thematicNarrative[${sectionIndex}].title`, section.title);
collectTexts(`thematicNarrative[${sectionIndex}].narrative`, section.narrative);
section.actions.forEach((action, actionIndex) => collectTexts(`thematicNarrative[${sectionIndex}].actions[${actionIndex}]`, action));
section.caveats.forEach((caveat, caveatIndex) => collectTexts(`thematicNarrative[${sectionIndex}].caveats[${caveatIndex}]`, caveat));
});
document.evidenceAppendix.techniqueAudit.forEach((row, rowIndex) => {
collectTexts(`evidenceAppendix.techniqueAudit[${rowIndex}].techniqueName`, row.techniqueName);
if (row.notes !== undefined) collectTexts(`evidenceAppendix.techniqueAudit[${rowIndex}].notes`, row.notes);
});
document.evidenceAppendix.conflicts.forEach((row, rowIndex) => {
collectTexts(`evidenceAppendix.conflicts[${rowIndex}].description`, row.description);
collectTexts(`evidenceAppendix.conflicts[${rowIndex}].impact`, row.impact);
});
document.evidenceAppendix.calculationEvidence.forEach((row, rowIndex) => {
collectTexts(`evidenceAppendix.calculationEvidence[${rowIndex}].label`, row.label);
collectTexts(`evidenceAppendix.calculationEvidence[${rowIndex}].value`, row.value);
collectTexts(`evidenceAppendix.calculationEvidence[${rowIndex}].source`, row.source);
});
document.evidenceAppendix.blockedTechniques.forEach((technique, rowIndex) => {
collectTexts(`evidenceAppendix.blockedTechniques[${rowIndex}]`, technique);
});
collectTexts("disclaimer", document.disclaimer);
forbidden.forEach(({ path, hits }) => errors.push(`${path}: forbidden content ${hits.join(",")}`));
if (hits.length > 0) errors.push(`${path}: forbidden content ${hits.join(",")}`);
}
const size = serializedReportDocumentBytes(document);
if (size > REPORT_DOCUMENT_MAX_BYTES) {
@@ -391,7 +557,7 @@ export function validateReportDocumentGuards(document: ReportDocumentV1): readon
}
export type ReportDocumentParseResult =
| Readonly<{ ok: true; document: ReportDocumentV1 }>
| Readonly<{ ok: true; document: ReportDocument }>
| Readonly<{ ok: false; errors: readonly ReportDocumentParseError[] }>;
export function safeParseReportDocument(input: unknown): ReportDocumentParseResult {
@@ -416,7 +582,7 @@ export function safeParseReportDocument(input: unknown): ReportDocumentParseResu
return { ok: true, document };
}
export function parseReportDocument(input: unknown): ReportDocumentV1 {
export function parseReportDocument(input: unknown): ReportDocument {
const result = safeParseReportDocument(input);
if (!result.ok) throw new ReportDocumentValidationError(result.errors);
return result.document;
+387 -13
View File
@@ -4,8 +4,11 @@ import {
safeParseServerReportDocument,
} from "./personal-report-contract.server-core.ts";
import type {
ClaimStatus,
EvidenceAppendix,
ReportDepth,
ReportDocumentV1,
ReportDocumentV2,
} from "./personal-report-contract.ts";
import type {
EvidenceRefStatus,
@@ -25,6 +28,7 @@ import type {
import { finalizeReportEvidenceBundleV2, validateReportEvidenceBundleV2 } from "./report-evidence-bundle-v2.ts";
import { buildReportThemePlan, normalizeReportTheme } from "./report-theme-evidence-plan.ts";
import { resolveActiveSkillPackage } from "./skill-package-registry.ts";
import { buildPersonalReportSectionPlan, validatePersonalReportSectionPlan, type PersonalReportSectionPlan } from "./personal-report-plan.ts";
// Compatibility re-export: prefer importing from ./personal-report-codes.ts
// directly (the pure, dependency-free codes module).
export { REPORT_STABLE_CODES } from "./personal-report-codes.ts";
@@ -84,6 +88,7 @@ const sha256Pattern = /^[0-9a-f]{64}$/;
export function computeRequestFingerprint(input: Readonly<{
reportType: string;
presentationMode: string;
depth: string;
themes: readonly string[];
sessionId: string | null;
chartProfileId: string | null;
@@ -91,6 +96,7 @@ export function computeRequestFingerprint(input: Readonly<{
return sha256Hex(canonicalSerialize({
reportType: input.reportType,
presentationMode: input.presentationMode,
depth: input.depth,
themes: [...new Set(input.themes)].sort(),
sessionId: input.sessionId,
chartProfileId: input.chartProfileId,
@@ -123,7 +129,7 @@ let cachedSkillSnapshot: SkillSnapshot | null = null;
export function resolveSkillSnapshot(): SkillSnapshot {
if (cachedSkillSnapshot) return cachedSkillSnapshot;
try {
const identity = resolveActiveSkillPackage("jyotish-vedic-astrology");
const identity = resolveActiveSkillPackage("jyotish-personal-report");
cachedSkillSnapshot = {
name: identity.name,
version: identity.version,
@@ -1404,6 +1410,347 @@ export function assembleReportDocument(
return document;
}
export type AssembleReportDocumentV2Input = Readonly<{
reportId: string;
generatedAt: string;
depth: ReportDepth;
bundle: ReportEvidenceBundleV2;
plan: PersonalReportSectionPlan;
agentOutput: PersonalReportAgentOutput;
}>;
const CLAIM_STATUS_RANK: Readonly<Record<ClaimStatus, number>> = {
multi_system_consensus: 0,
single_system_inference: 1,
parameter_sensitive: 2,
unclosed_divisional_chart: 3,
user_history_verification_required: 4,
blocked: 5,
};
function uniqueInOrder(values: readonly string[]): string[] {
return [...new Set(values)];
}
function equalStringArrays(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
/**
* The model is a writer only. This validation binds every generated thematic
* section to the deterministic server plan and its single Claim Card before
* any model text can enter ReportDocument v2.
*/
export function validatePersonalReportAgentOutputAgainstPlan(
output: PersonalReportAgentOutput,
plan: PersonalReportSectionPlan,
bundle: ReportEvidenceBundleV2,
): PersonalReportAgentOutput {
const writePlans = plan.sections.filter((section) => (
section.kind === "thematic" && section.disposition === "write"
));
if (output.thematicNarrative.length !== writePlans.length) {
throw new Error("report_writer_theme_count_mismatch");
}
const seen = new Set<string>();
const claimCards = new Map(bundle.claimCards.map((card) => [card.theme, card]));
const plans = new Map(writePlans.map((section) => [section.theme as string, section]));
for (const section of output.thematicNarrative) {
if (seen.has(section.theme)) throw new Error(`report_writer_duplicate_theme:${section.theme}`);
seen.add(section.theme);
const sectionPlan = plans.get(section.theme);
const card = claimCards.get(section.theme);
if (!sectionPlan || !card) throw new Error(`report_writer_unplanned_theme:${section.theme}`);
if (section.id !== sectionPlan.id) throw new Error(`report_writer_section_id_mismatch:${section.theme}`);
if (!equalStringArrays(section.evidenceRefs, sectionPlan.evidenceRefs)) {
throw new Error(`report_writer_evidence_refs_mismatch:${section.theme}`);
}
if (CLAIM_STATUS_RANK[section.claimStatus] < CLAIM_STATUS_RANK[card.assertionLevel]) {
throw new Error(`report_writer_claim_status_upgrade:${section.theme}`);
}
}
for (const sectionPlan of writePlans) {
if (!seen.has(sectionPlan.theme as string)) {
throw new Error(`report_writer_theme_missing:${sectionPlan.theme}`);
}
}
return output;
}
function claimStatusForEvidenceStatus(status: EvidenceRefStatus): ClaimStatus {
if (status === "verified") return "single_system_inference";
if (status === "partial") return "parameter_sensitive";
return "blocked";
}
function evidenceRefsForTechnique(
bundle: ReportEvidenceBundleV2,
technique: string,
requireExecuted = true,
): string[] {
const normalized = technique.toUpperCase();
return bundle.executionLedger.filter((receipt) => {
const receiptTechnique = receipt.technique.toUpperCase();
const matches = receiptTechnique === normalized || receiptTechnique.endsWith(`:${normalized}`);
return matches && (!requireExecuted || (receipt.executed && receipt.status !== "blocked"));
}).map((receipt) => receipt.id);
}
function evidenceAppendixFromBundle(
bundle: ReportEvidenceBundleV2,
usedEvidenceRefs: ReadonlySet<string>,
): EvidenceAppendix {
const d1 = bundle.charts.find((chart) => chart.id === "D1");
const calculationEvidence: EvidenceAppendix["calculationEvidence"] = [];
if (bundle.calculationProfile.calculationHashDerived) {
calculationEvidence.push({
id: "ev-calc-derived",
label: "calculation_hash",
value: bundle.calculationProfile.calculationHash,
source: "derived_server_sha256_over_allowlisted_calculation_facts",
});
}
if (d1?.ascendant) {
calculationEvidence.push({
id: "ev-calc-ascendant",
label: "ascendant",
value: `${d1.ascendant.sign} ${d1.ascendant.degree.toFixed(2)}°`,
source: "server_calculation",
});
}
bundle.calculationProfile.vimshottari?.forEach((period, index) => {
calculationEvidence.push({
id: `ev-calc-vimshottari-${index + 1}`,
label: `Vimshottari 大运:${period.lord}`,
value: `${period.start} ${period.end}`,
source: "server_calculation",
});
});
bundle.calculationProfile.narayana?.forEach((period, index) => {
calculationEvidence.push({
id: `ev-calc-narayana-${index + 1}`,
label: `Narayana 大运:${period.lord}`,
value: `${period.start} ${period.end}`,
source: "server_calculation",
});
});
const ledgerById = new Map(bundle.executionLedger.map((receipt) => [receipt.id, receipt]));
return {
expandedByDefault: bundle.presentationMode === "research",
techniqueAudit: bundle.executionLedger.map((receipt, index) => ({
id: receipt.id,
techniqueId: techniqueSlug(receipt.technique, `tech-${index + 1}`),
techniqueName: receipt.technique,
status: receipt.status,
used: usedEvidenceRefs.has(receipt.id),
...(receipt.note ? { notes: receipt.note.slice(0, 500) } : {}),
})),
conflicts: bundle.conflicts.map((conflict) => ({
id: conflict.id,
description: conflict.summary,
impact: conflict.resolutionStatus === "bounded"
? "冲突已被限制在证据边界内,相关结论不得提升确定性"
: "多技法结果不一致,相关结论已按确定性边界降级",
status: conflict.resolutionStatus === "bounded" ? "partial" : "unresolved",
})),
calculationEvidence,
blockedTechniques: uniqueInOrder(bundle.blockedSections.flatMap((section) => (
section.missingTechniqueRefs.map((ref) => ledgerById.get(ref)?.technique ?? ref)
))).slice(0, 100),
};
}
function canonicalBundleChart(
chart: ReportChartFact,
evidenceRefs: readonly string[],
status: ClaimStatus,
): ReportDocumentV2["charts"][number] {
const planets = chart.planets.filter((planet) => planet.house !== null).slice(0, 12).map((planet) => ({
name: planet.id,
sign: planet.sign,
longitudeDegrees: ((planet.degree % 360) + 360) % 360,
houseNumber: planet.house as number,
retrograde: planet.retrograde ?? false,
}));
return {
id: chart.id as ReportDocumentV2["charts"][number]["id"],
title: chart.title,
houses: chart.houses.map((house) => ({
houseNumber: house.number,
sign: house.sign,
occupants: [...house.occupants].slice(0, 12),
})),
...(planets.length > 0 ? { planets } : {}),
claimStatus: status,
evidenceRefs: [...evidenceRefs],
};
}
/** Assemble the canonical current report contract without model-generated markup. */
export function assembleReportDocumentV2(
input: AssembleReportDocumentV2Input,
): ReportDocumentV2 {
const bundle = validateReportEvidenceBundleV2(input.bundle);
const plan = validatePersonalReportSectionPlan(input.plan, bundle);
const agentOutput = validatePersonalReportAgentOutputAgainstPlan(input.agentOutput, plan, bundle);
if (bundle.skill.name !== "jyotish-personal-report") {
throw new Error("report_writer_skill_package_invalid");
}
const d1 = bundle.charts.find((chart) => chart.id === "D1");
const d1Refs = evidenceRefsForTechnique(bundle, "D1");
if (!d1 || !d1.ascendant || d1Refs.length === 0) {
throw new ReportEvidenceInsufficientError("d1_evidence_missing");
}
const thematicNarrative: ReportDocumentV2["thematicNarrative"] = agentOutput.thematicNarrative.map((section) => ({
id: section.id,
theme: section.theme,
title: section.title,
narrative: section.narrative,
actions: [...section.actions],
caveats: [...section.caveats],
claimStatus: section.claimStatus,
evidenceRefs: [...section.evidenceRefs],
}));
const thematicRefs = uniqueInOrder(thematicNarrative.flatMap((section) => section.evidenceRefs));
const executiveRefs = thematicRefs.length > 0 ? thematicRefs : d1Refs;
const chartIds = new Set(["D1", "D2", "D9", "D10", "D11", "D24"]);
const charts: ReportDocumentV2["charts"] = [];
for (const chart of bundle.charts) {
if (!chartIds.has(chart.id)) continue;
const refs = evidenceRefsForTechnique(bundle, chart.id);
if (refs.length === 0) continue;
const statuses = refs.map((ref) => bundle.evidenceRefs.find((entry) => entry.id === ref)?.status ?? "blocked");
const status = statuses.every((entry) => entry === "verified")
? "single_system_inference"
: statuses.some((entry) => entry === "blocked")
? "parameter_sensitive"
: claimStatusForEvidenceStatus(statuses[0]);
charts.push(canonicalBundleChart(chart, refs, status));
}
const blockedConflictDisclosure: ReportDocumentV2["blockedConflictDisclosure"] = bundle.blockedSections.map((section) => {
const missingEvidence = section.missingTechniqueRefs.map((ref) => (
bundle.executionLedger.find((receipt) => receipt.id === ref)?.technique ?? ref
));
const conflictNotes = bundle.conflicts.filter((conflict) => (
conflict.techniqueRefs.some((ref) => section.missingTechniqueRefs.includes(ref))
)).map((conflict) => conflict.summary);
return {
theme: section.theme,
title: section.section,
reason: section.reason,
missingEvidence,
conflictNotes,
evidenceRefs: [...section.missingTechniqueRefs],
claimStatus: "blocked" as const,
};
});
const timingSection = thematicNarrative.find((section) => section.theme === "timing");
const currentPhase: ReportDocumentV2["currentPhase"] = timingSection
? {
title: "当前阶段与时间边界",
phaseLabel: bundle.answerPolicy.canAnswerPreciseTiming ? "当前阶段" : "当前阶段(方向性)",
narrative: timingSection.narrative,
timingNotes: [],
caveats: [...timingSection.caveats],
claimStatus: timingSection.claimStatus,
evidenceRefs: [...timingSection.evidenceRefs],
}
: null;
const actionNotes: ReportDocumentV2["actionNotes"] = [];
thematicNarrative.forEach((section) => {
section.actions.forEach((action, index) => actionNotes.push({
id: `action-${section.theme.replaceAll(".", "-")}-${index + 1}`,
title: section.title,
note: action,
priority: index === 0 ? "now" : index === 1 ? "next" : "watch",
evidenceRefs: [...section.evidenceRefs],
}));
});
if (actionNotes.length === 0) {
const fallback = agentOutput.executiveSummary.priorities[0]
?? "将本报告列出的证据边界与待核验事项作为后续行动清单。";
actionNotes.push({
id: "action-evidence-review",
title: "后续核验",
note: fallback,
priority: "now",
evidenceRefs: [...executiveRefs],
});
}
const usedEvidenceRefs = new Set<string>([
...executiveRefs,
...d1Refs,
...thematicNarrative.flatMap((section) => section.evidenceRefs),
...actionNotes.flatMap((note) => note.evidenceRefs),
...charts.flatMap((chart) => chart.evidenceRefs),
...blockedConflictDisclosure.flatMap((section) => section.evidenceRefs),
]);
const appendix = evidenceAppendixFromBundle(bundle, usedEvidenceRefs);
const evidenceHash = computeEvidenceHash(appendix);
const hasBlockedCoverage = blockedConflictDisclosure.length > 0;
const weakestThematicStatus = thematicNarrative.reduce<ClaimStatus>((weakest, section) => (
CLAIM_STATUS_RANK[section.claimStatus] > CLAIM_STATUS_RANK[weakest] ? section.claimStatus : weakest
), "multi_system_consensus");
return {
schemaVersion: "report_document.v2",
reportId: input.reportId,
reportType: bundle.reportType,
presentationMode: bundle.presentationMode,
depth: input.depth,
requestedThemes: [...bundle.requestedThemes],
generatedAt: input.generatedAt,
subject: { ...bundle.subject },
provenance: {
skillName: bundle.skill.name,
skillVersion: bundle.skill.version,
skillSourceCommit: bundle.skill.sourceCommit,
skillSnapshotSha256: bundle.skill.sha256,
calculationHash: bundle.calculationProfile.calculationHash,
evidenceHash,
reportContractVersion: "2",
},
executiveSummary: {
headline: agentOutput.executiveSummary.headline,
summary: agentOutput.executiveSummary.summary,
priorities: [...agentOutput.executiveSummary.priorities],
overallClaimStatus: thematicNarrative.length === 0 || hasBlockedCoverage
? "blocked"
: weakestThematicStatus,
evidenceRefs: [...executiveRefs],
},
natalFoundation: {
title: "本命基础",
narrative: `D1 本命盘上升点位于 ${d1.ascendant.sign} ${d1.ascendant.degree.toFixed(2)}°。以下基础结构仅陈述服务器排盘事实,不自行增加未提供的占星推断。`,
keyFactors: d1.houses.slice(0, 12).map((house) => (
`${house.number} 宫落 ${house.sign}${house.occupants.length > 0 ? `,宫内对象:${house.occupants.join("、")}` : ""}`
)),
caveats: bundle.subject.birthTimeStatus === "confirmed"
? ["本节仍须与具体主题证据和执行凭证共同阅读。"]
: ["出生时间尚未达到 confirmed;涉及宫位敏感与时间性判断仅可作方向性参考。"],
claimStatus: claimStatusForEvidenceStatus(
bundle.evidenceRefs.find((entry) => entry.id === d1Refs[0])?.status ?? "blocked",
),
evidenceRefs: [...d1Refs],
},
currentPhase,
actionNotes,
charts,
thematicNarrative,
blockedConflictDisclosure,
evidenceAppendix: appendix,
disclaimer: PERSONAL_REPORT_DISCLAIMER,
};
}
// ---------------------------------------------------------------------------
// Deterministic post-generation guard
// ---------------------------------------------------------------------------
@@ -1521,7 +1868,13 @@ export function projectReportGuardReadModel(document: unknown): ReportGuardReadM
caveats: stringArray(row?.caveats),
});
}
if (sections.length === 0) return null;
if (sections.length === 0) {
const schemaVersion = text(root.schemaVersion);
const blockedDisclosures = Array.isArray(root.blockedConflictDisclosure)
? root.blockedConflictDisclosure
: [];
if (schemaVersion !== "report_document.v2" || blockedDisclosures.length === 0) return null;
}
return { executiveSummary: { headline, summary, overallClaimStatus }, sections };
}
@@ -1718,7 +2071,7 @@ export function applyReportGuard<D>(
}
}
}
if (summary && blockedSectionCount === readModel.sections.length) {
if (summary && (readModel.sections.length === 0 || blockedSectionCount === readModel.sections.length)) {
summary.overallClaimStatus = "blocked";
}
@@ -1733,14 +2086,16 @@ type GeneratePersonalReportBaseDeps = Readonly<{
reportId: string;
agent: ReportAgentPort;
now?: () => Date;
signal?: AbortSignal;
}>;
export type GeneratePersonalReportDeps = GeneratePersonalReportBaseDeps & Readonly<{
bundle: ReportEvidenceBundleV2;
depth: ReportDepth;
}>;
export type GeneratePersonalReportResult = Readonly<
| { status: "ready"; document: ReportDocumentV1; evidenceHash: string }
| { status: "ready"; document: ReportDocumentV2; evidenceHash: string }
| { status: "failed"; failureCode: "report_schema_invalid" | "report_guard_rejected" }
>;
@@ -1755,21 +2110,40 @@ export type GeneratePersonalReportResult = Readonly<
export async function generatePersonalReport(
deps: GeneratePersonalReportDeps,
): Promise<GeneratePersonalReportResult> {
const bundle = validateReportEvidenceBundleV2(deps.bundle);
let bundle: ReportEvidenceBundleV2;
let plan: PersonalReportSectionPlan;
let agentOutput: PersonalReportAgentOutput;
try {
bundle = validateReportEvidenceBundleV2(deps.bundle);
plan = validatePersonalReportSectionPlan(
buildPersonalReportSectionPlan(bundle, deps.depth),
bundle,
);
agentOutput = await deps.agent.generate(bundle, plan, { signal: deps.signal });
validatePersonalReportAgentOutputAgainstPlan(agentOutput, plan, bundle);
} catch {
return { status: "failed", failureCode: "report_schema_invalid" };
}
const packet = buildLegacyPacketFromBundle(bundle);
const agentOutput = await deps.agent.generate(bundle);
const candidate = assembleReportDocument({
reportId: deps.reportId,
generatedAt: (deps.now ?? (() => new Date()))().toISOString(),
packet,
agentOutput,
});
let candidate: ReportDocumentV2;
try {
candidate = assembleReportDocumentV2({
reportId: deps.reportId,
generatedAt: (deps.now ?? (() => new Date()))().toISOString(),
depth: deps.depth,
bundle,
plan,
agentOutput,
});
} catch {
return { status: "failed", failureCode: "report_schema_invalid" };
}
const guarded = applyReportGuard(candidate, packet);
if (!guarded.ok) {
return { status: "failed", failureCode: "report_guard_rejected" };
}
const parsed = safeParseServerReportDocument(guarded.document);
if (!parsed.ok) {
if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") {
return { status: "failed", failureCode: "report_schema_invalid" };
}
return {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
import "server-only";
export * from "./personal-report-job-service-core";
@@ -0,0 +1,203 @@
export const PERSONAL_REPORT_JOB_STATUSES = [
"queued",
"running",
"suspended",
"retrying",
"ready",
"failed",
"cancelled",
] as const;
export type PersonalReportJobStatus = (typeof PERSONAL_REPORT_JOB_STATUSES)[number];
export const PERSONAL_REPORT_JOB_ACTIVE_STATUSES = [
"queued",
"running",
"suspended",
"retrying",
] as const satisfies readonly PersonalReportJobStatus[];
export const PERSONAL_REPORT_JOB_TERMINAL_STATUSES = [
"ready",
"failed",
"cancelled",
] as const satisfies readonly PersonalReportJobStatus[];
export const DEFAULT_PERSONAL_REPORT_JOB_MAX_ATTEMPTS = 3;
export const MAX_PERSONAL_REPORT_JOB_MAX_ATTEMPTS = 10;
const transitionTargets = {
queued: ["running", "suspended", "cancelled"],
running: ["suspended", "retrying", "ready", "failed", "cancelled"],
suspended: ["queued", "cancelled"],
retrying: ["queued", "running", "failed", "cancelled"],
ready: [],
failed: [],
cancelled: [],
} as const satisfies Record<PersonalReportJobStatus, readonly PersonalReportJobStatus[]>;
const requestFingerprintPattern = /^[0-9a-f]{64}$/;
const progressPhasePattern = /^[a-z][a-z0-9_]{0,63}$/;
export type PersonalReportJobStateErrorReason =
| "invalid_transition"
| "invalid_retry_budget"
| "invalid_progress_phase";
export class PersonalReportJobStateError extends Error {
readonly name = "PersonalReportJobStateError";
constructor(
readonly reason: PersonalReportJobStateErrorReason,
message: string,
) {
super(message);
}
}
export function isPersonalReportJobActive(status: PersonalReportJobStatus): boolean {
return (PERSONAL_REPORT_JOB_ACTIVE_STATUSES as readonly string[]).includes(status);
}
export function isPersonalReportJobTerminal(status: PersonalReportJobStatus): boolean {
return (PERSONAL_REPORT_JOB_TERMINAL_STATUSES as readonly string[]).includes(status);
}
export function canTransitionPersonalReportJob(
from: PersonalReportJobStatus,
to: PersonalReportJobStatus,
): boolean {
return (transitionTargets[from] as readonly PersonalReportJobStatus[]).includes(to);
}
export function assertPersonalReportJobTransition(
from: PersonalReportJobStatus,
to: PersonalReportJobStatus,
): void {
if (!canTransitionPersonalReportJob(from, to)) {
throw new PersonalReportJobStateError(
"invalid_transition",
`Personal report job cannot transition from ${from} to ${to}`,
);
}
}
function assertRetryBudget(attemptCount: number, maxAttempts: number): void {
if (
!Number.isInteger(attemptCount)
|| attemptCount < 0
|| !Number.isInteger(maxAttempts)
|| maxAttempts < 1
|| maxAttempts > MAX_PERSONAL_REPORT_JOB_MAX_ATTEMPTS
|| attemptCount > maxAttempts
) {
throw new PersonalReportJobStateError(
"invalid_retry_budget",
`Invalid personal report retry budget: ${attemptCount}/${maxAttempts}`,
);
}
}
/**
* attemptCount is incremented when a worker successfully acquires a running
* lease. A retry may be scheduled only while another attempt remains.
*/
export function canRetryPersonalReportJob(
attemptCount: number,
maxAttempts: number,
): boolean {
assertRetryBudget(attemptCount, maxAttempts);
return attemptCount < maxAttempts;
}
export type PersonalReportJobLease = Readonly<{
status: PersonalReportJobStatus;
leaseOwner: string | null;
leaseExpiresAt: string | null;
}>;
function parsedTime(value: string | null): number | null {
if (!value) return null;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : null;
}
/**
* A running row without a valid expiry is treated as expired. This fail-closed
* rule prevents malformed leases from holding a user's active slot forever.
*/
export function isPersonalReportJobLeaseExpired(
job: PersonalReportJobLease,
now: Date,
): boolean {
if (job.status !== "running") return false;
const expiresAt = parsedTime(job.leaseExpiresAt);
return expiresAt === null || expiresAt <= now.getTime();
}
export function canHeartbeatPersonalReportJobLease(
job: PersonalReportJobLease,
workerId: string,
now: Date,
): boolean {
const expiresAt = parsedTime(job.leaseExpiresAt);
return job.status === "running"
&& workerId.length > 0
&& job.leaseOwner === workerId
&& expiresAt !== null
&& expiresAt > now.getTime();
}
export type PersonalReportExpiredLeaseRecovery =
| Readonly<{ kind: "none"; nextStatus: null }>
| Readonly<{ kind: "retry"; nextStatus: "retrying" }>
| Readonly<{ kind: "exhausted"; nextStatus: "failed" }>;
export function recoverExpiredPersonalReportJobLease(
job: PersonalReportJobLease & Readonly<{ attemptCount: number; maxAttempts: number }>,
now: Date,
): PersonalReportExpiredLeaseRecovery {
assertRetryBudget(job.attemptCount, job.maxAttempts);
if (!isPersonalReportJobLeaseExpired(job, now)) {
return { kind: "none", nextStatus: null };
}
return canRetryPersonalReportJob(job.attemptCount, job.maxAttempts)
? { kind: "retry", nextStatus: "retrying" }
: { kind: "exhausted", nextStatus: "failed" };
}
export function isPersonalReportRequestFingerprint(value: string): boolean {
return requestFingerprintPattern.test(value);
}
export type PersonalReportJobRequestIdentity = Readonly<{
requestId: string;
requestFingerprint: string;
}>;
export type PersonalReportJobRequestMatch = "new" | "replay" | "conflict";
/**
* requestId is the idempotency key. Reusing it with the same canonical request
* fingerprint is a replay; reusing it for different intent is a conflict.
*/
export function classifyPersonalReportJobRequest(
existing: PersonalReportJobRequestIdentity | null,
incoming: PersonalReportJobRequestIdentity,
): PersonalReportJobRequestMatch {
if (!existing || existing.requestId !== incoming.requestId) return "new";
return existing.requestFingerprint === incoming.requestFingerprint ? "replay" : "conflict";
}
export function isValidPersonalReportJobProgressPhase(value: string): boolean {
return progressPhasePattern.test(value);
}
export function assertPersonalReportJobProgressPhase(value: string): void {
if (!isValidPersonalReportJobProgressPhase(value)) {
throw new PersonalReportJobStateError(
"invalid_progress_phase",
`Invalid personal report progress phase: ${value}`,
);
}
}
+188
View File
@@ -0,0 +1,188 @@
import { z } from "zod";
import type { ReportEvidenceBundleV2 } from "./report-evidence-bundle-v2.ts";
export const REPORT_DEPTHS = ["concise", "standard", "deep", "research"] as const;
export type ReportDepth = (typeof REPORT_DEPTHS)[number];
export const REPORT_SECTION_KINDS = [
"executive_summary",
"natal_foundation",
"thematic",
"current_phase",
"action_notes",
"charts",
"claim_evidence_appendix",
"blocked_conflict_disclosure",
"provenance",
"disclaimer",
] as const;
export type ReportSectionKind = (typeof REPORT_SECTION_KINDS)[number];
export type ReportSectionPlanEntry = Readonly<{
id: string;
kind: ReportSectionKind;
theme: string | null;
disposition: "write" | "blocked";
evidenceRefs: readonly string[];
targetCharacters: Readonly<{ min: number; max: number }>;
}>;
export type PersonalReportSectionPlan = Readonly<{
schemaVersion: "personal_report_section_plan.v1";
depth: ReportDepth;
sections: readonly ReportSectionPlanEntry[];
}>;
const characterTargets: Record<ReportDepth, Readonly<{ min: number; max: number }>> = {
concise: { min: 120, max: 500 },
standard: { min: 260, max: 1_200 },
deep: { min: 480, max: 2_000 },
research: { min: 600, max: 2_800 },
};
const reportSectionPlanEntrySchema = z.object({
id: z.string().regex(/^[a-z][a-z0-9_-]{0,95}$/),
kind: z.enum(REPORT_SECTION_KINDS),
theme: z.string().regex(/^[a-z][a-z0-9_.-]{0,95}$/).nullable(),
disposition: z.enum(["write", "blocked"]),
evidenceRefs: z.array(z.string().regex(/^ev-[a-z0-9_-]{1,63}$/)).max(80),
targetCharacters: z.object({
min: z.number().int().min(0).max(10_000),
max: z.number().int().min(1).max(10_000),
}).strict(),
}).strict().superRefine((entry, context) => {
if (entry.targetCharacters.min > entry.targetCharacters.max) {
context.addIssue({ code: z.ZodIssueCode.custom, message: "report_plan_character_range_invalid" });
}
if (entry.kind === "thematic" && entry.theme === null) {
context.addIssue({ code: z.ZodIssueCode.custom, message: "report_plan_theme_missing" });
}
if (entry.kind !== "thematic" && entry.theme !== null) {
context.addIssue({ code: z.ZodIssueCode.custom, message: "report_plan_theme_unexpected" });
}
});
export const personalReportSectionPlanSchema = z.object({
schemaVersion: z.literal("personal_report_section_plan.v1"),
depth: z.enum(REPORT_DEPTHS),
sections: z.array(reportSectionPlanEntrySchema).min(1).max(40),
}).strict();
function uniqueSorted(values: readonly string[]): string[] {
return [...new Set(values)].sort();
}
function fixedSection(
id: string,
kind: Exclude<ReportSectionKind, "thematic">,
depth: ReportDepth,
evidenceRefs: readonly string[] = [],
): ReportSectionPlanEntry {
return {
id,
kind,
theme: null,
disposition: "write",
evidenceRefs: uniqueSorted(evidenceRefs),
targetCharacters: characterTargets[depth],
};
}
export function validatePersonalReportSectionPlan(
input: unknown,
bundle: ReportEvidenceBundleV2,
): PersonalReportSectionPlan {
const plan = personalReportSectionPlanSchema.parse(input);
const ids = new Set<string>();
for (const section of plan.sections) {
if (ids.has(section.id)) throw new Error(`report_plan_duplicate_section:${section.id}`);
ids.add(section.id);
}
const requiredKinds: ReportSectionKind[] = [
"executive_summary",
"natal_foundation",
"action_notes",
"charts",
"claim_evidence_appendix",
"blocked_conflict_disclosure",
"provenance",
"disclaimer",
];
for (const kind of requiredKinds) {
if (plan.sections.filter((section) => section.kind === kind).length !== 1) {
throw new Error(`report_plan_required_section_invalid:${kind}`);
}
}
const thematic = plan.sections.filter((section) => section.kind === "thematic");
const bundleEvidenceRefs = new Set(bundle.evidenceRefs.map((entry) => entry.id));
for (const theme of bundle.requestedThemes) {
const matches = thematic.filter((section) => section.theme === theme);
if (matches.length !== 1) throw new Error(`report_plan_theme_coverage_invalid:${theme}`);
const hasClaim = bundle.claimCards.some((card) => card.theme === theme);
const hasBlocked = bundle.blockedSections.some((section) => section.theme === theme);
const expected = hasClaim ? "write" : "blocked";
if (hasClaim === hasBlocked || matches[0].disposition !== expected) {
throw new Error(`report_plan_theme_disposition_invalid:${theme}`);
}
}
if (thematic.some((section) => !section.theme || !bundle.requestedThemes.includes(section.theme))) {
throw new Error("report_plan_unrequested_theme");
}
for (const section of plan.sections) {
for (const ref of section.evidenceRefs) {
if (!bundleEvidenceRefs.has(ref)) throw new Error(`report_plan_dangling_evidence_ref:${ref}`);
}
}
return plan;
}
export function buildPersonalReportSectionPlan(
bundle: ReportEvidenceBundleV2,
depth: ReportDepth,
): PersonalReportSectionPlan {
const themeSections = bundle.requestedThemes.map((theme): ReportSectionPlanEntry => {
const card = bundle.claimCards.find((entry) => entry.theme === theme);
const blocked = bundle.blockedSections.find((entry) => entry.theme === theme);
return {
id: `theme-${theme.replaceAll(".", "-")}`,
kind: "thematic",
theme,
disposition: card ? "write" : "blocked",
evidenceRefs: card
? uniqueSorted(card.executedTechniqueRefs)
: uniqueSorted(blocked?.missingTechniqueRefs ?? []),
targetCharacters: characterTargets[depth],
};
});
const timingEvidenceRefs = uniqueSorted([
...bundle.claimCards.filter((card) => card.theme === "timing").flatMap((card) => card.executedTechniqueRefs),
...bundle.blockedSections.filter((section) => section.theme === "timing").flatMap((section) => section.missingTechniqueRefs),
]);
const sections: ReportSectionPlanEntry[] = [
fixedSection("executive-summary", "executive_summary", depth),
fixedSection("natal-foundation", "natal_foundation", depth),
...themeSections,
...(bundle.requestedThemes.includes("timing")
? [{
...fixedSection("current-phase", "current_phase", depth, timingEvidenceRefs),
disposition: bundle.claimCards.some((card) => card.theme === "timing") ? "write" as const : "blocked" as const,
}]
: []),
fixedSection("action-notes", "action_notes", depth),
fixedSection("charts", "charts", 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),
...bundle.conflicts.flatMap((conflict) => conflict.techniqueRefs),
]),
fixedSection("provenance", "provenance", depth),
fixedSection("disclaimer", "disclaimer", depth),
];
return validatePersonalReportSectionPlan({
schemaVersion: "personal_report_section_plan.v1",
depth,
sections,
}, bundle);
}
+40 -24
View File
@@ -25,6 +25,7 @@ import {
} from "./personal-report-generation";
import { REPORT_STABLE_CODES } from "./personal-report-codes";
import { checkSameOrigin } from "./personal-report-entitlement";
import type { PersonalReportJobService } from "./personal-report-job-service-core";
import type {
CreateGeneratingInput,
CreateGeneratingResult,
@@ -40,6 +41,7 @@ export const personalReportCreateRequestSchema = z.object({
chartProfileId: z.string().uuid().nullable().optional(),
reportType: z.enum(["personal_full", "personal_thematic"]),
presentationMode: z.enum(["default", "research"]).default("default"),
depth: z.enum(["concise", "standard", "deep", "research"]).default("standard"),
themes: z.array(reportRequestThemes).min(1).max(6).default(["career", "marriage", "wealth", "timing"]),
}).strict();
@@ -106,6 +108,7 @@ export function reportView(row: PersonalReportRecord) {
requestId: row.requestId,
reportType: row.reportType,
presentationMode: row.presentationMode,
depth: row.depth,
status: row.status,
failureCode: row.failureCode,
createdAt: row.createdAt,
@@ -161,12 +164,9 @@ export type ReportCreateCoreDeps = Readonly<{
runWorkflow: (input: ConsultationInput) => Promise<unknown>;
createAgent: (model: Readonly<{ id: string }>) => ReportAgentPort;
skillSnapshot: SkillSnapshot;
/**
* Production may schedule the expensive workflow after the HTTP response.
* Unit tests omit this hook and execute synchronously so the complete
* generation contract remains deterministic and directly testable.
*/
deferGeneration?: (task: () => Promise<void>) => void;
/** Production supplies the durable queue. Tests may omit it to execute the
* full pipeline inline with deterministic fakes. */
jobs?: Pick<PersonalReportJobService, "enqueue">;
now?: () => Date;
}>;
@@ -250,6 +250,7 @@ export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise<R
const fingerprint = computeRequestFingerprint({
reportType: payload.reportType,
presentationMode: payload.presentationMode,
depth: payload.depth,
themes: payload.themes,
sessionId: payload.sessionId ?? null,
chartProfileId: payload.chartProfileId ?? null,
@@ -284,6 +285,7 @@ export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise<R
requestFingerprint: fingerprint,
reportType: payload.reportType,
presentationMode: payload.presentationMode,
depth: payload.depth,
requestedThemes: payload.themes,
sessionId: payload.sessionId ?? null,
chartProfileId: payload.chartProfileId ?? null,
@@ -393,6 +395,7 @@ export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise<R
const result: GeneratePersonalReportResult = await generatePersonalReport({
reportId: row.id,
bundle,
depth: payload.depth,
agent: deps.createAgent(deps.model),
now: deps.now,
});
@@ -425,25 +428,38 @@ export async function resolveReportCreate(deps: ReportCreateCoreDeps): Promise<R
}
};
if (deps.deferGeneration) {
deps.deferGeneration(async () => {
try {
await finishGeneration();
} catch {
// Unexpected model/adapter exceptions must not leave a permanent
// generating row. The stable schema failure is deliberately generic;
// report text, profile data and exception details are never logged.
try {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.schemaInvalid);
} catch {
// A concurrent terminal transition already won; keep that state.
}
if (deps.jobs) {
try {
const enqueued = await deps.jobs.enqueue({
userId,
requestId: payload.requestId,
requestFingerprint: fingerprint,
});
if (enqueued.kind === "request_conflict") {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.schemaInvalid);
return {
status: 409,
body: { error: "请求内容与已有任务不一致", code: REPORT_STABLE_CODES.requestConflict },
};
}
});
return {
status: 202,
body: { report: reportView(row) },
};
if (enqueued.kind === "active_job_exists") {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.generationInProgress);
return {
status: 409,
body: { error: "已有报告任务正在处理中", code: REPORT_STABLE_CODES.generationInProgress },
};
}
return {
status: 202,
body: { report: reportView(row), jobId: enqueued.job.id },
};
} catch {
await deps.persistence.markFailed(userId, row.id, REPORT_STABLE_CODES.schemaInvalid);
return {
status: 503,
body: { error: "报告任务暂时无法入队", code: REPORT_STABLE_CODES.generationFailed },
};
}
}
return finishGeneration();
@@ -3,7 +3,7 @@ import {
computeEvidenceHash,
} from "./personal-report-contract.server-core.ts";
import { REPORT_DOCUMENT_SCHEMA_VERSION } from "./personal-report-contract.ts";
import type { ReportDocumentV1 } from "./personal-report-contract.ts";
import type { ReportDocument } from "./personal-report-contract.ts";
/**
* Server-only persistence layer for personal reports (pure core).
@@ -50,6 +50,7 @@ export type PersonalReportStatus = (typeof PERSONAL_REPORT_STATUSES)[number];
export const PERSONAL_REPORT_TYPES = ["personal_full", "personal_thematic"] as const;
export const PERSONAL_REPORT_PRESENTATION_MODES = ["default", "research"] as const;
export const PERSONAL_REPORT_DEPTHS = ["concise", "standard", "deep", "research"] as const;
export type PersonalReportServiceErrorCode =
| "invalid_request"
@@ -82,8 +83,9 @@ export type PersonalReportRecord = Readonly<{
status: PersonalReportStatus;
schemaVersion: string;
presentationMode: (typeof PERSONAL_REPORT_PRESENTATION_MODES)[number];
depth: (typeof PERSONAL_REPORT_DEPTHS)[number];
requestedThemes: readonly string[];
reportDocument: ReportDocumentV1 | null;
reportDocument: ReportDocument | null;
calculationHash: string | null;
evidenceHash: string | null;
skillName: string | null;
@@ -102,6 +104,7 @@ export type CreateGeneratingInput = Readonly<{
requestFingerprint: string;
reportType: (typeof PERSONAL_REPORT_TYPES)[number];
presentationMode: (typeof PERSONAL_REPORT_PRESENTATION_MODES)[number];
depth: (typeof PERSONAL_REPORT_DEPTHS)[number];
requestedThemes?: readonly string[];
sessionId?: string | null;
chartProfileId?: string | null;
@@ -162,6 +165,7 @@ const RECORD_COLUMNS = [
"status",
"schema_version",
"presentation_mode",
"depth",
"requested_themes",
"report_document",
"calculation_hash",
@@ -230,7 +234,7 @@ function recordFromRow(row: DbRow | null | undefined): PersonalReportRecord | nu
: [];
const reportDocument = row.report_document === null || row.report_document === undefined
? null
: row.report_document as ReportDocumentV1;
: row.report_document as ReportDocument;
return {
id: stringOrNull(row.id) ?? "",
userId: stringOrNull(row.user_id) ?? "",
@@ -242,6 +246,7 @@ function recordFromRow(row: DbRow | null | undefined): PersonalReportRecord | nu
status: row.status as PersonalReportStatus,
schemaVersion: stringOrNull(row.schema_version) ?? "",
presentationMode: row.presentation_mode as PersonalReportRecord["presentationMode"],
depth: row.depth as PersonalReportRecord["depth"],
requestedThemes,
reportDocument,
calculationHash: stringOrNull(row.calculation_hash),
@@ -360,6 +365,9 @@ export function createPersonalReportService(
if (!PERSONAL_REPORT_PRESENTATION_MODES.includes(input.presentationMode)) {
throw new PersonalReportServiceError("invalid_request", "unsupported presentationMode");
}
if (!PERSONAL_REPORT_DEPTHS.includes(input.depth)) {
throw new PersonalReportServiceError("invalid_request", "unsupported depth");
}
optionalUuid(input.sessionId, "sessionId");
optionalUuid(input.chartProfileId, "chartProfileId");
requireSkillName(input.skillName);
@@ -379,6 +387,7 @@ export function createPersonalReportService(
status: "generating",
schema_version: REPORT_DOCUMENT_SCHEMA_VERSION,
presentation_mode: input.presentationMode,
depth: input.depth,
requested_themes: themes,
skill_name: input.skillName,
skill_version: input.skillVersion,
@@ -432,7 +441,7 @@ export function createPersonalReportService(
): Promise<PersonalReportRecord> {
requireUuid(userId, "userId");
requireUuid(reportId, "reportId");
let parsed: ReportDocumentV1;
let parsed: ReportDocument;
try {
parsed = parseServerReportDocument(document);
} catch (error) {
@@ -474,6 +483,7 @@ export function createPersonalReportService(
const { data, error } = await records()
.update({
status: "ready",
schema_version: parsed.schemaVersion,
report_document: parsed,
evidence_hash: evidenceHash,
calculation_hash: parsed.provenance.calculationHash,
@@ -0,0 +1,384 @@
import {
PersonalReportJobServiceError,
type PersonalReportJobRecord,
type PersonalReportJobService,
type RecoverExpiredPersonalReportJobsResult,
} from "./personal-report-job-service-core.ts";
import {
PersonalReportServiceError,
type PersonalReportFailureCode,
type PersonalReportRecord,
type PersonalReportService,
} from "./personal-report-service-core.ts";
import type { GeneratePersonalReportResult } from "./personal-report-generation.ts";
/**
* Durable personal-report worker orchestration.
*
* This core owns only lease/state coordination. It deliberately delegates
* profile loading and the evidence/model pipeline so unit tests can exercise
* crash recovery without a database, HTTP server, or model provider.
*/
export const PERSONAL_REPORT_WORKER_PROGRESS = {
loadingContext: { phase: "loading_context", percent: 10 },
generatingReport: { phase: "generating_report", percent: 30 },
persistingReport: { phase: "persisting_report", percent: 90 },
} as const;
export type PersonalReportWorkerErrorCode =
| PersonalReportFailureCode
| "report_generation_failed"
| "lease_lost";
export class PersonalReportWorkerError extends Error {
readonly name = "PersonalReportWorkerError";
constructor(
readonly code: PersonalReportWorkerErrorCode,
readonly retryable: boolean,
message?: string,
) {
super(message ?? code);
}
}
export type PersonalReportWorkerGenerationContext = Readonly<{
report: PersonalReportRecord;
profile: unknown;
signal: AbortSignal;
}>;
export type PersonalReportWorkerJobPort = Pick<
PersonalReportJobService,
| "recoverExpiredLeases"
| "claimLease"
| "heartbeatLease"
| "updateProgress"
| "markReady"
| "completeReportReady"
| "completeReportFailed"
| "markFailed"
| "scheduleRetry"
>;
export type PersonalReportWorkerReportPort = Pick<
PersonalReportService,
"getByUserAndRequestId"
>;
export type PersonalReportWorkerDeps = Readonly<{
workerId: string;
jobs: PersonalReportWorkerJobPort;
reports: PersonalReportWorkerReportPort;
loadProfile: (userId: string) => Promise<unknown | null>;
generate: (
context: PersonalReportWorkerGenerationContext,
) => Promise<GeneratePersonalReportResult>;
leaseSeconds?: number;
heartbeatIntervalMs?: number;
recoveryLimit?: number;
retryDelayMs?: (job: PersonalReportJobRecord, errorCode: string) => number;
now?: () => Date;
setInterval?: typeof globalThis.setInterval;
clearInterval?: typeof globalThis.clearInterval;
}>;
export type PersonalReportWorkerTickResult = Readonly<{
recovery: RecoverExpiredPersonalReportJobsResult;
outcome:
| "idle"
| "ready"
| "failed"
| "retry_scheduled"
| "reconcile_deferred"
| "lease_lost";
jobId: string | null;
}>;
export type PersonalReportWorkerLoopOptions = Readonly<{
pollIntervalMs?: number;
signal?: AbortSignal;
onError?: (error: unknown) => void;
sleep?: (milliseconds: number, signal?: AbortSignal) => Promise<void>;
}>;
function positiveInteger(value: number | undefined, fallback: number, field: string): number {
const resolved = value ?? fallback;
if (!Number.isInteger(resolved) || resolved <= 0) {
throw new Error(`${field} must be a positive integer`);
}
return resolved;
}
function isLeaseLoss(error: unknown): boolean {
return error instanceof PersonalReportJobServiceError
&& (error.code === "lease_lost" || error.code === "terminal_immutable");
}
function stableErrorCode(error: unknown): PersonalReportWorkerErrorCode {
if (error instanceof PersonalReportWorkerError) return error.code;
if (error instanceof PersonalReportServiceError) {
if (error.code === "invalid_document") return "report_schema_invalid";
if (error.code === "not_found") return "report_not_found";
}
if (error instanceof PersonalReportJobServiceError) {
if (error.code === "storage_invalid" || error.code === "invalid_request") {
return "report_schema_invalid";
}
if (error.code === "not_found") return "report_not_found";
if (error.code === "lease_lost") return "lease_lost";
}
return "report_generation_failed";
}
function isRetryable(error: unknown): boolean {
if (error instanceof PersonalReportWorkerError) return error.retryable;
if (error instanceof PersonalReportServiceError) {
return error.code === "storage_failed";
}
if (error instanceof PersonalReportJobServiceError) {
return error.code === "storage_failed";
}
return true;
}
function reportFailureCode(code: PersonalReportWorkerErrorCode): PersonalReportFailureCode {
switch (code) {
case "profile_incomplete":
case "birth_time_not_usable":
case "calculation_unavailable":
case "model_unavailable":
case "report_schema_invalid":
case "report_guard_rejected":
case "report_not_found":
return code;
default:
return "calculation_unavailable";
}
}
function timerUnref(timer: ReturnType<typeof globalThis.setInterval>): void {
const candidate = timer as ReturnType<typeof globalThis.setInterval> & { unref?: () => void };
candidate.unref?.();
}
function defaultRetryDelayMs(job: PersonalReportJobRecord): number {
const exponent = Math.max(0, Math.min(job.attemptCount - 1, 5));
return 5_000 * (2 ** exponent);
}
async function defaultSleep(milliseconds: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) return;
await new Promise<void>((resolve) => {
const timer = globalThis.setTimeout(resolve, milliseconds);
const candidate = timer as ReturnType<typeof globalThis.setTimeout> & { unref?: () => void };
candidate.unref?.();
signal?.addEventListener("abort", () => {
globalThis.clearTimeout(timer);
resolve();
}, { once: true });
});
}
export function createPersonalReportWorker(deps: PersonalReportWorkerDeps) {
const leaseSeconds = positiveInteger(deps.leaseSeconds, 120, "leaseSeconds");
const heartbeatIntervalMs = positiveInteger(
deps.heartbeatIntervalMs,
Math.max(1_000, Math.floor((leaseSeconds * 1_000) / 3)),
"heartbeatIntervalMs",
);
const recoveryLimit = positiveInteger(deps.recoveryLimit, 100, "recoveryLimit");
const setIntervalFn = deps.setInterval ?? globalThis.setInterval.bind(globalThis);
const clearIntervalFn = deps.clearInterval ?? globalThis.clearInterval.bind(globalThis);
const now = deps.now ?? (() => new Date());
const retryDelayMs = deps.retryDelayMs ?? defaultRetryDelayMs;
async function settleFailure(
job: PersonalReportJobRecord,
report: PersonalReportRecord | null,
error: unknown,
): Promise<PersonalReportWorkerTickResult["outcome"]> {
if (!job.leaseToken || isLeaseLoss(error)) return "lease_lost";
const code = stableErrorCode(error);
const failureCode = reportFailureCode(code);
const terminal = !isRetryable(error) || job.attemptCount >= job.maxAttempts;
try {
if (terminal) {
if (!report || report.status !== "generating") {
await deps.jobs.markFailed({
jobId: job.id,
leaseToken: job.leaseToken,
errorCode: code,
});
} else {
await deps.jobs.completeReportFailed({
jobId: job.id,
leaseToken: job.leaseToken,
errorCode: code,
report,
failureCode,
});
}
return "failed";
}
const retryAt = new Date(now().getTime() + retryDelayMs(job, code));
const scheduled = await deps.jobs.scheduleRetry({
jobId: job.id,
leaseToken: job.leaseToken,
errorCode: code,
nextAttemptAt: retryAt,
});
if (scheduled.kind === "scheduled") return "retry_scheduled";
if (!report || report.status !== "generating") return "failed";
await deps.jobs.completeReportFailed({
jobId: job.id,
leaseToken: job.leaseToken,
errorCode: code,
report,
failureCode,
});
return "failed";
} catch (transitionError) {
if (isLeaseLoss(transitionError)) return "lease_lost";
throw transitionError;
}
}
async function processClaimed(job: PersonalReportJobRecord): Promise<PersonalReportWorkerTickResult["outcome"]> {
if (!job.leaseToken) {
throw new PersonalReportWorkerError("lease_lost", false, "claimed job has no lease token");
}
const controller = new AbortController();
let heartbeatError: unknown = null;
let heartbeatChain = Promise.resolve();
const heartbeatTimer = setIntervalFn(() => {
heartbeatChain = heartbeatChain
.then(async () => {
if (heartbeatError !== null || controller.signal.aborted) return;
await deps.jobs.heartbeatLease({
jobId: job.id,
leaseToken: job.leaseToken!,
leaseSeconds,
});
})
.catch((error) => {
heartbeatError = error;
controller.abort(error);
});
}, heartbeatIntervalMs);
timerUnref(heartbeatTimer);
let report: PersonalReportRecord | null = null;
try {
report = await deps.reports.getByUserAndRequestId(job.userId, job.requestId);
if (!report) {
throw new PersonalReportWorkerError("report_not_found", false);
}
if (report.requestFingerprint !== job.requestFingerprint) {
throw new PersonalReportWorkerError("report_schema_invalid", false, "job/report fingerprint mismatch");
}
if (report.status === "ready") {
await deps.jobs.markReady({ jobId: job.id, leaseToken: job.leaseToken });
return "ready";
}
if (report.status === "failed") {
await deps.jobs.markFailed({
jobId: job.id,
leaseToken: job.leaseToken,
errorCode: report.failureCode ?? "report_generation_failed",
});
return "failed";
}
await deps.jobs.updateProgress({
jobId: job.id,
leaseToken: job.leaseToken,
...PERSONAL_REPORT_WORKER_PROGRESS.loadingContext,
});
const profile = await deps.loadProfile(job.userId);
if (!profile) {
throw new PersonalReportWorkerError("profile_incomplete", false);
}
// Refresh the lease synchronously immediately before the potentially
// long workflow/model call; the interval continues the heartbeat while
// that call is in flight.
await deps.jobs.heartbeatLease({
jobId: job.id,
leaseToken: job.leaseToken,
leaseSeconds,
});
await deps.jobs.updateProgress({
jobId: job.id,
leaseToken: job.leaseToken,
...PERSONAL_REPORT_WORKER_PROGRESS.generatingReport,
});
const generated = await deps.generate({ report, profile, signal: controller.signal });
await heartbeatChain;
if (heartbeatError !== null) throw heartbeatError;
if (generated.status === "failed") {
throw new PersonalReportWorkerError(generated.failureCode, false);
}
await deps.jobs.updateProgress({
jobId: job.id,
leaseToken: job.leaseToken,
...PERSONAL_REPORT_WORKER_PROGRESS.persistingReport,
});
await deps.jobs.completeReportReady({
jobId: job.id,
leaseToken: job.leaseToken,
report,
document: generated.document,
});
return "ready";
} catch (error) {
if (report?.status === "ready") {
// A ready report is the source of truth. If the projection-only job
// reconciliation fails, keep the live lease untouched so expiry
// recovery can converge it to ready; never downgrade it to failed.
return isLeaseLoss(error) ? "lease_lost" : "reconcile_deferred";
}
return settleFailure(job, report, error);
} finally {
clearIntervalFn(heartbeatTimer);
controller.abort();
await heartbeatChain;
}
}
return {
async tick(): Promise<PersonalReportWorkerTickResult> {
const recovery = await deps.jobs.recoverExpiredLeases(recoveryLimit);
const job = await deps.jobs.claimLease({ workerId: deps.workerId, leaseSeconds });
if (!job) return { recovery, outcome: "idle", jobId: null };
const outcome = await processClaimed(job);
return { recovery, outcome, jobId: job.id };
},
};
}
export async function runPersonalReportWorkerLoop(
worker: Readonly<{ tick: () => Promise<PersonalReportWorkerTickResult> }>,
options: PersonalReportWorkerLoopOptions = {},
): Promise<void> {
const pollIntervalMs = positiveInteger(options.pollIntervalMs, 2_000, "pollIntervalMs");
const sleep = options.sleep ?? defaultSleep;
while (!options.signal?.aborted) {
try {
const result = await worker.tick();
if (result.outcome !== "idle") continue;
} catch (error) {
options.onError?.(error);
}
await sleep(pollIntervalMs, options.signal);
}
}
+240
View File
@@ -0,0 +1,240 @@
import "server-only";
import { runConsultationWorkflow } from "@/mastra";
import { createPersonalReportAgent } from "@/mastra/personal-report";
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
import {
buildReportEvidenceBundleV2,
generatePersonalReport,
type SkillSnapshot,
} from "@/lib/personal-report-generation";
import { createSupabasePersonalReportJobService } from "@/lib/personal-report-job-service";
import {
createPersonalReportWorker,
PersonalReportWorkerError,
runPersonalReportWorkerLoop,
type PersonalReportWorkerGenerationContext,
} from "@/lib/personal-report-worker-core";
import { createSupabasePersonalReportService } from "@/lib/personal-report-service";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import type { ConsultationInput } from "@/mastra/consultation-workflow";
const PROFILE_COLUMNS = [
"name",
"birth_date",
"active_birth_time",
"birth_time_status",
"latitude",
"longitude",
"timezone_offset",
"birth_place_label",
].join(",");
type JsonRecord = Record<string, unknown>;
type WorkerGlobal = typeof globalThis & {
jyotishaPersonalReportWorker?: PersonalReportWorkerHandle;
};
export type PersonalReportWorkerHandle = Readonly<{
workerId: string;
done: Promise<void>;
stop: () => void;
}>;
function record(value: unknown): JsonRecord | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as JsonRecord
: null;
}
function text(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function finiteNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function parseBirthDate(value: unknown): { year: number; month: number; day: number } | null {
const date = text(value);
const match = date ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(date) : null;
if (!match) return null;
const year = Number.parseInt(match[1], 10);
const month = Number.parseInt(match[2], 10);
const day = Number.parseInt(match[3], 10);
if (year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1 || day > 31) return null;
return { year, month, day };
}
function parseBirthClock(value: unknown): { hour: number; minute: number } | null {
const clock = text(value);
const match = clock ? /^(\d{1,2}):(\d{2})(?::\d{2})?$/.exec(clock) : null;
if (!match) return null;
const hour = Number.parseInt(match[1], 10);
const minute = Number.parseInt(match[2], 10);
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
return { hour, minute };
}
function skillSnapshotForReport(context: PersonalReportWorkerGenerationContext): SkillSnapshot {
const { report } = context;
if (!report.skillName || !report.skillVersion || !report.skillSnapshotSha256) {
throw new PersonalReportWorkerError("report_schema_invalid", false, "report Skill provenance is incomplete");
}
return {
name: report.skillName,
version: report.skillVersion,
sha256: report.skillSnapshotSha256,
sourceCommit: report.skillSourceCommit,
};
}
async function generateProductionReport(context: PersonalReportWorkerGenerationContext) {
const profile = record(context.profile);
if (!profile) throw new PersonalReportWorkerError("profile_incomplete", false);
const birthTimeStatus = text(profile.birth_time_status);
const birthDate = parseBirthDate(profile.birth_date);
const birthClock = parseBirthClock(profile.active_birth_time);
const latitude = finiteNumber(profile.latitude);
const longitude = finiteNumber(profile.longitude);
const timezoneOffset = finiteNumber(profile.timezone_offset);
const birthPlaceLabel = text(profile.birth_place_label) ?? "未知出生地";
const displayName = text(profile.name) ?? "我的报告";
if ((birthTimeStatus !== "accepted" && birthTimeStatus !== "confirmed")
|| !birthDate || !birthClock || latitude === null || longitude === null || timezoneOffset === null) {
throw new PersonalReportWorkerError("birth_time_not_usable", false);
}
const catalog = await loadLanguageModelCatalog();
const model = catalog.models.find((entry) => entry.id === catalog.defaultModelId) ?? null;
if (!model) throw new PersonalReportWorkerError("model_unavailable", true);
const workflows: { theme: string; workflow: unknown }[] = [];
for (const rawTheme of context.report.requestedThemes) {
const input: ConsultationInput = {
year: birthDate.year,
month: birthDate.month,
day: birthDate.day,
hour: birthClock.hour,
minute: birthClock.minute,
lat: latitude,
lon: longitude,
tz: timezoneOffset,
city: birthPlaceLabel,
question: `请为个人报告计算 ${rawTheme} 主题证据`,
theme: rawTheme as ConsultationInput["theme"],
entryMode: "direct_chart",
};
try {
workflows.push({
theme: rawTheme,
workflow: await runConsultationWorkflow(input, { signal: context.signal }),
});
} catch (error) {
if (context.signal.aborted) throw error;
throw new PersonalReportWorkerError("calculation_unavailable", true);
}
}
const hasUsableBaseChart = workflows.some(({ workflow }) => {
const workflowRecord = record(workflow);
return workflowRecord?.success === true && record(workflowRecord.chart) !== null;
});
if (!hasUsableBaseChart) {
throw new PersonalReportWorkerError("calculation_unavailable", true);
}
let bundle;
try {
bundle = buildReportEvidenceBundleV2({
workflows,
subject: {
displayName,
birthTimeStatus,
birthPlaceLabel,
},
requestedThemes: context.report.requestedThemes,
reportType: context.report.reportType,
presentationMode: context.report.presentationMode,
skillSnapshot: skillSnapshotForReport(context),
});
} catch {
throw new PersonalReportWorkerError("calculation_unavailable", false);
}
return generatePersonalReport({
reportId: context.report.id,
bundle,
depth: context.report.depth,
agent: createPersonalReportAgent(model),
signal: context.signal,
});
}
function createProductionWorker(workerId: string) {
const admin = createAdminSupabaseClient();
const backend = admin as unknown as {
from(table: string): {
select(columns: string): {
eq(column: string, value: unknown): {
maybeSingle(): PromiseLike<{ data: unknown; error: { message: string } | null }>;
};
};
};
};
return createPersonalReportWorker({
workerId,
jobs: createSupabasePersonalReportJobService(admin),
reports: createSupabasePersonalReportService(admin),
loadProfile: async (userId) => {
const { data, error } = await backend
.from("profiles")
.select(PROFILE_COLUMNS)
.eq("id", userId)
.maybeSingle();
if (error) throw new Error("personal report profile load failed");
return data ?? null;
},
generate: generateProductionReport,
});
}
function sanitizedErrorName(error: unknown): string {
return error instanceof Error ? error.name : "UnknownError";
}
/**
* Starts one unref'ed loop per Node.js server instance. Database leases remain
* the cross-instance exclusivity boundary; the global only prevents duplicate
* loops caused by repeated instrumentation/module evaluation in one process.
*/
export function startPersonalReportWorker(): PersonalReportWorkerHandle {
const state = globalThis as WorkerGlobal;
if (state.jyotishaPersonalReportWorker) return state.jyotishaPersonalReportWorker;
const controller = new AbortController();
const workerId = `personal-report:${globalThis.crypto.randomUUID()}`;
let worker: ReturnType<typeof createProductionWorker> | null = null;
const lazyWorker = {
tick: async () => {
worker ??= createProductionWorker(workerId);
return worker.tick();
},
};
const done = runPersonalReportWorkerLoop(lazyWorker, {
signal: controller.signal,
onError: (error) => {
console.error(`[personal-report-worker] tick failed reason=${sanitizedErrorName(error)}`);
},
});
const handle: PersonalReportWorkerHandle = {
workerId,
done,
stop: () => controller.abort(),
};
state.jyotishaPersonalReportWorker = handle;
return handle;
}
+19 -8
View File
@@ -1,6 +1,7 @@
import { Agent } from "@mastra/core/agent";
import { z } from "zod";
import type { ResolvedLanguageModel } from "./model";
import type { PersonalReportSectionPlan } from "@/lib/personal-report-plan";
import type {
ReportChartHouse,
ReportDashaPeriod,
@@ -101,6 +102,7 @@ export const personalReportAgentOutputSchema = z.object({
thematicNarrative: z.array(
z.object({
id: reportSectionIdSchema,
theme: z.string().regex(/^[a-z][a-z0-9_.-]{0,95}$/, "invalid theme id"),
title: z.string().trim().min(1).max(160),
narrative: z.string().trim().min(1).max(4000),
actions: z.array(z.string().trim().min(1).max(400)).max(12).default([]),
@@ -108,7 +110,7 @@ export const personalReportAgentOutputSchema = z.object({
claimStatus: claimStatusSchema,
evidenceRefs: z.array(evidenceRefIdSchema).min(1).max(24),
}),
).min(1).max(12),
).max(12),
}).strict();
export type PersonalReportAgentOutput = z.infer<typeof personalReportAgentOutputSchema>;
@@ -136,7 +138,7 @@ Truth boundaries are hard output contracts:
Structure rules:
- Produce exactly the JSON object described by the requested output schema. No Markdown fences, no commentary, no hidden fields.
- executiveSummary.headline is one calm, concise Chinese sentence of at most 200 characters; it must not contain dates, timing windows, or deterministic claims.
- Section ids must be the lowercase theme keys (career, marriage, wealth, timing, general, or derived keys such as career_overview). Each thematicNarrative section must correspond to a requested theme and reference only exact "ev-..." ids listed in the Bundle evidenceRefs. Every narrative conclusion must trace to a claimCard. For a blockedSections theme, write an explicit blocked disclosure rather than an astrological conclusion, and use claimStatus=blocked.
- Write only thematic sections whose plan disposition is write. Section id, theme, and evidenceRefs must exactly match the plan entry. Every narrative conclusion must trace to the matching claimCard. Never output a blocked plan entry; the server creates those disclosures deterministically.
- Keep actions concrete and cautious; caveats must state limits honestly.
- Write formal, readable Simplified Chinese for a printed report.`;
@@ -169,16 +171,24 @@ export class PersonalReportAgentOutputError extends Error {
* text are structurally excluded by the agent definition (no skills, no tools,
* no memory).
*/
export function buildReportPrompt(bundle: ReportEvidenceBundleV2): string {
return `请根据以下唯一的 ReportEvidenceBundleV2 生成个人报告 JSON。只使用 claimCards 中的结论;blockedSections 必须如实披露;不得提升 assertionLevel。严格按输出 schema 返回 JSON。
${JSON.stringify(bundle)}`;
export function buildReportPrompt(
bundle: ReportEvidenceBundleV2,
plan: PersonalReportSectionPlan,
): string {
return `请根据以下唯一的已验证 ReportEvidenceBundleV2 和 PersonalReportSectionPlan 生成个人报告 JSON。只输出 plan 中 disposition=write 的 thematic sectionblocked section 由服务器确定性生成,不得补写。只使用 claimCards 中的结论,不得提升 assertionLevel。每个 section 的 id、theme 和 evidenceRefs 必须严格匹配 plan。严格按输出 schema 返回 JSON。
${JSON.stringify({ bundle, plan })}`;
}
export type ReportAgentGenerateOptions = Readonly<{
signal?: AbortSignal;
}>;
export type ReportAgentPort = Readonly<{
modelId: string;
generate(
bundle: ReportEvidenceBundleV2,
signal?: AbortSignal,
plan: PersonalReportSectionPlan,
options?: ReportAgentGenerateOptions,
): Promise<PersonalReportAgentOutput>;
}>;
@@ -194,9 +204,10 @@ export function createPersonalReportAgent(model: ResolvedLanguageModel): ReportA
return {
modelId: model.id,
async generate(bundle, signal) {
async generate(bundle, plan, options) {
const startedAt = Date.now();
const prompt = buildReportPrompt(bundle);
const signal = options?.signal;
const prompt = buildReportPrompt(bundle, plan);
let repairAttempted = false;
try {
const first = await agent.generate(