fix(consultation): project bounded evidence for model
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user