fix(consultation): project bounded evidence for model

This commit is contained in:
Jesse_Chen
2026-08-14 17:57:03 +08:00
parent f4b3da6511
commit 0e0268f611
5 changed files with 177 additions and 5 deletions
+6 -4
View File
@@ -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<ReturnType<typeof toAgentConsultationContext>> | null = null;
let calculation: Promise<ReturnType<typeof toModelOutput>> | 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 });
@@ -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<typeof consultationEvidencePacketSchema>;
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<typeof toAgentConsultationContext>, 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);
+1 -1
View File
@@ -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");
@@ -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<string, unknown>;
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);
});
@@ -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);
});