diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 57839522..8834d962 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -2749,14 +2749,14 @@ - 状态:resolved(staging candidate) - 首次发现:2026-08-11 -- 最近更新:2026-08-11 +- 最近更新:2026-08-14 - 影响面:`POST /api/consult` 的个人/一般咨询、Mastra Agent、浏览器流式活动状态、咨询消息执行回执与计费结算;production 默认路径不变。 - 用户现象:回答可以生成,但服务端在 Agent stream 前已经完成咨询工作流,Agent 只是复述结果;Web 只能按“有无正文”猜测状态,无法证明 Agent 实际加载 Jyotish Skill 或调用排盘工具。 - 触发条件:咨询进入旧 runtime;route 直接执行 `runConsultationWorkflow()`,再把大段结果塞入 prompt,且仅返回 `text/plain`。 - 根因:编排权在 Next.js route,不在 Agent;个人 Agent 未绑定服务器排盘工具,Skill/tool 执行合同和公开事件协议均不存在。若直接透传 `fullStream`,还会泄露 reasoning、工具参数/结果、出生资料或 Skill 内容。 -- 修复:增加 `legacy|canary|enabled` runtime 开关;新路径由带 Jyotish Skill 的 Mastra Agent 调用请求级 `run-jyotish-consultation`,工具参数只允许 question/theme,出生资料始终由服务器上下文绑定,并以请求内 Promise 保证重复调用只计算一次。服务端把 `fullStream` 清洗为 NDJSON,仅公开安全的 run/Skill/tool/activity/answer 事件;Skill 和主工具合同未完成时最多重试一次,仍失败则释放预留点数且不保存成功消息。Web 按 content-type 保留 legacy `text/plain` 回滚路径,新路径只累积 `answer.delta`,收到 `run.completed` 后保存 workflow/execution receipt,并用服务器 activity 驱动状态 UI。浏览器断线后服务端继续完成互斥结算。 -- 验证:聚焦回归覆盖动态 Skill 工具、General Agent 无个人排盘工具、服务器绑定参数、并发幂等、unverified precise timing blocked、私有 chunk 过滤、任意 NDJSON 边界、合同完成前正文阻塞、失败不保存、真实 activity UI、execution receipt 持久化、legacy 流与断线结算合同;最终命令和结果记录在本次 staging 发布回报。 -- 防复发:个人咨询不得在 Agent stream 前直接执行主 workflow;不得把出生资料放入模型工具参数;公开流不得包含 reasoning、provider metadata、工具输入/结果或 Skill 正文;只有 `run.completed` 可进入成功消息持久化,步骤最多 32 个,计算与结算都必须请求内幂等。 +- 修复:增加 `legacy|canary|enabled` runtime 开关;新路径由带 Jyotish Skill 的 Mastra Agent 调用请求级 `run-jyotish-consultation`,工具参数只允许 question/theme,出生资料始终由服务器上下文绑定,并以请求内 Promise 保证重复调用只计算一次。服务端把 `fullStream` 清洗为 NDJSON,仅公开安全的 run/Skill/tool/activity/answer 事件;Skill 和主工具合同未完成时最多重试一次,仍失败则释放预留点数且不保存成功消息。Web 按 content-type 保留 legacy `text/plain` 回滚路径,新路径只累积 `answer.delta`,收到 `run.completed` 后保存 workflow/execution receipt,并用服务器 activity 驱动状态 UI。浏览器断线后服务端继续完成互斥结算。2026-08-14 进一步加入 `ConsultationPlan v2`:服务端在真实点数预留前用最终 profile mode、精度边界和单次成本上限校验 plan;同一个 pre-reserve plan 原样传给 Agent 工具与 legacy workflow,模型提供的 question/theme 不能重建或改变 plan。Python 仅接受服务端 route allowlist 的完整 metadata;partial metadata 不得通过删除 `plan_version` 降级为 legacy,且 plan 只能收紧、不能授予精确应期权限。`ConsultationPlan` 在创建时对对象和嵌套数组执行深冻结,确保扣点后的 workflow 不能改写边界。模型 evidence packet 不再接收任意对象后依赖 denylist 清洗,而是分别对 `natal_foundation`、`domain`、`timing`、`validation` 与 `evidence_contract` 执行显式业务字段 allowlist 投影;主题 evidence 的 `details` 只选择批准字段,不整体透传,从结构上阻断出生、位置、账务、身份、权限和工具 payload 的别名绕过。 +- 验证:聚焦回归覆盖动态 Skill 工具、General Agent 无个人排盘工具、服务器绑定参数、并发幂等、unverified precise timing blocked、私有 chunk 过滤、任意 NDJSON 边界、合同完成前正文阻塞、失败不保存、真实 activity UI、execution receipt 持久化、legacy 流与断线结算合同;`ConsultationPlan v2` 的 TypeScript 定向测试 49/49、Python 合同与 API dry-run 测试 27/27 通过;回归覆盖 plan 与嵌套数组冻结,以及 `reported_time`、`lng`、`utc_offset`、`account_balance`、`entitlements`、`access_scopes` 六类恶意嵌套别名不进入模型 packet,同时保留本命、Shadbala、Ashtakavarga、Dasha、Narayana 与 evidence status。目标 ESLint、Python `py_compile` 与 `git diff --check` 通过;全量 `tsc --noEmit` 仅保留与本任务无关的既有 migration fixture 字段和 staging workflow 正则 target 错误。 +- 防复发:个人咨询不得在 Agent stream 前直接执行主 workflow;不得把出生资料放入模型工具参数;公开流不得包含 reasoning、provider metadata、工具输入/结果或 Skill 正文;只有 `run.completed` 可进入成功消息持久化,步骤最多 32 个,计算与结算都必须请求内幂等。版本化 plan metadata 必须 all-or-none,扣点前校验的 plan 必须作为同一不可变对象进入实际 workflow;`required_layers`/`claim_boundary` 只表示 allowlist 合同,不能冒充 layer 已执行或 evidence gate 已通过。 - 回滚:仅将 staging 的 `CONSULTATION_AGENTIC_RUNTIME` 设为 `legacy`;无需回滚数据库或修改 production。 - 相关记录:BUG-161 - 复发自:历史咨询 runtime diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index 9772d0f4..3c99f664 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -10,6 +10,11 @@ import { import { blocksPromptExtraction } from "@/lib/consult-safety"; import { parseAgentReply } from "@/lib/agent-reply"; import { createConsultationReplyMetadata } from "@/lib/consultation-reply-metadata"; +import { + ConsultationPlanValidationError, + createConsultationPlan, + type ConsultationPlan, +} from "@/lib/consultation-plan"; import { consultationEntrypointSchema, resolveConsultationQuestion, @@ -38,6 +43,7 @@ import { import { ConsultationProfileTruthError, prepareConsultationRoute, + type PreparedConsultationRoute, } from "@/lib/consultation-route-service"; import { z } from "zod"; @@ -245,6 +251,7 @@ export async function POST(request: Request) { const requestId = parsed.data.requestId; const sessionId = parsed.data.sessionId; const consultationTheme = parsed.data.theme; + const visibleQuestion = parsed.data.question; const userControlledPrompt = [ parsed.data.question, @@ -265,9 +272,9 @@ export async function POST(request: Request) { type SelectedModel = NonNullable>>; type ReservationResult = { success: boolean; credits: number | null; error_code: string | null }; type ModelSelection = Awaited>>; - let prepared: Awaited>>; + let prepared: PreparedConsultationRoute; try { - prepared = await prepareConsultationRoute({ + prepared = await prepareConsultationRoute({ userId, mode: parsed.data.consultationMode, async loadProfile(profileUserId) { @@ -279,6 +286,12 @@ export async function POST(request: Request) { if (error || !data) throw new ConsultationProfileTruthError("profile_unavailable"); return data; }, + beforeReserve: ({ consultationMode }) => createConsultationPlan({ + userIntent: resolvedQuestion.modelQuestion, + theme: consultationTheme, + consultationMode, + modelCreditCost: sessionModel.creditCost, + }), reserve: () => reserveConsultationModel( chatSession.model_id, (modelId) => sessionModel?.id === modelId ? sessionModel : null, @@ -303,6 +316,17 @@ export async function POST(request: Request) { ), }); } catch (error) { + if (error instanceof ConsultationPlanValidationError) { + return NextResponse.json( + { + error: "当前咨询计划不可用", + message: error.code === "plan_cost_ceiling_exceeded" + ? "当前会话模型超出本次咨询的点数上限,请选择标准模型后重试,本次不会扣点。" + : "当前咨询模式与回答精度边界不一致,请刷新后重试,本次不会扣点。", + }, + { status: 409 }, + ); + } if (error instanceof ConsultationProfileTruthError) { const modeChanged = error.code === "mode_changed"; return NextResponse.json( @@ -411,7 +435,7 @@ export async function POST(request: Request) { const reply = parseAgentReply( rawTransformedText, consultationTheme, - createConsultationReplyMetadata({ theme: consultationTheme, question: parsed.data.question }), + createConsultationReplyMetadata({ theme: consultationTheme, question: visibleQuestion }), ); if (!reply.text) throw new Error("empty_agent_reply"); const responseMessage = { @@ -580,6 +604,8 @@ export async function POST(request: Request) { sessionId, requestId, consultationMode, + plan: prepared.preReserveResult, + theme: consultationTheme, serverChart: prepared.serverChart, abortSignal: agentAbortSignal, state, @@ -709,7 +735,10 @@ export async function POST(request: Request) { theme: parsed.data.theme, }); const workflowContext = applyBirthTimeModeToWorkflowContext( - await runConsultationWorkflow(toolInput, { foreground: true }), + await runConsultationWorkflow(toolInput, { + foreground: true, + plan: prepared.preReserveResult, + }), consultationMode, ); const workflowReceipt = consultationWorkflowReceipt(workflowContext); diff --git a/frontend/src/lib/consultation-plan.ts b/frontend/src/lib/consultation-plan.ts index 452fd5fd..4ac50025 100644 --- a/frontend/src/lib/consultation-plan.ts +++ b/frontend/src/lib/consultation-plan.ts @@ -1,21 +1,53 @@ import { z } from "zod"; +import type { ConsultationBirthTimeMode } from "./consultation-birth-time-mode.ts"; 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 consultationPlanVersion = "consultation-plan-v2" as const; +export const consultationPlanMaxCreditCost = 1 as const; + +const consultationPrecisionBoundaryValues = ["server_evidence_required", "precise_timing_blocked"] as const; export const consultationPlanSchema = z.object({ + plan_version: z.literal(consultationPlanVersion), 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), + birthTimeMode: z.enum(["verified_chart", "unverified_birth_time", "general_no_birth_time"]), + precisionBoundary: z.enum(consultationPrecisionBoundaryValues), + maxCreditCost: z.literal(consultationPlanMaxCreditCost), }).strict(); -export type ConsultationPlan = z.infer; +type DeepReadonly = T extends (...args: never[]) => unknown + ? T + : T extends readonly (infer Item)[] + ? readonly DeepReadonly[] + : T extends object + ? { readonly [Key in keyof T]: DeepReadonly } + : T; + +export type ConsultationPlan = DeepReadonly>; export type ConsultationPlanTheme = "career" | "marriage" | "wealth" | "timing" | "general"; +export type ConsultationPlanValidationCode = + | "plan_mode_mismatch" + | "plan_precision_mismatch" + | "plan_cost_ceiling_exceeded"; + +export class ConsultationPlanValidationError extends Error { + readonly code: ConsultationPlanValidationCode; + + constructor(code: ConsultationPlanValidationCode) { + super(`Consultation plan rejected: ${code}`); + this.name = "ConsultationPlanValidationError"; + this.code = code; + } +} + const domainsByTheme: Record = { career: ["career"], marriage: ["marriage"], @@ -32,17 +64,55 @@ const evidenceByTheme: Record(value: T): DeepReadonly { + if (value && typeof value === "object" && !Object.isFrozen(value)) { + for (const child of Object.values(value)) deepFreeze(child); + Object.freeze(value); + } + return value as DeepReadonly; +} + +function precisionBoundaryFor(mode: ConsultationBirthTimeMode) { + return mode === "verified_chart" ? "server_evidence_required" as const : "precise_timing_blocked" as const; +} + +export function validateConsultationPlan( + plan: ConsultationPlan, + input: Readonly<{ consultationMode: ConsultationBirthTimeMode; modelCreditCost: number }>, +) { + if (plan.birthTimeMode !== input.consultationMode) { + throw new ConsultationPlanValidationError("plan_mode_mismatch"); + } + if (plan.precisionBoundary !== precisionBoundaryFor(input.consultationMode)) { + throw new ConsultationPlanValidationError("plan_precision_mismatch"); + } + if (!Number.isInteger(input.modelCreditCost) || input.modelCreditCost < 1 || input.modelCreditCost > plan.maxCreditCost) { + throw new ConsultationPlanValidationError("plan_cost_ceiling_exceeded"); + } + return plan; +} + export function createConsultationPlan(input: { userIntent: string; theme: ConsultationPlanTheme; + consultationMode?: ConsultationBirthTimeMode; + modelCreditCost?: number; depth?: ConsultationPlan["depth"]; timingHorizon?: ConsultationPlan["timingHorizon"]; }): ConsultationPlan { - return consultationPlanSchema.parse({ + const consultationMode = input.consultationMode ?? "verified_chart"; + const plan = deepFreeze(consultationPlanSchema.parse({ + plan_version: consultationPlanVersion, 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], - }); + birthTimeMode: consultationMode, + precisionBoundary: precisionBoundaryFor(consultationMode), + maxCreditCost: consultationPlanMaxCreditCost, + })); + return input.modelCreditCost === undefined + ? plan + : validateConsultationPlan(plan, { consultationMode, modelCreditCost: input.modelCreditCost }); } diff --git a/frontend/src/lib/consultation-route-service.ts b/frontend/src/lib/consultation-route-service.ts index e61d52fa..5b8fb310 100644 --- a/frontend/src/lib/consultation-route-service.ts +++ b/frontend/src/lib/consultation-route-service.ts @@ -59,14 +59,34 @@ export type ServerChartConsultation = Readonly<{ }>; }>; +type ConsultationPreReserveContext = Readonly<{ + consultationMode: ConsultationBirthTimeMode; + serverChart: ServerChartConsultation | null; +}>; + type PrepareConsultationRouteInput = Readonly<{ userId: string; mode: ConsultationBirthTimeMode; loadProfile: (userId: string) => Promise; resolveTimezoneOffset?: (profile: unknown, selectedTime?: string) => Promise; + beforeReserve?: (context: ConsultationPreReserveContext) => unknown | Promise; reserve: () => Promise; }>; +type PrepareConsultationRouteWithGuard = Omit< + PrepareConsultationRouteInput, + "beforeReserve" +> & Readonly<{ + beforeReserve: (context: ConsultationPreReserveContext) => GuardResult | Promise; +}>; + +export type PreparedConsultationRoute = Readonly<{ + consultationMode: ConsultationBirthTimeMode; + serverChart: ServerChartConsultation | null; + reservation: Reservation; + preReserveResult: GuardResult; +}>; + type RecordValue = Record; const allowedBirthTimeSources = new Set([ @@ -256,9 +276,15 @@ function serverChartFromProfile( * The route's pre-billing service boundary. Chart modes must load and resolve * account truth successfully before the reservation callback can run. */ -export async function prepareConsultationRoute( +export function prepareConsultationRoute( + input: PrepareConsultationRouteWithGuard, +): Promise>>; +export function prepareConsultationRoute( input: PrepareConsultationRouteInput, -) { +): Promise>; +export async function prepareConsultationRoute( + input: PrepareConsultationRouteInput | PrepareConsultationRouteWithGuard, +): Promise | undefined>> { let profile: unknown; try { profile = await input.loadProfile(input.userId); @@ -286,6 +312,9 @@ export async function prepareConsultationRoute( } serverChart = serverChartFromProfile(profile, consultationMode); } + const preReserveResult = input.beforeReserve + ? await input.beforeReserve({ consultationMode, serverChart }) as Awaited + : undefined; const reservation = await input.reserve(); - return Object.freeze({ consultationMode, serverChart, reservation }); + return Object.freeze({ consultationMode, serverChart, reservation, preReserveResult }); } diff --git a/frontend/src/lib/consultation-workflow-request.ts b/frontend/src/lib/consultation-workflow-request.ts index 0fe7b16e..579f5e89 100644 --- a/frontend/src/lib/consultation-workflow-request.ts +++ b/frontend/src/lib/consultation-workflow-request.ts @@ -2,6 +2,15 @@ export const consultationThemeValues = ["career", "marriage", "wealth", "timing" export type ConsultationTheme = typeof consultationThemeValues[number]; +export type ConsultationWorkflowPlanMetadata = Readonly<{ + plan_version: string; + depth: string; + requestedDomains: readonly string[]; + timingHorizon: string | null; + precisionBoundary: string; + requiredEvidenceCategories: readonly string[]; +}>; + export type ConsultationWorkflowRoute = | "career" | "marriage" @@ -17,9 +26,15 @@ type WorkflowProjection = { strictWorkflowRoute: ConsultationWorkflowRoute; requiredLayers: readonly string[]; claimBoundary: string; + plan_version: string; + depth: string; + requested_domains: readonly string[]; + timing_horizon: string | null; + precision_boundary: string; + required_evidence_categories: readonly string[]; }; -const routeRequirements: Record & { prefix?: string }> = { +const routeRequirements: Record & { prefix?: string }> = { career: { themes: ["career"], strictWorkflowRoute: "career", @@ -53,7 +68,11 @@ const routeRequirements: Record; + plan: ConsultationPlan; + theme: ConsultationPlanTheme; serverChart: ServerChartConsultation; abortSignal?: AbortSignal; state: ConsultationRuntimeState; @@ -101,12 +103,12 @@ export function createConsultationTools(ctx: ConsultationAgentContext) { 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.", inputSchema: consultationToolInputSchema, - execute: async (input, context) => { + execute: async (_input, context) => { if (calculation) return calculation; ctx.state.consultationToolStarted = true; ctx.state.consultationToolCallCount += 1; const startedAt = Date.now(); - const plan = createConsultationPlan({ userIntent: input.question, theme: input.theme }); + const plan = ctx.plan; calculation = (async () => { try { await context.writer?.custom({ @@ -117,11 +119,12 @@ export function createConsultationTools(ctx: ConsultationAgentContext) { ...ctx.serverChart.toolInput, entryMode: "direct_chart", question: plan.userIntent, - theme: input.theme, + theme: ctx.theme, }); const workflow = await (ctx.runWorkflow ?? runConsultationWorkflow)(toolInput, { foreground: true, signal: context.abortSignal ?? ctx.abortSignal, + plan, }); const guarded = applyBirthTimeModeToWorkflowContext(workflow, ctx.consultationMode); const receipt = consultationWorkflowReceipt(guarded); diff --git a/frontend/src/mastra/consultation-workflow.ts b/frontend/src/mastra/consultation-workflow.ts index 5a90ab48..bc617784 100644 --- a/frontend/src/mastra/consultation-workflow.ts +++ b/frontend/src/mastra/consultation-workflow.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { consultationEvidenceCategoryValues, type ConsultationPlan } from "../lib/consultation-plan.ts"; +import { consultationEvidenceCategoryValues, createConsultationPlan, type ConsultationPlan } from "../lib/consultation-plan.ts"; import { consultationThemeValues, projectConsultationWorkflowRequest } from "../lib/consultation-workflow-request.ts"; export const consultationInputSchema = z.object({ @@ -47,10 +47,11 @@ const apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; export async function runConsultationWorkflow( input: ConsultationInput, - options?: { foreground?: boolean; signal?: AbortSignal }, + options?: { foreground?: boolean; signal?: AbortSignal; plan?: ConsultationPlan }, ) { const { entryMode, question, theme, ...workflowInput } = input; - const workflowRequest = projectConsultationWorkflowRequest(question, theme); + const plan = options?.plan ?? createConsultationPlan({ userIntent: question, theme }); + const workflowRequest = projectConsultationWorkflowRequest(question, theme, plan); const timeout = AbortSignal.timeout(90_000); const signal = options?.signal ? AbortSignal.any([options.signal, timeout]) : timeout; const response = await fetch(`${apiBase}/api/consultation_workflow`, { @@ -63,6 +64,15 @@ export async function runConsultationWorkflow( question_text: workflowRequest.question, theme: workflowRequest.themes, defer_optional_external_evidence: options?.foreground === true, + plan_version: workflowRequest.plan_version, + strict_workflow_route: workflowRequest.strictWorkflowRoute, + required_layers: workflowRequest.requiredLayers, + claim_boundary: workflowRequest.claimBoundary, + plan_depth: workflowRequest.depth, + requested_domains: workflowRequest.requested_domains, + timing_horizon: workflowRequest.timing_horizon, + precision_boundary: workflowRequest.precision_boundary, + required_evidence_categories: workflowRequest.required_evidence_categories, }), signal, }); @@ -87,8 +97,6 @@ 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({ @@ -113,24 +121,175 @@ export const consultationEvidencePacketSchema = z.object({ export type ConsultationEvidencePacket = z.infer; -function compactModelOutput(value: unknown, depth = 0): ModelOutputValue | undefined { - if (value === undefined || depth > 3) return undefined; +function normalizeEvidenceKey(key: string) { + return key.replace(/[^a-z0-9]/gi, "").toLowerCase(); +} + +function boundedPrimitive(value: unknown): ModelOutputValue | 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; + return undefined; +} + +const astrologyEntityKeys = new Set([ + "sun", "moon", "mars", "mercury", "jupiter", "venus", "saturn", "rahu", "ketu", + "aries", "taurus", "gemini", "cancer", "leo", "virgo", "libra", "scorpio", + "sagittarius", "capricorn", "aquarius", "pisces", + "first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth", + "ninth", "tenth", "eleventh", "twelfth", +]); + +function isAstrologyEntityKey(key: string) { + const normalized = normalizeEvidenceKey(key); + return astrologyEntityKeys.has(normalized) + || /^h(?:ouse)?\d{1,2}$/.test(normalized) + || /^\d{1,2}$/.test(normalized); +} + +function projectAllowlistedTree( + value: unknown, + allowedKeys: ReadonlySet, + options: Readonly<{ allowAstrologyEntityKeys?: boolean; depth?: number }> = {}, +): ModelOutputValue | undefined { + const primitive = boundedPrimitive(value); + if (primitive !== undefined) return primitive; + const depth = options.depth ?? 0; + if (depth > 4) return undefined; if (Array.isArray(value)) { - return value.slice(0, 24).map((item) => compactModelOutput(item, depth + 1)).filter((item): item is ModelOutputValue => item !== undefined); + return value.slice(0, 24) + .map((item) => projectAllowlistedTree(item, allowedKeys, { ...options, depth: depth + 1 })) + .filter((item): item is ModelOutputValue => item !== undefined); } - if (typeof value !== "object") return undefined; + if (!value || 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; + const normalized = normalizeEvidenceKey(key); + if (!allowedKeys.has(normalized) && !(options.allowAstrologyEntityKeys && isAstrologyEntityKey(key))) continue; + const projected = projectAllowlistedTree(item, allowedKeys, { ...options, depth: depth + 1 }); + if (projected !== undefined) output[key] = projected; if (Object.keys(output).length >= 24) break; } return output; } +const natalFoundationKeys = new Set([ + "ascendant", "planets", "houses", "shadbala", "ashtakavarga", "sarvashtakavarga", + "name", "planet", "sign", "signidx", "degree", "degreeinsign", "degreeraw", "lon", + "nakshatra", "pada", "lord", "house", "number", "occupants", "aspects", "retrograde", + "isretrograde", "dignity", "status", "total", "totalrupa", "rupa", "ratio", "required", + "rank", "strength", "score", "value", "sav", "bav", "points", "count", "housescores", + "components", "matrix", "matrixshape", "signs", "sources", "source", "method", "boundary", +]); + +const timingKeys = new Set([ + "dasha", "dashaboundaries", "narayanadasha", "status", "current", "next", "mahadasha", + "antardasha", "pratyantardasha", "currentdasha", "period", "dashaperiod", "periods", "timeline", + "boundaries", "boundarycount", "start", "end", "startyear", "endyear", "iscurrent", + "activationdescription", "sign", "lord", "planet", "name", "strength", "score", "source", + "method", "confidence", "confidencecap", "summary", "conclusion", "fingerprint", +]); + +const validationKeys = new Set([ + "techniquetruth", "referencetransparency", "shadbalaboundary", "status", "state", "verified", + "executed", "blocked", "notapplicable", "available", "coverage", "source", "sources", "method", + "methods", "technique", "techniques", "reason", "reasons", "boundary", "confidence", + "confidencecap", "summary", "conclusion", "publiccontextonly", "similarpubliccases", + "highsimilaritypublicreferencesavailable", "requesteduncovereddomains", "timingstate", + "partialmatch", "narayanastatus", "transitstatus", "productiontuningallowed", "nomajorityvote", +]); + +const domainDetailsKeys = new Set([ + "source", "derived", "fragment", "sign", "signidx", "planets", "planetslabel", "lord", "house", + "score", "level", "clues", "marriagecount", "d9marriagequality", "headline", "strengths", "risks", + "boundaries", "monthlyframe", "primarystate", "manifestationmode", "frictionsource", "timeconfidence", + "current", "summary", "interpretation", "description", "note", "status", "confidence", +]); + +function projectNatalFoundation(context: ReturnType) { + return projectAllowlistedTree({ + ascendant: context.chart.ascendant, + planets: context.chart.planets, + houses: context.chart.houses, + shadbala: context.chart.shadbala, + ashtakavarga: context.chart.ashtakavarga, + }, natalFoundationKeys, { allowAstrologyEntityKeys: true }); +} + +function projectDomainEvidence(value: unknown): ModelOutputValue { + const theme = record(value); + const output: { [key: string]: ModelOutputValue } = {}; + for (const key of ["theme", "summary", "narrative", "strength"] as const) { + const projected = boundedPrimitive(theme[key]); + if (projected !== undefined) output[key] = projected; + } + if (Array.isArray(theme.recommendations)) { + output.recommendations = theme.recommendations.slice(0, 12) + .map(boundedPrimitive) + .filter((item): item is ModelOutputValue => item !== undefined); + } + if (Array.isArray(theme.evidence)) { + output.evidence = theme.evidence.slice(0, 24).map((rawItem) => { + const item = record(rawItem); + const projected: { [key: string]: ModelOutputValue } = {}; + for (const key of ["technique", "chart", "conclusion", "sentiment", "strength"] as const) { + const scalar = boundedPrimitive(item[key]); + if (scalar !== undefined) projected[key] = scalar; + } + const details = projectAllowlistedTree(item.details, domainDetailsKeys, { allowAstrologyEntityKeys: true }); + if (details !== undefined) projected.details = details; + return projected; + }); + } + if (Array.isArray(theme.conflicts)) { + const conflictKeys = new Set(["techniquea", "conclusiona", "techniqueb", "conclusionb", "resolution", "reasoning", "winner"]); + output.conflicts = theme.conflicts.slice(0, 12) + .map((item) => projectAllowlistedTree(item, conflictKeys)) + .filter((item): item is ModelOutputValue => item !== undefined); + } + const timing = projectAllowlistedTree(theme.timing, timingKeys, { allowAstrologyEntityKeys: true }); + if (timing !== undefined) output.timing = timing; + return output; +} + +function projectTimingEvidence(context: ReturnType) { + return projectAllowlistedTree({ + dasha: context.chart.dasha, + dasha_boundaries: context.local_layers.dasha_boundaries, + narayana_dasha: context.local_layers.narayana_dasha, + }, timingKeys, { allowAstrologyEntityKeys: true }); +} + +function projectValidationEvidence(context: ReturnType) { + return projectAllowlistedTree({ + technique_truth: context.evidence_contract.technique_truth, + reference_transparency: context.reference_transparency, + shadbala_boundary: context.local_layers.shadbala_boundary, + }, validationKeys, { allowAstrologyEntityKeys: true }); +} + +function stringList(value: unknown) { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string").slice(0, 24) : []; +} + +function projectEvidenceContract(contract: ReturnType["evidence_contract"]) { + const policy = record(contract.answer_policy); + return { + available_layers: stringList(contract.available_layers), + missing_route_layers: stringList(contract.missing_route_layers), + hard_blockers: stringList(contract.hard_blockers), + answer_policy: { + can_answer_direction: policy.can_answer_direction === true, + can_answer_precise_timing: policy.can_answer_precise_timing === true, + ...(typeof policy.should_lead_with_limitations === "boolean" + ? { should_lead_with_limitations: policy.should_lead_with_limitations } + : {}), + }, + ...(typeof contract.user_facing_limitation === "string" + ? { user_facing_limitation: boundedPrimitive(contract.user_facing_limitation) } + : {}), + }; +} + function hasModelOutputEvidence(value: unknown) { return value !== undefined && value !== null && (typeof value !== "object" || Object.keys(value as object).length > 0); } @@ -147,36 +306,22 @@ export function toModelOutput(context: ReturnType requiredCategories.has(card.category)) @@ -187,13 +332,7 @@ export function toModelOutput(context: ReturnType { let calls = 0; let captured: unknown; + let capturedPlan: unknown; const state = createConsultationRuntimeState(); + const plan = createConsultationPlan({ + userIntent: "事业如何", theme: "career", consultationMode: "unverified_birth_time", modelCreditCost: 1, + }); + assert.throws( + () => (plan.requestedDomains as unknown as string[]).push("timing"), + TypeError, + ); const tools = createConsultationTools({ userId: "u", sessionId: "s", requestId: "r", consultationMode: "unverified_birth_time", - serverChart, state, - runWorkflow: async (input) => { calls += 1; captured = input; return workflow(); }, + plan, + theme: "career", serverChart, state, + runWorkflow: async (input, options) => { + calls += 1; captured = input; capturedPlan = options?.plan; return workflow(); + }, }); const tool = tools["run-jyotish-consultation"]; assert.deepEqual(Object.keys((tool.inputSchema as unknown as { shape: object }).shape), ["question", "theme"]); const execute = tool.execute!; const context = { observe: { span: async (_n: string, fn: () => Promise) => fn(), log() {} } } as never; const [first, second] = await Promise.all([ - execute({ question: "事业如何", theme: "career" }, context), - execute({ question: "事业如何", theme: "career" }, context), + execute({ question: "尝试改成精确应期", theme: "timing" }, context), + execute({ question: "尝试改成婚恋", theme: "marriage" }, context), ]); assert.equal(calls, 1); assert.deepEqual(first, second); assert.deepEqual(captured, { ...serverChart.toolInput, entryMode: "direct_chart", question: "事业如何", theme: "career" }); + assert.strictEqual(capturedPlan, plan); assert.equal(state.consultationToolCallCount, 1); assert.equal(state.workflowReceipt?.preciseTiming, "blocked"); }); diff --git a/frontend/tests/consultation-context.test.ts b/frontend/tests/consultation-context.test.ts index acd037b2..3ec60579 100644 --- a/frontend/tests/consultation-context.test.ts +++ b/frontend/tests/consultation-context.test.ts @@ -59,24 +59,54 @@ test("projects only bounded server-selected evidence to the model", () => { 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 }], + ascendant: { sign: "Leo", degree: 12.5, reported_time: "private-reported-time" }, + planets: [{ name: "Sun", sign: "Capricorn", degree: 4.2, lng: "private-lng" }], houses: { first: { sign: "Leo" } }, - dasha: { current: "Mars", next: "Rahu" }, + dasha: { current: "Mars", next: "Rahu", utc_offset: "private-utc-offset" }, + shadbala: { planets: { Sun: { total_rupa: 7.1 } } }, + ashtakavarga: { house_scores: { "10": 31 } }, modules: { shadbala: { total: 412 }, ashtakavarga: { total: 28 }, - dasha_boundaries: { next: "2027-03" }, + dasha_boundaries: { next: "2027-03", utc_offset: "private-module-utc-offset" }, narayana_dasha: { current: "Aries" }, }, }, routing: { primary_theme: "career" }, - thematic_report: { themes: { career: { outlook: "steady", source: "server" } } }, + thematic_report: { + themes: { + career: { + theme: "career", + summary: "保留这条服务端证据摘要", + narrative: "事业证据叙事", + strength: "strong", + account_balance: "private-account-balance", + recommendations: ["保持稳健推进"], + evidence: [{ + technique: "D10-Dashamsha-local", + chart: "D10", + conclusion: "D10 事业分盘已进入证据链", + sentiment: "positive", + strength: "strong", + details: { + source: "chart", + derived: true, + entitlements: "private-entitlements", + access_scopes: "private-access-scopes", + }, + }], + }, + }, + }, 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 }, + technique_truth: { status: "verified", access_scopes: "private-validation-scopes" }, + answer_policy: { + can_answer_direction: true, + can_answer_precise_timing: false, + entitlements: "private-policy-entitlements", + }, user_facing_limitation: "精确月份暂不可用", }, reference_transparency: { similar_public_cases: { status: "public_context_only" } }, @@ -93,6 +123,25 @@ test("projects only bounded server-selected evidence to the model", () => { assert.equal(serialized.includes("03:04"), false); assert.equal(serialized.includes("25.03"), false); assert.equal(serialized.includes("121.56"), false); + for (const privateValue of [ + "private-reported-time", "private-lng", "private-utc-offset", "private-module-utc-offset", + "private-account-balance", "private-entitlements", "private-access-scopes", + "private-validation-scopes", "private-policy-entitlements", + ]) { + assert.equal(serialized.includes(privateValue), false); + } + for (const privateKey of [ + "reported_time", "lng", "utc_offset", "account_balance", "entitlements", "access_scopes", + ]) { + assert.equal(serialized.includes(`"${privateKey}"`), false); + } + assert.equal(serialized.includes("保留这条服务端证据摘要"), true); + assert.equal(serialized.includes("D10-Dashamsha-local"), true); + assert.equal(serialized.includes("total_rupa"), true); + assert.equal(serialized.includes("house_scores"), true); + assert.equal(serialized.includes('"dasha"'), true); + assert.equal(serialized.includes("narayana_dasha"), true); + assert.equal(serialized.includes('"status":"verified"'), true); const answerPolicy = output.evidence_contract.answer_policy as Record; assert.equal(answerPolicy.can_answer_precise_timing, false); assert.deepEqual(output.claim_cards.map((card) => card.category), ["natal_foundation", "domain", "timing", "validation"]); diff --git a/frontend/tests/consultation-plan.test.ts b/frontend/tests/consultation-plan.test.ts new file mode 100644 index 00000000..e4ae5990 --- /dev/null +++ b/frontend/tests/consultation-plan.test.ts @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { z } from "zod"; +import { + ConsultationPlanValidationError, + consultationPlanMaxCreditCost, + createConsultationPlan, + validateConsultationPlan, +} from "../src/lib/consultation-plan.ts"; + +for (const theme of ["career", "marriage", "wealth", "timing", "general"] as const) { + test(`creates a bounded server-owned ${theme} plan`, () => { + const plan = createConsultationPlan({ + userIntent: "请回答我的问题", + theme, + consultationMode: "verified_chart", + modelCreditCost: 1, + }); + assert.equal(plan.plan_version, "consultation-plan-v2"); + assert.equal(plan.birthTimeMode, "verified_chart"); + assert.equal(plan.precisionBoundary, "server_evidence_required"); + assert.equal(plan.maxCreditCost, consultationPlanMaxCreditCost); + }); +} + +test("rejects a theme outside the consultation allowlist", () => { + assert.throws( + () => createConsultationPlan({ + userIntent: "请回答我的问题", + theme: "medical" as "career", + consultationMode: "verified_chart", + }), + (error: unknown) => error instanceof z.ZodError, + ); +}); + +test("server birth-time mode fixes the precision boundary", () => { + assert.equal(createConsultationPlan({ + userIntent: "未来什么时候适合行动", + theme: "timing", + consultationMode: "unverified_birth_time", + }).precisionBoundary, "precise_timing_blocked"); + assert.equal(createConsultationPlan({ + userIntent: "什么是上升星座", + theme: "general", + consultationMode: "general_no_birth_time", + }).precisionBoundary, "precise_timing_blocked"); +}); + +test("rejects mode or precision tampering", () => { + const plan = createConsultationPlan({ + userIntent: "未来什么时候适合行动", + theme: "timing", + consultationMode: "unverified_birth_time", + }); + assert.throws( + () => validateConsultationPlan({ ...plan, precisionBoundary: "server_evidence_required" }, { + consultationMode: "unverified_birth_time", + modelCreditCost: 1, + }), + (error: unknown) => error instanceof ConsultationPlanValidationError + && error.code === "plan_precision_mismatch", + ); + assert.throws( + () => validateConsultationPlan(plan, { + consultationMode: "verified_chart", + modelCreditCost: 1, + }), + (error: unknown) => error instanceof ConsultationPlanValidationError + && error.code === "plan_mode_mismatch", + ); +}); + +test("rejects model cost above the release consultation ceiling", () => { + assert.throws( + () => createConsultationPlan({ + userIntent: "事业如何", + theme: "career", + consultationMode: "verified_chart", + modelCreditCost: consultationPlanMaxCreditCost + 1, + }), + (error: unknown) => error instanceof ConsultationPlanValidationError + && error.code === "plan_cost_ceiling_exceeded", + ); +}); + + +test("rejects non-positive or fractional model costs", () => { + for (const modelCreditCost of [0, -1, 0.5, Number.NaN, Number.POSITIVE_INFINITY]) { + assert.throws( + () => createConsultationPlan({ + userIntent: "事业如何", + theme: "career", + consultationMode: "verified_chart", + modelCreditCost, + }), + (error: unknown) => error instanceof ConsultationPlanValidationError + && error.code === "plan_cost_ceiling_exceeded", + ); + } +}); + +test("deep-freezes the server-owned plan and nested evidence lists", () => { + const plan = createConsultationPlan({ + userIntent: "事业如何", + theme: "career", + consultationMode: "verified_chart", + modelCreditCost: 1, + }); + const mutable = plan as unknown as { + precisionBoundary: string; + requestedDomains: string[]; + requiredEvidenceCategories: string[]; + }; + + assert.equal(Object.isFrozen(plan), true); + assert.equal(Object.isFrozen(plan.requestedDomains), true); + assert.equal(Object.isFrozen(plan.requiredEvidenceCategories), true); + assert.throws(() => { mutable.precisionBoundary = "precise_timing_blocked"; }, TypeError); + assert.throws(() => mutable.requestedDomains.push("timing"), TypeError); + assert.throws(() => mutable.requiredEvidenceCategories.splice(0, 1), TypeError); + assert.equal(plan.precisionBoundary, "server_evidence_required"); + assert.deepEqual(plan.requestedDomains, ["career"]); + assert.deepEqual(plan.requiredEvidenceCategories, ["natal_foundation", "domain"]); +}); diff --git a/frontend/tests/consultation-route-service.test.ts b/frontend/tests/consultation-route-service.test.ts index b560abd9..7d4aaac6 100644 --- a/frontend/tests/consultation-route-service.test.ts +++ b/frontend/tests/consultation-route-service.test.ts @@ -33,14 +33,21 @@ test("route service loads complete server truth before billing and uses only rep order.push("profile"); return profile; }, + beforeReserve({ consultationMode, serverChart }) { + order.push("plan"); + assert.equal(consultationMode, "unverified_birth_time"); + assert.equal(serverChart?.truth.selectedTimeKind, "reported"); + return Object.freeze({ version: "consultation-plan-v2" }); + }, async reserve() { order.push("reserve"); return { reservation: "ok" }; }, }); - assert.deepEqual(order, ["profile", "reserve"]); + assert.deepEqual(order, ["profile", "plan", "reserve"]); assert.deepEqual(prepared.reservation, { reservation: "ok" }); + assert.deepEqual(prepared.preReserveResult, { version: "consultation-plan-v2" }); assert.deepEqual(prepared.serverChart?.toolInput, { year: 1997, month: 8, @@ -63,6 +70,32 @@ test("route service loads complete server truth before billing and uses only rep }); }); + +test("pre-reserve plan guard sees resolved server mode and blocks billing on failure", async () => { + const order: string[] = []; + await assert.rejects(prepareConsultationRoute({ + userId: "user-1", + mode: "general_no_birth_time", + loadProfile: async () => ({ + ...profile, + reported_birth_time: "14:49:00", + birth_time_source: "family_exact", + birth_time_status: "reported", + }), + beforeReserve({ consultationMode, serverChart }) { + order.push("plan"); + assert.equal(consultationMode, "unverified_birth_time"); + assert.equal(serverChart?.toolInput.minute, 49); + throw new Error("plan rejected"); + }, + reserve: async () => { + order.push("reserve"); + return "reserved"; + }, + }), /plan rejected/); + assert.deepEqual(order, ["plan"]); +}); + test("verified route uses only server active time", async () => { const prepared = await prepareConsultationRoute({ userId: "user-1", diff --git a/frontend/tests/consultation-workflow-contract.test.ts b/frontend/tests/consultation-workflow-contract.test.ts index 98cda0c3..664ec977 100644 --- a/frontend/tests/consultation-workflow-contract.test.ts +++ b/frontend/tests/consultation-workflow-contract.test.ts @@ -9,6 +9,8 @@ const tools = readFileSync(new URL("../src/mastra/consultation-tools.ts", import 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 routeService = readFileSync(new URL("../src/lib/consultation-route-service.ts", import.meta.url), "utf8"); +const pythonPlanContract = readFileSync(new URL("../../scripts/consultation_plan_contract.py", import.meta.url), "utf8"); const stagingCompose = readFileSync(new URL("../../deploy/docker-compose.staging.yml", import.meta.url), "utf8"); @@ -18,12 +20,32 @@ test("consultation plans are server-owned and bounded", () => { assert.match(plan, /requestedDomains/); assert.match(plan, /requiredEvidenceCategories/); assert.match(plan, /createConsultationPlan/); - assert.match(tools, /createConsultationPlan\(\{ userIntent: input\.question, theme: input\.theme \}\)/); + assert.match(tools, /const plan = ctx\.plan/); + assert.match(tools, /question: plan\.userIntent,[\s\S]*theme: ctx\.theme/); + assert.doesNotMatch(tools, /createConsultationPlan/); 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); + assert.match(plan, /precisionBoundary/); + assert.match(plan, /consultationPlanMaxCreditCost = 1/); + const modelToolInput = tools.slice(tools.indexOf("const consultationToolInputSchema"), tools.indexOf("const MAX_RECORDED_STEPS")); + assert.doesNotMatch(modelToolInput, /birth|latitude|longitude|engine|script|credit/i); + const prepareCall = route.slice(route.indexOf("prepared = await prepareConsultationRoute"), route.indexOf("const modelSelection")); + assert.match(prepareCall, /beforeReserve: \(\{ consultationMode \}\) => createConsultationPlan\(\{/); + assert.ok(prepareCall.indexOf("beforeReserve:") < prepareCall.indexOf("reserve: () => reserveConsultationModel(")); + assert.ok(routeService.indexOf("const preReserveResult = input.beforeReserve") < routeService.indexOf("const reservation = await input.reserve()")); + assert.match(route, /plan: prepared\.preReserveResult,[\s\S]*theme: consultationTheme/); + assert.match(route, /runConsultationWorkflow\(toolInput, \{[\s\S]*plan: prepared\.preReserveResult/); + assert.match(workflow, /plan_version: workflowRequest\.plan_version/); + assert.match(workflow, /strict_workflow_route: workflowRequest\.strictWorkflowRoute/); + assert.match(workflow, /required_layers: workflowRequest\.requiredLayers/); + assert.match(workflow, /claim_boundary: workflowRequest\.claimBoundary/); + assert.match(workflow, /precision_boundary: workflowRequest\.precision_boundary/); + assert.match(workflow, /required_evidence_categories: workflowRequest\.required_evidence_categories/); + assert.match(pythonPlanContract, /PLAN_VERSION = "consultation-plan-v2"/); + assert.match(pythonPlanContract, /server_allowlist_validated/); + assert.match(pythonPlanContract, /can_answer_precise_timing": False/); }); test("uses one runtime step append entry and no scattered hard-coded step cap", () => { diff --git a/frontend/tests/consultation-workflow-request.test.ts b/frontend/tests/consultation-workflow-request.test.ts index cf46febb..f537c197 100644 --- a/frontend/tests/consultation-workflow-request.test.ts +++ b/frontend/tests/consultation-workflow-request.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { createConsultationPlan } from "../src/lib/consultation-plan.ts"; import { projectConsultationWorkflowRequest } from "../src/lib/consultation-workflow-request.ts"; import { runConsultationWorkflow } from "../src/mastra/index.ts"; import type { ConsultationInput } from "../src/mastra/index.ts"; @@ -9,7 +10,12 @@ test("timing questions use a legal report theme and preserve a timing route hint const question = "未来哪些阶段值得把握?"; // When: its workflow request is projected for the Python service. - const request = projectConsultationWorkflowRequest(question, "timing"); + const plan = createConsultationPlan({ + userIntent: question, + theme: "timing", + consultationMode: "unverified_birth_time", + }); + const request = projectConsultationWorkflowRequest(question, "timing", plan); // Then: the illegal public theme is converted to a legal report theme with strict route metadata. assert.equal(request.question, "应期与阶段问题:未来哪些阶段值得把握?"); @@ -18,6 +24,9 @@ test("timing questions use a legal report theme and preserve a timing route hint assert.ok(request.requiredLayers.includes("Narayana")); assert.ok(request.requiredLayers.includes("negative holdout gate")); assert.equal(request.claimBoundary, "candidate_day_month_window_only_until_holdout_passes"); + assert.equal(request.plan_version, "consultation-plan-v2"); + assert.equal(request.precision_boundary, "precise_timing_blocked"); + assert.equal(request.timing_horizon, "next_12_months"); }); test("consultation workflow allows a cold engine run to finish", async () => { @@ -68,7 +77,13 @@ test("timing input projects only legal private workflow fields", async () => { try { // When: the adapter invokes the Python workflow. - await runConsultationWorkflow(input); + await runConsultationWorkflow(input, { + plan: createConsultationPlan({ + userIntent: input.question, + theme: input.theme, + consultationMode: "unverified_birth_time", + }), + }); } finally { globalThis.fetch = originalFetch; } @@ -78,7 +93,13 @@ test("timing input projects only legal private workflow fields", async () => { assert.equal(body.question, "应期与阶段问题:未来哪些阶段值得把握?"); assert.equal(body.question_text, "应期与阶段问题:未来哪些阶段值得把握?"); assert.deepEqual(body.theme, ["career"]); - assert.doesNotMatch(requestBody, /"timing"/); + assert.equal(body.plan_version, "consultation-plan-v2"); + assert.equal(body.strict_workflow_route, "timing"); + assert.ok(body.required_layers.includes("negative holdout gate")); + assert.equal(body.claim_boundary, "candidate_day_month_window_only_until_holdout_passes"); + assert.equal(body.precision_boundary, "precise_timing_blocked"); + assert.deepEqual(body.requested_domains, ["timing"]); + assert.deepEqual(body.required_evidence_categories, ["natal_foundation", "timing", "validation"]); assert.equal(input.question, "未来哪些阶段值得把握?"); assert.equal(input.theme, "timing"); }); diff --git a/scripts/consultation_plan_contract.py b/scripts/consultation_plan_contract.py new file mode 100644 index 00000000..afebaa3a --- /dev/null +++ b/scripts/consultation_plan_contract.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Fail-closed contract for server-generated consultation workflow plans.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +PLAN_VERSION = "consultation-plan-v2" +_ALLOWED_DEPTHS = {"concise", "standard", "deep", "research"} +_ALLOWED_HORIZONS = {"next_3_months", "next_12_months", "next_24_months", "long_term"} +_ALLOWED_PRECISION_BOUNDARIES = {"server_evidence_required", "precise_timing_blocked"} +_PLAN_METADATA_KEYS = frozenset({ + "plan_version", + "strict_workflow_route", + "required_layers", + "claim_boundary", + "plan_depth", + "requested_domains", + "timing_horizon", + "precision_boundary", + "required_evidence_categories", +}) + + +@dataclass(frozen=True) +class RouteContract: + themes: tuple[str, ...] + resolved_routes: tuple[str, ...] + requested_domains: tuple[str, ...] + required_evidence_categories: tuple[str, ...] + required_layers: tuple[str, ...] + claim_boundary: str + + +_ROUTE_CONTRACTS = { + "career": RouteContract( + themes=("career",), + resolved_routes=("career",), + requested_domains=("career",), + required_evidence_categories=("natal_foundation", "domain"), + required_layers=("D1", "D10", "10th house/lord", "A10", "AmK", "Vimshottari", "Narayana", "Transit"), + claim_boundary="career_direction_and_broad_timing_only", + ), + "marriage": RouteContract( + themes=("marriage",), + resolved_routes=("relationship",), + requested_domains=("marriage",), + required_evidence_categories=("natal_foundation", "domain"), + required_layers=("D1", "D9", "7th house/lord", "Venus/Jupiter", "DK", "UL", "A7", "Vimshottari", "Narayana", "Transit"), + claim_boundary="relationship_pattern_and_broad_window_only", + ), + "wealth": RouteContract( + themes=("wealth",), + resolved_routes=("finance",), + requested_domains=("wealth",), + required_evidence_categories=("natal_foundation", "domain"), + required_layers=("D1", "D2", "D11", "2nd/11th/9th/5th houses", "Wealth Yogas", "Ashtakavarga", "Dasha"), + claim_boundary="wealth_structure_not_financial_advice", + ), + "timing": RouteContract( + themes=("career",), + resolved_routes=("timing",), + requested_domains=("timing",), + required_evidence_categories=("natal_foundation", "timing", "validation"), + required_layers=("Vimshottari", "Narayana", "Transit", "Varga", "negative holdout gate"), + claim_boundary="candidate_day_month_window_only_until_holdout_passes", + ), + "general": RouteContract( + themes=("career", "marriage", "wealth"), + resolved_routes=("general", "career", "relationship", "finance", "timing"), + requested_domains=("career", "marriage", "wealth"), + required_evidence_categories=("natal_foundation", "domain"), + required_layers=("D1", "D9", "D10", "D2", "Dasha", "Narayana", "Transit", "Functional Benefic/Malefic"), + claim_boundary="multi_domain_summary_with_missing_layers_disclosed", + ), +} + + +class ConsultationPlanContractError(ValueError): + """Raised when a versioned product plan does not match the server allowlist.""" + + +def _require_list(body: dict[str, Any], key: str) -> tuple[str, ...]: + value = body.get(key) + if not isinstance(value, list) or not value or any(not isinstance(item, str) or not item for item in value): + raise ConsultationPlanContractError(f"invalid consultation plan field: {key}") + return tuple(value) + + +def validate_consultation_plan_contract( + body: dict[str, Any], + *, + themes: list[str], + route_packet: dict[str, Any], +) -> dict[str, Any] | None: + """Validate the optional versioned plan without accepting free-form workflow control. + + Older research/MCP callers may omit ``plan_version``. Product runtime calls include it + and must match every server-owned route field exactly. + """ + + present_plan_keys = _PLAN_METADATA_KEYS.intersection(body) + if not present_plan_keys: + return None + missing_plan_keys = _PLAN_METADATA_KEYS.difference(body) + if missing_plan_keys: + missing = ", ".join(sorted(missing_plan_keys)) + raise ConsultationPlanContractError(f"incomplete consultation plan metadata: {missing}") + if body.get("plan_version") != PLAN_VERSION: + raise ConsultationPlanContractError("unsupported consultation plan version") + + strict_route = body.get("strict_workflow_route") + contract = _ROUTE_CONTRACTS.get(strict_route) + if contract is None: + raise ConsultationPlanContractError("unsupported consultation workflow route") + if tuple(themes) != contract.themes: + raise ConsultationPlanContractError("consultation plan theme mismatch") + + resolved_route = str(route_packet.get("question_type") or "") + if resolved_route not in contract.resolved_routes: + raise ConsultationPlanContractError("consultation plan route mismatch") + if _require_list(body, "required_layers") != contract.required_layers: + raise ConsultationPlanContractError("consultation plan required layers mismatch") + if body.get("claim_boundary") != contract.claim_boundary: + raise ConsultationPlanContractError("consultation plan claim boundary mismatch") + if _require_list(body, "requested_domains") != contract.requested_domains: + raise ConsultationPlanContractError("consultation plan requested domains mismatch") + if _require_list(body, "required_evidence_categories") != contract.required_evidence_categories: + raise ConsultationPlanContractError("consultation plan evidence categories mismatch") + + depth = body.get("plan_depth") + if depth not in _ALLOWED_DEPTHS: + raise ConsultationPlanContractError("unsupported consultation plan depth") + horizon = body.get("timing_horizon") + if horizon is not None and horizon not in _ALLOWED_HORIZONS: + raise ConsultationPlanContractError("unsupported consultation timing horizon") + if strict_route == "timing" and horizon is None: + raise ConsultationPlanContractError("timing consultation requires a horizon") + + precision_boundary = body.get("precision_boundary") + if precision_boundary not in _ALLOWED_PRECISION_BOUNDARIES: + raise ConsultationPlanContractError("unsupported consultation precision boundary") + + return { + "plan_version": PLAN_VERSION, + "strict_workflow_route": strict_route, + "plan_depth": depth, + "requested_domains": list(contract.requested_domains), + "timing_horizon": horizon, + "required_evidence_categories": list(contract.required_evidence_categories), + "required_layers": list(contract.required_layers), + "claim_boundary": contract.claim_boundary, + "precision_boundary": precision_boundary, + "enforcement": "server_allowlist_validated", + } + + +def apply_plan_precision_boundary( + consumer_context: dict[str, Any], + plan_contract: dict[str, Any] | None, +) -> dict[str, Any]: + """Apply only a restrictive plan boundary; evidence policy remains authoritative.""" + + if not plan_contract or plan_contract.get("precision_boundary") != "precise_timing_blocked": + return consumer_context + answer_policy = consumer_context.get("answer_policy") + if not isinstance(answer_policy, dict): + return consumer_context + return { + **consumer_context, + "answer_policy": { + **answer_policy, + "can_answer_precise_timing": False, + "should_lead_with_limitations": True, + "plan_precision_boundary": "precise_timing_blocked", + }, + } diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 3be3cec9..4e2ec022 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -848,6 +848,24 @@ def execute_consultation_workflow( Path(__file__).resolve().parents[1] / 'references/oracle/three_engine_parity_replay_manifest.json' ) route_packet = _UNIFIED_CONSULTATION_ORCHESTRATOR.resolve_route(question, themes) + try: + from scripts.consultation_plan_contract import ( + ConsultationPlanContractError, + apply_plan_precision_boundary, + validate_consultation_plan_contract, + ) + except ModuleNotFoundError: # pragma: no cover - direct script execution + from consultation_plan_contract import ( + ConsultationPlanContractError, + apply_plan_precision_boundary, + validate_consultation_plan_contract, + ) + try: + consultation_plan_contract = validate_consultation_plan_contract( + body, themes=themes, route_packet=route_packet, + ) + except ConsultationPlanContractError as exc: + raise BadRequest(str(exc)) from exc western_evidence_packet = _western_evidence_packet_from_body(body, route_packet, birth_payload=birth_payload) unified_contract = _UNIFIED_CONSULTATION_ORCHESTRATOR.shared_contract( entry_mode=entry_mode, @@ -885,6 +903,8 @@ def execute_consultation_workflow( 'executed_steps': [], 'skipped_steps': known_steps, } + if consultation_plan_contract: + result['consultation_plan_contract'] = consultation_plan_contract result['runtime_evidence_log'] = _UNIFIED_CONSULTATION_ORCHESTRATOR.runtime_evidence_log( surface=surface, entry_mode=entry_mode, @@ -1061,6 +1081,7 @@ def execute_consultation_workflow( machine_evidence_packet=machine_evidence_packet, vedastro_official=vedastro_official, ) + consumer_context = apply_plan_precision_boundary(consumer_context, consultation_plan_contract) real_case_calibration = _UNIFIED_CONSULTATION_ORCHESTRATOR.real_case_calibration_catalog( route_packet=route_packet, machine_evidence_packet=machine_evidence_packet, @@ -1094,6 +1115,7 @@ def execute_consultation_workflow( 'executed_steps': executed_steps, 'skipped_steps': skipped_steps, }, + 'consultation_plan_contract': consultation_plan_contract, 'source_priority': { 'mode': 'vedastro_official_snapshot_first', 'priority': [ diff --git a/tests/test_consultation_plan_contract.py b/tests/test_consultation_plan_contract.py new file mode 100644 index 00000000..32be28af --- /dev/null +++ b/tests/test_consultation_plan_contract.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import pytest + +from scripts.consultation_plan_contract import ( + ConsultationPlanContractError, + apply_plan_precision_boundary, + validate_consultation_plan_contract, +) + + +def timing_body(**overrides): + body = { + "plan_version": "consultation-plan-v2", + "strict_workflow_route": "timing", + "required_layers": ["Vimshottari", "Narayana", "Transit", "Varga", "negative holdout gate"], + "claim_boundary": "candidate_day_month_window_only_until_holdout_passes", + "plan_depth": "standard", + "requested_domains": ["timing"], + "timing_horizon": "next_12_months", + "precision_boundary": "precise_timing_blocked", + "required_evidence_categories": ["natal_foundation", "timing", "validation"], + } + body.update(overrides) + return body + + +def test_versioned_plan_is_validated_against_server_route_allowlist(): + contract = validate_consultation_plan_contract( + timing_body(), + themes=["career"], + route_packet={"question_type": "timing"}, + ) + + assert contract == { + "plan_version": "consultation-plan-v2", + "strict_workflow_route": "timing", + "plan_depth": "standard", + "requested_domains": ["timing"], + "timing_horizon": "next_12_months", + "required_evidence_categories": ["natal_foundation", "timing", "validation"], + "required_layers": ["Vimshottari", "Narayana", "Transit", "Varga", "negative holdout gate"], + "claim_boundary": "candidate_day_month_window_only_until_holdout_passes", + "precision_boundary": "precise_timing_blocked", + "enforcement": "server_allowlist_validated", + } + + +@pytest.mark.parametrize( + ("override", "message"), + [ + ({"plan_version": "consultation-plan-v1"}, "version"), + ({"strict_workflow_route": "free_script"}, "route"), + ({"required_layers": ["D1"]}, "required layers"), + ({"claim_boundary": "exact_date_allowed"}, "claim boundary"), + ({"requested_domains": ["career"]}, "requested domains"), + ({"required_evidence_categories": ["natal_foundation"]}, "evidence categories"), + ({"plan_depth": "unbounded"}, "depth"), + ({"timing_horizon": None}, "requires a horizon"), + ({"precision_boundary": "precise_timing_allowed"}, "precision boundary"), + ], +) +def test_versioned_plan_rejects_free_form_or_tampered_workflow_control(override, message): + with pytest.raises(ConsultationPlanContractError, match=message): + validate_consultation_plan_contract( + timing_body(**override), + themes=["career"], + route_packet={"question_type": "timing"}, + ) + + +def test_versioned_plan_rejects_theme_and_resolved_route_mismatch(): + with pytest.raises(ConsultationPlanContractError, match="theme mismatch"): + validate_consultation_plan_contract( + timing_body(), + themes=["marriage"], + route_packet={"question_type": "timing"}, + ) + with pytest.raises(ConsultationPlanContractError, match="route mismatch"): + validate_consultation_plan_contract( + timing_body(), + themes=["career"], + route_packet={"question_type": "career"}, + ) + + +def test_legacy_research_callers_without_plan_metadata_remain_compatible(): + assert validate_consultation_plan_contract( + {"question": "事业如何"}, + themes=["career"], + route_packet={"question_type": "career"}, + ) is None + + +@pytest.mark.parametrize( + "partial_body", + [ + {"strict_workflow_route": "timing"}, + {"required_layers": ["Vimshottari"]}, + {"claim_boundary": "candidate_day_month_window_only_until_holdout_passes"}, + {"plan_version": "consultation-plan-v2"}, + ], +) +def test_partial_plan_metadata_cannot_downgrade_to_legacy(partial_body): + with pytest.raises(ConsultationPlanContractError, match="incomplete consultation plan metadata"): + validate_consultation_plan_contract( + partial_body, + themes=["career"], + route_packet={"question_type": "timing"}, + ) + + +@pytest.mark.parametrize("missing_key", list(timing_body())) +def test_versioned_plan_requires_the_complete_metadata_set(missing_key): + body = timing_body() + body.pop(missing_key) + with pytest.raises(ConsultationPlanContractError, match="incomplete consultation plan metadata"): + validate_consultation_plan_contract( + body, + themes=["career"], + route_packet={"question_type": "timing"}, + ) + + +def test_blocked_plan_can_only_restrict_evidence_owned_precision_policy(): + context = { + "answer_policy": { + "can_answer_precise_timing": True, + "should_lead_with_limitations": False, + } + } + blocked = apply_plan_precision_boundary(context, {"precision_boundary": "precise_timing_blocked"}) + evidence_owned = apply_plan_precision_boundary(context, {"precision_boundary": "server_evidence_required"}) + + assert blocked["answer_policy"]["can_answer_precise_timing"] is False + assert blocked["answer_policy"]["should_lead_with_limitations"] is True + assert evidence_owned == context + + +def test_api_dry_run_exposes_validated_plan_contract_and_rejects_tampering(): + from scripts.jyotish_api_server import BadRequest, JyotishAPIHandler + + handler = JyotishAPIHandler.__new__(JyotishAPIHandler) + body = { + **timing_body(), + "dry_run": True, + "entry_mode": "direct_chart", + "question": "应期与阶段问题:未来哪些阶段值得把握?", + "theme": ["career"], + "year": 1990, + "month": 1, + "day": 1, + "hour": 12, + "minute": 0, + "lat": 25, + "lon": 121, + "tz": 8, + } + + result = handler._compute_consultation_workflow(body) + assert result["routing"]["question_type"] == "timing" + assert result["consultation_plan_contract"]["enforcement"] == "server_allowlist_validated" + + with pytest.raises(BadRequest, match="claim boundary mismatch"): + handler._compute_consultation_workflow({**body, "claim_boundary": "exact_date_allowed"})