feat(consultation): validate bounded consultation plans

This commit is contained in:
Jesse_Chen
2026-08-14 17:38:27 +08:00
parent a8141d955c
commit f4b3da6511
3 changed files with 62 additions and 1 deletions
+48
View File
@@ -0,0 +1,48 @@
import { z } from "zod";
export const consultationDomainValues = ["career", "marriage", "wealth", "timing"] as const;
export const consultationDepthValues = ["concise", "standard", "deep", "research"] as const;
export const consultationTimingHorizonValues = ["next_3_months", "next_12_months", "next_24_months", "long_term"] as const;
export const consultationEvidenceCategoryValues = ["natal_foundation", "domain", "timing", "validation"] as const;
export const consultationPlanSchema = z.object({
userIntent: z.string().trim().min(1).max(500),
requestedDomains: z.array(z.enum(consultationDomainValues)).min(1).max(3),
depth: z.enum(consultationDepthValues),
timingHorizon: z.enum(consultationTimingHorizonValues).nullable(),
requiredEvidenceCategories: z.array(z.enum(consultationEvidenceCategoryValues)).min(1).max(4),
}).strict();
export type ConsultationPlan = z.infer<typeof consultationPlanSchema>;
export type ConsultationPlanTheme = "career" | "marriage" | "wealth" | "timing" | "general";
const domainsByTheme: Record<ConsultationPlanTheme, ConsultationPlan["requestedDomains"]> = {
career: ["career"],
marriage: ["marriage"],
wealth: ["wealth"],
timing: ["timing"],
general: ["career", "marriage", "wealth"],
};
const evidenceByTheme: Record<ConsultationPlanTheme, ConsultationPlan["requiredEvidenceCategories"]> = {
career: ["natal_foundation", "domain"],
marriage: ["natal_foundation", "domain"],
wealth: ["natal_foundation", "domain"],
timing: ["natal_foundation", "timing", "validation"],
general: ["natal_foundation", "domain"],
};
export function createConsultationPlan(input: {
userIntent: string;
theme: ConsultationPlanTheme;
depth?: ConsultationPlan["depth"];
timingHorizon?: ConsultationPlan["timingHorizon"];
}): ConsultationPlan {
return consultationPlanSchema.parse({
userIntent: input.userIntent,
requestedDomains: domainsByTheme[input.theme],
depth: input.depth ?? "standard",
timingHorizon: input.timingHorizon ?? (input.theme === "timing" ? "next_12_months" : null),
requiredEvidenceCategories: evidenceByTheme[input.theme],
});
}
+3 -1
View File
@@ -2,6 +2,7 @@ import { createTool } from "@mastra/core/tools";
import { z } from "zod";
import { applyBirthTimeModeToWorkflowContext, type ConsultationBirthTimeMode } from "../lib/consultation-birth-time-mode.ts";
import type { ServerChartConsultation } from "../lib/consultation-route-service.ts";
import { createConsultationPlan } from "../lib/consultation-plan.ts";
import type { WorkflowReceipt } from "../lib/consultation-agent-events.ts";
import {
consultationInputSchema,
@@ -104,6 +105,7 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
ctx.state.consultationToolStarted = true;
ctx.state.consultationToolCallCount += 1;
const startedAt = Date.now();
const plan = createConsultationPlan({ userIntent: input.question, theme: input.theme });
calculation = (async () => {
try {
await context.writer?.custom({
@@ -113,7 +115,7 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
const toolInput = consultationInputSchema.parse({
...ctx.serverChart.toolInput,
entryMode: "direct_chart",
question: input.question,
question: plan.userIntent,
theme: input.theme,
});
const workflow = await (ctx.runWorkflow ?? runConsultationWorkflow)(toolInput, {
@@ -8,9 +8,20 @@ const mastra = readFileSync(new URL("../src/mastra/index.ts", import.meta.url),
const tools = readFileSync(new URL("../src/mastra/consultation-tools.ts", import.meta.url), "utf8");
const stream = readFileSync(new URL("../src/lib/stream-agent-response.ts", import.meta.url), "utf8");
const workflow = readFileSync(new URL("../src/mastra/consultation-workflow.ts", import.meta.url), "utf8");
const plan = readFileSync(new URL("../src/lib/consultation-plan.ts", import.meta.url), "utf8");
const stagingCompose = readFileSync(new URL("../../deploy/docker-compose.staging.yml", import.meta.url), "utf8");
test("consultation plans are server-owned and bounded", () => {
assert.match(plan, /consultationPlanSchema/);
assert.match(plan, /requestedDomains/);
assert.match(plan, /requiredEvidenceCategories/);
assert.match(plan, /createConsultationPlan/);
assert.match(tools, /createConsultationPlan\(\{ userIntent: input\.question, theme: input\.theme \}\)/);
assert.doesNotMatch(plan, /birth|latitude|longitude|engine|script/i);
});
test("uses one runtime step append entry and no scattered hard-coded step cap", () => {
assert.match(tools, /export function appendConsultationRuntimeStep/);
assert.doesNotMatch(tools, /steps\.length\s*>=\s*32/);