import { createHash } from "node:crypto"; import { computeEvidenceHash, safeParseServerReportDocument, } from "./personal-report-contract.server-core.ts"; import type { ClaimStatus, EvidenceAppendix, ReportDepth, ReportDocumentV1, ReportDocumentV2, } from "./personal-report-contract.ts"; import type { EvidenceRefStatus, PersonalReportAgentOutput, ReportAgentPort, ReportEvidenceBundleV2, ReportEvidencePacket, ReportPlanetFact, } from "@/mastra/personal-report"; import type { EvidenceConflict, ReportChartFact, ReportClaimCard, SafeReportSubject, TechniqueExecutionReceipt, } from "./report-evidence-bundle-v2.ts"; 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"; // 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"; /** * Personal report generation: workflow evidence -> minimal packet -> report * agent -> candidate document -> deterministic guard -> canonical server * parse. * * Contract and persistence are the canonical shared modules (p3): * - `personal-report-contract.ts` (isomorphic) + `personal-report-contract.server-core.ts` * / `personal-report-contract.server.ts` (server hash + parse entry). * - `personal-report-service-core.ts` / `personal-report-service.ts` * (persistence, fingerprint idempotency). * * This module never duplicates the schema and never falls back to * mock/example/random/sample data. Missing real evidence fails closed. * Stable API codes live in the dependency-free ./personal-report-codes.ts. */ // --------------------------------------------------------------------------- // Stable failure codes live in ./personal-report-codes.ts (imported above). // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Canonical serialization + fingerprints // --------------------------------------------------------------------------- export function canonicalSerialize(value: unknown): string { if (value === undefined) return "null"; if (Array.isArray(value)) { return `[${value.map(canonicalSerialize).join(",")}]`; } if (value !== null && typeof value === "object") { const source = value as Record; const keys = Object.keys(source).sort(); return `{${keys .map((key) => `${JSON.stringify(key)}:${canonicalSerialize(source[key])}`) .join(",")}}`; } return JSON.stringify(value); } export function sha256Hex(text: string): string { return createHash("sha256").update(text).digest("hex"); } const sha256Pattern = /^[0-9a-f]{64}$/; /** * Canonical request fingerprint for idempotency. Represents ONLY the request * payload (reportType, presentationMode, sorted/deduped themes, sessionId, * chartProfileId). requestId is deliberately excluded: (user_id, request_id) * is already the unique key and the fingerprint exists solely to detect a * different payload under the same requestId (409 report_request_conflict). */ export function computeRequestFingerprint(input: Readonly<{ reportType: string; presentationMode: string; depth: string; themes: readonly string[]; sessionId: string | null; chartProfileId: string | null; }>): string { return sha256Hex(canonicalSerialize({ reportType: input.reportType, presentationMode: input.presentationMode, depth: input.depth, themes: [...new Set(input.themes)].sort(), sessionId: input.sessionId, chartProfileId: input.chartProfileId, })); } // --------------------------------------------------------------------------- // Skill snapshot provenance (real server-side value, never "unknown") // --------------------------------------------------------------------------- export type SkillSnapshot = Readonly<{ name: string; version: string; sha256: string; sourceCommit: string | null; }>; export class SkillSnapshotUnavailableError extends Error { readonly code = "calculation_unavailable"; constructor(reason: string) { super(`Skill snapshot unavailable: ${reason}`); this.name = "SkillSnapshotUnavailableError"; } } let cachedSkillSnapshot: SkillSnapshot | null = null; /** Resolve report provenance from the verified active commercial package. */ export function resolveSkillSnapshot(): SkillSnapshot { if (cachedSkillSnapshot) return cachedSkillSnapshot; try { const identity = resolveActiveSkillPackage("jyotish-personal-report"); cachedSkillSnapshot = { name: identity.name, version: identity.version, sha256: identity.sha256, sourceCommit: identity.sourceCommit, }; return cachedSkillSnapshot; } catch (error) { throw new SkillSnapshotUnavailableError( error instanceof Error ? error.message : "registry resolution failed", ); } } // --------------------------------------------------------------------------- // Allowlist evidence packet builder (workflow response -> minimal packet) // --------------------------------------------------------------------------- type JsonRecord = Record; function record(value: unknown): JsonRecord | null { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : null; } function text(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function finiteNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } function booleanValue(value: unknown): boolean | null { return typeof value === "boolean" ? value : null; } function stringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.map(text).filter((item): item is string => item !== null); } const SIGNS = [ "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", ] as const; const SIGN_INDEX = new Map(SIGNS.map((sign, index) => [sign, index])); const SIGN_INDEX_CN = new Map([ ["白羊座", 0], ["金牛座", 1], ["双子座", 2], ["巨蟹座", 3], ["狮子座", 4], ["处女座", 5], ["天秤座", 6], ["天蝎座", 7], ["射手座", 8], ["摩羯座", 9], ["水瓶座", 10], ["双鱼座", 11], ]); function signIndex(value: unknown): number | null { if (typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 11) return value; if (typeof value === "string") { return SIGN_INDEX.get(value) ?? SIGN_INDEX_CN.get(value) ?? null; } return null; } /** * Mirrors the orchestrator's base_chart selection exactly: * modules.chart (dict) -> chart_data.chart (nested) -> chart_data itself. */ function resolveBaseChart(chartData: JsonRecord): JsonRecord { const modules = record(chartData.modules); const modulesChart = modules ? record(modules.chart) : null; if (modulesChart) return modulesChart; const nested = record(chartData.chart); if (nested) return nested; return chartData; } /** * Planets accept the real engine's object map ({Sun: {...}, ...}) and the * legacy array shape. Absolute longitude comes from degree_raw / longitude_deg * / lon / absolute_degree / degree (the engine's planet degree is the absolute * 0-360 longitude; degree_in_sign is the in-sign offset and is NOT used here). * Entries without a complete fact set are skipped (allowlist of full facts). */ function readPlanets(value: unknown): ReportPlanetFact[] { const planets: ReportPlanetFact[] = []; const entries: Readonly<[string, unknown]>[] = Array.isArray(value) ? value.map((item, index) => [String(index), item] as const) : Object.entries(record(value) ?? {}); for (const [key, item] of entries) { const row = record(item); if (!row) continue; const id = text(row?.id ?? row?.name ?? row?.planet) ?? key; const sign = text(row?.sign ?? row?.sign_name); const degree = finiteNumber( row?.degree_raw ?? row?.longitude_deg ?? row?.lon ?? row?.absolute_degree ?? row?.degree, ); if (!sign || degree === null) continue; planets.push({ id, sign, degree, house: finiteNumber(row?.house ?? row?.house_number), retrograde: booleanValue(row?.retrograde ?? row?.is_retrograde), }); } return planets; } /** * Houses accept the real engine's object map ({house_1: {cusp_sign, ...}}) and * the legacy array shape ({number, sign}). The real map has NO whole-sign * `sign` field (only Placidus cusp_sign); house signs are therefore derived * deterministically from the ascendant sign + house number (whole-sign, * matching the engine's own whole-sign planet-house numbering) and marked * signDerived. Array entries that carry a real sign keep it. Occupants are * filled from planet whole-sign house numbers. */ function readHouses( value: unknown, ascendantSignIndex: number | null, planets: readonly ReportPlanetFact[], ): ReportEvidencePacket["chart"]["houses"] { const rows: Readonly<{ number: number; sign: string | null }>[] = []; if (Array.isArray(value)) { for (const item of value) { const row = record(item); const number = finiteNumber(row?.number ?? row?.house ?? row?.index ?? row?.house_number); if (number === null) continue; rows.push({ number, sign: text(row?.sign ?? row?.sign_name) }); } } else { const map = record(value) ?? {}; for (const [key, item] of Object.entries(map)) { const number = /^house_(\d{1,2})$/.exec(key)?.[1] ?? (/^\d{1,2}$/.test(key) ? key : null); if (!number) continue; const parsed = Number.parseInt(number, 10); if (parsed < 1 || parsed > 12) continue; const row = record(item); rows.push({ number: parsed, sign: row ? text(row.sign ?? row.sign_name) : null }); } } const houses: ReportEvidencePacket["chart"]["houses"] = rows.map((row) => { const realSign = row.sign; const derivedSign = ascendantSignIndex !== null ? SIGNS[((ascendantSignIndex + row.number - 1) % 12 + 12) % 12] : null; const sign = realSign ?? derivedSign; if (!sign) return { number: row.number, sign: "", signDerived: true, occupants: [] }; return { number: row.number, sign, signDerived: realSign === null, occupants: planets .filter((planet) => planet.house === row.number) .map((planet) => planet.id) .slice(0, 12), }; }); return houses; } /** * Divisional charts in the real engine live under modules.varga_full with keys * D9_Navamsa / D10_Dasamsa (or D9 / D10). Each varga is {Ascendant: {sign_idx| * sign}, : {sign_idx|sign}, _meta, _dignity, ...} — there are no house * arrays. House signs are derived whole-sign from the divisional ascendant and * occupants from each planet's sign index (the same derivation the engine uses * for D11). Nothing is fabricated; missing varga data simply omits the chart. */ function readVargaHouses( vargaFull: JsonRecord | null, ): ReportEvidencePacket["chart"]["vargaHouses"] { if (!vargaFull) return []; const result: { id: "D9" | "D10"; houses: ReportEvidencePacket["chart"]["houses"] }[] = []; const variants: Readonly> = { D9: ["D9_Navamsa", "D9"], D10: ["D10_Dasamsa", "D10"], }; for (const [id, keys] of Object.entries(variants) as ReadonlyArray) { const varga = keys .map((key) => record(vargaFull[key])) .find((entry): entry is JsonRecord => entry !== null); if (!varga) continue; const ascendant = record(varga.Ascendant); const ascIndex = ascendant ? signIndex(ascendant.sign_idx ?? ascendant.sign) : null; if (ascIndex === null) continue; const occupants: string[][] = Array.from({ length: 12 }, () => []); for (const [name, item] of Object.entries(varga)) { if (name.startsWith("_") || name === "Ascendant" || name === "planets") continue; const row = record(item); const planetIndex = row ? signIndex(row.sign_idx ?? row.sign) : null; if (planetIndex === null) continue; const house = (((planetIndex - ascIndex) % 12) + 12) % 12 + 1; const safeName = safeCelestialName(name); if (safeName) occupants[house - 1].push(safeName); } const houses: ReportEvidencePacket["chart"]["houses"] = Array.from( { length: 12 }, (_, index) => ({ number: index + 1, sign: SIGNS[((ascIndex + index) % 12 + 12) % 12], signDerived: true, occupants: occupants[index].slice(0, 12), }), ); result.push({ id, houses }); } return result; } function readDashaPeriods(rows: unknown): ReportEvidencePacket["chart"]["vimshottari"] { if (!Array.isArray(rows)) return null; const periods: { lord: string; start: string; end: string }[] = []; for (const item of rows) { const row = record(item); const lord = text(row?.lord ?? row?.planet ?? row?.name); const start = text(row?.start ?? row?.start_date); const end = text(row?.end ?? row?.end_date); if (lord && start && end) periods.push({ lord, start, end }); } return periods.length > 0 ? periods : null; } function readVimshottari(chart: JsonRecord | null): ReportEvidencePacket["chart"]["vimshottari"] { const dasha = record(chart?.dasha); if (!dasha) return null; const mahadashas = Array.isArray(dasha.mahadashas) ? dasha.mahadashas : null; return mahadashas ? readDashaPeriods(mahadashas) : null; } function readNarayana(modules: JsonRecord | null): ReportEvidencePacket["chart"]["narayana"] { const narayana = record(modules?.narayana_dasha); if (!narayana) return null; const rows = Array.isArray(narayana.periods) ? narayana.periods : Array.isArray(narayana.mahadashas) ? narayana.mahadashas : null; return rows ? readDashaPeriods(rows) : null; } /** * machine_evidence_packet.sections is an object map in the real engine * ({D1: {status: "used"|"missing", source_path}, planet_degrees: {...}, ...}); * the legacy array shape is also accepted. */ function readSections(machinePacket: JsonRecord | null): { name: string; status: string; sourcePath: string; }[] { const raw = machinePacket?.sections; const entries = Array.isArray(raw) ? raw.map((item, index) => [String(index), item] as const) : Object.entries(record(raw) ?? {}); const sections: { name: string; status: string; sourcePath: string }[] = []; for (const [key, item] of entries) { const row = record(item) ?? {}; sections.push({ name: text(row?.name ?? row?.technique) ?? key, status: text(row?.status) ?? "unknown", sourcePath: text(row?.source_path) ?? "", }); } return sections; } /** * Deterministic evidence-status rule for machine-packet sections. The real * engine only emits used/missing; "verified" as a literal string must never be * required or the gate would always fail. Core calculation sections (D1, * planet_degrees, house_degrees) with an internal source path map to verified; * everything else internal is partial; external oracle sections are capped at * partial; missing/blocked stay blocked. */ const VERIFIED_CALCULATION_SECTIONS = new Set(["D1", "planet_degrees", "house_degrees"]); const EXTERNAL_SECTIONS = new Set([ "external_oracle_status", "vedastro_official_raw_response", "vedastro_official_raw_archive_manifest", ]); function sectionEvidenceStatus( status: string, name: string, sourcePath: string, ): "verified" | "partial" | "blocked" { if (status === "missing" || status === "blocked" || status === "official_blocked") return "blocked"; if (status === "verified") return "verified"; if (status === "partial") return "partial"; // used / available / received_unverified / unknown / undefined const internal = sourcePath.startsWith("chart.") || sourcePath.startsWith("modules.") || sourcePath.startsWith("scripts."); if (VERIFIED_CALCULATION_SECTIONS.has(name) && internal) return "verified"; if (EXTERNAL_SECTIONS.has(name) || sourcePath.startsWith("vedastro_")) return "partial"; return "partial"; } export type BuildEvidencePacketInput = Readonly<{ workflow: unknown; subject: ReportEvidencePacket["subject"]; requestedThemes: readonly string[]; reportType: "personal_full" | "personal_thematic"; presentationMode: "default" | "research"; candidateRange: Readonly<{ start: string; end: string }> | null; skillSnapshot: SkillSnapshot; }>; /** Raised when the real workflow evidence cannot support an honest report. */ export class ReportEvidenceInsufficientError extends Error { readonly code = "calculation_unavailable"; constructor(reason: string) { super(`Report evidence insufficient: ${reason}`); this.name = "ReportEvidenceInsufficientError"; } } function assertUsablePacket(packet: ReportEvidencePacket): void { if (!packet.chart.ascendant) { throw new ReportEvidenceInsufficientError("ascendant_missing"); } const houseNumbers = packet.chart.houses.map((house) => house.number); const unique = new Set(houseNumbers); if (houseNumbers.length !== 12 || unique.size !== 12 || houseNumbers.some((number) => number < 1 || number > 12)) { throw new ReportEvidenceInsufficientError("d1_houses_incomplete"); } if (packet.chart.planets.length === 0) { throw new ReportEvidenceInsufficientError("planets_missing"); } if (packet.chart.planets.some((planet) => planet.house === null || planet.retrograde === null)) { throw new ReportEvidenceInsufficientError("planet_fact_incomplete"); } if (packet.evidenceRefs.length === 0) { throw new ReportEvidenceInsufficientError("evidence_refs_missing"); } // At least one ref must be backed by an explicit verified fact; pure layer // names (partial) are not enough to claim evidence-backed sections. if (!packet.evidenceRefs.some((ref) => ref.status === "verified")) { throw new ReportEvidenceInsufficientError("no_verified_evidence_ref"); } } /** * Extracts ONLY allowlisted facts from the real workflow response. Internal * paths, prompts, exception stacks, chat history and unrelated raw objects are * structurally excluded: unknown keys are never copied. Fails closed when the * evidence cannot support a report. */ export function buildReportEvidencePacket(input: BuildEvidencePacketInput): ReportEvidencePacket { const workflow = record(input.workflow) ?? {}; const chartData = record(workflow.chart) ?? {}; const modules = record(chartData.modules) ?? {}; const consumerContext = record(workflow.consumer_context) ?? {}; const machinePacket = record(workflow.machine_evidence_packet) ?? {}; const answerPolicy = record(consumerContext.answer_policy) ?? {}; // Real base chart selection mirrors the orchestrator: modules.chart (dict) // -> chart_data.chart (nested) -> chart_data itself. Houses fall back to the // top-level chart like the orchestrator's house_degrees section does. const baseChart = resolveBaseChart(chartData); const ascendant = record(baseChart.ascendant); const ascendantSign = text(ascendant?.sign ?? ascendant?.sign_name); const ascendantSignIndex = ascendantSign ? signIndex(ascendantSign) : null; const ascendantDegree = finiteNumber( ascendant?.degree_in_sign ?? ascendant?.degree ?? ascendant?.longitude_deg ?? ascendant?.lon, ); const planets = readPlanets(baseChart.planets); const houses = readHouses( baseChart.houses ?? chartData.houses, ascendantSignIndex, planets, ); const vimshottari = readVimshottari(baseChart); const narayana = readNarayana(modules); const vargaHouses = readVargaHouses(record(modules.varga_full)); const availableLayers = stringArray(consumerContext.available_layers); const missingLayers = stringArray(consumerContext.missing_route_layers); const hardBlockers = stringArray(consumerContext.hard_blockers); const techniqueAudit: { technique: string; status: string; note: string }[] = []; const seenTechniques = new Set(); const sections = readSections(machinePacket); // Layer names alone are route availability, not verified facts: they map to // partial at best. for (const technique of [...availableLayers, ...missingLayers, ...hardBlockers]) { if (!technique || seenTechniques.has(technique)) continue; seenTechniques.add(technique); const status = hardBlockers.includes(technique) ? "blocked" : missingLayers.includes(technique) ? "missing" : "available"; techniqueAudit.push({ technique, status, note: status === "missing" ? "not computed for this route" : status === "blocked" ? "hard blocker" : "", }); } // Machine-packet facts override route-layer availability for the same technique. const sectionReason: Readonly> = { missing: "未返回本次计算数据", official_blocked: "官方外部引擎本次未验证或不可用", received_unverified: "已收到外部响应,但未完成官方验证", local_fallback: "本次仅有本地回退结果,未取得官方验证", blocked: "本次执行被阻塞", }; for (const section of sections) { const row = { technique: section.name, status: sectionEvidenceStatus(section.status, section.name, section.sourcePath), note: [sectionReason[section.status], section.sourcePath ? `source: ${section.sourcePath}` : ""] .filter(Boolean) .join(";"), }; const existingIndex = techniqueAudit.findIndex((item) => item.technique === section.name); if (existingIndex >= 0) techniqueAudit[existingIndex] = row; else techniqueAudit.push(row); } const blockedTechniques = hardBlockers.length > 0 ? hardBlockers : sections.filter((section) => section.status === "blocked").map((section) => section.name); const conflicts: { techniques: string[]; summary: string }[] = []; const rawConflicts = Array.isArray(machinePacket.conflicts) ? machinePacket.conflicts : Array.isArray(consumerContext.conflicts) ? consumerContext.conflicts : []; for (const item of rawConflicts) { const row = record(item); const summary = text(row?.summary ?? row?.message ?? row?.description); const techniques = stringArray(row?.techniques ?? row?.layers); if (summary) conflicts.push({ techniques, summary }); } const evidenceRefs: { id: string; technique: string; status: EvidenceRefStatus }[] = []; techniqueAudit.forEach((row, index) => { evidenceRefs.push({ id: `ev-audit-${index + 1}`, technique: row.technique, status: canonicalTechniqueStatus(row.status), }); }); conflicts.forEach((conflict, index) => { evidenceRefs.push({ id: `ev-conflict-${index + 1}`, technique: conflict.techniques.join("+") || "conflict", status: "blocked", }); }); const deterministicForbidden = stringArray(answerPolicy.deterministic_claims_forbidden_for); const canAnswerPreciseTiming = answerPolicy.can_answer_precise_timing === true; const calculationFacts = { ascendant: ascendantSign && ascendantDegree !== null ? { sign: ascendantSign, degree: ascendantDegree } : null, planets, houses, vimshottari, narayana, }; const engineHash = text(baseChart.result_hash) ?? text(chartData.result_hash) ?? text(machinePacket.calculation_hash); const calculationHash = engineHash && sha256Pattern.test(engineHash) ? engineHash : sha256Hex(canonicalSerialize(calculationFacts)); const packet: ReportEvidencePacket = { schemaVersion: "report_evidence_packet.v1", subject: input.subject, requestedThemes: [...input.requestedThemes], reportType: input.reportType, presentationMode: input.presentationMode, chart: { calculationHash, calculationHashDerived: !(engineHash && sha256Pattern.test(engineHash)), ascendant: ascendantSign && ascendantDegree !== null ? { sign: ascendantSign, degree: ascendantDegree } : null, planets, houses, vimshottari, narayana, vargaHouses, }, techniqueAudit, conflicts, blockedTechniques: [...new Set(blockedTechniques)], evidenceRefs, candidateRange: input.candidateRange, answerPolicy: { canAnswerPreciseTiming: canAnswerPreciseTiming && input.candidateRange === null, deterministicClaimsForbiddenFor: [...new Set(deterministicForbidden)], }, skillName: input.skillSnapshot.name, skillVersion: input.skillSnapshot.version, skillSnapshotSha256: input.skillSnapshot.sha256, skillSourceCommit: input.skillSnapshot.sourceCommit, }; assertUsablePacket(packet); return packet; } export function canonicalTechniqueStatus(status: string): "verified" | "partial" | "blocked" { if (status === "verified") return "verified"; // "available" is only a route-layer name, never verified evidence. if (status === "partial" || status === "available" || status === "unknown") return "partial"; return "blocked"; } export type BuildReportEvidenceBundleV2Input = Readonly<{ workflows: readonly Readonly<{ theme: string; workflow: unknown }>[]; subject: SafeReportSubject; requestedThemes: readonly string[]; reportType: "personal_full" | "personal_thematic"; presentationMode: "default" | "research"; skillSnapshot: SkillSnapshot; }>; type BuiltThemePacket = Readonly<{ theme: string; workflow: JsonRecord; packet: ReportEvidencePacket; }>; const RECEIPT_STATUS_RANK: Readonly> = { blocked: 0, partial: 1, verified: 2, }; function evidenceSlug(value: string, fallback: string): string { const slug = value.toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, "") .slice(0, 48); return slug || fallback; } function techniqueLookupKey(value: string): string { return value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); } function techniqueMatches(receipt: TechniqueExecutionReceipt, aliases: readonly string[]): boolean { const receiptKey = techniqueLookupKey(receipt.technique); return aliases.some((alias) => { const aliasKey = techniqueLookupKey(alias); return receiptKey === aliasKey || receiptKey.endsWith(`_${aliasKey}`) || receiptKey.startsWith(`${aliasKey}_`); }); } const SAFE_CELESTIAL_NAMES = new Map([ "sun", "moon", "mars", "mercury", "jupiter", "venus", "saturn", "rahu", "ketu", "uranus", "neptune", "pluto", "ascendant", "lagna", ].map((name) => [name, name.charAt(0).toUpperCase() + name.slice(1)])); const SAFE_WORKFLOW_TECHNIQUES = new Map([ ["d1", "D1"], ["d2", "D2"], ["d4", "D4"], ["d6", "D6"], ["d7", "D7"], ["d9", "D9"], ["d10", "D10"], ["d11", "D11"], ["d12", "D12"], ["d24", "D24"], ["d30", "D30"], ["a7", "A7"], ["a10", "A10"], ["ul", "UL"], ["upapada", "UL"], ["dk", "DK"], ["darakaraka", "DK"], ["amk", "AmK"], ["amatyakaraka", "AmK"], ["karma_pada", "A10"], ["vimshottari", "Vimshottari"], ["vimshottari_dasha", "Vimshottari"], ["dasha_boundaries", "Vimshottari"], ["narayana", "Narayana"], ["narayana_dasha", "Narayana"], ["transit", "Transit"], ["gochara", "Transit"], ["yoga", "Yoga"], ["yogas", "Yoga"], ["ashtakavarga", "Ashtakavarga"], ["functional_benefic_malefic", "Functional Benefic/Malefic"], ["planet_degrees", "Planet Degrees"], ["house_degrees", "House Degrees"], ]); const SAFE_AYANAMSA = new Map([ ["lahiri", "Lahiri"], ["raman", "Raman"], ["kp", "Krishnamurti/KP"], ["krishnamurti", "Krishnamurti/KP"], ["krishnamurti/kp", "Krishnamurti/KP"], ["krishnamurti_paddhati", "Krishnamurti/KP"], ["fagan_bradley", "Fagan-Bradley"], ["djwhal_khul", "Djwhal Khul"], ["sassanian", "Sassanian"], ["true_citra", "True Citra"], ]); const SAFE_NODE_MODES = new Map([ ["mean", "mean"], ["mean_node", "mean"], ["true", "true"], ["true_node", "true"], ]); const SAFE_HOUSE_SYSTEMS = new Map([ ["equal", "equal"], ["placidus", "placidus"], ["porphyry", "porphyry"], ["sripati", "sripati"], ["whole_sign", "whole_sign"], ["koch", "koch"], ]); const SAFE_POLICY_BOUNDARIES = new Set([ "timing", "medical", "investment", "exact_dates", "medical_diagnosis", "investment_guarantees", "kp_system", "muhurta", "gochara_event_timing", "sahams", "sphuta_trisphuta_family", "tajika_yogas", "conception_chart", "relationship_combinations", ]); function safeWorkflowTechnique(value: string): string | null { return SAFE_WORKFLOW_TECHNIQUES.get(techniqueLookupKey(value)) ?? null; } type CalculationProfileField = "ayanamsa" | "nodeMode" | "houseSystem"; function safeCalculationLabel(value: string | null, field: CalculationProfileField): string | null { if (!value) return null; const key = value.trim().toLowerCase().replace(/[ -]+/g, "_"); if (field === "ayanamsa") return SAFE_AYANAMSA.get(key) ?? null; if (field === "nodeMode") return SAFE_NODE_MODES.get(key) ?? null; return SAFE_HOUSE_SYSTEMS.get(key) ?? null; } function safePolicyBoundary( value: string, ): ReportEvidenceBundleV2["answerPolicy"]["deterministicClaimsForbiddenFor"][number] | null { const candidate = value.trim().toLowerCase(); return SAFE_POLICY_BOUNDARIES.has(candidate) ? candidate as ReportEvidenceBundleV2["answerPolicy"]["deterministicClaimsForbiddenFor"][number] : null; } function safeCelestialName(value: string): string | null { const candidate = value.trim(); return SAFE_CELESTIAL_NAMES.get(candidate.toLowerCase()) ?? null; } function safeChartSign(value: string): string | null { const index = signIndex(value); return index === null ? null : SIGNS[index]; } function safeDashaDate(value: string): string | null { const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); if (dateOnly) { const year = Number(dateOnly[1]); if (year < 1600 || year > 2400) return null; const parsed = new Date(`${value}T00:00:00.000Z`); return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value ? value : null; } if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(value)) { return null; } const parsed = new Date(value); const year = parsed.getUTCFullYear(); return Number.isFinite(parsed.getTime()) && year >= 1600 && year <= 2400 ? value : null; } function safeDashaPeriods( periods: readonly Readonly<{ lord: string; start: string; end: string }>[] | null, ): readonly Readonly<{ lord: string; start: string; end: string }>[] | null { if (!periods) return null; const safe = periods.flatMap((period) => { const lord = safeCelestialName(period.lord) ?? safeChartSign(period.lord); const start = safeDashaDate(period.start); const end = safeDashaDate(period.end); return lord && start && end && Date.parse(start) < Date.parse(end) ? [{ lord, start, end }] : []; }); return safe.length > 0 ? safe : null; } function safeChartFact(chart: ReportChartFact): ReportChartFact | null { const ascendantSign = chart.ascendant ? safeChartSign(chart.ascendant.sign) : null; if (chart.id === "D1" && (!chart.ascendant || !ascendantSign)) return null; const ascendantIndex = ascendantSign ? signIndex(ascendantSign) : null; const planets = chart.planets.flatMap((planet) => { const id = safeCelestialName(planet.id); const sign = safeChartSign(planet.sign); if (!id || !sign) return []; return [{ ...planet, id, sign }]; }); const houses = chart.houses.flatMap((house) => { if (!Number.isInteger(house.number) || house.number < 1 || house.number > 12) return []; const sign = safeChartSign(house.sign) ?? (ascendantIndex === null ? null : SIGNS[(ascendantIndex + house.number - 1) % 12]); if (!sign) return []; return [{ ...house, sign, signDerived: house.signDerived || safeChartSign(house.sign) === null, occupants: house.occupants.flatMap((occupant) => { const safe = safeCelestialName(occupant); return safe ? [safe] : []; }), }]; }); return { ...chart, ascendant: chart.ascendant && ascendantSign ? { sign: ascendantSign, degree: chart.ascendant.degree } : null, houses, planets, }; } function safeReceiptNote(status: EvidenceRefStatus, executed: boolean): string { if (!executed) return status === "blocked" ? "本次未取得该技法证据" : "仅识别为可用层,本次未执行"; return status === "verified" ? "本次服务器计算已验证" : "本次已执行,但证据确定性仍为部分"; } const EXECUTED_MACHINE_SECTION_STATUSES = new Set([ "used", "verified", "partial", "received_unverified", "local_fallback", "official_verified", "executed", "success", "completed", ]); function readMachineSectionExecuted(workflow: JsonRecord, technique: string): boolean | null { const machinePacket = record(workflow.machine_evidence_packet); const section = readSections(machinePacket).find((item) => item.name === technique); if (!section) return null; return EXECUTED_MACHINE_SECTION_STATUSES.has(section.status.trim().toLowerCase()); } function readCalculationProfileText( workflow: JsonRecord, keys: readonly string[], field: CalculationProfileField, ): string | null { const chart = record(workflow.chart) ?? {}; const modules = record(chart.modules) ?? {}; const base = resolveBaseChart(chart); const profile = record(workflow.calculation_profile) ?? record(chart.calculation_profile) ?? {}; for (const key of keys) { const value = safeCalculationLabel(text(profile[key] ?? base[key] ?? chart[key] ?? modules[key]), field); if (value) return value; } return null; } function readAllVargaCharts(workflow: JsonRecord): ReportChartFact[] { const chart = record(workflow.chart) ?? {}; const modules = record(chart.modules) ?? {}; const vargaFull = record(modules.varga_full); if (!vargaFull) return []; const charts: ReportChartFact[] = []; for (const [rawId, rawValue] of Object.entries(vargaFull)) { const id = rawId.toUpperCase(); if (!/^D\d{1,3}$/.test(id)) continue; const varga = record(rawValue); if (!varga) continue; const ascendant = record(varga.Ascendant); const ascIndex = ascendant ? signIndex(ascendant.sign_idx ?? ascendant.sign) : null; if (ascIndex === null) continue; const occupants: string[][] = Array.from({ length: 12 }, () => []); for (const [name, item] of Object.entries(varga)) { if (name.startsWith("_") || name === "Ascendant" || name === "planets") continue; const row = record(item); const planetIndex = row ? signIndex(row.sign_idx ?? row.sign) : null; if (planetIndex === null) continue; const house = (((planetIndex - ascIndex) % 12) + 12) % 12 + 1; occupants[house - 1].push(name); } charts.push({ id, title: `${id} 分盘`, ascendant: { sign: SIGNS[ascIndex], degree: finiteNumber(ascendant?.degree_in_sign ?? ascendant?.degree) ?? 0, }, houses: Array.from({ length: 12 }, (_, index) => ({ number: index + 1, sign: SIGNS[(ascIndex + index) % 12], signDerived: true, occupants: occupants[index].slice(0, 20), })), planets: [], }); } return charts; } function mergeChart(target: Map, chart: ReportChartFact): void { const existing = target.get(chart.id); if (!existing || (existing.houses.length < 12 && chart.houses.length === 12)) target.set(chart.id, chart); } function buildLegacyPacketFromBundle(bundle: ReportEvidenceBundleV2): ReportEvidencePacket { const d1 = bundle.charts.find((chart) => chart.id === "D1"); if (!d1?.ascendant) throw new ReportEvidenceInsufficientError("ascendant_missing"); const vargaHouses = bundle.charts .filter((chart): chart is ReportChartFact & { id: "D9" | "D10" } => chart.id === "D9" || chart.id === "D10") .map((chart) => ({ id: chart.id, houses: chart.houses })); const receiptById = new Map(bundle.executionLedger.map((receipt) => [receipt.id, receipt])); return { schemaVersion: "report_evidence_packet.v1", subject: bundle.subject, requestedThemes: bundle.requestedThemes, reportType: bundle.reportType, presentationMode: bundle.presentationMode, chart: { calculationHash: bundle.calculationProfile.calculationHash, calculationHashDerived: bundle.calculationProfile.calculationHashDerived, ascendant: d1.ascendant, planets: d1.planets, houses: d1.houses, vimshottari: bundle.calculationProfile.vimshottari, narayana: bundle.calculationProfile.narayana, vargaHouses, }, techniqueAudit: bundle.executionLedger.map((receipt) => ({ id: receipt.id, technique: receipt.technique, status: receipt.status, note: receipt.note, })), conflicts: bundle.conflicts.map((conflict) => ({ id: conflict.id, techniques: conflict.techniqueRefs.map((ref) => receiptById.get(ref)?.technique ?? ref), summary: conflict.summary, })), blockedTechniques: bundle.executionLedger .filter((receipt) => receipt.status === "blocked") .map((receipt) => receipt.technique), evidenceRefs: bundle.evidenceRefs, candidateRange: null, answerPolicy: { canAnswerPreciseTiming: bundle.answerPolicy.canAnswerPreciseTiming, deterministicClaimsForbiddenFor: bundle.answerPolicy.deterministicClaimsForbiddenFor, }, skillName: bundle.skill.name, skillVersion: bundle.skill.version, skillSnapshotSha256: bundle.skill.sha256, skillSourceCommit: bundle.skill.sourceCommit, }; } /** * Builds the server-owned, allowlisted ReportEvidenceBundle v2 from one * workflow result per requested theme. A missing thematic layer produces a * blocked section, while an unusable D1 base fails closed. */ export function buildReportEvidenceBundleV2( input: BuildReportEvidenceBundleV2Input, ): ReportEvidenceBundleV2 { const requestedPlans = buildReportThemePlan(input.requestedThemes); const packets: BuiltThemePacket[] = []; for (const item of input.workflows) { const workflow = record(item.workflow); if (!workflow) continue; try { packets.push({ theme: normalizeReportTheme(item.theme), workflow, packet: buildReportEvidencePacket({ workflow, subject: input.subject, requestedThemes: [normalizeReportTheme(item.theme)], reportType: input.reportType, presentationMode: input.presentationMode, candidateRange: null, skillSnapshot: input.skillSnapshot, }), }); } catch (error) { if (!(error instanceof ReportEvidenceInsufficientError)) throw error; } } const base = packets[0]; if (!base) throw new ReportEvidenceInsufficientError("d1_base_unavailable"); const receiptsByKey = new Map(); const upsertReceipt = (technique: string, status: EvidenceRefStatus, executed: boolean) => { const key = techniqueLookupKey(technique) || "technique"; const next: TechniqueExecutionReceipt = { id: `ev-tech-${evidenceSlug(technique, "technique")}`, technique, status, executed, note: safeReceiptNote(status, executed), }; const current = receiptsByKey.get(key); if (!current || Number(next.executed) > Number(current.executed) || (next.executed === current.executed && RECEIPT_STATUS_RANK[next.status] > RECEIPT_STATUS_RANK[current.status])) { receiptsByKey.set(key, next); } return receiptsByKey.get(key)!; }; for (const { packet, workflow } of packets) { for (const row of packet.techniqueAudit) { const technique = safeWorkflowTechnique(row.technique); if (!technique) continue; const status = canonicalTechniqueStatus(row.status); const machineSectionExecuted = readMachineSectionExecuted(workflow, row.technique); const executed = machineSectionExecuted ?? (row.status === "verified" || row.status === "used"); upsertReceipt(technique, status, executed && status !== "blocked"); } } upsertReceipt("D1", "verified", true); if (base.packet.chart.vimshottari?.length) upsertReceipt("Vimshottari", "verified", true); if (base.packet.chart.narayana?.length) upsertReceipt("Narayana", "verified", true); const chartsById = new Map(); const safeD1 = safeChartFact({ id: "D1", title: "D1 本命盘", ascendant: base.packet.chart.ascendant, houses: base.packet.chart.houses, planets: base.packet.chart.planets, }); if (!safeD1) throw new ReportEvidenceInsufficientError("d1_safe_projection_unavailable"); mergeChart(chartsById, safeD1); for (const packet of packets) { for (const varga of packet.packet.chart.vargaHouses) { const safeChart = safeChartFact({ id: varga.id, title: `${varga.id} 分盘`, houses: varga.houses, planets: [], }); if (safeChart) { mergeChart(chartsById, safeChart); upsertReceipt(varga.id, "verified", true); } } for (const chart of readAllVargaCharts(packet.workflow)) { const safeChart = safeChartFact(chart); if (!safeChart) continue; mergeChart(chartsById, safeChart); upsertReceipt(safeChart.id, "verified", true); } } const claimCards: ReportClaimCard[] = []; const blockedSections: ReportEvidenceBundleV2["blockedSections"][number][] = []; for (const plan of requestedPlans) { const matched: TechniqueExecutionReceipt[] = []; const missingRefs: string[] = []; for (const group of plan.requiredTechniqueGroups) { const receipt = [...receiptsByKey.values()].find((candidate) => ( candidate.executed && candidate.status !== "blocked" && techniqueMatches(candidate, group.anyOf) )); if (receipt) { matched.push(receipt); continue; } const missing = upsertReceipt(`${plan.theme}:${group.label}`, "blocked", false); missingRefs.push(missing.id); } if (missingRefs.length > 0) { blockedSections.push({ id: `ev-blocked-${evidenceSlug(plan.theme, "theme")}`, theme: plan.theme, section: plan.section, reason: `缺少最低证据组:${plan.requiredTechniqueGroups .filter((group) => missingRefs.some((ref) => ref.endsWith(evidenceSlug(`${plan.theme}:${group.label}`, "missing")))) .map((group) => group.label) .join("、") || "主题证据未闭合"}`, missingTechniqueRefs: missingRefs, }); continue; } const uniqueMatched = [...new Map(matched.map((receipt) => [receipt.id, receipt])).values()]; const allVerified = uniqueMatched.every((receipt) => receipt.status === "verified"); const assertionLevel = allVerified && uniqueMatched.length >= 2 ? "multi_system_consensus" : allVerified ? "single_system_inference" : "parameter_sensitive"; claimCards.push({ id: `ev-claim-${evidenceSlug(plan.theme, "theme")}`, theme: plan.theme, section: plan.section, conclusion: `服务器已闭合${plan.section}所需的最低证据组;本节只能在所列事实与确定性级别内解释。`, supportingFacts: uniqueMatched.map((receipt, index) => ({ id: `ev-fact-${evidenceSlug(plan.theme, "theme")}-${index + 1}`, label: receipt.technique, value: receipt.technique === "D1" && base.packet.chart.ascendant ? `D1 上升为 ${base.packet.chart.ascendant.sign},本次基础盘已建立` : `${receipt.technique} 已执行并纳入本主题证据计划`, evidenceRef: receipt.id, status: receipt.status, })), counterFacts: [], executedTechniqueRefs: uniqueMatched.map((receipt) => receipt.id), assertionLevel, timingBoundary: input.subject.birthTimeStatus === "confirmed" ? null : "出生时间未达到 confirmed;时间结论仅允许方向性表达", verificationQuestions: assertionLevel === "parameter_sensitive" ? ["建议结合真实经历核验本主题的部分证据结论"] : [], }); } const conflicts: EvidenceConflict[] = []; for (const { packet } of packets) { for (const conflict of packet.conflicts) { const techniqueRefs = conflict.techniques .map((technique) => [...receiptsByKey.values()].find((receipt) => techniqueMatches(receipt, [technique]))?.id) .filter((ref): ref is string => Boolean(ref)); const summary = techniqueRefs.length > 0 ? "所列技法的结果存在未闭合冲突,报告不得据此作确定性提升。" : "本次证据存在未闭合冲突,报告必须披露该边界。"; const id = `ev-conflict-${conflicts.length + 1}`; if (!conflicts.some((item) => ( item.summary === summary && item.techniqueRefs.join("|") === techniqueRefs.join("|") ))) { conflicts.push({ id, techniqueRefs, summary, resolutionStatus: "unresolved" }); } } } const executionLedger = [...receiptsByKey.values()]; const deterministicClaimsForbiddenFor = [...new Set(packets.flatMap(({ packet }) => ( packet.answerPolicy.deterministicClaimsForbiddenFor.flatMap((value) => { const safe = safePolicyBoundary(value); return safe ? [safe] : []; }) )))]; const canAnswerPreciseTiming = input.subject.birthTimeStatus === "confirmed" && packets.length > 0 && packets.every(({ packet }) => packet.answerPolicy.canAnswerPreciseTiming); const birthTimePolicy = input.subject.birthTimeStatus === "confirmed" ? "confirmed" : input.subject.birthTimeStatus === "accepted" ? "accepted_directional_only" : input.subject.birthTimeStatus === "candidate" ? "candidate_directional_only" : "reported_directional_only"; return finalizeReportEvidenceBundleV2({ schemaVersion: "report_evidence_bundle.v2", subject: input.subject, requestedThemes: requestedPlans.map((plan) => plan.theme), reportType: input.reportType, presentationMode: input.presentationMode, calculationProfile: { calculationHash: base.packet.chart.calculationHash, calculationHashDerived: base.packet.chart.calculationHashDerived, birthTimeStatus: input.subject.birthTimeStatus, ayanamsa: readCalculationProfileText(base.workflow, ["ayanamsa", "ayanamsa_name"], "ayanamsa"), nodeMode: readCalculationProfileText(base.workflow, ["node_mode", "nodeMode"], "nodeMode"), houseSystem: readCalculationProfileText(base.workflow, ["house_system", "houseSystem"], "houseSystem"), vimshottari: safeDashaPeriods(base.packet.chart.vimshottari), narayana: safeDashaPeriods(base.packet.chart.narayana), }, skill: input.skillSnapshot, charts: [...chartsById.values()], claimCards, blockedSections, conflicts, executionLedger, evidenceRefs: executionLedger.map((receipt) => ({ id: receipt.id, technique: receipt.technique, status: receipt.status, })), answerPolicy: { canAnswerPreciseTiming, birthTimePolicy, deterministicClaimsForbiddenFor, }, }); } function techniqueSlug(name: string, fallback: string): string { const slug = name.toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, "") .slice(0, 80); return /^[a-z0-9_.-]{1,80}$/.test(slug) ? slug : fallback; } // --------------------------------------------------------------------------- // Document assembly (deterministic; agent writes narrative only) // --------------------------------------------------------------------------- export const PERSONAL_REPORT_DISCLAIMER = "本报告基于所提供出生信息与服务器计算的排盘证据生成,属于解释性参考,不构成医疗、法律或投资建议。出生时间未经确认时,报告中的时间相关表述仅为方向性参考。"; export type AssembleReportDocumentInput = Readonly<{ reportId: string; generatedAt: string; packet: ReportEvidencePacket; agentOutput: PersonalReportAgentOutput; }>; function canonicalChartHouses( houses: readonly ReportEvidencePacket["chart"]["houses"][number][], ): ReportDocumentV1["charts"][number]["houses"] { return houses.map((house) => ({ houseNumber: house.number, sign: house.sign, occupants: [...house.occupants].slice(0, 12), })); } function canonicalPlanets( planets: readonly ReportPlanetFact[], ): ReportDocumentV1["charts"][number]["planets"] { return planets.map((planet) => ({ name: planet.id, sign: planet.sign, longitudeDegrees: planet.degree, houseNumber: planet.house as number, retrograde: planet.retrograde as boolean, })); } /** * Builds the candidate ReportDocument v1. Order is strict: appendix first, * then evidenceHash = computeEvidenceHash(appendix) (canonical server hash, * never a model self-report), then the final document. */ export function assembleReportDocument( input: AssembleReportDocumentInput, ): ReportDocumentV1 { const { packet } = input; if (!packet.chart.ascendant) { throw new ReportEvidenceInsufficientError("ascendant_missing"); } const usedEvidenceRefs = new Set( input.agentOutput.thematicNarrative.flatMap((section) => section.evidenceRefs), ); const techniqueAudit: EvidenceAppendix["techniqueAudit"] = packet.techniqueAudit.map( (row, index) => { const id = row.id ?? `ev-audit-${index + 1}`; return { id, techniqueId: techniqueSlug(row.technique, `tech-${index + 1}`), techniqueName: row.technique, status: canonicalTechniqueStatus(row.status), used: usedEvidenceRefs.has(id), ...(row.note ? { notes: row.note.slice(0, 500) } : {}), }; }, ); const conflicts: EvidenceAppendix["conflicts"] = packet.conflicts.map((conflict, index) => ({ id: conflict.id ?? `ev-conflict-${index + 1}`, description: conflict.summary.slice(0, 1000), impact: "多技法结果不一致,相关结论已按确定性边界降级", status: "unresolved", })); const calculationEvidence: EvidenceAppendix["calculationEvidence"] = []; if (packet.chart.calculationHashDerived) { calculationEvidence.push({ id: "ev-calc-derived", label: "calculation_hash", value: packet.chart.calculationHash, source: "derived_server_sha256_over_allowlisted_calculation_facts", }); } if (packet.chart.houses.some((house) => house.signDerived) || packet.chart.vargaHouses.some((varga) => varga.houses.some((house) => house.signDerived))) { calculationEvidence.push({ id: "ev-calc-house-signs", label: "house_sign_derivation", value: "whole_sign_from_ascendant_for_houses_without_a_source_sign", source: "server_derived", }); } calculationEvidence.push({ id: "ev-calc-ascendant", label: "ascendant", value: `${packet.chart.ascendant.sign} ${packet.chart.ascendant.degree.toFixed(2)}°`, source: "server_calculation", }); packet.chart.vimshottari?.forEach((period, index) => { calculationEvidence.push({ id: `ev-calc-vimshottari-${index + 1}`, label: `Vimshottari 大运:${period.lord}`, value: `${period.start} – ${period.end}`, source: "server_calculation", }); }); packet.chart.narayana?.forEach((period, index) => { calculationEvidence.push({ id: `ev-calc-narayana-${index + 1}`, label: `Narayana 大运:${period.lord}`, value: `${period.start} – ${period.end}`, source: "server_calculation", }); }); const appendix: EvidenceAppendix = { expandedByDefault: false, techniqueAudit, conflicts, calculationEvidence, blockedTechniques: packet.blockedTechniques .map((technique) => technique.slice(0, 120)) .slice(0, 100), }; const evidenceHash = computeEvidenceHash(appendix); const charts: ReportDocumentV1["charts"] = [{ id: "D1", title: "本命盘 D1", houses: canonicalChartHouses(packet.chart.houses), planets: canonicalPlanets(packet.chart.planets), claimStatus: packet.blockedTechniques.length > 0 ? "blocked" : "single_system_inference", }]; for (const varga of packet.chart.vargaHouses) { if (varga.houses.length === 0) continue; charts.push({ id: varga.id, title: varga.id === "D9" ? "九分盘 D9" : "事业盘 D10", houses: canonicalChartHouses(varga.houses), claimStatus: "single_system_inference", }); } const document: ReportDocumentV1 = { schemaVersion: "report_document.v1", reportId: input.reportId, reportType: packet.reportType, presentationMode: packet.presentationMode, generatedAt: input.generatedAt, subject: { displayName: packet.subject.displayName, birthTimeStatus: packet.subject.birthTimeStatus, birthPlaceLabel: packet.subject.birthPlaceLabel, }, provenance: { skillName: packet.skillName, skillVersion: packet.skillVersion, skillSourceCommit: packet.skillSourceCommit, skillSnapshotSha256: packet.skillSnapshotSha256, calculationHash: packet.chart.calculationHash, evidenceHash, reportContractVersion: "1", }, executiveSummary: { headline: input.agentOutput.executiveSummary.headline, summary: input.agentOutput.executiveSummary.summary, priorities: [...input.agentOutput.executiveSummary.priorities], overallClaimStatus: input.agentOutput.thematicNarrative.some( (section) => section.claimStatus === "blocked", ) ? "blocked" : "single_system_inference", }, charts, thematicNarrative: input.agentOutput.thematicNarrative.map((section) => ({ id: section.id, title: section.title, narrative: section.narrative, actions: [...section.actions], caveats: [...section.caveats], claimStatus: section.claimStatus, evidenceRefs: [...section.evidenceRefs], })), evidenceAppendix: appendix, disclaimer: PERSONAL_REPORT_DISCLAIMER, }; return document; } export type AssembleReportDocumentV2Input = Readonly<{ reportId: string; generatedAt: string; depth: ReportDepth; bundle: ReportEvidenceBundleV2; plan: PersonalReportSectionPlan; agentOutput: PersonalReportAgentOutput; }>; const CLAIM_STATUS_RANK: Readonly> = { multi_system_consensus: 0, single_system_inference: 1, parameter_sensitive: 2, unclosed_divisional_chart: 3, user_history_verification_required: 4, blocked: 5, }; function uniqueInOrder(values: readonly string[]): string[] { return [...new Set(values)]; } function equalStringArrays(left: readonly string[], right: readonly string[]): boolean { return left.length === right.length && left.every((value, index) => value === right[index]); } /** * The model is a writer only. This validation binds every generated thematic * section to the deterministic server plan and its single Claim Card before * any model text can enter ReportDocument v2. */ export function validatePersonalReportAgentOutputAgainstPlan( output: PersonalReportAgentOutput, plan: PersonalReportSectionPlan, bundle: ReportEvidenceBundleV2, ): PersonalReportAgentOutput { const writePlans = plan.sections.filter((section) => ( section.kind === "thematic" && section.disposition === "write" )); if (output.thematicNarrative.length !== writePlans.length) { throw new Error("report_writer_theme_count_mismatch"); } const seen = new Set(); const claimCards = new Map(bundle.claimCards.map((card) => [card.theme, card])); const plans = new Map(writePlans.map((section) => [section.theme as string, section])); for (const section of output.thematicNarrative) { if (seen.has(section.theme)) throw new Error(`report_writer_duplicate_theme:${section.theme}`); seen.add(section.theme); const sectionPlan = plans.get(section.theme); const card = claimCards.get(section.theme); if (!sectionPlan || !card) throw new Error(`report_writer_unplanned_theme:${section.theme}`); if (section.id !== sectionPlan.id) throw new Error(`report_writer_section_id_mismatch:${section.theme}`); if (!equalStringArrays(section.evidenceRefs, sectionPlan.evidenceRefs)) { throw new Error(`report_writer_evidence_refs_mismatch:${section.theme}`); } if (CLAIM_STATUS_RANK[section.claimStatus] < CLAIM_STATUS_RANK[card.assertionLevel]) { 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}`); } } return output; } function claimStatusForEvidenceStatus(status: EvidenceRefStatus): ClaimStatus { if (status === "verified") return "single_system_inference"; if (status === "partial") return "parameter_sensitive"; return "blocked"; } function evidenceRefsForTechnique( bundle: ReportEvidenceBundleV2, technique: string, requireExecuted = true, ): string[] { const normalized = technique.toUpperCase(); return bundle.executionLedger.filter((receipt) => { const receiptTechnique = receipt.technique.toUpperCase(); const matches = receiptTechnique === normalized || receiptTechnique.endsWith(`:${normalized}`); return matches && (!requireExecuted || (receipt.executed && receipt.status !== "blocked")); }).map((receipt) => receipt.id); } function evidenceAppendixFromBundle( bundle: ReportEvidenceBundleV2, usedEvidenceRefs: ReadonlySet, ): EvidenceAppendix { const d1 = bundle.charts.find((chart) => chart.id === "D1"); const calculationEvidence: EvidenceAppendix["calculationEvidence"] = []; if (bundle.calculationProfile.calculationHashDerived) { calculationEvidence.push({ id: "ev-calc-derived", label: "calculation_hash", value: bundle.calculationProfile.calculationHash, source: "derived_server_sha256_over_allowlisted_calculation_facts", }); } if (d1?.ascendant) { calculationEvidence.push({ id: "ev-calc-ascendant", label: "ascendant", value: `${d1.ascendant.sign} ${d1.ascendant.degree.toFixed(2)}°`, source: "server_calculation", }); } bundle.calculationProfile.vimshottari?.forEach((period, index) => { calculationEvidence.push({ id: `ev-calc-vimshottari-${index + 1}`, label: `Vimshottari 大运:${period.lord}`, value: `${period.start} – ${period.end}`, source: "server_calculation", }); }); bundle.calculationProfile.narayana?.forEach((period, index) => { calculationEvidence.push({ id: `ev-calc-narayana-${index + 1}`, label: `Narayana 大运:${period.lord}`, value: `${period.start} – ${period.end}`, source: "server_calculation", }); }); const ledgerById = new Map(bundle.executionLedger.map((receipt) => [receipt.id, receipt])); return { expandedByDefault: bundle.presentationMode === "research", techniqueAudit: bundle.executionLedger.map((receipt, index) => ({ id: receipt.id, techniqueId: techniqueSlug(receipt.technique, `tech-${index + 1}`), techniqueName: receipt.technique, status: receipt.status, used: usedEvidenceRefs.has(receipt.id), ...(receipt.note ? { notes: receipt.note.slice(0, 500) } : {}), })), conflicts: bundle.conflicts.map((conflict) => ({ id: conflict.id, description: conflict.summary, impact: conflict.resolutionStatus === "bounded" ? "冲突已被限制在证据边界内,相关结论不得提升确定性" : "多技法结果不一致,相关结论已按确定性边界降级", status: conflict.resolutionStatus === "bounded" ? "partial" : "unresolved", })), calculationEvidence, blockedTechniques: uniqueInOrder(bundle.blockedSections.flatMap((section) => ( section.missingTechniqueRefs.map((ref) => ledgerById.get(ref)?.technique ?? ref) ))).slice(0, 100), }; } function canonicalBundleChart( chart: ReportChartFact, evidenceRefs: readonly string[], status: ClaimStatus, ): ReportDocumentV2["charts"][number] { const planets = chart.planets.filter((planet) => planet.house !== null).slice(0, 12).map((planet) => ({ name: planet.id, sign: planet.sign, longitudeDegrees: ((planet.degree % 360) + 360) % 360, houseNumber: planet.house as number, retrograde: planet.retrograde ?? false, })); return { id: chart.id as ReportDocumentV2["charts"][number]["id"], title: chart.title, houses: chart.houses.map((house) => ({ houseNumber: house.number, sign: house.sign, occupants: [...house.occupants].slice(0, 12), })), ...(planets.length > 0 ? { planets } : {}), claimStatus: status, evidenceRefs: [...evidenceRefs], }; } /** Assemble the canonical current report contract without model-generated markup. */ export function assembleReportDocumentV2( input: AssembleReportDocumentV2Input, ): ReportDocumentV2 { const bundle = validateReportEvidenceBundleV2(input.bundle); const plan = validatePersonalReportSectionPlan(input.plan, bundle); const agentOutput = validatePersonalReportAgentOutputAgainstPlan(input.agentOutput, plan, bundle); if (bundle.skill.name !== "jyotish-personal-report") { throw new Error("report_writer_skill_package_invalid"); } const d1 = bundle.charts.find((chart) => chart.id === "D1"); const d1Refs = evidenceRefsForTechnique(bundle, "D1"); if (!d1 || !d1.ascendant || d1Refs.length === 0) { throw new ReportEvidenceInsufficientError("d1_evidence_missing"); } const thematicNarrative: ReportDocumentV2["thematicNarrative"] = agentOutput.thematicNarrative.map((section) => ({ id: section.id, theme: section.theme, title: section.title, narrative: section.narrative, actions: [...section.actions], caveats: [...section.caveats], claimStatus: section.claimStatus, evidenceRefs: [...section.evidenceRefs], })); const thematicRefs = uniqueInOrder(thematicNarrative.flatMap((section) => section.evidenceRefs)); const executiveRefs = thematicRefs.length > 0 ? thematicRefs : d1Refs; const chartIds = new Set(["D1", "D2", "D9", "D10", "D11", "D24"]); const charts: ReportDocumentV2["charts"] = []; for (const chart of bundle.charts) { if (!chartIds.has(chart.id)) continue; const refs = evidenceRefsForTechnique(bundle, chart.id); if (refs.length === 0) continue; const statuses = refs.map((ref) => bundle.evidenceRefs.find((entry) => entry.id === ref)?.status ?? "blocked"); const status = statuses.every((entry) => entry === "verified") ? "single_system_inference" : statuses.some((entry) => entry === "blocked") ? "parameter_sensitive" : claimStatusForEvidenceStatus(statuses[0]); charts.push(canonicalBundleChart(chart, refs, status)); } const blockedConflictDisclosure: ReportDocumentV2["blockedConflictDisclosure"] = bundle.blockedSections.map((section) => { const missingEvidence = section.missingTechniqueRefs.map((ref) => ( bundle.executionLedger.find((receipt) => receipt.id === ref)?.technique ?? ref )); const conflictNotes = bundle.conflicts.filter((conflict) => ( conflict.techniqueRefs.some((ref) => section.missingTechniqueRefs.includes(ref)) )).map((conflict) => conflict.summary); return { theme: section.theme, title: section.section, reason: section.reason, missingEvidence, conflictNotes, evidenceRefs: [...section.missingTechniqueRefs], claimStatus: "blocked" as const, }; }); const timingSection = thematicNarrative.find((section) => section.theme === "timing"); const currentPhase: ReportDocumentV2["currentPhase"] = timingSection ? { title: "当前阶段与时间边界", phaseLabel: bundle.answerPolicy.canAnswerPreciseTiming ? "当前阶段" : "当前阶段(方向性)", narrative: timingSection.narrative, timingNotes: [], caveats: [...timingSection.caveats], claimStatus: timingSection.claimStatus, evidenceRefs: [...timingSection.evidenceRefs], } : null; const actionNotes: ReportDocumentV2["actionNotes"] = []; thematicNarrative.forEach((section) => { section.actions.forEach((action, index) => actionNotes.push({ id: `action-${section.theme.replaceAll(".", "-")}-${index + 1}`, title: section.title, note: action, priority: index === 0 ? "now" : index === 1 ? "next" : "watch", evidenceRefs: [...section.evidenceRefs], })); }); if (actionNotes.length === 0) { const fallback = agentOutput.executiveSummary.priorities[0] ?? "将本报告列出的证据边界与待核验事项作为后续行动清单。"; actionNotes.push({ id: "action-evidence-review", title: "后续核验", note: fallback, priority: "now", evidenceRefs: [...executiveRefs], }); } const usedEvidenceRefs = new Set([ ...executiveRefs, ...d1Refs, ...thematicNarrative.flatMap((section) => section.evidenceRefs), ...actionNotes.flatMap((note) => note.evidenceRefs), ...charts.flatMap((chart) => chart.evidenceRefs), ...blockedConflictDisclosure.flatMap((section) => section.evidenceRefs), ]); const appendix = evidenceAppendixFromBundle(bundle, usedEvidenceRefs); const evidenceHash = computeEvidenceHash(appendix); const hasBlockedCoverage = blockedConflictDisclosure.length > 0; const weakestThematicStatus = thematicNarrative.reduce((weakest, section) => ( CLAIM_STATUS_RANK[section.claimStatus] > CLAIM_STATUS_RANK[weakest] ? section.claimStatus : weakest ), "multi_system_consensus"); return { schemaVersion: "report_document.v2", reportId: input.reportId, reportType: bundle.reportType, presentationMode: bundle.presentationMode, depth: input.depth, requestedThemes: [...bundle.requestedThemes], generatedAt: input.generatedAt, subject: { ...bundle.subject }, provenance: { skillName: bundle.skill.name, skillVersion: bundle.skill.version, skillSourceCommit: bundle.skill.sourceCommit, skillSnapshotSha256: bundle.skill.sha256, calculationHash: bundle.calculationProfile.calculationHash, evidenceHash, reportContractVersion: "2", }, executiveSummary: { headline: agentOutput.executiveSummary.headline, summary: agentOutput.executiveSummary.summary, priorities: [...agentOutput.executiveSummary.priorities], overallClaimStatus: thematicNarrative.length === 0 || hasBlockedCoverage ? "blocked" : weakestThematicStatus, evidenceRefs: [...executiveRefs], }, natalFoundation: { title: "本命基础", narrative: `D1 本命盘上升点位于 ${d1.ascendant.sign} ${d1.ascendant.degree.toFixed(2)}°。以下基础结构仅陈述服务器排盘事实,不自行增加未提供的占星推断。`, keyFactors: d1.houses.slice(0, 12).map((house) => ( `第 ${house.number} 宫落 ${house.sign}${house.occupants.length > 0 ? `,宫内对象:${house.occupants.join("、")}` : ""}` )), caveats: bundle.subject.birthTimeStatus === "confirmed" ? ["本节仍须与具体主题证据和执行凭证共同阅读。"] : ["出生时间尚未达到 confirmed;涉及宫位敏感与时间性判断仅可作方向性参考。"], claimStatus: claimStatusForEvidenceStatus( bundle.evidenceRefs.find((entry) => entry.id === d1Refs[0])?.status ?? "blocked", ), evidenceRefs: [...d1Refs], }, currentPhase, actionNotes, charts, thematicNarrative, blockedConflictDisclosure, evidenceAppendix: appendix, disclaimer: PERSONAL_REPORT_DISCLAIMER, }; } // --------------------------------------------------------------------------- // Deterministic post-generation guard // --------------------------------------------------------------------------- export const PRECISE_TIMING_PATTERNS: readonly RegExp[] = [ /20\d{2}\s*年\s*[0-90-9一二三四五六七八九十]{1,2}\s*月(?:\s*[0-90-9一二三四五六七八九十]{1,2}\s*日)?/, /[0-90-9一二三四五六七八九十]{1,2}\s*月\s*[0-90-9一二三四五六七八九十]{1,2}\s*日/, /(?:今年|明年|后年|本月|下月)\s*[0-90-9一二三四五六七八九十]{1,2}\s*月/, /(?:今年|明年|后年)\s*(?:上旬|中旬|下旬)/, ]; export const MEDICAL_DETERMINISTIC_PATTERNS: readonly RegExp[] = [ /(?:必定|一定会|肯定会|必然|百分之百|保证)\s*(?:会|能|将)?\s*(?:得|患|染)(?:上)?(?:癌症|肿瘤|心脏病|糖尿病|绝症|重病|白血病)/, /(?:必定|一定会|肯定会|必然|百分之百|保证)\s*(?:会|能|将)?\s*(?:不孕|流产|难产|残疾|瘫痪|失明|早逝|夭折|猝死)/, /(?:必定|一定会|肯定会|必然|百分之百|保证)\s*(?:治愈|康复|痊愈|好转)/, /必死|必生男|必生女|必然不孕|命中注定(?:会)?(?:死|得病)/, ]; export const LEGAL_DETERMINISTIC_PATTERNS: readonly RegExp[] = [ /(?:必定|一定会|肯定会|必然|百分之百|保证)\s*(?:会|能|将)?\s*(?:胜诉|败诉|无罪|获释|判刑|坐牢|诉讼成功|官司(?:能|会)?赢)/, ]; export const INVESTMENT_DETERMINISTIC_PATTERNS: readonly RegExp[] = [ /(?:必定|一定会|肯定会|必然|百分之百|保证|稳|必)\s*(?:会|能|将)?\s*(?:赚钱|盈利|回本|大涨|暴涨|涨停|翻倍|暴富|亏光)/, /稳赚不赔|必涨|必跌|保本保息|包赚/, ]; export type ForbiddenClaimDomain = "medical" | "legal" | "investment" | "timing"; export type ForbiddenClaim = Readonly<{ domain: ForbiddenClaimDomain; matchedText: string }>; export function findForbiddenDeterministicClaims(textValue: string): ForbiddenClaim[] { const claims: ForbiddenClaim[] = []; const scan = (domain: ForbiddenClaimDomain, patterns: readonly RegExp[]) => { for (const pattern of patterns) { const match = textValue.match(pattern); if (match) claims.push({ domain, matchedText: match[0] }); } }; scan("timing", PRECISE_TIMING_PATTERNS); scan("medical", MEDICAL_DETERMINISTIC_PATTERNS); scan("legal", LEGAL_DETERMINISTIC_PATTERNS); scan("investment", INVESTMENT_DETERMINISTIC_PATTERNS); return claims; } export function splitSentences(textValue: string): string[] { return textValue .split(/(?<=[。!?!?;;])\s*|\n+/u) .map((part) => part.trim()) .filter((part) => part.length > 0); } export type RedactionResult = Readonly<{ text: string; removedCount: number }>; /** * Removes sentences that match the given forbidden patterns. Deterministic * and locale-independent: sentence splitting is punctuation/newline based. */ export function redactDeterministicSentences( textValue: string, patterns: readonly RegExp[], ): RedactionResult { const sentences = splitSentences(textValue); const kept: string[] = []; let removedCount = 0; for (const sentence of sentences) { if (patterns.some((pattern) => pattern.test(sentence))) { removedCount += 1; } else { kept.push(sentence); } } return { text: kept.join(""), removedCount }; } const BLOCKED_SECTION_CAVEAT = "该部分证据受限,已按确定性边界降级,仅保留方向性描述。"; type GuardSection = { id: string; narrative: string; claimStatus: string; evidenceRefs: string[]; caveats: string[]; }; export type ReportGuardReadModel = { executiveSummary: { headline: string; summary: string; overallClaimStatus: string }; sections: GuardSection[]; }; /** Structural projection of a parsed document for guard purposes only. */ export function projectReportGuardReadModel(document: unknown): ReportGuardReadModel | null { const root = record(document); if (!root) return null; const executiveSummary = record(root.executiveSummary); if (!executiveSummary) return null; const headline = text(executiveSummary.headline); const summary = text(executiveSummary.summary); const overallClaimStatus = text(executiveSummary.overallClaimStatus); if (!headline || !summary || !overallClaimStatus) return null; if (!Array.isArray(root.thematicNarrative)) return null; const sections: GuardSection[] = []; for (const item of root.thematicNarrative) { const row = record(item); const id = text(row?.id); const narrative = text(row?.narrative); const claimStatus = text(row?.claimStatus); if (!id || !narrative || !claimStatus) return null; sections.push({ id, narrative, claimStatus, evidenceRefs: stringArray(row?.evidenceRefs), caveats: stringArray(row?.caveats), }); } if (sections.length === 0) { const schemaVersion = text(root.schemaVersion); const blockedDisclosures = Array.isArray(root.blockedConflictDisclosure) ? root.blockedConflictDisclosure : []; if (schemaVersion !== "report_document.v2" || blockedDisclosures.length === 0) return null; } return { executiveSummary: { headline, summary, overallClaimStatus }, sections }; } export type GuardResult = | { ok: true; document: D } | { ok: false; code: "report_guard_rejected"; reason: string }; function effectiveClaimStatus( claimed: string, refs: readonly ReportEvidencePacket["evidenceRefs"][number][], ): string { if (refs.length === 0) return "blocked"; const refById = new Map(refs.map((ref) => [ref.id, ref])); let hasBlocked = false; for (const sectionRef of refs) { if (!refById.has(sectionRef.id) || refById.get(sectionRef.id)!.status === "blocked") { hasBlocked = true; } } if (hasBlocked && refs.every((ref) => refById.get(ref.id)?.status === "blocked")) { return "blocked"; } if (hasBlocked) return "parameter_sensitive"; return claimed; } /** * Deterministic post-generation guard. It never invents data; it only * downgrades, redacts, or rejects. Runs BEFORE the final canonical server * parse, so every mutation is re-validated by the contract. */ export function applyReportGuard( document: D, packet: ReportEvidencePacket, ): GuardResult { const readModel = projectReportGuardReadModel(document); if (!readModel) { return { ok: false, code: "report_guard_rejected", reason: "report_document_unreadable" }; } // 1. evidenceRefs existence: every section ref must exist in the packet // (the packet refs ARE the canonical appendix ids). const packetRefIds = new Set(packet.evidenceRefs.map((ref) => ref.id)); for (const section of readModel.sections) { for (const ref of section.evidenceRefs) { if (!packetRefIds.has(ref)) { return { ok: false, code: "report_guard_rejected", reason: `unresolved_evidence_ref:${ref}` }; } } } const next = structuredClone(document) as JsonRecord; const narrative = Array.isArray(next.thematicNarrative) ? next.thematicNarrative : []; const summary = record(next.executiveSummary); const timingBlocked = !packet.answerPolicy.canAnswerPreciseTiming; const summaryPriorities = Array.isArray(summary?.priorities) ? summary.priorities : []; const summaryTextForScan = [ readModel.executiveSummary.summary, ...summaryPriorities.map(String), ].join("\n"); // 2. Medical / legal / investment deterministic claims are never shippable. for (const section of readModel.sections) { const row = narrative.find((item) => record(item)?.id === section.id); const target = record(row); const sectionText = [ text(target?.narrative) ?? section.narrative, ...(Array.isArray(target?.actions) ? target.actions.map(String) : []), ...(Array.isArray(target?.caveats) ? target.caveats.map(String) : []), ].join("\n"); const hardDomain = findForbiddenDeterministicClaims(sectionText).find( (claim) => claim.domain !== "timing", ); if (hardDomain) { return { ok: false, code: "report_guard_rejected", reason: `deterministic_${hardDomain.domain}_claim`, }; } } if (findForbiddenDeterministicClaims(summaryTextForScan).some((claim) => claim.domain !== "timing")) { return { ok: false, code: "report_guard_rejected", reason: "deterministic_claim_in_summary", }; } if (findForbiddenDeterministicClaims(readModel.executiveSummary.headline).length > 0) { return { ok: false, code: "report_guard_rejected", reason: "deterministic_claim_in_headline", }; } // 3. Precise timing restriction: redact future precise timing from sections // and summary, downgrade affected sections to blocked. const step3Blocked = new Set(); if (timingBlocked) { for (const section of readModel.sections) { const row = narrative.find((item) => record(item)?.id === section.id); const target = record(row); if (!target) continue; const redacted = redactDeterministicSentences( typeof target.narrative === "string" ? target.narrative : "", PRECISE_TIMING_PATTERNS, ); let changed = false; if (redacted.removedCount > 0) { target.narrative = redacted.text; changed = true; } if (Array.isArray(target.actions)) { const keptActions = target.actions .map(String) .filter((action) => !PRECISE_TIMING_PATTERNS.some((pattern) => pattern.test(action))); if (keptActions.length !== target.actions.length) { target.actions = keptActions; changed = true; } } if (Array.isArray(target.caveats)) { const keptCaveats = target.caveats .map(String) .filter((caveat) => !PRECISE_TIMING_PATTERNS.some((pattern) => pattern.test(caveat))); if (keptCaveats.length !== target.caveats.length) { target.caveats = keptCaveats; changed = true; } } if (changed) { step3Blocked.add(section.id); target.claimStatus = "blocked"; const caveats = stringArray(target.caveats); if (!caveats.includes(BLOCKED_SECTION_CAVEAT)) caveats.push(BLOCKED_SECTION_CAVEAT); target.caveats = caveats; } } const summaryRedacted = redactDeterministicSentences( readModel.executiveSummary.summary, PRECISE_TIMING_PATTERNS, ); if (summary) { if (summaryRedacted.removedCount > 0) { summary.summary = summaryRedacted.text; summary.overallClaimStatus = "blocked"; } if (Array.isArray(summary.priorities)) { const keptPriorities = summary.priorities .map(String) .filter((priority) => !PRECISE_TIMING_PATTERNS.some((pattern) => pattern.test(priority))); if (keptPriorities.length !== summary.priorities.length) { summary.priorities = keptPriorities; } } } } // 4. Blocked downgrade from evidence refs: a section whose refs are all // blocked must be blocked; any blocked ref caps the section at // parameter_sensitive. Blocked sections must not keep deterministic // phrasing of any domain. If every section ends blocked, the whole // report is blocked. const refStatuses = new Map(packet.evidenceRefs.map((ref) => [ref.id, ref.status])); let blockedSectionCount = 0; for (const section of readModel.sections) { const row = narrative.find((item) => record(item)?.id === section.id); const target = record(row); if (!target) continue; const sectionRefs = section.evidenceRefs.map((ref) => ( refStatuses.has(ref) ? packet.evidenceRefs.find((candidate) => candidate.id === ref)! : null )).filter((ref): ref is ReportEvidencePacket["evidenceRefs"][number] => ref !== null); target.claimStatus = step3Blocked.has(section.id) ? "blocked" : effectiveClaimStatus(section.claimStatus, sectionRefs); if (target.claimStatus === "blocked") blockedSectionCount += 1; const finalStatus = target.claimStatus as string; if (finalStatus === "blocked") { const narrativeText = typeof target.narrative === "string" ? target.narrative : ""; const blockedClaims = findForbiddenDeterministicClaims(narrativeText); const hardDomain = blockedClaims.find((claim) => claim.domain !== "timing"); if (hardDomain) { return { ok: false, code: "report_guard_rejected", reason: `deterministic_${hardDomain.domain}_claim_in_blocked_section`, }; } const redacted = redactDeterministicSentences(narrativeText, PRECISE_TIMING_PATTERNS); if (redacted.removedCount > 0) { target.narrative = redacted.text; } } } if (summary && (readModel.sections.length === 0 || blockedSectionCount === readModel.sections.length)) { summary.overallClaimStatus = "blocked"; } return { ok: true, document: next as D }; } // --------------------------------------------------------------------------- // Generation pipeline: agent -> document -> guard -> canonical server parse // --------------------------------------------------------------------------- type GeneratePersonalReportBaseDeps = Readonly<{ reportId: string; agent: ReportAgentPort; now?: () => Date; signal?: AbortSignal; }>; export type GeneratePersonalReportDeps = GeneratePersonalReportBaseDeps & Readonly<{ bundle: ReportEvidenceBundleV2; depth: ReportDepth; }>; export type GeneratePersonalReportResult = Readonly< | { status: "ready"; document: ReportDocumentV2; evidenceHash: string } | { status: "failed"; failureCode: "report_schema_invalid" | "report_guard_rejected" } >; /** * Runs the dedicated report agent exactly once (plus its single internal * repair retry), assembles the candidate document, applies the deterministic * guard, then runs the FINAL canonical server parse on the guarded document. * The evidence hash is recomputed from the canonical evidence appendix by the * server contract — never a model self-report. Never falls back to mock, * example, random or sample data. */ export async function generatePersonalReport( deps: GeneratePersonalReportDeps, ): Promise { let bundle: ReportEvidenceBundleV2; let plan: PersonalReportSectionPlan; let agentOutput: PersonalReportAgentOutput; try { bundle = validateReportEvidenceBundleV2(deps.bundle); plan = validatePersonalReportSectionPlan( buildPersonalReportSectionPlan(bundle, deps.depth), bundle, ); agentOutput = await deps.agent.generate(bundle, plan, { signal: deps.signal }); validatePersonalReportAgentOutputAgainstPlan(agentOutput, plan, bundle); } catch { return { status: "failed", failureCode: "report_schema_invalid" }; } const packet = buildLegacyPacketFromBundle(bundle); let candidate: ReportDocumentV2; try { candidate = assembleReportDocumentV2({ reportId: deps.reportId, generatedAt: (deps.now ?? (() => new Date()))().toISOString(), depth: deps.depth, bundle, plan, agentOutput, }); } catch { return { status: "failed", failureCode: "report_schema_invalid" }; } const guarded = applyReportGuard(candidate, packet); 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 { status: "failed", failureCode: "report_schema_invalid" }; } return { status: "ready", document: parsed.document, evidenceHash: computeEvidenceHash(parsed.document.evidenceAppendix), }; }