fbd6e48036
Staging wrote four ready chapters then marked the job schema-invalid 34ms later with no summary telemetry. Keep chapter persistence and assemble going if onProgress or a non-lease heartbeat error fails. Co-authored-by: Cursor <cursoragent@cursor.com>
3391 lines
133 KiB
TypeScript
3391 lines
133 KiB
TypeScript
import { createHash } from "node:crypto";
|
||
import {
|
||
computeEvidenceHash,
|
||
safeParseServerReportDocument,
|
||
} from "./personal-report-contract.server-core.ts";
|
||
import {
|
||
REQUIRED_THEME_CHARTS,
|
||
type ClaimStatus,
|
||
type EvidenceAppendix,
|
||
type ReportDepth,
|
||
type ReportDocumentV1,
|
||
type ReportDocumentV2,
|
||
} from "./personal-report-contract.ts";
|
||
import {
|
||
PersonalReportAgentOutputError,
|
||
type EvidenceRefStatus,
|
||
type PersonalReportAgentOutput,
|
||
type ReportAgentPort,
|
||
type ReportEvidenceBundleV2,
|
||
type ReportEvidencePacket,
|
||
type ReportPlanetFact,
|
||
} from "@/mastra/personal-report";
|
||
import type {
|
||
EvidenceConflict,
|
||
ReportChartFact,
|
||
ReportClaimCard,
|
||
ReportCurrentDashaFact,
|
||
ReportFunctionalRole,
|
||
ReportFunctionalRoleFact,
|
||
ReportInterpretiveFacts,
|
||
ReportSavScoreFact,
|
||
ReportShadbalaRankFact,
|
||
ReportThemeNarrativeSeed,
|
||
ReportYogaCategory,
|
||
ReportYogaFact,
|
||
SafeReportSubject,
|
||
TechniqueExecutionReceipt,
|
||
} from "./report-evidence-bundle-v2.ts";
|
||
import {
|
||
REPORT_YOGA_CATEGORIES,
|
||
finalizeReportEvidenceBundleV2,
|
||
validateReportEvidenceBundleV2,
|
||
} from "./report-evidence-bundle-v2.ts";
|
||
import { buildReportThemePlan, normalizeReportTheme } from "./report-theme-evidence-plan.ts";
|
||
import { resolveActiveSkillPackage } from "./skill-package-registry.ts";
|
||
import {
|
||
buildPersonalReportSectionPlan,
|
||
sectionOutputTokenBudget,
|
||
summaryOutputTokenBudget,
|
||
validatePersonalReportSectionPlan,
|
||
type PersonalReportSectionPlan,
|
||
type ReportSectionPlanEntry,
|
||
} from "./personal-report-plan.ts";
|
||
import type { PersonalReportSectionRecord, PersonalReportSectionService } from "./personal-report-section-service-core.ts";
|
||
// Compatibility re-export: prefer importing from ./personal-report-codes.ts
|
||
// directly (the pure, dependency-free codes module).
|
||
export { REPORT_STABLE_CODES } from "./personal-report-codes.ts";
|
||
|
||
/**
|
||
* Personal report generation: workflow evidence -> minimal packet -> report
|
||
* agent -> candidate document -> deterministic guard -> canonical server
|
||
* parse.
|
||
*
|
||
* Contract and persistence are the canonical shared modules (p3):
|
||
* - `personal-report-contract.ts` (isomorphic) + `personal-report-contract.server-core.ts`
|
||
* / `personal-report-contract.server.ts` (server hash + parse entry).
|
||
* - `personal-report-service-core.ts` / `personal-report-service.ts`
|
||
* (persistence, fingerprint idempotency).
|
||
*
|
||
* This module never duplicates the schema and never falls back to
|
||
* mock/example/random/sample data. Missing real evidence fails closed.
|
||
* Stable API codes live in the dependency-free ./personal-report-codes.ts.
|
||
*/
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Stable failure codes live in ./personal-report-codes.ts (imported above).
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Canonical serialization + fingerprints
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export function canonicalSerialize(value: unknown): string {
|
||
if (value === undefined) return "null";
|
||
if (Array.isArray(value)) {
|
||
return `[${value.map(canonicalSerialize).join(",")}]`;
|
||
}
|
||
if (value !== null && typeof value === "object") {
|
||
const source = value as Record<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;
|
||
}
|
||
|
||
const DOCUMENT_VARGA_CHART_IDS = ["D2", "D9", "D10", "D11", "D24"] as const;
|
||
type DocumentVargaChartId = (typeof DOCUMENT_VARGA_CHART_IDS)[number];
|
||
|
||
const VARGA_KEY_ALIASES: Readonly<Record<DocumentVargaChartId, readonly string[]>> = {
|
||
D2: ["D2_Hora", "D2"],
|
||
D9: ["D9_Navamsa", "D9"],
|
||
D10: ["D10_Dasamsa", "D10"],
|
||
D11: ["D11_Rudramsa", "D11"],
|
||
D24: ["D24_Chaturvimsamsa", "D24_Siddhamsa", "D24"],
|
||
};
|
||
|
||
function canonicalDocumentVargaChartId(rawId: string): DocumentVargaChartId | null {
|
||
const match = /^(D(?:24|11|10|9|2))(?:[_\s-].*)?$/.exec(rawId.trim().toUpperCase());
|
||
return match ? match[1] as DocumentVargaChartId : null;
|
||
}
|
||
|
||
const VARGA_ENGINE_META_KEYS = new Set([
|
||
"Ascendant",
|
||
"ascendant",
|
||
"planets",
|
||
"house_chart",
|
||
"division",
|
||
"name",
|
||
"meaning",
|
||
]);
|
||
|
||
function vargaPlanetHouse(row: JsonRecord, ascIndex: number): number | null {
|
||
const declared = finiteNumber(row.house);
|
||
if (declared !== null && Number.isInteger(declared) && declared >= 1 && declared <= 12) {
|
||
return declared;
|
||
}
|
||
const planetIndex = signIndex(row.sign_index ?? row.sign_idx ?? row.sign);
|
||
if (planetIndex === null) return null;
|
||
return (((planetIndex - ascIndex) % 12) + 12) % 12 + 1;
|
||
}
|
||
|
||
function collectVargaOccupants(
|
||
entries: Iterable<readonly [string, unknown]>,
|
||
ascIndex: number,
|
||
): string[][] {
|
||
const occupants: string[][] = Array.from({ length: 12 }, () => []);
|
||
for (const [name, item] of entries) {
|
||
const row = record(item);
|
||
if (!row) continue;
|
||
const house = vargaPlanetHouse(row, ascIndex);
|
||
if (house === null) continue;
|
||
const safeName = safeCelestialName(name);
|
||
if (safeName) occupants[house - 1].push(safeName);
|
||
}
|
||
return occupants;
|
||
}
|
||
|
||
/**
|
||
* Divisional charts live under modules.varga_full with keys such as D2_Hora /
|
||
* D9_Navamsa / D10_Dasamsa / D11_Rudramsa (or D2 / D9 / D10 / D11).
|
||
*
|
||
* The live engine shape is {ascendant: {sign, sign_index, degree}, planets:
|
||
* {Sun: {sign, sign_index, house, degree}, ...}, house_chart, division, name,
|
||
* meaning}. An older fixture shape used {Ascendant: {sign_idx|sign}, <planet>:
|
||
* {sign_idx|sign}, _meta, ...}. Both are accepted. House signs are whole-sign
|
||
* from the divisional ascendant. Occupants come from planets.house when
|
||
* present, otherwise from sign index. house_chart and unknown keys are not
|
||
* copied. Occupant names go through the celestial allowlist.
|
||
*/
|
||
function deriveVargaHousesFromEngine(
|
||
varga: JsonRecord,
|
||
): Readonly<{
|
||
ascIndex: number;
|
||
degree: number;
|
||
houses: ReportEvidencePacket["chart"]["houses"];
|
||
}> | null {
|
||
const ascendant = record(varga.ascendant) ?? record(varga.Ascendant);
|
||
const ascIndex = ascendant
|
||
? signIndex(ascendant.sign_index ?? ascendant.sign_idx ?? ascendant.sign)
|
||
: null;
|
||
if (ascIndex === null) return null;
|
||
const planets = record(varga.planets);
|
||
const occupants = planets
|
||
? collectVargaOccupants(Object.entries(planets), ascIndex)
|
||
: collectVargaOccupants(
|
||
Object.entries(varga).filter(([name]) => (
|
||
!name.startsWith("_") && !VARGA_ENGINE_META_KEYS.has(name)
|
||
)),
|
||
ascIndex,
|
||
);
|
||
return {
|
||
ascIndex,
|
||
degree: finiteNumber(ascendant?.degree ?? ascendant?.degree_in_sign) ?? 0,
|
||
houses: Array.from({ length: 12 }, (_, index) => ({
|
||
number: index + 1,
|
||
sign: SIGNS[((ascIndex + index) % 12 + 12) % 12],
|
||
signDerived: true,
|
||
occupants: occupants[index].slice(0, 12),
|
||
})),
|
||
};
|
||
}
|
||
|
||
function readVargaHouses(
|
||
vargaFull: JsonRecord | null,
|
||
): ReportEvidencePacket["chart"]["vargaHouses"] {
|
||
if (!vargaFull) return [];
|
||
const result: ReportEvidencePacket["chart"]["vargaHouses"][number][] = [];
|
||
const seen = new Set<DocumentVargaChartId>();
|
||
const take = (id: DocumentVargaChartId, raw: unknown): void => {
|
||
if (seen.has(id)) return;
|
||
const varga = record(raw);
|
||
if (!varga) return;
|
||
const derived = deriveVargaHousesFromEngine(varga);
|
||
if (!derived) return;
|
||
seen.add(id);
|
||
result.push({ id, houses: derived.houses });
|
||
};
|
||
for (const id of DOCUMENT_VARGA_CHART_IDS) {
|
||
for (const key of VARGA_KEY_ALIASES[id]) take(id, vargaFull[key]);
|
||
}
|
||
for (const [rawId, rawValue] of Object.entries(vargaFull)) {
|
||
const id = canonicalDocumentVargaChartId(rawId);
|
||
if (id) take(id, rawValue);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function readDashaPeriods(rows: unknown): ReportEvidencePacket["chart"]["vimshottari"] {
|
||
if (!Array.isArray(rows)) return null;
|
||
const periods: { lord: string; start: string; end: string }[] = [];
|
||
for (const item of rows) {
|
||
const row = record(item);
|
||
const lord = text(row?.lord ?? row?.planet ?? row?.name);
|
||
const start = text(row?.start ?? row?.start_date);
|
||
const end = text(row?.end ?? row?.end_date);
|
||
if (lord && start && end) periods.push({ lord, start, end });
|
||
}
|
||
return periods.length > 0 ? periods : null;
|
||
}
|
||
|
||
function readVimshottari(chart: JsonRecord | null): ReportEvidencePacket["chart"]["vimshottari"] {
|
||
const dasha = record(chart?.dasha);
|
||
if (!dasha) return null;
|
||
const mahadashas = Array.isArray(dasha.mahadashas) ? dasha.mahadashas : null;
|
||
return mahadashas ? readDashaPeriods(mahadashas) : null;
|
||
}
|
||
|
||
function readNarayana(modules: JsonRecord | null): ReportEvidencePacket["chart"]["narayana"] {
|
||
const narayana = record(modules?.narayana_dasha);
|
||
if (!narayana) return null;
|
||
const rows = Array.isArray(narayana.periods) ? narayana.periods
|
||
: Array.isArray(narayana.mahadashas) ? narayana.mahadashas : null;
|
||
return rows ? readDashaPeriods(rows) : null;
|
||
}
|
||
|
||
/**
|
||
* machine_evidence_packet.sections is an object map in the real engine
|
||
* ({D1: {status: "used"|"missing", source_path}, planet_degrees: {...}, ...});
|
||
* the legacy array shape is also accepted.
|
||
*/
|
||
function readSections(machinePacket: JsonRecord | null): {
|
||
name: string;
|
||
status: string;
|
||
sourcePath: string;
|
||
}[] {
|
||
const raw = machinePacket?.sections;
|
||
const entries = Array.isArray(raw)
|
||
? raw.map((item, index) => [String(index), item] as const)
|
||
: Object.entries(record(raw) ?? {});
|
||
const sections: { name: string; status: string; sourcePath: string }[] = [];
|
||
for (const [key, item] of entries) {
|
||
const row = record(item) ?? {};
|
||
sections.push({
|
||
name: text(row?.name ?? row?.technique) ?? key,
|
||
status: text(row?.status) ?? "unknown",
|
||
sourcePath: text(row?.source_path) ?? "",
|
||
});
|
||
}
|
||
return sections;
|
||
}
|
||
|
||
/**
|
||
* Deterministic evidence-status rule for machine-packet sections. The real
|
||
* engine only emits used/missing; "verified" as a literal string must never be
|
||
* required or the gate would always fail. Core calculation sections (D1,
|
||
* planet_degrees, house_degrees) with an internal source path map to verified;
|
||
* everything else internal is partial; external oracle sections are capped at
|
||
* partial; missing/blocked stay blocked.
|
||
*/
|
||
const VERIFIED_CALCULATION_SECTIONS = new Set(["D1", "planet_degrees", "house_degrees"]);
|
||
const EXTERNAL_SECTIONS = new Set([
|
||
"external_oracle_status",
|
||
"vedastro_official_raw_response",
|
||
"vedastro_official_raw_archive_manifest",
|
||
]);
|
||
|
||
function sectionEvidenceStatus(
|
||
status: string,
|
||
name: string,
|
||
sourcePath: string,
|
||
): "verified" | "partial" | "blocked" {
|
||
if (status === "missing" || status === "blocked" || status === "official_blocked") return "blocked";
|
||
if (status === "verified") return "verified";
|
||
if (status === "partial") return "partial";
|
||
// used / available / received_unverified / unknown / undefined
|
||
const internal = sourcePath.startsWith("chart.")
|
||
|| sourcePath.startsWith("modules.")
|
||
|| sourcePath.startsWith("scripts.");
|
||
if (VERIFIED_CALCULATION_SECTIONS.has(name) && internal) return "verified";
|
||
if (EXTERNAL_SECTIONS.has(name) || sourcePath.startsWith("vedastro_")) return "partial";
|
||
return "partial";
|
||
}
|
||
|
||
export type BuildEvidencePacketInput = Readonly<{
|
||
workflow: unknown;
|
||
subject: ReportEvidencePacket["subject"];
|
||
requestedThemes: readonly string[];
|
||
reportType: "personal_full" | "personal_thematic";
|
||
presentationMode: "default" | "research";
|
||
candidateRange: Readonly<{ start: string; end: string }> | null;
|
||
skillSnapshot: SkillSnapshot;
|
||
}>;
|
||
|
||
/** Raised when the real workflow evidence cannot support an honest report. */
|
||
export class ReportEvidenceInsufficientError extends Error {
|
||
readonly code = "calculation_unavailable";
|
||
|
||
constructor(reason: string) {
|
||
super(`Report evidence insufficient: ${reason}`);
|
||
this.name = "ReportEvidenceInsufficientError";
|
||
}
|
||
}
|
||
|
||
function assertUsablePacket(packet: ReportEvidencePacket): void {
|
||
if (!packet.chart.ascendant) {
|
||
throw new ReportEvidenceInsufficientError("ascendant_missing");
|
||
}
|
||
const houseNumbers = packet.chart.houses.map((house) => house.number);
|
||
const unique = new Set(houseNumbers);
|
||
if (houseNumbers.length !== 12 || unique.size !== 12
|
||
|| houseNumbers.some((number) => number < 1 || number > 12)) {
|
||
throw new ReportEvidenceInsufficientError("d1_houses_incomplete");
|
||
}
|
||
if (packet.chart.planets.length === 0) {
|
||
throw new ReportEvidenceInsufficientError("planets_missing");
|
||
}
|
||
if (packet.chart.planets.some((planet) => planet.house === null || planet.retrograde === null)) {
|
||
throw new ReportEvidenceInsufficientError("planet_fact_incomplete");
|
||
}
|
||
if (packet.evidenceRefs.length === 0) {
|
||
throw new ReportEvidenceInsufficientError("evidence_refs_missing");
|
||
}
|
||
// At least one ref must be backed by an explicit verified fact; pure layer
|
||
// names (partial) are not enough to claim evidence-backed sections.
|
||
if (!packet.evidenceRefs.some((ref) => ref.status === "verified")) {
|
||
throw new ReportEvidenceInsufficientError("no_verified_evidence_ref");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Extracts ONLY allowlisted facts from the real workflow response. Internal
|
||
* paths, prompts, exception stacks, chat history and unrelated raw objects are
|
||
* structurally excluded: unknown keys are never copied. Fails closed when the
|
||
* evidence cannot support a report.
|
||
*/
|
||
export function buildReportEvidencePacket(input: BuildEvidencePacketInput): ReportEvidencePacket {
|
||
const workflow = record(input.workflow) ?? {};
|
||
const chartData = record(workflow.chart) ?? {};
|
||
const modules = record(chartData.modules) ?? {};
|
||
const consumerContext = record(workflow.consumer_context) ?? {};
|
||
const machinePacket = record(workflow.machine_evidence_packet) ?? {};
|
||
const answerPolicy = record(consumerContext.answer_policy) ?? {};
|
||
|
||
// Real base chart selection mirrors the orchestrator: modules.chart (dict)
|
||
// -> chart_data.chart (nested) -> chart_data itself. Houses fall back to the
|
||
// top-level chart like the orchestrator's house_degrees section does.
|
||
const baseChart = resolveBaseChart(chartData);
|
||
|
||
const ascendant = record(baseChart.ascendant);
|
||
const ascendantSign = text(ascendant?.sign ?? ascendant?.sign_name);
|
||
const ascendantSignIndex = ascendantSign ? signIndex(ascendantSign) : null;
|
||
const ascendantDegree = finiteNumber(
|
||
ascendant?.degree_in_sign ?? ascendant?.degree ?? ascendant?.longitude_deg ?? ascendant?.lon,
|
||
);
|
||
|
||
const planets = readPlanets(baseChart.planets);
|
||
const houses = readHouses(
|
||
baseChart.houses ?? chartData.houses,
|
||
ascendantSignIndex,
|
||
planets,
|
||
);
|
||
const vimshottari = readVimshottari(baseChart);
|
||
const narayana = readNarayana(modules);
|
||
const vargaHouses = readVargaHouses(record(modules.varga_full));
|
||
|
||
const availableLayers = stringArray(consumerContext.available_layers);
|
||
const missingLayers = stringArray(consumerContext.missing_route_layers);
|
||
const hardBlockers = stringArray(consumerContext.hard_blockers);
|
||
|
||
const techniqueAudit: { technique: string; status: string; note: string }[] = [];
|
||
const seenTechniques = new Set<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"], ["true_pushya", "True Pushya"],
|
||
]);
|
||
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[] = [];
|
||
const seen = new Set<DocumentVargaChartId>();
|
||
const take = (id: DocumentVargaChartId, raw: unknown): void => {
|
||
if (seen.has(id)) return;
|
||
const varga = record(raw);
|
||
if (!varga) return;
|
||
const derived = deriveVargaHousesFromEngine(varga);
|
||
if (!derived) return;
|
||
seen.add(id);
|
||
charts.push({
|
||
id,
|
||
title: `${id} 分盘`,
|
||
ascendant: {
|
||
sign: SIGNS[derived.ascIndex],
|
||
degree: derived.degree,
|
||
},
|
||
houses: derived.houses,
|
||
planets: [],
|
||
});
|
||
};
|
||
for (const id of DOCUMENT_VARGA_CHART_IDS) {
|
||
for (const key of VARGA_KEY_ALIASES[id]) take(id, vargaFull[key]);
|
||
}
|
||
for (const [rawId, rawValue] of Object.entries(vargaFull)) {
|
||
const id = canonicalDocumentVargaChartId(rawId);
|
||
if (id) take(id, rawValue);
|
||
}
|
||
return charts;
|
||
}
|
||
|
||
function mergeChart(target: Map<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
|
||
.flatMap((chart) => {
|
||
const id = canonicalDocumentVargaChartId(chart.id);
|
||
return id ? [{ id, houses: chart.houses }] : [];
|
||
});
|
||
const receiptById = new Map(bundle.executionLedger.map((receipt) => [receipt.id, receipt]));
|
||
return {
|
||
schemaVersion: "report_evidence_packet.v1",
|
||
subject: bundle.subject,
|
||
requestedThemes: bundle.requestedThemes,
|
||
reportType: bundle.reportType,
|
||
presentationMode: bundle.presentationMode,
|
||
chart: {
|
||
calculationHash: bundle.calculationProfile.calculationHash,
|
||
calculationHashDerived: bundle.calculationProfile.calculationHashDerived,
|
||
ascendant: d1.ascendant,
|
||
planets: d1.planets,
|
||
houses: d1.houses,
|
||
vimshottari: bundle.calculationProfile.vimshottari,
|
||
narayana: bundle.calculationProfile.narayana,
|
||
vargaHouses,
|
||
},
|
||
techniqueAudit: bundle.executionLedger.map((receipt) => ({
|
||
id: receipt.id,
|
||
technique: receipt.technique,
|
||
status: receipt.status,
|
||
note: receipt.note,
|
||
})),
|
||
conflicts: bundle.conflicts.map((conflict) => ({
|
||
id: conflict.id,
|
||
techniques: conflict.techniqueRefs.map((ref) => receiptById.get(ref)?.technique ?? ref),
|
||
summary: conflict.summary,
|
||
})),
|
||
blockedTechniques: bundle.executionLedger
|
||
.filter((receipt) => receipt.status === "blocked")
|
||
.map((receipt) => receipt.technique),
|
||
evidenceRefs: bundle.evidenceRefs,
|
||
candidateRange: null,
|
||
answerPolicy: {
|
||
canAnswerPreciseTiming: bundle.answerPolicy.canAnswerPreciseTiming,
|
||
deterministicClaimsForbiddenFor: bundle.answerPolicy.deterministicClaimsForbiddenFor,
|
||
},
|
||
skillName: bundle.skill.name,
|
||
skillVersion: bundle.skill.version,
|
||
skillSnapshotSha256: bundle.skill.sha256,
|
||
skillSourceCommit: bundle.skill.sourceCommit,
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Interpretive facts + theme narrative seeds (server-owned allowlist)
|
||
//
|
||
// The engine already computes a functional benefic/malefic table, a shadbala
|
||
// ranking, SAV scores, the current Vimshottari maha/antardasha, detected yogas
|
||
// and guided-topic copy. Before this layer the report packet threw all of it
|
||
// away, so the claim cards could only carry execution receipts. Extraction is
|
||
// allowlist-style: unknown keys are never copied, planet/sign/house values go
|
||
// through the existing safe projections, and free text is length-capped and
|
||
// scrubbed of internal vocabulary before it can reach the writer prompt.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Substrings that disqualify a whole seed line. Server-generated narratives can
|
||
* mention internal routes, vendor names or blocked-layer bookkeeping; the
|
||
* writer prompt must never see those, so a line containing any of them is
|
||
* dropped rather than rewritten.
|
||
*/
|
||
const FORBIDDEN_SEED_TOKENS = [
|
||
"vedastro", "mevg", "workflow", "references/", "scripts/", "modules.",
|
||
"skill", "prompt", "swisseph", "ephemeris", "oracle", "engine", "sha256",
|
||
"schema", "fallback", "blocked", "not_found", "not_available", "json",
|
||
"http", "localhost", "strict_", "_strict", "api", "pipeline", "backend",
|
||
];
|
||
|
||
/** Trims, flattens whitespace and rejects lines carrying internal vocabulary. */
|
||
function safeSeedText(value: unknown, max: number): string | null {
|
||
if (typeof value !== "string") return null;
|
||
const flattened = value
|
||
.replace(/[\u0000-\u001f\u007f]+/g, " ")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
if (flattened.length === 0) return null;
|
||
const lowered = flattened.toLowerCase();
|
||
if (FORBIDDEN_SEED_TOKENS.some((token) => lowered.includes(token))) return null;
|
||
if (!/[\p{L}\p{N}]/u.test(flattened)) return null;
|
||
return flattened.slice(0, max);
|
||
}
|
||
|
||
/** Same bound, but for machine-ish domain labels (convergence buckets). */
|
||
function safeDomainLabel(value: unknown): string | null {
|
||
const raw = typeof value === "string" ? value : text(record(value)?.domain);
|
||
if (!raw) return null;
|
||
const cleaned = raw.replace(/[^\p{L}\p{N}_ /·-]+/gu, "").trim();
|
||
return cleaned.length > 0 ? cleaned.slice(0, 80) : null;
|
||
}
|
||
|
||
const YOGA_CATEGORY_ALIASES: Readonly<Record<string, ReportYogaCategory>> = {
|
||
raja: "raja", raja_yoga: "raja", neecha_bhanga: "raja",
|
||
dhana: "dhana", dhana_yoga: "dhana", wealth: "dhana",
|
||
mahapurusha: "mahapurusha", pancha_mahapurusha: "mahapurusha",
|
||
nabhasa: "nabhasa",
|
||
chandra: "lunar", chandra_extended: "lunar", lunar_yoga: "lunar", lunar: "lunar",
|
||
surya: "solar", solar_yoga: "solar", solar: "solar",
|
||
kalatra: "relationship", relationship: "relationship", marriage: "relationship",
|
||
putra: "progeny", progeny: "progeny",
|
||
vidya: "education", education: "education",
|
||
ayur: "health", health: "health", ari: "health",
|
||
moksha: "spiritual", guru: "spiritual", aryama: "spiritual", asha: "spiritual",
|
||
shakti: "spiritual", karma: "spiritual", spiritual: "spiritual",
|
||
durbhaga: "affliction", affliction: "affliction",
|
||
conjunction: "conjunction",
|
||
auspicious: "auspicious",
|
||
special: "special",
|
||
extended: "extended",
|
||
};
|
||
|
||
function safeYogaCategory(value: unknown): ReportYogaCategory {
|
||
const raw = text(value);
|
||
if (!raw) return "other";
|
||
const key = raw.trim().toLowerCase().replace(/[ -]+/g, "_");
|
||
const alias = YOGA_CATEGORY_ALIASES[key];
|
||
if (alias) return alias;
|
||
return (REPORT_YOGA_CATEGORIES as readonly string[]).includes(key)
|
||
? key as ReportYogaCategory
|
||
: "other";
|
||
}
|
||
|
||
const YOGA_NAME_PATTERN = /^[\p{L}\p{N} ()/·,.'’++-]{1,80}$/u;
|
||
|
||
function safeYogaName(value: unknown): string | null {
|
||
const raw = text(value);
|
||
if (!raw) return null;
|
||
const trimmed = raw.replace(/\s+/g, " ").trim().slice(0, 80);
|
||
return YOGA_NAME_PATTERN.test(trimmed) ? trimmed : null;
|
||
}
|
||
|
||
function safeHouseNumbers(value: unknown): number[] {
|
||
if (!Array.isArray(value)) return [];
|
||
const houses = value
|
||
.map((item) => finiteNumber(item))
|
||
.filter((item): item is number => item !== null && Number.isInteger(item) && item >= 1 && item <= 12);
|
||
return [...new Set(houses)].sort((a, b) => a - b);
|
||
}
|
||
|
||
function readEvidenceSnapshot(workflow: JsonRecord): JsonRecord | null {
|
||
const chart = record(workflow.chart) ?? {};
|
||
const pack = record(chart.ai_prompt_pack);
|
||
return pack ? record(pack.evidence_snapshot) : null;
|
||
}
|
||
|
||
function readChartModules(workflow: JsonRecord): JsonRecord {
|
||
return record(record(workflow.chart)?.modules) ?? {};
|
||
}
|
||
|
||
function charaKarakaPlanetPresent(
|
||
karakas: JsonRecord | null,
|
||
keys: readonly string[],
|
||
): boolean {
|
||
if (!karakas) return false;
|
||
return keys.some((key) => {
|
||
const planet = text(record(karakas[key])?.planet);
|
||
return Boolean(planet && safeCelestialName(planet));
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Functional benefic/malefic table. `yogakarakas` wins over the benefic and
|
||
* malefic lists so a planet is never reported twice.
|
||
*/
|
||
function readFunctionalRoles(workflow: JsonRecord, evidenceRef: string): ReportFunctionalRoleFact[] {
|
||
const snapshot = readEvidenceSnapshot(workflow);
|
||
const modules = readChartModules(workflow);
|
||
const layer = record(snapshot?.functional_benefic_malefic)
|
||
?? record(modules.functional_benefic_malefic);
|
||
if (!layer) return [];
|
||
if (text(layer.status) === "blocked") return [];
|
||
const owned = record(layer.owned_houses) ?? {};
|
||
const buckets: readonly Readonly<[ReportFunctionalRole, unknown]>[] = [
|
||
["yogakaraka", layer.yogakarakas],
|
||
["benefic", layer.functional_benefics],
|
||
["malefic", layer.functional_malefics],
|
||
["neutral", layer.functional_neutrals],
|
||
];
|
||
const roles = new Map<string, ReportFunctionalRoleFact>();
|
||
for (const [role, source] of buckets) {
|
||
for (const raw of stringArray(source)) {
|
||
const planet = safeCelestialName(raw);
|
||
if (!planet || roles.has(planet)) continue;
|
||
roles.set(planet, {
|
||
planet,
|
||
role,
|
||
ownedHouses: safeHouseNumbers(owned[planet] ?? owned[raw]),
|
||
evidenceRef,
|
||
});
|
||
}
|
||
}
|
||
return [...roles.values()].slice(0, 12);
|
||
}
|
||
|
||
/**
|
||
* Detected yogas only. The engine also ships the full candidate rule table
|
||
* under modules.yogas with `hit: false`; those are NOT present in the chart and
|
||
* must never be copied.
|
||
*/
|
||
function readYogaFacts(workflow: JsonRecord, evidenceRef: string): ReportYogaFact[] {
|
||
const chart = record(workflow.chart) ?? {};
|
||
const modules = readChartModules(workflow);
|
||
const rows: unknown[] = [];
|
||
if (Array.isArray(chart.yogas)) rows.push(...chart.yogas);
|
||
const yogaModule = record(modules.yogas) ?? record(modules.yoga);
|
||
if (yogaModule && Array.isArray(yogaModule.yogas)) {
|
||
rows.push(...yogaModule.yogas.filter((item) => record(item)?.hit === true));
|
||
}
|
||
const facts = new Map<string, ReportYogaFact>();
|
||
for (const item of rows) {
|
||
const row = record(item);
|
||
if (!row) continue;
|
||
const name = safeYogaName(row.name ?? row.yoga ?? row.title);
|
||
if (!name) continue;
|
||
const key = name.toLowerCase();
|
||
if (facts.has(key)) continue;
|
||
const planets = stringArray(row.planets)
|
||
.flatMap((raw) => {
|
||
const safe = safeCelestialName(raw);
|
||
return safe ? [safe] : [];
|
||
});
|
||
facts.set(key, {
|
||
name,
|
||
category: safeYogaCategory(row.category ?? row.cat),
|
||
planets: [...new Set(planets)].slice(0, 9),
|
||
evidenceRef,
|
||
});
|
||
if (facts.size >= 40) break;
|
||
}
|
||
return [...facts.values()];
|
||
}
|
||
|
||
function readShadbalaRanking(workflow: JsonRecord): ReportShadbalaRankFact[] {
|
||
const snapshot = readEvidenceSnapshot(workflow);
|
||
const chart = record(workflow.chart) ?? {};
|
||
const strength = record(snapshot?.strength);
|
||
const rawRows: Readonly<{ planet: unknown; rank: unknown; rupa: unknown }>[] = [];
|
||
if (Array.isArray(strength?.shadbala_ranking)) {
|
||
for (const item of strength.shadbala_ranking) {
|
||
const row = record(item);
|
||
if (!row) continue;
|
||
rawRows.push({ planet: row.planet ?? row.name, rank: row.rank, rupa: row.total_rupas ?? row.rupas });
|
||
}
|
||
} else {
|
||
for (const [planet, item] of Object.entries(record(chart.shadbala) ?? {})) {
|
||
const row = record(item);
|
||
if (!row) continue;
|
||
rawRows.push({ planet, rank: row.rank, rupa: row.total_rupas ?? row.rupas });
|
||
}
|
||
}
|
||
const parsed = rawRows.flatMap((row) => {
|
||
const planetName = text(row.planet);
|
||
const planet = planetName ? safeCelestialName(planetName) : null;
|
||
if (!planet) return [];
|
||
const rupa = finiteNumber(row.rupa);
|
||
const declaredRank = finiteNumber(row.rank);
|
||
return [{
|
||
planet,
|
||
declaredRank: declaredRank !== null && Number.isInteger(declaredRank) && declaredRank >= 1 && declaredRank <= 9
|
||
? declaredRank
|
||
: null,
|
||
rupa: rupa !== null && rupa >= 0 && rupa <= 100 ? rupa : null,
|
||
}];
|
||
});
|
||
const deduped = [...new Map(parsed.map((row) => [row.planet, row])).values()];
|
||
const ordered = [...deduped].sort((a, b) => {
|
||
if (a.declaredRank !== null && b.declaredRank !== null) return a.declaredRank - b.declaredRank;
|
||
return (b.rupa ?? -1) - (a.rupa ?? -1) || a.planet.localeCompare(b.planet);
|
||
});
|
||
// Ranks are re-derived from the ordering so a partial or duplicated engine
|
||
// rank can never produce two planets at the same rank.
|
||
return ordered.slice(0, 9).map((row, index): ReportShadbalaRankFact => ({
|
||
planet: row.planet,
|
||
rank: index + 1,
|
||
rupa: row.rupa,
|
||
}));
|
||
}
|
||
|
||
function readSavFacts(
|
||
workflow: JsonRecord,
|
||
ascendantSignIndex: number | null,
|
||
): Readonly<{ scores: ReportSavScoreFact[]; total: number | null }> {
|
||
const snapshot = readEvidenceSnapshot(workflow);
|
||
const modules = readChartModules(workflow);
|
||
const sav = record(record(modules.ashtakavarga)?.sav);
|
||
const strength = record(snapshot?.strength);
|
||
const rawScores = record(sav?.scores) ?? record(strength?.sav_scores);
|
||
const rawTotal = finiteNumber(sav?.total ?? strength?.sav_total);
|
||
const scores = new Map<number, number>();
|
||
for (const [key, value] of Object.entries(rawScores ?? {})) {
|
||
const score = finiteNumber(value);
|
||
if (score === null || score < 0 || score > 100) continue;
|
||
const houseKey = /^house_(\d{1,2})$/.exec(key)?.[1] ?? (/^\d{1,2}$/.test(key) ? key : null);
|
||
let house: number | null = null;
|
||
if (houseKey) {
|
||
const parsed = Number.parseInt(houseKey, 10);
|
||
house = parsed >= 1 && parsed <= 12 ? parsed : null;
|
||
} else if (ascendantSignIndex !== null) {
|
||
const index = signIndex(key);
|
||
house = index === null ? null : (((index - ascendantSignIndex) % 12) + 12) % 12 + 1;
|
||
}
|
||
if (house === null || scores.has(house)) continue;
|
||
scores.set(house, score);
|
||
}
|
||
return {
|
||
scores: [...scores.entries()].map(([house, score]) => ({ house, score })).sort((a, b) => a.house - b.house),
|
||
total: rawTotal !== null && rawTotal >= 0 && rawTotal <= 1000 ? rawTotal : null,
|
||
};
|
||
}
|
||
|
||
function readCurrentDasha(workflow: JsonRecord): ReportCurrentDashaFact | null {
|
||
const snapshot = readEvidenceSnapshot(workflow);
|
||
const modules = readChartModules(workflow);
|
||
const subPeriods = record(record(modules.dasha_sub_periods)?.current);
|
||
const timingVimshottari = record(record(snapshot?.timing)?.vimshottari);
|
||
const dashaCurrent = record(record(modules.dasha)?.current_dasha);
|
||
const candidates: Readonly<{
|
||
maha: unknown; antar: unknown; start: unknown; end: unknown;
|
||
}>[] = [];
|
||
if (subPeriods) {
|
||
const maha = record(subPeriods.mahadasha);
|
||
const antar = record(subPeriods.antardasha);
|
||
candidates.push({
|
||
maha: maha?.lord, antar: antar?.lord, start: maha?.start, end: maha?.end,
|
||
});
|
||
}
|
||
if (timingVimshottari) {
|
||
candidates.push({
|
||
maha: timingVimshottari.mahadasha,
|
||
antar: timingVimshottari.antardasha,
|
||
start: timingVimshottari.start,
|
||
end: timingVimshottari.end,
|
||
});
|
||
}
|
||
if (dashaCurrent) {
|
||
candidates.push({
|
||
maha: dashaCurrent.lord,
|
||
antar: record(dashaCurrent.antardasha)?.lord,
|
||
start: dashaCurrent.start,
|
||
end: dashaCurrent.end,
|
||
});
|
||
}
|
||
for (const candidate of candidates) {
|
||
const mahaName = text(candidate.maha);
|
||
const mahadasha = mahaName ? safeCelestialName(mahaName) : null;
|
||
const antarName = text(candidate.antar);
|
||
const antardasha = antarName ? safeCelestialName(antarName) : null;
|
||
const startText = text(candidate.start);
|
||
const endText = text(candidate.end);
|
||
const start = startText ? safeDashaDate(startText) : null;
|
||
const end = endText ? safeDashaDate(endText) : null;
|
||
if (mahadasha && start && end && Date.parse(start) < Date.parse(end)) {
|
||
return { mahadasha, antardasha, start, end };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function readConvergenceDomains(workflow: JsonRecord): string[] {
|
||
const snapshot = readEvidenceSnapshot(workflow);
|
||
const modules = readChartModules(workflow);
|
||
const rows = Array.isArray(record(snapshot?.timing)?.convergence_top_domains)
|
||
? record(snapshot?.timing)!.convergence_top_domains as unknown[]
|
||
: Array.isArray(record(modules.dasa_convergence)?.top_convergent_domains)
|
||
? record(modules.dasa_convergence)!.top_convergent_domains as unknown[]
|
||
: [];
|
||
const domains: string[] = [];
|
||
for (const item of rows) {
|
||
const label = safeDomainLabel(item);
|
||
if (label && !domains.includes(label)) domains.push(label);
|
||
if (domains.length >= 6) break;
|
||
}
|
||
return domains;
|
||
}
|
||
|
||
/**
|
||
* Server-side thematic narrative payloads. Present in the engine's own
|
||
* `_build_ai_prompt_pack`; the consultation API's chart prompt pack currently
|
||
* omits them, so this returns an empty map there instead of inventing text.
|
||
*/
|
||
const NARRATIVE_SNAPSHOT_THEMES: Readonly<Record<string, string>> = {
|
||
career_narrative: "career",
|
||
relationship_narrative: "marriage",
|
||
finance_narrative: "wealth",
|
||
};
|
||
|
||
const STRICT_MODULE_THEMES: Readonly<Record<string, string>> = {
|
||
career_strict_evidence: "career",
|
||
relationship_strict_evidence: "marriage",
|
||
finance_strict_evidence: "wealth",
|
||
};
|
||
|
||
type NarrativeDraft = { headline: string | null; strengths: string[]; risks: string[]; boundaries: string[] };
|
||
|
||
function readNarrativePayload(value: unknown): NarrativeDraft | null {
|
||
const row = record(value);
|
||
if (!row) return null;
|
||
const headline = safeSeedText(row.headline, 300);
|
||
const pick = (source: unknown) => stringArray(source)
|
||
.flatMap((item) => {
|
||
const safe = safeSeedText(item, 400);
|
||
return safe ? [safe] : [];
|
||
})
|
||
.slice(0, 8);
|
||
const strengths = pick(row.strengths);
|
||
const risks = pick(row.risks);
|
||
const boundaries = pick(row.boundaries);
|
||
if (!headline && strengths.length === 0 && risks.length === 0 && boundaries.length === 0) return null;
|
||
return { headline, strengths, risks, boundaries };
|
||
}
|
||
|
||
function readEngineNarratives(workflow: JsonRecord): Map<string, NarrativeDraft> {
|
||
const snapshot = readEvidenceSnapshot(workflow);
|
||
const modules = readChartModules(workflow);
|
||
const drafts = new Map<string, NarrativeDraft>();
|
||
for (const [key, theme] of Object.entries(NARRATIVE_SNAPSHOT_THEMES)) {
|
||
const draft = readNarrativePayload(snapshot?.[key]);
|
||
if (draft) drafts.set(theme, draft);
|
||
}
|
||
for (const [key, theme] of Object.entries(STRICT_MODULE_THEMES)) {
|
||
if (drafts.has(theme)) continue;
|
||
const draft = readNarrativePayload(record(modules[key])?.user_narrative);
|
||
if (draft) drafts.set(theme, draft);
|
||
}
|
||
return drafts;
|
||
}
|
||
|
||
/**
|
||
* Guided-topic copy. Only the three topics that map onto a report theme are
|
||
* used; `birth_time_rectification` is a product-flow topic, not a chart
|
||
* reading, and is deliberately skipped.
|
||
*/
|
||
const GUIDED_TOPIC_THEMES: Readonly<Record<string, string>> = {
|
||
relationship_partnership: "marriage",
|
||
career_direction: "career",
|
||
wealth_risk: "wealth",
|
||
};
|
||
|
||
|
||
function readGuidedTopicDrafts(workflow: JsonRecord): Map<string, NarrativeDraft> {
|
||
const modules = readChartModules(workflow);
|
||
const topics = Array.isArray(modules.guided_topics) ? modules.guided_topics : [];
|
||
const drafts = new Map<string, NarrativeDraft>();
|
||
for (const item of topics) {
|
||
const row = record(item);
|
||
if (!row) continue;
|
||
const theme = GUIDED_TOPIC_THEMES[text(row.id) ?? ""];
|
||
if (!theme || drafts.has(theme)) continue;
|
||
const headline = safeSeedText(row.title, 300);
|
||
const strengths = [row.reality_value, row.why_worth_exploring]
|
||
.flatMap((value) => {
|
||
const safe = safeSeedText(value, 400);
|
||
return safe ? [safe] : [];
|
||
});
|
||
const monthly = record(row.monthly_adjudication_summary);
|
||
const risks = [record(monthly?.friction_source)?.value]
|
||
.flatMap((value) => {
|
||
const safe = safeSeedText(value, 400);
|
||
return safe ? [safe] : [];
|
||
});
|
||
const boundaries = [record(monthly?.time_confidence)?.value]
|
||
.flatMap((value) => {
|
||
const safe = safeSeedText(value, 400);
|
||
return safe ? [safe] : [];
|
||
});
|
||
if (!headline && strengths.length === 0) continue;
|
||
drafts.set(theme, {
|
||
headline,
|
||
strengths: strengths.slice(0, 8),
|
||
risks: risks.slice(0, 8),
|
||
boundaries: boundaries.slice(0, 8),
|
||
});
|
||
}
|
||
return drafts;
|
||
}
|
||
|
||
/** SAV houses that actually carry the theme's meaning. */
|
||
const THEME_SAV_HOUSES: Readonly<Record<string, readonly number[]>> = {
|
||
career: [10, 6],
|
||
marriage: [7],
|
||
wealth: [2, 11],
|
||
education: [4, 5],
|
||
migration_home: [4, 12],
|
||
family: [4, 5],
|
||
health_pressure: [6],
|
||
timing: [1],
|
||
general: [1, 10],
|
||
};
|
||
|
||
function joinNames(values: readonly string[], limit: number): string {
|
||
return values.slice(0, limit).join("、");
|
||
}
|
||
|
||
/**
|
||
* Deterministic interpretive lines composed from the structured facts above.
|
||
* Every value is a closed-vocabulary planet/sign name, a house number or an
|
||
* engine-computed number — no model output and no free text from the request.
|
||
*/
|
||
function buildInterpretiveThemeLines(
|
||
theme: string,
|
||
facts: ReportInterpretiveFacts,
|
||
ascendantSign: string | null,
|
||
): string[] {
|
||
const lines: string[] = [];
|
||
const benefics = facts.functionalRoles.filter((role) => role.role === "benefic").map((role) => role.planet);
|
||
const malefics = facts.functionalRoles.filter((role) => role.role === "malefic").map((role) => role.planet);
|
||
const yogakarakas = facts.functionalRoles.filter((role) => role.role === "yogakaraka");
|
||
if (ascendantSign && facts.functionalRoles.length > 0) {
|
||
lines.push(
|
||
`本命上升为 ${ascendantSign};按宫主判定的功能吉星为 ${joinNames(benefics, 4) || "无"},功能凶星为 ${joinNames(malefics, 4) || "无"}。`,
|
||
);
|
||
}
|
||
// Theme-specific lines come first so the two-line claim conclusion differs
|
||
// per chapter instead of repeating the chart identity everywhere.
|
||
const savHouses = THEME_SAV_HOUSES[theme] ?? [];
|
||
const savLine = facts.savScores
|
||
.filter((row) => savHouses.includes(row.house))
|
||
.map((row) => `第 ${row.house} 宫 ${row.score} 分`)
|
||
.join(",");
|
||
if (savLine) {
|
||
const total = facts.savTotal === null ? "" : `(全盘合计 ${facts.savTotal} 分)`;
|
||
lines.push(`本主题相关宫位的八分力总分:${savLine}${total}。`);
|
||
}
|
||
if (facts.currentDasha) {
|
||
const antar = facts.currentDasha.antardasha ? `、副运 ${facts.currentDasha.antardasha}` : "";
|
||
lines.push(
|
||
`当前处于 ${facts.currentDasha.mahadasha} 主运${antar},主运区间 ${facts.currentDasha.start} 至 ${facts.currentDasha.end}。`,
|
||
);
|
||
}
|
||
for (const karaka of yogakarakas.slice(0, 2)) {
|
||
const houses = karaka.ownedHouses.length > 0
|
||
? `同时主管第 ${karaka.ownedHouses.join("、")} 宫,`
|
||
: "";
|
||
lines.push(`${karaka.planet} ${houses}在本盘属 yogakaraka,是最值得依靠的行动主轴。`);
|
||
}
|
||
if (facts.yogas.length > 0) {
|
||
lines.push(`本盘已成立的组合:${joinNames(facts.yogas.map((yoga) => yoga.name), 4)}。`);
|
||
}
|
||
if (facts.shadbalaRanking.length >= 3) {
|
||
const strongest = facts.shadbalaRanking.slice(0, 3).map((row) => row.planet);
|
||
const weakest = facts.shadbalaRanking[facts.shadbalaRanking.length - 1];
|
||
lines.push(`六分力相对强弱:${joinNames(strongest, 3)} 居前,${weakest.planet} 垫底,说明发力顺序而非绝对好坏。`);
|
||
}
|
||
if (facts.convergenceDomains.length > 0) {
|
||
lines.push(`多套时间系统同时指向的领域:${joinNames(facts.convergenceDomains, 4)}。`);
|
||
}
|
||
return lines.flatMap((line) => {
|
||
const safe = safeSeedText(line, 400);
|
||
return safe ? [safe] : [];
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Builds the server-owned, allowlisted ReportEvidenceBundle v2 from one
|
||
* workflow result per requested theme. A missing thematic layer produces a
|
||
* blocked section, while an unusable D1 base fails closed.
|
||
*/
|
||
export function buildReportEvidenceBundleV2(
|
||
input: BuildReportEvidenceBundleV2Input,
|
||
): ReportEvidenceBundleV2 {
|
||
const requestedPlans = buildReportThemePlan(input.requestedThemes);
|
||
const packets: BuiltThemePacket[] = [];
|
||
for (const item of input.workflows) {
|
||
const workflow = record(item.workflow);
|
||
if (!workflow) continue;
|
||
try {
|
||
packets.push({
|
||
theme: normalizeReportTheme(item.theme),
|
||
workflow,
|
||
packet: buildReportEvidencePacket({
|
||
workflow,
|
||
subject: input.subject,
|
||
requestedThemes: [normalizeReportTheme(item.theme)],
|
||
reportType: input.reportType,
|
||
presentationMode: input.presentationMode,
|
||
candidateRange: null,
|
||
skillSnapshot: input.skillSnapshot,
|
||
}),
|
||
});
|
||
} catch (error) {
|
||
if (!(error instanceof ReportEvidenceInsufficientError)) throw error;
|
||
}
|
||
}
|
||
const base = packets[0];
|
||
if (!base) throw new ReportEvidenceInsufficientError("d1_base_unavailable");
|
||
|
||
const receiptsByKey = new Map<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);
|
||
}
|
||
}
|
||
|
||
for (const { workflow } of packets) {
|
||
const modules = readChartModules(workflow);
|
||
if (text(record(modules.transits)?.status)?.toLowerCase() === "executed") {
|
||
upsertReceipt("Transit", "partial", true);
|
||
}
|
||
const karakas = record(record(modules.jaimini)?.chara_karakas);
|
||
if (charaKarakaPlanetPresent(karakas, ["AmK", "Amatyakaraka"])) {
|
||
upsertReceipt("AmK", "partial", true);
|
||
}
|
||
if (charaKarakaPlanetPresent(karakas, ["DK", "Darakaraka"])) {
|
||
upsertReceipt("DK", "partial", true);
|
||
}
|
||
}
|
||
|
||
// --- interpretive facts -------------------------------------------------
|
||
// Bound to receipts that already exist in the ledger; a layer without a
|
||
// receipt is skipped rather than given a synthetic ref.
|
||
const receiptIdFor = (aliases: readonly string[]): string | null => {
|
||
const receipt = [...receiptsByKey.values()].find((candidate) => (
|
||
candidate.executed && candidate.status !== "blocked" && techniqueMatches(candidate, aliases)
|
||
));
|
||
return receipt?.id ?? null;
|
||
};
|
||
const functionalRef = receiptIdFor(["Functional Benefic/Malefic"]);
|
||
const yogaRef = receiptIdFor(["Yoga"]);
|
||
const ashtakavargaRef = receiptIdFor(["Ashtakavarga"]);
|
||
const vimshottariRef = receiptIdFor(["Vimshottari"]);
|
||
const d1Ref = receiptIdFor(["D1"]);
|
||
const baseAscendantSignIndex = base.packet.chart.ascendant
|
||
? signIndex(base.packet.chart.ascendant.sign)
|
||
: null;
|
||
const baseAscendantSign = base.packet.chart.ascendant
|
||
? safeChartSign(base.packet.chart.ascendant.sign)
|
||
: null;
|
||
|
||
const collectFirst = <T>(read: (workflow: JsonRecord) => readonly T[]): T[] => {
|
||
for (const { workflow } of packets) {
|
||
const rows = read(workflow);
|
||
if (rows.length > 0) return [...rows];
|
||
}
|
||
return [];
|
||
};
|
||
const savFacts = (() => {
|
||
for (const { workflow } of packets) {
|
||
const result = readSavFacts(workflow, baseAscendantSignIndex);
|
||
if (result.scores.length > 0 || result.total !== null) return result;
|
||
}
|
||
return { scores: [] as ReportSavScoreFact[], total: null as number | null };
|
||
})();
|
||
const currentDasha = (() => {
|
||
for (const { workflow } of packets) {
|
||
const dasha = readCurrentDasha(workflow);
|
||
if (dasha) return dasha;
|
||
}
|
||
return null;
|
||
})();
|
||
const interpretiveFacts: ReportInterpretiveFacts = {
|
||
yogas: yogaRef ? collectFirst((workflow) => readYogaFacts(workflow, yogaRef)) : [],
|
||
functionalRoles: functionalRef
|
||
? collectFirst((workflow) => readFunctionalRoles(workflow, functionalRef))
|
||
: [],
|
||
shadbalaRanking: collectFirst(readShadbalaRanking),
|
||
savScores: ashtakavargaRef ? savFacts.scores : [],
|
||
savTotal: ashtakavargaRef ? savFacts.total : null,
|
||
currentDasha: vimshottariRef ? currentDasha : null,
|
||
convergenceDomains: collectFirst(readConvergenceDomains),
|
||
};
|
||
|
||
// --- theme narrative seeds ----------------------------------------------
|
||
const engineNarratives = new Map<string, NarrativeDraft>();
|
||
const guidedDrafts = new Map<string, NarrativeDraft>();
|
||
for (const { workflow } of packets) {
|
||
for (const [theme, draft] of readEngineNarratives(workflow)) {
|
||
if (!engineNarratives.has(theme)) engineNarratives.set(theme, draft);
|
||
}
|
||
for (const [theme, draft] of readGuidedTopicDrafts(workflow)) {
|
||
if (!guidedDrafts.has(theme)) guidedDrafts.set(theme, draft);
|
||
}
|
||
}
|
||
const seedEvidenceRefs = [
|
||
...(d1Ref ? [d1Ref] : []),
|
||
...(functionalRef ? [functionalRef] : []),
|
||
...(yogaRef ? [yogaRef] : []),
|
||
...(ashtakavargaRef ? [ashtakavargaRef] : []),
|
||
...(vimshottariRef ? [vimshottariRef] : []),
|
||
];
|
||
const buildThemeSeed = (theme: string): ReportThemeNarrativeSeed | null => {
|
||
const engineDraft = engineNarratives.get(theme) ?? null;
|
||
const guidedDraft = guidedDrafts.get(theme) ?? null;
|
||
const interpretiveLines = buildInterpretiveThemeLines(theme, interpretiveFacts, baseAscendantSign);
|
||
// The engine's own thematic narrative headline is already conclusion
|
||
// shaped, so it wins. Without one, the first chart-specific interpretive
|
||
// line becomes the headline; guided-topic copy is generic framing and only
|
||
// ever lands in the tail of strengths.
|
||
const [headline, leadStrengths] = engineDraft?.headline
|
||
? [engineDraft.headline, interpretiveLines]
|
||
: interpretiveLines.length > 0
|
||
? [interpretiveLines[0], interpretiveLines.slice(1)]
|
||
: [guidedDraft?.headline ?? null, []];
|
||
if (!headline) return null;
|
||
const strengths = [
|
||
...leadStrengths,
|
||
...(engineDraft?.strengths ?? []),
|
||
...(guidedDraft?.strengths ?? []),
|
||
].slice(0, 8);
|
||
return {
|
||
theme,
|
||
headline,
|
||
strengths,
|
||
risks: [...(engineDraft?.risks ?? []), ...(guidedDraft?.risks ?? [])].slice(0, 8),
|
||
boundaries: [...(engineDraft?.boundaries ?? []), ...(guidedDraft?.boundaries ?? [])].slice(0, 8),
|
||
evidenceRefs: [...new Set(seedEvidenceRefs)].slice(0, 24),
|
||
};
|
||
};
|
||
|
||
/** Sentence-joins deterministic seed lines without gluing clauses together. */
|
||
const joinSeedSentences = (parts: readonly string[]): string => parts
|
||
.map((part) => (/[。!?;.!?;]$/.test(part) ? part : `${part}。`))
|
||
.join("");
|
||
|
||
const claimCards: ReportClaimCard[] = [];
|
||
const themeNarrativeSeeds: ReportThemeNarrativeSeed[] = [];
|
||
const blockedSections: ReportEvidenceBundleV2["blockedSections"][number][] = [];
|
||
for (const plan of requestedPlans) {
|
||
const matched: TechniqueExecutionReceipt[] = [];
|
||
const missingRefs: string[] = [];
|
||
for (const group of plan.requiredTechniqueGroups) {
|
||
const receipt = [...receiptsByKey.values()].find((candidate) => (
|
||
candidate.executed && candidate.status !== "blocked" && techniqueMatches(candidate, group.anyOf)
|
||
));
|
||
if (receipt) {
|
||
matched.push(receipt);
|
||
continue;
|
||
}
|
||
const missing = upsertReceipt(`${plan.theme}:${group.label}`, "blocked", false);
|
||
missingRefs.push(missing.id);
|
||
}
|
||
if (missingRefs.length > 0) {
|
||
blockedSections.push({
|
||
id: `ev-blocked-${evidenceSlug(plan.theme, "theme")}`,
|
||
theme: plan.theme,
|
||
section: plan.section,
|
||
reason: `缺少最低证据组:${plan.requiredTechniqueGroups
|
||
.filter((group) => missingRefs.some((ref) => ref.endsWith(evidenceSlug(`${plan.theme}:${group.label}`, "missing"))))
|
||
.map((group) => group.label)
|
||
.join("、") || "主题证据未闭合"}`,
|
||
missingTechniqueRefs: missingRefs,
|
||
});
|
||
continue;
|
||
}
|
||
const uniqueMatched = [...new Map(matched.map((receipt) => [receipt.id, receipt])).values()];
|
||
const allVerified = uniqueMatched.every((receipt) => receipt.status === "verified");
|
||
// The assertionLevel derivation is unchanged: richer content must never
|
||
// raise certainty. Only the seed-less fallback may lower it.
|
||
const derivedLevel: ClaimStatus = allVerified && uniqueMatched.length >= 2
|
||
? "multi_system_consensus"
|
||
: allVerified
|
||
? "single_system_inference"
|
||
: "parameter_sensitive";
|
||
const seed = buildThemeSeed(plan.theme);
|
||
if (seed) themeNarrativeSeeds.push(seed);
|
||
const conclusion = seed
|
||
? joinSeedSentences([seed.headline, ...seed.strengths.slice(0, 2)]).slice(0, 1200)
|
||
: `服务器已闭合${plan.section}所需的最低证据组;本节只能在所列事实与确定性级别内解释。`;
|
||
const assertionLevel: ClaimStatus = seed
|
||
? derivedLevel
|
||
: derivedLevel === "multi_system_consensus"
|
||
? "single_system_inference"
|
||
: derivedLevel;
|
||
const themeSlug = evidenceSlug(plan.theme, "theme");
|
||
const receiptFactValue = (technique: string): string => {
|
||
if (technique === "D1" && baseAscendantSign) {
|
||
const roles = interpretiveFacts.functionalRoles.length > 0
|
||
? `;功能吉星 ${joinNames(interpretiveFacts.functionalRoles.filter((role) => role.role === "benefic").map((role) => role.planet), 4) || "无"},功能凶星 ${joinNames(interpretiveFacts.functionalRoles.filter((role) => role.role === "malefic").map((role) => role.planet), 4) || "无"}`
|
||
: "";
|
||
return `本命上升 ${baseAscendantSign}${roles}`;
|
||
}
|
||
if (technique === "Vimshottari" && interpretiveFacts.currentDasha) {
|
||
const antar = interpretiveFacts.currentDasha.antardasha
|
||
? `、副运 ${interpretiveFacts.currentDasha.antardasha}`
|
||
: "";
|
||
return `当前 ${interpretiveFacts.currentDasha.mahadasha} 主运${antar}(${interpretiveFacts.currentDasha.start} 至 ${interpretiveFacts.currentDasha.end})`;
|
||
}
|
||
if (technique === "Yoga" && interpretiveFacts.yogas.length > 0) {
|
||
return `已成立组合:${joinNames(interpretiveFacts.yogas.map((yoga) => yoga.name), 4)}`;
|
||
}
|
||
if (technique === "Ashtakavarga" && interpretiveFacts.savScores.length > 0) {
|
||
const houses = THEME_SAV_HOUSES[plan.theme] ?? [];
|
||
const scored = interpretiveFacts.savScores
|
||
.filter((row) => houses.includes(row.house))
|
||
.map((row) => `第 ${row.house} 宫 ${row.score} 分`)
|
||
.join(",");
|
||
const total = interpretiveFacts.savTotal === null ? "" : `,全盘合计 ${interpretiveFacts.savTotal} 分`;
|
||
return `八分力:${scored || "本主题宫位未取得分值"}${total}`;
|
||
}
|
||
if (technique === "Functional Benefic/Malefic" && interpretiveFacts.functionalRoles.length > 0) {
|
||
const karaka = interpretiveFacts.functionalRoles.find((role) => role.role === "yogakaraka");
|
||
return `功能吉凶已判定${karaka ? `,${karaka.planet} 为 yogakaraka` : ""}`;
|
||
}
|
||
return `${technique} 已执行并纳入本主题证据计划`;
|
||
};
|
||
const supportingFacts = uniqueMatched.map((receipt, index) => ({
|
||
id: `ev-fact-${themeSlug}-${index + 1}`,
|
||
label: receipt.technique.slice(0, 160),
|
||
value: receiptFactValue(receipt.technique).slice(0, 800),
|
||
evidenceRef: receipt.id,
|
||
status: receipt.status,
|
||
}));
|
||
// Universal interpretive layers ride along even when the theme's minimum
|
||
// evidence group does not require them; they stay bound to their own
|
||
// receipt so nothing is attributed to a technique that did not run.
|
||
const matchedIds = new Set(uniqueMatched.map((receipt) => receipt.id));
|
||
const universalLayers: readonly Readonly<[string | null, string]>[] = [
|
||
[functionalRef, "Functional Benefic/Malefic"],
|
||
[yogaRef, "Yoga"],
|
||
[ashtakavargaRef, "Ashtakavarga"],
|
||
[vimshottariRef, "Vimshottari"],
|
||
];
|
||
for (const [ref, technique] of universalLayers) {
|
||
if (!ref || matchedIds.has(ref)) continue;
|
||
const receipt = [...receiptsByKey.values()].find((candidate) => candidate.id === ref);
|
||
if (!receipt) continue;
|
||
const value = receiptFactValue(technique);
|
||
if (value === `${technique} 已执行并纳入本主题证据计划`) continue;
|
||
supportingFacts.push({
|
||
id: `ev-fact-${themeSlug}-extra-${evidenceSlug(technique, "layer")}`,
|
||
label: technique.slice(0, 160),
|
||
value: value.slice(0, 800),
|
||
evidenceRef: ref,
|
||
status: receipt.status,
|
||
});
|
||
}
|
||
const counterFactRef = uniqueMatched[0];
|
||
const counterFacts = counterFactRef
|
||
? (seed?.risks ?? []).slice(0, 8).map((risk, index) => ({
|
||
id: `ev-cfact-${themeSlug}-${index + 1}`,
|
||
label: "风险边界",
|
||
value: risk.slice(0, 800),
|
||
evidenceRef: counterFactRef.id,
|
||
status: counterFactRef.status,
|
||
}))
|
||
: [];
|
||
claimCards.push({
|
||
id: `ev-claim-${themeSlug}`,
|
||
theme: plan.theme,
|
||
section: plan.section,
|
||
conclusion,
|
||
supportingFacts: supportingFacts.slice(0, 40),
|
||
counterFacts,
|
||
executedTechniqueRefs: uniqueMatched.map((receipt) => receipt.id),
|
||
assertionLevel,
|
||
timingBoundary: input.subject.birthTimeStatus === "confirmed"
|
||
? null
|
||
: "出生时间未达到 confirmed;时间结论仅允许方向性表达",
|
||
verificationQuestions: assertionLevel === "parameter_sensitive"
|
||
? ["建议结合真实经历核验本主题的部分证据结论"]
|
||
: [],
|
||
});
|
||
}
|
||
|
||
const conflicts: EvidenceConflict[] = [];
|
||
for (const { packet } of packets) {
|
||
for (const conflict of packet.conflicts) {
|
||
const techniqueRefs = conflict.techniques
|
||
.map((technique) => [...receiptsByKey.values()].find((receipt) => techniqueMatches(receipt, [technique]))?.id)
|
||
.filter((ref): ref is string => Boolean(ref));
|
||
const summary = techniqueRefs.length > 0
|
||
? "所列技法的结果存在未闭合冲突,报告不得据此作确定性提升。"
|
||
: "本次证据存在未闭合冲突,报告必须披露该边界。";
|
||
const id = `ev-conflict-${conflicts.length + 1}`;
|
||
if (!conflicts.some((item) => (
|
||
item.summary === summary
|
||
&& item.techniqueRefs.join("|") === techniqueRefs.join("|")
|
||
))) {
|
||
conflicts.push({ id, techniqueRefs, summary, resolutionStatus: "unresolved" });
|
||
}
|
||
}
|
||
}
|
||
|
||
const executionLedger = [...receiptsByKey.values()];
|
||
const deterministicClaimsForbiddenFor = [...new Set(packets.flatMap(({ packet }) => (
|
||
packet.answerPolicy.deterministicClaimsForbiddenFor.flatMap((value) => {
|
||
const safe = safePolicyBoundary(value);
|
||
return safe ? [safe] : [];
|
||
})
|
||
)))];
|
||
const canAnswerPreciseTiming = input.subject.birthTimeStatus === "confirmed"
|
||
&& packets.length > 0
|
||
&& packets.every(({ packet }) => packet.answerPolicy.canAnswerPreciseTiming);
|
||
const birthTimePolicy = input.subject.birthTimeStatus === "confirmed"
|
||
? "confirmed"
|
||
: input.subject.birthTimeStatus === "accepted"
|
||
? "accepted_directional_only"
|
||
: input.subject.birthTimeStatus === "candidate"
|
||
? "candidate_directional_only"
|
||
: "reported_directional_only";
|
||
|
||
return demoteThemesMissingRequiredCharts(finalizeReportEvidenceBundleV2({
|
||
schemaVersion: "report_evidence_bundle.v2",
|
||
subject: input.subject,
|
||
requestedThemes: requestedPlans.map((plan) => plan.theme),
|
||
reportType: input.reportType,
|
||
presentationMode: input.presentationMode,
|
||
calculationProfile: {
|
||
calculationHash: base.packet.chart.calculationHash,
|
||
calculationHashDerived: base.packet.chart.calculationHashDerived,
|
||
birthTimeStatus: input.subject.birthTimeStatus,
|
||
ayanamsa: readCalculationProfileText(base.workflow, ["ayanamsa", "ayanamsa_name"], "ayanamsa"),
|
||
nodeMode: readCalculationProfileText(base.workflow, ["node_mode", "nodeMode"], "nodeMode"),
|
||
houseSystem: readCalculationProfileText(base.workflow, ["house_system", "houseSystem"], "houseSystem"),
|
||
vimshottari: safeDashaPeriods(base.packet.chart.vimshottari),
|
||
narayana: safeDashaPeriods(base.packet.chart.narayana),
|
||
},
|
||
skill: input.skillSnapshot,
|
||
charts: [...chartsById.values()],
|
||
claimCards,
|
||
interpretiveFacts,
|
||
themeNarrativeSeeds,
|
||
blockedSections,
|
||
conflicts,
|
||
executionLedger,
|
||
evidenceRefs: executionLedger.map((receipt) => ({
|
||
id: receipt.id,
|
||
technique: receipt.technique,
|
||
status: receipt.status,
|
||
})),
|
||
answerPolicy: {
|
||
canAnswerPreciseTiming,
|
||
birthTimePolicy,
|
||
deterministicClaimsForbiddenFor,
|
||
},
|
||
}));
|
||
}
|
||
|
||
function requiredStructuredChartsForTheme(theme: string): readonly string[] {
|
||
return REQUIRED_THEME_CHARTS[theme] ?? [];
|
||
}
|
||
|
||
/**
|
||
* A write-theme claim without its required structured charts would assemble
|
||
* into a document that the final parse rejects. Demote that theme to a blocked
|
||
* disclosure instead of failing the whole report.
|
||
*/
|
||
function demoteThemesMissingRequiredCharts(bundle: ReportEvidenceBundleV2): ReportEvidenceBundleV2 {
|
||
const presentCharts = new Set(bundle.charts.map((chart) => chart.id));
|
||
const demoted = bundle.claimCards.filter((card) => (
|
||
requiredStructuredChartsForTheme(card.theme).some((chartId) => !presentCharts.has(chartId))
|
||
));
|
||
if (demoted.length === 0) return bundle;
|
||
|
||
const extraReceipts: TechniqueExecutionReceipt[] = [];
|
||
const extraBlocked: ReportEvidenceBundleV2["blockedSections"][number][] = [];
|
||
const existingIds = new Set([
|
||
...bundle.executionLedger.map((receipt) => receipt.id),
|
||
...bundle.blockedSections.map((section) => section.id),
|
||
]);
|
||
for (const card of demoted) {
|
||
const missing = requiredStructuredChartsForTheme(card.theme)
|
||
.filter((chartId) => !presentCharts.has(chartId));
|
||
const refs: string[] = [];
|
||
for (const chartId of missing) {
|
||
const technique = `${card.theme}:${chartId}`;
|
||
let id = `ev-tech-${evidenceSlug(technique, "missing")}`;
|
||
if (existingIds.has(id)) id = `ev-tech-${evidenceSlug(`${technique}_chart`, "missing")}`;
|
||
extraReceipts.push({
|
||
id,
|
||
technique,
|
||
status: "blocked",
|
||
executed: false,
|
||
note: safeReceiptNote("blocked", false),
|
||
});
|
||
existingIds.add(id);
|
||
refs.push(id);
|
||
}
|
||
const blockedId = existingIds.has(`ev-blocked-${evidenceSlug(card.theme, "theme")}`)
|
||
? `ev-blocked-${evidenceSlug(`${card.theme}_charts`, "theme")}`
|
||
: `ev-blocked-${evidenceSlug(card.theme, "theme")}`;
|
||
extraBlocked.push({
|
||
id: blockedId,
|
||
theme: card.theme,
|
||
section: card.section,
|
||
reason: `缺少结构化分盘:${missing.join("、")}`,
|
||
missingTechniqueRefs: refs,
|
||
});
|
||
existingIds.add(blockedId);
|
||
}
|
||
const demotedThemes = new Set(demoted.map((card) => card.theme));
|
||
const { bundleHash: _bundleHash, ...content } = bundle;
|
||
void _bundleHash;
|
||
return finalizeReportEvidenceBundleV2({
|
||
...content,
|
||
claimCards: bundle.claimCards.filter((card) => !demotedThemes.has(card.theme)),
|
||
blockedSections: [...bundle.blockedSections, ...extraBlocked],
|
||
executionLedger: [...bundle.executionLedger, ...extraReceipts],
|
||
evidenceRefs: [
|
||
...bundle.evidenceRefs,
|
||
...extraReceipts.map(({ id, technique, status }) => ({ id, technique, status })),
|
||
],
|
||
themeNarrativeSeeds: bundle.themeNarrativeSeeds.filter((seed) => !demotedThemes.has(seed.theme)),
|
||
});
|
||
}
|
||
|
||
function techniqueSlug(name: string, fallback: string): string {
|
||
const slug = name.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, "_")
|
||
.replace(/^_+|_+$/g, "")
|
||
.slice(0, 80);
|
||
return /^[a-z0-9_.-]{1,80}$/.test(slug) ? slug : fallback;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Document assembly (deterministic; agent writes narrative only)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export const PERSONAL_REPORT_DISCLAIMER =
|
||
"本报告基于所提供出生信息与服务器计算的排盘证据生成,属于解释性参考,不构成医疗、法律或投资建议。出生时间未经确认时,报告中的时间相关表述仅为方向性参考。";
|
||
|
||
export type AssembleReportDocumentInput = Readonly<{
|
||
reportId: string;
|
||
generatedAt: string;
|
||
packet: ReportEvidencePacket;
|
||
agentOutput: PersonalReportAgentOutput;
|
||
}>;
|
||
|
||
function canonicalChartHouses(
|
||
houses: readonly ReportEvidencePacket["chart"]["houses"][number][],
|
||
): ReportDocumentV1["charts"][number]["houses"] {
|
||
return houses.map((house) => ({
|
||
houseNumber: house.number,
|
||
sign: house.sign,
|
||
occupants: [...house.occupants].slice(0, 12),
|
||
}));
|
||
}
|
||
|
||
function canonicalPlanets(
|
||
planets: readonly ReportPlanetFact[],
|
||
): ReportDocumentV1["charts"][number]["planets"] {
|
||
return planets.map((planet) => ({
|
||
name: planet.id,
|
||
sign: planet.sign,
|
||
longitudeDegrees: planet.degree,
|
||
houseNumber: planet.house as number,
|
||
retrograde: planet.retrograde as boolean,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Builds the candidate ReportDocument v1. Order is strict: appendix first,
|
||
* then evidenceHash = computeEvidenceHash(appendix) (canonical server hash,
|
||
* never a model self-report), then the final document.
|
||
*/
|
||
export function assembleReportDocument(
|
||
input: AssembleReportDocumentInput,
|
||
): ReportDocumentV1 {
|
||
const { packet } = input;
|
||
if (!packet.chart.ascendant) {
|
||
throw new ReportEvidenceInsufficientError("ascendant_missing");
|
||
}
|
||
|
||
const usedEvidenceRefs = new Set(
|
||
input.agentOutput.thematicNarrative.flatMap((section) => section.evidenceRefs),
|
||
);
|
||
const techniqueAudit: EvidenceAppendix["techniqueAudit"] = packet.techniqueAudit.map(
|
||
(row, index) => {
|
||
const id = row.id ?? `ev-audit-${index + 1}`;
|
||
return {
|
||
id,
|
||
techniqueId: techniqueSlug(row.technique, `tech-${index + 1}`),
|
||
techniqueName: row.technique,
|
||
status: canonicalTechniqueStatus(row.status),
|
||
used: usedEvidenceRefs.has(id),
|
||
...(row.note ? { notes: row.note.slice(0, 500) } : {}),
|
||
};
|
||
},
|
||
);
|
||
|
||
const conflicts: EvidenceAppendix["conflicts"] = packet.conflicts.map((conflict, index) => ({
|
||
id: conflict.id ?? `ev-conflict-${index + 1}`,
|
||
description: conflict.summary.slice(0, 1000),
|
||
impact: "多技法结果不一致,相关结论已按确定性边界降级",
|
||
status: "unresolved",
|
||
}));
|
||
|
||
const calculationEvidence: EvidenceAppendix["calculationEvidence"] = [];
|
||
if (packet.chart.calculationHashDerived) {
|
||
calculationEvidence.push({
|
||
id: "ev-calc-derived",
|
||
label: "calculation_hash",
|
||
value: packet.chart.calculationHash,
|
||
source: "derived_server_sha256_over_allowlisted_calculation_facts",
|
||
});
|
||
}
|
||
if (packet.chart.houses.some((house) => house.signDerived)
|
||
|| packet.chart.vargaHouses.some((varga) => varga.houses.some((house) => house.signDerived))) {
|
||
calculationEvidence.push({
|
||
id: "ev-calc-house-signs",
|
||
label: "house_sign_derivation",
|
||
value: "whole_sign_from_ascendant_for_houses_without_a_source_sign",
|
||
source: "server_derived",
|
||
});
|
||
}
|
||
calculationEvidence.push({
|
||
id: "ev-calc-ascendant",
|
||
label: "ascendant",
|
||
value: `${packet.chart.ascendant.sign} ${packet.chart.ascendant.degree.toFixed(2)}°`,
|
||
source: "server_calculation",
|
||
});
|
||
packet.chart.vimshottari?.forEach((period, index) => {
|
||
calculationEvidence.push({
|
||
id: `ev-calc-vimshottari-${index + 1}`,
|
||
label: `Vimshottari 大运:${period.lord}`,
|
||
value: `${period.start} – ${period.end}`,
|
||
source: "server_calculation",
|
||
});
|
||
});
|
||
packet.chart.narayana?.forEach((period, index) => {
|
||
calculationEvidence.push({
|
||
id: `ev-calc-narayana-${index + 1}`,
|
||
label: `Narayana 大运:${period.lord}`,
|
||
value: `${period.start} – ${period.end}`,
|
||
source: "server_calculation",
|
||
});
|
||
});
|
||
|
||
const appendix: EvidenceAppendix = {
|
||
expandedByDefault: false,
|
||
techniqueAudit,
|
||
conflicts,
|
||
calculationEvidence,
|
||
blockedTechniques: packet.blockedTechniques
|
||
.map((technique) => technique.slice(0, 120))
|
||
.slice(0, 100),
|
||
};
|
||
const evidenceHash = computeEvidenceHash(appendix);
|
||
|
||
const charts: ReportDocumentV1["charts"] = [{
|
||
id: "D1",
|
||
title: "本命盘 D1",
|
||
houses: canonicalChartHouses(packet.chart.houses),
|
||
planets: canonicalPlanets(packet.chart.planets),
|
||
claimStatus: packet.blockedTechniques.length > 0 ? "blocked" : "single_system_inference",
|
||
}];
|
||
for (const varga of packet.chart.vargaHouses) {
|
||
if (varga.id !== "D9" && varga.id !== "D10") continue;
|
||
if (varga.houses.length === 0) continue;
|
||
charts.push({
|
||
id: varga.id,
|
||
title: varga.id === "D9" ? "九分盘 D9" : "事业盘 D10",
|
||
houses: canonicalChartHouses(varga.houses),
|
||
claimStatus: "single_system_inference",
|
||
});
|
||
}
|
||
|
||
const document: ReportDocumentV1 = {
|
||
schemaVersion: "report_document.v1",
|
||
reportId: input.reportId,
|
||
reportType: packet.reportType,
|
||
presentationMode: packet.presentationMode,
|
||
generatedAt: input.generatedAt,
|
||
subject: {
|
||
displayName: packet.subject.displayName,
|
||
birthTimeStatus: packet.subject.birthTimeStatus,
|
||
birthPlaceLabel: packet.subject.birthPlaceLabel,
|
||
},
|
||
provenance: {
|
||
skillName: packet.skillName,
|
||
skillVersion: packet.skillVersion,
|
||
skillSourceCommit: packet.skillSourceCommit,
|
||
skillSnapshotSha256: packet.skillSnapshotSha256,
|
||
calculationHash: packet.chart.calculationHash,
|
||
evidenceHash,
|
||
reportContractVersion: "1",
|
||
},
|
||
executiveSummary: {
|
||
headline: input.agentOutput.executiveSummary.headline,
|
||
summary: input.agentOutput.executiveSummary.summary,
|
||
priorities: [...input.agentOutput.executiveSummary.priorities],
|
||
overallClaimStatus: input.agentOutput.thematicNarrative.some(
|
||
(section) => section.claimStatus === "blocked",
|
||
)
|
||
? "blocked"
|
||
: "single_system_inference",
|
||
},
|
||
charts,
|
||
thematicNarrative: input.agentOutput.thematicNarrative.map((section) => ({
|
||
id: section.id,
|
||
title: section.title,
|
||
narrative: section.narrative,
|
||
actions: [...section.actions],
|
||
caveats: [...section.caveats],
|
||
claimStatus: section.claimStatus,
|
||
evidenceRefs: [...section.evidenceRefs],
|
||
})),
|
||
evidenceAppendix: appendix,
|
||
disclaimer: PERSONAL_REPORT_DISCLAIMER,
|
||
};
|
||
return document;
|
||
}
|
||
|
||
|
||
export type AssembleReportDocumentV2Input = Readonly<{
|
||
reportId: string;
|
||
generatedAt: string;
|
||
depth: ReportDepth;
|
||
bundle: ReportEvidenceBundleV2;
|
||
plan: PersonalReportSectionPlan;
|
||
agentOutput: PersonalReportAgentOutput;
|
||
allowIncompleteThematic?: boolean;
|
||
additionalBlockedSections?: readonly Readonly<{
|
||
theme: string;
|
||
title: string;
|
||
reason: string;
|
||
missingEvidence: readonly string[];
|
||
conflictNotes: readonly string[];
|
||
evidenceRefs: readonly string[];
|
||
}>[];
|
||
}>;
|
||
|
||
const CLAIM_STATUS_RANK: Readonly<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 equalStringSets(left: readonly string[], right: readonly string[]): boolean {
|
||
const uniqueLeft = [...new Set(left)].sort();
|
||
const uniqueRight = [...new Set(right)].sort();
|
||
return uniqueLeft.length === uniqueRight.length
|
||
&& uniqueLeft.every((value, index) => value === uniqueRight[index]);
|
||
}
|
||
|
||
/**
|
||
* The model is a writer only. This validation binds every generated thematic
|
||
* section to the deterministic server plan and its single Claim Card before
|
||
* any model text can enter ReportDocument v2.
|
||
*/
|
||
export function validatePersonalReportAgentOutputAgainstPlan(
|
||
output: PersonalReportAgentOutput,
|
||
plan: PersonalReportSectionPlan,
|
||
bundle: ReportEvidenceBundleV2,
|
||
options: Readonly<{ allowIncompleteThematic?: boolean }> = {},
|
||
): PersonalReportAgentOutput {
|
||
const writePlans = plan.sections.filter((section) => (
|
||
section.kind === "thematic" && section.disposition === "write"
|
||
));
|
||
const allowIncompleteThematic = options.allowIncompleteThematic === true;
|
||
if (!allowIncompleteThematic && output.thematicNarrative.length !== writePlans.length) {
|
||
throw new Error("report_writer_theme_count_mismatch");
|
||
}
|
||
const seen = new Set<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 (!equalStringSets(section.evidenceRefs, sectionPlan.evidenceRefs)) {
|
||
throw new Error(`report_writer_evidence_refs_mismatch:${section.theme}`);
|
||
}
|
||
if (CLAIM_STATUS_RANK[section.claimStatus] < CLAIM_STATUS_RANK[card.assertionLevel]) {
|
||
throw new Error(`report_writer_claim_status_upgrade:${section.theme}`);
|
||
}
|
||
}
|
||
if (!allowIncompleteThematic) {
|
||
for (const sectionPlan of writePlans) {
|
||
if (!seen.has(sectionPlan.theme as string)) {
|
||
throw new Error(`report_writer_theme_missing:${sectionPlan.theme}`);
|
||
}
|
||
}
|
||
}
|
||
return output;
|
||
}
|
||
|
||
function claimStatusForEvidenceStatus(status: EvidenceRefStatus): ClaimStatus {
|
||
if (status === "verified") return "single_system_inference";
|
||
if (status === "partial") return "parameter_sensitive";
|
||
return "blocked";
|
||
}
|
||
|
||
function evidenceRefsForTechnique(
|
||
bundle: ReportEvidenceBundleV2,
|
||
technique: string,
|
||
requireExecuted = true,
|
||
): string[] {
|
||
const normalized = technique.toUpperCase();
|
||
return bundle.executionLedger.filter((receipt) => {
|
||
const receiptTechnique = receipt.technique.toUpperCase();
|
||
const matches = receiptTechnique === normalized || receiptTechnique.endsWith(`:${normalized}`);
|
||
return matches && (!requireExecuted || (receipt.executed && receipt.status !== "blocked"));
|
||
}).map((receipt) => receipt.id);
|
||
}
|
||
|
||
function evidenceAppendixFromBundle(
|
||
bundle: ReportEvidenceBundleV2,
|
||
usedEvidenceRefs: ReadonlySet<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, {
|
||
allowIncompleteThematic: input.allowIncompleteThematic,
|
||
});
|
||
if (bundle.skill.name !== "jyotish-personal-report") {
|
||
throw new Error("report_writer_skill_package_invalid");
|
||
}
|
||
|
||
const d1 = bundle.charts.find((chart) => chart.id === "D1");
|
||
const d1Refs = evidenceRefsForTechnique(bundle, "D1");
|
||
if (!d1 || !d1.ascendant || d1Refs.length === 0) {
|
||
throw new ReportEvidenceInsufficientError("d1_evidence_missing");
|
||
}
|
||
|
||
const thematicNarrative: ReportDocumentV2["thematicNarrative"] = agentOutput.thematicNarrative.map((section) => ({
|
||
id: section.id,
|
||
theme: section.theme,
|
||
title: section.title,
|
||
narrative: section.narrative,
|
||
actions: [...section.actions],
|
||
caveats: [...section.caveats],
|
||
claimStatus: section.claimStatus,
|
||
evidenceRefs: [...section.evidenceRefs],
|
||
}));
|
||
const thematicRefs = uniqueInOrder(thematicNarrative.flatMap((section) => section.evidenceRefs));
|
||
const executiveRefs = thematicRefs.length > 0 ? thematicRefs : d1Refs;
|
||
|
||
const chartIds = new Set(["D1", "D2", "D9", "D10", "D11", "D24"]);
|
||
const charts: ReportDocumentV2["charts"] = [];
|
||
for (const chart of bundle.charts) {
|
||
if (!chartIds.has(chart.id)) continue;
|
||
const refs = evidenceRefsForTechnique(bundle, chart.id);
|
||
if (refs.length === 0) continue;
|
||
const statuses = refs.map((ref) => bundle.evidenceRefs.find((entry) => entry.id === ref)?.status ?? "blocked");
|
||
const status = statuses.every((entry) => entry === "verified")
|
||
? "single_system_inference"
|
||
: statuses.some((entry) => entry === "blocked")
|
||
? "parameter_sensitive"
|
||
: claimStatusForEvidenceStatus(statuses[0]);
|
||
charts.push(canonicalBundleChart(chart, refs, status));
|
||
}
|
||
|
||
const blockedConflictDisclosure: ReportDocumentV2["blockedConflictDisclosure"] = [
|
||
...bundle.blockedSections.map((section) => {
|
||
const missingEvidence = section.missingTechniqueRefs.map((ref) => (
|
||
bundle.executionLedger.find((receipt) => receipt.id === ref)?.technique ?? ref
|
||
));
|
||
const conflictNotes = bundle.conflicts.filter((conflict) => (
|
||
conflict.techniqueRefs.some((ref) => section.missingTechniqueRefs.includes(ref))
|
||
)).map((conflict) => conflict.summary);
|
||
return {
|
||
theme: section.theme,
|
||
title: section.section,
|
||
reason: section.reason,
|
||
missingEvidence,
|
||
conflictNotes,
|
||
evidenceRefs: [...section.missingTechniqueRefs],
|
||
claimStatus: "blocked" as const,
|
||
};
|
||
}),
|
||
...(input.additionalBlockedSections ?? []).map((section) => ({
|
||
theme: section.theme,
|
||
title: section.title,
|
||
reason: section.reason,
|
||
missingEvidence: [...section.missingEvidence],
|
||
conflictNotes: [...section.conflictNotes],
|
||
evidenceRefs: [...section.evidenceRefs],
|
||
claimStatus: "blocked" as const,
|
||
})),
|
||
];
|
||
|
||
const timingSection = thematicNarrative.find((section) => section.theme === "timing");
|
||
const currentPhase: ReportDocumentV2["currentPhase"] = timingSection
|
||
? {
|
||
title: "当前阶段与时间边界",
|
||
phaseLabel: bundle.answerPolicy.canAnswerPreciseTiming ? "当前阶段" : "当前阶段(方向性)",
|
||
narrative: timingSection.narrative,
|
||
timingNotes: [],
|
||
caveats: [...timingSection.caveats],
|
||
claimStatus: timingSection.claimStatus,
|
||
evidenceRefs: [...timingSection.evidenceRefs],
|
||
}
|
||
: null;
|
||
|
||
const actionNotes: ReportDocumentV2["actionNotes"] = [];
|
||
thematicNarrative.forEach((section) => {
|
||
section.actions.forEach((action, index) => actionNotes.push({
|
||
id: `action-${section.theme.replaceAll(".", "-")}-${index + 1}`,
|
||
title: section.title,
|
||
note: action,
|
||
priority: index === 0 ? "now" : index === 1 ? "next" : "watch",
|
||
evidenceRefs: [...section.evidenceRefs],
|
||
}));
|
||
});
|
||
if (actionNotes.length === 0) {
|
||
const fallback = agentOutput.executiveSummary.priorities[0]
|
||
?? "将本报告列出的证据边界与待核验事项作为后续行动清单。";
|
||
actionNotes.push({
|
||
id: "action-evidence-review",
|
||
title: "后续核验",
|
||
note: fallback,
|
||
priority: "now",
|
||
evidenceRefs: [...executiveRefs],
|
||
});
|
||
}
|
||
|
||
const usedEvidenceRefs = new Set<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;
|
||
userId?: string;
|
||
requestId?: string;
|
||
sectionService?: PersonalReportSectionService;
|
||
onProgress?: (progress: Readonly<{ phase: string; completed: number; total: number }>) => Promise<void> | void;
|
||
}>;
|
||
|
||
export type ReportSchemaInnerReason = string;
|
||
|
||
export type GeneratePersonalReportResult = Readonly<
|
||
| { status: "ready"; document: ReportDocumentV2; evidenceHash: string; usage?: Readonly<{ inputTokens: number; outputTokens: number; cache?: import("./agent-generation-settings").PromptCacheUsage | null; actualModelId?: string; modelConfigVersion?: number }> }
|
||
| { status: "failed"; failureCode: "report_schema_invalid"; innerReason: ReportSchemaInnerReason }
|
||
| { status: "failed"; failureCode: "report_guard_rejected" }
|
||
>;
|
||
|
||
const SAFE_INNER_REASON = /^report_(?:writer|plan)_[a-z0-9_.:-]{0,80}$/;
|
||
|
||
export function classifyReportSchemaInnerReason(error: unknown): ReportSchemaInnerReason {
|
||
if (error instanceof PersonalReportAgentOutputError) return "agent_output_invalid";
|
||
if (error instanceof ReportEvidenceInsufficientError) return "assemble_invalid";
|
||
const message = error instanceof Error ? error.message : "";
|
||
if (SAFE_INNER_REASON.test(message)) return message;
|
||
const token = message.split(":")[0] ?? "";
|
||
if (SAFE_INNER_REASON.test(token)) return token;
|
||
return "schema_invalid_unclassified";
|
||
}
|
||
|
||
export function classifyFinalParseInnerReason(
|
||
errors: readonly Readonly<{ path: string; message: string }>[],
|
||
): ReportSchemaInnerReason {
|
||
if (errors.some((error) => error.message.includes("requires structured"))) {
|
||
return "final_parse_missing_divisional_chart";
|
||
}
|
||
if (errors.some((error) => error.path === "provenance.evidenceHash")) {
|
||
return "final_parse_evidence_hash";
|
||
}
|
||
return "final_parse_rejected";
|
||
}
|
||
|
||
export function isPersonalReportGenerationAbort(error: unknown, signal?: AbortSignal): boolean {
|
||
if (signal?.aborted) return true;
|
||
return error instanceof Error && error.name === "AbortError";
|
||
}
|
||
|
||
function failSchema(innerReason: ReportSchemaInnerReason): GeneratePersonalReportResult {
|
||
console.info("[personal-report]", JSON.stringify({
|
||
event: "generation_failed",
|
||
failureCode: "report_schema_invalid",
|
||
innerReason,
|
||
}));
|
||
return { status: "failed", failureCode: "report_schema_invalid", innerReason };
|
||
}
|
||
|
||
function rethrowIfAborted(error: unknown, signal?: AbortSignal): void {
|
||
if (isPersonalReportGenerationAbort(error, signal)) throw error;
|
||
}
|
||
|
||
export function classifySectionErrorCode(error: unknown): string {
|
||
const finishReason = error instanceof PersonalReportAgentOutputError ? error.finishReason : null;
|
||
if (finishReason === "length") return "section_output_truncated";
|
||
const message = error instanceof Error ? error.message : "";
|
||
if (message.includes("length") || message.includes("truncat")) return "section_output_truncated";
|
||
if (
|
||
message.includes("report_writer_section_identity_mismatch")
|
||
|| message.includes("report_writer_unplanned_theme")
|
||
|| message.includes("report_writer_section_id_mismatch")
|
||
) {
|
||
return "section_identity_mismatch";
|
||
}
|
||
if (message.includes("report_writer_evidence_refs_mismatch")) return "section_refs_mismatch";
|
||
if (message.includes("evidence")) return "section_evidence_insufficient";
|
||
return "section_output_invalid";
|
||
}
|
||
|
||
/** Keep each section prompt bounded without weakening the evidence contract. */
|
||
export function filterReportEvidenceBundleForSection(
|
||
source: ReportEvidenceBundleV2,
|
||
section: ReportSectionPlanEntry,
|
||
): ReportEvidenceBundleV2 {
|
||
const claim = source.claimCards.find((card) => card.theme === section.theme);
|
||
const seeds = source.themeNarrativeSeeds.filter((seed) => seed.theme === section.theme);
|
||
// The universal interpretive layers (functional benefic/malefic, yogas,
|
||
// ashtakavarga, current dasha) are chart-wide, not theme-specific, so their
|
||
// receipts ride along with every section. Everything else still comes from
|
||
// the plan's own evidenceRefs.
|
||
const sourceRefIds = new Set(source.evidenceRefs.map((ref) => ref.id));
|
||
const interpretiveRefs = [
|
||
...source.interpretiveFacts.yogas.map((yoga) => yoga.evidenceRef),
|
||
...source.interpretiveFacts.functionalRoles.map((role) => role.evidenceRef),
|
||
...seeds.flatMap((seed) => seed.evidenceRefs),
|
||
].filter((ref) => sourceRefIds.has(ref));
|
||
const requestedRefs = new Set([...section.evidenceRefs, ...interpretiveRefs]);
|
||
const ledger = source.executionLedger.filter((receipt) => requestedRefs.has(receipt.id));
|
||
const evidenceRefs = source.evidenceRefs.filter((ref) => requestedRefs.has(ref.id));
|
||
const selectedRefIds = new Set(evidenceRefs.map((ref) => ref.id));
|
||
const savHouses = THEME_SAV_HOUSES[section.theme ?? ""] ?? [];
|
||
const interpretiveFacts: ReportInterpretiveFacts = {
|
||
yogas: source.interpretiveFacts.yogas.filter((yoga) => selectedRefIds.has(yoga.evidenceRef)),
|
||
functionalRoles: source.interpretiveFacts.functionalRoles
|
||
.filter((role) => selectedRefIds.has(role.evidenceRef)),
|
||
shadbalaRanking: source.interpretiveFacts.shadbalaRanking,
|
||
// Only the houses this chapter actually reads survive the trim.
|
||
savScores: savHouses.length > 0
|
||
? source.interpretiveFacts.savScores.filter((row) => savHouses.includes(row.house))
|
||
: source.interpretiveFacts.savScores,
|
||
savTotal: source.interpretiveFacts.savTotal,
|
||
currentDasha: source.interpretiveFacts.currentDasha,
|
||
convergenceDomains: source.interpretiveFacts.convergenceDomains,
|
||
};
|
||
const charts = source.charts.filter((chart) => (chart.id === "D1"
|
||
|| ledger.some((receipt) => receipt.technique.toUpperCase() === chart.id)));
|
||
const conflicts = source.conflicts.filter((conflict) => (
|
||
conflict.techniqueRefs.some((ref) => selectedRefIds.has(ref))
|
||
));
|
||
return finalizeReportEvidenceBundleV2({
|
||
schemaVersion: source.schemaVersion,
|
||
subject: source.subject,
|
||
requestedThemes: section.theme ? [section.theme] : [...source.requestedThemes],
|
||
reportType: source.reportType,
|
||
presentationMode: source.presentationMode,
|
||
calculationProfile: source.calculationProfile,
|
||
skill: source.skill,
|
||
charts,
|
||
claimCards: claim ? [{
|
||
...claim,
|
||
supportingFacts: claim.supportingFacts.filter((fact) => selectedRefIds.has(fact.evidenceRef)),
|
||
counterFacts: claim.counterFacts.filter((fact) => selectedRefIds.has(fact.evidenceRef)),
|
||
}] : [],
|
||
interpretiveFacts,
|
||
themeNarrativeSeeds: seeds.map((seed) => ({
|
||
...seed,
|
||
evidenceRefs: seed.evidenceRefs.filter((ref) => selectedRefIds.has(ref)),
|
||
})),
|
||
blockedSections: source.blockedSections.filter((blocked) => blocked.theme === section.theme),
|
||
conflicts,
|
||
executionLedger: ledger,
|
||
evidenceRefs,
|
||
answerPolicy: source.answerPolicy,
|
||
});
|
||
}
|
||
|
||
function sectionFailureReason(errorCode: string | null): string {
|
||
if (errorCode === "section_output_truncated" || errorCode?.includes("length") || errorCode?.includes("truncat")) {
|
||
return "本节未能生成:输出被截断。";
|
||
}
|
||
if (errorCode === "section_refs_mismatch") return "本节未能生成:证据引用未对齐。";
|
||
if (errorCode === "section_identity_mismatch") return "本节未能生成:章节身份未对齐。";
|
||
if (errorCode === "section_evidence_insufficient" || errorCode?.includes("evidence")) {
|
||
return "本节未能生成:证据不足。";
|
||
}
|
||
return "本节未能生成:输出未通过校验。";
|
||
}
|
||
|
||
function sectionErrorCode(error: unknown): string {
|
||
return classifySectionErrorCode(error);
|
||
}
|
||
|
||
async function generateSectionedPersonalReport(
|
||
deps: GeneratePersonalReportDeps,
|
||
bundle: ReportEvidenceBundleV2,
|
||
plan: PersonalReportSectionPlan,
|
||
): Promise<GeneratePersonalReportResult> {
|
||
const sectionService = deps.sectionService;
|
||
if (!sectionService || !deps.userId || !deps.requestId || !deps.agent.generateSection || !deps.agent.generateSummary) {
|
||
return failSchema("sectioned_dependencies_missing");
|
||
}
|
||
const writePlans = plan.sections.filter((entry) => entry.kind === "thematic" && entry.disposition === "write");
|
||
const emitProgress = async (phase: string, completed: number): Promise<void> => {
|
||
try {
|
||
await deps.onProgress?.({ phase, completed, total: writePlans.length });
|
||
} catch (error) {
|
||
rethrowIfAborted(error, deps.signal);
|
||
}
|
||
};
|
||
for (const entry of writePlans) {
|
||
await sectionService.ensure({
|
||
userId: deps.userId, requestId: deps.requestId, sectionId: entry.id, maxAttempts: 2,
|
||
});
|
||
}
|
||
const existing = new Map((await sectionService.list(deps.userId, deps.requestId)).map((row) => [row.sectionId, row]));
|
||
const ready: PersonalReportSectionRecord[] = [];
|
||
const blocked: PersonalReportSectionRecord[] = [];
|
||
for (const entry of writePlans) {
|
||
let row = existing.get(entry.id) ?? null;
|
||
if (row?.status === "ready" && row.payload) { ready.push(row); continue; }
|
||
if (row?.status === "blocked") { blocked.push(row); continue; }
|
||
let sectionBundle: ReportEvidenceBundleV2;
|
||
try {
|
||
sectionBundle = filterReportEvidenceBundleForSection(bundle, entry);
|
||
} catch (error) {
|
||
rethrowIfAborted(error, deps.signal);
|
||
const blockedRow = await sectionService.block({
|
||
userId: deps.userId, requestId: deps.requestId, sectionId: entry.id, errorCode: sectionErrorCode(error),
|
||
});
|
||
if (!blockedRow) throw new Error("section_block_failed");
|
||
blocked.push(blockedRow);
|
||
await emitProgress(`section:${entry.id}`, ready.length + blocked.length);
|
||
continue;
|
||
}
|
||
const outputBudget = sectionOutputTokenBudget(entry.targetCharacters.max);
|
||
try {
|
||
while (true) {
|
||
row = await sectionService.start({ userId: deps.userId, requestId: deps.requestId, sectionId: entry.id });
|
||
if (!row) {
|
||
const current = (await sectionService.list(deps.userId, deps.requestId)).find((item) => item.sectionId === entry.id);
|
||
if (current?.status === "ready" && current.payload) { ready.push(current); break; }
|
||
if (current?.status === "blocked") { blocked.push(current); break; }
|
||
throw new Error("section_start_failed");
|
||
}
|
||
try {
|
||
const titles = ready.map((item) => item.payload?.title).filter((title): title is string => Boolean(title));
|
||
const output = await deps.agent.generateSection(sectionBundle, entry, titles, {
|
||
signal: deps.signal,
|
||
maxOutputTokens: outputBudget,
|
||
targetCharactersMin: entry.targetCharacters.min,
|
||
assertWriterOutput: (candidate) => {
|
||
if (candidate.id !== entry.id || candidate.theme !== entry.theme) throw new Error("report_writer_section_identity_mismatch");
|
||
if (!equalStringSets(candidate.evidenceRefs, entry.evidenceRefs)) throw new Error("report_writer_evidence_refs_mismatch");
|
||
},
|
||
});
|
||
const completed = await sectionService.complete({ userId: deps.userId, requestId: deps.requestId, sectionId: entry.id, payload: output });
|
||
if (!completed) throw new Error("section_complete_failed");
|
||
ready.push(completed);
|
||
await emitProgress(`section:${entry.id}`, ready.length + blocked.length);
|
||
break;
|
||
} catch (error) {
|
||
rethrowIfAborted(error, deps.signal);
|
||
if (row.attemptCount >= row.maxAttempts) {
|
||
const blockedRow = await sectionService.block({
|
||
userId: deps.userId, requestId: deps.requestId, sectionId: entry.id, errorCode: sectionErrorCode(error),
|
||
});
|
||
if (!blockedRow) throw new Error("section_block_failed");
|
||
blocked.push(blockedRow);
|
||
await emitProgress(`section:${entry.id}`, ready.length + blocked.length);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
rethrowIfAborted(error, deps.signal);
|
||
const current = (await sectionService.list(deps.userId, deps.requestId)).find((item) => item.sectionId === entry.id);
|
||
if (current?.status === "ready" && current.payload) { ready.push(current); continue; }
|
||
if (current?.status === "blocked") {
|
||
if (!blocked.some((item) => item.sectionId === current.sectionId)) blocked.push(current);
|
||
continue;
|
||
}
|
||
const blockedRow = await sectionService.block({
|
||
userId: deps.userId, requestId: deps.requestId, sectionId: entry.id, errorCode: sectionErrorCode(error),
|
||
});
|
||
if (!blockedRow) throw new Error("section_block_failed");
|
||
blocked.push(blockedRow);
|
||
await emitProgress(`section:${entry.id}`, ready.length + blocked.length);
|
||
}
|
||
}
|
||
if (writePlans.length > 0 && ready.length === 0) {
|
||
return failSchema("all_sections_blocked");
|
||
}
|
||
await emitProgress("summary", writePlans.length);
|
||
let summary;
|
||
try {
|
||
summary = await deps.agent.generateSummary(ready.map((item) => ({
|
||
title: item.payload!.title, claimStatus: item.payload!.claimStatus,
|
||
})), { signal: deps.signal, maxOutputTokens: summaryOutputTokenBudget() });
|
||
} catch (error) {
|
||
rethrowIfAborted(error, deps.signal);
|
||
return failSchema(classifyReportSchemaInnerReason(error));
|
||
}
|
||
const agentOutput: PersonalReportAgentOutput = {
|
||
executiveSummary: summary,
|
||
thematicNarrative: ready.flatMap((item) => item.payload ? [item.payload] : []),
|
||
};
|
||
const blockedDisclosures = blocked.map((item) => {
|
||
const entry = writePlans.find((candidate) => candidate.id === item.sectionId)!;
|
||
const refs = [...entry.evidenceRefs];
|
||
return {
|
||
theme: entry.theme!, title: entry.theme!, reason: sectionFailureReason(item.lastErrorCode),
|
||
missingEvidence: [sectionFailureReason(item.lastErrorCode)], conflictNotes: [], evidenceRefs: refs,
|
||
};
|
||
});
|
||
await emitProgress("assemble", writePlans.length);
|
||
try {
|
||
const candidate = assembleReportDocumentV2({
|
||
reportId: deps.reportId, generatedAt: (deps.now ?? (() => new Date()))().toISOString(),
|
||
depth: deps.depth, bundle, plan, agentOutput, allowIncompleteThematic: true,
|
||
additionalBlockedSections: blockedDisclosures,
|
||
});
|
||
const guarded = applyReportGuard(candidate, buildLegacyPacketFromBundle(bundle));
|
||
if (!guarded.ok) return { status: "failed", failureCode: "report_guard_rejected" };
|
||
const parsed = safeParseServerReportDocument(guarded.document);
|
||
if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") return failSchema(parsed.ok ? "final_parse_rejected" : classifyFinalParseInnerReason(parsed.errors));
|
||
return { status: "ready", document: parsed.document, evidenceHash: computeEvidenceHash(parsed.document.evidenceAppendix), usage: deps.agent.getUsage?.() };
|
||
} catch (error) {
|
||
rethrowIfAborted(error, deps.signal);
|
||
return failSchema(error instanceof ReportEvidenceInsufficientError ? "assemble_invalid" : classifyReportSchemaInnerReason(error));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Runs the dedicated report agent exactly once (plus its single internal
|
||
* repair retry), assembles the candidate document, applies the deterministic
|
||
* guard, then runs the FINAL canonical server parse on the guarded document.
|
||
* The evidence hash is recomputed from the canonical evidence appendix by the
|
||
* server contract — never a model self-report. Never falls back to mock,
|
||
* example, random or sample data.
|
||
*/
|
||
export async function generatePersonalReport(
|
||
deps: GeneratePersonalReportDeps,
|
||
): Promise<GeneratePersonalReportResult> {
|
||
let bundle: ReportEvidenceBundleV2;
|
||
try {
|
||
bundle = validateReportEvidenceBundleV2(deps.bundle);
|
||
bundle = demoteThemesMissingRequiredCharts(bundle);
|
||
} catch (error) {
|
||
rethrowIfAborted(error, deps.signal);
|
||
return failSchema("bundle_invalid");
|
||
}
|
||
|
||
let plan: PersonalReportSectionPlan;
|
||
try {
|
||
plan = validatePersonalReportSectionPlan(
|
||
buildPersonalReportSectionPlan(bundle, deps.depth),
|
||
bundle,
|
||
);
|
||
} catch (error) {
|
||
rethrowIfAborted(error, deps.signal);
|
||
return failSchema("plan_invalid");
|
||
}
|
||
|
||
if (deps.sectionService) {
|
||
return generateSectionedPersonalReport(deps, bundle, plan);
|
||
}
|
||
|
||
const bindWriter = (output: PersonalReportAgentOutput) => (
|
||
validatePersonalReportAgentOutputAgainstPlan(output, plan, bundle)
|
||
);
|
||
|
||
let agentOutput: PersonalReportAgentOutput;
|
||
try {
|
||
agentOutput = await deps.agent.generate(bundle, plan, {
|
||
signal: deps.signal,
|
||
assertWriterOutput: bindWriter,
|
||
});
|
||
} catch (error) {
|
||
rethrowIfAborted(error, deps.signal);
|
||
return failSchema(
|
||
error instanceof PersonalReportAgentOutputError
|
||
? "agent_output_invalid"
|
||
: classifyReportSchemaInnerReason(error),
|
||
);
|
||
}
|
||
|
||
try {
|
||
agentOutput = bindWriter(agentOutput);
|
||
} catch (error) {
|
||
rethrowIfAborted(error, deps.signal);
|
||
try {
|
||
agentOutput = bindWriter(await deps.agent.generate(bundle, plan, {
|
||
signal: deps.signal,
|
||
assertWriterOutput: bindWriter,
|
||
}));
|
||
} catch (repairError) {
|
||
rethrowIfAborted(repairError, deps.signal);
|
||
return failSchema(classifyReportSchemaInnerReason(repairError));
|
||
}
|
||
}
|
||
|
||
const packet = buildLegacyPacketFromBundle(bundle);
|
||
let candidate: ReportDocumentV2;
|
||
try {
|
||
candidate = assembleReportDocumentV2({
|
||
reportId: deps.reportId,
|
||
generatedAt: (deps.now ?? (() => new Date()))().toISOString(),
|
||
depth: deps.depth,
|
||
bundle,
|
||
plan,
|
||
agentOutput,
|
||
});
|
||
} catch (error) {
|
||
rethrowIfAborted(error, deps.signal);
|
||
return failSchema(
|
||
error instanceof ReportEvidenceInsufficientError
|
||
? "assemble_invalid"
|
||
: classifyReportSchemaInnerReason(error),
|
||
);
|
||
}
|
||
const guarded = applyReportGuard(candidate, packet);
|
||
if (!guarded.ok) {
|
||
return { status: "failed", failureCode: "report_guard_rejected" };
|
||
}
|
||
const parsed = safeParseServerReportDocument(guarded.document);
|
||
if (!parsed.ok || parsed.document.schemaVersion !== "report_document.v2") {
|
||
return failSchema(parsed.ok ? "final_parse_rejected" : classifyFinalParseInnerReason(parsed.errors));
|
||
}
|
||
return {
|
||
status: "ready",
|
||
document: parsed.document,
|
||
evidenceHash: computeEvidenceHash(parsed.document.evidenceAppendix),
|
||
usage: deps.agent.getUsage?.(),
|
||
};
|
||
}
|