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
@@ -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 {