diff --git a/frontend/src/mastra/consultation-tools.ts b/frontend/src/mastra/consultation-tools.ts index 7eed5cc5..f05d986e 100644 --- a/frontend/src/mastra/consultation-tools.ts +++ b/frontend/src/mastra/consultation-tools.ts @@ -9,6 +9,7 @@ import { consultationWorkflowReceipt, runConsultationWorkflow, toAgentConsultationContext, + toModelOutput, } from "./consultation-workflow.ts"; const consultationToolInputSchema = z.object({ @@ -95,7 +96,7 @@ export function createConsultationAgentContext(context: ConsultationAgentContext } export function createConsultationTools(ctx: ConsultationAgentContext) { - let calculation: Promise> | null = null; + let calculation: Promise> | null = null; const consultationTool = createTool({ id: "run-jyotish-consultation", description: "Calculate one server-verified personal Jyotish consultation. Birth data is bound by the server and is never accepted from the model.", @@ -131,14 +132,15 @@ export function createConsultationTools(ctx: ConsultationAgentContext) { missingLayers: receipt.missingLayers === "none" ? [] : receipt.missingLayers.split(",").map((item) => item.trim()).filter(Boolean), }; ctx.state.techniqueTruth = receipt.techniqueTruth; - ctx.state.consultationToolCompleted = true; ctx.state.consultationToolDurationMs = Date.now() - startedAt; - appendConsultationRuntimeStep(ctx.state, { kind: "tool", name: "run-jyotish-consultation", status: "completed", durationMs: ctx.state.consultationToolDurationMs }); await context.writer?.custom({ type: "data-jyotish-activity", data: { phase: "evidence-validation", label: "正在核对可用证据" }, }); - return toAgentConsultationContext(guarded); + const modelOutput = toModelOutput(toAgentConsultationContext(guarded), plan); + ctx.state.consultationToolCompleted = true; + appendConsultationRuntimeStep(ctx.state, { kind: "tool", name: "run-jyotish-consultation", status: "completed", durationMs: ctx.state.consultationToolDurationMs }); + return modelOutput; } catch (error) { ctx.state.consultationToolDurationMs = Date.now() - startedAt; appendConsultationRuntimeStep(ctx.state, { kind: "tool", name: "run-jyotish-consultation", status: "failed", durationMs: ctx.state.consultationToolDurationMs }); diff --git a/frontend/src/mastra/consultation-workflow.ts b/frontend/src/mastra/consultation-workflow.ts index 59ebc93f..5a90ab48 100644 --- a/frontend/src/mastra/consultation-workflow.ts +++ b/frontend/src/mastra/consultation-workflow.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { consultationEvidenceCategoryValues, type ConsultationPlan } from "../lib/consultation-plan.ts"; import { consultationThemeValues, projectConsultationWorkflowRequest } from "../lib/consultation-workflow-request.ts"; export const consultationInputSchema = z.object({ @@ -84,6 +85,120 @@ export function consultationWorkflowReceipt(data: JsonRecord) { }; } +type ModelOutputValue = string | number | boolean | null | ModelOutputValue[] | { [key: string]: ModelOutputValue }; + +const modelOutputBlockedKeys = /^(birth(?:_|[A-Z]|$)|reportedBirthTime|activeBirthTime|selectedTime|toolInput|userId|sessionId|requestId|password|token|secret|cookie|authorization|raw_payload|private_payload)$/i; + +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(), + }), + claim_cards: z.array(z.object({ + category: z.enum(consultationEvidenceCategoryValues), + source: z.literal("server_chart").or(z.literal("server_workflow")), + evidence: modelOutputEvidenceSchema, + })), + rectification: z.object({ boundary: z.string() }), +}).strict(); + +export type ConsultationEvidencePacket = z.infer; + +function compactModelOutput(value: unknown, depth = 0): ModelOutputValue | undefined { + if (value === undefined || depth > 3) return 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; + if (Array.isArray(value)) { + return value.slice(0, 24).map((item) => compactModelOutput(item, depth + 1)).filter((item): item is ModelOutputValue => item !== undefined); + } + if (typeof value !== "object") return undefined; + const output: { [key: string]: ModelOutputValue } = {}; + for (const [key, item] of Object.entries(value)) { + if (modelOutputBlockedKeys.test(key)) continue; + const compacted = compactModelOutput(item, depth + 1); + if (compacted !== undefined) output[key] = compacted; + if (Object.keys(output).length >= 24) break; + } + return output; +} + +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, plan?: ConsultationPlan): 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: compactModelOutput({ + ascendant: context.chart.ascendant, + planets: context.chart.planets, + houses: context.chart.houses, + shadbala: context.chart.shadbala, + ashtakavarga: context.chart.ashtakavarga, + }), + }, + { + category: "domain" as const, + source: "server_workflow" as const, + evidence: compactModelOutput(context.thematic_evidence), + }, + { + category: "timing" as const, + source: "server_workflow" as const, + evidence: compactModelOutput({ + dasha: context.chart.dasha, + dasha_boundaries: context.local_layers.dasha_boundaries, + narayana_dasha: context.local_layers.narayana_dasha, + }), + }, + { + category: "validation" as const, + source: "server_workflow" as const, + evidence: compactModelOutput({ + technique_truth: contract.technique_truth, + reference_transparency: context.reference_transparency, + shadbala_boundary: context.local_layers.shadbala_boundary, + }), + }, + ] + .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: { + available_layers: compactModelOutput(contract.available_layers), + missing_route_layers: compactModelOutput(contract.missing_route_layers), + hard_blockers: compactModelOutput(contract.hard_blockers), + answer_policy: compactModelOutput(contract.answer_policy), + user_facing_limitation: compactModelOutput(contract.user_facing_limitation), + }, + claim_cards: cards, + rectification: { boundary: context.rectification.boundary }, + }); +} + export function toAgentConsultationContext(data: JsonRecord) { const chart = record(data.chart); const modules = record(chart.modules); diff --git a/frontend/src/mastra/index.ts b/frontend/src/mastra/index.ts index eb90fc28..edcebe90 100644 --- a/frontend/src/mastra/index.ts +++ b/frontend/src/mastra/index.ts @@ -9,7 +9,7 @@ import { resolveSkillPackageRuntimePath, } from "../lib/skill-package-registry.ts"; -export { consultationInputSchema, consultationWorkflowReceipt, consultationWorkflowResponseSchema, runConsultationWorkflow, toAgentConsultationContext } from "./consultation-workflow.ts"; +export { consultationInputSchema, consultationWorkflowReceipt, consultationWorkflowResponseSchema, runConsultationWorkflow, toAgentConsultationContext, toModelOutput } from "./consultation-workflow.ts"; export type { ConsultationInput } from "./consultation-workflow.ts"; const jyotishSkillPackage = resolveActiveSkillPackage("jyotish-vedic-astrology"); diff --git a/frontend/tests/consultation-context.test.ts b/frontend/tests/consultation-context.test.ts index 76693879..acd037b2 100644 --- a/frontend/tests/consultation-context.test.ts +++ b/frontend/tests/consultation-context.test.ts @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; +import { createConsultationPlan } from "../src/lib/consultation-plan.ts"; +import { toAgentConsultationContext, toModelOutput } from "../src/mastra/consultation-workflow.ts"; + test("passes transparent public-case references into the agent context", () => { const workflowSource = readFileSync(new URL("../src/mastra/consultation-workflow.ts", import.meta.url), "utf8"); @@ -49,3 +52,51 @@ test("keeps strength, Ashtakavarga, and timing evidence available to the answer assert.match(workflowSource, /numerical_parity: record\(data\.external_parity_gate\)/); assert.match(workflowSource, /real_case_calibration: record\(data\.real_case_calibration\)/); }); + +test("projects only bounded server-selected evidence to the model", () => { + const context = toAgentConsultationContext({ + success: true, + question: "事业如何", + chart: { + birth: { date: "1990-01-02", time: "03:04", latitude: 25.03, longitude: 121.56 }, + ascendant: { sign: "Leo", degree: 12.5 }, + planets: [{ name: "Sun", sign: "Capricorn", degree: 4.2 }], + houses: { first: { sign: "Leo" } }, + dasha: { current: "Mars", next: "Rahu" }, + modules: { + shadbala: { total: 412 }, + ashtakavarga: { total: 28 }, + dasha_boundaries: { next: "2027-03" }, + narayana_dasha: { current: "Aries" }, + }, + }, + routing: { primary_theme: "career" }, + thematic_report: { themes: { career: { outlook: "steady", source: "server" } } }, + consumer_context: { + route: "career", core_status: "ready", available_layers: ["natal", "timing"], + missing_route_layers: [], hard_blockers: [], + technique_truth: { status: "verified" }, + answer_policy: { can_answer_direction: true, can_answer_precise_timing: false }, + user_facing_limitation: "精确月份暂不可用", + }, + reference_transparency: { similar_public_cases: { status: "public_context_only" } }, + rectification: { summary: "none" }, + }); + const output = toModelOutput(context); + const careerPlan = createConsultationPlan({ userIntent: "事业如何", theme: "career" }); + const careerOutput = toModelOutput(context, careerPlan); + const serialized = JSON.stringify(output); + + assert.equal(output.packet_version, "consultation-evidence-packet-v2"); + assert.equal("birth" in output, false); + assert.equal(serialized.includes("1990-01-02"), false); + assert.equal(serialized.includes("03:04"), false); + assert.equal(serialized.includes("25.03"), false); + assert.equal(serialized.includes("121.56"), false); + const answerPolicy = output.evidence_contract.answer_policy as Record; + assert.equal(answerPolicy.can_answer_precise_timing, false); + assert.deepEqual(output.claim_cards.map((card) => card.category), ["natal_foundation", "domain", "timing", "validation"]); + assert.deepEqual(careerOutput.claim_cards.map((card) => card.category), ["natal_foundation", "domain"]); + assert.equal(JSON.stringify(careerOutput).includes('"dasha"'), false); + assert.equal(serialized.includes("consultation-evidence-packet-v2"), true); +}); diff --git a/frontend/tests/consultation-workflow-contract.test.ts b/frontend/tests/consultation-workflow-contract.test.ts index b68828ea..ab5cf0c3 100644 --- a/frontend/tests/consultation-workflow-contract.test.ts +++ b/frontend/tests/consultation-workflow-contract.test.ts @@ -19,6 +19,10 @@ test("consultation plans are server-owned and bounded", () => { assert.match(plan, /requiredEvidenceCategories/); assert.match(plan, /createConsultationPlan/); assert.match(tools, /createConsultationPlan\(\{ userIntent: input\.question, theme: input\.theme \}\)/); + assert.match(workflow, /packet_version: "consultation-evidence-packet-v2"/); + assert.match(workflow, /claim_cards/); + assert.match(tools, /toModelOutput\(toAgentConsultationContext\(guarded\), plan\)/); + assert.match(tools, /const modelOutput = toModelOutput[\s\S]*ctx\.state\.consultationToolCompleted = true/); assert.doesNotMatch(plan, /birth|latitude|longitude|engine|script/i); });