feat(consultation): enforce server plan boundaries

This commit is contained in:
Jesse_Chen
2026-08-14 20:22:52 +08:00
parent 09f811a79e
commit 65a75ab29f
16 changed files with 997 additions and 74 deletions
+4 -4
View File
@@ -2749,14 +2749,14 @@
- 状态:resolvedstaging 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 的完整 metadatapartial 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
+33 -4
View File
@@ -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<Awaited<ReturnType<typeof resolveSessionLanguageModel>>>;
type ReservationResult = { success: boolean; credits: number | null; error_code: string | null };
type ModelSelection = Awaited<ReturnType<typeof reserveConsultationModel<SelectedModel, ReservationResult>>>;
let prepared: Awaited<ReturnType<typeof prepareConsultationRoute<ModelSelection>>>;
let prepared: PreparedConsultationRoute<ModelSelection, ConsultationPlan>;
try {
prepared = await prepareConsultationRoute({
prepared = await prepareConsultationRoute<ModelSelection, ConsultationPlan>({
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);
+73 -3
View File
@@ -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<typeof consultationPlanSchema>;
type DeepReadonly<T> = T extends (...args: never[]) => unknown
? T
: T extends readonly (infer Item)[]
? readonly DeepReadonly<Item>[]
: T extends object
? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
: T;
export type ConsultationPlan = DeepReadonly<z.infer<typeof consultationPlanSchema>>;
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<ConsultationPlanTheme, ConsultationPlan["requestedDomains"]> = {
career: ["career"],
marriage: ["marriage"],
@@ -32,17 +64,55 @@ const evidenceByTheme: Record<ConsultationPlanTheme, ConsultationPlan["requiredE
general: ["natal_foundation", "domain"],
};
function deepFreeze<T>(value: T): DeepReadonly<T> {
if (value && typeof value === "object" && !Object.isFrozen(value)) {
for (const child of Object.values(value)) deepFreeze(child);
Object.freeze(value);
}
return value as DeepReadonly<T>;
}
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 });
}
+32 -3
View File
@@ -59,14 +59,34 @@ export type ServerChartConsultation = Readonly<{
}>;
}>;
type ConsultationPreReserveContext = Readonly<{
consultationMode: ConsultationBirthTimeMode;
serverChart: ServerChartConsultation | null;
}>;
type PrepareConsultationRouteInput<Reservation> = Readonly<{
userId: string;
mode: ConsultationBirthTimeMode;
loadProfile: (userId: string) => Promise<unknown>;
resolveTimezoneOffset?: (profile: unknown, selectedTime?: string) => Promise<unknown>;
beforeReserve?: (context: ConsultationPreReserveContext) => unknown | Promise<unknown>;
reserve: () => Promise<Reservation>;
}>;
type PrepareConsultationRouteWithGuard<Reservation, GuardResult> = Omit<
PrepareConsultationRouteInput<Reservation>,
"beforeReserve"
> & Readonly<{
beforeReserve: (context: ConsultationPreReserveContext) => GuardResult | Promise<GuardResult>;
}>;
export type PreparedConsultationRoute<Reservation, GuardResult = undefined> = Readonly<{
consultationMode: ConsultationBirthTimeMode;
serverChart: ServerChartConsultation | null;
reservation: Reservation;
preReserveResult: GuardResult;
}>;
type RecordValue = Record<string, unknown>;
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<Reservation>(
export function prepareConsultationRoute<Reservation, GuardResult>(
input: PrepareConsultationRouteWithGuard<Reservation, GuardResult>,
): Promise<PreparedConsultationRoute<Reservation, Awaited<GuardResult>>>;
export function prepareConsultationRoute<Reservation>(
input: PrepareConsultationRouteInput<Reservation>,
) {
): Promise<PreparedConsultationRoute<Reservation>>;
export async function prepareConsultationRoute<Reservation, GuardResult>(
input: PrepareConsultationRouteInput<Reservation> | PrepareConsultationRouteWithGuard<Reservation, GuardResult>,
): Promise<PreparedConsultationRoute<Reservation, Awaited<GuardResult> | undefined>> {
let profile: unknown;
try {
profile = await input.loadProfile(input.userId);
@@ -286,6 +312,9 @@ export async function prepareConsultationRoute<Reservation>(
}
serverChart = serverChartFromProfile(profile, consultationMode);
}
const preReserveResult = input.beforeReserve
? await input.beforeReserve({ consultationMode, serverChart }) as Awaited<GuardResult>
: undefined;
const reservation = await input.reserve();
return Object.freeze({ consultationMode, serverChart, reservation });
return Object.freeze({ consultationMode, serverChart, reservation, preReserveResult });
}
@@ -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<ConsultationTheme, Omit<WorkflowProjection, "question"> & { prefix?: string }> = {
const routeRequirements: Record<ConsultationTheme, Omit<WorkflowProjection, "question" | "plan_version" | "depth" | "requested_domains" | "timing_horizon" | "precision_boundary" | "required_evidence_categories"> & { prefix?: string }> = {
career: {
themes: ["career"],
strictWorkflowRoute: "career",
@@ -53,7 +68,11 @@ const routeRequirements: Record<ConsultationTheme, Omit<WorkflowProjection, "que
},
};
export function projectConsultationWorkflowRequest(question: string, theme: ConsultationTheme) {
export function projectConsultationWorkflowRequest(
question: string,
theme: ConsultationTheme,
plan: ConsultationWorkflowPlanMetadata,
) {
const requirement = routeRequirements[theme];
return {
question: `${requirement.prefix ?? ""}${question}`,
@@ -61,5 +80,11 @@ export function projectConsultationWorkflowRequest(question: string, theme: Cons
strictWorkflowRoute: requirement.strictWorkflowRoute,
requiredLayers: requirement.requiredLayers,
claimBoundary: requirement.claimBoundary,
plan_version: plan.plan_version,
depth: plan.depth,
requested_domains: plan.requestedDomains,
timing_horizon: plan.timingHorizon,
precision_boundary: plan.precisionBoundary,
required_evidence_categories: plan.requiredEvidenceCategories,
} satisfies WorkflowProjection;
}
+7 -4
View File
@@ -2,7 +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 { ConsultationPlan, ConsultationPlanTheme } from "../lib/consultation-plan.ts";
import type { WorkflowReceipt } from "../lib/consultation-agent-events.ts";
import {
consultationInputSchema,
@@ -85,6 +85,8 @@ export type ConsultationAgentContext = Readonly<{
sessionId: string;
requestId: string;
consultationMode: Exclude<ConsultationBirthTimeMode, "general_no_birth_time">;
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);
+176 -37
View File
@@ -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<typeof consultationEvidencePacketSchema>;
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<string>,
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<typeof toAgentConsultationContext>) {
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<typeof toAgentConsultationContext>) {
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<typeof toAgentConsultationContext>) {
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<typeof toAgentConsultationContext>["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<typeof toAgentConsultationCont
{
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,
}),
evidence: projectNatalFoundation(context),
},
{
category: "domain" as const,
source: "server_workflow" as const,
evidence: compactModelOutput(context.thematic_evidence),
evidence: projectDomainEvidence(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,
}),
evidence: projectTimingEvidence(context),
},
{
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,
}),
evidence: projectValidationEvidence(context),
},
]
.filter((card) => requiredCategories.has(card.category))
@@ -187,13 +332,7 @@ export function toModelOutput(context: ReturnType<typeof toAgentConsultationCont
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),
},
evidence_contract: projectEvidenceContract(contract),
claim_cards: cards,
rectification: { boundary: context.rectification.boundary },
});
@@ -8,6 +8,7 @@ import {
} from "../src/mastra/consultation-tools.ts";
import { getJyotishAgent } from "../src/mastra/index.ts";
import { consultationAgentPublicEventSchema, createNdjsonParser } from "../src/lib/consultation-agent-events.ts";
import { createConsultationPlan } from "../src/lib/consultation-plan.ts";
import { collectAgentPublicEvents, ensureFinalResponseText, streamAgentResponse } from "../src/lib/stream-agent-response.ts";
const serverChart = {
@@ -38,23 +39,35 @@ function workflow() {
test("context-bound tool exposes only question/theme and calculates once", async () => {
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<unknown>) => 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");
});
+56 -7
View File
@@ -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<string, unknown>;
assert.equal(answerPolicy.can_answer_precise_timing, false);
assert.deepEqual(output.claim_cards.map((card) => card.category), ["natal_foundation", "domain", "timing", "validation"]);
+125
View File
@@ -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"]);
});
@@ -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",
@@ -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", () => {
@@ -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");
});
+178
View File
@@ -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",
},
}
+22
View File
@@ -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': [
+165
View File
@@ -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"})