Files
Jyotisha/frontend/src/mastra/consultation-workflow.ts
T
Jesse_ChenandClaude Opus 5.5 c74bfcc06b feat(consult): rule engine always runs and merges with chart yogas (BUG-1174); closed-list traditional_yogas on general/career/wealth cards (BUG-1175)
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
2026-10-02 08:32:11 +08:00

893 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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),
};
}