feat(consultation): enforce server plan boundaries
This commit is contained in:
+4
-4
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
@@ -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': [
|
||||
|
||||
@@ -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"})
|
||||
Reference in New Issue
Block a user