diff --git a/frontend/src/lib/consultation-plan.ts b/frontend/src/lib/consultation-plan.ts new file mode 100644 index 00000000..452fd5fd --- /dev/null +++ b/frontend/src/lib/consultation-plan.ts @@ -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; +export type ConsultationPlanTheme = "career" | "marriage" | "wealth" | "timing" | "general"; + +const domainsByTheme: Record = { + career: ["career"], + marriage: ["marriage"], + wealth: ["wealth"], + timing: ["timing"], + general: ["career", "marriage", "wealth"], +}; + +const evidenceByTheme: Record = { + 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], + }); +} diff --git a/frontend/src/mastra/consultation-tools.ts b/frontend/src/mastra/consultation-tools.ts index 9f84f6bb..7eed5cc5 100644 --- a/frontend/src/mastra/consultation-tools.ts +++ b/frontend/src/mastra/consultation-tools.ts @@ -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, { diff --git a/frontend/tests/consultation-workflow-contract.test.ts b/frontend/tests/consultation-workflow-contract.test.ts index 93b25bc5..b68828ea 100644 --- a/frontend/tests/consultation-workflow-contract.test.ts +++ b/frontend/tests/consultation-workflow-contract.test.ts @@ -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/);