feat(consultation): validate bounded consultation plans
This commit is contained in:
@@ -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],
|
||||
});
|
||||
}
|
||||
@@ -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/);
|
||||
|
||||
Reference in New Issue
Block a user