import { z } from "zod"; import type { ReportEvidenceBundleV2 } from "./report-evidence-bundle-v2.ts"; export const REPORT_DEPTHS = ["concise", "standard", "deep", "research"] as const; export type ReportDepth = (typeof REPORT_DEPTHS)[number]; export const REPORT_SECTION_KINDS = [ "executive_summary", "natal_foundation", "thematic", "current_phase", "action_notes", "charts", "claim_evidence_appendix", "blocked_conflict_disclosure", "provenance", "disclaimer", ] as const; export type ReportSectionKind = (typeof REPORT_SECTION_KINDS)[number]; export type ReportSectionPlanEntry = Readonly<{ id: string; kind: ReportSectionKind; theme: string | null; disposition: "write" | "blocked"; evidenceRefs: readonly string[]; targetCharacters: Readonly<{ min: number; max: number }>; }>; export type PersonalReportSectionPlan = Readonly<{ schemaVersion: "personal_report_section_plan.v1"; depth: ReportDepth; sections: readonly ReportSectionPlanEntry[]; }>; const characterTargets: Record> = { concise: { min: 120, max: 500 }, standard: { min: 260, max: 1_200 }, deep: { min: 480, max: 2_000 }, research: { min: 600, max: 2_800 }, }; const reportSectionPlanEntrySchema = z.object({ id: z.string().regex(/^[a-z][a-z0-9_-]{0,95}$/), kind: z.enum(REPORT_SECTION_KINDS), theme: z.string().regex(/^[a-z][a-z0-9_.-]{0,95}$/).nullable(), disposition: z.enum(["write", "blocked"]), evidenceRefs: z.array(z.string().regex(/^ev-[a-z0-9_-]{1,63}$/)).max(80), targetCharacters: z.object({ min: z.number().int().min(0).max(10_000), max: z.number().int().min(1).max(10_000), }).strict(), }).strict().superRefine((entry, context) => { if (entry.targetCharacters.min > entry.targetCharacters.max) { context.addIssue({ code: z.ZodIssueCode.custom, message: "report_plan_character_range_invalid" }); } if (entry.kind === "thematic" && entry.theme === null) { context.addIssue({ code: z.ZodIssueCode.custom, message: "report_plan_theme_missing" }); } if (entry.kind !== "thematic" && entry.theme !== null) { context.addIssue({ code: z.ZodIssueCode.custom, message: "report_plan_theme_unexpected" }); } }); export const personalReportSectionPlanSchema = z.object({ schemaVersion: z.literal("personal_report_section_plan.v1"), depth: z.enum(REPORT_DEPTHS), sections: z.array(reportSectionPlanEntrySchema).min(1).max(40), }).strict(); function uniqueSorted(values: readonly string[]): string[] { return [...new Set(values)].sort(); } function fixedSection( id: string, kind: Exclude, depth: ReportDepth, evidenceRefs: readonly string[] = [], ): ReportSectionPlanEntry { return { id, kind, theme: null, disposition: "write", evidenceRefs: uniqueSorted(evidenceRefs), targetCharacters: characterTargets[depth], }; } export function validatePersonalReportSectionPlan( input: unknown, bundle: ReportEvidenceBundleV2, ): PersonalReportSectionPlan { const plan = personalReportSectionPlanSchema.parse(input); const ids = new Set(); for (const section of plan.sections) { if (ids.has(section.id)) throw new Error(`report_plan_duplicate_section:${section.id}`); ids.add(section.id); } const requiredKinds: ReportSectionKind[] = [ "executive_summary", "natal_foundation", "action_notes", "charts", "claim_evidence_appendix", "blocked_conflict_disclosure", "provenance", "disclaimer", ]; for (const kind of requiredKinds) { if (plan.sections.filter((section) => section.kind === kind).length !== 1) { throw new Error(`report_plan_required_section_invalid:${kind}`); } } const thematic = plan.sections.filter((section) => section.kind === "thematic"); const bundleEvidenceRefs = new Set(bundle.evidenceRefs.map((entry) => entry.id)); for (const theme of bundle.requestedThemes) { const matches = thematic.filter((section) => section.theme === theme); if (matches.length !== 1) throw new Error(`report_plan_theme_coverage_invalid:${theme}`); const hasClaim = bundle.claimCards.some((card) => card.theme === theme); const hasBlocked = bundle.blockedSections.some((section) => section.theme === theme); const expected = hasClaim ? "write" : "blocked"; if (hasClaim === hasBlocked || matches[0].disposition !== expected) { throw new Error(`report_plan_theme_disposition_invalid:${theme}`); } } if (thematic.some((section) => !section.theme || !bundle.requestedThemes.includes(section.theme))) { throw new Error("report_plan_unrequested_theme"); } for (const section of plan.sections) { for (const ref of section.evidenceRefs) { if (!bundleEvidenceRefs.has(ref)) throw new Error(`report_plan_dangling_evidence_ref:${ref}`); } } return plan; } export function buildPersonalReportSectionPlan( bundle: ReportEvidenceBundleV2, depth: ReportDepth, ): PersonalReportSectionPlan { const themeSections = bundle.requestedThemes.map((theme): ReportSectionPlanEntry => { const card = bundle.claimCards.find((entry) => entry.theme === theme); const blocked = bundle.blockedSections.find((entry) => entry.theme === theme); return { id: `theme-${theme.replaceAll(".", "-")}`, kind: "thematic", theme, disposition: card ? "write" : "blocked", evidenceRefs: card ? uniqueSorted(card.executedTechniqueRefs) : uniqueSorted(blocked?.missingTechniqueRefs ?? []), targetCharacters: characterTargets[depth], }; }); const timingEvidenceRefs = uniqueSorted([ ...bundle.claimCards.filter((card) => card.theme === "timing").flatMap((card) => card.executedTechniqueRefs), ...bundle.blockedSections.filter((section) => section.theme === "timing").flatMap((section) => section.missingTechniqueRefs), ]); const sections: ReportSectionPlanEntry[] = [ fixedSection("executive-summary", "executive_summary", depth), fixedSection("natal-foundation", "natal_foundation", depth), ...themeSections, ...(bundle.requestedThemes.includes("timing") ? [{ ...fixedSection("current-phase", "current_phase", depth, timingEvidenceRefs), disposition: bundle.claimCards.some((card) => card.theme === "timing") ? "write" as const : "blocked" as const, }] : []), fixedSection("action-notes", "action_notes", depth), fixedSection("charts", "charts", depth), fixedSection("claim-evidence-appendix", "claim_evidence_appendix", depth, bundle.evidenceRefs.map((entry) => entry.id)), fixedSection("blocked-conflict-disclosure", "blocked_conflict_disclosure", depth, [ ...bundle.blockedSections.flatMap((section) => section.missingTechniqueRefs), ...bundle.conflicts.flatMap((conflict) => conflict.techniqueRefs), ]), fixedSection("provenance", "provenance", depth), fixedSection("disclaimer", "disclaimer", depth), ]; return validatePersonalReportSectionPlan({ schemaVersion: "personal_report_section_plan.v1", depth, sections, }, bundle); }