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

2155 lines
83 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash } from "node:crypto";
import {
computeEvidenceHash,
safeParseServerReportDocument,
} from "./personal-report-contract.server-core.ts";
import type {
ClaimStatus,
EvidenceAppendix,
ReportDepth,
ReportDocumentV1,
ReportDocumentV2,
} from "./personal-report-contract.ts";
import type {
EvidenceRefStatus,
PersonalReportAgentOutput,
ReportAgentPort,
ReportEvidenceBundleV2,
ReportEvidencePacket,
ReportPlanetFact,
} from "@/mastra/personal-report";
import type {
EvidenceConflict,
ReportChartFact,
ReportClaimCard,
SafeReportSubject,
TechniqueExecutionReceipt,
} from "./report-evidence-bundle-v2.ts";
import { finalizeReportEvidenceBundleV2, validateReportEvidenceBundleV2 } from "./report-evidence-bundle-v2.ts";
import { buildReportThemePlan, normalizeReportTheme } from "./report-theme-evidence-plan.ts";
import { resolveActiveSkillPackage } from "./skill-package-registry.ts";
import { buildPersonalReportSectionPlan, validatePersonalReportSectionPlan, type PersonalReportSectionPlan } from "./personal-report-plan.ts";
// Compatibility re-export: prefer importing from ./personal-report-codes.ts
// directly (the pure, dependency-free codes module).
export { REPORT_STABLE_CODES } from "./personal-report-codes.ts";
/**
* Personal report generation: workflow evidence -> minimal packet -> report
* agent -> candidate document -> deterministic guard -> canonical server
* parse.
*
* Contract and persistence are the canonical shared modules (p3):
* - `personal-report-contract.ts` (isomorphic) + `personal-report-contract.server-core.ts`
* / `personal-report-contract.server.ts` (server hash + parse entry).
* - `personal-report-service-core.ts` / `personal-report-service.ts`
* (persistence, fingerprint idempotency).
*
* This module never duplicates the schema and never falls back to
* mock/example/random/sample data. Missing real evidence fails closed.
* Stable API codes live in the dependency-free ./personal-report-codes.ts.
*/
// ---------------------------------------------------------------------------
// Stable failure codes live in ./personal-report-codes.ts (imported above).
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Canonical serialization + fingerprints
// ---------------------------------------------------------------------------
export function canonicalSerialize(value: unknown): string {
if (value === undefined) return "null";
if (Array.isArray(value)) {
return `[${value.map(canonicalSerialize).join(",")}]`;
}
if (value !== null && typeof value === "object") {
const source = value as Record<string, unknown>;
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<string, unknown>;
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<string, number>(SIGNS.map((sign, index) => [sign, index]));
const SIGN_INDEX_CN = new Map<string, number>([
["白羊座", 0], ["金牛座", 1], ["双子座", 2], ["巨蟹座", 3], ["狮子座", 4], ["处女座", 5],
["天秤座", 6], ["天蝎座", 7], ["射手座", 8], ["摩羯座", 9], ["水瓶座", 10], ["双鱼座", 11],
]);
function signIndex(value: unknown): number | null {
if (typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 11) return value;
if (typeof value === "string") {
return SIGN_INDEX.get(value) ?? SIGN_INDEX_CN.get(value) ?? null;
}
return null;
}
/**
* Mirrors the orchestrator's base_chart selection exactly:
* modules.chart (dict) -> chart_data.chart (nested) -> chart_data itself.
*/
function resolveBaseChart(chartData: JsonRecord): JsonRecord {
const modules = record(chartData.modules);
const modulesChart = modules ? record(modules.chart) : null;
if (modulesChart) return modulesChart;
const nested = record(chartData.chart);
if (nested) return nested;
return chartData;
}
/**
* Planets accept the real engine's object map ({Sun: {...}, ...}) and the
* legacy array shape. Absolute longitude comes from degree_raw / longitude_deg
* / lon / absolute_degree / degree (the engine's planet degree is the absolute
* 0-360 longitude; degree_in_sign is the in-sign offset and is NOT used here).
* Entries without a complete fact set are skipped (allowlist of full facts).
*/
function readPlanets(value: unknown): ReportPlanetFact[] {
const planets: ReportPlanetFact[] = [];
const entries: Readonly<[string, unknown]>[] = Array.isArray(value)
? value.map((item, index) => [String(index), item] as const)
: Object.entries(record(value) ?? {});
for (const [key, item] of entries) {
const row = record(item);
if (!row) continue;
const id = text(row?.id ?? row?.name ?? row?.planet) ?? key;
const sign = text(row?.sign ?? row?.sign_name);
const degree = finiteNumber(
row?.degree_raw ?? row?.longitude_deg ?? row?.lon ?? row?.absolute_degree ?? row?.degree,
);
if (!sign || degree === null) continue;
planets.push({
id,
sign,
degree,
house: finiteNumber(row?.house ?? row?.house_number),
retrograde: booleanValue(row?.retrograde ?? row?.is_retrograde),
});
}
return planets;
}
/**
* Houses accept the real engine's object map ({house_1: {cusp_sign, ...}}) and
* the legacy array shape ({number, sign}). The real map has NO whole-sign
* `sign` field (only Placidus cusp_sign); house signs are therefore derived
* deterministically from the ascendant sign + house number (whole-sign,
* matching the engine's own whole-sign planet-house numbering) and marked
* signDerived. Array entries that carry a real sign keep it. Occupants are
* filled from planet whole-sign house numbers.
*/
function readHouses(
value: unknown,
ascendantSignIndex: number | null,
planets: readonly ReportPlanetFact[],
): ReportEvidencePacket["chart"]["houses"] {
const rows: Readonly<{ number: number; sign: string | null }>[] = [];
if (Array.isArray(value)) {
for (const item of value) {
const row = record(item);
const number = finiteNumber(row?.number ?? row?.house ?? row?.index ?? row?.house_number);
if (number === null) continue;
rows.push({ number, sign: text(row?.sign ?? row?.sign_name) });
}
} else {
const map = record(value) ?? {};
for (const [key, item] of Object.entries(map)) {
const number = /^house_(\d{1,2})$/.exec(key)?.[1] ?? (/^\d{1,2}$/.test(key) ? key : null);
if (!number) continue;
const parsed = Number.parseInt(number, 10);
if (parsed < 1 || parsed > 12) continue;
const row = record(item);
rows.push({ number: parsed, sign: row ? text(row.sign ?? row.sign_name) : null });
}
}
const houses: ReportEvidencePacket["chart"]["houses"] = rows.map((row) => {
const realSign = row.sign;
const derivedSign = ascendantSignIndex !== null
? SIGNS[((ascendantSignIndex + row.number - 1) % 12 + 12) % 12]
: null;
const sign = realSign ?? derivedSign;
if (!sign) return { number: row.number, sign: "", signDerived: true, occupants: [] };
return {
number: row.number,
sign,
signDerived: realSign === null,
occupants: planets
.filter((planet) => planet.house === row.number)
.map((planet) => planet.id)
.slice(0, 12),
};
});
return houses;
}
/**
* Divisional charts in the real engine live under modules.varga_full with keys
* D9_Navamsa / D10_Dasamsa (or D9 / D10). Each varga is {Ascendant: {sign_idx|
* sign}, <planet>: {sign_idx|sign}, _meta, _dignity, ...} — there are no house
* arrays. House signs are derived whole-sign from the divisional ascendant and
* occupants from each planet's sign index (the same derivation the engine uses
* for D11). Nothing is fabricated; missing varga data simply omits the chart.
*/
function readVargaHouses(
vargaFull: JsonRecord | null,
): ReportEvidencePacket["chart"]["vargaHouses"] {
if (!vargaFull) return [];
const result: { id: "D9" | "D10"; houses: ReportEvidencePacket["chart"]["houses"] }[] = [];
const variants: Readonly<Record<"D9" | "D10", readonly string[]>> = {
D9: ["D9_Navamsa", "D9"],
D10: ["D10_Dasamsa", "D10"],
};
for (const [id, keys] of Object.entries(variants) as ReadonlyArray<readonly ["D9" | "D10", readonly string[]]>) {
const varga = keys
.map((key) => record(vargaFull[key]))
.find((entry): entry is JsonRecord => entry !== null);
if (!varga) continue;
const ascendant = record(varga.Ascendant);
const ascIndex = ascendant ? signIndex(ascendant.sign_idx ?? ascendant.sign) : null;
if (ascIndex === null) continue;
const occupants: string[][] = Array.from({ length: 12 }, () => []);
for (const [name, item] of Object.entries(varga)) {
if (name.startsWith("_") || name === "Ascendant" || name === "planets") continue;
const row = record(item);
const planetIndex = row ? signIndex(row.sign_idx ?? row.sign) : null;
if (planetIndex === null) continue;
const house = (((planetIndex - ascIndex) % 12) + 12) % 12 + 1;
const safeName = safeCelestialName(name);
if (safeName) occupants[house - 1].push(safeName);
}
const houses: ReportEvidencePacket["chart"]["houses"] = Array.from(
{ length: 12 },
(_, index) => ({
number: index + 1,
sign: SIGNS[((ascIndex + index) % 12 + 12) % 12],
signDerived: true,
occupants: occupants[index].slice(0, 12),
}),
);
result.push({ id, houses });
}
return result;
}
function readDashaPeriods(rows: unknown): ReportEvidencePacket["chart"]["vimshottari"] {
if (!Array.isArray(rows)) return null;
const periods: { lord: string; start: string; end: string }[] = [];
for (const item of rows) {
const row = record(item);
const lord = text(row?.lord ?? row?.planet ?? row?.name);
const start = text(row?.start ?? row?.start_date);
const end = text(row?.end ?? row?.end_date);
if (lord && start && end) periods.push({ lord, start, end });
}
return periods.length > 0 ? periods : null;
}
function readVimshottari(chart: JsonRecord | null): ReportEvidencePacket["chart"]["vimshottari"] {
const dasha = record(chart?.dasha);
if (!dasha) return null;
const mahadashas = Array.isArray(dasha.mahadashas) ? dasha.mahadashas : null;
return mahadashas ? readDashaPeriods(mahadashas) : null;
}
function readNarayana(modules: JsonRecord | null): ReportEvidencePacket["chart"]["narayana"] {
const narayana = record(modules?.narayana_dasha);
if (!narayana) return null;
const rows = Array.isArray(narayana.periods) ? narayana.periods
: Array.isArray(narayana.mahadashas) ? narayana.mahadashas : null;
return rows ? readDashaPeriods(rows) : null;
}
/**
* machine_evidence_packet.sections is an object map in the real engine
* ({D1: {status: "used"|"missing", source_path}, planet_degrees: {...}, ...});
* the legacy array shape is also accepted.
*/
function readSections(machinePacket: JsonRecord | null): {
name: string;
status: string;
sourcePath: string;
}[] {
const raw = machinePacket?.sections;
const entries = Array.isArray(raw)
? raw.map((item, index) => [String(index), item] as const)
: Object.entries(record(raw) ?? {});
const sections: { name: string; status: string; sourcePath: string }[] = [];
for (const [key, item] of entries) {
const row = record(item) ?? {};
sections.push({
name: text(row?.name ?? row?.technique) ?? key,
status: text(row?.status) ?? "unknown",
sourcePath: text(row?.source_path) ?? "",
});
}
return sections;
}
/**
* Deterministic evidence-status rule for machine-packet sections. The real
* engine only emits used/missing; "verified" as a literal string must never be
* required or the gate would always fail. Core calculation sections (D1,
* planet_degrees, house_degrees) with an internal source path map to verified;
* everything else internal is partial; external oracle sections are capped at
* partial; missing/blocked stay blocked.
*/
const VERIFIED_CALCULATION_SECTIONS = new Set(["D1", "planet_degrees", "house_degrees"]);
const EXTERNAL_SECTIONS = new Set([
"external_oracle_status",
"vedastro_official_raw_response",
"vedastro_official_raw_archive_manifest",
]);
function sectionEvidenceStatus(
status: string,
name: string,
sourcePath: string,
): "verified" | "partial" | "blocked" {
if (status === "missing" || status === "blocked" || status === "official_blocked") return "blocked";
if (status === "verified") return "verified";
if (status === "partial") return "partial";
// used / available / received_unverified / unknown / undefined
const internal = sourcePath.startsWith("chart.")
|| sourcePath.startsWith("modules.")
|| sourcePath.startsWith("scripts.");
if (VERIFIED_CALCULATION_SECTIONS.has(name) && internal) return "verified";
if (EXTERNAL_SECTIONS.has(name) || sourcePath.startsWith("vedastro_")) return "partial";
return "partial";
}
export type BuildEvidencePacketInput = Readonly<{
workflow: unknown;
subject: ReportEvidencePacket["subject"];
requestedThemes: readonly string[];
reportType: "personal_full" | "personal_thematic";
presentationMode: "default" | "research";
candidateRange: Readonly<{ start: string; end: string }> | null;
skillSnapshot: SkillSnapshot;
}>;
/** Raised when the real workflow evidence cannot support an honest report. */
export class ReportEvidenceInsufficientError extends Error {
readonly code = "calculation_unavailable";
constructor(reason: string) {
super(`Report evidence insufficient: ${reason}`);
this.name = "ReportEvidenceInsufficientError";
}
}
function assertUsablePacket(packet: ReportEvidencePacket): void {
if (!packet.chart.ascendant) {
throw new ReportEvidenceInsufficientError("ascendant_missing");
}
const houseNumbers = packet.chart.houses.map((house) => house.number);
const unique = new Set(houseNumbers);
if (houseNumbers.length !== 12 || unique.size !== 12
|| houseNumbers.some((number) => number < 1 || number > 12)) {
throw new ReportEvidenceInsufficientError("d1_houses_incomplete");
}
if (packet.chart.planets.length === 0) {
throw new ReportEvidenceInsufficientError("planets_missing");
}
if (packet.chart.planets.some((planet) => planet.house === null || planet.retrograde === null)) {
throw new ReportEvidenceInsufficientError("planet_fact_incomplete");
}
if (packet.evidenceRefs.length === 0) {
throw new ReportEvidenceInsufficientError("evidence_refs_missing");
}
// At least one ref must be backed by an explicit verified fact; pure layer
// names (partial) are not enough to claim evidence-backed sections.
if (!packet.evidenceRefs.some((ref) => ref.status === "verified")) {
throw new ReportEvidenceInsufficientError("no_verified_evidence_ref");
}
}
/**
* Extracts ONLY allowlisted facts from the real workflow response. Internal
* paths, prompts, exception stacks, chat history and unrelated raw objects are
* structurally excluded: unknown keys are never copied. Fails closed when the
* evidence cannot support a report.
*/
export function buildReportEvidencePacket(input: BuildEvidencePacketInput): ReportEvidencePacket {
const workflow = record(input.workflow) ?? {};
const chartData = record(workflow.chart) ?? {};
const modules = record(chartData.modules) ?? {};
const consumerContext = record(workflow.consumer_context) ?? {};
const machinePacket = record(workflow.machine_evidence_packet) ?? {};
const answerPolicy = record(consumerContext.answer_policy) ?? {};
// Real base chart selection mirrors the orchestrator: modules.chart (dict)
// -> chart_data.chart (nested) -> chart_data itself. Houses fall back to the
// top-level chart like the orchestrator's house_degrees section does.
const baseChart = resolveBaseChart(chartData);
const ascendant = record(baseChart.ascendant);
const ascendantSign = text(ascendant?.sign ?? ascendant?.sign_name);
const ascendantSignIndex = ascendantSign ? signIndex(ascendantSign) : null;
const ascendantDegree = finiteNumber(
ascendant?.degree_in_sign ?? ascendant?.degree ?? ascendant?.longitude_deg ?? ascendant?.lon,
);
const planets = readPlanets(baseChart.planets);
const houses = readHouses(
baseChart.houses ?? chartData.houses,
ascendantSignIndex,
planets,
);
const vimshottari = readVimshottari(baseChart);
const narayana = readNarayana(modules);
const vargaHouses = readVargaHouses(record(modules.varga_full));
const availableLayers = stringArray(consumerContext.available_layers);
const missingLayers = stringArray(consumerContext.missing_route_layers);
const hardBlockers = stringArray(consumerContext.hard_blockers);
const techniqueAudit: { technique: string; status: string; note: string }[] = [];
const seenTechniques = new Set<string>();
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<Record<string, string>> = {
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<Record<EvidenceRefStatus, number>> = {
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<string, string>([
["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<string, string>([
["lahiri", "Lahiri"], ["raman", "Raman"], ["kp", "Krishnamurti/KP"],
["krishnamurti", "Krishnamurti/KP"], ["krishnamurti/kp", "Krishnamurti/KP"],
["krishnamurti_paddhati", "Krishnamurti/KP"], ["fagan_bradley", "Fagan-Bradley"],
["djwhal_khul", "Djwhal Khul"], ["sassanian", "Sassanian"],
["true_citra", "True Citra"],
]);
const SAFE_NODE_MODES = new Map<string, string>([
["mean", "mean"], ["mean_node", "mean"], ["true", "true"], ["true_node", "true"],
]);
const SAFE_HOUSE_SYSTEMS = new Map<string, string>([
["equal", "equal"], ["placidus", "placidus"], ["porphyry", "porphyry"],
["sripati", "sripati"], ["whole_sign", "whole_sign"], ["koch", "koch"],
]);
const SAFE_POLICY_BOUNDARIES = new Set([
"timing",
"medical",
"investment",
"exact_dates",
"medical_diagnosis",
"investment_guarantees",
"kp_system",
"muhurta",
"gochara_event_timing",
"sahams",
"sphuta_trisphuta_family",
"tajika_yogas",
"conception_chart",
"relationship_combinations",
]);
function safeWorkflowTechnique(value: string): string | null {
return SAFE_WORKFLOW_TECHNIQUES.get(techniqueLookupKey(value)) ?? null;
}
type CalculationProfileField = "ayanamsa" | "nodeMode" | "houseSystem";
function safeCalculationLabel(value: string | null, field: CalculationProfileField): string | null {
if (!value) return null;
const key = value.trim().toLowerCase().replace(/[ -]+/g, "_");
if (field === "ayanamsa") return SAFE_AYANAMSA.get(key) ?? null;
if (field === "nodeMode") return SAFE_NODE_MODES.get(key) ?? null;
return SAFE_HOUSE_SYSTEMS.get(key) ?? null;
}
function safePolicyBoundary(
value: string,
): ReportEvidenceBundleV2["answerPolicy"]["deterministicClaimsForbiddenFor"][number] | null {
const candidate = value.trim().toLowerCase();
return SAFE_POLICY_BOUNDARIES.has(candidate)
? candidate as ReportEvidenceBundleV2["answerPolicy"]["deterministicClaimsForbiddenFor"][number]
: null;
}
function safeCelestialName(value: string): string | null {
const candidate = value.trim();
return SAFE_CELESTIAL_NAMES.get(candidate.toLowerCase()) ?? null;
}
function safeChartSign(value: string): string | null {
const index = signIndex(value);
return index === null ? null : SIGNS[index];
}
function safeDashaDate(value: string): string | null {
const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (dateOnly) {
const year = Number(dateOnly[1]);
if (year < 1600 || year > 2400) return null;
const parsed = new Date(`${value}T00:00:00.000Z`);
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value
? value
: null;
}
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(value)) {
return null;
}
const parsed = new Date(value);
const year = parsed.getUTCFullYear();
return Number.isFinite(parsed.getTime()) && year >= 1600 && year <= 2400 ? value : null;
}
function safeDashaPeriods(
periods: readonly Readonly<{ lord: string; start: string; end: string }>[] | null,
): readonly Readonly<{ lord: string; start: string; end: string }>[] | null {
if (!periods) return null;
const safe = periods.flatMap((period) => {
const lord = safeCelestialName(period.lord) ?? safeChartSign(period.lord);
const start = safeDashaDate(period.start);
const end = safeDashaDate(period.end);
return lord && start && end && Date.parse(start) < Date.parse(end)
? [{ lord, start, end }]
: [];
});
return safe.length > 0 ? safe : null;
}
function safeChartFact(chart: ReportChartFact): ReportChartFact | null {
const ascendantSign = chart.ascendant ? safeChartSign(chart.ascendant.sign) : null;
if (chart.id === "D1" && (!chart.ascendant || !ascendantSign)) return null;
const ascendantIndex = ascendantSign ? signIndex(ascendantSign) : null;
const planets = chart.planets.flatMap((planet) => {
const id = safeCelestialName(planet.id);
const sign = safeChartSign(planet.sign);
if (!id || !sign) return [];
return [{ ...planet, id, sign }];
});
const houses = chart.houses.flatMap((house) => {
if (!Number.isInteger(house.number) || house.number < 1 || house.number > 12) return [];
const sign = safeChartSign(house.sign)
?? (ascendantIndex === null ? null : SIGNS[(ascendantIndex + house.number - 1) % 12]);
if (!sign) return [];
return [{
...house,
sign,
signDerived: house.signDerived || safeChartSign(house.sign) === null,
occupants: house.occupants.flatMap((occupant) => {
const safe = safeCelestialName(occupant);
return safe ? [safe] : [];
}),
}];
});
return {
...chart,
ascendant: chart.ascendant && ascendantSign
? { sign: ascendantSign, degree: chart.ascendant.degree }
: null,
houses,
planets,
};
}
function safeReceiptNote(status: EvidenceRefStatus, executed: boolean): string {
if (!executed) return status === "blocked" ? "本次未取得该技法证据" : "仅识别为可用层,本次未执行";
return status === "verified" ? "本次服务器计算已验证" : "本次已执行,但证据确定性仍为部分";
}
const EXECUTED_MACHINE_SECTION_STATUSES = new Set([
"used",
"verified",
"partial",
"received_unverified",
"local_fallback",
"official_verified",
"executed",
"success",
"completed",
]);
function readMachineSectionExecuted(workflow: JsonRecord, technique: string): boolean | null {
const machinePacket = record(workflow.machine_evidence_packet);
const section = readSections(machinePacket).find((item) => item.name === technique);
if (!section) return null;
return EXECUTED_MACHINE_SECTION_STATUSES.has(section.status.trim().toLowerCase());
}
function readCalculationProfileText(
workflow: JsonRecord,
keys: readonly string[],
field: CalculationProfileField,
): string | null {
const chart = record(workflow.chart) ?? {};
const modules = record(chart.modules) ?? {};
const base = resolveBaseChart(chart);
const profile = record(workflow.calculation_profile) ?? record(chart.calculation_profile) ?? {};
for (const key of keys) {
const value = safeCalculationLabel(text(profile[key] ?? base[key] ?? chart[key] ?? modules[key]), field);
if (value) return value;
}
return null;
}
function readAllVargaCharts(workflow: JsonRecord): ReportChartFact[] {
const chart = record(workflow.chart) ?? {};
const modules = record(chart.modules) ?? {};
const vargaFull = record(modules.varga_full);
if (!vargaFull) return [];
const charts: ReportChartFact[] = [];
for (const [rawId, rawValue] of Object.entries(vargaFull)) {
const id = rawId.toUpperCase();
if (!/^D\d{1,3}$/.test(id)) continue;
const varga = record(rawValue);
if (!varga) continue;
const ascendant = record(varga.Ascendant);
const ascIndex = ascendant ? signIndex(ascendant.sign_idx ?? ascendant.sign) : null;
if (ascIndex === null) continue;
const occupants: string[][] = Array.from({ length: 12 }, () => []);
for (const [name, item] of Object.entries(varga)) {
if (name.startsWith("_") || name === "Ascendant" || name === "planets") continue;
const row = record(item);
const planetIndex = row ? signIndex(row.sign_idx ?? row.sign) : null;
if (planetIndex === null) continue;
const house = (((planetIndex - ascIndex) % 12) + 12) % 12 + 1;
occupants[house - 1].push(name);
}
charts.push({
id,
title: `${id} 分盘`,
ascendant: {
sign: SIGNS[ascIndex],
degree: finiteNumber(ascendant?.degree_in_sign ?? ascendant?.degree) ?? 0,
},
houses: Array.from({ length: 12 }, (_, index) => ({
number: index + 1,
sign: SIGNS[(ascIndex + index) % 12],
signDerived: true,
occupants: occupants[index].slice(0, 20),
})),
planets: [],
});
}
return charts;
}
function mergeChart(target: Map<string, ReportChartFact>, chart: ReportChartFact): void {
const existing = target.get(chart.id);
if (!existing || (existing.houses.length < 12 && chart.houses.length === 12)) target.set(chart.id, chart);
}
function buildLegacyPacketFromBundle(bundle: ReportEvidenceBundleV2): ReportEvidencePacket {
const d1 = bundle.charts.find((chart) => chart.id === "D1");
if (!d1?.ascendant) throw new ReportEvidenceInsufficientError("ascendant_missing");
const vargaHouses = bundle.charts
.filter((chart): chart is ReportChartFact & { id: "D9" | "D10" } => chart.id === "D9" || chart.id === "D10")
.map((chart) => ({ id: chart.id, houses: chart.houses }));
const receiptById = new Map(bundle.executionLedger.map((receipt) => [receipt.id, receipt]));
return {
schemaVersion: "report_evidence_packet.v1",
subject: bundle.subject,
requestedThemes: bundle.requestedThemes,
reportType: bundle.reportType,
presentationMode: bundle.presentationMode,
chart: {
calculationHash: bundle.calculationProfile.calculationHash,
calculationHashDerived: bundle.calculationProfile.calculationHashDerived,
ascendant: d1.ascendant,
planets: d1.planets,
houses: d1.houses,
vimshottari: bundle.calculationProfile.vimshottari,
narayana: bundle.calculationProfile.narayana,
vargaHouses,
},
techniqueAudit: bundle.executionLedger.map((receipt) => ({
id: receipt.id,
technique: receipt.technique,
status: receipt.status,
note: receipt.note,
})),
conflicts: bundle.conflicts.map((conflict) => ({
id: conflict.id,
techniques: conflict.techniqueRefs.map((ref) => receiptById.get(ref)?.technique ?? ref),
summary: conflict.summary,
})),
blockedTechniques: bundle.executionLedger
.filter((receipt) => receipt.status === "blocked")
.map((receipt) => receipt.technique),
evidenceRefs: bundle.evidenceRefs,
candidateRange: null,
answerPolicy: {
canAnswerPreciseTiming: bundle.answerPolicy.canAnswerPreciseTiming,
deterministicClaimsForbiddenFor: bundle.answerPolicy.deterministicClaimsForbiddenFor,
},
skillName: bundle.skill.name,
skillVersion: bundle.skill.version,
skillSnapshotSha256: bundle.skill.sha256,
skillSourceCommit: bundle.skill.sourceCommit,
};
}
/**
* Builds the server-owned, allowlisted ReportEvidenceBundle v2 from one
* workflow result per requested theme. A missing thematic layer produces a
* blocked section, while an unusable D1 base fails closed.
*/
export function buildReportEvidenceBundleV2(
input: BuildReportEvidenceBundleV2Input,
): ReportEvidenceBundleV2 {
const requestedPlans = buildReportThemePlan(input.requestedThemes);
const packets: BuiltThemePacket[] = [];
for (const item of input.workflows) {
const workflow = record(item.workflow);
if (!workflow) continue;
try {
packets.push({
theme: normalizeReportTheme(item.theme),
workflow,
packet: buildReportEvidencePacket({
workflow,
subject: input.subject,
requestedThemes: [normalizeReportTheme(item.theme)],
reportType: input.reportType,
presentationMode: input.presentationMode,
candidateRange: null,
skillSnapshot: input.skillSnapshot,
}),
});
} catch (error) {
if (!(error instanceof ReportEvidenceInsufficientError)) throw error;
}
}
const base = packets[0];
if (!base) throw new ReportEvidenceInsufficientError("d1_base_unavailable");
const receiptsByKey = new Map<string, TechniqueExecutionReceipt>();
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<string, ReportChartFact>();
const safeD1 = safeChartFact({
id: "D1",
title: "D1 本命盘",
ascendant: base.packet.chart.ascendant,
houses: base.packet.chart.houses,
planets: base.packet.chart.planets,
});
if (!safeD1) throw new ReportEvidenceInsufficientError("d1_safe_projection_unavailable");
mergeChart(chartsById, safeD1);
for (const packet of packets) {
for (const varga of packet.packet.chart.vargaHouses) {
const safeChart = safeChartFact({
id: varga.id,
title: `${varga.id} 分盘`,
houses: varga.houses,
planets: [],
});
if (safeChart) {
mergeChart(chartsById, safeChart);
upsertReceipt(varga.id, "verified", true);
}
}
for (const chart of readAllVargaCharts(packet.workflow)) {
const safeChart = safeChartFact(chart);
if (!safeChart) continue;
mergeChart(chartsById, safeChart);
upsertReceipt(safeChart.id, "verified", true);
}
}
const claimCards: ReportClaimCard[] = [];
const blockedSections: ReportEvidenceBundleV2["blockedSections"][number][] = [];
for (const plan of requestedPlans) {
const matched: TechniqueExecutionReceipt[] = [];
const missingRefs: string[] = [];
for (const group of plan.requiredTechniqueGroups) {
const receipt = [...receiptsByKey.values()].find((candidate) => (
candidate.executed && candidate.status !== "blocked" && techniqueMatches(candidate, group.anyOf)
));
if (receipt) {
matched.push(receipt);
continue;
}
const missing = upsertReceipt(`${plan.theme}:${group.label}`, "blocked", false);
missingRefs.push(missing.id);
}
if (missingRefs.length > 0) {
blockedSections.push({
id: `ev-blocked-${evidenceSlug(plan.theme, "theme")}`,
theme: plan.theme,
section: plan.section,
reason: `缺少最低证据组:${plan.requiredTechniqueGroups
.filter((group) => missingRefs.some((ref) => ref.endsWith(evidenceSlug(`${plan.theme}:${group.label}`, "missing"))))
.map((group) => group.label)
.join("、") || "主题证据未闭合"}`,
missingTechniqueRefs: missingRefs,
});
continue;
}
const uniqueMatched = [...new Map(matched.map((receipt) => [receipt.id, receipt])).values()];
const allVerified = uniqueMatched.every((receipt) => receipt.status === "verified");
const assertionLevel = allVerified && uniqueMatched.length >= 2
? "multi_system_consensus"
: allVerified
? "single_system_inference"
: "parameter_sensitive";
claimCards.push({
id: `ev-claim-${evidenceSlug(plan.theme, "theme")}`,
theme: plan.theme,
section: plan.section,
conclusion: `服务器已闭合${plan.section}所需的最低证据组;本节只能在所列事实与确定性级别内解释。`,
supportingFacts: uniqueMatched.map((receipt, index) => ({
id: `ev-fact-${evidenceSlug(plan.theme, "theme")}-${index + 1}`,
label: receipt.technique,
value: receipt.technique === "D1" && base.packet.chart.ascendant
? `D1 上升为 ${base.packet.chart.ascendant.sign},本次基础盘已建立`
: `${receipt.technique} 已执行并纳入本主题证据计划`,
evidenceRef: receipt.id,
status: receipt.status,
})),
counterFacts: [],
executedTechniqueRefs: uniqueMatched.map((receipt) => receipt.id),
assertionLevel,
timingBoundary: input.subject.birthTimeStatus === "confirmed"
? null
: "出生时间未达到 confirmed;时间结论仅允许方向性表达",
verificationQuestions: assertionLevel === "parameter_sensitive"
? ["建议结合真实经历核验本主题的部分证据结论"]
: [],
});
}
const conflicts: EvidenceConflict[] = [];
for (const { packet } of packets) {
for (const conflict of packet.conflicts) {
const techniqueRefs = conflict.techniques
.map((technique) => [...receiptsByKey.values()].find((receipt) => techniqueMatches(receipt, [technique]))?.id)
.filter((ref): ref is string => Boolean(ref));
const summary = techniqueRefs.length > 0
? "所列技法的结果存在未闭合冲突,报告不得据此作确定性提升。"
: "本次证据存在未闭合冲突,报告必须披露该边界。";
const id = `ev-conflict-${conflicts.length + 1}`;
if (!conflicts.some((item) => (
item.summary === summary
&& item.techniqueRefs.join("|") === techniqueRefs.join("|")
))) {
conflicts.push({ id, techniqueRefs, summary, resolutionStatus: "unresolved" });
}
}
}
const executionLedger = [...receiptsByKey.values()];
const deterministicClaimsForbiddenFor = [...new Set(packets.flatMap(({ packet }) => (
packet.answerPolicy.deterministicClaimsForbiddenFor.flatMap((value) => {
const safe = safePolicyBoundary(value);
return safe ? [safe] : [];
})
)))];
const canAnswerPreciseTiming = input.subject.birthTimeStatus === "confirmed"
&& packets.length > 0
&& packets.every(({ packet }) => packet.answerPolicy.canAnswerPreciseTiming);
const birthTimePolicy = input.subject.birthTimeStatus === "confirmed"
? "confirmed"
: input.subject.birthTimeStatus === "accepted"
? "accepted_directional_only"
: input.subject.birthTimeStatus === "candidate"
? "candidate_directional_only"
: "reported_directional_only";
return finalizeReportEvidenceBundleV2({
schemaVersion: "report_evidence_bundle.v2",
subject: input.subject,
requestedThemes: requestedPlans.map((plan) => plan.theme),
reportType: input.reportType,
presentationMode: input.presentationMode,
calculationProfile: {
calculationHash: base.packet.chart.calculationHash,
calculationHashDerived: base.packet.chart.calculationHashDerived,
birthTimeStatus: input.subject.birthTimeStatus,
ayanamsa: readCalculationProfileText(base.workflow, ["ayanamsa", "ayanamsa_name"], "ayanamsa"),
nodeMode: readCalculationProfileText(base.workflow, ["node_mode", "nodeMode"], "nodeMode"),
houseSystem: readCalculationProfileText(base.workflow, ["house_system", "houseSystem"], "houseSystem"),
vimshottari: safeDashaPeriods(base.packet.chart.vimshottari),
narayana: safeDashaPeriods(base.packet.chart.narayana),
},
skill: input.skillSnapshot,
charts: [...chartsById.values()],
claimCards,
blockedSections,
conflicts,
executionLedger,
evidenceRefs: executionLedger.map((receipt) => ({
id: receipt.id,
technique: receipt.technique,
status: receipt.status,
})),
answerPolicy: {
canAnswerPreciseTiming,
birthTimePolicy,
deterministicClaimsForbiddenFor,
},
});
}
function techniqueSlug(name: string, fallback: string): string {
const slug = name.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 80);
return /^[a-z0-9_.-]{1,80}$/.test(slug) ? slug : fallback;
}
// ---------------------------------------------------------------------------
// Document assembly (deterministic; agent writes narrative only)
// ---------------------------------------------------------------------------
export const PERSONAL_REPORT_DISCLAIMER =
"本报告基于所提供出生信息与服务器计算的排盘证据生成,属于解释性参考,不构成医疗、法律或投资建议。出生时间未经确认时,报告中的时间相关表述仅为方向性参考。";
export type AssembleReportDocumentInput = Readonly<{
reportId: string;
generatedAt: string;
packet: ReportEvidencePacket;
agentOutput: PersonalReportAgentOutput;
}>;
function canonicalChartHouses(
houses: readonly ReportEvidencePacket["chart"]["houses"][number][],
): ReportDocumentV1["charts"][number]["houses"] {
return houses.map((house) => ({
houseNumber: house.number,
sign: house.sign,
occupants: [...house.occupants].slice(0, 12),
}));
}
function canonicalPlanets(
planets: readonly ReportPlanetFact[],
): ReportDocumentV1["charts"][number]["planets"] {
return planets.map((planet) => ({
name: planet.id,
sign: planet.sign,
longitudeDegrees: planet.degree,
houseNumber: planet.house as number,
retrograde: planet.retrograde as boolean,
}));
}
/**
* Builds the candidate ReportDocument v1. Order is strict: appendix first,
* then evidenceHash = computeEvidenceHash(appendix) (canonical server hash,
* never a model self-report), then the final document.
*/
export function assembleReportDocument(
input: AssembleReportDocumentInput,
): ReportDocumentV1 {
const { packet } = input;
if (!packet.chart.ascendant) {
throw new ReportEvidenceInsufficientError("ascendant_missing");
}
const usedEvidenceRefs = new Set(
input.agentOutput.thematicNarrative.flatMap((section) => section.evidenceRefs),
);
const techniqueAudit: EvidenceAppendix["techniqueAudit"] = packet.techniqueAudit.map(
(row, index) => {
const id = row.id ?? `ev-audit-${index + 1}`;
return {
id,
techniqueId: techniqueSlug(row.technique, `tech-${index + 1}`),
techniqueName: row.technique,
status: canonicalTechniqueStatus(row.status),
used: usedEvidenceRefs.has(id),
...(row.note ? { notes: row.note.slice(0, 500) } : {}),
};
},
);
const conflicts: EvidenceAppendix["conflicts"] = packet.conflicts.map((conflict, index) => ({
id: conflict.id ?? `ev-conflict-${index + 1}`,
description: conflict.summary.slice(0, 1000),
impact: "多技法结果不一致,相关结论已按确定性边界降级",
status: "unresolved",
}));
const calculationEvidence: EvidenceAppendix["calculationEvidence"] = [];
if (packet.chart.calculationHashDerived) {
calculationEvidence.push({
id: "ev-calc-derived",
label: "calculation_hash",
value: packet.chart.calculationHash,
source: "derived_server_sha256_over_allowlisted_calculation_facts",
});
}
if (packet.chart.houses.some((house) => house.signDerived)
|| packet.chart.vargaHouses.some((varga) => varga.houses.some((house) => house.signDerived))) {
calculationEvidence.push({
id: "ev-calc-house-signs",
label: "house_sign_derivation",
value: "whole_sign_from_ascendant_for_houses_without_a_source_sign",
source: "server_derived",
});
}
calculationEvidence.push({
id: "ev-calc-ascendant",
label: "ascendant",
value: `${packet.chart.ascendant.sign} ${packet.chart.ascendant.degree.toFixed(2)}°`,
source: "server_calculation",
});
packet.chart.vimshottari?.forEach((period, index) => {
calculationEvidence.push({
id: `ev-calc-vimshottari-${index + 1}`,
label: `Vimshottari 大运:${period.lord}`,
value: `${period.start} ${period.end}`,
source: "server_calculation",
});
});
packet.chart.narayana?.forEach((period, index) => {
calculationEvidence.push({
id: `ev-calc-narayana-${index + 1}`,
label: `Narayana 大运:${period.lord}`,
value: `${period.start} ${period.end}`,
source: "server_calculation",
});
});
const appendix: EvidenceAppendix = {
expandedByDefault: false,
techniqueAudit,
conflicts,
calculationEvidence,
blockedTechniques: packet.blockedTechniques
.map((technique) => technique.slice(0, 120))
.slice(0, 100),
};
const evidenceHash = computeEvidenceHash(appendix);
const charts: ReportDocumentV1["charts"] = [{
id: "D1",
title: "本命盘 D1",
houses: canonicalChartHouses(packet.chart.houses),
planets: canonicalPlanets(packet.chart.planets),
claimStatus: packet.blockedTechniques.length > 0 ? "blocked" : "single_system_inference",
}];
for (const varga of packet.chart.vargaHouses) {
if (varga.houses.length === 0) continue;
charts.push({
id: varga.id,
title: varga.id === "D9" ? "九分盘 D9" : "事业盘 D10",
houses: canonicalChartHouses(varga.houses),
claimStatus: "single_system_inference",
});
}
const document: ReportDocumentV1 = {
schemaVersion: "report_document.v1",
reportId: input.reportId,
reportType: packet.reportType,
presentationMode: packet.presentationMode,
generatedAt: input.generatedAt,
subject: {
displayName: packet.subject.displayName,
birthTimeStatus: packet.subject.birthTimeStatus,
birthPlaceLabel: packet.subject.birthPlaceLabel,
},
provenance: {
skillName: packet.skillName,
skillVersion: packet.skillVersion,
skillSourceCommit: packet.skillSourceCommit,
skillSnapshotSha256: packet.skillSnapshotSha256,
calculationHash: packet.chart.calculationHash,
evidenceHash,
reportContractVersion: "1",
},
executiveSummary: {
headline: input.agentOutput.executiveSummary.headline,
summary: input.agentOutput.executiveSummary.summary,
priorities: [...input.agentOutput.executiveSummary.priorities],
overallClaimStatus: input.agentOutput.thematicNarrative.some(
(section) => section.claimStatus === "blocked",
)
? "blocked"
: "single_system_inference",
},
charts,
thematicNarrative: input.agentOutput.thematicNarrative.map((section) => ({
id: section.id,
title: section.title,
narrative: section.narrative,
actions: [...section.actions],
caveats: [...section.caveats],
claimStatus: section.claimStatus,
evidenceRefs: [...section.evidenceRefs],
})),
evidenceAppendix: appendix,
disclaimer: PERSONAL_REPORT_DISCLAIMER,
};
return document;
}
export type AssembleReportDocumentV2Input = Readonly<{
reportId: string;
generatedAt: string;
depth: ReportDepth;
bundle: ReportEvidenceBundleV2;
plan: PersonalReportSectionPlan;
agentOutput: PersonalReportAgentOutput;
}>;
const CLAIM_STATUS_RANK: Readonly<Record<ClaimStatus, number>> = {
multi_system_consensus: 0,
single_system_inference: 1,
parameter_sensitive: 2,
unclosed_divisional_chart: 3,
user_history_verification_required: 4,
blocked: 5,
};
function uniqueInOrder(values: readonly string[]): string[] {
return [...new Set(values)];
}
function equalStringArrays(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
/**
* The model is a writer only. This validation binds every generated thematic
* section to the deterministic server plan and its single Claim Card before
* any model text can enter ReportDocument v2.
*/
export function validatePersonalReportAgentOutputAgainstPlan(
output: PersonalReportAgentOutput,
plan: PersonalReportSectionPlan,
bundle: ReportEvidenceBundleV2,
): PersonalReportAgentOutput {
const writePlans = plan.sections.filter((section) => (
section.kind === "thematic" && section.disposition === "write"
));
if (output.thematicNarrative.length !== writePlans.length) {
throw new Error("report_writer_theme_count_mismatch");
}
const seen = new Set<string>();
const claimCards = new Map(bundle.claimCards.map((card) => [card.theme, card]));
const plans = new Map(writePlans.map((section) => [section.theme as string, section]));
for (const section of output.thematicNarrative) {
if (seen.has(section.theme)) throw new Error(`report_writer_duplicate_theme:${section.theme}`);
seen.add(section.theme);
const sectionPlan = plans.get(section.theme);
const card = claimCards.get(section.theme);
if (!sectionPlan || !card) throw new Error(`report_writer_unplanned_theme:${section.theme}`);
if (section.id !== sectionPlan.id) throw new Error(`report_writer_section_id_mismatch:${section.theme}`);
if (!equalStringArrays(section.evidenceRefs, sectionPlan.evidenceRefs)) {
throw new Error(`report_writer_evidence_refs_mismatch:${section.theme}`);
}
if (CLAIM_STATUS_RANK[section.claimStatus] < CLAIM_STATUS_RANK[card.assertionLevel]) {
throw new Error(`report_writer_claim_status_upgrade:${section.theme}`);
}
}
for (const sectionPlan of writePlans) {
if (!seen.has(sectionPlan.theme as string)) {
throw new Error(`report_writer_theme_missing:${sectionPlan.theme}`);
}
}
return output;
}
function claimStatusForEvidenceStatus(status: EvidenceRefStatus): ClaimStatus {
if (status === "verified") return "single_system_inference";
if (status === "partial") return "parameter_sensitive";
return "blocked";
}
function evidenceRefsForTechnique(
bundle: ReportEvidenceBundleV2,
technique: string,
requireExecuted = true,
): string[] {
const normalized = technique.toUpperCase();
return bundle.executionLedger.filter((receipt) => {
const receiptTechnique = receipt.technique.toUpperCase();
const matches = receiptTechnique === normalized || receiptTechnique.endsWith(`:${normalized}`);
return matches && (!requireExecuted || (receipt.executed && receipt.status !== "blocked"));
}).map((receipt) => receipt.id);
}
function evidenceAppendixFromBundle(
bundle: ReportEvidenceBundleV2,
usedEvidenceRefs: ReadonlySet<string>,
): EvidenceAppendix {
const d1 = bundle.charts.find((chart) => chart.id === "D1");
const calculationEvidence: EvidenceAppendix["calculationEvidence"] = [];
if (bundle.calculationProfile.calculationHashDerived) {
calculationEvidence.push({
id: "ev-calc-derived",
label: "calculation_hash",
value: bundle.calculationProfile.calculationHash,
source: "derived_server_sha256_over_allowlisted_calculation_facts",
});
}
if (d1?.ascendant) {
calculationEvidence.push({
id: "ev-calc-ascendant",
label: "ascendant",
value: `${d1.ascendant.sign} ${d1.ascendant.degree.toFixed(2)}°`,
source: "server_calculation",
});
}
bundle.calculationProfile.vimshottari?.forEach((period, index) => {
calculationEvidence.push({
id: `ev-calc-vimshottari-${index + 1}`,
label: `Vimshottari 大运:${period.lord}`,
value: `${period.start} ${period.end}`,
source: "server_calculation",
});
});
bundle.calculationProfile.narayana?.forEach((period, index) => {
calculationEvidence.push({
id: `ev-calc-narayana-${index + 1}`,
label: `Narayana 大运:${period.lord}`,
value: `${period.start} ${period.end}`,
source: "server_calculation",
});
});
const ledgerById = new Map(bundle.executionLedger.map((receipt) => [receipt.id, receipt]));
return {
expandedByDefault: bundle.presentationMode === "research",
techniqueAudit: bundle.executionLedger.map((receipt, index) => ({
id: receipt.id,
techniqueId: techniqueSlug(receipt.technique, `tech-${index + 1}`),
techniqueName: receipt.technique,
status: receipt.status,
used: usedEvidenceRefs.has(receipt.id),
...(receipt.note ? { notes: receipt.note.slice(0, 500) } : {}),
})),
conflicts: bundle.conflicts.map((conflict) => ({
id: conflict.id,
description: conflict.summary,
impact: conflict.resolutionStatus === "bounded"
? "冲突已被限制在证据边界内,相关结论不得提升确定性"
: "多技法结果不一致,相关结论已按确定性边界降级",
status: conflict.resolutionStatus === "bounded" ? "partial" : "unresolved",
})),
calculationEvidence,
blockedTechniques: uniqueInOrder(bundle.blockedSections.flatMap((section) => (
section.missingTechniqueRefs.map((ref) => ledgerById.get(ref)?.technique ?? ref)
))).slice(0, 100),
};
}
function canonicalBundleChart(
chart: ReportChartFact,
evidenceRefs: readonly string[],
status: ClaimStatus,
): ReportDocumentV2["charts"][number] {
const planets = chart.planets.filter((planet) => planet.house !== null).slice(0, 12).map((planet) => ({
name: planet.id,
sign: planet.sign,
longitudeDegrees: ((planet.degree % 360) + 360) % 360,
houseNumber: planet.house as number,
retrograde: planet.retrograde ?? false,
}));
return {
id: chart.id as ReportDocumentV2["charts"][number]["id"],
title: chart.title,
houses: chart.houses.map((house) => ({
houseNumber: house.number,
sign: house.sign,
occupants: [...house.occupants].slice(0, 12),
})),
...(planets.length > 0 ? { planets } : {}),
claimStatus: status,
evidenceRefs: [...evidenceRefs],
};
}
/** Assemble the canonical current report contract without model-generated markup. */
export function assembleReportDocumentV2(
input: AssembleReportDocumentV2Input,
): ReportDocumentV2 {
const bundle = validateReportEvidenceBundleV2(input.bundle);
const plan = validatePersonalReportSectionPlan(input.plan, bundle);
const agentOutput = validatePersonalReportAgentOutputAgainstPlan(input.agentOutput, plan, bundle);
if (bundle.skill.name !== "jyotish-personal-report") {
throw new Error("report_writer_skill_package_invalid");
}
const d1 = bundle.charts.find((chart) => chart.id === "D1");
const d1Refs = evidenceRefsForTechnique(bundle, "D1");
if (!d1 || !d1.ascendant || d1Refs.length === 0) {
throw new ReportEvidenceInsufficientError("d1_evidence_missing");
}
const thematicNarrative: ReportDocumentV2["thematicNarrative"] = agentOutput.thematicNarrative.map((section) => ({
id: section.id,
theme: section.theme,
title: section.title,
narrative: section.narrative,
actions: [...section.actions],
caveats: [...section.caveats],
claimStatus: section.claimStatus,
evidenceRefs: [...section.evidenceRefs],
}));
const thematicRefs = uniqueInOrder(thematicNarrative.flatMap((section) => section.evidenceRefs));
const executiveRefs = thematicRefs.length > 0 ? thematicRefs : d1Refs;
const chartIds = new Set(["D1", "D2", "D9", "D10", "D11", "D24"]);
const charts: ReportDocumentV2["charts"] = [];
for (const chart of bundle.charts) {
if (!chartIds.has(chart.id)) continue;
const refs = evidenceRefsForTechnique(bundle, chart.id);
if (refs.length === 0) continue;
const statuses = refs.map((ref) => bundle.evidenceRefs.find((entry) => entry.id === ref)?.status ?? "blocked");
const status = statuses.every((entry) => entry === "verified")
? "single_system_inference"
: statuses.some((entry) => entry === "blocked")
? "parameter_sensitive"
: claimStatusForEvidenceStatus(statuses[0]);
charts.push(canonicalBundleChart(chart, refs, status));
}
const blockedConflictDisclosure: ReportDocumentV2["blockedConflictDisclosure"] = bundle.blockedSections.map((section) => {
const missingEvidence = section.missingTechniqueRefs.map((ref) => (
bundle.executionLedger.find((receipt) => receipt.id === ref)?.technique ?? ref
));
const conflictNotes = bundle.conflicts.filter((conflict) => (
conflict.techniqueRefs.some((ref) => section.missingTechniqueRefs.includes(ref))
)).map((conflict) => conflict.summary);
return {
theme: section.theme,
title: section.section,
reason: section.reason,
missingEvidence,
conflictNotes,
evidenceRefs: [...section.missingTechniqueRefs],
claimStatus: "blocked" as const,
};
});
const timingSection = thematicNarrative.find((section) => section.theme === "timing");
const currentPhase: ReportDocumentV2["currentPhase"] = timingSection
? {
title: "当前阶段与时间边界",
phaseLabel: bundle.answerPolicy.canAnswerPreciseTiming ? "当前阶段" : "当前阶段(方向性)",
narrative: timingSection.narrative,
timingNotes: [],
caveats: [...timingSection.caveats],
claimStatus: timingSection.claimStatus,
evidenceRefs: [...timingSection.evidenceRefs],
}
: null;
const actionNotes: ReportDocumentV2["actionNotes"] = [];
thematicNarrative.forEach((section) => {
section.actions.forEach((action, index) => actionNotes.push({
id: `action-${section.theme.replaceAll(".", "-")}-${index + 1}`,
title: section.title,
note: action,
priority: index === 0 ? "now" : index === 1 ? "next" : "watch",
evidenceRefs: [...section.evidenceRefs],
}));
});
if (actionNotes.length === 0) {
const fallback = agentOutput.executiveSummary.priorities[0]
?? "将本报告列出的证据边界与待核验事项作为后续行动清单。";
actionNotes.push({
id: "action-evidence-review",
title: "后续核验",
note: fallback,
priority: "now",
evidenceRefs: [...executiveRefs],
});
}
const usedEvidenceRefs = new Set<string>([
...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<ClaimStatus>((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-9-9一二三四五六七八九十]{1,2}\s*月(?:\s*[0-9-9一二三四五六七八九十]{1,2}\s*日)?/,
/[0-9-9一二三四五六七八九十]{1,2}\s*月\s*[0-9-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<D> =
| { 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<D>(
document: D,
packet: ReportEvidencePacket,
): GuardResult<D> {
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<string>();
if (timingBlocked) {
for (const section of readModel.sections) {
const row = narrative.find((item) => record(item)?.id === section.id);
const target = record(row);
if (!target) continue;
const redacted = redactDeterministicSentences(
typeof target.narrative === "string" ? target.narrative : "",
PRECISE_TIMING_PATTERNS,
);
let changed = false;
if (redacted.removedCount > 0) {
target.narrative = redacted.text;
changed = true;
}
if (Array.isArray(target.actions)) {
const keptActions = target.actions
.map(String)
.filter((action) => !PRECISE_TIMING_PATTERNS.some((pattern) => pattern.test(action)));
if (keptActions.length !== target.actions.length) {
target.actions = keptActions;
changed = true;
}
}
if (Array.isArray(target.caveats)) {
const keptCaveats = target.caveats
.map(String)
.filter((caveat) => !PRECISE_TIMING_PATTERNS.some((pattern) => pattern.test(caveat)));
if (keptCaveats.length !== target.caveats.length) {
target.caveats = keptCaveats;
changed = true;
}
}
if (changed) {
step3Blocked.add(section.id);
target.claimStatus = "blocked";
const caveats = stringArray(target.caveats);
if (!caveats.includes(BLOCKED_SECTION_CAVEAT)) caveats.push(BLOCKED_SECTION_CAVEAT);
target.caveats = caveats;
}
}
const summaryRedacted = redactDeterministicSentences(
readModel.executiveSummary.summary,
PRECISE_TIMING_PATTERNS,
);
if (summary) {
if (summaryRedacted.removedCount > 0) {
summary.summary = summaryRedacted.text;
summary.overallClaimStatus = "blocked";
}
if (Array.isArray(summary.priorities)) {
const keptPriorities = summary.priorities
.map(String)
.filter((priority) => !PRECISE_TIMING_PATTERNS.some((pattern) => pattern.test(priority)));
if (keptPriorities.length !== summary.priorities.length) {
summary.priorities = keptPriorities;
}
}
}
}
// 4. Blocked downgrade from evidence refs: a section whose refs are all
// blocked must be blocked; any blocked ref caps the section at
// parameter_sensitive. Blocked sections must not keep deterministic
// phrasing of any domain. If every section ends blocked, the whole
// report is blocked.
const refStatuses = new Map(packet.evidenceRefs.map((ref) => [ref.id, ref.status]));
let blockedSectionCount = 0;
for (const section of readModel.sections) {
const row = narrative.find((item) => record(item)?.id === section.id);
const target = record(row);
if (!target) continue;
const sectionRefs = section.evidenceRefs.map((ref) => (
refStatuses.has(ref) ? packet.evidenceRefs.find((candidate) => candidate.id === ref)! : null
)).filter((ref): ref is ReportEvidencePacket["evidenceRefs"][number] => ref !== null);
target.claimStatus = step3Blocked.has(section.id)
? "blocked"
: effectiveClaimStatus(section.claimStatus, sectionRefs);
if (target.claimStatus === "blocked") blockedSectionCount += 1;
const finalStatus = target.claimStatus as string;
if (finalStatus === "blocked") {
const narrativeText = typeof target.narrative === "string" ? target.narrative : "";
const blockedClaims = findForbiddenDeterministicClaims(narrativeText);
const hardDomain = blockedClaims.find((claim) => claim.domain !== "timing");
if (hardDomain) {
return {
ok: false,
code: "report_guard_rejected",
reason: `deterministic_${hardDomain.domain}_claim_in_blocked_section`,
};
}
const redacted = redactDeterministicSentences(narrativeText, PRECISE_TIMING_PATTERNS);
if (redacted.removedCount > 0) {
target.narrative = redacted.text;
}
}
}
if (summary && (readModel.sections.length === 0 || blockedSectionCount === readModel.sections.length)) {
summary.overallClaimStatus = "blocked";
}
return { ok: true, document: next as D };
}
// ---------------------------------------------------------------------------
// Generation pipeline: agent -> document -> guard -> canonical server parse
// ---------------------------------------------------------------------------
type GeneratePersonalReportBaseDeps = Readonly<{
reportId: string;
agent: ReportAgentPort;
now?: () => Date;
signal?: AbortSignal;
}>;
export type GeneratePersonalReportDeps = GeneratePersonalReportBaseDeps & Readonly<{
bundle: ReportEvidenceBundleV2;
depth: ReportDepth;
}>;
export type GeneratePersonalReportResult = Readonly<
| { status: "ready"; document: ReportDocumentV2; evidenceHash: string }
| { status: "failed"; failureCode: "report_schema_invalid" | "report_guard_rejected" }
>;
/**
* Runs the dedicated report agent exactly once (plus its single internal
* repair retry), assembles the candidate document, applies the deterministic
* guard, then runs the FINAL canonical server parse on the guarded document.
* The evidence hash is recomputed from the canonical evidence appendix by the
* server contract — never a model self-report. Never falls back to mock,
* example, random or sample data.
*/
export async function generatePersonalReport(
deps: GeneratePersonalReportDeps,
): Promise<GeneratePersonalReportResult> {
let bundle: ReportEvidenceBundleV2;
let plan: PersonalReportSectionPlan;
let agentOutput: PersonalReportAgentOutput;
try {
bundle = validateReportEvidenceBundleV2(deps.bundle);
plan = validatePersonalReportSectionPlan(
buildPersonalReportSectionPlan(bundle, deps.depth),
bundle,
);
agentOutput = await deps.agent.generate(bundle, plan, { signal: deps.signal });
validatePersonalReportAgentOutputAgainstPlan(agentOutput, plan, bundle);
} catch {
return { status: "failed", failureCode: "report_schema_invalid" };
}
const packet = buildLegacyPacketFromBundle(bundle);
let candidate: ReportDocumentV2;
try {
candidate = assembleReportDocumentV2({
reportId: deps.reportId,
generatedAt: (deps.now ?? (() => new Date()))().toISOString(),
depth: deps.depth,
bundle,
plan,
agentOutput,
});
} catch {
return { status: "failed", failureCode: "report_schema_invalid" };
}
const guarded = applyReportGuard(candidate, packet);
if (!guarded.ok) {
return { status: "failed", failureCode: "report_guard_rejected" };
}
const parsed = safeParseServerReportDocument(guarded.document);
if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") {
return { status: "failed", failureCode: "report_schema_invalid" };
}
return {
status: "ready",
document: parsed.document,
evidenceHash: computeEvidenceHash(parsed.document.evidenceAppendix),
};
}