feat(reports): generate personal reports by section
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

This commit is contained in:
Jesse_Chen
2026-08-30 22:59:17 +08:00
parent 1d1c1d3d24
commit 0ae3e2d796
17 changed files with 1173 additions and 111 deletions
@@ -10,6 +10,7 @@ import {
createSupabasePersonalReportService,
type PersonalReportService,
} from "@/lib/personal-report-service";
import { createSupabasePersonalReportJobService } from "@/lib/personal-report-job-service";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
@@ -37,15 +38,15 @@ async function resolvePersistenceForUser() {
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return { userId: null as string | null, persistence: null as PersonalReportService | null };
return { userId: null as string | null, persistence: null as PersonalReportService | null, jobs: null };
}
const persistence = createSupabasePersonalReportService(supabase);
return { userId: user.id, persistence };
return { userId: user.id, persistence, jobs: createSupabasePersonalReportJobService(supabase) };
}
export async function GET(request: Request, context: RouteContext) {
try {
const { userId, persistence } = await resolvePersistenceForUser();
const { userId, persistence, jobs } = await resolvePersistenceForUser();
const { reportId } = await context.params;
if (!uuidPattern.test(reportId)) {
return NextResponse.json(
@@ -69,6 +70,7 @@ export async function GET(request: Request, context: RouteContext) {
// canonical server parse (schema + guards + evidence hash recompute)
// before it is returned to the browser. Client-side validation is never
// a substitute.
jobs: jobs ?? undefined,
validateReadyDocument: (document) => {
const parsed = safeParseServerReportDocument(document);
return parsed.ok
@@ -33,7 +33,7 @@ export type ReportLoadState =
| { phase: "loading" }
| { phase: "unauthorized" }
| { phase: "not-found" }
| { phase: "generating" }
| { phase: "generating"; progressPercent?: number; progressPhase?: string }
| { phase: "timed-out" }
| { phase: "failed"; failureCode: string | null }
| { phase: "invalid"; message: string }
@@ -50,6 +50,8 @@ export interface ReportEnvelopeView {
failureCode: string | null;
createdAt: string;
completedAt: string | null;
progressPercent?: number;
progressPhase?: string;
}
/**
@@ -89,8 +91,15 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
}
return { phase: "ready", document: parsed.document };
}
case "generating":
return { phase: "generating" };
case "generating": {
const progressPercent = typeof view.progressPercent === "number" ? view.progressPercent : undefined;
const progressPhase = typeof view.progressPhase === "string" ? view.progressPhase : undefined;
return {
phase: "generating",
...(progressPercent === undefined ? {} : { progressPercent }),
...(progressPhase === undefined ? {} : { progressPhase }),
};
}
case "failed": {
const code = typeof view.failureCode === "string" && view.failureCode.length > 0
? view.failureCode
@@ -192,6 +201,11 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
}, [load]);
const generating = state.phase === "generating";
const progressLabel = generating && state.progressPhase?.startsWith("section:")
? `正在生成主题(进度 ${Math.max(0, Math.min(100, Math.round(state.progressPercent ?? 30)))}%`
: generating && state.progressPhase === "summary" ? "正在汇总全部主题"
: generating && state.progressPhase === "assemble" ? "正在装配报告"
: null;
useVisibilityAwarePoll({
enabled: generating,
@@ -210,7 +224,7 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
<main className="flex min-h-svh flex-col items-center justify-center gap-4 px-4 text-center">
<InlineSpinner className="text-primary" size={32} />
<p className="text-ink" role="status">
{generating ? "报告正在生成中,请稍候…" : "正在加载报告…"}
{generating ? (progressLabel ?? "报告正在生成中,请稍候…") : "正在加载报告…"}
</p>
{generating && (
<>
+199 -8
View File
@@ -29,7 +29,8 @@ 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";
import { buildPersonalReportSectionPlan, validatePersonalReportSectionPlan, type PersonalReportSectionPlan, type ReportSectionPlanEntry } from "./personal-report-plan.ts";
import type { PersonalReportSectionRecord, PersonalReportSectionService } from "./personal-report-section-service-core.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";
@@ -1419,6 +1420,15 @@ export type AssembleReportDocumentV2Input = Readonly<{
bundle: ReportEvidenceBundleV2;
plan: PersonalReportSectionPlan;
agentOutput: PersonalReportAgentOutput;
allowIncompleteThematic?: boolean;
additionalBlockedSections?: readonly Readonly<{
theme: string;
title: string;
reason: string;
missingEvidence: readonly string[];
conflictNotes: readonly string[];
evidenceRefs: readonly string[];
}>[];
}>;
const CLAIM_STATUS_RANK: Readonly<Record<ClaimStatus, number>> = {
@@ -1450,11 +1460,13 @@ export function validatePersonalReportAgentOutputAgainstPlan(
output: PersonalReportAgentOutput,
plan: PersonalReportSectionPlan,
bundle: ReportEvidenceBundleV2,
options: Readonly<{ allowIncompleteThematic?: boolean }> = {},
): PersonalReportAgentOutput {
const writePlans = plan.sections.filter((section) => (
section.kind === "thematic" && section.disposition === "write"
));
if (output.thematicNarrative.length !== writePlans.length) {
const allowIncompleteThematic = options.allowIncompleteThematic === true;
if (!allowIncompleteThematic && output.thematicNarrative.length !== writePlans.length) {
throw new Error("report_writer_theme_count_mismatch");
}
const seen = new Set<string>();
@@ -1474,9 +1486,11 @@ export function validatePersonalReportAgentOutputAgainstPlan(
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}`);
if (!allowIncompleteThematic) {
for (const sectionPlan of writePlans) {
if (!seen.has(sectionPlan.theme as string)) {
throw new Error(`report_writer_theme_missing:${sectionPlan.theme}`);
}
}
}
return output;
@@ -1597,7 +1611,9 @@ export function assembleReportDocumentV2(
): ReportDocumentV2 {
const bundle = validateReportEvidenceBundleV2(input.bundle);
const plan = validatePersonalReportSectionPlan(input.plan, bundle);
const agentOutput = validatePersonalReportAgentOutputAgainstPlan(input.agentOutput, plan, bundle);
const agentOutput = validatePersonalReportAgentOutputAgainstPlan(input.agentOutput, plan, bundle, {
allowIncompleteThematic: input.allowIncompleteThematic,
});
if (bundle.skill.name !== "jyotish-personal-report") {
throw new Error("report_writer_skill_package_invalid");
}
@@ -1636,7 +1652,8 @@ export function assembleReportDocumentV2(
charts.push(canonicalBundleChart(chart, refs, status));
}
const blockedConflictDisclosure: ReportDocumentV2["blockedConflictDisclosure"] = bundle.blockedSections.map((section) => {
const blockedConflictDisclosure: ReportDocumentV2["blockedConflictDisclosure"] = [
...bundle.blockedSections.map((section) => {
const missingEvidence = section.missingTechniqueRefs.map((ref) => (
bundle.executionLedger.find((receipt) => receipt.id === ref)?.technique ?? ref
));
@@ -1652,7 +1669,17 @@ export function assembleReportDocumentV2(
evidenceRefs: [...section.missingTechniqueRefs],
claimStatus: "blocked" as const,
};
});
}),
...(input.additionalBlockedSections ?? []).map((section) => ({
theme: section.theme,
title: section.title,
reason: section.reason,
missingEvidence: [...section.missingEvidence],
conflictNotes: [...section.conflictNotes],
evidenceRefs: [...section.evidenceRefs],
claimStatus: "blocked" as const,
})),
];
const timingSection = thematicNarrative.find((section) => section.theme === "timing");
const currentPhase: ReportDocumentV2["currentPhase"] = timingSection
@@ -2096,6 +2123,10 @@ type GeneratePersonalReportBaseDeps = Readonly<{
export type GeneratePersonalReportDeps = GeneratePersonalReportBaseDeps & Readonly<{
bundle: ReportEvidenceBundleV2;
depth: ReportDepth;
userId?: string;
requestId?: string;
sectionService?: PersonalReportSectionService;
onProgress?: (progress: Readonly<{ phase: string; completed: number; total: number }>) => Promise<void> | void;
}>;
export type ReportSchemaInnerReason = string;
@@ -2136,6 +2167,162 @@ function rethrowIfAborted(error: unknown, signal?: AbortSignal): void {
if (isPersonalReportGenerationAbort(error, signal)) throw error;
}
const SECTION_OUTPUT_TOKEN_BUDGET = 3072;
const SUMMARY_OUTPUT_TOKEN_BUDGET = 1536;
/** Keep each section prompt bounded without weakening the evidence contract. */
export function filterReportEvidenceBundleForSection(
source: ReportEvidenceBundleV2,
section: ReportSectionPlanEntry,
): ReportEvidenceBundleV2 {
const requestedRefs = new Set(section.evidenceRefs);
const claim = source.claimCards.find((card) => card.theme === section.theme);
const ledger = source.executionLedger.filter((receipt) => requestedRefs.has(receipt.id));
const evidenceRefs = source.evidenceRefs.filter((ref) => requestedRefs.has(ref.id));
const selectedRefIds = new Set(evidenceRefs.map((ref) => ref.id));
const charts = source.charts.filter((chart) => (chart.id === "D1"
|| ledger.some((receipt) => receipt.technique.toUpperCase() === chart.id)));
const conflicts = source.conflicts.filter((conflict) => (
conflict.techniqueRefs.some((ref) => selectedRefIds.has(ref))
));
return finalizeReportEvidenceBundleV2({
schemaVersion: source.schemaVersion,
subject: source.subject,
requestedThemes: section.theme ? [section.theme] : [...source.requestedThemes],
reportType: source.reportType,
presentationMode: source.presentationMode,
calculationProfile: source.calculationProfile,
skill: source.skill,
charts,
claimCards: claim ? [{
...claim,
supportingFacts: claim.supportingFacts.filter((fact) => selectedRefIds.has(fact.evidenceRef)),
counterFacts: claim.counterFacts.filter((fact) => selectedRefIds.has(fact.evidenceRef)),
}] : [],
blockedSections: source.blockedSections.filter((blocked) => blocked.theme === section.theme),
conflicts,
executionLedger: ledger,
evidenceRefs,
answerPolicy: source.answerPolicy,
});
}
function sectionFailureReason(errorCode: string | null): string {
if (errorCode?.includes("length") || errorCode?.includes("truncat")) return "本节未能生成:输出被截断。";
if (errorCode?.includes("evidence")) return "本节未能生成:证据不足。";
return "本节未能生成:输出未通过校验。";
}
function sectionErrorCode(error: unknown): string {
const message = error instanceof Error ? error.message : "";
return message.includes("length") || message.includes("truncat")
? "section_output_truncated"
: message.includes("evidence")
? "section_evidence_insufficient"
: "section_output_invalid";
}
async function generateSectionedPersonalReport(
deps: GeneratePersonalReportDeps,
bundle: ReportEvidenceBundleV2,
plan: PersonalReportSectionPlan,
): Promise<GeneratePersonalReportResult> {
const sectionService = deps.sectionService;
if (!sectionService || !deps.userId || !deps.requestId || !deps.agent.generateSection || !deps.agent.generateSummary) {
return failSchema("sectioned_dependencies_missing");
}
const writePlans = plan.sections.filter((entry) => entry.kind === "thematic" && entry.disposition === "write");
for (const entry of writePlans) {
await sectionService.ensure({
userId: deps.userId, requestId: deps.requestId, sectionId: entry.id, maxAttempts: 2,
});
}
const existing = new Map((await sectionService.list(deps.userId, deps.requestId)).map((row) => [row.sectionId, row]));
const ready: PersonalReportSectionRecord[] = [];
const blocked: PersonalReportSectionRecord[] = [];
for (const entry of writePlans) {
let row = existing.get(entry.id) ?? null;
if (row?.status === "ready" && row.payload) { ready.push(row); continue; }
if (row?.status === "blocked") { blocked.push(row); continue; }
const sectionBundle = filterReportEvidenceBundleForSection(bundle, entry);
while (true) {
row = await sectionService.start({ userId: deps.userId, requestId: deps.requestId, sectionId: entry.id });
if (!row) {
const current = (await sectionService.list(deps.userId, deps.requestId)).find((item) => item.sectionId === entry.id);
if (current?.status === "ready" && current.payload) { ready.push(current); break; }
if (current?.status === "blocked") { blocked.push(current); break; }
throw new Error("section_start_failed");
}
try {
const titles = ready.map((item) => item.payload?.title).filter((title): title is string => Boolean(title));
const output = await deps.agent.generateSection(sectionBundle, entry, titles, {
signal: deps.signal,
maxOutputTokens: Math.min(SECTION_OUTPUT_TOKEN_BUDGET, Math.max(1024, Math.ceil(entry.targetCharacters.max / 2))),
assertWriterOutput: (candidate) => {
if (candidate.id !== entry.id || candidate.theme !== entry.theme) throw new Error("report_writer_section_identity_mismatch");
if (!equalStringSets(candidate.evidenceRefs, entry.evidenceRefs)) throw new Error("report_writer_evidence_refs_mismatch");
},
});
const completed = await sectionService.complete({ userId: deps.userId, requestId: deps.requestId, sectionId: entry.id, payload: output });
if (!completed) throw new Error("section_complete_failed");
ready.push(completed);
await deps.onProgress?.({ phase: `section:${entry.id}`, completed: ready.length + blocked.length, total: writePlans.length });
break;
} catch (error) {
rethrowIfAborted(error, deps.signal);
if (row.attemptCount >= row.maxAttempts) {
const blockedRow = await sectionService.block({ userId: deps.userId, requestId: deps.requestId, sectionId: entry.id, errorCode: sectionErrorCode(error) });
if (!blockedRow) throw new Error("section_block_failed");
blocked.push(blockedRow);
await deps.onProgress?.({ phase: `section:${entry.id}`, completed: ready.length + blocked.length, total: writePlans.length });
break;
}
}
}
}
if (writePlans.length > 0 && ready.length === 0) {
return { status: "failed", failureCode: "report_schema_invalid", innerReason: "all_sections_blocked" };
}
await deps.onProgress?.({ phase: "summary", completed: writePlans.length, total: writePlans.length });
let summary;
try {
summary = await deps.agent.generateSummary(ready.map((item) => ({
title: item.payload!.title, claimStatus: item.payload!.claimStatus,
})), { signal: deps.signal, maxOutputTokens: SUMMARY_OUTPUT_TOKEN_BUDGET });
} catch (error) {
rethrowIfAborted(error, deps.signal);
return failSchema(classifyReportSchemaInnerReason(error));
}
const agentOutput: PersonalReportAgentOutput = {
executiveSummary: summary,
thematicNarrative: ready.flatMap((item) => item.payload ? [item.payload] : []),
};
const blockedDisclosures = blocked.map((item) => {
const entry = writePlans.find((candidate) => candidate.id === item.sectionId)!;
const refs = [...entry.evidenceRefs];
return {
theme: entry.theme!, title: entry.theme!, reason: sectionFailureReason(item.lastErrorCode),
missingEvidence: [sectionFailureReason(item.lastErrorCode)], conflictNotes: [], evidenceRefs: refs,
};
});
await deps.onProgress?.({ phase: "assemble", completed: writePlans.length, total: writePlans.length });
try {
const candidate = assembleReportDocumentV2({
reportId: deps.reportId, generatedAt: (deps.now ?? (() => new Date()))().toISOString(),
depth: deps.depth, bundle, plan, agentOutput, allowIncompleteThematic: true,
additionalBlockedSections: blockedDisclosures,
});
const guarded = applyReportGuard(candidate, buildLegacyPacketFromBundle(bundle));
if (!guarded.ok) return { status: "failed", failureCode: "report_guard_rejected" };
const parsed = safeParseServerReportDocument(guarded.document);
if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") return failSchema("final_parse_rejected");
return { status: "ready", document: parsed.document, evidenceHash: computeEvidenceHash(parsed.document.evidenceAppendix) };
} catch (error) {
rethrowIfAborted(error, deps.signal);
return failSchema(error instanceof ReportEvidenceInsufficientError ? "assemble_invalid" : classifyReportSchemaInnerReason(error));
}
}
/**
* Runs the dedicated report agent exactly once (plus its single internal
* repair retry), assembles the candidate document, applies the deterministic
@@ -2166,6 +2353,10 @@ export async function generatePersonalReport(
return failSchema("plan_invalid");
}
if (deps.sectionService) {
return generateSectionedPersonalReport(deps, bundle, plan);
}
const bindWriter = (output: PersonalReportAgentOutput) => (
validatePersonalReportAgentOutputAgainstPlan(output, plan, bundle)
);
@@ -37,7 +37,7 @@ const transitionTargets = {
} as const satisfies Record<PersonalReportJobStatus, readonly PersonalReportJobStatus[]>;
const requestFingerprintPattern = /^[0-9a-f]{64}$/;
const progressPhasePattern = /^[a-z][a-z0-9_]{0,63}$/;
const progressPhasePattern = /^(?:[a-z][a-z0-9_]{0,63}|section:[a-z][a-z0-9_-]{0,95})$/;
export type PersonalReportJobStateErrorReason =
| "invalid_transition"
@@ -25,7 +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 { PersonalReportJobRecord, PersonalReportJobService } from "./personal-report-job-service-core";
import type {
CreateGeneratingInput,
CreateGeneratingResult,
@@ -131,7 +131,7 @@ export function reportListTimestamp(value: unknown): string {
return "";
}
export function reportView(row: PersonalReportRecord) {
export function reportView(row: PersonalReportRecord, job?: PersonalReportJobRecord | null) {
return {
id: row.id,
requestId: row.requestId,
@@ -142,6 +142,7 @@ export function reportView(row: PersonalReportRecord) {
failureCode: row.failureCode,
createdAt: row.createdAt,
completedAt: row.completedAt,
...(job ? { progressPercent: job.progressPercent, progressPhase: job.progressPhase } : {}),
};
}
@@ -513,6 +514,7 @@ export type ReportReadCoreDeps = Readonly<{
validateReadyDocument: (
document: unknown,
) => { ok: true; document: unknown } | { ok: false };
jobs?: Pick<PersonalReportJobService, "getOwnedByRequestId">;
}>;
export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<ReportRouteResponse> {
@@ -533,6 +535,7 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
body: { error: "报告不存在", code: REPORT_STABLE_CODES.notFound },
};
}
const job = deps.jobs ? await deps.jobs.getOwnedByRequestId(deps.userId, row.requestId) : null;
if (row.status === "ready") {
// Re-validate the stored document through the canonical server parse
// before it is allowed to leave the server; an invalid stored document is
@@ -544,16 +547,16 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise<Repor
body: {
error: "报告内容未通过合同校验",
code: REPORT_STABLE_CODES.schemaInvalid,
report: reportView(row),
report: reportView(row, job),
},
};
}
return {
status: 200,
body: { report: reportView(row), reportDocument: validated.document },
body: { report: reportView(row, job), reportDocument: validated.document },
};
}
return { status: 200, body: { report: reportView(row) } };
return { status: 200, body: { report: reportView(row, job) } };
}
export type ReportDeleteCoreDeps = Readonly<{
@@ -0,0 +1,141 @@
import type { PersonalReportAgentOutput } from "@/mastra/personal-report";
export type PersonalReportSectionPayload = PersonalReportAgentOutput["thematicNarrative"][number];
export type PersonalReportSectionStatus = "pending" | "ready" | "blocked";
export type PersonalReportSectionRecord = Readonly<{
userId: string;
requestId: string;
sectionId: string;
payload: PersonalReportSectionPayload | null;
status: PersonalReportSectionStatus;
attemptCount: number;
maxAttempts: number;
lastErrorCode: string | null;
createdAt: string;
updatedAt: string;
}>;
type SectionIdentity = Readonly<{ userId: string; requestId: string; sectionId: string }>;
export type PersonalReportSectionService = Readonly<{
ensure(input: SectionIdentity & { maxAttempts: number }): Promise<PersonalReportSectionRecord>;
list(userId: string, requestId: string): Promise<readonly PersonalReportSectionRecord[]>;
start(input: SectionIdentity): Promise<PersonalReportSectionRecord | null>;
complete(input: SectionIdentity & { payload: PersonalReportSectionPayload }): Promise<PersonalReportSectionRecord | null>;
block(input: SectionIdentity & { errorCode: string }): Promise<PersonalReportSectionRecord | null>;
}>;
export type PersonalReportSectionQueryResult = Readonly<{
data: unknown;
error: Readonly<{ message: string; code?: string }> | null;
}>;
type QueryBuilder = PromiseLike<PersonalReportSectionQueryResult> & {
select(columns: string): QueryBuilder;
eq(column: string, value: unknown): QueryBuilder;
order(column: string, options?: Readonly<{ ascending?: boolean }>): QueryBuilder;
maybeSingle(): PromiseLike<PersonalReportSectionQueryResult>;
};
type DataClient = {
from(table: string): QueryBuilder;
rpc(functionName: string, args?: Readonly<Record<string, unknown>>): PromiseLike<PersonalReportSectionQueryResult>;
};
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const sectionIdPattern = /^[a-z][a-z0-9_-]{0,95}$/;
const errorCodePattern = /^[a-z][a-z0-9_]{0,63}$/;
const columns = [
"user_id", "request_id", "section_id", "payload", "status", "attempt_count",
"max_attempts", "last_error_code", "created_at", "updated_at",
].join(",");
type DbRow = Record<string, unknown>;
function requireUuid(value: string, field: string): void {
if (!uuidPattern.test(value)) throw new Error(`${field} is invalid`);
}
function requireSectionId(value: string): void {
if (!sectionIdPattern.test(value)) throw new Error("sectionId is invalid");
}
function row(value: unknown): PersonalReportSectionRecord {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("personal report section row is invalid");
const source = value as DbRow;
const userId = String(source.user_id);
const requestId = String(source.request_id);
const sectionId = String(source.section_id);
requireUuid(userId, "userId");
requireUuid(requestId, "requestId");
requireSectionId(sectionId);
const status = source.status;
if (status !== "pending" && status !== "ready" && status !== "blocked") throw new Error("section status is invalid");
const payload = source.payload === null || source.payload === undefined ? null : source.payload as PersonalReportSectionPayload;
if (status === "ready" && payload === null) throw new Error("ready section payload is missing");
if (status !== "ready" && payload !== null) throw new Error("non-ready section payload is unexpected");
const attemptCount = Number(source.attempt_count);
const maxAttempts = Number(source.max_attempts);
if (!Number.isInteger(attemptCount) || !Number.isInteger(maxAttempts) || attemptCount < 0 || maxAttempts < 1 || attemptCount > maxAttempts) {
throw new Error("section attempt budget is invalid");
}
const createdAt = String(source.created_at);
const updatedAt = String(source.updated_at);
if (!Number.isFinite(Date.parse(createdAt)) || !Number.isFinite(Date.parse(updatedAt))) throw new Error("section timestamp is invalid");
return {
userId, requestId, sectionId, payload, status, attemptCount, maxAttempts,
lastErrorCode: source.last_error_code === null || source.last_error_code === undefined ? null : String(source.last_error_code),
createdAt, updatedAt,
};
}
function first(data: unknown): unknown {
return Array.isArray(data) ? data[0] ?? null : data;
}
function requireData(result: PersonalReportSectionQueryResult): unknown {
if (result.error) throw new Error(result.error.message);
return result.data;
}
export function createPersonalReportSectionService(client: DataClient): PersonalReportSectionService {
return {
async ensure(input) {
requireUuid(input.userId, "userId");
requireUuid(input.requestId, "requestId");
requireSectionId(input.sectionId);
if (!Number.isInteger(input.maxAttempts) || input.maxAttempts < 1 || input.maxAttempts > 10) throw new Error("maxAttempts is invalid");
const result = await client.rpc("ensure_personal_report_section", {
p_user_id: input.userId, p_request_id: input.requestId, p_section_id: input.sectionId, p_max_attempts: input.maxAttempts,
});
const value = requireData(result);
const parsed = row(first(value));
if (!parsed) throw new Error("section ensure returned no row");
return parsed;
},
async list(userId, requestId) {
requireUuid(userId, "userId");
requireUuid(requestId, "requestId");
const result = await client.from("personal_report_sections").select(columns).eq("user_id", userId).eq("request_id", requestId).order("section_id", { ascending: true });
const value = requireData(result);
if (!Array.isArray(value)) throw new Error("section list returned invalid data");
return value.map(row);
},
async start(input) {
const result = await client.rpc("start_personal_report_section", { p_user_id: input.userId, p_request_id: input.requestId, p_section_id: input.sectionId });
const value = requireData(result);
const parsed = first(value);
return parsed === null ? null : row(parsed);
},
async complete(input) {
const result = await client.rpc("complete_personal_report_section", { p_user_id: input.userId, p_request_id: input.requestId, p_section_id: input.sectionId, p_payload: input.payload });
const value = requireData(result);
const parsed = first(value);
return parsed === null ? null : row(parsed);
},
async block(input) {
if (!errorCodePattern.test(input.errorCode)) throw new Error("errorCode is invalid");
const result = await client.rpc("block_personal_report_section", { p_user_id: input.userId, p_request_id: input.requestId, p_section_id: input.sectionId, p_error_code: input.errorCode });
const value = requireData(result);
const parsed = first(value);
return parsed === null ? null : row(parsed);
},
};
}
@@ -0,0 +1,3 @@
import "server-only";
export * from "./personal-report-section-service-core";
@@ -11,6 +11,7 @@ import {
type PersonalReportService,
} from "./personal-report-service-core.ts";
import type { GeneratePersonalReportResult } from "./personal-report-generation.ts";
import type { PersonalReportSectionService } from "./personal-report-section-service-core.ts";
/**
* Durable personal-report worker orchestration.
@@ -47,6 +48,8 @@ export type PersonalReportWorkerGenerationContext = Readonly<{
report: PersonalReportRecord;
profile: unknown;
signal: AbortSignal;
sectionService?: PersonalReportSectionService;
onProgress?: (progress: Readonly<{ phase: string; completed: number; total: number }>) => Promise<void> | void;
}>;
export type PersonalReportWorkerJobPort = Pick<
@@ -75,6 +78,7 @@ export type PersonalReportWorkerDeps = Readonly<{
generate: (
context: PersonalReportWorkerGenerationContext,
) => Promise<GeneratePersonalReportResult>;
sectionService?: PersonalReportSectionService;
leaseSeconds?: number;
heartbeatIntervalMs?: number;
recoveryLimit?: number;
@@ -321,7 +325,20 @@ export function createPersonalReportWorker(deps: PersonalReportWorkerDeps) {
...PERSONAL_REPORT_WORKER_PROGRESS.generatingReport,
});
const generated = await deps.generate({ report, profile, signal: controller.signal });
const generated = await deps.generate({
report,
profile,
signal: controller.signal,
sectionService: deps.sectionService,
onProgress: async (progress) => {
const percent = progress.total > 0
? Math.min(89, 30 + Math.floor((progress.completed / progress.total) * 55))
: 30;
await deps.jobs.updateProgress({
jobId: job.id, leaseToken: job.leaseToken!, phase: progress.phase, percent,
});
},
});
await heartbeatChain;
if (heartbeatError !== null) throw heartbeatError;
if (generated.status === "failed") {
@@ -16,6 +16,7 @@ import {
type PersonalReportWorkerGenerationContext,
} from "@/lib/personal-report-worker-core";
import { createSupabasePersonalReportService } from "@/lib/personal-report-service";
import { createPersonalReportSectionService } from "@/lib/personal-report-section-service-core";
import { resolveReportBirthClock } from "@/lib/personal-report-route-core";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import type { ConsultationInput } from "@/mastra/consultation-workflow";
@@ -164,6 +165,10 @@ async function generateProductionReport(context: PersonalReportWorkerGenerationC
depth: context.report.depth,
agent: createPersonalReportAgent(model),
signal: context.signal,
userId: context.report.userId,
requestId: context.report.requestId,
sectionService: context.sectionService,
onProgress: context.onProgress,
});
}
@@ -182,6 +187,7 @@ function createProductionWorker(workerId: string) {
workerId,
jobs: createSupabasePersonalReportJobService(admin),
reports: createSupabasePersonalReportService(admin),
sectionService: createPersonalReportSectionService(admin as never),
loadProfile: async (userId) => {
const { data, error } = await backend
.from("profiles")
+167 -85
View File
@@ -1,7 +1,8 @@
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 { PersonalReportSectionPlan, ReportSectionPlanEntry } from "@/lib/personal-report-plan";
import { agentGenerationSettings } from "@/lib/agent-generation-settings";
import type {
ReportChartHouse,
ReportDashaPeriod,
@@ -93,27 +94,49 @@ export type ReportEvidencePacket = Readonly<{
const reportSectionIdSchema = z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/, "invalid section id");
const evidenceRefIdSchema = z.string().regex(/^ev-[a-z0-9_-]{1,63}$/, "invalid evidence id");
export const personalReportAgentOutputSchema = z.object({
executiveSummary: z.object({
headline: z.string().trim().min(1).max(200),
summary: z.string().trim().min(1).max(2000),
priorities: z.array(z.string().trim().min(1).max(200)).max(8).default([]),
}),
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([]),
caveats: z.array(z.string().trim().min(1).max(400)).max(12).default([]),
claimStatus: claimStatusSchema,
evidenceRefs: z.array(evidenceRefIdSchema).min(1).max(24),
}),
).max(12),
}).strict();
export type PersonalReportExecutiveSummary = Readonly<{
headline: string;
summary: string;
priorities: string[];
}>;
export type PersonalReportAgentOutput = z.infer<typeof personalReportAgentOutputSchema>;
export type PersonalReportThematicNarrative = Readonly<{
id: string;
theme: string;
title: string;
narrative: string;
actions: string[];
caveats: string[];
claimStatus: z.infer<typeof claimStatusSchema>;
evidenceRefs: string[];
}>;
export const personalReportExecutiveSummarySchema = z.object({
headline: z.string().trim().min(1).max(200),
summary: z.string().trim().min(1).max(2000),
priorities: z.array(z.string().trim().min(1).max(200)).max(8).default([]),
}).strict() as unknown as z.ZodType<PersonalReportExecutiveSummary>;
export const personalReportThematicNarrativeSchema = 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([]),
caveats: z.array(z.string().trim().min(1).max(400)).max(12).default([]),
claimStatus: claimStatusSchema,
evidenceRefs: z.array(evidenceRefIdSchema).min(1).max(24),
}).strict() as unknown as z.ZodType<PersonalReportThematicNarrative>;
export const personalReportAgentOutputSchema = z.object({
executiveSummary: personalReportExecutiveSummarySchema,
thematicNarrative: z.array(personalReportThematicNarrativeSchema).max(12),
}).strict() as unknown as z.ZodType<PersonalReportAgentOutput>;
export type PersonalReportAgentOutput = Readonly<{
executiveSummary: PersonalReportExecutiveSummary;
thematicNarrative: PersonalReportThematicNarrative[];
}>;
export type PersonalReportAgentTelemetry = Readonly<{
modelId: string;
@@ -200,6 +223,17 @@ export type ReportAgentGenerateOptions = Readonly<{
assertWriterOutput?: (output: PersonalReportAgentOutput) => void;
}>;
export type ReportAgentSectionOptions = Readonly<{
signal?: AbortSignal;
assertWriterOutput?: (output: z.output<typeof personalReportThematicNarrativeSchema>) => void;
maxOutputTokens?: number;
}>;
export type ReportAgentSummaryOptions = Readonly<{
signal?: AbortSignal;
maxOutputTokens?: number;
}>;
export type ReportAgentPort = Readonly<{
modelId: string;
generate(
@@ -207,15 +241,41 @@ export type ReportAgentPort = Readonly<{
plan: PersonalReportSectionPlan,
options?: ReportAgentGenerateOptions,
): Promise<PersonalReportAgentOutput>;
generateSection?: (
bundle: ReportEvidenceBundleV2,
section: ReportSectionPlanEntry,
completedTitles: readonly string[],
options?: ReportAgentSectionOptions,
) => Promise<z.output<typeof personalReportThematicNarrativeSchema>>;
generateSummary?: (
sections: readonly Readonly<{ title: string; claimStatus: string }>[],
options?: ReportAgentSummaryOptions,
) => Promise<z.output<typeof personalReportExecutiveSummarySchema>>;
}>;
const REPAIR_PROMPT_SUFFIX = "\n\n上次输出未通过结构或章节计划校验。请只输出符合要求 schema 的 JSON 对象,不要任何额外文字。";
const REPAIR_PROMPT_SUFFIX = "\\n\\n上次输出未通过结构或章节计划校验。请只输出符合要求 schema 的 JSON 对象,不要任何额外文字。";
type GenerationResult = { object?: unknown; usage?: unknown; finishReason?: unknown };
function isAbortError(error: unknown, signal?: AbortSignal): boolean {
if (signal?.aborted) return true;
return error instanceof Error && error.name === "AbortError";
}
function sectionPrompt(
bundle: ReportEvidenceBundleV2,
section: ReportSectionPlanEntry,
completedTitles: readonly string[],
): string {
return `请只生成以下一个个人报告 thematicNarrative 条目。严格输出单个 JSON 对象,不要数组、Markdown 或额外文字。只使用给定证据;section id、theme、evidenceRefs 必须与 plan 完全一致。已完成章节标题仅用于避免重复,不要复述正文。\n${JSON.stringify({ bundle, plan: section, completedSectionTitles: completedTitles })}`;
}
function summaryPrompt(
sections: readonly Readonly<{ title: string; claimStatus: string }>[],
): string {
return `请根据全部已完成主题的标题和 claimStatus 生成个人报告 executiveSummary。严格输出 JSON 对象,不要 Markdown 或额外文字。不得编造未列出的主题或精确时间。\n${JSON.stringify({ sections })}`;
}
export function createPersonalReportAgent(model: ResolvedLanguageModel): ReportAgentPort {
const agent = new Agent({
id: `personal-report-${model.id}`,
@@ -224,74 +284,96 @@ export function createPersonalReportAgent(model: ResolvedLanguageModel): ReportA
instructions: personalReportInstructions,
});
const runStructured = async <T>(input: Readonly<{
prompt: string;
schema: z.ZodType<T>;
signal?: AbortSignal;
maxOutputTokens?: number;
accept: (value: T) => void;
}>): Promise<T> => {
const startedAt = Date.now();
const signal = input.signal;
const prompt = input.prompt;
let repairAttempted = false;
let attemptReturned = false;
const runOnce = (content: string) => agent.generate(
[{ role: "user", content }],
{
abortSignal: signal,
structuredOutput: { schema: input.schema, jsonPromptInjection: "inline" as const },
...(input.maxOutputTokens === undefined ? {} : agentGenerationSettings(model.model, {
thinking: "disabled",
answerTokens: input.maxOutputTokens,
})),
},
);
const accept = (result: GenerationResult): { ok: true; data: T } | { ok: false; error?: unknown } => {
const parsed = input.schema.safeParse(result.object);
if (!parsed.success) return { ok: false };
try {
input.accept(parsed.data);
return { ok: true, data: parsed.data };
} catch (error) {
if (isAbortError(error, input.signal)) throw error;
return { ok: false, error };
}
};
try {
attemptReturned = false;
const first = await runOnce(prompt);
attemptReturned = true;
const accepted = accept(first);
if (accepted.ok) {
await logTelemetry(model.id, startedAt, false, "resolved", first.usage, first.finishReason);
return accepted.data;
}
await logTelemetry(model.id, startedAt, false, "failed", first.usage, first.finishReason);
repairAttempted = true;
attemptReturned = false;
const repaired = await runOnce(`${prompt}${REPAIR_PROMPT_SUFFIX}`);
attemptReturned = true;
const repairedAccepted = accept(repaired);
if (repairedAccepted.ok) {
await logTelemetry(model.id, startedAt, true, "resolved", repaired.usage, repaired.finishReason);
return repairedAccepted.data;
}
await logTelemetry(model.id, startedAt, true, "failed", repaired.usage, repaired.finishReason);
if (repairedAccepted.error) throw repairedAccepted.error;
throw new PersonalReportAgentOutputError();
} catch (error) {
if (error instanceof PersonalReportAgentOutputError) throw error;
if (isAbortError(error, input.signal)) throw error;
if (error instanceof Error && error.message.startsWith("report_writer_")) throw error;
if (!attemptReturned) await logTelemetry(model.id, startedAt, repairAttempted, "failed", null, null);
throw error;
}
};
return {
modelId: model.id,
async generate(bundle, plan, options) {
const startedAt = Date.now();
generate: (bundle, plan, options) => {
const signal = options?.signal;
const prompt = buildReportPrompt(bundle, plan);
const structuredOutput = {
return runStructured({
prompt: buildReportPrompt(bundle, plan),
schema: personalReportAgentOutputSchema,
jsonPromptInjection: "inline" as const,
};
let repairAttempted = false;
type GenerationResult = { object?: unknown; usage?: unknown; finishReason?: unknown };
const runOnce = (content: string) => agent.generate(
[{ role: "user", content }],
{ abortSignal: signal, structuredOutput },
);
let attemptReturned = false;
const accept = (result: GenerationResult):
| { ok: true; data: PersonalReportAgentOutput }
| { ok: false; cause: "schema" | "bind"; error?: unknown } => {
const parsed = personalReportAgentOutputSchema.safeParse(result.object);
if (!parsed.success) return { ok: false, cause: "schema" };
try {
options?.assertWriterOutput?.(parsed.data);
return { ok: true, data: parsed.data };
} catch (error) {
if (isAbortError(error, signal)) throw error;
return { ok: false, cause: "bind", error };
}
};
try {
attemptReturned = false;
const first = await runOnce(prompt);
attemptReturned = true;
const firstAccepted = accept(first);
if (firstAccepted.ok) {
await logTelemetry(model.id, startedAt, false, "resolved", first.usage, first.finishReason);
return firstAccepted.data;
}
await logTelemetry(model.id, startedAt, false, "failed", first.usage, first.finishReason);
// Exactly one repair retry is allowed. A second failure is terminal.
repairAttempted = true;
attemptReturned = false;
const repaired = await runOnce(`${prompt}${REPAIR_PROMPT_SUFFIX}`);
attemptReturned = true;
const repairedAccepted = accept(repaired);
if (repairedAccepted.ok) {
await logTelemetry(model.id, startedAt, true, "resolved", repaired.usage, repaired.finishReason);
return repairedAccepted.data;
}
await logTelemetry(model.id, startedAt, true, "failed", repaired.usage, repaired.finishReason);
if (repairedAccepted.cause === "bind" && repairedAccepted.error) {
throw repairedAccepted.error;
}
throw new PersonalReportAgentOutputError();
} catch (error) {
if (error instanceof PersonalReportAgentOutputError) throw error;
if (isAbortError(error, signal)) throw error;
if (error instanceof Error && error.message.startsWith("report_writer_")) throw error;
if (!attemptReturned) {
await logTelemetry(model.id, startedAt, repairAttempted, "failed", null, null);
}
throw error;
}
signal,
accept: (output) => options?.assertWriterOutput?.(output),
});
},
generateSection: (bundle, section, completedTitles, options) => runStructured({
prompt: sectionPrompt(bundle, section, completedTitles),
schema: personalReportThematicNarrativeSchema,
signal: options?.signal,
maxOutputTokens: options?.maxOutputTokens,
accept: (output) => options?.assertWriterOutput?.(output),
}),
generateSummary: (sections, options) => runStructured({
prompt: summaryPrompt(sections),
schema: personalReportExecutiveSummarySchema,
signal: options?.signal,
maxOutputTokens: options?.maxOutputTokens,
accept: () => undefined,
}),
};
}