import { createHash } from "node:crypto"; import { computeEvidenceHash, safeParseServerReportDocument, } from "./personal-report-contract.server-core.ts"; import { REQUIRED_THEME_CHARTS, type ClaimStatus, type EvidenceAppendix, type ReportDepth, type ReportDocumentV1, type ReportDocumentV2, } from "./personal-report-contract.ts"; import { PersonalReportAgentOutputError, type EvidenceRefStatus, type PersonalReportAgentOutput, type ReportAgentPort, type ReportEvidenceBundleV2, type ReportEvidencePacket, type ReportPlanetFact, } from "@/mastra/personal-report"; import type { EvidenceConflict, ReportChartFact, ReportClaimCard, ReportCurrentDashaFact, ReportFunctionalRole, ReportFunctionalRoleFact, ReportInterpretiveFacts, ReportSavScoreFact, ReportShadbalaRankFact, ReportThemeNarrativeSeed, ReportYogaCategory, ReportYogaFact, SafeReportSubject, TechniqueExecutionReceipt, } from "./report-evidence-bundle-v2.ts"; import { REPORT_YOGA_CATEGORIES, 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, sectionOutputTokenBudget, summaryOutputTokenBudget, validatePersonalReportSectionPlan, type PersonalReportSectionPlan, type ReportSectionPlanEntry, } from "./personal-report-plan.ts"; import type { PersonalReportSectionRecord, PersonalReportSectionService } from "./personal-report-section-service-core.ts"; // Compatibility re-export: prefer importing from ./personal-report-codes.ts // directly (the pure, dependency-free codes module). export { REPORT_STABLE_CODES } from "./personal-report-codes.ts"; /** * 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; } const DOCUMENT_VARGA_CHART_IDS = ["D2", "D9", "D10", "D11", "D24"] as const; type DocumentVargaChartId = (typeof DOCUMENT_VARGA_CHART_IDS)[number]; const VARGA_KEY_ALIASES: Readonly> = { D2: ["D2_Hora", "D2"], D9: ["D9_Navamsa", "D9"], D10: ["D10_Dasamsa", "D10"], D11: ["D11_Rudramsa", "D11"], D24: ["D24_Chaturvimsamsa", "D24_Siddhamsa", "D24"], }; function canonicalDocumentVargaChartId(rawId: string): DocumentVargaChartId | null { const match = /^(D(?:24|11|10|9|2))(?:[_\s-].*)?$/.exec(rawId.trim().toUpperCase()); return match ? match[1] as DocumentVargaChartId : null; } const VARGA_ENGINE_META_KEYS = new Set([ "Ascendant", "ascendant", "planets", "house_chart", "division", "name", "meaning", ]); function vargaPlanetHouse(row: JsonRecord, ascIndex: number): number | null { const declared = finiteNumber(row.house); if (declared !== null && Number.isInteger(declared) && declared >= 1 && declared <= 12) { return declared; } const planetIndex = signIndex(row.sign_index ?? row.sign_idx ?? row.sign); if (planetIndex === null) return null; return (((planetIndex - ascIndex) % 12) + 12) % 12 + 1; } function collectVargaOccupants( entries: Iterable, ascIndex: number, ): string[][] { const occupants: string[][] = Array.from({ length: 12 }, () => []); for (const [name, item] of entries) { const row = record(item); if (!row) continue; const house = vargaPlanetHouse(row, ascIndex); if (house === null) continue; const safeName = safeCelestialName(name); if (safeName) occupants[house - 1].push(safeName); } return occupants; } /** * Divisional charts live under modules.varga_full with keys such as D2_Hora / * D9_Navamsa / D10_Dasamsa / D11_Rudramsa (or D2 / D9 / D10 / D11). * * The live engine shape is {ascendant: {sign, sign_index, degree}, planets: * {Sun: {sign, sign_index, house, degree}, ...}, house_chart, division, name, * meaning}. An older fixture shape used {Ascendant: {sign_idx|sign}, : * {sign_idx|sign}, _meta, ...}. Both are accepted. House signs are whole-sign * from the divisional ascendant. Occupants come from planets.house when * present, otherwise from sign index. house_chart and unknown keys are not * copied. Occupant names go through the celestial allowlist. */ function deriveVargaHousesFromEngine( varga: JsonRecord, ): Readonly<{ ascIndex: number; degree: number; houses: ReportEvidencePacket["chart"]["houses"]; }> | null { const ascendant = record(varga.ascendant) ?? record(varga.Ascendant); const ascIndex = ascendant ? signIndex(ascendant.sign_index ?? ascendant.sign_idx ?? ascendant.sign) : null; if (ascIndex === null) return null; const planets = record(varga.planets); const occupants = planets ? collectVargaOccupants(Object.entries(planets), ascIndex) : collectVargaOccupants( Object.entries(varga).filter(([name]) => ( !name.startsWith("_") && !VARGA_ENGINE_META_KEYS.has(name) )), ascIndex, ); return { ascIndex, degree: finiteNumber(ascendant?.degree ?? ascendant?.degree_in_sign) ?? 0, houses: Array.from({ length: 12 }, (_, index) => ({ number: index + 1, sign: SIGNS[((ascIndex + index) % 12 + 12) % 12], signDerived: true, occupants: occupants[index].slice(0, 12), })), }; } function readVargaHouses( vargaFull: JsonRecord | null, ): ReportEvidencePacket["chart"]["vargaHouses"] { if (!vargaFull) return []; const result: ReportEvidencePacket["chart"]["vargaHouses"][number][] = []; const seen = new Set(); const take = (id: DocumentVargaChartId, raw: unknown): void => { if (seen.has(id)) return; const varga = record(raw); if (!varga) return; const derived = deriveVargaHousesFromEngine(varga); if (!derived) return; seen.add(id); result.push({ id, houses: derived.houses }); }; for (const id of DOCUMENT_VARGA_CHART_IDS) { for (const key of VARGA_KEY_ALIASES[id]) take(id, vargaFull[key]); } for (const [rawId, rawValue] of Object.entries(vargaFull)) { const id = canonicalDocumentVargaChartId(rawId); if (id) take(id, rawValue); } 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"], ["true_pushya", "True Pushya"], ]); 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[] = []; const seen = new Set(); const take = (id: DocumentVargaChartId, raw: unknown): void => { if (seen.has(id)) return; const varga = record(raw); if (!varga) return; const derived = deriveVargaHousesFromEngine(varga); if (!derived) return; seen.add(id); charts.push({ id, title: `${id} 分盘`, ascendant: { sign: SIGNS[derived.ascIndex], degree: derived.degree, }, houses: derived.houses, planets: [], }); }; for (const id of DOCUMENT_VARGA_CHART_IDS) { for (const key of VARGA_KEY_ALIASES[id]) take(id, vargaFull[key]); } for (const [rawId, rawValue] of Object.entries(vargaFull)) { const id = canonicalDocumentVargaChartId(rawId); if (id) take(id, rawValue); } 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 .flatMap((chart) => { const id = canonicalDocumentVargaChartId(chart.id); return id ? [{ 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, }; } // --------------------------------------------------------------------------- // Interpretive facts + theme narrative seeds (server-owned allowlist) // // The engine already computes a functional benefic/malefic table, a shadbala // ranking, SAV scores, the current Vimshottari maha/antardasha, detected yogas // and guided-topic copy. Before this layer the report packet threw all of it // away, so the claim cards could only carry execution receipts. Extraction is // allowlist-style: unknown keys are never copied, planet/sign/house values go // through the existing safe projections, and free text is length-capped and // scrubbed of internal vocabulary before it can reach the writer prompt. // --------------------------------------------------------------------------- /** * Substrings that disqualify a whole seed line. Server-generated narratives can * mention internal routes, vendor names or blocked-layer bookkeeping; the * writer prompt must never see those, so a line containing any of them is * dropped rather than rewritten. */ const FORBIDDEN_SEED_TOKENS = [ "vedastro", "mevg", "workflow", "references/", "scripts/", "modules.", "skill", "prompt", "swisseph", "ephemeris", "oracle", "engine", "sha256", "schema", "fallback", "blocked", "not_found", "not_available", "json", "http", "localhost", "strict_", "_strict", "api", "pipeline", "backend", ]; /** Trims, flattens whitespace and rejects lines carrying internal vocabulary. */ function safeSeedText(value: unknown, max: number): string | null { if (typeof value !== "string") return null; const flattened = value .replace(/[\u0000-\u001f\u007f]+/g, " ") .replace(/\s+/g, " ") .trim(); if (flattened.length === 0) return null; const lowered = flattened.toLowerCase(); if (FORBIDDEN_SEED_TOKENS.some((token) => lowered.includes(token))) return null; if (!/[\p{L}\p{N}]/u.test(flattened)) return null; return flattened.slice(0, max); } /** Same bound, but for machine-ish domain labels (convergence buckets). */ function safeDomainLabel(value: unknown): string | null { const raw = typeof value === "string" ? value : text(record(value)?.domain); if (!raw) return null; const cleaned = raw.replace(/[^\p{L}\p{N}_ /·-]+/gu, "").trim(); return cleaned.length > 0 ? cleaned.slice(0, 80) : null; } const YOGA_CATEGORY_ALIASES: Readonly> = { raja: "raja", raja_yoga: "raja", neecha_bhanga: "raja", dhana: "dhana", dhana_yoga: "dhana", wealth: "dhana", mahapurusha: "mahapurusha", pancha_mahapurusha: "mahapurusha", nabhasa: "nabhasa", chandra: "lunar", chandra_extended: "lunar", lunar_yoga: "lunar", lunar: "lunar", surya: "solar", solar_yoga: "solar", solar: "solar", kalatra: "relationship", relationship: "relationship", marriage: "relationship", putra: "progeny", progeny: "progeny", vidya: "education", education: "education", ayur: "health", health: "health", ari: "health", moksha: "spiritual", guru: "spiritual", aryama: "spiritual", asha: "spiritual", shakti: "spiritual", karma: "spiritual", spiritual: "spiritual", durbhaga: "affliction", affliction: "affliction", conjunction: "conjunction", auspicious: "auspicious", special: "special", extended: "extended", }; function safeYogaCategory(value: unknown): ReportYogaCategory { const raw = text(value); if (!raw) return "other"; const key = raw.trim().toLowerCase().replace(/[ -]+/g, "_"); const alias = YOGA_CATEGORY_ALIASES[key]; if (alias) return alias; return (REPORT_YOGA_CATEGORIES as readonly string[]).includes(key) ? key as ReportYogaCategory : "other"; } const YOGA_NAME_PATTERN = /^[\p{L}\p{N} ()/·,.'’++-]{1,80}$/u; function safeYogaName(value: unknown): string | null { const raw = text(value); if (!raw) return null; const trimmed = raw.replace(/\s+/g, " ").trim().slice(0, 80); return YOGA_NAME_PATTERN.test(trimmed) ? trimmed : null; } function safeHouseNumbers(value: unknown): number[] { if (!Array.isArray(value)) return []; const houses = value .map((item) => finiteNumber(item)) .filter((item): item is number => item !== null && Number.isInteger(item) && item >= 1 && item <= 12); return [...new Set(houses)].sort((a, b) => a - b); } function readEvidenceSnapshot(workflow: JsonRecord): JsonRecord | null { const chart = record(workflow.chart) ?? {}; const pack = record(chart.ai_prompt_pack); return pack ? record(pack.evidence_snapshot) : null; } function readChartModules(workflow: JsonRecord): JsonRecord { return record(record(workflow.chart)?.modules) ?? {}; } function charaKarakaPlanetPresent( karakas: JsonRecord | null, keys: readonly string[], ): boolean { if (!karakas) return false; return keys.some((key) => { const planet = text(record(karakas[key])?.planet); return Boolean(planet && safeCelestialName(planet)); }); } /** * Functional benefic/malefic table. `yogakarakas` wins over the benefic and * malefic lists so a planet is never reported twice. */ function readFunctionalRoles(workflow: JsonRecord, evidenceRef: string): ReportFunctionalRoleFact[] { const snapshot = readEvidenceSnapshot(workflow); const modules = readChartModules(workflow); const layer = record(snapshot?.functional_benefic_malefic) ?? record(modules.functional_benefic_malefic); if (!layer) return []; if (text(layer.status) === "blocked") return []; const owned = record(layer.owned_houses) ?? {}; const buckets: readonly Readonly<[ReportFunctionalRole, unknown]>[] = [ ["yogakaraka", layer.yogakarakas], ["benefic", layer.functional_benefics], ["malefic", layer.functional_malefics], ["neutral", layer.functional_neutrals], ]; const roles = new Map(); for (const [role, source] of buckets) { for (const raw of stringArray(source)) { const planet = safeCelestialName(raw); if (!planet || roles.has(planet)) continue; roles.set(planet, { planet, role, ownedHouses: safeHouseNumbers(owned[planet] ?? owned[raw]), evidenceRef, }); } } return [...roles.values()].slice(0, 12); } /** * Detected yogas only. The engine also ships the full candidate rule table * under modules.yogas with `hit: false`; those are NOT present in the chart and * must never be copied. */ function readYogaFacts(workflow: JsonRecord, evidenceRef: string): ReportYogaFact[] { const chart = record(workflow.chart) ?? {}; const modules = readChartModules(workflow); const rows: unknown[] = []; if (Array.isArray(chart.yogas)) rows.push(...chart.yogas); const yogaModule = record(modules.yogas) ?? record(modules.yoga); if (yogaModule && Array.isArray(yogaModule.yogas)) { rows.push(...yogaModule.yogas.filter((item) => record(item)?.hit === true)); } const facts = new Map(); for (const item of rows) { const row = record(item); if (!row) continue; const name = safeYogaName(row.name ?? row.yoga ?? row.title); if (!name) continue; const key = name.toLowerCase(); if (facts.has(key)) continue; const planets = stringArray(row.planets) .flatMap((raw) => { const safe = safeCelestialName(raw); return safe ? [safe] : []; }); facts.set(key, { name, category: safeYogaCategory(row.category ?? row.cat), planets: [...new Set(planets)].slice(0, 9), evidenceRef, }); if (facts.size >= 40) break; } return [...facts.values()]; } function readShadbalaRanking(workflow: JsonRecord): ReportShadbalaRankFact[] { const snapshot = readEvidenceSnapshot(workflow); const chart = record(workflow.chart) ?? {}; const strength = record(snapshot?.strength); const rawRows: Readonly<{ planet: unknown; rank: unknown; rupa: unknown }>[] = []; if (Array.isArray(strength?.shadbala_ranking)) { for (const item of strength.shadbala_ranking) { const row = record(item); if (!row) continue; rawRows.push({ planet: row.planet ?? row.name, rank: row.rank, rupa: row.total_rupas ?? row.rupas }); } } else { for (const [planet, item] of Object.entries(record(chart.shadbala) ?? {})) { const row = record(item); if (!row) continue; rawRows.push({ planet, rank: row.rank, rupa: row.total_rupas ?? row.rupas }); } } const parsed = rawRows.flatMap((row) => { const planetName = text(row.planet); const planet = planetName ? safeCelestialName(planetName) : null; if (!planet) return []; const rupa = finiteNumber(row.rupa); const declaredRank = finiteNumber(row.rank); return [{ planet, declaredRank: declaredRank !== null && Number.isInteger(declaredRank) && declaredRank >= 1 && declaredRank <= 9 ? declaredRank : null, rupa: rupa !== null && rupa >= 0 && rupa <= 100 ? rupa : null, }]; }); const deduped = [...new Map(parsed.map((row) => [row.planet, row])).values()]; const ordered = [...deduped].sort((a, b) => { if (a.declaredRank !== null && b.declaredRank !== null) return a.declaredRank - b.declaredRank; return (b.rupa ?? -1) - (a.rupa ?? -1) || a.planet.localeCompare(b.planet); }); // Ranks are re-derived from the ordering so a partial or duplicated engine // rank can never produce two planets at the same rank. return ordered.slice(0, 9).map((row, index): ReportShadbalaRankFact => ({ planet: row.planet, rank: index + 1, rupa: row.rupa, })); } function readSavFacts( workflow: JsonRecord, ascendantSignIndex: number | null, ): Readonly<{ scores: ReportSavScoreFact[]; total: number | null }> { const snapshot = readEvidenceSnapshot(workflow); const modules = readChartModules(workflow); const sav = record(record(modules.ashtakavarga)?.sav); const strength = record(snapshot?.strength); const rawScores = record(sav?.scores) ?? record(strength?.sav_scores); const rawTotal = finiteNumber(sav?.total ?? strength?.sav_total); const scores = new Map(); for (const [key, value] of Object.entries(rawScores ?? {})) { const score = finiteNumber(value); if (score === null || score < 0 || score > 100) continue; const houseKey = /^house_(\d{1,2})$/.exec(key)?.[1] ?? (/^\d{1,2}$/.test(key) ? key : null); let house: number | null = null; if (houseKey) { const parsed = Number.parseInt(houseKey, 10); house = parsed >= 1 && parsed <= 12 ? parsed : null; } else if (ascendantSignIndex !== null) { const index = signIndex(key); house = index === null ? null : (((index - ascendantSignIndex) % 12) + 12) % 12 + 1; } if (house === null || scores.has(house)) continue; scores.set(house, score); } return { scores: [...scores.entries()].map(([house, score]) => ({ house, score })).sort((a, b) => a.house - b.house), total: rawTotal !== null && rawTotal >= 0 && rawTotal <= 1000 ? rawTotal : null, }; } function readCurrentDasha(workflow: JsonRecord): ReportCurrentDashaFact | null { const snapshot = readEvidenceSnapshot(workflow); const modules = readChartModules(workflow); const subPeriods = record(record(modules.dasha_sub_periods)?.current); const timingVimshottari = record(record(snapshot?.timing)?.vimshottari); const dashaCurrent = record(record(modules.dasha)?.current_dasha); const candidates: Readonly<{ maha: unknown; antar: unknown; start: unknown; end: unknown; }>[] = []; if (subPeriods) { const maha = record(subPeriods.mahadasha); const antar = record(subPeriods.antardasha); candidates.push({ maha: maha?.lord, antar: antar?.lord, start: maha?.start, end: maha?.end, }); } if (timingVimshottari) { candidates.push({ maha: timingVimshottari.mahadasha, antar: timingVimshottari.antardasha, start: timingVimshottari.start, end: timingVimshottari.end, }); } if (dashaCurrent) { candidates.push({ maha: dashaCurrent.lord, antar: record(dashaCurrent.antardasha)?.lord, start: dashaCurrent.start, end: dashaCurrent.end, }); } for (const candidate of candidates) { const mahaName = text(candidate.maha); const mahadasha = mahaName ? safeCelestialName(mahaName) : null; const antarName = text(candidate.antar); const antardasha = antarName ? safeCelestialName(antarName) : null; const startText = text(candidate.start); const endText = text(candidate.end); const start = startText ? safeDashaDate(startText) : null; const end = endText ? safeDashaDate(endText) : null; if (mahadasha && start && end && Date.parse(start) < Date.parse(end)) { return { mahadasha, antardasha, start, end }; } } return null; } function readConvergenceDomains(workflow: JsonRecord): string[] { const snapshot = readEvidenceSnapshot(workflow); const modules = readChartModules(workflow); const rows = Array.isArray(record(snapshot?.timing)?.convergence_top_domains) ? record(snapshot?.timing)!.convergence_top_domains as unknown[] : Array.isArray(record(modules.dasa_convergence)?.top_convergent_domains) ? record(modules.dasa_convergence)!.top_convergent_domains as unknown[] : []; const domains: string[] = []; for (const item of rows) { const label = safeDomainLabel(item); if (label && !domains.includes(label)) domains.push(label); if (domains.length >= 6) break; } return domains; } /** * Server-side thematic narrative payloads. Present in the engine's own * `_build_ai_prompt_pack`; the consultation API's chart prompt pack currently * omits them, so this returns an empty map there instead of inventing text. */ const NARRATIVE_SNAPSHOT_THEMES: Readonly> = { career_narrative: "career", relationship_narrative: "marriage", finance_narrative: "wealth", }; const STRICT_MODULE_THEMES: Readonly> = { career_strict_evidence: "career", relationship_strict_evidence: "marriage", finance_strict_evidence: "wealth", }; type NarrativeDraft = { headline: string | null; strengths: string[]; risks: string[]; boundaries: string[] }; function readNarrativePayload(value: unknown): NarrativeDraft | null { const row = record(value); if (!row) return null; const headline = safeSeedText(row.headline, 300); const pick = (source: unknown) => stringArray(source) .flatMap((item) => { const safe = safeSeedText(item, 400); return safe ? [safe] : []; }) .slice(0, 8); const strengths = pick(row.strengths); const risks = pick(row.risks); const boundaries = pick(row.boundaries); if (!headline && strengths.length === 0 && risks.length === 0 && boundaries.length === 0) return null; return { headline, strengths, risks, boundaries }; } function readEngineNarratives(workflow: JsonRecord): Map { const snapshot = readEvidenceSnapshot(workflow); const modules = readChartModules(workflow); const drafts = new Map(); for (const [key, theme] of Object.entries(NARRATIVE_SNAPSHOT_THEMES)) { const draft = readNarrativePayload(snapshot?.[key]); if (draft) drafts.set(theme, draft); } for (const [key, theme] of Object.entries(STRICT_MODULE_THEMES)) { if (drafts.has(theme)) continue; const draft = readNarrativePayload(record(modules[key])?.user_narrative); if (draft) drafts.set(theme, draft); } return drafts; } /** * Guided-topic copy. Only the three topics that map onto a report theme are * used; `birth_time_rectification` is a product-flow topic, not a chart * reading, and is deliberately skipped. */ const GUIDED_TOPIC_THEMES: Readonly> = { relationship_partnership: "marriage", career_direction: "career", wealth_risk: "wealth", }; function readGuidedTopicDrafts(workflow: JsonRecord): Map { const modules = readChartModules(workflow); const topics = Array.isArray(modules.guided_topics) ? modules.guided_topics : []; const drafts = new Map(); for (const item of topics) { const row = record(item); if (!row) continue; const theme = GUIDED_TOPIC_THEMES[text(row.id) ?? ""]; if (!theme || drafts.has(theme)) continue; const headline = safeSeedText(row.title, 300); const strengths = [row.reality_value, row.why_worth_exploring] .flatMap((value) => { const safe = safeSeedText(value, 400); return safe ? [safe] : []; }); const monthly = record(row.monthly_adjudication_summary); const risks = [record(monthly?.friction_source)?.value] .flatMap((value) => { const safe = safeSeedText(value, 400); return safe ? [safe] : []; }); const boundaries = [record(monthly?.time_confidence)?.value] .flatMap((value) => { const safe = safeSeedText(value, 400); return safe ? [safe] : []; }); if (!headline && strengths.length === 0) continue; drafts.set(theme, { headline, strengths: strengths.slice(0, 8), risks: risks.slice(0, 8), boundaries: boundaries.slice(0, 8), }); } return drafts; } /** SAV houses that actually carry the theme's meaning. */ const THEME_SAV_HOUSES: Readonly> = { career: [10, 6], marriage: [7], wealth: [2, 11], education: [4, 5], migration_home: [4, 12], family: [4, 5], health_pressure: [6], timing: [1], general: [1, 10], }; function joinNames(values: readonly string[], limit: number): string { return values.slice(0, limit).join("、"); } /** * Deterministic interpretive lines composed from the structured facts above. * Every value is a closed-vocabulary planet/sign name, a house number or an * engine-computed number — no model output and no free text from the request. */ function buildInterpretiveThemeLines( theme: string, facts: ReportInterpretiveFacts, ascendantSign: string | null, ): string[] { const lines: string[] = []; const benefics = facts.functionalRoles.filter((role) => role.role === "benefic").map((role) => role.planet); const malefics = facts.functionalRoles.filter((role) => role.role === "malefic").map((role) => role.planet); const yogakarakas = facts.functionalRoles.filter((role) => role.role === "yogakaraka"); if (ascendantSign && facts.functionalRoles.length > 0) { lines.push( `本命上升为 ${ascendantSign};按宫主判定的功能吉星为 ${joinNames(benefics, 4) || "无"},功能凶星为 ${joinNames(malefics, 4) || "无"}。`, ); } // Theme-specific lines come first so the two-line claim conclusion differs // per chapter instead of repeating the chart identity everywhere. const savHouses = THEME_SAV_HOUSES[theme] ?? []; const savLine = facts.savScores .filter((row) => savHouses.includes(row.house)) .map((row) => `第 ${row.house} 宫 ${row.score} 分`) .join(","); if (savLine) { const total = facts.savTotal === null ? "" : `(全盘合计 ${facts.savTotal} 分)`; lines.push(`本主题相关宫位的八分力总分:${savLine}${total}。`); } if (facts.currentDasha) { const antar = facts.currentDasha.antardasha ? `、副运 ${facts.currentDasha.antardasha}` : ""; lines.push( `当前处于 ${facts.currentDasha.mahadasha} 主运${antar},主运区间 ${facts.currentDasha.start} 至 ${facts.currentDasha.end}。`, ); } for (const karaka of yogakarakas.slice(0, 2)) { const houses = karaka.ownedHouses.length > 0 ? `同时主管第 ${karaka.ownedHouses.join("、")} 宫,` : ""; lines.push(`${karaka.planet} ${houses}在本盘属 yogakaraka,是最值得依靠的行动主轴。`); } if (facts.yogas.length > 0) { lines.push(`本盘已成立的组合:${joinNames(facts.yogas.map((yoga) => yoga.name), 4)}。`); } if (facts.shadbalaRanking.length >= 3) { const strongest = facts.shadbalaRanking.slice(0, 3).map((row) => row.planet); const weakest = facts.shadbalaRanking[facts.shadbalaRanking.length - 1]; lines.push(`六分力相对强弱:${joinNames(strongest, 3)} 居前,${weakest.planet} 垫底,说明发力顺序而非绝对好坏。`); } if (facts.convergenceDomains.length > 0) { lines.push(`多套时间系统同时指向的领域:${joinNames(facts.convergenceDomains, 4)}。`); } return lines.flatMap((line) => { const safe = safeSeedText(line, 400); return safe ? [safe] : []; }); } /** * 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); } } for (const { workflow } of packets) { const modules = readChartModules(workflow); if (text(record(modules.transits)?.status)?.toLowerCase() === "executed") { upsertReceipt("Transit", "partial", true); } const karakas = record(record(modules.jaimini)?.chara_karakas); if (charaKarakaPlanetPresent(karakas, ["AmK", "Amatyakaraka"])) { upsertReceipt("AmK", "partial", true); } if (charaKarakaPlanetPresent(karakas, ["DK", "Darakaraka"])) { upsertReceipt("DK", "partial", true); } } // --- interpretive facts ------------------------------------------------- // Bound to receipts that already exist in the ledger; a layer without a // receipt is skipped rather than given a synthetic ref. const receiptIdFor = (aliases: readonly string[]): string | null => { const receipt = [...receiptsByKey.values()].find((candidate) => ( candidate.executed && candidate.status !== "blocked" && techniqueMatches(candidate, aliases) )); return receipt?.id ?? null; }; const functionalRef = receiptIdFor(["Functional Benefic/Malefic"]); const yogaRef = receiptIdFor(["Yoga"]); const ashtakavargaRef = receiptIdFor(["Ashtakavarga"]); const vimshottariRef = receiptIdFor(["Vimshottari"]); const d1Ref = receiptIdFor(["D1"]); const baseAscendantSignIndex = base.packet.chart.ascendant ? signIndex(base.packet.chart.ascendant.sign) : null; const baseAscendantSign = base.packet.chart.ascendant ? safeChartSign(base.packet.chart.ascendant.sign) : null; const collectFirst = (read: (workflow: JsonRecord) => readonly T[]): T[] => { for (const { workflow } of packets) { const rows = read(workflow); if (rows.length > 0) return [...rows]; } return []; }; const savFacts = (() => { for (const { workflow } of packets) { const result = readSavFacts(workflow, baseAscendantSignIndex); if (result.scores.length > 0 || result.total !== null) return result; } return { scores: [] as ReportSavScoreFact[], total: null as number | null }; })(); const currentDasha = (() => { for (const { workflow } of packets) { const dasha = readCurrentDasha(workflow); if (dasha) return dasha; } return null; })(); const interpretiveFacts: ReportInterpretiveFacts = { yogas: yogaRef ? collectFirst((workflow) => readYogaFacts(workflow, yogaRef)) : [], functionalRoles: functionalRef ? collectFirst((workflow) => readFunctionalRoles(workflow, functionalRef)) : [], shadbalaRanking: collectFirst(readShadbalaRanking), savScores: ashtakavargaRef ? savFacts.scores : [], savTotal: ashtakavargaRef ? savFacts.total : null, currentDasha: vimshottariRef ? currentDasha : null, convergenceDomains: collectFirst(readConvergenceDomains), }; // --- theme narrative seeds ---------------------------------------------- const engineNarratives = new Map(); const guidedDrafts = new Map(); for (const { workflow } of packets) { for (const [theme, draft] of readEngineNarratives(workflow)) { if (!engineNarratives.has(theme)) engineNarratives.set(theme, draft); } for (const [theme, draft] of readGuidedTopicDrafts(workflow)) { if (!guidedDrafts.has(theme)) guidedDrafts.set(theme, draft); } } const seedEvidenceRefs = [ ...(d1Ref ? [d1Ref] : []), ...(functionalRef ? [functionalRef] : []), ...(yogaRef ? [yogaRef] : []), ...(ashtakavargaRef ? [ashtakavargaRef] : []), ...(vimshottariRef ? [vimshottariRef] : []), ]; const buildThemeSeed = (theme: string): ReportThemeNarrativeSeed | null => { const engineDraft = engineNarratives.get(theme) ?? null; const guidedDraft = guidedDrafts.get(theme) ?? null; const interpretiveLines = buildInterpretiveThemeLines(theme, interpretiveFacts, baseAscendantSign); // The engine's own thematic narrative headline is already conclusion // shaped, so it wins. Without one, the first chart-specific interpretive // line becomes the headline; guided-topic copy is generic framing and only // ever lands in the tail of strengths. const [headline, leadStrengths] = engineDraft?.headline ? [engineDraft.headline, interpretiveLines] : interpretiveLines.length > 0 ? [interpretiveLines[0], interpretiveLines.slice(1)] : [guidedDraft?.headline ?? null, []]; if (!headline) return null; const strengths = [ ...leadStrengths, ...(engineDraft?.strengths ?? []), ...(guidedDraft?.strengths ?? []), ].slice(0, 8); return { theme, headline, strengths, risks: [...(engineDraft?.risks ?? []), ...(guidedDraft?.risks ?? [])].slice(0, 8), boundaries: [...(engineDraft?.boundaries ?? []), ...(guidedDraft?.boundaries ?? [])].slice(0, 8), evidenceRefs: [...new Set(seedEvidenceRefs)].slice(0, 24), }; }; /** Sentence-joins deterministic seed lines without gluing clauses together. */ const joinSeedSentences = (parts: readonly string[]): string => parts .map((part) => (/[。!?;.!?;]$/.test(part) ? part : `${part}。`)) .join(""); const claimCards: ReportClaimCard[] = []; const themeNarrativeSeeds: ReportThemeNarrativeSeed[] = []; 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"); // The assertionLevel derivation is unchanged: richer content must never // raise certainty. Only the seed-less fallback may lower it. const derivedLevel: ClaimStatus = allVerified && uniqueMatched.length >= 2 ? "multi_system_consensus" : allVerified ? "single_system_inference" : "parameter_sensitive"; const seed = buildThemeSeed(plan.theme); if (seed) themeNarrativeSeeds.push(seed); const conclusion = seed ? joinSeedSentences([seed.headline, ...seed.strengths.slice(0, 2)]).slice(0, 1200) : `服务器已闭合${plan.section}所需的最低证据组;本节只能在所列事实与确定性级别内解释。`; const assertionLevel: ClaimStatus = seed ? derivedLevel : derivedLevel === "multi_system_consensus" ? "single_system_inference" : derivedLevel; const themeSlug = evidenceSlug(plan.theme, "theme"); const receiptFactValue = (technique: string): string => { if (technique === "D1" && baseAscendantSign) { const roles = interpretiveFacts.functionalRoles.length > 0 ? `;功能吉星 ${joinNames(interpretiveFacts.functionalRoles.filter((role) => role.role === "benefic").map((role) => role.planet), 4) || "无"},功能凶星 ${joinNames(interpretiveFacts.functionalRoles.filter((role) => role.role === "malefic").map((role) => role.planet), 4) || "无"}` : ""; return `本命上升 ${baseAscendantSign}${roles}`; } if (technique === "Vimshottari" && interpretiveFacts.currentDasha) { const antar = interpretiveFacts.currentDasha.antardasha ? `、副运 ${interpretiveFacts.currentDasha.antardasha}` : ""; return `当前 ${interpretiveFacts.currentDasha.mahadasha} 主运${antar}(${interpretiveFacts.currentDasha.start} 至 ${interpretiveFacts.currentDasha.end})`; } if (technique === "Yoga" && interpretiveFacts.yogas.length > 0) { return `已成立组合:${joinNames(interpretiveFacts.yogas.map((yoga) => yoga.name), 4)}`; } if (technique === "Ashtakavarga" && interpretiveFacts.savScores.length > 0) { const houses = THEME_SAV_HOUSES[plan.theme] ?? []; const scored = interpretiveFacts.savScores .filter((row) => houses.includes(row.house)) .map((row) => `第 ${row.house} 宫 ${row.score} 分`) .join(","); const total = interpretiveFacts.savTotal === null ? "" : `,全盘合计 ${interpretiveFacts.savTotal} 分`; return `八分力:${scored || "本主题宫位未取得分值"}${total}`; } if (technique === "Functional Benefic/Malefic" && interpretiveFacts.functionalRoles.length > 0) { const karaka = interpretiveFacts.functionalRoles.find((role) => role.role === "yogakaraka"); return `功能吉凶已判定${karaka ? `,${karaka.planet} 为 yogakaraka` : ""}`; } return `${technique} 已执行并纳入本主题证据计划`; }; const supportingFacts = uniqueMatched.map((receipt, index) => ({ id: `ev-fact-${themeSlug}-${index + 1}`, label: receipt.technique.slice(0, 160), value: receiptFactValue(receipt.technique).slice(0, 800), evidenceRef: receipt.id, status: receipt.status, })); // Universal interpretive layers ride along even when the theme's minimum // evidence group does not require them; they stay bound to their own // receipt so nothing is attributed to a technique that did not run. const matchedIds = new Set(uniqueMatched.map((receipt) => receipt.id)); const universalLayers: readonly Readonly<[string | null, string]>[] = [ [functionalRef, "Functional Benefic/Malefic"], [yogaRef, "Yoga"], [ashtakavargaRef, "Ashtakavarga"], [vimshottariRef, "Vimshottari"], ]; for (const [ref, technique] of universalLayers) { if (!ref || matchedIds.has(ref)) continue; const receipt = [...receiptsByKey.values()].find((candidate) => candidate.id === ref); if (!receipt) continue; const value = receiptFactValue(technique); if (value === `${technique} 已执行并纳入本主题证据计划`) continue; supportingFacts.push({ id: `ev-fact-${themeSlug}-extra-${evidenceSlug(technique, "layer")}`, label: technique.slice(0, 160), value: value.slice(0, 800), evidenceRef: ref, status: receipt.status, }); } const counterFactRef = uniqueMatched[0]; const counterFacts = counterFactRef ? (seed?.risks ?? []).slice(0, 8).map((risk, index) => ({ id: `ev-cfact-${themeSlug}-${index + 1}`, label: "风险边界", value: risk.slice(0, 800), evidenceRef: counterFactRef.id, status: counterFactRef.status, })) : []; claimCards.push({ id: `ev-claim-${themeSlug}`, theme: plan.theme, section: plan.section, conclusion, supportingFacts: supportingFacts.slice(0, 40), 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 demoteThemesMissingRequiredCharts(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, interpretiveFacts, themeNarrativeSeeds, blockedSections, conflicts, executionLedger, evidenceRefs: executionLedger.map((receipt) => ({ id: receipt.id, technique: receipt.technique, status: receipt.status, })), answerPolicy: { canAnswerPreciseTiming, birthTimePolicy, deterministicClaimsForbiddenFor, }, })); } function requiredStructuredChartsForTheme(theme: string): readonly string[] { return REQUIRED_THEME_CHARTS[theme] ?? []; } /** * A write-theme claim without its required structured charts would assemble * into a document that the final parse rejects. Demote that theme to a blocked * disclosure instead of failing the whole report. */ function demoteThemesMissingRequiredCharts(bundle: ReportEvidenceBundleV2): ReportEvidenceBundleV2 { const presentCharts = new Set(bundle.charts.map((chart) => chart.id)); const demoted = bundle.claimCards.filter((card) => ( requiredStructuredChartsForTheme(card.theme).some((chartId) => !presentCharts.has(chartId)) )); if (demoted.length === 0) return bundle; const extraReceipts: TechniqueExecutionReceipt[] = []; const extraBlocked: ReportEvidenceBundleV2["blockedSections"][number][] = []; const existingIds = new Set([ ...bundle.executionLedger.map((receipt) => receipt.id), ...bundle.blockedSections.map((section) => section.id), ]); for (const card of demoted) { const missing = requiredStructuredChartsForTheme(card.theme) .filter((chartId) => !presentCharts.has(chartId)); const refs: string[] = []; for (const chartId of missing) { const technique = `${card.theme}:${chartId}`; let id = `ev-tech-${evidenceSlug(technique, "missing")}`; if (existingIds.has(id)) id = `ev-tech-${evidenceSlug(`${technique}_chart`, "missing")}`; extraReceipts.push({ id, technique, status: "blocked", executed: false, note: safeReceiptNote("blocked", false), }); existingIds.add(id); refs.push(id); } const blockedId = existingIds.has(`ev-blocked-${evidenceSlug(card.theme, "theme")}`) ? `ev-blocked-${evidenceSlug(`${card.theme}_charts`, "theme")}` : `ev-blocked-${evidenceSlug(card.theme, "theme")}`; extraBlocked.push({ id: blockedId, theme: card.theme, section: card.section, reason: `缺少结构化分盘:${missing.join("、")}`, missingTechniqueRefs: refs, }); existingIds.add(blockedId); } const demotedThemes = new Set(demoted.map((card) => card.theme)); const { bundleHash: _bundleHash, ...content } = bundle; void _bundleHash; return finalizeReportEvidenceBundleV2({ ...content, claimCards: bundle.claimCards.filter((card) => !demotedThemes.has(card.theme)), blockedSections: [...bundle.blockedSections, ...extraBlocked], executionLedger: [...bundle.executionLedger, ...extraReceipts], evidenceRefs: [ ...bundle.evidenceRefs, ...extraReceipts.map(({ id, technique, status }) => ({ id, technique, status })), ], themeNarrativeSeeds: bundle.themeNarrativeSeeds.filter((seed) => !demotedThemes.has(seed.theme)), }); } 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.id !== "D9" && varga.id !== "D10") continue; 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; allowIncompleteThematic?: boolean; additionalBlockedSections?: readonly Readonly<{ theme: string; title: string; reason: string; missingEvidence: readonly string[]; conflictNotes: readonly string[]; evidenceRefs: readonly string[]; }>[]; }>; const CLAIM_STATUS_RANK: Readonly> = { 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 equalStringSets(left: readonly string[], right: readonly string[]): boolean { const uniqueLeft = [...new Set(left)].sort(); const uniqueRight = [...new Set(right)].sort(); return uniqueLeft.length === uniqueRight.length && uniqueLeft.every((value, index) => value === uniqueRight[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, options: Readonly<{ allowIncompleteThematic?: boolean }> = {}, ): PersonalReportAgentOutput { const writePlans = plan.sections.filter((section) => ( section.kind === "thematic" && section.disposition === "write" )); const allowIncompleteThematic = options.allowIncompleteThematic === true; if (!allowIncompleteThematic && 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 (!equalStringSets(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}`); } } if (!allowIncompleteThematic) { for (const sectionPlan of writePlans) { if (!seen.has(sectionPlan.theme as string)) { throw new Error(`report_writer_theme_missing:${sectionPlan.theme}`); } } } return output; } 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, { allowIncompleteThematic: input.allowIncompleteThematic, }); 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, }; }), ...(input.additionalBlockedSections ?? []).map((section) => ({ theme: section.theme, title: section.title, reason: section.reason, missingEvidence: [...section.missingEvidence], conflictNotes: [...section.conflictNotes], evidenceRefs: [...section.evidenceRefs], claimStatus: "blocked" as const, })), ]; const timingSection = thematicNarrative.find((section) => section.theme === "timing"); const currentPhase: ReportDocumentV2["currentPhase"] = timingSection ? { 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; userId?: string; requestId?: string; sectionService?: PersonalReportSectionService; onProgress?: (progress: Readonly<{ phase: string; completed: number; total: number }>) => Promise | void; }>; export type ReportSchemaInnerReason = string; export type GeneratePersonalReportResult = Readonly< | { status: "ready"; document: ReportDocumentV2; evidenceHash: string; usage?: Readonly<{ inputTokens: number; outputTokens: number; cache?: import("./agent-generation-settings").PromptCacheUsage | null; actualModelId?: string; modelConfigVersion?: number }> } | { status: "failed"; failureCode: "report_schema_invalid"; innerReason: ReportSchemaInnerReason } | { status: "failed"; failureCode: "report_guard_rejected" } >; const SAFE_INNER_REASON = /^report_(?:writer|plan)_[a-z0-9_.:-]{0,80}$/; export function classifyReportSchemaInnerReason(error: unknown): ReportSchemaInnerReason { if (error instanceof PersonalReportAgentOutputError) return "agent_output_invalid"; if (error instanceof ReportEvidenceInsufficientError) return "assemble_invalid"; const message = error instanceof Error ? error.message : ""; if (SAFE_INNER_REASON.test(message)) return message; const token = message.split(":")[0] ?? ""; if (SAFE_INNER_REASON.test(token)) return token; return "schema_invalid_unclassified"; } export function classifyFinalParseInnerReason( errors: readonly Readonly<{ path: string; message: string }>[], ): ReportSchemaInnerReason { if (errors.some((error) => error.message.includes("requires structured"))) { return "final_parse_missing_divisional_chart"; } if (errors.some((error) => error.path === "provenance.evidenceHash")) { return "final_parse_evidence_hash"; } return "final_parse_rejected"; } export function isPersonalReportGenerationAbort(error: unknown, signal?: AbortSignal): boolean { if (signal?.aborted) return true; return error instanceof Error && error.name === "AbortError"; } function failSchema(innerReason: ReportSchemaInnerReason): GeneratePersonalReportResult { console.info("[personal-report]", JSON.stringify({ event: "generation_failed", failureCode: "report_schema_invalid", innerReason, })); return { status: "failed", failureCode: "report_schema_invalid", innerReason }; } function rethrowIfAborted(error: unknown, signal?: AbortSignal): void { if (isPersonalReportGenerationAbort(error, signal)) throw error; } export function classifySectionErrorCode(error: unknown): string { const finishReason = error instanceof PersonalReportAgentOutputError ? error.finishReason : null; if (finishReason === "length") return "section_output_truncated"; const message = error instanceof Error ? error.message : ""; if (message.includes("length") || message.includes("truncat")) return "section_output_truncated"; if ( message.includes("report_writer_section_identity_mismatch") || message.includes("report_writer_unplanned_theme") || message.includes("report_writer_section_id_mismatch") ) { return "section_identity_mismatch"; } if (message.includes("report_writer_evidence_refs_mismatch")) return "section_refs_mismatch"; if (message.includes("evidence")) return "section_evidence_insufficient"; return "section_output_invalid"; } /** Keep each section prompt bounded without weakening the evidence contract. */ export function filterReportEvidenceBundleForSection( source: ReportEvidenceBundleV2, section: ReportSectionPlanEntry, ): ReportEvidenceBundleV2 { const claim = source.claimCards.find((card) => card.theme === section.theme); const seeds = source.themeNarrativeSeeds.filter((seed) => seed.theme === section.theme); // The universal interpretive layers (functional benefic/malefic, yogas, // ashtakavarga, current dasha) are chart-wide, not theme-specific, so their // receipts ride along with every section. Everything else still comes from // the plan's own evidenceRefs. const sourceRefIds = new Set(source.evidenceRefs.map((ref) => ref.id)); const interpretiveRefs = [ ...source.interpretiveFacts.yogas.map((yoga) => yoga.evidenceRef), ...source.interpretiveFacts.functionalRoles.map((role) => role.evidenceRef), ...seeds.flatMap((seed) => seed.evidenceRefs), ].filter((ref) => sourceRefIds.has(ref)); const requestedRefs = new Set([...section.evidenceRefs, ...interpretiveRefs]); const ledger = source.executionLedger.filter((receipt) => requestedRefs.has(receipt.id)); const evidenceRefs = source.evidenceRefs.filter((ref) => requestedRefs.has(ref.id)); const selectedRefIds = new Set(evidenceRefs.map((ref) => ref.id)); const savHouses = THEME_SAV_HOUSES[section.theme ?? ""] ?? []; const interpretiveFacts: ReportInterpretiveFacts = { yogas: source.interpretiveFacts.yogas.filter((yoga) => selectedRefIds.has(yoga.evidenceRef)), functionalRoles: source.interpretiveFacts.functionalRoles .filter((role) => selectedRefIds.has(role.evidenceRef)), shadbalaRanking: source.interpretiveFacts.shadbalaRanking, // Only the houses this chapter actually reads survive the trim. savScores: savHouses.length > 0 ? source.interpretiveFacts.savScores.filter((row) => savHouses.includes(row.house)) : source.interpretiveFacts.savScores, savTotal: source.interpretiveFacts.savTotal, currentDasha: source.interpretiveFacts.currentDasha, convergenceDomains: source.interpretiveFacts.convergenceDomains, }; const charts = source.charts.filter((chart) => (chart.id === "D1" || ledger.some((receipt) => receipt.technique.toUpperCase() === chart.id))); const conflicts = source.conflicts.filter((conflict) => ( conflict.techniqueRefs.some((ref) => selectedRefIds.has(ref)) )); return finalizeReportEvidenceBundleV2({ schemaVersion: source.schemaVersion, subject: source.subject, requestedThemes: section.theme ? [section.theme] : [...source.requestedThemes], reportType: source.reportType, presentationMode: source.presentationMode, calculationProfile: source.calculationProfile, skill: source.skill, charts, claimCards: claim ? [{ ...claim, supportingFacts: claim.supportingFacts.filter((fact) => selectedRefIds.has(fact.evidenceRef)), counterFacts: claim.counterFacts.filter((fact) => selectedRefIds.has(fact.evidenceRef)), }] : [], interpretiveFacts, themeNarrativeSeeds: seeds.map((seed) => ({ ...seed, evidenceRefs: seed.evidenceRefs.filter((ref) => selectedRefIds.has(ref)), })), blockedSections: source.blockedSections.filter((blocked) => blocked.theme === section.theme), conflicts, executionLedger: ledger, evidenceRefs, answerPolicy: source.answerPolicy, }); } function sectionFailureReason(errorCode: string | null): string { if (errorCode === "section_output_truncated" || errorCode?.includes("length") || errorCode?.includes("truncat")) { return "本节未能生成:输出被截断。"; } if (errorCode === "section_refs_mismatch") return "本节未能生成:证据引用未对齐。"; if (errorCode === "section_identity_mismatch") return "本节未能生成:章节身份未对齐。"; if (errorCode === "section_evidence_insufficient" || errorCode?.includes("evidence")) { return "本节未能生成:证据不足。"; } return "本节未能生成:输出未通过校验。"; } function sectionErrorCode(error: unknown): string { return classifySectionErrorCode(error); } async function generateSectionedPersonalReport( deps: GeneratePersonalReportDeps, bundle: ReportEvidenceBundleV2, plan: PersonalReportSectionPlan, ): Promise { const sectionService = deps.sectionService; if (!sectionService || !deps.userId || !deps.requestId || !deps.agent.generateSection || !deps.agent.generateSummary) { return failSchema("sectioned_dependencies_missing"); } const writePlans = plan.sections.filter((entry) => entry.kind === "thematic" && entry.disposition === "write"); const emitProgress = async (phase: string, completed: number): Promise => { try { await deps.onProgress?.({ phase, completed, total: writePlans.length }); } catch (error) { rethrowIfAborted(error, deps.signal); } }; for (const entry of writePlans) { await sectionService.ensure({ userId: deps.userId, requestId: deps.requestId, sectionId: entry.id, maxAttempts: 2, }); } const existing = new Map((await sectionService.list(deps.userId, deps.requestId)).map((row) => [row.sectionId, row])); const ready: PersonalReportSectionRecord[] = []; const blocked: PersonalReportSectionRecord[] = []; for (const entry of writePlans) { let row = existing.get(entry.id) ?? null; if (row?.status === "ready" && row.payload) { ready.push(row); continue; } if (row?.status === "blocked") { blocked.push(row); continue; } let sectionBundle: ReportEvidenceBundleV2; try { sectionBundle = filterReportEvidenceBundleForSection(bundle, entry); } catch (error) { rethrowIfAborted(error, deps.signal); const blockedRow = await sectionService.block({ userId: deps.userId, requestId: deps.requestId, sectionId: entry.id, errorCode: sectionErrorCode(error), }); if (!blockedRow) throw new Error("section_block_failed"); blocked.push(blockedRow); await emitProgress(`section:${entry.id}`, ready.length + blocked.length); continue; } const outputBudget = sectionOutputTokenBudget(entry.targetCharacters.max); try { while (true) { row = await sectionService.start({ userId: deps.userId, requestId: deps.requestId, sectionId: entry.id }); if (!row) { const current = (await sectionService.list(deps.userId, deps.requestId)).find((item) => item.sectionId === entry.id); if (current?.status === "ready" && current.payload) { ready.push(current); break; } if (current?.status === "blocked") { blocked.push(current); break; } throw new Error("section_start_failed"); } try { const titles = ready.map((item) => item.payload?.title).filter((title): title is string => Boolean(title)); const output = await deps.agent.generateSection(sectionBundle, entry, titles, { signal: deps.signal, maxOutputTokens: outputBudget, targetCharactersMin: entry.targetCharacters.min, assertWriterOutput: (candidate) => { if (candidate.id !== entry.id || candidate.theme !== entry.theme) throw new Error("report_writer_section_identity_mismatch"); if (!equalStringSets(candidate.evidenceRefs, entry.evidenceRefs)) throw new Error("report_writer_evidence_refs_mismatch"); }, }); const completed = await sectionService.complete({ userId: deps.userId, requestId: deps.requestId, sectionId: entry.id, payload: output }); if (!completed) throw new Error("section_complete_failed"); ready.push(completed); await emitProgress(`section:${entry.id}`, ready.length + blocked.length); break; } catch (error) { rethrowIfAborted(error, deps.signal); if (row.attemptCount >= row.maxAttempts) { const blockedRow = await sectionService.block({ userId: deps.userId, requestId: deps.requestId, sectionId: entry.id, errorCode: sectionErrorCode(error), }); if (!blockedRow) throw new Error("section_block_failed"); blocked.push(blockedRow); await emitProgress(`section:${entry.id}`, ready.length + blocked.length); break; } } } } catch (error) { rethrowIfAborted(error, deps.signal); const current = (await sectionService.list(deps.userId, deps.requestId)).find((item) => item.sectionId === entry.id); if (current?.status === "ready" && current.payload) { ready.push(current); continue; } if (current?.status === "blocked") { if (!blocked.some((item) => item.sectionId === current.sectionId)) blocked.push(current); continue; } const blockedRow = await sectionService.block({ userId: deps.userId, requestId: deps.requestId, sectionId: entry.id, errorCode: sectionErrorCode(error), }); if (!blockedRow) throw new Error("section_block_failed"); blocked.push(blockedRow); await emitProgress(`section:${entry.id}`, ready.length + blocked.length); } } if (writePlans.length > 0 && ready.length === 0) { return failSchema("all_sections_blocked"); } await emitProgress("summary", writePlans.length); let summary; try { summary = await deps.agent.generateSummary(ready.map((item) => ({ title: item.payload!.title, claimStatus: item.payload!.claimStatus, })), { signal: deps.signal, maxOutputTokens: summaryOutputTokenBudget() }); } catch (error) { rethrowIfAborted(error, deps.signal); return failSchema(classifyReportSchemaInnerReason(error)); } const agentOutput: PersonalReportAgentOutput = { executiveSummary: summary, thematicNarrative: ready.flatMap((item) => item.payload ? [item.payload] : []), }; const blockedDisclosures = blocked.map((item) => { const entry = writePlans.find((candidate) => candidate.id === item.sectionId)!; const refs = [...entry.evidenceRefs]; return { theme: entry.theme!, title: entry.theme!, reason: sectionFailureReason(item.lastErrorCode), missingEvidence: [sectionFailureReason(item.lastErrorCode)], conflictNotes: [], evidenceRefs: refs, }; }); await emitProgress("assemble", writePlans.length); try { const candidate = assembleReportDocumentV2({ reportId: deps.reportId, generatedAt: (deps.now ?? (() => new Date()))().toISOString(), depth: deps.depth, bundle, plan, agentOutput, allowIncompleteThematic: true, additionalBlockedSections: blockedDisclosures, }); const guarded = applyReportGuard(candidate, buildLegacyPacketFromBundle(bundle)); if (!guarded.ok) return { status: "failed", failureCode: "report_guard_rejected" }; const parsed = safeParseServerReportDocument(guarded.document); if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") return failSchema(parsed.ok ? "final_parse_rejected" : classifyFinalParseInnerReason(parsed.errors)); return { status: "ready", document: parsed.document, evidenceHash: computeEvidenceHash(parsed.document.evidenceAppendix), usage: deps.agent.getUsage?.() }; } catch (error) { rethrowIfAborted(error, deps.signal); return failSchema(error instanceof ReportEvidenceInsufficientError ? "assemble_invalid" : classifyReportSchemaInnerReason(error)); } } /** * Runs the dedicated report agent exactly once (plus its single internal * repair retry), assembles the candidate document, applies the deterministic * 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; try { bundle = validateReportEvidenceBundleV2(deps.bundle); bundle = demoteThemesMissingRequiredCharts(bundle); } catch (error) { rethrowIfAborted(error, deps.signal); return failSchema("bundle_invalid"); } let plan: PersonalReportSectionPlan; try { plan = validatePersonalReportSectionPlan( buildPersonalReportSectionPlan(bundle, deps.depth), bundle, ); } catch (error) { rethrowIfAborted(error, deps.signal); return failSchema("plan_invalid"); } if (deps.sectionService) { return generateSectionedPersonalReport(deps, bundle, plan); } const bindWriter = (output: PersonalReportAgentOutput) => ( validatePersonalReportAgentOutputAgainstPlan(output, plan, bundle) ); let agentOutput: PersonalReportAgentOutput; try { agentOutput = await deps.agent.generate(bundle, plan, { signal: deps.signal, assertWriterOutput: bindWriter, }); } catch (error) { rethrowIfAborted(error, deps.signal); return failSchema( error instanceof PersonalReportAgentOutputError ? "agent_output_invalid" : classifyReportSchemaInnerReason(error), ); } try { agentOutput = bindWriter(agentOutput); } catch (error) { rethrowIfAborted(error, deps.signal); try { agentOutput = bindWriter(await deps.agent.generate(bundle, plan, { signal: deps.signal, assertWriterOutput: bindWriter, })); } catch (repairError) { rethrowIfAborted(repairError, deps.signal); return failSchema(classifyReportSchemaInnerReason(repairError)); } } 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 (error) { rethrowIfAborted(error, deps.signal); return failSchema( error instanceof ReportEvidenceInsufficientError ? "assemble_invalid" : classifyReportSchemaInnerReason(error), ); } 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 failSchema(parsed.ok ? "final_parse_rejected" : classifyFinalParseInnerReason(parsed.errors)); } return { status: "ready", document: parsed.document, evidenceHash: computeEvidenceHash(parsed.document.evidenceAppendix), usage: deps.agent.getUsage?.(), }; }