189 lines
7.0 KiB
TypeScript
189 lines
7.0 KiB
TypeScript
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<ReportDepth, Readonly<{ min: number; max: number }>> = {
|
|
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<ReportSectionKind, "thematic">,
|
|
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<string>();
|
|
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);
|
|
}
|