diff --git a/frontend/src/lib/consultation-evidence-card.ts b/frontend/src/lib/consultation-evidence-card.ts new file mode 100644 index 00000000..ac050087 --- /dev/null +++ b/frontend/src/lib/consultation-evidence-card.ts @@ -0,0 +1,552 @@ +import type { ConsultationDomain } from "./consultation-domain-registry.ts"; +import { consultationDomainDefinition } from "./consultation-domain-registry.ts"; + +/** + * The evidence card: what the answer-writing model reads about the chart + * (TASK-consult-evidence-card-20260927, D1). + * + * The consultation still calculates the full spectrum (D6); the full result + * stays on the server for receipts, the 「本轮技法」 panel, reports and the + * one-shot evidence lookup. The card is a base section (ascendant, twelve + * house signs, planet placements, functional benefics/malefics with house + * lordship, the running Vimshottari MD/AD/PD with dates, the running Narayana + * period) plus one section per domain, cut to that domain's vargas, houses, + * planets and layers. + * + * Every value is copied from the model projection (`toModelOutput`) or, for + * the three layers the projection does not carry (chart Shadbala summary, + * Ashtakavarga house scores, chara karakas), from the engine's own values in + * the agent context. Nothing is rewritten, rounded, converted or translated; + * what is missing is listed in `gaps`, never filled in. + * + * Pure functions, no React. + */ + +export const EVIDENCE_CARD_VERSION = "evidence-card-v1" as const; + +/** D4: the one line the card carries instead of the long status layers. */ +export const EVIDENCE_CARD_BACKSTAGE_LINE = "全网资料核对、真实案例校准:未做,置信度封顶"; + +/** chars / 3.5, the research's estimate (not a vendor tokenizer). */ +export const EVIDENCE_CARD_CHARS_PER_TOKEN = 3.5; + +export type EvidenceCardDomainSpec = Readonly<{ + vargas: readonly string[]; + houses: readonly number[]; + arudha: readonly string[]; + karakas: readonly string[]; + planets: readonly string[]; + yogas: boolean; + transits: boolean; + ashtakavarga: boolean; + shadbala: boolean; + charaDasha: boolean; +}>; + +const spec = (value: Partial & Pick): EvidenceCardDomainSpec => ({ + houses: [], + arudha: [], + karakas: [], + planets: [], + yogas: false, + transits: false, + ashtakavarga: false, + shadbala: false, + charaDasha: false, + ...value, +}); + +/** + * Domain sections, ported from the research draft `CARD_SPECS` + * (scripts/research/consult_evidence_card_lib.py; docs/research/ + * consult_evidence_card_draft_2026_09_27.json) as the single source. Health + * carries D30 and education D5 as in the Python focus techniques. The draft has + * no timing / annual card: those two follow the registry and the must-use + * layers (Vimshottari + Narayana are in every base section; D1 / D9 as the + * divisional check, transits, Chara Dasha). + */ +export const EVIDENCE_CARD_SPECS: Readonly> = { + career: spec({ vargas: ["D10"], houses: [10], arudha: ["A10"], karakas: ["AmK"], yogas: true, transits: true, shadbala: true }), + marriage: spec({ vargas: ["D9"], houses: [7], arudha: ["UL", "A7"], karakas: ["DK"], planets: ["Venus", "Jupiter"], transits: true }), + wealth: spec({ vargas: ["D2", "D11"], houses: [2, 11, 9, 5], yogas: true, ashtakavarga: true, shadbala: true }), + health: spec({ vargas: ["D6", "D8", "D30"], houses: [6, 8], transits: true, shadbala: true }), + education: spec({ vargas: ["D5", "D24"], houses: [5, 9], planets: ["Mercury", "Jupiter"] }), + migration: spec({ vargas: ["D4", "D12"], houses: [4, 12] }), + family: spec({ vargas: ["D7", "D12"], houses: [4, 5, 9] }), + parents: spec({ vargas: ["D12"], houses: [4, 9], planets: ["Sun", "Moon"] }), + children: spec({ vargas: ["D7"], houses: [5], karakas: ["PK"], planets: ["Jupiter"] }), + annual: spec({ vargas: ["D1"], houses: [1], transits: true, charaDasha: true }), + timing: spec({ vargas: ["D1", "D9"], planets: ["Saturn", "Jupiter"], transits: true, charaDasha: true }), + general: spec({ vargas: ["D9", "D10", "D2"], houses: [1, 10, 7, 2], arudha: ["A10"], yogas: true, transits: true, ashtakavarga: true, shadbala: true }), +}; + +/** The formal vargas the engine materialises (varga_spectrum.formal). */ +export const FORMAL_VARGA_CODES = [ + "D1", "D2", "D3", "D4", "D5", "D6", "D7", "D8", "D9", "D10", + "D11", "D12", "D16", "D20", "D24", "D27", "D30", "D40", "D45", "D60", +] as const; + +export const WESTERN_TECHNIQUE_IDS = [ + "transits", "solar_return", "secondary_progressions", "solar_arc_directions", + "converse_secondary_progressions", "converse_solar_arc_directions", "midpoints", + "lunar_return", "transit_duration_scan", "parans", +] as const; + +/** + * Sections outside the card that the one-shot lookup tool may return from the + * request's own calculation (D8). A closed list, so the model cannot ask for + * anything the server did not compute. + */ +export const EVIDENCE_LOOKUP_SECTIONS = [ + "varga:D1", "varga:D2", "varga:D3", "varga:D4", "varga:D5", "varga:D6", "varga:D7", + "varga:D8", "varga:D9", "varga:D10", "varga:D11", "varga:D12", "varga:D16", "varga:D20", + "varga:D24", "varga:D27", "varga:D30", "varga:D40", "varga:D45", "varga:D60", + "varga:research_dn", + "varga:extended", + "western:natal", + "western:transits", "western:solar_return", "western:secondary_progressions", + "western:solar_arc_directions", "western:converse_secondary_progressions", + "western:converse_solar_arc_directions", "western:midpoints", "western:lunar_return", + "western:transit_duration_scan", "western:parans", + "yogas", + "ashtakavarga", + "shadbala", + "transits", + "chara_dasha", + "arudha_padas", + "chara_karakas", + "kp_cusps", + "gulika", + "kakshya", + "vimshottari_mahadashas", + "domain_thematic_evidence", +] as const satisfies readonly ( + | `varga:${typeof FORMAL_VARGA_CODES[number]}` + | "varga:research_dn" + | "varga:extended" + | "western:natal" + | `western:${typeof WESTERN_TECHNIQUE_IDS[number]}` + | "yogas" | "ashtakavarga" | "shadbala" | "transits" | "chara_dasha" | "arudha_padas" + | "chara_karakas" | "kp_cusps" | "gulika" | "kakshya" | "vimshottari_mahadashas" + | "domain_thematic_evidence" +)[]; + +export type EvidenceLookupSection = typeof EVIDENCE_LOOKUP_SECTIONS[number]; + +type Json = string | number | boolean | null | Json[] | { [key: string]: Json }; +type JsonRecord = { [key: string]: Json }; + +export type EvidenceCardDomainSection = { + label: string; + vargas: JsonRecord; + houses: JsonRecord; + focus_planets?: string[]; + arudha?: JsonRecord; + karakas?: JsonRecord; + yogas?: JsonRecord; + transits?: Json; + ashtakavarga?: JsonRecord; + shadbala?: Json; + chara_dasha?: Json; + /** + * Present only when this domain's own base differs from the card's base + * (the first domain's). The same chart gives the same base; a difference is + * shown per domain instead of one side being presented as shared. + */ + base?: EvidenceCard["base"]; +}; + +export type EvidenceCard = { + card_version: typeof EVIDENCE_CARD_VERSION; + domains: ConsultationDomain[]; + base: { + natal: { + ascendant: Json; + houses: JsonRecord; + planets: JsonRecord; + functional_benefic_malefic: JsonRecord; + }; + timing: { + vimshottari: JsonRecord; + narayana: JsonRecord; + }; + }; + domain_sections: Partial>; + meta: { + backstage: typeof EVIDENCE_CARD_BACKSTAGE_LINE; + supplementable_sections: EvidenceLookupSection[]; + gaps: string[]; + }; +}; + +/** One domain's inputs: the projected packet and the agent context behind it. */ +export type EvidenceCardSource = Readonly<{ + domain: ConsultationDomain; + /** `toModelOutput(...)` for this domain. */ + packet: unknown; + /** `toAgentConsultationContext(...)` for this domain (engine values). */ + context: unknown; +}>; + +function record(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function isJson(value: unknown): value is Json { + if (value === null || typeof value === "string" || typeof value === "boolean") return true; + if (typeof value === "number") return Number.isFinite(value); + if (Array.isArray(value)) return value.every(isJson); + if (typeof value === "object") return Object.values(value as object).every(isJson); + return false; +} + +/** A JSON value copied as is, or undefined. Never reshaped. */ +function copy(value: unknown): Json | undefined { + return value !== undefined && isJson(value) ? structuredClone(value) : undefined; +} + +function pickKeys(source: unknown, keys: readonly string[]): JsonRecord { + const from = record(source); + const out: JsonRecord = {}; + for (const key of keys) { + const value = copy(from[key]); + if (value !== undefined) out[key] = value; + } + return out; +} + +function caseInsensitive(source: unknown, name: string): [string, unknown] | undefined { + const lowered = name.toLowerCase(); + return Object.entries(record(source)).find(([key]) => key.toLowerCase() === lowered); +} + +function claimEvidence(packet: unknown, category: string): Record { + const cards = record(packet).claim_cards; + if (!Array.isArray(cards)) return {}; + const card = cards.find((item) => record(item).category === category); + return record(record(card).evidence); +} + +const PLANET_ROW_KEYS = ["sign", "house", "degree_in_sign", "nakshatra", "retrograde", "status"] as const; +const FUNCTIONAL_KEYS = [ + "functional_benefics", "functional_malefics", "functional_neutrals", "yogakarakas", "owned_houses", +] as const; +const PERIOD_KEYS = ["lord", "start", "end"] as const; +const NARAYANA_PERIOD_KEYS = ["sign", "lord", "years", "start_age", "end_age"] as const; +const KARAKA_KEYS = ["planet", "degree_in_sign", "rank"] as const; +const ARUDHA_KEYS = ["sign", "lord"] as const; + +function nonEmpty(value: JsonRecord) { + return Object.keys(value).length > 0; +} + +function baseNatal(natal: Record, gaps: string[]) { + const ascendant = copy(natal.ascendant); + if (ascendant === undefined) gaps.push("base.ascendant"); + const houses: JsonRecord = {}; + for (const [number, row] of Object.entries(record(natal.houses))) { + const sign = record(row).sign; + if (typeof sign === "string") houses[number] = sign; + } + if (!nonEmpty(houses)) gaps.push("base.houses"); + const planets: JsonRecord = {}; + for (const [name, row] of Object.entries(record(natal.planets))) { + const picked = pickKeys(row, PLANET_ROW_KEYS); + if (nonEmpty(picked)) planets[name] = picked; + } + if (!nonEmpty(planets)) gaps.push("base.planets"); + const functional = pickKeys(natal.functional_benefic_malefic, FUNCTIONAL_KEYS); + if (!nonEmpty(functional)) gaps.push("base.functional_benefic_malefic"); + return { ascendant: ascendant ?? null, houses, planets, functional_benefic_malefic: functional }; +} + +function baseTiming(timing: Record, gaps: string[]) { + const sub = record(timing.dasha_sub_periods); + const current = record(sub.current); + const pratyantar = record(sub.pratyantar_dasha_timeline); + const vimshottari: JsonRecord = {}; + const set = (key: string, value: unknown) => { + const picked = pickKeys(value, PERIOD_KEYS); + if (nonEmpty(picked)) vimshottari[key] = picked; + }; + set("mahadasha", current.mahadasha); + set("antardasha", current.antardasha); + set("pratyantardasha", pratyantar.current); + set("next_antardasha", sub.next); + set("next_pratyantardasha", pratyantar.next); + if (Array.isArray(sub.boundaries)) { + const rows = sub.boundaries.map((row) => pickKeys(row, PERIOD_KEYS)).filter(nonEmpty); + if (rows.length) vimshottari.antardashas_in_mahadasha = rows; + } + for (const key of ["mahadasha", "antardasha", "pratyantardasha"]) { + if (!vimshottari[key]) gaps.push(`base.vimshottari.${key}`); + } + const currentNarayana = record(record(timing.narayana_dasha).current_dasha); + const narayana: JsonRecord = {}; + for (const level of ["md", "ad", "pd"] as const) { + const picked = pickKeys(currentNarayana[level], NARAYANA_PERIOD_KEYS); + if (nonEmpty(picked)) narayana[level] = picked; + } + const remaining = copy(currentNarayana.remaining_years); + if (remaining !== undefined) narayana.remaining_years = remaining; + if (!narayana.md) gaps.push("base.narayana.md"); + return { vimshottari, narayana }; +} + +function domainSection( + domain: ConsultationDomain, + source: EvidenceCardSource, + base: EvidenceCard["base"], + gaps: string[], +): EvidenceCardDomainSection { + const rule = EVIDENCE_CARD_SPECS[domain]; + const natal = claimEvidence(source.packet, "natal_foundation"); + const timing = claimEvidence(source.packet, "timing"); + const context = record(source.context); + const localLayers = record(context.local_layers); + const chart = record(context.chart); + const formal = record(record(natal.varga_spectrum).formal); + const section: EvidenceCardDomainSection = { + label: consultationDomainDefinition(domain).label, + vargas: {}, + houses: {}, + }; + for (const code of rule.vargas) { + const chartRow = pickKeys(formal[code], ["lagna", "planets"]); + if (nonEmpty(chartRow)) section.vargas[code] = chartRow; + else gaps.push(`${domain}.varga.${code}`); + } + const owned = record(base.natal.functional_benefic_malefic.owned_houses); + for (const number of rule.houses) { + const sign = base.natal.houses[String(number)]; + if (typeof sign !== "string") { + gaps.push(`${domain}.house.${number}`); + continue; + } + // Selection only: planets the projection places in this house, and the + // planets the functional layer lists as owning it. + const occupants = Object.entries(base.natal.planets) + .filter(([, row]) => record(row).house === number) + .map(([name]) => name); + const lords = Object.entries(owned) + .filter(([, houses]) => Array.isArray(houses) && houses.includes(number)) + .map(([name]) => name); + section.houses[String(number)] = { sign, occupants, lords }; + } + if (rule.planets.length) { + const focus: string[] = []; + for (const name of rule.planets) { + const hit = caseInsensitive(base.natal.planets, name); + if (hit) focus.push(hit[0]); + else gaps.push(`${domain}.planet.${name}`); + } + section.focus_planets = focus; + } + if (rule.arudha.length) { + const padas = record(record(natal.arudha_padas).padas); + const arudha: JsonRecord = {}; + for (const code of rule.arudha) { + const picked = pickKeys(padas[code], ARUDHA_KEYS); + if (nonEmpty(picked)) arudha[code] = picked; + else gaps.push(`${domain}.arudha.${code}`); + } + section.arudha = arudha; + } + if (rule.karakas.length) { + const karakas: JsonRecord = {}; + const table = record(record(localLayers.jaimini).chara_karakas); + for (const code of rule.karakas) { + const picked = pickKeys(table[code], KARAKA_KEYS); + if (nonEmpty(picked)) karakas[code] = picked; + else gaps.push(`${domain}.karaka.${code}`); + } + section.karakas = karakas; + } + if (rule.yogas) { + const yogas = record(natal.yogas); + const rows = Array.isArray(yogas.yogas) ? yogas.yogas.map(record) : []; + const names = rows.map((row) => row.name).filter((name): name is string => typeof name === "string"); + const boundaries = [...new Set(rows.map((row) => row.claim_boundary).filter((value): value is string => typeof value === "string"))]; + if (names.length || typeof yogas.count === "number") { + section.yogas = { + ...(typeof yogas.count === "number" ? { count: yogas.count } : {}), + names, + ...(boundaries.length ? { claim_boundaries: boundaries } : {}), + }; + } else { + gaps.push(`${domain}.yogas`); + } + } + if (rule.transits) { + const transits = copy(timing.transits); + if (transits !== undefined) section.transits = transits; + else gaps.push(`${domain}.transits`); + } + if (rule.charaDasha) { + const chara = copy(timing.chara_dasha); + if (chara !== undefined) section.chara_dasha = chara; + else gaps.push(`${domain}.chara_dasha`); + } + if (rule.ashtakavarga) { + const table = record(localLayers.ashtakavarga); + const houseScores = record(table.house_scores); + const byHouse: JsonRecord = {}; + for (const number of rule.houses) { + const row = pickKeys(houseScores[`house_${number}`], ["sign", "sav_score", "level"]); + if (nonEmpty(row)) byHouse[String(number)] = row; + } + const strongest = copy(table.strongest_signs); + const weakest = copy(table.weakest_signs); + if (nonEmpty(byHouse) || strongest !== undefined) { + section.ashtakavarga = { + sav_by_house: byHouse, + ...(strongest !== undefined ? { strongest_signs: strongest } : {}), + ...(weakest !== undefined ? { weakest_signs: weakest } : {}), + }; + } else { + gaps.push(`${domain}.ashtakavarga`); + } + } + if (rule.shadbala) { + const shadbala = copy(chart.shadbala); + if (shadbala !== undefined && shadbala !== null) section.shadbala = shadbala; + else gaps.push(`${domain}.shadbala`); + } + return section; +} + +function onCard(card: Pick, section: EvidenceLookupSection) { + const sections = Object.values(card.domain_sections); + if (section.startsWith("varga:")) { + const code = section.slice("varga:".length); + return sections.some((item) => item && code in item.vargas); + } + const key = ({ + yogas: "yogas", + ashtakavarga: "ashtakavarga", + shadbala: "shadbala", + transits: "transits", + chara_dasha: "chara_dasha", + } as const)[section as "yogas"]; + return key ? sections.some((item) => item && item[key] !== undefined) : false; +} + +/** + * Build the card for up to two domains (the existing per-turn cap). The base + * section is taken once, from the first domain's sources; it is the same chart. + */ +export function buildEvidenceCard(sources: readonly EvidenceCardSource[]): EvidenceCard { + if (sources.length === 0) throw new Error("evidence_card_needs_a_domain"); + const gaps: string[] = []; + const first = sources[0]!; + const base = { + natal: baseNatal(claimEvidence(first.packet, "natal_foundation"), gaps), + timing: baseTiming(claimEvidence(first.packet, "timing"), gaps), + }; + const domainSections: EvidenceCard["domain_sections"] = {}; + const domains: ConsultationDomain[] = []; + for (const source of sources) { + if (domains.includes(source.domain)) continue; + domains.push(source.domain); + let own = base; + if (source !== first) { + const ownGaps: string[] = []; + const candidate = { + natal: baseNatal(claimEvidence(source.packet, "natal_foundation"), ownGaps), + timing: baseTiming(claimEvidence(source.packet, "timing"), ownGaps), + }; + if (JSON.stringify(candidate) !== JSON.stringify(base)) { + own = candidate; + gaps.push(`${source.domain}.base_differs`, ...ownGaps.map((gap) => `${source.domain}.${gap}`)); + } + } + const section = domainSection(source.domain, source, own, gaps); + if (own !== base) section.base = own; + domainSections[source.domain] = section; + } + const card: EvidenceCard = { + card_version: EVIDENCE_CARD_VERSION, + domains, + base, + domain_sections: domainSections, + meta: { + backstage: EVIDENCE_CARD_BACKSTAGE_LINE, + supplementable_sections: [], + gaps, + }, + }; + card.meta.supplementable_sections = EVIDENCE_LOOKUP_SECTIONS.filter((section) => !onCard(card, section)); + return card; +} + +export function evidenceCardTokenEstimate(chars: number) { + return Math.round(chars / EVIDENCE_CARD_CHARS_PER_TOKEN); +} + +type FieldValue = string | number | boolean | null; + +/** + * The card's scalar leaves by field id. A field id is the card path with the + * domain name dropped (`base.planets.Moon.sign`, `domain.vargas.D12.lagna`), + * so ids aggregate across domains; array positions are dropped as well. + */ +export function evidenceCardFieldValues(card: EvidenceCard): Map { + const out = new Map(); + const walk = (value: unknown, path: string) => { + if (value === null || typeof value !== "object") { + if (value === undefined) return; + const list = out.get(path) ?? []; + list.push(value as FieldValue); + out.set(path, list); + return; + } + if (Array.isArray(value)) { + for (const item of value) walk(item, path); + return; + } + for (const [key, item] of Object.entries(value)) walk(item, path ? `${path}.${key}` : key); + }; + walk(card.base, "base"); + for (const section of Object.values(card.domain_sections)) { + if (!section) continue; + const { label: _label, ...rest } = section; + void _label; + walk(rest, "domain"); + } + return out; +} + +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; +const PLANET_NAMES = new Set(["sun", "moon", "mars", "mercury", "jupiter", "venus", "saturn", "rahu", "ketu"]); + +/** + * Which card fields the answer quoted (research R5). A field counts only when + * its value is an ISO date, a degree (a number with a fractional part) or a + * planet-in-sign phrase of at least 8 characters (`Moon in Pisces`), and that + * text appears verbatim in the answer. Bare planet or sign names do not count. + * Only field ids are returned; the matched text is not kept. + */ +export function citedEvidenceCardFields(card: EvidenceCard, answer: string): string[] { + if (!answer) return []; + const cited = new Set(); + for (const [fieldId, values] of evidenceCardFieldValues(card)) { + for (const value of values) { + if (typeof value === "string" && ISO_DATE.test(value) && answer.includes(value)) { + cited.add(fieldId); + } else if (typeof value === "number" && !Number.isInteger(value) && answer.includes(String(value))) { + cited.add(fieldId); + } else if (typeof value === "string" && fieldId.endsWith(".sign")) { + const parts = fieldId.split("."); + const planet = parts[parts.length - 2] ?? ""; + const phrase = `${planet} in ${value}`; + if (PLANET_NAMES.has(planet.toLowerCase()) && phrase.length >= 8 && answer.includes(phrase)) cited.add(fieldId); + } else if (typeof value === "string" && /\.planets\.[A-Za-z]+$/.test(fieldId)) { + // Varga planets map a planet straight to its sign. + const planet = fieldId.slice(fieldId.lastIndexOf(".") + 1); + const phrase = `${planet} in ${value}`; + if (PLANET_NAMES.has(planet.toLowerCase()) && phrase.length >= 8 && answer.includes(phrase)) cited.add(fieldId); + } + } + } + return [...cited].sort().slice(0, 64); +} diff --git a/frontend/src/mastra/consultation-tools.ts b/frontend/src/mastra/consultation-tools.ts index 9a008679..712e6d58 100644 --- a/frontend/src/mastra/consultation-tools.ts +++ b/frontend/src/mastra/consultation-tools.ts @@ -17,6 +17,7 @@ import { normalizeTechniqueAuditRows } from "../lib/consultation-technique-audit import type { AgentModelFinishReason } from "../lib/agent-observability.ts"; import { agentGenerationSettings, AGENT_SLICE_ANSWER_OUTPUT_TOKENS, AGENT_SLICE_THINKING_OUTPUT_TOKENS } from "../lib/agent-generation-settings.ts"; import { chartCalculationProgressLabel } from "../lib/consultation-activity-labels.ts"; +import { buildEvidenceCard, type EvidenceCard } from "../lib/consultation-evidence-card.ts"; import { pinsConsultationDomains, type ConsultationEntrypoint } from "../lib/consultation-entrypoint.ts"; import { dailyConsultationThinkingPlan, @@ -266,6 +267,15 @@ export type ConsultationRuntimeState = { composeFinishReason?: AgentModelFinishReason | "missing"; composeAborted?: boolean; answerVisibleChars?: number; + /** + * The evidence card the answer model was given (T3). Server-side only: the + * telemetry (T6) reads its size and field ids, never logs its values. + */ + evidenceCard?: EvidenceCard; + /** Characters of the card's model-visible JSON (claim cards + card meta). */ + evidenceCardChars?: number; + /** Characters of the whole tool result the model saw. */ + modelVisibleChars?: number; }; export function createConsultationRuntimeState(options: { plannedSteps?: number; reservedValidationSteps?: number } = {}): ConsultationRuntimeState { @@ -730,7 +740,11 @@ function toModelDomainPlanContext( ...(methodology ? { methodology } : {}), }; if (consultations.length === 1 && omittedDomains.length === 0) { - return { ...consultations[0], ...plan, consultations }; + // Server-side full context. The single-domain result used to repeat the + // whole packet under `consultations` (about half of what the model read); + // the model now reads the evidence card view instead, and nothing + // server-side reads the copy. + return { ...consultations[0], ...plan }; } const hoisted = consultations.length === 1 ? { sharedClaimCards: consultations[0].claim_cards, consultations } @@ -743,11 +757,99 @@ function toModelDomainPlanContext( return { ...merged, ...plan, consultations: hoisted.consultations }; } +type FullDomainPlanContext = ReturnType; + +export const CONSULTATION_MODEL_PACKET_VERSION = "consultation-evidence-packet-v3" as const; + +/** + * The answer contract the system prompt states its rules against (red line 1): + * status, evidence_contract (available / missing layers, hard blockers, + * answer_policy incl. can_answer_direction / can_answer_precise_timing / + * should_lead_with_limitations, user_facing_limitation), claim_cards and + * rectification, plus the plan fields the prompt names (methodology, domains, + * omitted_domains, consultations for several domains). The audit table, the + * varga and western spectra, must_use_layers and presentation stay + * server-side (D3). + */ +function modelAnswerContract(contract: unknown) { + const source = evidenceRecord(contract); + return { + available_layers: Array.isArray(source.available_layers) ? source.available_layers : [], + missing_route_layers: Array.isArray(source.missing_route_layers) ? source.missing_route_layers : [], + hard_blockers: Array.isArray(source.hard_blockers) ? source.hard_blockers : [], + answer_policy: evidenceRecord(source.answer_policy), + ...(typeof source.user_facing_limitation === "string" + ? { user_facing_limitation: source.user_facing_limitation } + : {}), + }; +} + +/** The card as the model sees it: its facts as the claim cards, plus its meta. */ +function evidenceCardClaimCards(card: EvidenceCard) { + return [ + { category: "natal_foundation" as const, source: "server_chart" as const, evidence: card.base.natal }, + { category: "timing" as const, source: "server_workflow" as const, evidence: card.base.timing }, + { category: "domain" as const, source: "server_workflow" as const, evidence: card.domain_sections }, + ]; +} + +function evidenceCardMeta(card: EvidenceCard) { + return { + card_version: card.card_version, + domains: card.domains, + backstage: card.meta.backstage, + supplementable_sections: card.meta.supplementable_sections, + ...(card.meta.gaps.length ? { gaps: card.meta.gaps } : {}), + }; +} + +/** + * What the answer-writing model reads (D1): the answer contract and the + * evidence card. The full per-domain result stays in `full` on the server. + */ +export function toModelEvidenceView(full: FullDomainPlanContext, card: EvidenceCard) { + const consultations = "consultations" in full && Array.isArray(full.consultations) ? full.consultations : null; + return { + packet_version: CONSULTATION_MODEL_PACKET_VERSION, + ...(typeof full.question === "string" ? { question: full.question } : {}), + route: full.route, + status: full.status, + evidence_contract: modelAnswerContract(full.evidence_contract), + claim_cards: evidenceCardClaimCards(card), + evidence_card: evidenceCardMeta(card), + // `presentation.required_blocks` (raw_structure … technique_audit_table) + // stays server-side for the thinking plan: the answer shape travels with + // the user turn (BUG-1053) and the audit table is never written in the body + // (BUG-298 / BUG-943). + rectification: full.rectification, + success: full.success, + domains: full.domains, + omitted_domains: full.omitted_domains, + ...(full.methodology ? { methodology: full.methodology } : {}), + // Several domains: the top level is the most restrictive merge; each + // domain's own contract is kept for per-domain detail. Its chart facts are + // in the one card, not repeated here. + ...(consultations + ? { + consultations: consultations.map((consultation) => ({ + domain: consultation.domain, + route: consultation.route, + status: consultation.status, + evidence_contract: modelAnswerContract(consultation.evidence_contract), + rectification: consultation.rectification, + })), + } + : {}), + }; +} + +export type ConsultationModelEvidenceView = ReturnType; + export function createConsultationTools(ctx: ConsultationAgentContext) { - let calculation: Promise> | null = null; + let calculation: Promise | null = null; const consultationTool = createTool({ id: "run-jyotish-consultation", - description: `Run one server-validated plan of personal Jyotish consultation domains. Send only question and domains. The single ordered domains array is the only way to select domains: list every domain the question needs, in priority order, up to ${MAX_CONSULTATION_DOMAIN_PLAN_VALUES}. Do not drop a relevant domain to shorten the plan. Use only the ids enumerated in the schema; workflow or checklist names from the skill's methodology are not domain ids. Questions about one's parents (父母) use parents and questions about one's children (子女) use children; family is only for the household as a whole (家里、家庭氛围). Domains execute one after another and each costs about ${Math.round(CONSULTATION_DOMAIN_DURATION_MS / 1000)}s of the run's wall clock; the server executes as many as that clock can pay for (about ${MAX_CONSULTATION_DOMAINS}) and returns the rest in omitted_domains. Birth data is server-bound and must never be supplied. The result always carries one top-level answer contract—status, evidence_contract, claim_cards, rectification—which for several domains is the most restrictive merge of the executed ones, with per-domain detail in consultations. One calculation is executed per request and reused, so repeating the call with different parameters cannot change the result.`, + description: `Run one server-validated plan of personal Jyotish consultation domains. Send only question and domains. The single ordered domains array is the only way to select domains: list every domain the question needs, in priority order, up to ${MAX_CONSULTATION_DOMAIN_PLAN_VALUES}. Do not drop a relevant domain to shorten the plan. Use only the ids enumerated in the schema; workflow or checklist names from the skill's methodology are not domain ids. Questions about one's parents (父母) use parents and questions about one's children (子女) use children; family is only for the household as a whole (家里、家庭氛围). Domains execute one after another and each costs about ${Math.round(CONSULTATION_DOMAIN_DURATION_MS / 1000)}s of the run's wall clock; the server executes as many as that clock can pay for (about ${MAX_CONSULTATION_DOMAINS}) and returns the rest in omitted_domains. Birth data is server-bound and must never be supplied. The result always carries one top-level answer contract—status, evidence_contract, claim_cards, rectification—which for several domains is the most restrictive merge of the executed ones, with each domain's own contract in consultations. claim_cards is the evidence card: the base chart (ascendant, house signs, placements, functional benefics/malefics, the running Vimshottari and Narayana periods) and a section per domain; evidence_card lists what else this calculation holds. One calculation is executed per request and reused, so repeating the call with different parameters cannot change the result.`, inputSchema: consultationToolInputSchema, execute: async (input, context) => { const requestedDomains = canonicalDomainPlan(input, ctx); @@ -837,6 +939,18 @@ export function createConsultationTools(ctx: ConsultationAgentContext) { ctx.state.consultationToolSuccessCount += 1; appendConsultationRuntimeStep(ctx.state, { kind: "tool", name: "run-jyotish-consultation", status: "completed", durationMs: ctx.state.consultationToolDurationMs }); const modelContext = toModelDomainPlanContext(executions, omittedDomains); + const card = buildEvidenceCard(executions.map((execution) => ({ + domain: execution.domain, + packet: execution.modelOutput, + context: execution.context, + }))); + const modelView = toModelEvidenceView(modelContext, card); + ctx.state.evidenceCard = card; + ctx.state.evidenceCardChars = JSON.stringify({ + claim_cards: modelView.claim_cards, + evidence_card: modelView.evidence_card, + }).length; + ctx.state.modelVisibleChars = JSON.stringify(modelView).length; ctx.state.methodologySectionCount = modelContext.methodology?.sections.length ?? 0; ctx.state.techniqueAuditTable = normalizeTechniqueAuditRows( modelContext.evidence_contract?.technique_audit_table, @@ -850,7 +964,7 @@ export function createConsultationTools(ctx: ConsultationAgentContext) { ? modelContext.evidence_contract.must_use_layers.filter((item): item is string => typeof item === "string") : undefined, }); - return modelContext; + return modelView; } catch (error) { ctx.state.consultationToolDurationMs = now() - startedAt; appendConsultationRuntimeStep(ctx.state, { diff --git a/frontend/src/mastra/consultation-workflow.ts b/frontend/src/mastra/consultation-workflow.ts index 4ea247bf..8f9d0a4f 100644 --- a/frontend/src/mastra/consultation-workflow.ts +++ b/frontend/src/mastra/consultation-workflow.ts @@ -773,6 +773,9 @@ export function toAgentConsultationContext(data: JsonRecord) { // 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, diff --git a/frontend/tests/consult-evidence-card-20260927.test.ts b/frontend/tests/consult-evidence-card-20260927.test.ts new file mode 100644 index 00000000..7b9a1b04 --- /dev/null +++ b/frontend/tests/consult-evidence-card-20260927.test.ts @@ -0,0 +1,302 @@ +// TASK-consult-evidence-card-20260927 T3: the answer model reads the answer +// contract plus the evidence card instead of the whole projected result. +// +// Fixture: real engine consultation responses for three public AA charts +// (scripts/research/capture_consult_evidence_card_golden.py, AGENTS §7.4). +// Expected facts are read straight from the raw engine payload, so a card that +// rewrote, rounded or re-derived a value fails here. +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + buildEvidenceCard, + citedEvidenceCardFields, + EVIDENCE_CARD_BACKSTAGE_LINE, + EVIDENCE_CARD_SPECS, + EVIDENCE_CARD_VERSION, + evidenceCardFieldValues, + type EvidenceCard, +} from "../src/lib/consultation-evidence-card.ts"; +import { consultationDomainIds, type ConsultationDomain } from "../src/lib/consultation-domain-registry.ts"; +import { createConsultationPlan } from "../src/lib/consultation-plan.ts"; +import { normalizeTechniqueAuditRows } from "../src/lib/consultation-technique-audit.ts"; +import { + consultationWorkflowResponseSchema, + toAgentConsultationContext, + toModelOutput, +} from "../src/mastra/consultation-workflow.ts"; +import { + CONSULTATION_MODEL_PACKET_VERSION, + createConsultationRuntimeState, + createConsultationTools, +} from "../src/mastra/consultation-tools.ts"; + +type Json = Record; +const golden = JSON.parse(readFileSync( + new URL("./fixtures/consult-evidence-card-golden.json", import.meta.url), + "utf8", +)) as { charts: Array<{ id: string; workflow: Json }> }; + +function dig(value: unknown, ...keys: string[]): unknown { + let current = value; + for (const key of keys) { + if (!current || typeof current !== "object" || Array.isArray(current)) return undefined; + current = (current as Json)[key]; + } + return current; +} + +function sources(workflow: Json, domain: ConsultationDomain) { + const context = toAgentConsultationContext(consultationWorkflowResponseSchema.parse(structuredClone(workflow))); + const packet = toModelOutput(context, createConsultationPlan({ userIntent: "q", theme: domain }), domain); + return { domain, packet, context }; +} + +function cardFor(workflow: Json, domain: ConsultationDomain): EvidenceCard { + return buildEvidenceCard([sources(workflow, domain)]); +} + +const serverChart = { + name: "public", + toolInput: { year: 2000, month: 1, day: 1, hour: 0, minute: 0, city: "fixture", lat: 0, lon: 0, tz: 0, ayanamsa: "raman" }, + truth: { birthTimeSource: "reported" }, +}; + +async function runTool(workflow: Json, domains: ConsultationDomain[]) { + const state = createConsultationRuntimeState(); + const tools = createConsultationTools({ + userId: "u", sessionId: "s", requestId: `card-${domains.join("-")}`, consultationMode: "verified_chart", + serverChart: serverChart as never, state, + runWorkflow: async () => structuredClone(workflow) as never, + }); + const view = await tools["run-jyotish-consultation"].execute!( + { question: "测试问题", domains } as never, + { writer: { custom: async () => {} } } as never, + ) as Json; + return { view, state }; +} + +function engineFacts(workflow: Json) { + const chart = dig(workflow, "chart") as Json; + const modules = dig(chart, "modules") as Json; + const formal = dig(modules, "varga_spectrum", "formal") as Json; + return { + ascendantSign: dig(chart, "ascendant", "sign"), + ascendantDegree: dig(chart, "ascendant", "degree_in_sign"), + moonSign: dig(chart, "planets", "Moon", "sign"), + moonHouse: dig(chart, "planets", "Moon", "house"), + moonDegree: dig(chart, "planets", "Moon", "degree_in_sign"), + mahadasha: dig(modules, "dasha_sub_periods", "current", "mahadasha"), + antardasha: dig(modules, "dasha_sub_periods", "current", "antardasha"), + pratyantar: dig(modules, "dasha_sub_periods", "pratyantar_dasha_timeline", "current"), + narayanaMd: dig(modules, "narayana_dasha", "current_dasha", "md"), + functional: dig(modules, "functional_benefic_malefic") as Json, + varga: (code: string) => formal[code] as Json, + }; +} + +test("the card copies the engine's own facts verbatim (red line 2)", () => { + for (const chart of golden.charts) { + const facts = engineFacts(chart.workflow); + for (const domain of ["parents", "children", "marriage", "career"] as const) { + const card = cardFor(chart.workflow, domain); + const natal = card.base.natal as Json; + const timing = card.base.timing as Json; + const where = `${chart.id}/${domain}`; + assert.equal(dig(natal, "ascendant", "sign"), facts.ascendantSign, `${where} ascendant sign`); + assert.equal(dig(natal, "ascendant", "degree_in_sign"), facts.ascendantDegree, `${where} ascendant degree`); + assert.equal(dig(natal, "planets", "Moon", "sign"), facts.moonSign, `${where} Moon sign`); + assert.equal(dig(natal, "planets", "Moon", "house"), facts.moonHouse, `${where} Moon house`); + assert.equal(dig(natal, "planets", "Moon", "degree_in_sign"), facts.moonDegree, `${where} Moon degree`); + for (const [cardKey, engine] of [ + ["mahadasha", facts.mahadasha], + ["antardasha", facts.antardasha], + ["pratyantardasha", facts.pratyantar], + ] as const) { + for (const key of ["lord", "start", "end"] as const) { + assert.equal(dig(timing, "vimshottari", cardKey, key), dig(engine, key), `${where} ${cardKey}.${key}`); + } + } + assert.equal(dig(timing, "narayana", "md", "sign"), dig(facts.narayanaMd, "sign"), `${where} Narayana sign`); + assert.equal(dig(timing, "narayana", "md", "start_age"), dig(facts.narayanaMd, "start_age"), `${where} Narayana start age`); + assert.deepEqual(dig(natal, "functional_benefic_malefic", "functional_benefics"), facts.functional.functional_benefics, `${where} functional benefics`); + assert.deepEqual(dig(natal, "functional_benefic_malefic", "functional_malefics"), facts.functional.functional_malefics, `${where} functional malefics`); + } + const varga = (domain: ConsultationDomain, code: string) => dig(cardFor(chart.workflow, domain).domain_sections, domain, "vargas", code) as Json; + for (const [domain, code] of [["parents", "D12"], ["family", "D12"], ["migration", "D12"], ["children", "D7"], ["family", "D7"], ["marriage", "D9"], ["general", "D9"], ["career", "D10"], ["general", "D10"]] as const) { + const engine = facts.varga(code); + assert.equal(varga(domain, code)?.lagna, engine.lagna, `${chart.id}/${domain} ${code} lagna`); + assert.equal(dig(varga(domain, code), "planets", "Moon"), dig(engine, "planets", "Moon"), `${chart.id}/${domain} ${code} Moon`); + assert.deepEqual(varga(domain, code)?.planets, engine.planets, `${chart.id}/${domain} ${code} planets`); + } + } +}); + +test("every card value is a value the projection or the engine already holds (nothing rewritten)", () => { + function scalars(value: unknown, into = new Set()) { + if (value === null || typeof value !== "object") { + into.add(JSON.stringify(value)); + return into; + } + for (const [key, item] of Object.entries(value as object)) { + if (!Array.isArray(value)) into.add(JSON.stringify(key)); + scalars(item, into); + } + return into; + } + for (const chart of golden.charts) { + for (const domain of consultationDomainIds) { + const input = sources(chart.workflow, domain); + const known = scalars({ packet: input.packet, context: input.context }); + const card = buildEvidenceCard([input]); + for (const [fieldId, values] of evidenceCardFieldValues(card)) { + for (const value of values) { + assert.ok(known.has(JSON.stringify(value)), `${chart.id}/${domain} ${fieldId}=${JSON.stringify(value)} is not an engine value`); + } + } + assert.deepEqual(card.meta.gaps, [], `${chart.id}/${domain} gaps`); + } + } +}); + +test("every domain's card covers Part B: dual dasha, domain vargas, functional benefic/malefic, raw degrees and dates (red line 3)", () => { + const isoDate = /^\d{4}-\d{2}-\d{2}$/; + for (const chart of golden.charts) { + for (const domain of consultationDomainIds) { + const card = cardFor(chart.workflow, domain); + const where = `${chart.id}/${domain}`; + const timing = card.base.timing as Json; + // Dual dasha: Vimshottari MD/AD/PD with dates and the running Narayana period. + for (const level of ["mahadasha", "antardasha", "pratyantardasha"]) { + assert.match(String(dig(timing, "vimshottari", level, "start")), isoDate, `${where} ${level} start`); + assert.match(String(dig(timing, "vimshottari", level, "end")), isoDate, `${where} ${level} end`); + } + assert.equal(typeof dig(timing, "narayana", "md", "sign"), "string", `${where} Narayana md`); + // Domain vargas: every varga the domain's spec names. + const section = card.domain_sections[domain]; + assert.ok(section, `${where} domain section`); + assert.ok(EVIDENCE_CARD_SPECS[domain].vargas.length > 0, `${where} spec names a varga`); + for (const code of EVIDENCE_CARD_SPECS[domain].vargas) { + assert.equal(typeof dig(section.vargas, code, "lagna"), "string", `${where} ${code}`); + } + // Functional benefics / malefics with house lordship. + const functional = card.base.natal.functional_benefic_malefic; + assert.ok(Array.isArray(functional.functional_benefics) && functional.functional_benefics.length > 0, `${where} benefics`); + assert.ok(Array.isArray(functional.functional_malefics) && functional.functional_malefics.length > 0, `${where} malefics`); + assert.ok(Object.keys(functional.owned_houses as object).length > 0, `${where} lordship`); + // Raw degrees. + assert.equal(typeof dig(card.base.natal, "ascendant", "degree_in_sign"), "number", `${where} ascendant degree`); + assert.equal(typeof dig(card.base.natal, "planets", "Sun", "degree_in_sign"), "number", `${where} Sun degree`); + } + } +}); + +test("the model sees the answer contract and the card, not the full result (red line 1, D1, D3)", async () => { + const workflow = golden.charts[0]!.workflow; + const { view } = await runTool(workflow, ["parents"]); + const context = toAgentConsultationContext(consultationWorkflowResponseSchema.parse(structuredClone(workflow))); + const full = toModelOutput(context, createConsultationPlan({ userIntent: "q", theme: "parents" }), "parents"); + + // The keys the system prompt states its rules against, with the same values + // the full result carried. + assert.equal(view.packet_version, CONSULTATION_MODEL_PACKET_VERSION); + assert.equal(view.status, full.status); + assert.equal(view.route, full.route); + assert.deepEqual(dig(view, "evidence_contract", "answer_policy"), full.evidence_contract.answer_policy); + assert.equal(dig(view, "evidence_contract", "answer_policy", "can_answer_direction"), true); + assert.equal(typeof dig(view, "evidence_contract", "answer_policy", "can_answer_precise_timing"), "boolean"); + assert.deepEqual(dig(view, "evidence_contract", "hard_blockers"), full.evidence_contract.hard_blockers); + assert.deepEqual(dig(view, "evidence_contract", "missing_route_layers"), full.evidence_contract.missing_route_layers); + assert.deepEqual(dig(view, "evidence_contract", "available_layers"), full.evidence_contract.available_layers); + assert.equal(dig(view, "evidence_contract", "user_facing_limitation"), full.evidence_contract.user_facing_limitation); + assert.deepEqual(view.rectification, full.rectification); + assert.equal("presentation" in view, false, "required_blocks names the audit table the body must not carry"); + assert.deepEqual((view.claim_cards as Array<{ category: string }>).map((card) => card.category), ["natal_foundation", "timing", "domain"]); + assert.deepEqual(view.domains, ["parents"]); + assert.deepEqual(view.omitted_domains, []); + assert.equal(view.success, true); + assert.ok(dig(view, "methodology", "sections"), "the domain checklist still travels with the result"); + assert.equal(dig(view, "evidence_card", "card_version"), EVIDENCE_CARD_VERSION); + assert.equal(dig(view, "evidence_card", "backstage"), EVIDENCE_CARD_BACKSTAGE_LINE); + // No duplicate package, no audit table, no spectra, no status layers. + assert.equal("consultations" in view, false); + const { methodology: _methodology, ...withoutMethod } = view; + void _methodology; + const text = JSON.stringify(withoutMethod); + // As keys: available_layers may still name a layer ("varga_spectrum") as available. + for (const absent of ["technique_audit_table", "varga_spectrum", "western_spectrum", "must_use_layers", "research_dn", "extended", "technique_truth", "reference_transparency", "vedastro_cross_check", "presentation"]) { + assert.equal(text.includes(`"${absent}":`), false, absent); + } +}); + +test("a two-domain turn carries one base, both domain sections and each domain's own contract", async () => { + const workflow = golden.charts[1]!.workflow; + const { view } = await runTool(workflow, ["children", "education"]); + const claimCards = view.claim_cards as Array<{ category: string; evidence: Json }>; + assert.deepEqual(claimCards.map((card) => card.category), ["natal_foundation", "timing", "domain"]); + assert.deepEqual(Object.keys(claimCards[2]!.evidence), ["children", "education"]); + const consultations = view.consultations as Array; + assert.deepEqual(consultations.map((item) => item.domain), ["children", "education"]); + for (const item of consultations) { + assert.deepEqual(Object.keys(item).sort(), ["domain", "evidence_contract", "rectification", "route", "status"]); + assert.equal(typeof dig(item, "evidence_contract", "answer_policy", "can_answer_direction"), "boolean"); + } + assert.equal(typeof dig(view, "evidence_contract", "answer_policy", "can_answer_direction"), "boolean"); +}); + +test("the 「本轮技法」 rows are exactly the rows the full result produced before the card", async () => { + for (const chart of golden.charts) { + for (const domains of [["parents"], ["career"], ["children", "education"]] as ConsultationDomain[][]) { + const { state } = await runTool(chart.workflow, domains); + // Before the card: single domain read the domain packet's audit table; + // several domains read the first packet's (mergeConsultationEvidencePackets). + const first = sources(chart.workflow, domains[0]!).packet; + const expected = normalizeTechniqueAuditRows(first.evidence_contract.technique_audit_table); + assert.ok((expected?.length ?? 0) > 30, "the audit table is the full 40-odd rows"); + assert.deepEqual(state.techniqueAuditTable, expected, `${chart.id}/${domains.join("+")}`); + } + } +}); + +test("model-visible size falls to card scale; only the method checklist can push it past 12,000", async () => { + const methodHeavy = new Set(["career", "marriage", "wealth"]); + for (const chart of golden.charts) { + for (const domain of consultationDomainIds) { + const { view, state } = await runTool(chart.workflow, [domain]); + const total = JSON.stringify(view).length; + const { methodology, ...rest } = view; + const withoutMethod = JSON.stringify(rest).length; + assert.equal(state.modelVisibleChars, total); + assert.ok(withoutMethod <= 12_000, `${chart.id}/${domain}: contract + card ${withoutMethod}`); + if (!methodHeavy.has(domain)) assert.ok(total <= 12_000, `${chart.id}/${domain}: ${total}`); + assert.ok(total <= 18_000, `${chart.id}/${domain}: ${total} (methodology ${JSON.stringify(methodology ?? null).length})`); + } + } +}); + +test("the card names what else this calculation holds, without the sections it already carries", () => { + const card = cardFor(golden.charts[0]!.workflow, "parents"); + assert.equal(card.meta.backstage, EVIDENCE_CARD_BACKSTAGE_LINE); + assert.ok(card.meta.supplementable_sections.includes("varga:D60")); + assert.ok(card.meta.supplementable_sections.includes("western:solar_return")); + assert.ok(card.meta.supplementable_sections.includes("yogas")); + assert.equal(card.meta.supplementable_sections.includes("varga:D12"), false); + const career = cardFor(golden.charts[0]!.workflow, "career"); + assert.equal(career.meta.supplementable_sections.includes("yogas"), false); + assert.equal(career.meta.supplementable_sections.includes("varga:D10"), false); +}); + +test("citation counting keeps field ids only and needs a date, degree or planet-in-sign phrase", () => { + const card = cardFor(golden.charts[0]!.workflow, "parents"); + const start = String(dig(card.base.timing, "vimshottari", "pratyantardasha", "start")); + const moonSign = String(dig(card.base.natal, "planets", "Moon", "sign")); + const answer = `子运从 ${start} 开始;Moon in ${moonSign}。月亮是 ${moonSign}。`; + const cited = citedEvidenceCardFields(card, answer); + assert.ok(cited.includes("base.timing.vimshottari.pratyantardasha.start")); + assert.ok(cited.includes("base.natal.planets.Moon.sign")); + for (const id of cited) assert.match(id, /^[A-Za-z][A-Za-z0-9._-]*$/); + // A bare sign name is not a citation. + assert.deepEqual(citedEvidenceCardFields(card, `月亮 ${moonSign}`), []); + assert.deepEqual(citedEvidenceCardFields(card, ""), []); +}); diff --git a/frontend/tests/consultation-agentic-runtime.test.ts b/frontend/tests/consultation-agentic-runtime.test.ts index 557f9926..c774bbef 100644 --- a/frontend/tests/consultation-agentic-runtime.test.ts +++ b/frontend/tests/consultation-agentic-runtime.test.ts @@ -131,7 +131,8 @@ type PlanResult = Record & { user_facing_limitation?: string; }; rectification: { boundary: string }; - claim_cards: Array<{ category: string }>; + claim_cards: Array<{ category: string; evidence?: unknown }>; + evidence_card: { card_version: string; domains: string[]; gaps?: string[]; supplementable_sections: string[] }; }; async function runDomainPlan( @@ -358,24 +359,28 @@ test("the merged contract exposes every policy field a single domain exposes", a }); test("the identical natal projection is carried once instead of per domain", async () => { + // 原值:模型可见结果的顶层 claim_cards 只剩一张 natal_foundation,每个 consultations[i] 里带自己的 + // timing / domain 卡;两个领域本命不一致时顶层 claim_cards 为空、各自带 natal_foundation。 + // 新值:模型读数据卡。顶层 claim_cards 固定三张(本命基础段、时间基础段、领域段),基础段只出现一次; + // consultations[i] 只留各领域自己的答题契约,不再带 claim_cards。两个领域本命不一致时, + // 不一致的那个领域段自带 base,并在 evidence_card.gaps 里写明 .base_differs。 + // 原因:TASK-consult-evidence-card-20260927 T3(D1:写答案的模型只读数据卡;两领域合并一张卡,基础段只放一份)。 const { result } = await runDomainPlan(["career", "wealth", "timing"], (theme) => workflow(theme, { chart: natalChart })); - assert.deepEqual(result.claim_cards.map((card) => card.category), ["natal_foundation"]); - assert.equal( - result.consultations.every((item) => item.claim_cards.every((card) => card.category !== "natal_foundation")), - true, - ); - assert.equal(result.consultations.some((item) => item.claim_cards.length > 0), true); + assert.deepEqual(result.claim_cards.map((card) => card.category), ["natal_foundation", "timing", "domain"]); + assert.equal(result.consultations.every((item) => !("claim_cards" in item)), true); + const sections = result.claim_cards.find((card) => card.category === "domain")?.evidence as Record; + assert.deepEqual(Object.keys(sections), ["career", "wealth"]); + assert.equal(Object.values(sections).every((section) => section.base === undefined), true); // When the domains genuinely disagree, nothing is presented as shared. const differing = await runDomainPlan(["career", "wealth"], (theme) => workflow(theme, { chart: theme === "career" ? natalChart : { ...natalChart, ascendant: { sign: "Virgo", degree: 1 } }, })); - assert.deepEqual(differing.result.claim_cards, []); - assert.equal( - differing.result.consultations.every((item) => item.claim_cards.some((card) => card.category === "natal_foundation")), - true, - ); + const differingSections = differing.result.claim_cards.find((card) => card.category === "domain")?.evidence as Record; + assert.equal(differingSections.career?.base, undefined); + assert.deepEqual(differingSections.wealth?.base?.natal.ascendant, { sign: "Virgo", degree: 1 }); + assert.ok(differing.result.evidence_card.gaps?.includes("wealth.base_differs")); }); test("the domain cap is what the run budget can actually pay for", () => { diff --git a/scripts/research/measure_consult_evidence_card.ts b/scripts/research/measure_consult_evidence_card.ts new file mode 100644 index 00000000..183eccf1 --- /dev/null +++ b/scripts/research/measure_consult_evidence_card.ts @@ -0,0 +1,86 @@ +/** + * Measure what the answer model sees after the evidence card + * (TASK-consult-evidence-card-20260927 T3), on the research's own cached + * engine responses (3 public AA charts x 10 questions, produced by + * consult_evidence_card_run.py --cache ). Read-only; runs the production + * calculation tool with the cached workflow standing in for the engine. + * + * node frontend/node_modules/tsx/dist/cli.mjs scripts/research/measure_consult_evidence_card.ts [out.json] + */ +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + createConsultationRuntimeState, + createConsultationTools, +} from "../../frontend/src/mastra/consultation-tools.ts"; +import type { ConsultationDomain } from "../../frontend/src/lib/consultation-domain-registry.ts"; + +const QUESTIONS: Record = { + career: { domain: "career", question: "未来一年,事业和收入该关注什么?" }, + marriage: { domain: "marriage", question: "我的关系模式是什么?" }, + wealth: { domain: "wealth", question: "我的财富增长方式和风险点是什么?" }, + health: { domain: "health", question: "我近期的身心压力模式和调节重点是什么?" }, + education: { domain: "education", question: "我的学习优势、瓶颈和进阶方向是什么?" }, + migration: { domain: "migration", question: "迁居、置业或海外发展更适合怎样规划?" }, + family: { domain: "family", question: "我的家庭关系与责任边界该如何理解?" }, + general: { domain: "general", question: "请综合说明我当前最值得关注的主题。" }, + parents: { domain: "parents", question: "我和父母关系如何" }, + children: { domain: "children", question: "我和子女的关系如何" }, +}; + +const [cacheDir, outPath] = process.argv.slice(2); +if (!cacheDir) { + process.stderr.write("usage: measure_consult_evidence_card.ts [out.json]\n"); + process.exit(2); +} + +const serverChart = { + name: "public", + toolInput: { year: 2000, month: 1, day: 1, hour: 0, minute: 0, city: "cache", lat: 0, lon: 0, tz: 0, ayanamsa: "raman" }, + truth: { birthTimeSource: "reported" }, +}; + +async function main(dir: string) { +const rows: Array> = []; +for (const file of readdirSync(dir).filter((name) => name.endsWith(".workflow.json")).sort()) { + const [chart, questionId] = file.replace(".workflow.json", "").split("__"); + const question = QUESTIONS[questionId ?? ""]; + if (!question) continue; + const workflow = JSON.parse(readFileSync(join(dir, file), "utf8")); + const state = createConsultationRuntimeState(); + const tools = createConsultationTools({ + userId: "u", sessionId: "s", requestId: `measure-${chart}-${questionId}`, consultationMode: "verified_chart", + serverChart: serverChart as never, state, + runWorkflow: async () => structuredClone(workflow), + }); + const view = await tools["run-jyotish-consultation"].execute!( + { question: question.question, domains: [question.domain] } as never, + { writer: { custom: async () => {} } } as never, + ) as Record; + const text = JSON.stringify(view); + const methodology = view.methodology ? JSON.stringify(view.methodology).length : 0; + const { claim_cards, evidence_card, methodology: _m, ...contract } = view; + void _m; + rows.push({ + id: `${chart}__${questionId}`, + chart, + question: questionId, + domain: question.domain, + model_visible_chars: text.length, + card_chars: JSON.stringify({ claim_cards, evidence_card }).length, + contract_chars: JSON.stringify(contract).length, + methodology_chars: methodology, + without_methodology_chars: text.length - (methodology ? methodology + ",\"methodology\":".length : 0), + token_estimate: Math.round(text.length / 3.5), + gaps: (evidence_card as { gaps?: string[] }).gaps ?? [], + technique_rows: state.techniqueAuditTable?.length ?? 0, + }); +} +const summary = JSON.stringify(rows, null, 1); +if (outPath) writeFileSync(outPath, summary); +for (const row of rows) { + process.stdout.write(`${row.id}\tvisible=${row.model_visible_chars}\tcard=${row.card_chars}\tcontract=${row.contract_chars}\tmethodology=${row.methodology_chars}\tgaps=${(row.gaps as string[]).join(",")}\n`); +} +} + +void main(cacheDir); diff --git a/tests/test_consult_evidence_card_research.py b/tests/test_consult_evidence_card_research.py index f5af4db5..2c1e9602 100644 --- a/tests/test_consult_evidence_card_research.py +++ b/tests/test_consult_evidence_card_research.py @@ -129,5 +129,12 @@ def test_telemetry_spec_stores_no_user_text(): def test_single_domain_wrap_source_still_matches_projector(): + # 原值:assert "return { ...consultations[0], ...plan, consultations };" in source + # (调研投影脚本模仿的「单领域整包再复制进 consultations」)。 + # 新值:服务端完整上下文单领域不再复制整包;模型读 toModelEvidenceView(答题契约 + 数据卡)。 + # 调研脚本 project_consult_model_context.ts 仍按旧形状复现「改前」体量,作为基线快照保留。 + # 原因:TASK-consult-evidence-card-20260927 T3「去掉 consultations 整包重复」。 source = (ROOT / "frontend/src/mastra/consultation-tools.ts").read_text(encoding="utf-8") - assert "return { ...consultations[0], ...plan, consultations };" in source + assert "return { ...consultations[0], ...plan, consultations };" not in source + assert "return { ...consultations[0], ...plan };" in source + assert "export function toModelEvidenceView(" in source