diff --git a/PROGRESS-report-sectioned-20260830.md b/PROGRESS-report-sectioned-20260830.md index 003e7694..cb426d6f 100644 --- a/PROGRESS-report-sectioned-20260830.md +++ b/PROGRESS-report-sectioned-20260830.md @@ -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: × 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. diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index abdc0ad7..be202061 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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 部署;本地提交待生成 diff --git a/frontend/src/app/api/reports/[reportId]/route.ts b/frontend/src/app/api/reports/[reportId]/route.ts index 60b3f560..be2d5f42 100644 --- a/frontend/src/app/api/reports/[reportId]/route.ts +++ b/frontend/src/app/api/reports/[reportId]/route.ts @@ -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 diff --git a/frontend/src/components/personal-report/personal-report-page.tsx b/frontend/src/components/personal-report/personal-report-page.tsx index 1aab4cb2..138559ab 100644 --- a/frontend/src/components/personal-report/personal-report-page.tsx +++ b/frontend/src/components/personal-report/personal-report-page.tsx @@ -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 }) {

- {generating ? "报告正在生成中,请稍候…" : "正在加载报告…"} + {generating ? (progressLabel ?? "报告正在生成中,请稍候…") : "正在加载报告…"}

{generating && ( <> diff --git a/frontend/src/lib/personal-report-generation.ts b/frontend/src/lib/personal-report-generation.ts index 39a555b9..fffcaf93 100644 --- a/frontend/src/lib/personal-report-generation.ts +++ b/frontend/src/lib/personal-report-generation.ts @@ -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> = { @@ -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(); @@ -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; }>; 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 { + 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) ); diff --git a/frontend/src/lib/personal-report-job-state.ts b/frontend/src/lib/personal-report-job-state.ts index 7193d533..6f9f4794 100644 --- a/frontend/src/lib/personal-report-job-state.ts +++ b/frontend/src/lib/personal-report-job-state.ts @@ -37,7 +37,7 @@ const transitionTargets = { } as const satisfies Record; 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" diff --git a/frontend/src/lib/personal-report-route-core.ts b/frontend/src/lib/personal-report-route-core.ts index 76ca6bc4..d3af39b8 100644 --- a/frontend/src/lib/personal-report-route-core.ts +++ b/frontend/src/lib/personal-report-route-core.ts @@ -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; }>; export async function resolveReportRead(deps: ReportReadCoreDeps): Promise { @@ -533,6 +535,7 @@ export async function resolveReportRead(deps: ReportReadCoreDeps): Promise; + +type SectionIdentity = Readonly<{ userId: string; requestId: string; sectionId: string }>; + +export type PersonalReportSectionService = Readonly<{ + ensure(input: SectionIdentity & { maxAttempts: number }): Promise; + list(userId: string, requestId: string): Promise; + start(input: SectionIdentity): Promise; + complete(input: SectionIdentity & { payload: PersonalReportSectionPayload }): Promise; + block(input: SectionIdentity & { errorCode: string }): Promise; +}>; + +export type PersonalReportSectionQueryResult = Readonly<{ + data: unknown; + error: Readonly<{ message: string; code?: string }> | null; +}>; + +type QueryBuilder = PromiseLike & { + select(columns: string): QueryBuilder; + eq(column: string, value: unknown): QueryBuilder; + order(column: string, options?: Readonly<{ ascending?: boolean }>): QueryBuilder; + maybeSingle(): PromiseLike; +}; + +type DataClient = { + from(table: string): QueryBuilder; + rpc(functionName: string, args?: Readonly>): PromiseLike; +}; + +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; +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); + }, + }; +} diff --git a/frontend/src/lib/personal-report-section-service.ts b/frontend/src/lib/personal-report-section-service.ts new file mode 100644 index 00000000..28ef4597 --- /dev/null +++ b/frontend/src/lib/personal-report-section-service.ts @@ -0,0 +1,3 @@ +import "server-only"; + +export * from "./personal-report-section-service-core"; diff --git a/frontend/src/lib/personal-report-worker-core.ts b/frontend/src/lib/personal-report-worker-core.ts index 66d60ca4..95088cd2 100644 --- a/frontend/src/lib/personal-report-worker-core.ts +++ b/frontend/src/lib/personal-report-worker-core.ts @@ -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; }>; export type PersonalReportWorkerJobPort = Pick< @@ -75,6 +78,7 @@ export type PersonalReportWorkerDeps = Readonly<{ generate: ( context: PersonalReportWorkerGenerationContext, ) => Promise; + 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") { diff --git a/frontend/src/lib/personal-report-worker.ts b/frontend/src/lib/personal-report-worker.ts index 18da6f12..d23c9630 100644 --- a/frontend/src/lib/personal-report-worker.ts +++ b/frontend/src/lib/personal-report-worker.ts @@ -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") diff --git a/frontend/src/mastra/personal-report.ts b/frontend/src/mastra/personal-report.ts index ff7bc4a3..315694d9 100644 --- a/frontend/src/mastra/personal-report.ts +++ b/frontend/src/mastra/personal-report.ts @@ -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; +export type PersonalReportThematicNarrative = Readonly<{ + id: string; + theme: string; + title: string; + narrative: string; + actions: string[]; + caveats: string[]; + claimStatus: z.infer; + 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; + +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; + +export const personalReportAgentOutputSchema = z.object({ + executiveSummary: personalReportExecutiveSummarySchema, + thematicNarrative: z.array(personalReportThematicNarrativeSchema).max(12), +}).strict() as unknown as z.ZodType; + +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) => 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; + generateSection?: ( + bundle: ReportEvidenceBundleV2, + section: ReportSectionPlanEntry, + completedTitles: readonly string[], + options?: ReportAgentSectionOptions, + ) => Promise>; + generateSummary?: ( + sections: readonly Readonly<{ title: string; claimStatus: string }>[], + options?: ReportAgentSummaryOptions, + ) => Promise>; }>; -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 (input: Readonly<{ + prompt: string; + schema: z.ZodType; + signal?: AbortSignal; + maxOutputTokens?: number; + accept: (value: T) => void; + }>): Promise => { + 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, + }), }; } diff --git a/frontend/supabase/migrations/20260830020000_personal_report_sections.sql b/frontend/supabase/migrations/20260830020000_personal_report_sections.sql new file mode 100644 index 00000000..19da8e4c --- /dev/null +++ b/frontend/supabase/migrations/20260830020000_personal_report_sections.sql @@ -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; diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts index 02b3a1b5..e2667029 100644 --- a/frontend/tests/database-local-business.test.ts +++ b/frontend/tests/database-local-business.test.ts @@ -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", diff --git a/frontend/tests/database-personal-report-sections.test.ts b/frontend/tests/database-personal-report-sections.test.ts new file mode 100644 index 00000000..5ff8bc2d --- /dev/null +++ b/frontend/tests/database-personal-report-sections.test.ts @@ -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(); + } +}); diff --git a/frontend/tests/personal-report-generation-v2.test.ts b/frontend/tests/personal-report-generation-v2.test.ts index 3ed435b8..e1afb5a0 100644 --- a/frontend/tests/personal-report-generation-v2.test.ts +++ b/frontend/tests/personal-report-generation-v2.test.ts @@ -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; + titles?: string[][]; + sectionCalls?: string[]; + failThemes?: ReadonlySet; + 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 { + 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 = []; + 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> = []; + 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); +}); diff --git a/frontend/tests/report-polling-contract.test.ts b/frontend/tests/report-polling-contract.test.ts index c2ca991e..ab74f1cd 100644 --- a/frontend/tests/report-polling-contract.test.ts +++ b/frontend/tests/report-polling-contract.test.ts @@ -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, //); + assert.match(pageSource, /generating \? \(progressLabel \?\? "报告正在生成中,请稍候…"\)/); + assert.match(pageSource, /已等待 \{formatWaitedDuration\(waitedMs\)\}/); }); test("the shared hook pauses on hidden, refreshes on visible and always cleans up", () => {