Files
Jyotisha/frontend/src/lib/personal-report-plan.ts
T
2026-08-15 05:10:37 +08:00

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);
}