Goldens regenerated by the capture scripts (PYTHONHASHSEED=0, cache TTL 0; two runs byte-identical). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
893 lines
40 KiB
TypeScript
893 lines
40 KiB
TypeScript
import { z } from "zod";
|
||
import { consultationEvidenceCategoryValues, createConsultationPlan, type ConsultationPlan } from "../lib/consultation-plan.ts";
|
||
import { consultationThemeValues, projectConsultationWorkflowRequest } from "../lib/consultation-workflow-request.ts";
|
||
import { AYANAMSA_VALUES, DEFAULT_AYANAMSA } from "../lib/ayanamsa.ts";
|
||
import { applyUnreliableVargasToWorkflowContext } from "../lib/consultation-birth-time-mode.ts";
|
||
|
||
export const consultationInputSchema = z.object({
|
||
year: z.number().int().min(1900).max(2100),
|
||
month: z.number().int().min(1).max(12),
|
||
day: z.number().int().min(1).max(31),
|
||
hour: z.number().int().min(0).max(23),
|
||
minute: z.number().int().min(0).max(59),
|
||
lat: z.number().min(-90).max(90),
|
||
lon: z.number().min(-180).max(180),
|
||
tz: z.number().min(-12).max(14),
|
||
ayanamsa: z.enum(AYANAMSA_VALUES).default(DEFAULT_AYANAMSA),
|
||
city: z.string().trim().min(1).max(120),
|
||
question: z.string().trim().min(1).max(500),
|
||
theme: z.enum(consultationThemeValues),
|
||
entryMode: z.enum(["direct_chart", "rectification"]).default("direct_chart"),
|
||
declared_accuracy: z.enum(["rectified", "minute", "15min", "1hour", "unknown"]).optional(),
|
||
time_source: z.string().trim().min(1).max(40).optional(),
|
||
birth_time_accuracy: z.enum(["confirmed", "provisional", "approximate"]).optional(),
|
||
candidate_range: z.object({ start_time: z.string(), end_time: z.string() }).passthrough().optional(),
|
||
representative_time: z.string().optional(),
|
||
declared_window_start: z.string().optional(),
|
||
declared_window_end: z.string().optional(),
|
||
uncertainty_before_minutes: z.number().int().min(0).max(720).optional(),
|
||
uncertainty_after_minutes: z.number().int().min(0).max(720).optional(),
|
||
// Charts a segment-v1 adoption could not determine (BUG-1138). Server-owned,
|
||
// never sent to the engine; it only shapes the context the model reads.
|
||
unreliable_vargas: z.array(z.enum(["D1", "D9", "D10"])).min(1).optional(),
|
||
});
|
||
export type ConsultationInput = z.infer<typeof consultationInputSchema>;
|
||
type JsonRecord = Record<string, unknown>;
|
||
|
||
const workflowConsumerContextSchema = z.object({
|
||
route: z.string().min(1),
|
||
core_status: z.enum(["ready", "degraded", "blocked"]),
|
||
available_layers: z.array(z.string()),
|
||
missing_route_layers: z.array(z.string()),
|
||
hard_blockers: z.array(z.string()),
|
||
technique_truth: z.record(z.unknown()).optional(),
|
||
answer_policy: z.object({
|
||
can_answer_direction: z.boolean(),
|
||
can_answer_precise_timing: z.boolean(),
|
||
}).passthrough(),
|
||
}).passthrough();
|
||
|
||
export const consultationWorkflowResponseSchema = z.object({
|
||
success: z.boolean(),
|
||
chart: z.record(z.unknown()),
|
||
routing: z.record(z.unknown()),
|
||
consumer_context: workflowConsumerContextSchema,
|
||
birth_time_sensitivity: z.object({
|
||
schema: z.literal("jyotish.report_birth_time_sensitivity.v1"),
|
||
status: z.enum(["not_applicable", "candidate_window_only"]),
|
||
}).passthrough().optional(),
|
||
}).passthrough();
|
||
|
||
function record(value: unknown): JsonRecord {
|
||
return value && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : {};
|
||
}
|
||
|
||
function rectificationBoundaryFromGate(rectification: JsonRecord): string {
|
||
const accuracy = String(rectification.effective_accuracy || "");
|
||
const lagnaSensitive = record(rectification.lagna_boundary).is_sensitive === true;
|
||
if ((accuracy === "minute" || accuracy === "rectified") && !lagnaSensitive) {
|
||
return "precision_ok";
|
||
}
|
||
if ((accuracy === "5min" || accuracy === "15min") && !lagnaSensitive) {
|
||
return "precision_annotated";
|
||
}
|
||
return "not_auto_rectified";
|
||
}
|
||
|
||
const apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
|
||
|
||
/**
|
||
* Closed vocabulary for why a consultation workflow call failed.
|
||
*
|
||
* The upstream error text is a provider payload and must not reach logs, so
|
||
* the failure is classified here into a machine code that observability can
|
||
* record without carrying free-form content.
|
||
*/
|
||
export const consultationWorkflowFailureCodes = [
|
||
"workflow_bad_request",
|
||
"workflow_forbidden",
|
||
"workflow_unsupported_media_type",
|
||
"workflow_rate_limited",
|
||
"workflow_queue_full",
|
||
"workflow_server_error",
|
||
"workflow_http_error",
|
||
"workflow_empty_response",
|
||
"workflow_contract_invalid",
|
||
"workflow_timeout",
|
||
"workflow_aborted",
|
||
"workflow_unreachable",
|
||
] as const;
|
||
|
||
export type ConsultationWorkflowFailureCode = typeof consultationWorkflowFailureCodes[number];
|
||
|
||
export class ConsultationWorkflowError extends Error {
|
||
readonly code: ConsultationWorkflowFailureCode;
|
||
|
||
constructor(code: ConsultationWorkflowFailureCode, message: string) {
|
||
super(message);
|
||
this.name = "ConsultationWorkflowError";
|
||
this.code = code;
|
||
}
|
||
}
|
||
|
||
function httpFailureCode(status: number): ConsultationWorkflowFailureCode {
|
||
if (status === 400) return "workflow_bad_request";
|
||
if (status === 403) return "workflow_forbidden";
|
||
if (status === 415) return "workflow_unsupported_media_type";
|
||
if (status === 429) return "workflow_rate_limited";
|
||
if (status === 503) return "workflow_queue_full";
|
||
if (status >= 500) return "workflow_server_error";
|
||
return "workflow_http_error";
|
||
}
|
||
|
||
export function consultationWorkflowFailureCode(error: unknown): ConsultationWorkflowFailureCode | undefined {
|
||
if (error instanceof ConsultationWorkflowError) return error.code;
|
||
if (error instanceof DOMException && error.name === "TimeoutError") return "workflow_timeout";
|
||
if (error instanceof DOMException && error.name === "AbortError") return "workflow_aborted";
|
||
return undefined;
|
||
}
|
||
|
||
export async function runConsultationWorkflow(
|
||
input: ConsultationInput,
|
||
options?: {
|
||
foreground?: boolean;
|
||
signal?: AbortSignal;
|
||
plan?: ConsultationPlan;
|
||
requestId?: string;
|
||
entrypoint?: string;
|
||
},
|
||
) {
|
||
const { entryMode, question, theme, unreliable_vargas: unreliableVargas, ...workflowInput } = input;
|
||
const plan = options?.plan ?? createConsultationPlan({ userIntent: question, theme });
|
||
const workflowRequest = projectConsultationWorkflowRequest(question, theme, plan);
|
||
const timeout = AbortSignal.timeout(90_000);
|
||
const signal = options?.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
|
||
const response = await fetch(`${apiBase}/api/consultation_workflow`, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({
|
||
...workflowInput,
|
||
// Correlates this call with the API access log when a run fails.
|
||
...(options?.requestId ? { request_id: options.requestId } : {}),
|
||
...(options?.entrypoint ? { entrypoint: options.entrypoint } : {}),
|
||
entry_mode: entryMode,
|
||
question: workflowRequest.question,
|
||
question_text: workflowRequest.question,
|
||
theme: workflowRequest.themes,
|
||
defer_optional_external_evidence: options?.foreground === true,
|
||
plan_version: workflowRequest.plan_version,
|
||
strict_workflow_route: workflowRequest.strictWorkflowRoute,
|
||
required_layers: workflowRequest.requiredLayers,
|
||
claim_boundary: workflowRequest.claimBoundary,
|
||
plan_depth: workflowRequest.depth,
|
||
requested_domains: workflowRequest.requested_domains,
|
||
timing_horizon: workflowRequest.timing_horizon,
|
||
precision_boundary: workflowRequest.precision_boundary,
|
||
required_evidence_categories: workflowRequest.required_evidence_categories,
|
||
}),
|
||
signal,
|
||
});
|
||
const data = await response.json().catch(() => null);
|
||
if (!response.ok || !data) {
|
||
throw new ConsultationWorkflowError(
|
||
response.ok ? "workflow_empty_response" : httpFailureCode(response.status),
|
||
data?.error || data?.message || `Jyotish API returned ${response.status}`,
|
||
);
|
||
}
|
||
const parsed = consultationWorkflowResponseSchema.safeParse(data);
|
||
if (!parsed.success) {
|
||
throw new ConsultationWorkflowError(
|
||
"workflow_contract_invalid",
|
||
"Jyotish API returned an incomplete consultation contract",
|
||
);
|
||
}
|
||
return applyUnreliableVargasToWorkflowContext(attachMinuteSensitiveThemes(parsed.data), unreliableVargas);
|
||
}
|
||
|
||
export function minuteSensitiveThemesFromBirthTimeSensitivity(value: unknown): string[] {
|
||
const sensitivity = record(value);
|
||
const themes = record(sensitivity.theme_sensitivity);
|
||
const sensitive: string[] = [];
|
||
for (const [key, row] of Object.entries(themes)) {
|
||
if (record(row).status === "sensitive" && !sensitive.includes(key)) {
|
||
sensitive.push(key);
|
||
}
|
||
}
|
||
return sensitive.slice(0, 24);
|
||
}
|
||
|
||
function attachMinuteSensitiveThemes<T extends {
|
||
consumer_context?: unknown;
|
||
birth_time_sensitivity?: unknown;
|
||
}>(data: T): T {
|
||
const consumer = record(data.consumer_context);
|
||
const policy = record(consumer.answer_policy);
|
||
return {
|
||
...data,
|
||
consumer_context: {
|
||
...consumer,
|
||
answer_policy: {
|
||
...policy,
|
||
minute_sensitive_themes: minuteSensitiveThemesFromBirthTimeSensitivity(data.birth_time_sensitivity),
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
export function consultationWorkflowReceipt(data: JsonRecord) {
|
||
const consumerContext = workflowConsumerContextSchema.parse(data.consumer_context);
|
||
const policy = record(consumerContext.answer_policy);
|
||
const minuteSensitive = Array.isArray(policy.minute_sensitive_themes)
|
||
? policy.minute_sensitive_themes.filter((item): item is string => typeof item === "string")
|
||
: minuteSensitiveThemesFromBirthTimeSensitivity(data.birth_time_sensitivity);
|
||
return {
|
||
route: consumerContext.route,
|
||
status: consumerContext.core_status,
|
||
preciseTiming: consumerContext.answer_policy.can_answer_precise_timing ? "allowed" : "blocked",
|
||
missingLayers: consumerContext.missing_route_layers.join(",") || "none",
|
||
techniqueTruth: String(record(consumerContext.technique_truth).status || "unknown"),
|
||
evidenceStatus: record(consumerContext.commercial_evidence_status),
|
||
...(minuteSensitive.length > 0 ? { minuteSensitiveThemes: minuteSensitive } : {}),
|
||
};
|
||
}
|
||
|
||
type ModelOutputValue = string | number | boolean | null | ModelOutputValue[] | { [key: string]: ModelOutputValue };
|
||
|
||
const modelOutputEvidenceSchema = z.unknown();
|
||
|
||
export const consultationEvidencePacketSchema = z.object({
|
||
packet_version: z.literal("consultation-evidence-packet-v2"),
|
||
question: z.string().optional(),
|
||
route: z.string(),
|
||
status: z.enum(["ready", "degraded", "blocked"]),
|
||
evidence_contract: z.object({
|
||
available_layers: modelOutputEvidenceSchema.optional(),
|
||
missing_route_layers: modelOutputEvidenceSchema.optional(),
|
||
hard_blockers: modelOutputEvidenceSchema.optional(),
|
||
answer_policy: modelOutputEvidenceSchema.optional(),
|
||
user_facing_limitation: modelOutputEvidenceSchema.optional(),
|
||
technique_audit_table: modelOutputEvidenceSchema.optional(),
|
||
varga_spectrum: modelOutputEvidenceSchema.optional(),
|
||
western_spectrum: modelOutputEvidenceSchema.optional(),
|
||
must_use_layers: modelOutputEvidenceSchema.optional(),
|
||
}),
|
||
claim_cards: z.array(z.object({
|
||
category: z.enum(consultationEvidenceCategoryValues),
|
||
source: z.literal("server_chart").or(z.literal("server_workflow")),
|
||
evidence: modelOutputEvidenceSchema,
|
||
})),
|
||
presentation: z.object({
|
||
template: z.literal("skill_level_2"),
|
||
required_blocks: z.array(z.string()),
|
||
}),
|
||
rectification: z.object({ boundary: z.string() }),
|
||
}).strict();
|
||
|
||
export type ConsultationEvidencePacket = z.infer<typeof consultationEvidencePacketSchema>;
|
||
|
||
function normalizeEvidenceKey(key: string) {
|
||
return key.replace(/[^a-z0-9]/gi, "").toLowerCase();
|
||
}
|
||
|
||
function boundedPrimitive(value: unknown): ModelOutputValue | undefined {
|
||
if (value === null || typeof value === "boolean" || typeof value === "number") return value;
|
||
if (typeof value === "string") return value.length > 800 ? `${value.slice(0, 797)}...` : value;
|
||
return undefined;
|
||
}
|
||
|
||
const astrologyEntityKeys = new Set([
|
||
"sun", "moon", "mars", "mercury", "jupiter", "venus", "saturn", "rahu", "ketu",
|
||
"aries", "taurus", "gemini", "cancer", "leo", "virgo", "libra", "scorpio",
|
||
"sagittarius", "capricorn", "aquarius", "pisces",
|
||
"first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth",
|
||
"ninth", "tenth", "eleventh", "twelfth",
|
||
]);
|
||
|
||
function isAstrologyEntityKey(key: string) {
|
||
const normalized = normalizeEvidenceKey(key);
|
||
return astrologyEntityKeys.has(normalized)
|
||
|| /^h(?:ouse)?\d{1,2}$/.test(normalized)
|
||
|| /^\d{1,2}$/.test(normalized);
|
||
}
|
||
|
||
function projectAllowlistedTree(
|
||
value: unknown,
|
||
allowedKeys: ReadonlySet<string>,
|
||
options: Readonly<{ allowAstrologyEntityKeys?: boolean; depth?: number }> = {},
|
||
): ModelOutputValue | undefined {
|
||
const primitive = boundedPrimitive(value);
|
||
if (primitive !== undefined) return primitive;
|
||
const depth = options.depth ?? 0;
|
||
if (depth > 4) return undefined;
|
||
if (Array.isArray(value)) {
|
||
return value.slice(0, 24)
|
||
.map((item) => projectAllowlistedTree(item, allowedKeys, { ...options, depth: depth + 1 }))
|
||
.filter((item): item is ModelOutputValue => item !== undefined);
|
||
}
|
||
if (!value || typeof value !== "object") return undefined;
|
||
const output: { [key: string]: ModelOutputValue } = {};
|
||
for (const [key, item] of Object.entries(value)) {
|
||
const normalized = normalizeEvidenceKey(key);
|
||
if (!allowedKeys.has(normalized) && !(options.allowAstrologyEntityKeys && isAstrologyEntityKey(key))) continue;
|
||
const projected = projectAllowlistedTree(item, allowedKeys, { ...options, depth: depth + 1 });
|
||
if (projected !== undefined) output[key] = projected;
|
||
if (Object.keys(output).length >= 24) break;
|
||
}
|
||
return output;
|
||
}
|
||
|
||
const natalFoundationKeys = new Set([
|
||
"ascendant", "planets", "houses", "shadbala", "ashtakavarga", "sarvashtakavarga",
|
||
"name", "planet", "sign", "signidx", "degree", "degreeinsign", "degreeraw", "lon",
|
||
"nakshatra", "pada", "lord", "house", "number", "occupants", "aspects", "retrograde",
|
||
"isretrograde", "dignity", "status", "total", "totalrupa", "rupa", "ratio", "required",
|
||
"rank", "strength", "score", "value", "sav", "bav", "points", "count", "housescores",
|
||
"components", "matrix", "matrixshape", "signs", "sources", "source", "method", "boundary",
|
||
"functionalbeneficmalefic", "functionalbenefics", "functionalmalefics", "functionalneutrals",
|
||
"yogakarakas", "ownedhouses", "ascendantsign", "effectonconfidence",
|
||
"yogas", "category", "arudhapadas", "padas", "a10", "a7", "ul", "al", "gulika", "mandi", "kakshya",
|
||
// Evidence card v2: the engine writes the Arudha Lagna (AL) as pada `A1`.
|
||
"a1",
|
||
"kpcusps", "cusps", "sublord", "star", "starlord", "claimboundary", "reason",
|
||
]);
|
||
|
||
const timingKeys = new Set([
|
||
"dasha", "dashaboundaries", "dashasubperiods", "narayanadasha", "status", "current", "next", "mahadasha",
|
||
"antardasha", "pratyantardasha", "currentdasha", "period", "dashaperiod", "periods", "timeline",
|
||
"boundaries", "boundarycount", "start", "end", "startyear", "endyear", "iscurrent",
|
||
"activationdescription", "sign", "lord", "planet", "name", "strength", "score", "source",
|
||
"method", "confidence", "confidencecap", "summary", "conclusion", "fingerprint",
|
||
"charadasha", "transits", "sadesati", "triggers", "triggercount", "searchperiod", "window", "sequence", "durationyears",
|
||
"fromage", "toage", "phase", "phasename", "moonsign", "saturnsign", "intensity", "active",
|
||
"date", "target", "kind", "orb", "boundary", "claimboundary",
|
||
// BUG-1054: the engine writes the running Narayana period under
|
||
// `current_dasha.md` / `ad` / `pd` (sign, lord, years, start_age, end_age)
|
||
// and the Vimshottari pratyantar window under `pratyantar_dasha_timeline`.
|
||
// Without these keys the projection kept `current_dasha` as an empty object
|
||
// and dropped the pratyantar dates, although the engine had both.
|
||
"md", "ad", "pd", "years", "startage", "endage", "remainingyears", "pratyantardashatimeline",
|
||
]);
|
||
|
||
const validationKeys = new Set([
|
||
"techniquetruth", "referencetransparency", "shadbalaboundary", "status", "state", "verified",
|
||
"executed", "blocked", "notapplicable", "available", "coverage", "source", "sources", "method",
|
||
"methods", "technique", "techniques", "reason", "reasons", "boundary", "confidence",
|
||
"confidencecap", "summary", "conclusion", "publiccontextonly", "similarpubliccases",
|
||
"highsimilaritypublicreferencesavailable", "requesteduncovereddomains", "timingstate",
|
||
"partialmatch", "narayanastatus", "transitstatus", "productiontuningallowed", "nomajorityvote",
|
||
"vedastrocrosscheck", "officialclosurestate", "officialclosurereason", "natal", "ascendant",
|
||
]);
|
||
|
||
const domainDetailsKeys = new Set([
|
||
"source", "derived", "fragment", "sign", "signidx", "planets", "planetslabel", "lord", "house",
|
||
"score", "level", "clues", "marriagecount", "d9marriagequality", "headline", "strengths", "risks",
|
||
"boundaries", "monthlyframe", "primarystate", "manifestationmode", "frictionsource", "timeconfidence",
|
||
"current", "summary", "interpretation", "description", "note", "status", "confidence",
|
||
]);
|
||
|
||
function projectVargaChart(value: unknown): ModelOutputValue | undefined {
|
||
const chart = record(value);
|
||
const planetsIn = record(chart.planets);
|
||
const planets: { [key: string]: ModelOutputValue } = {};
|
||
for (const [name, sign] of Object.entries(planetsIn)) {
|
||
const projected = boundedPrimitive(sign);
|
||
if (projected !== undefined) planets[name] = projected;
|
||
}
|
||
const output: { [key: string]: ModelOutputValue } = {};
|
||
const lagna = boundedPrimitive(chart.lagna);
|
||
if (lagna !== undefined) output.lagna = lagna;
|
||
if (Object.keys(planets).length) output.planets = planets;
|
||
for (const key of ["name", "meaning", "boundary"] as const) {
|
||
const projected = boundedPrimitive(chart[key]);
|
||
if (projected !== undefined) output[key] = projected;
|
||
}
|
||
return Object.keys(output).length ? output : undefined;
|
||
}
|
||
|
||
function projectVargaGroup(value: unknown): ModelOutputValue | undefined {
|
||
const group = record(value);
|
||
const output: { [key: string]: ModelOutputValue } = {};
|
||
for (const [code, chart] of Object.entries(group)) {
|
||
const projected = projectVargaChart(chart);
|
||
if (projected !== undefined) output[code] = projected;
|
||
}
|
||
return Object.keys(output).length ? output : undefined;
|
||
}
|
||
|
||
function projectVargaSpectrum(value: unknown): ModelOutputValue | undefined {
|
||
const spectrum = record(value);
|
||
if (!Object.keys(spectrum).length) return undefined;
|
||
const output: { [key: string]: ModelOutputValue } = {};
|
||
for (const key of ["status", "mode", "boundary"] as const) {
|
||
const projected = boundedPrimitive(spectrum[key]);
|
||
if (projected !== undefined) output[key] = projected;
|
||
}
|
||
const counts = record(spectrum.counts);
|
||
if (Object.keys(counts).length) {
|
||
const compact: { [key: string]: ModelOutputValue } = {};
|
||
for (const [key, item] of Object.entries(counts)) {
|
||
const projected = boundedPrimitive(item);
|
||
if (projected !== undefined) compact[key] = projected;
|
||
}
|
||
if (Object.keys(compact).length) output.counts = compact;
|
||
}
|
||
for (const group of ["formal", "research_dn", "extended"] as const) {
|
||
const projected = projectVargaGroup(spectrum[group]);
|
||
if (projected !== undefined) output[group] = projected;
|
||
}
|
||
if (Array.isArray(spectrum.blocked)) {
|
||
output.blocked = spectrum.blocked.filter((item): item is string => typeof item === "string").slice(0, 60);
|
||
}
|
||
return Object.keys(output).length ? output : undefined;
|
||
}
|
||
|
||
function projectTechniqueAudit(value: unknown): ModelOutputValue | undefined {
|
||
if (!Array.isArray(value)) return undefined;
|
||
const rows = value.slice(0, 80).map((item) => {
|
||
const row = record(item);
|
||
const projected: { [key: string]: ModelOutputValue } = {};
|
||
for (const key of ["technique", "status", "system", "boundary", "effect_on_confidence"] as const) {
|
||
const scalar = boundedPrimitive(row[key]);
|
||
if (scalar !== undefined) projected[key] = scalar;
|
||
}
|
||
for (const key of ["key_functional_benefics", "key_functional_malefics"] as const) {
|
||
if (!Array.isArray(row[key])) continue;
|
||
const names = row[key]
|
||
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
|
||
.slice(0, 9);
|
||
if (names.length) projected[key] = names;
|
||
}
|
||
return projected;
|
||
}).filter((row) => Object.keys(row).length > 0);
|
||
return rows.length ? rows : undefined;
|
||
}
|
||
|
||
function projectWesternTechnique(value: unknown): ModelOutputValue | undefined {
|
||
const row = record(value);
|
||
if (!Object.keys(row).length) return undefined;
|
||
const projected: { [key: string]: ModelOutputValue } = {};
|
||
for (const key of [
|
||
"status", "boundary", "target_date", "start_date", "end_date", "target_year", "return_date", "event_count",
|
||
] as const) {
|
||
const scalar = boundedPrimitive(row[key]);
|
||
if (scalar !== undefined) projected[key] = scalar;
|
||
}
|
||
if (Array.isArray(row.aspects)) {
|
||
const aspects = row.aspects.slice(0, 6).map((item) => {
|
||
const aspect = record(item);
|
||
const compact: { [key: string]: ModelOutputValue } = {};
|
||
for (const key of ["transit_planet", "natal_point", "aspect", "orb"] as const) {
|
||
const scalar = boundedPrimitive(aspect[key]);
|
||
if (scalar !== undefined) compact[key] = scalar;
|
||
}
|
||
return compact;
|
||
}).filter((item) => Object.keys(item).length);
|
||
if (aspects.length) projected.aspects = aspects;
|
||
}
|
||
if (Array.isArray(row.windows)) {
|
||
const windows = row.windows.slice(0, 6).map((item) => {
|
||
const window = record(item);
|
||
const compact: { [key: string]: ModelOutputValue } = {};
|
||
for (const key of ["transit_planet", "natal_point", "aspect", "start_date", "end_date", "min_orb"] as const) {
|
||
const scalar = boundedPrimitive(window[key]);
|
||
if (scalar !== undefined) compact[key] = scalar;
|
||
}
|
||
return compact;
|
||
}).filter((item) => Object.keys(item).length);
|
||
if (windows.length) projected.windows = windows;
|
||
}
|
||
return Object.keys(projected).length ? projected : undefined;
|
||
}
|
||
|
||
function projectWesternSpectrum(value: unknown): ModelOutputValue | undefined {
|
||
const spectrum = record(value);
|
||
if (!Object.keys(spectrum).length) return undefined;
|
||
const output: { [key: string]: ModelOutputValue } = {};
|
||
for (const key of ["status", "zodiac", "house_system", "boundary"] as const) {
|
||
const projected = boundedPrimitive(spectrum[key]);
|
||
if (projected !== undefined) output[key] = projected;
|
||
}
|
||
const natal = record(spectrum.natal);
|
||
if (Object.keys(natal).length) {
|
||
const compact: { [key: string]: ModelOutputValue } = {};
|
||
for (const [key, item] of Object.entries(natal)) {
|
||
const projected = boundedPrimitive(item);
|
||
if (projected !== undefined) compact[key] = projected;
|
||
}
|
||
if (Object.keys(compact).length) output.natal = compact;
|
||
}
|
||
const techniques = record(spectrum.techniques);
|
||
if (Object.keys(techniques).length) {
|
||
const compact: { [key: string]: ModelOutputValue } = {};
|
||
for (const [name, layer] of Object.entries(techniques)) {
|
||
const projected = projectWesternTechnique(layer);
|
||
if (projected !== undefined) compact[name] = projected;
|
||
}
|
||
if (Object.keys(compact).length) output.techniques = compact;
|
||
}
|
||
return Object.keys(output).length ? output : undefined;
|
||
}
|
||
|
||
/*
|
||
* Evidence card v2 (TASK-consult-evidence-card-v2-20260927 T2): the native
|
||
* technique layers the engine now attaches (chart.consultation_native_layers,
|
||
* scripts/consultation_native_layers.py). Each layer has its own allowlist of
|
||
* nested keys, as BUG-1054 did for the Narayana period, so the existing natal
|
||
* and timing sets are not widened; depth and list caps are unchanged.
|
||
*/
|
||
const nativeD9SummaryKeys = new Set([
|
||
"status", "source", "lagna", "lagnavargottama", "planets", "d9sign", "d9dignity", "vargottama",
|
||
"reversals", "planet", "d1", "d9", "reversalrule",
|
||
]);
|
||
const nativePunarphooKeys = new Set([
|
||
"status", "source", "natalpresent", "orbdeg", "orblimitdeg", "samehouse", "saturnreturntonatalmoon",
|
||
"active", "claimboundary",
|
||
]);
|
||
const nativeVivahSahamKeys = new Set([
|
||
"status", "source", "longitude", "sign", "degreeinsign", "house", "natalconjuncts", "planet", "diffdeg", "boundary",
|
||
]);
|
||
const nativeDayNightKeys = new Set(["status", "source", "isday"]);
|
||
// BUG-1154: whole-sign graha drishti and combustion (build_graha_drishti).
|
||
const nativeGrahaDrishtiKeys = new Set([
|
||
"status", "reason", "source", "rule", "planets", "houses", "aspectshouses", "aspectedby", "combust",
|
||
]);
|
||
// BUG-1157: each yoga's domain tag (the Raman packet that governs it) and the sieve's hit.
|
||
const nativeYogaDomainKeys = new Set(["status", "reason", "source", "rows", "name", "hit", "domain"]);
|
||
// BUG-1175: closed-list classical yogas found formed; the card writes the label itself.
|
||
const nativeTraditionalYogaKeys = new Set(["status", "reason", "source", "names"]);
|
||
const nativeSlowTransitKeys = new Set([
|
||
"status", "source", "referencedate", "window", "start", "end", "current", "sign", "degreeinsign",
|
||
"retrograde", "natalhouse", "sav", "bav", "keydates", "kind", "date", "body", "fromsign", "tosign",
|
||
"tonatalhouse", "motion", "housesinwindow", "boundary",
|
||
// BUG-1156: transit Saturn's house counted from the natal Moon.
|
||
"saturnfrommoonhouse",
|
||
]);
|
||
const nativeDoubleTransitKeys = new Set([
|
||
"status", "source", "date", "activehouses", "conclusions", "house", "layer", "target", "strength",
|
||
"jupiterpac", "saturnpac", "conclusioncount", "karakatargets", "dk", "ul", "doubletransit", "method", "boundary",
|
||
]);
|
||
const nativeAnnualKeys = new Set([
|
||
"status", "reason", "source", "targetyear", "age", "varshayear", "start", "end", "annuallagna", "varshesha",
|
||
"muntha", "sign", "lord", "houseinannualchart", "houseinnatalchart", "muddadasha", "months",
|
||
"tajikaaspects", "name", "planets", "aspect", "motion", "tajikaaspectsstatus", "producers",
|
||
"solarreturn", "annualchart", "yearlord", "tajikayogas", "boundary",
|
||
]);
|
||
|
||
function nativeLayer(context: ReturnType<typeof toAgentConsultationContext>, name: string) {
|
||
return record(record(context.local_layers.native_layers)[name]);
|
||
}
|
||
|
||
function projectNativeLayers(
|
||
context: ReturnType<typeof toAgentConsultationContext>,
|
||
layers: ReadonlyArray<readonly [string, ReadonlySet<string>]>,
|
||
) {
|
||
const output: { [key: string]: ModelOutputValue } = {};
|
||
for (const [name, keys] of layers) {
|
||
const layer = nativeLayer(context, name);
|
||
if (!Object.keys(layer).length) continue;
|
||
const projected = projectAllowlistedTree(layer, keys, { allowAstrologyEntityKeys: true });
|
||
if (projected !== undefined) output[name] = projected;
|
||
}
|
||
return output;
|
||
}
|
||
|
||
function projectNatalFoundation(context: ReturnType<typeof toAgentConsultationContext>) {
|
||
const foundation = projectAllowlistedTree({
|
||
ascendant: context.chart.ascendant,
|
||
planets: context.chart.planets,
|
||
houses: context.chart.houses,
|
||
shadbala: context.chart.shadbala,
|
||
ashtakavarga: context.chart.ashtakavarga,
|
||
functional_benefic_malefic: context.local_layers.functional_benefic_malefic,
|
||
yogas: context.local_layers.yogas,
|
||
arudha_padas: context.local_layers.arudha_padas,
|
||
gulika: context.local_layers.gulika,
|
||
kakshya: context.local_layers.kakshya,
|
||
kp_cusps: context.local_layers.kp_cusps,
|
||
}, natalFoundationKeys, { allowAstrologyEntityKeys: true });
|
||
const output = record(foundation);
|
||
const varga = projectVargaSpectrum(context.local_layers.varga_spectrum);
|
||
if (varga !== undefined) output.varga_spectrum = varga;
|
||
Object.assign(output, projectNativeLayers(context, [
|
||
["d9_summary", nativeD9SummaryKeys],
|
||
["punarphoo", nativePunarphooKeys],
|
||
["vivah_saham", nativeVivahSahamKeys],
|
||
["day_night", nativeDayNightKeys],
|
||
["graha_drishti", nativeGrahaDrishtiKeys],
|
||
["yoga_domains", nativeYogaDomainKeys],
|
||
["traditional_yogas", nativeTraditionalYogaKeys],
|
||
]));
|
||
return Object.keys(output).length ? output : undefined;
|
||
}
|
||
|
||
function projectDomainEvidence(value: unknown): ModelOutputValue {
|
||
const theme = record(value);
|
||
const output: { [key: string]: ModelOutputValue } = {};
|
||
for (const key of ["theme", "summary", "narrative", "strength"] as const) {
|
||
const projected = boundedPrimitive(theme[key]);
|
||
if (projected !== undefined) output[key] = projected;
|
||
}
|
||
if (Array.isArray(theme.recommendations)) {
|
||
output.recommendations = theme.recommendations.slice(0, 12)
|
||
.map(boundedPrimitive)
|
||
.filter((item): item is ModelOutputValue => item !== undefined);
|
||
}
|
||
if (Array.isArray(theme.evidence)) {
|
||
output.evidence = theme.evidence.slice(0, 24).map((rawItem) => {
|
||
const item = record(rawItem);
|
||
const projected: { [key: string]: ModelOutputValue } = {};
|
||
for (const key of ["technique", "chart", "conclusion", "sentiment", "strength"] as const) {
|
||
const scalar = boundedPrimitive(item[key]);
|
||
if (scalar !== undefined) projected[key] = scalar;
|
||
}
|
||
const details = projectAllowlistedTree(item.details, domainDetailsKeys, { allowAstrologyEntityKeys: true });
|
||
if (details !== undefined) projected.details = details;
|
||
return projected;
|
||
});
|
||
}
|
||
if (Array.isArray(theme.conflicts)) {
|
||
const conflictKeys = new Set(["techniquea", "conclusiona", "techniqueb", "conclusionb", "resolution", "reasoning", "winner"]);
|
||
output.conflicts = theme.conflicts.slice(0, 12)
|
||
.map((item) => projectAllowlistedTree(item, conflictKeys))
|
||
.filter((item): item is ModelOutputValue => item !== undefined);
|
||
}
|
||
const timing = projectAllowlistedTree(theme.timing, timingKeys, { allowAstrologyEntityKeys: true });
|
||
if (timing !== undefined) output.timing = timing;
|
||
return output;
|
||
}
|
||
|
||
function projectTimingEvidence(context: ReturnType<typeof toAgentConsultationContext>) {
|
||
const timing = projectAllowlistedTree({
|
||
dasha: context.chart.dasha,
|
||
dasha_sub_periods: context.local_layers.dasha_sub_periods,
|
||
narayana_dasha: context.local_layers.narayana_dasha,
|
||
chara_dasha: context.local_layers.chara_dasha,
|
||
transits: context.local_layers.transits,
|
||
}, timingKeys, { allowAstrologyEntityKeys: true });
|
||
const output = record(timing);
|
||
const western = projectWesternSpectrum(context.evidence_contract.western_spectrum);
|
||
if (western !== undefined) output.western_spectrum = western;
|
||
Object.assign(output, projectNativeLayers(context, [
|
||
["slow_transits", nativeSlowTransitKeys],
|
||
["double_transit", nativeDoubleTransitKeys],
|
||
["annual_tajika", nativeAnnualKeys],
|
||
]));
|
||
return Object.keys(output).length ? output : undefined;
|
||
}
|
||
|
||
function projectValidationEvidence(context: ReturnType<typeof toAgentConsultationContext>) {
|
||
const validation = projectAllowlistedTree({
|
||
technique_truth: context.evidence_contract.technique_truth,
|
||
reference_transparency: context.reference_transparency,
|
||
shadbala_boundary: context.local_layers.shadbala_boundary,
|
||
vedastro_cross_check: context.vedastro_cross_check,
|
||
}, validationKeys, { allowAstrologyEntityKeys: true });
|
||
const output = record(validation);
|
||
const audit = projectTechniqueAudit(context.evidence_contract.technique_audit_table);
|
||
if (audit !== undefined) output.technique_audit_table = audit;
|
||
return Object.keys(output).length ? output : undefined;
|
||
}
|
||
|
||
function stringList(value: unknown, limit = 24) {
|
||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string").slice(0, limit) : [];
|
||
}
|
||
|
||
const SHARED_MUST_USE_LAYERS = [
|
||
"Formal Vargas D1–D60",
|
||
"Functional Benefic/Malefic",
|
||
"Yogas",
|
||
"Vimshottari sub-periods",
|
||
"Narayana Dasha",
|
||
"Shadbala components",
|
||
"Ashtakavarga",
|
||
"Arudha / UL / A10",
|
||
"Western natal (tropical)",
|
||
"Transits / Sade Sati",
|
||
"VedAstro Cloud State",
|
||
] as const;
|
||
|
||
const DOMAIN_MUST_USE_LAYERS: Readonly<Record<string, readonly string[]>> = {
|
||
career: ["Formal Vargas D1–D60", "Arudha / UL / A10", "Narayana Dasha", "Chara Dasha"],
|
||
marriage: ["Formal Vargas D1–D60", "Arudha / UL / A10"],
|
||
wealth: ["Formal Vargas D1–D60", "Ashtakavarga"],
|
||
timing: ["Narayana Dasha", "Vimshottari sub-periods", "Transits / Sade Sati", "Chara Dasha"],
|
||
annual: ["Narayana Dasha", "Vimshottari sub-periods", "Chara Dasha"],
|
||
health: ["Formal Vargas D1–D60", "Shadbala components"],
|
||
// D12 (parents), D7 (children) and both for the household question live in
|
||
// the formal varga set; Narayana is the second dasha track for all three.
|
||
family: ["Formal Vargas D1–D60", "Narayana Dasha"],
|
||
parents: ["Formal Vargas D1–D60", "Narayana Dasha"],
|
||
children: ["Formal Vargas D1–D60", "Narayana Dasha"],
|
||
};
|
||
|
||
function mustUseExecutedLayers(route: string, audit: unknown): string[] {
|
||
const rows = Array.isArray(audit) ? audit : [];
|
||
const executed = new Set(
|
||
rows
|
||
.map((item) => record(item))
|
||
.filter((row) => row.status === "executed")
|
||
.map((row) => String(row.technique || "")),
|
||
);
|
||
const wanted = [...SHARED_MUST_USE_LAYERS, ...(DOMAIN_MUST_USE_LAYERS[route] ?? [])];
|
||
const output: string[] = [];
|
||
const seen = new Set<string>();
|
||
for (const name of wanted) {
|
||
if (!executed.has(name) || seen.has(name)) continue;
|
||
seen.add(name);
|
||
output.push(name);
|
||
}
|
||
return output.slice(0, 24);
|
||
}
|
||
|
||
function projectEvidenceContract(
|
||
contract: ReturnType<typeof toAgentConsultationContext>["evidence_contract"],
|
||
domain?: string,
|
||
) {
|
||
const policy = record(contract.answer_policy);
|
||
const audit = projectTechniqueAudit(contract.technique_audit_table);
|
||
const varga = projectVargaSpectrum(contract.varga_spectrum);
|
||
const western = projectWesternSpectrum(contract.western_spectrum);
|
||
// The engine reports `family` for parents and children; the plan's own domain
|
||
// picks their must-use layers.
|
||
const mustUse = mustUseExecutedLayers(domain ?? String(contract.route || ""), contract.technique_audit_table);
|
||
return {
|
||
available_layers: stringList(contract.available_layers, 80),
|
||
missing_route_layers: stringList(contract.missing_route_layers, 40),
|
||
hard_blockers: stringList(contract.hard_blockers),
|
||
answer_policy: {
|
||
can_answer_direction: policy.can_answer_direction === true,
|
||
can_answer_precise_timing: policy.can_answer_precise_timing === true,
|
||
...(typeof policy.should_lead_with_limitations === "boolean"
|
||
? { should_lead_with_limitations: policy.should_lead_with_limitations }
|
||
: {}),
|
||
...(Array.isArray(policy.unreliable_vargas) && policy.unreliable_vargas.length
|
||
? { unreliable_vargas: stringList(policy.unreliable_vargas, 3), deterministic_claims_forbidden_for: stringList(policy.unreliable_vargas, 3) }
|
||
: {}),
|
||
},
|
||
...(typeof contract.user_facing_limitation === "string"
|
||
? { user_facing_limitation: boundedPrimitive(contract.user_facing_limitation) }
|
||
: {}),
|
||
...(audit !== undefined ? { technique_audit_table: audit } : {}),
|
||
...(varga !== undefined ? { varga_spectrum: varga } : {}),
|
||
...(western !== undefined ? { western_spectrum: western } : {}),
|
||
...(mustUse.length > 0 ? { must_use_layers: mustUse } : {}),
|
||
};
|
||
}
|
||
|
||
function hasModelOutputEvidence(value: unknown) {
|
||
return value !== undefined && value !== null && (typeof value !== "object" || Object.keys(value as object).length > 0);
|
||
}
|
||
|
||
/**
|
||
* Keep the complete workflow context for application/audit use, but expose only
|
||
* bounded, server-selected evidence to the model. This is not a claim generator:
|
||
* every card is a projection of an existing server result.
|
||
*/
|
||
export function toModelOutput(
|
||
context: ReturnType<typeof toAgentConsultationContext>,
|
||
plan?: ConsultationPlan,
|
||
domain?: string,
|
||
): ConsultationEvidencePacket {
|
||
const contract = context.evidence_contract;
|
||
const requiredCategories = new Set(plan?.requiredEvidenceCategories ?? consultationEvidenceCategoryValues);
|
||
const cards = [
|
||
{
|
||
category: "natal_foundation" as const,
|
||
source: "server_chart" as const,
|
||
evidence: projectNatalFoundation(context),
|
||
},
|
||
{
|
||
category: "domain" as const,
|
||
source: "server_workflow" as const,
|
||
evidence: projectDomainEvidence(context.thematic_evidence),
|
||
},
|
||
{
|
||
category: "timing" as const,
|
||
source: "server_workflow" as const,
|
||
evidence: projectTimingEvidence(context),
|
||
},
|
||
{
|
||
category: "validation" as const,
|
||
source: "server_workflow" as const,
|
||
evidence: projectValidationEvidence(context),
|
||
},
|
||
]
|
||
.filter((card) => requiredCategories.has(card.category))
|
||
.filter((card) => hasModelOutputEvidence(card.evidence));
|
||
|
||
return consultationEvidencePacketSchema.parse({
|
||
packet_version: "consultation-evidence-packet-v2",
|
||
question: context.question,
|
||
route: contract.route,
|
||
status: contract.core_status,
|
||
evidence_contract: projectEvidenceContract(contract, domain),
|
||
claim_cards: cards,
|
||
presentation: {
|
||
template: "skill_level_2",
|
||
required_blocks: [
|
||
"raw_structure",
|
||
"raman_six_step",
|
||
"yoga_table",
|
||
"timing",
|
||
"synthesis",
|
||
"technique_audit_table",
|
||
"modern_wrap",
|
||
],
|
||
},
|
||
rectification: { boundary: context.rectification.boundary },
|
||
});
|
||
}
|
||
|
||
export function toAgentConsultationContext(data: JsonRecord) {
|
||
const chart = record(data.chart);
|
||
const modules = record(chart.modules);
|
||
const routing = record(data.routing);
|
||
const thematicReport = record(data.thematic_report);
|
||
const themes = record(thematicReport.themes);
|
||
const primaryTheme = String(routing.primary_theme || routing.question_type || "general");
|
||
const selectedTheme = record(themes[primaryTheme]);
|
||
const rectification = record(data.rectification);
|
||
const consumerContext = record(data.consumer_context);
|
||
return {
|
||
success: data.success === true,
|
||
question: data.question,
|
||
routing,
|
||
consumer_context: consumerContext,
|
||
evidence_contract: {
|
||
route: consumerContext.route,
|
||
core_status: consumerContext.core_status,
|
||
available_layers: consumerContext.available_layers,
|
||
missing_route_layers: consumerContext.missing_route_layers,
|
||
hard_blockers: consumerContext.hard_blockers,
|
||
technique_truth: consumerContext.technique_truth,
|
||
commercial_evidence_status: consumerContext.commercial_evidence_status,
|
||
answer_policy: consumerContext.answer_policy,
|
||
user_facing_limitation: consumerContext.user_facing_limitation,
|
||
technique_audit_table: consumerContext.technique_audit_table,
|
||
varga_spectrum: consumerContext.varga_spectrum,
|
||
western_spectrum: consumerContext.western_spectrum,
|
||
},
|
||
chart: {
|
||
birth: chart.birth, ascendant: chart.ascendant, planets: chart.planets, houses: chart.houses,
|
||
dasha: chart.dasha, shadbala: chart.shadbala, ashtakavarga: chart.ashtakavarga, yogas: chart.yogas,
|
||
},
|
||
local_layers: {
|
||
shadbala_boundary: "Shadbala is a locally consistent relative-strength layer; external component-level absolute parity remains partial and must not be stated as closed.",
|
||
varga_full: modules.varga_full, varga_spectrum: modules.varga_spectrum, arudha_padas: modules.arudha_padas, ashtakavarga: modules.ashtakavarga,
|
||
// `modules.dasha_boundaries` was read here for months and never written by the engine, so
|
||
// every answer was composed without sub-period boundaries while the receipt still reported
|
||
// precise timing as allowed. The field now names the layer the engine actually attaches.
|
||
dasha_sub_periods: modules.dasha_sub_periods, narayana_dasha: modules.narayana_dasha, gulika: modules.gulika,
|
||
kakshya: modules.kakshya,
|
||
// Chara karakas for the evidence card (AmK / DK / PK). Not projected to
|
||
// the model packet; the card copies the three it needs.
|
||
jaimini: modules.jaimini,
|
||
yogas: modules.yogas || chart.yogas,
|
||
kp_cusps: modules.kp_cusps,
|
||
chara_dasha: modules.chara_dasha,
|
||
transits: modules.transits,
|
||
functional_benefic_malefic: modules.functional_benefic_malefic
|
||
|| record(data.machine_evidence_packet).functional_benefic_malefic,
|
||
// Evidence card v2: native technique layers (D9 summary, Punarphoo,
|
||
// Double Transit, slow transits, annual Tajika pack, lookup-only layers).
|
||
native_layers: chart.consultation_native_layers,
|
||
},
|
||
rectification: {
|
||
boundary: rectificationBoundaryFromGate(rectification),
|
||
summary: rectification.summary,
|
||
enabled_vargas: rectification.enabled_vargas,
|
||
lagna_boundary: rectification.lagna_boundary,
|
||
},
|
||
candidate_range: record(data.candidate_range),
|
||
range_boundary_contexts: record(data.range_boundary_contexts),
|
||
thematic_evidence: selectedTheme,
|
||
vedastro_gateway: record(data.vedastro_gateway),
|
||
vedastro_cross_check: record(consumerContext.vedastro_cross_check),
|
||
external_engine_evidence: {
|
||
runtime_truth: record(data.runtime_truth), numerical_parity: record(data.external_parity_gate),
|
||
real_case_calibration: record(data.real_case_calibration),
|
||
},
|
||
reference_transparency: record(data.reference_transparency),
|
||
};
|
||
}
|