2155 lines
83 KiB
TypeScript
2155 lines
83 KiB
TypeScript
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-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<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),
|
||
};
|
||
}
|