feat(reports): generate personal reports by section
This commit is contained in:
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user