Files
Jyotisha/frontend/src/lib/consultation-agent-events.ts
T
Jesse_ChenandCursor 10ae149c58
Independent Staging Quality Gate / validate (push) Successful in 7m41s
Independent Staging Quality Gate / publish (push) Successful in 9m39s
fix(consult): check the evidence gate against the route the answer is on
七条路由的证据门都不是自己的:route_requirements 的键写成 relationship/finance,而
路由名是 marriage/wealth,另有 5 条路由压根没有条目,全部静默落到 general 的门。
missingLayers: [] 因此不表示证据齐备,只表示没检查过——婚姻的 UL 与财富的 D2 从未
进入检查。

同一函数另有两处判据也没接到权威来源。7 块正则用问题文本重猜领域,而领域早已由模型
声明并写进 route_packet,一句写作「情感」而非表里「感情」的提问在 marriage 路由上完全
拿不到性别解读边界。timing_layers_ready 读的是 missing_route_layers,该列表只装本路由
要求的层,于是对任何不要求 narayana_dasha 的路由恒为真,精确应期在该层根本没算出来时
也照样放行。三处的失败方向都是静默放宽,因此没有任何人报错。

三处都接回权威来源:10 条路由逐条显式列出必需层(层名限定为证据包真实构建的 section,
所以 wealth 不要求引擎不产出的 D11)、领域边界按 route 查表、出生时间边界从矫正闸门的
effective_accuracy 与 Lagna 敏感度派生、就绪判断直接读 section 状态。唯一保留文本探测
的是「用户有没有要一个具体日期」——服务端对此没有权威来源,改为 timing/annual 路由结构
性携带、文本仅作叠加,一次措辞漏判不再能把信号清零。

另外把 skillReferenceReadCount 暴露为回执的 skill.referenceReads(必填)与可观测日志的
skillReferenceReads。它此前数完即丢,而 skill_read 按设计不记成 runtime step,因此「模型
有没有真的翻开方法文档」在运行结束后无处可查。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 11:50:28 +08:00

117 lines
5.4 KiB
TypeScript

import { z } from "zod";
import { consultationDomainSchema, type ConsultationDomain } from "./consultation-domain-registry.ts";
export const publicActivityPhaseSchema = z.enum([
"loading-method",
"chart-calculation",
"evidence-validation",
"answer-composition",
]);
export type PublicActivityPhase = z.infer<typeof publicActivityPhaseSchema>;
export type WorkflowReceipt = Readonly<{
route: string;
status: string;
preciseTiming: string;
missingLayers: readonly string[];
domains?: readonly ConsultationDomain[];
// Requested but not calculated, because the run's wall clock could not pay
// for them. Present so a partial plan cannot be read as a complete one.
omittedDomains?: readonly ConsultationDomain[];
}>;
export const workflowReceiptSchema: z.ZodType<WorkflowReceipt> = z.object({
route: z.string().max(120),
status: z.string().max(120),
preciseTiming: z.string().max(120),
missingLayers: z.array(z.string().max(120)).max(30),
domains: z.array(consultationDomainSchema).min(1).max(6).optional(),
omittedDomains: z.array(consultationDomainSchema).min(1).max(6).optional(),
}).strict();
const executionStepSchema = z.object({
sequence: z.number().int().min(1).max(32),
kind: z.enum(["skill", "tool", "validation"]),
name: z.string().max(120),
status: z.enum(["completed", "failed"]),
durationMs: z.number().int().min(0).optional(),
}).strict();
const stepBudgetSchema = z.object({
planned: z.number().int().min(1).max(32),
used: z.number().int().min(0).max(32),
remaining: z.number().int().min(0).max(32),
truncated: z.boolean(),
}).strict();
export const agentExecutionReceiptSchema = z.object({
runId: z.string().min(1).max(120),
runtime: z.literal("mastra-agentic"),
skill: z.object({
name: z.literal("jyotish-vedic-astrology"),
loaded: z.boolean(),
version: z.string().max(120).optional(),
// How many reference documents the model opened after loading the skill. Required rather than
// optional: the count was tracked in runtime state and surfaced nowhere, so "did the model
// consult the method at all" was unanswerable from a finished run. Zero is a real answer.
referenceReads: z.number().int().min(0).max(64),
}).strict(),
steps: z.array(executionStepSchema).max(32),
stepBudget: stepBudgetSchema.optional(),
workflow: workflowReceiptSchema,
techniqueTruth: z.string().max(120).optional(),
}).strict();
export type AgentExecutionReceipt = z.infer<typeof agentExecutionReceiptSchema>;
const runStartedSchema = z.object({ type: z.literal("run.started"), runId: z.string(), requestId: z.string() }).strict();
const skillStartedSchema = z.object({ type: z.literal("skill.started"), name: z.literal("jyotish-vedic-astrology") }).strict();
const skillCompletedSchema = z.object({ type: z.literal("skill.completed"), name: z.literal("jyotish-vedic-astrology") }).strict();
const toolStartedSchema = z.object({ type: z.literal("tool.started"), callId: z.string(), tool: z.literal("run-jyotish-consultation"), label: z.string() }).strict();
const activitySchema = z.object({ type: z.literal("activity"), phase: publicActivityPhaseSchema, label: z.string().max(120) }).strict();
const toolCompletedSchema = z.object({
type: z.literal("tool.completed"), callId: z.string(), tool: z.literal("run-jyotish-consultation"),
status: z.enum(["ready", "degraded", "blocked"]), durationMs: z.number().int().min(0),
}).strict();
const toolFailedSchema = z.object({
type: z.literal("tool.failed"), callId: z.string(), tool: z.literal("run-jyotish-consultation"),
code: z.enum(["calculation_failed", "timeout", "cancelled"]),
}).strict();
const answerDeltaSchema = z.object({ type: z.literal("answer.delta"), text: z.string() }).strict();
const runCompletedSchema = z.object({ type: z.literal("run.completed"), receipt: agentExecutionReceiptSchema }).strict();
// A failure is the case the receipt is most needed for, so it carries the same
// allowlisted receipt a completed run does. It stays optional because the
// receipt is built from live state that a hard failure may leave unparseable,
// and losing the whole failure event would be worse than losing its receipt.
const runFailedSchema = z.object({
type: z.literal("run.failed"),
code: z.enum(["runtime_contract_incomplete", "calculation_failed", "empty_answer", "cancelled"]),
message: z.string().max(200),
receipt: agentExecutionReceiptSchema.optional(),
}).strict();
export const consultationAgentPublicEventSchema = z.discriminatedUnion("type", [
runStartedSchema, skillStartedSchema, skillCompletedSchema, toolStartedSchema, activitySchema,
toolCompletedSchema, toolFailedSchema, answerDeltaSchema, runCompletedSchema, runFailedSchema,
]);
export type ConsultationAgentPublicEvent = z.infer<typeof consultationAgentPublicEventSchema>;
export function createNdjsonParser(onEvent: (event: ConsultationAgentPublicEvent) => void) {
let buffer = "";
function consume(value: string, final: boolean) {
buffer += value;
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (line.trim()) onEvent(consultationAgentPublicEventSchema.parse(JSON.parse(line)));
}
if (final && buffer.trim()) {
onEvent(consultationAgentPublicEventSchema.parse(JSON.parse(buffer)));
buffer = "";
}
}
return Object.freeze({
push: (value: string) => consume(value, false),
finish: (value = "") => consume(value, true),
});
}