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
+33
View File
@@ -30,3 +30,36 @@ Task 0 observation is complete. No output cap, schema relaxation, or schema tigh
## Task 1–4
- Not started at the time of this entry.
## Task 1–4 implementation
- Task 1: added `public.personal_report_sections` in migration `20260830020000_personal_report_sections.sql`. The migration is transactional and idempotent, enables RLS, grants authenticated read-only access to own rows, grants service-owned writes, and exposes security-definer section state functions (`ensure`, `start`, `complete`, `block`). `report_document` remains final-output-only.
- Task 2: generation is now serial `plan → section:<id> × N → summary → assemble → ready`. Each section receives only the evidence refs declared by its plan entry plus completed section titles; summary receives only completed titles and `claimStatus`. Existing ready section rows are reused on resume and are not regenerated.
- Task 3: section retry exhaustion marks that section `blocked` with an allowlisted error category, continues other sections, and assembles an explicit disclosure. A report fails only when every write section is blocked or summary/assembly fails.
- Task 4: worker updates `progress_percent` and `progress_phase`; the report page renders phase-aware progress while preserving timed-out/resume/report-center states.
## Validation
- `./node_modules/.bin/tsc --noEmit --pretty false`: passed.
- `npm run lint`: passed with 0 errors and 24 pre-existing warnings.
- Focused sectioned generation, resume, blocked fallback, polling, and database tests: 33 passed, 0 failed.
- `npm run test:db`: passed with 34 passed, 0 failed (Docker-backed).
- Full `./node_modules/.bin/tsx --test tests/*.test.ts`: exit 1 in this local run. The report-polling assertion that locked the old fixed loading copy was the only failure caused by this change and was updated with the required explanation; the remaining observed failures were existing local environment/resource failures (parallel PostgreSQL fixture/migration startup and missing Python `yaml` module), listed for baseline comparison before final push.
- `npm run db:migrate:check`: passed in isolated Docker PostgreSQL; apply exited 0, check exited 0, and reapply exited 0 with `already applied 20260830020000_personal_report_sections.sql`.
- `npm run build`: passed; Next.js production build completed with 5 pre-existing Turbopack dynamic-filesystem warnings.
### Full-test comparison
- `./node_modules/.bin/tsx --test tests/*.test.ts`: exited 1 in the final local run. The report-section focused tests remained green; no new report-generation or section-service failure was observed. The failing tests were outside this change and/or local-environment dependent:
- `service and restricted admin database identities stay separated`
- `v9 legacy backfill maps statuses, keeps one resumable per user and is idempotent`
- `ingest P0: education kinds, batch confirm, opening focus reuse, precision lock`
- `PR-4 candidate decisions use server UUIDs, receipt-derived gates and separate acceptance/confirmation`
- `changed staging workflows are syntactically valid YAML` (local Python missing `yaml`)
- additional database suites reported PostgreSQL fixture shutdown/permission/resource failures during the long parallel run.
- These failures are not being reclassified as code-green; the focused acceptance suite and the required Docker-backed `npm run test:db` are the evidence for this change.
## Delivery
- Task 1–4 commit: pending local commit.
- Staging push/deploy: pending.
+16
View File
@@ -6910,3 +6910,19 @@
- 相关记录:BUG-405、BUG-407、BUG-432、BUG-440、BUG-441、BUG-442、BUG-449
- 复发自:BUG-432(出题层/决策层/可见承载再次分裂)、BUG-440(覆盖不足状态再次空转)、BUG-441/BUG-449(口述焦点可见性与服务端兜底边界未覆盖本路径)
- 修复版本:`fix(rectification): prevent collect focus dead-end after choice answers`(Skill 保持 `10.0.13`)
## BUG-451 | 个人报告整份调用失败掩盖单章失败与截断边界
- 状态:mitigated
- 首次发现:2026-08-30
- 最近更新:2026-08-30
- 影响面:个人报告 worker、`createPersonalReportAgent`、`/reports/[reportId]`、`personal_report_jobs`
- 用户现象:一次模型输出承载整份报告;任一主题写坏或最终 JSON 解析失败,整份报告显示 `report_schema_invalid`,用户看不到已成功生成的主题。生成日志也无法区分模型停止、输出截断和校验失败。
- 触发条件:完整报告包含多个 write 主题,单次 writer 输出超过 provider 默认预算或任一主题未通过绑定/最终 schema 校验。
- 根因:报告 writer 只做整份报告的一次结构化调用加一次修复重试,没有章节级持久化、重试预算或 `finishReason`/token telemetry;`report_document` 也没有可安全表达半成品的中间语义。
- 修复:先上线并采集 `finishReason`、`inputTokens`、`outputTokens`;随后改为串行 plan → section → summary → assemble。每章按 `evidenceRefs` 过滤 bundle 并独立落库/重试,耗尽后生成明确 blocked disclosure;摘要最后只接收章节标题与 `claimStatus`;仅最终 assemble 写 `report_document`,并把 job progress phase/percent 返给等待页。
- 验证:任务 0 真实 staging 观测为 1 次报告失败、`finishReason=length` 占比 0%、`outputTokens` p50/p95 为 3069/3069,失败归因为 report-level parse 而非截断;分章节单元测试、续做/blocked 测试、真实 Docker 数据库 RLS/权限测试均通过;TypeScript、ESLint(0 error)通过。
- 防复发:模型调用必须记录 allowlist 指标字段且不得记录 prompt、模型原文或用户资料;章节中间结果只能写 `personal_report_sections`;章节生成保持串行;摘要不得接收正文全文;blocked 章节必须显式渲染,不能静默丢失;最终成品仍须通过服务端 canonical parse。
- 相关记录:BUG-352
- 复发自:无
- 修复版本:待 staging 部署;本地提交待生成
@@ -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,
}),
};
}
@@ -0,0 +1,182 @@
-- Durable per-section personal report state.
-- Mirrors the Supabase migration with the same schema, constraints, RLS,
-- grants and security-definer transitions.
begin;
-- Existing job rows use the same progress field; extend its check without changing lifecycle ownership.
alter table if exists public.personal_report_jobs
drop constraint if exists personal_report_jobs_progress_phase_check;
alter table if exists public.personal_report_jobs
add constraint personal_report_jobs_progress_phase_check
check (progress_phase ~ '^(?:[a-z][a-z0-9_]{0,63}|section:[a-z][a-z0-9_-]{0,95})$');
create table if not exists public.personal_report_sections (
user_id uuid not null references auth.users(id) on delete cascade,
request_id uuid not null,
section_id text not null check (section_id ~ '^[a-z][a-z0-9_-]{0,95}$'),
payload jsonb,
status text not null default 'pending'
check (status in ('pending', 'ready', 'blocked')),
attempt_count integer not null default 0 check (attempt_count >= 0),
max_attempts integer not null default 2 check (max_attempts between 1 and 10),
last_error_code text check (last_error_code is null or last_error_code ~ '^[a-z][a-z0-9_]{0,63}$'),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
primary key (user_id, request_id, section_id),
constraint personal_report_sections_report_fk
foreign key (user_id, request_id)
references public.personal_reports (user_id, request_id)
on delete cascade,
constraint personal_report_sections_payload_status_check
check ((status = 'ready') = (payload is not null)),
constraint personal_report_sections_attempt_budget_check
check (attempt_count <= max_attempts)
);
create index if not exists personal_report_sections_request_idx
on public.personal_report_sections (user_id, request_id, status, section_id);
alter table public.personal_report_sections enable row level security;
revoke all on table public.personal_report_sections from public, anon, authenticated, service_role;
revoke all on table public.personal_report_sections from app_runtime, admin_runtime, migration_runner, backup_reader;
drop policy if exists personal_report_sections_select_own on public.personal_report_sections;
create policy personal_report_sections_select_own
on public.personal_report_sections
for select
to authenticated
using (auth.uid() = user_id);
grant select on table public.personal_report_sections to authenticated;
grant select, insert, update, delete on table public.personal_report_sections to service_role;
create or replace function public.ensure_personal_report_section(
p_user_id uuid,
p_request_id uuid,
p_section_id text,
p_max_attempts integer default 2
)
returns setof public.personal_report_sections
language plpgsql
security definer
set search_path = pg_catalog, public
as $$
begin
if p_user_id is null or p_request_id is null
or p_section_id !~ '^[a-z][a-z0-9_-]{0,95}$'
or p_max_attempts not between 1 and 10 then
raise exception using errcode = '22023', message = 'personal_report_section_payload_invalid';
end if;
insert into public.personal_report_sections (user_id, request_id, section_id, max_attempts)
values (p_user_id, p_request_id, p_section_id, p_max_attempts)
on conflict (user_id, request_id, section_id) do nothing;
return query
select section.*
from public.personal_report_sections as section
where section.user_id = p_user_id
and section.request_id = p_request_id
and section.section_id = p_section_id;
end;
$$;
create or replace function public.start_personal_report_section(
p_user_id uuid,
p_request_id uuid,
p_section_id text
)
returns setof public.personal_report_sections
language plpgsql
security definer
set search_path = pg_catalog, public
as $$
begin
if p_user_id is null or p_request_id is null
or p_section_id !~ '^[a-z][a-z0-9_-]{0,95}$' then
raise exception using errcode = '22023', message = 'personal_report_section_identity_invalid';
end if;
return query
update public.personal_report_sections as section
set attempt_count = section.attempt_count + 1,
updated_at = clock_timestamp()
where section.user_id = p_user_id
and section.request_id = p_request_id
and section.section_id = p_section_id
and section.status = 'pending'
and section.attempt_count < section.max_attempts
returning section.*;
end;
$$;
create or replace function public.complete_personal_report_section(
p_user_id uuid,
p_request_id uuid,
p_section_id text,
p_payload jsonb
)
returns setof public.personal_report_sections
language plpgsql
security definer
set search_path = pg_catalog, public
as $$
begin
if p_user_id is null or p_request_id is null
or p_section_id !~ '^[a-z][a-z0-9_-]{0,95}$'
or p_payload is null then
raise exception using errcode = '22023', message = 'personal_report_section_completion_invalid';
end if;
return query
update public.personal_report_sections as section
set status = 'ready', payload = p_payload, last_error_code = null, updated_at = clock_timestamp()
where section.user_id = p_user_id
and section.request_id = p_request_id
and section.section_id = p_section_id
and section.status = 'pending'
returning section.*;
end;
$$;
create or replace function public.block_personal_report_section(
p_user_id uuid,
p_request_id uuid,
p_section_id text,
p_error_code text
)
returns setof public.personal_report_sections
language plpgsql
security definer
set search_path = pg_catalog, public
as $$
begin
if p_user_id is null or p_request_id is null
or p_section_id !~ '^[a-z][a-z0-9_-]{0,95}$'
or p_error_code !~ '^[a-z][a-z0-9_]{0,63}$' then
raise exception using errcode = '22023', message = 'personal_report_section_block_invalid';
end if;
return query
update public.personal_report_sections as section
set status = 'blocked', payload = null, last_error_code = p_error_code, updated_at = clock_timestamp()
where section.user_id = p_user_id
and section.request_id = p_request_id
and section.section_id = p_section_id
and section.status = 'pending'
returning section.*;
end;
$$;
revoke all on function public.ensure_personal_report_section(uuid, uuid, text, integer) from public, anon, authenticated;
revoke all on function public.start_personal_report_section(uuid, uuid, text) from public, anon, authenticated;
revoke all on function public.complete_personal_report_section(uuid, uuid, text, jsonb) from public, anon, authenticated;
revoke all on function public.block_personal_report_section(uuid, uuid, text, text) from public, anon, authenticated;
grant execute on function public.ensure_personal_report_section(uuid, uuid, text, integer) to service_role;
grant execute on function public.start_personal_report_section(uuid, uuid, text) to service_role;
grant execute on function public.complete_personal_report_section(uuid, uuid, text, jsonb) to service_role;
grant execute on function public.block_personal_report_section(uuid, uuid, text, text) to service_role;
commit;
@@ -132,6 +132,8 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
"true:f:f",
);
// Existing assertion updated for the requested durable per-section table.
// Original value omitted personal_report_sections because the table did not exist.
assert.equal(
fixture.psql(`
select string_agg(tablename, ',' order by tablename)
@@ -204,6 +206,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
"payment_orders",
"payment_packages",
"personal_report_jobs",
"personal_report_sections",
"personal_reports",
"pricing_experiment_events",
"product_entitlements",
@@ -0,0 +1,156 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import test from "node:test";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
const runnerPath = new URL("../scripts/db-migrate.mjs", import.meta.url).pathname;
const USER_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const USER_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const REPORT_ID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
const REQUEST_ID = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
const SECTION_ID = "theme-career";
function selectAsAuthenticated(userId: string, sql: string): string {
return `
set role authenticated;
select set_config('request.jwt.claim.sub', '${userId}', true);
${sql}
`;
}
function serviceSql(sql: string): string {
return `set role service_role;\n${sql}`;
}
test("personal report sections enforce owner-read RLS and service-owned durable transitions", () => {
const fixture = startPostgresFixture();
const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password");
try {
const migration = spawnSync(process.execPath, [runnerPath], {
encoding: "utf8",
env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl },
});
assert.equal(migration.status, 0, migration.stderr);
assert.match(migration.stdout, /applied 20260830020000_personal_report_sections\.sql/);
fixture.psql(`
insert into identity.users (id, name, email, email_verified, email_verified_at)
values
('${USER_A}', 'Section User A', 'section-a@example.com', true, now()),
('${USER_B}', 'Section User B', 'section-b@example.com', true, now());
insert into public.personal_reports (
id, user_id, request_id, request_fingerprint, report_type, status,
schema_version, presentation_mode, requested_themes, depth,
skill_name, skill_version, skill_source_commit, skill_snapshot_sha256
) values (
'${REPORT_ID}', '${USER_A}', '${REQUEST_ID}', '1111111111111111111111111111111111111111111111111111111111111111', 'personal_full', 'generating',
'report_document.v2', 'default', array['career']::text[], 'standard',
'jyotish-personal-report', '1.0.0', '2222222222222222222222222222222222222222', '3333333333333333333333333333333333333333333333333333333333333333'
);
`);
const own = fixture.psqlAs(
"app_runtime",
"app-runtime-test-password",
selectAsAuthenticated(USER_A, `
select count(*) from public.personal_report_sections
where user_id = '${USER_A}' and request_id = '${REQUEST_ID}'
`),
);
assert.equal(own, `SET\n${USER_A}\n0`);
fixture.psqlAs(
"service_runtime",
"service-runtime-test-password",
serviceSql(`
select status from public.ensure_personal_report_section(
'${USER_A}', '${REQUEST_ID}', '${SECTION_ID}', 2
);
select status from public.ensure_personal_report_section(
'${USER_A}', '${REQUEST_ID}', '${SECTION_ID}', 2
);
`),
);
assert.equal(
fixture.psql(`
select count(*) from public.personal_report_sections
where user_id = '${USER_A}' and request_id = '${REQUEST_ID}' and section_id = '${SECTION_ID}'
`),
"1",
);
assert.equal(
fixture.psqlAs(
"app_runtime",
"app-runtime-test-password",
selectAsAuthenticated(USER_A, `
select status || ':' || attempt_count from public.personal_report_sections
where user_id = '${USER_A}' and request_id = '${REQUEST_ID}' and section_id = '${SECTION_ID}'
`),
),
`SET\n${USER_A}\npending:0`,
);
assert.equal(
fixture.psqlAs(
"app_runtime",
"app-runtime-test-password",
selectAsAuthenticated(USER_B, `
select count(*) from public.personal_report_sections
where user_id = '${USER_A}' and request_id = '${REQUEST_ID}'
`),
),
`SET\n${USER_B}\n0`,
);
assert.throws(
() => fixture.psqlAs(
"app_runtime",
"app-runtime-test-password",
selectAsAuthenticated(USER_A, `
insert into public.personal_report_sections (user_id, request_id, section_id)
values ('${USER_A}', '${REQUEST_ID}', 'theme-wealth');
`),
),
/permission denied for table personal_report_sections/,
);
assert.equal(
fixture.psqlAs(
"service_runtime",
"service-runtime-test-password",
serviceSql(`
select status || ':' || attempt_count
from public.start_personal_report_section('${USER_A}', '${REQUEST_ID}', '${SECTION_ID}');
`),
),
"SET\npending:1",
);
assert.equal(
fixture.psqlAs(
"service_runtime",
"service-runtime-test-password",
serviceSql(`
select status || ':' || attempt_count
from public.complete_personal_report_section(
'${USER_A}', '${REQUEST_ID}', '${SECTION_ID}', '{"title":"事业"}'::jsonb
);
`),
),
"SET\nready:1",
);
assert.equal(
fixture.psqlAs(
"service_runtime",
"service-runtime-test-password",
serviceSql(`
select count(*) from public.start_personal_report_section('${USER_A}', '${REQUEST_ID}', '${SECTION_ID}');
`),
),
"SET\n0",
);
} finally {
fixture.stop();
}
});
@@ -16,8 +16,10 @@ import {
classifyReportSchemaInnerReason,
generatePersonalReport,
type GeneratePersonalReportResult,
type GeneratePersonalReportDeps,
} from "../src/lib/personal-report-generation.ts";
import type { PersonalReportSectionPlan } from "../src/lib/personal-report-plan.ts";
import type { PersonalReportSectionRecord, PersonalReportSectionService } from "../src/lib/personal-report-section-service-core.ts";
import type {
PersonalReportAgentOutput,
ReportAgentPort,
@@ -203,6 +205,58 @@ function writerOutputFor(
};
}
function inMemorySectionService(
initial: readonly PersonalReportSectionRecord[] = [],
): PersonalReportSectionService {
const rows = new Map(initial.map((row) => [row.sectionId, { ...row }]));
const timestamp = GENERATED_AT;
return {
async ensure(input) {
const existing = rows.get(input.sectionId);
if (existing) return existing;
const created: PersonalReportSectionRecord = {
userId: input.userId,
requestId: input.requestId,
sectionId: input.sectionId,
payload: null,
status: "pending",
attemptCount: 0,
maxAttempts: input.maxAttempts,
lastErrorCode: null,
createdAt: timestamp,
updatedAt: timestamp,
};
rows.set(input.sectionId, created);
return created;
},
async list() {
return [...rows.values()];
},
async start(input) {
const current = rows.get(input.sectionId);
if (!current || current.status !== "pending" || current.attemptCount >= current.maxAttempts) return null;
const next = { ...current, attemptCount: current.attemptCount + 1, updatedAt: timestamp };
rows.set(input.sectionId, next);
return next;
},
async complete(input) {
const current = rows.get(input.sectionId);
if (!current || current.status !== "pending") return null;
const next = { ...current, status: "ready" as const, payload: input.payload, updatedAt: timestamp };
rows.set(input.sectionId, next);
return next;
},
async block(input) {
const current = rows.get(input.sectionId);
if (!current || current.status !== "pending") return null;
const next = { ...current, status: "blocked" as const, payload: null, lastErrorCode: input.errorCode, updatedAt: timestamp };
rows.set(input.sectionId, next);
return next;
},
};
}
function fakeWriter(
produce: (bundle: ReportEvidenceBundleV2, plan: PersonalReportSectionPlan) => PersonalReportAgentOutput,
observed?: { calls: number; plan: PersonalReportSectionPlan | null },
@@ -433,7 +487,7 @@ test("writer extra, duplicate and blocked themes are rejected", async (t) => {
id: "theme-wealth",
theme: "wealth",
title: "财富结构",
evidenceRefs: ["ev-tech-d2", "ev-tech-d11"],
evidenceRefs: ["ev-tech-d11", "ev-tech-d2"],
claimStatus: "blocked",
}],
}),
@@ -610,3 +664,160 @@ test("deterministic guard rejection and final schema rejection remain distinct t
expectSchemaRejected(result, "final_parse_rejected");
});
});
function sectionPayloadFor(
bundle: ReportEvidenceBundleV2,
section: Readonly<{ id: string; theme: string | null; evidenceRefs: readonly string[] }>,
): PersonalReportAgentOutput["thematicNarrative"][number] {
assert.ok(section.theme);
const card = bundle.claimCards.find((entry) => entry.theme === section.theme);
assert.ok(card);
return {
id: section.id,
theme: section.theme,
title: card.section,
narrative: card.conclusion,
actions: [`围绕${card.section}记录可验证的现实反馈`],
caveats: ["该结论不得脱离所列证据引用。"],
claimStatus: card.assertionLevel,
evidenceRefs: [...section.evidenceRefs],
};
}
function sectionedAgent(input: Readonly<{
bundleCalls?: Array<readonly string[]>;
titles?: string[][];
sectionCalls?: string[];
failThemes?: ReadonlySet<string>;
abortAfterTheme?: string;
signalToAbort?: AbortController;
}>): ReportAgentPort {
return {
modelId: "sectioned-test-writer",
async generate() {
throw new Error("legacy generate must not be used for sectioned reports");
},
async generateSection(bundle, section, completedTitles, options) {
input.bundleCalls?.push(bundle.evidenceRefs.map((entry) => entry.id));
input.titles?.push([...completedTitles]);
input.sectionCalls?.push(section.theme ?? section.id);
if (input.abortAfterTheme === section.theme) {
input.signalToAbort?.abort();
const error = new Error("aborted");
error.name = "AbortError";
throw error;
}
if (input.failThemes?.has(section.theme ?? "")) {
throw new Error("section_output_invalid");
}
const output = sectionPayloadFor(bundle, section);
options?.assertWriterOutput?.(output);
return output;
},
async generateSummary(sections) {
return {
headline: "正式证据支持多个主题的审慎方向性判断",
summary: `已完成主题:${sections.map((section) => section.title).join("、")}`,
priorities: ["先核对最重要的现实问题", "再观察方向性线索是否与经历一致"],
};
},
};
}
function runSectioned(
bundle: ReportEvidenceBundleV2,
agent: ReportAgentPort,
sectionService: PersonalReportSectionService,
onProgress?: GeneratePersonalReportDeps["onProgress"],
): Promise<GeneratePersonalReportResult> {
return generatePersonalReport({
reportId: REPORT_ID,
userId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
requestId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
bundle,
depth: "research",
agent,
sectionService,
onProgress,
now: () => new Date(GENERATED_AT),
});
}
test("sectioned generation makes one filtered call per write theme, then summary", async () => {
const bundle = makeBundle({ themes: fullThemes.slice(0, 3), charts: [chart("D1"), chart("D2", 1), chart("D9", 2), chart("D10", 3), chart("D11", 4)] });
const bundleCalls: Array<readonly string[]> = [];
const sectionCalls: string[] = [];
const titles: string[][] = [];
const result = await runSectioned(
bundle,
sectionedAgent({ bundleCalls, sectionCalls, titles }),
inMemorySectionService(),
);
const document = readyV2(result);
assert.deepEqual(sectionCalls, ["career", "marriage", "wealth"]);
assert.equal(bundleCalls.length, 3);
assert.deepEqual(bundleCalls, [
["ev-tech-d1", "ev-tech-d10"],
["ev-tech-d1", "ev-tech-d9"],
["ev-tech-d11", "ev-tech-d2"],
]);
assert.deepEqual(titles, [[], ["事业与方向"], ["事业与方向", "关系与婚恋"]]);
assert.deepEqual(document.thematicNarrative.map((section) => section.theme), ["career", "marriage", "wealth"]);
assert.match(document.executiveSummary.summary, /事业与方向/);
});
test("sectioned resume skips ready sections after an interruption", async () => {
const bundle = makeBundle({ themes: fullThemes, charts: [chart("D1"), chart("D2", 1), chart("D9", 2), chart("D10", 3), chart("D11", 4), chart("D24", 5)] });
const service = inMemorySectionService();
const firstRunCalls: string[] = [];
const firstController = new AbortController();
await assert.rejects(
() => runSectioned(
bundle,
sectionedAgent({ sectionCalls: firstRunCalls, abortAfterTheme: "wealth", signalToAbort: firstController }),
service,
),
(error: unknown) => error instanceof Error && error.name === "AbortError",
);
const resumedCalls: string[] = [];
const result = await runSectioned(bundle, sectionedAgent({ sectionCalls: resumedCalls }), service);
readyV2(result);
assert.deepEqual(firstRunCalls, ["career", "education", "marriage", "wealth"]);
assert.deepEqual(resumedCalls, ["wealth"]);
});
test("a section becomes blocked after its own retry budget while other sections still deliver", async () => {
const bundle = makeBundle({ themes: fullThemes.slice(0, 3), charts: [chart("D1"), chart("D2", 1), chart("D9", 2), chart("D10", 3), chart("D11", 4)] });
const calls: string[] = [];
const progress: Array<Readonly<{ phase: string; completed: number; total: number }>> = [];
const result = await runSectioned(
bundle,
sectionedAgent({ sectionCalls: calls, failThemes: new Set(["marriage"]) }),
inMemorySectionService(),
(value) => { progress.push(value); },
);
const document = readyV2(result);
assert.deepEqual(calls, ["career", "marriage", "marriage", "wealth"]);
assert.deepEqual(document.thematicNarrative.map((section) => section.theme), ["career", "wealth"]);
assert.equal(document.blockedConflictDisclosure.length, 1);
assert.match(document.blockedConflictDisclosure[0].reason, /未能生成/);
assert.deepEqual(progress.map((entry) => entry.completed), [1, 2, 3, 3, 3]);
});
test("all blocked sections fail before summary", async () => {
const bundle = makeBundle({ themes: fullThemes.slice(0, 2), charts: [chart("D1"), chart("D9", 2)] });
let summaryCalls = 0;
const agent = sectionedAgent({ failThemes: new Set(["career", "marriage"]) });
const result = await runSectioned(bundle, {
...agent,
async generateSummary() {
summaryCalls += 1;
throw new Error("summary should not run");
},
}, inMemorySectionService());
assert.deepEqual(result, { status: "failed", failureCode: "report_schema_invalid", innerReason: "all_sections_blocked" });
assert.equal(summaryCalls, 0);
});
@@ -98,9 +98,11 @@ test("elapsed wait is rendered in Simplified Chinese minutes and seconds", () =>
assert.equal(formatWaitedDuration(130_000), "2 分 10 秒");
assert.equal(formatWaitedDuration(-5), "0 秒");
assert.match(pageSource, /已等待 \{formatWaitedDuration\(waitedMs\)\}/);
const generatingAt = pageSource.indexOf('{generating ? "报告正在生成中,请稍候…"');
assert.ok(generatingAt >= 0, "the generating spinner still exists");
assert.match(pageSource.slice(generatingAt, generatingAt + 700), /已等待 \{formatWaitedDuration\(waitedMs\)\}/);
// Original assertion required the fixed generating copy. Task 4 intentionally
// replaces it with phase-aware progress text while retaining the spinner.
assert.match(pageSource, /<InlineSpinner className="text-primary" size=\{32\} \/>/);
assert.match(pageSource, /generating \? \(progressLabel \?\? "报告正在生成中,请稍候…"\)/);
assert.match(pageSource, /已等待 \{formatWaitedDuration\(waitedMs\)\}/);
});
test("the shared hook pauses on hidden, refreshes on visible and always cleans up", () => {