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>
This commit is contained in:
@@ -3941,3 +3941,37 @@
|
||||
- 相关记录:ERR-103(`docs/research/pre_work_error_ledger.md`,同一 Compose 现象的误诊,本次给出真实根因)、ERR-105(同一台跳板机磁盘耗尽的基础设施记录)、BUG-264(本次被卡住无法发布的修复)
|
||||
- 复发自:无
|
||||
- 修复版本:本地未提交候选
|
||||
|
||||
## BUG-267 | 咨询证据门的三处判据都没接到权威来源:婚姻/财富的必需层从未被检查,领域边界靠猜关键词,精确应期无条件放行
|
||||
|
||||
- 状态:resolved(本地修复,待提交与发布)
|
||||
- 首次发现:2026-08-18
|
||||
- 最近更新:2026-08-18
|
||||
- 影响面:`scripts/jyotish_api_server.py` 的 `_build_consumer_context()`,即所有产品咨询(web `/api/consult`)与 MCP/研究路径共用的回答真相合同。直接影响 10 条路由中 7 条的证据门、6 个领域的解读边界,以及全部路由的精确应期许可。
|
||||
- 用户现象:手测 staging 时,一次婚姻方向的咨询回执报 `status: "ready"`、`missingLayers: []`、`preciseTiming: "allowed"`,看上去证据齐备;但同一次回答里既没有 Upapada(UL)层的任何结论,也没有性别解读边界。回执的「齐备」与回答的内容互相矛盾,而回执本身不含任何可据以追问的线索。
|
||||
- 触发条件:任何走 `marriage`、`wealth`、`health`、`education`、`migration`、`family`、`annual` 路由的咨询(即除 `career`、`timing`、`general` 外的全部路由);以及任何问题文本没落在七条关键词正则里的领域提问。
|
||||
- 根因:同一个函数里三处判据各自接错了来源。其一,`route_requirements` 的键写成 `relationship` 与 `finance`,而 `_ROUTE_DEFINITIONS` 里的路由名是 `marriage` 与 `wealth`,`.get(route, general)` 于是静默把这两条路由降级成 `general` 的门(`D1/D9/dasha_boundaries`),婚姻的 UL 与财富的 D2 从未进入检查;另有 5 条路由(health/education/migration/family/annual)压根没有条目,同样落到 `general`。`missingLayers: []` 因此不表示证据齐备,只表示没检查过。其二,7 个领域上下文层与解读边界由 7 块 `re.search` 匹配问题文本决定,而领域此时已经由模型声明并写进了 `route_packet`——用文本再猜一遍既是重复,又只能覆盖关键词表里的写法:本次实测中一句明显是情感取向的问题因为写作「情感」而非表里的「感情」,在 `marriage` 路由上完全没拿到性别解读边界。其三,`timing_layers_ready` 判断 `dasha_boundaries` 与 `narayana_dasha` 是否就绪时读的是 `missing_route_layers`,而该列表只包含**本路由要求的**层,于是对任何不要求 `narayana_dasha` 的路由恒为真,`can_answer_precise_timing` 在该层根本没算出来时也照样放行。三处的共同形态是:判据没有落在权威来源(路由表、路由声明、证据 section)上,而是落在错键、文本猜测和一个恰好为空的列表上,因此全部失败方向都是「静默放宽」。
|
||||
- 修复:把三处判据都接回权威来源。`_ROUTE_REQUIRED_LAYERS` 提为模块级常量并对 `_ROUTE_DEFINITIONS` 的 10 条路由逐条显式列出,不再依赖 `general` 兜底;表里每个层名都限定为证据包真实构建的 section(因此 `wealth` 只要求 D2 而不要求引擎当前不产出的 D11,`migration` 要求确实产出的 D4),避免把状态整体推成 `degraded`。领域上下文层与边界改为 `_ROUTE_DOMAIN_CONTEXT` 按 route 查表,7 块正则整体删除。出生时间不确定边界改从矫正闸门的真实状态派生(`effective_accuracy` 不在精确档位,或 `lagna_boundary.is_sensitive`),不再等用户把「出生时间不准」说出来;档位缺失按不确定处理——不知道精度不等于已确认精度。`timing_layers_ready` 改为直接读 `sections` 的 `status`,与路由要不要求该层无关。唯一保留文本探测的是 `precise_timing_requested`:「用户有没有要一个具体时间」是服务端没有权威来源的信号,因此改成 timing/annual 路由结构性携带、文本探测仅作叠加,一次措辞漏判不再能把信号清零。
|
||||
- 验证:`tests/test_consultation_consumer_context.py` 新增 8 条并全绿(该文件共 16 条通过)。8 条覆盖:遍历 `_ROUTE_DEFINITIONS` 断言每条路由都有自己的证据门条目(这是本该拦住本 bug 的守卫),且表里每个层名都能在真实证据包的 sections 里找到;`marriage` 缺 UL 必须报 `degraded` 且 `missingLayers == ['UL']`;`wealth` 缺 D2 同理;性别边界在不含任何关键词的措辞下仍随路由挂上,且不串入其他领域的边界;出生时间边界在「问题完全没提出生时间但精度为 1hour」时出现、在「精度 minute 且 Lagna 不敏感」时不出现;精度档位缺失按不确定处理;`minute` 档位下 Lagna 敏感仍保留边界;`marriage` 路由在 `narayana_dasha` 缺失时 `can_answer_precise_timing` 必须为 false(此时 `missingLayers` 仍为 `[]`、状态仍为 `ready`,正是旧代码放行的那个组合)。已逐条验证这 8 条在旧代码上会失败:旧表下 `marriage`/`wealth` 的门确为 `['D1','D9','dasha_boundaries']`(UL、D2 均不在内),旧正则对该措辞返回 False,旧 `timing_layers_ready` 在 `narayana_dasha` 缺失时仍为 True。未做的验证:**没有在 staging 上真实跑一次婚姻类咨询复看回执**,因此「回执与回答不再矛盾」只有单测证据,线上措辞与耗时未观测。
|
||||
- 待跟进:其一,`birth_time_rectifier.get_effective_accuracy()` 会返回 `'5min'`(声明 minute + 家人清楚记得),而 `ACCURACY_MATRIX` 没有 `'5min'` 这一行,`get_enabled_vargas()` 于是落到 `unknown` 档——比声明 `15min` 更差。本轮只让 `'5min'` 在边界判定上按不确定处理(方向正确),没有补这一行矩阵,分盘可用性仍被低估。其二,`jyotish_api_server.py` 约 7293 行处(穆胡尔塔领域选择)仍有一处同形态的关键词匹配,本轮未动。其三,`projectEvidenceContract()` 的投影范围仍不含 `deterministic_claims_forbidden_for`(见 BUG-256 待跟进),本轮未改投影层。
|
||||
- 防复发:查表取合同时不得用 `.get(key, 默认值)` 静默兜底——本次三处缺陷里有两处都是「取不到就用一个更宽松的值」,而更宽松的失败方向不会有任何人报错。凡是按路由/领域分派的表,必须有一条测试遍历权威路由集合断言逐条覆盖,并断言表里引用的层名在运行时真实存在;键名与权威定义分处两个文件时,这条测试是唯一能发现键名漂移的机制。已经由模型声明的语义(领域、路由)不得在服务端用文本正则重新推断一遍:重复推断不会更准,只会多出一处静默失效点。判断「某层是否就绪」必须直接读该层的状态,不得借道任何按条件裁剪过的列表——`missing_route_layers` 这类列表为空既可能是齐备也可能是没检查,两者不可区分。
|
||||
- 相关记录:BUG-259(同一函数上游的路由分歧,本条是「路由定了以后合同没跟上」)、BUG-256(同为回答契约投影/合并层的缺口,其待跟进项与本条同源)、BUG-268(本条的可观测性对照:回执里同样查不到模型有没有读方法)
|
||||
- 复发自:无
|
||||
- 修复版本:本地未提交候选
|
||||
|
||||
## BUG-268 | 模型有没有真的翻开方法文档,运行结束后无处可查:计数器数完就丢
|
||||
|
||||
- 状态:resolved(本地修复,待提交与发布)
|
||||
- 首次发现:2026-08-18
|
||||
- 最近更新:2026-08-18
|
||||
- 影响面:`/api/consult` 的公开回执 `AgentExecutionReceipt` 与服务端可观测事件;影响「web 输出为何不如本地 Agent」这一类问题的可诊断性。
|
||||
- 用户现象:用户对比本地 Agent 与 web 的输出质量,web 明显更弱。但两份成功回执里能看到的只有 `skill.loaded: true`、`steps: [skill, tool]` 与 `stepBudget.used: 2`,无法回答「模型到底有没有读过方法文档」——而这正是两条链路最可能的差异所在。
|
||||
- 触发条件:任何一次咨询运行结束后试图回溯模型的方法使用情况。
|
||||
- 根因:Mastra 的 `skill` 工具返回 SKILL.md 的全文指令加上 references/scripts/assets 的**文件名清单**,真正打开某份参考文档要另调 `skill_read`。`createConsultationRuntimeHooks` 确实在 `afterToolCall` 里数了 `skillReferenceReadCount`,但这个计数器既没进公开回执,也没进 `agentObservabilityEventSchema`,数完即丢。同时 `skill_read` 按设计不记成 runtime step(只有 `skill`/`tool`/`validation` 三类会记),所以 `steps` 与 `stepBudget.used` 天然看不见它;服务端日志里唯一能间接反映的是 `modelStepCount`。结果是:唯一能直接回答该问题的数字被算出来后丢弃,只留下一个需要推断的替代量。
|
||||
- 修复:把 `referenceReads` 加进回执的 `skill` 对象,并设为**必填**而非可选——正是「可选且没人填」让这个数字消失的,必填能让将来任何一处新的回执构造点无法再省掉它。同时把 `skillReferenceReads` 加进 `consultationModelStepTelemetry()`,它已被展开进可观测日志,因此服务端日志自动与 `modelStepCount` 并列拿到该值。两个构造点(工具可选路径与强制工具路径)都改为从 `state.skillReferenceReadCount` 读取。
|
||||
- 验证:`frontend/tests/consultation-agentic-runtime.test.ts` 37 项通过,其中新增 1 项:走真实 hooks,断言加载 skill 后计数仍为 0(说明「已加载」不等于「读过方法」)、两次成功的参考读取记为 2、失败的读取不计数、参考读取不出现在 steps 里(因此 steps 无法替代该字段),最后断言回执解析后 `skill.referenceReads === 2`。既有断言同步收紧:`consultationModelStepTelemetry()` 的期望值现在包含 `skillReferenceReads`。`tsc --noEmit` 清洁。未做的验证:**没有在 staging 上取一次真实回执**,因此线上那两次运行的 `referenceReads` 究竟是 0 还是别的值仍未观测——这正是本条要让它可观测的那个数。
|
||||
- 待跟进:拿到线上真实值后再判断下一步。若确认为 0,则「web 不如本地」的主因是方法文档在步数预算内从未被打开,方向应是让方法可达(预算、指令、或把关键方法上提进 SKILL.md),而不是继续加服务端约束。
|
||||
- 防复发:被数出来的诊断量必须有一个出口(回执或可观测日志),否则等于没数。当某个字段的作用正是「证明某件事发生过或没发生过」时,它在 schema 里应当必填:可选字段缺失与「值为 0」在下游无法区分,而这里 0 恰恰是最需要被看见的答案。另外,不要用 `steps` / `stepBudget.used` 推断模型的全部动作——这两者只记录被显式登记的三类步骤,模型的其余工具调用在其中不可见。
|
||||
- 相关记录:BUG-267(同一批手测暴露的另一处静默缺口)、BUG-255(步数预算被浪费,当时也依赖 `modelStepCount` 这一间接量定位)、BUG-258(同为「失败/结束时回执信息不足」的形态)
|
||||
- 复发自:无
|
||||
- 修复版本:本地未提交候选
|
||||
|
||||
@@ -681,7 +681,11 @@ export async function POST(request: Request) {
|
||||
const executionReceipt = (): AgentExecutionReceipt => ({
|
||||
runId: requestId,
|
||||
runtime: "mastra-agentic",
|
||||
skill: { name: "jyotish-vedic-astrology", loaded: state.jyotishSkillLoaded },
|
||||
skill: {
|
||||
name: "jyotish-vedic-astrology",
|
||||
loaded: state.jyotishSkillLoaded,
|
||||
referenceReads: state.skillReferenceReadCount,
|
||||
},
|
||||
steps: publicConsultationRuntimeSteps(state),
|
||||
stepBudget: consultationStepBudgetReceipt(state),
|
||||
workflow: workflowReceipt,
|
||||
@@ -745,7 +749,11 @@ export async function POST(request: Request) {
|
||||
const executionReceipt = (): AgentExecutionReceipt => ({
|
||||
runId: requestId,
|
||||
runtime: "mastra-agentic",
|
||||
skill: { name: "jyotish-vedic-astrology", loaded: state.jyotishSkillLoaded },
|
||||
skill: {
|
||||
name: "jyotish-vedic-astrology",
|
||||
loaded: state.jyotishSkillLoaded,
|
||||
referenceReads: state.skillReferenceReadCount,
|
||||
},
|
||||
steps: publicConsultationRuntimeSteps(state),
|
||||
stepBudget: consultationStepBudgetReceipt(state),
|
||||
workflow: state.workflowReceipt ?? workflowReceipt,
|
||||
|
||||
@@ -132,6 +132,9 @@ export const agentObservabilityEventSchema = z.object({
|
||||
// every attempt. Both are enum-like machine values, never provider text.
|
||||
modelFinishReason: z.enum(agentModelFinishReasons).optional(),
|
||||
modelStepCount: countSchema.optional(),
|
||||
// How many reference documents the model opened after loading the skill. Zero on a run that
|
||||
// answered a domain question means the method was never consulted, which no other field shows.
|
||||
skillReferenceReads: countSchema.optional(),
|
||||
|
||||
inputTokens: tokenCountSchema.optional(),
|
||||
outputTokens: tokenCountSchema.optional(),
|
||||
|
||||
@@ -51,6 +51,10 @@ export const agentExecutionReceiptSchema = 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(),
|
||||
|
||||
@@ -123,6 +123,7 @@ export function createConsultationRuntimeState(options: { plannedSteps?: number;
|
||||
export function consultationModelStepTelemetry(state: ConsultationRuntimeState) {
|
||||
return {
|
||||
modelStepCount: state.modelStepCount,
|
||||
skillReferenceReads: state.skillReferenceReadCount,
|
||||
...(state.modelFinishReason === undefined ? {} : { modelFinishReason: state.modelFinishReason }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ test("chat session schema preserves the safe agent execution receipt", () => {
|
||||
const receipt = {
|
||||
runId: "run-1",
|
||||
runtime: "mastra-agentic" as const,
|
||||
skill: { name: "jyotish-vedic-astrology" as const, loaded: true },
|
||||
skill: { name: "jyotish-vedic-astrology" as const, loaded: true, referenceReads: 0 },
|
||||
steps: [{ sequence: 1, kind: "skill" as const, name: "jyotish-vedic-astrology", status: "completed" as const }],
|
||||
workflow: { route: "multi-domain", status: "ready", preciseTiming: "allowed", missingLayers: [], domains: ["general", "timing"] },
|
||||
techniqueTruth: "verified",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
canonicalDomainPlan,
|
||||
consultationModelStepTelemetry,
|
||||
consultationStepBudgetReceipt,
|
||||
createConsultationRuntimeHooks,
|
||||
createConsultationTools,
|
||||
createConsultationRuntimeState,
|
||||
domainFitsRunBudget,
|
||||
@@ -625,14 +626,14 @@ test("the public receipt never carries the internal failure classification", ()
|
||||
// guards the run from failing while building a successful response.
|
||||
const receipt = agentExecutionReceiptSchema.parse({
|
||||
runId: "run", runtime: "mastra-agentic",
|
||||
skill: { name: "jyotish-vedic-astrology", loaded: true },
|
||||
skill: { name: "jyotish-vedic-astrology", loaded: true, referenceReads: 0 },
|
||||
steps,
|
||||
workflow: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] },
|
||||
});
|
||||
assert.equal(receipt.steps.length, 2);
|
||||
assert.throws(() => agentExecutionReceiptSchema.parse({
|
||||
runId: "run", runtime: "mastra-agentic",
|
||||
skill: { name: "jyotish-vedic-astrology", loaded: true },
|
||||
skill: { name: "jyotish-vedic-astrology", loaded: true, referenceReads: 0 },
|
||||
steps: state.steps,
|
||||
workflow: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] },
|
||||
}));
|
||||
@@ -644,17 +645,20 @@ test("the public receipt never carries the model step budget diagnostics", () =>
|
||||
state.modelFinishReason = "tool-calls";
|
||||
appendConsultationRuntimeStep(state, { kind: "skill", name: "jyotish-vedic-astrology", status: "completed" });
|
||||
|
||||
assert.deepEqual(consultationModelStepTelemetry(state), { modelStepCount: 8, modelFinishReason: "tool-calls" });
|
||||
assert.deepEqual(
|
||||
consultationModelStepTelemetry(state),
|
||||
{ modelStepCount: 8, skillReferenceReads: 0, modelFinishReason: "tool-calls" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
consultationModelStepTelemetry(createConsultationRuntimeState()),
|
||||
{ modelStepCount: 0 },
|
||||
{ modelStepCount: 0, skillReferenceReads: 0 },
|
||||
);
|
||||
|
||||
// The client receipt schema is strict, so leaking either field would make a
|
||||
// successful run fail while serializing its own answer.
|
||||
const receipt = agentExecutionReceiptSchema.parse({
|
||||
runId: "run", runtime: "mastra-agentic",
|
||||
skill: { name: "jyotish-vedic-astrology", loaded: true },
|
||||
skill: { name: "jyotish-vedic-astrology", loaded: true, referenceReads: 0 },
|
||||
steps: publicConsultationRuntimeSteps(state),
|
||||
stepBudget: consultationStepBudgetReceipt(state),
|
||||
workflow: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] },
|
||||
@@ -662,13 +666,35 @@ test("the public receipt never carries the model step budget diagnostics", () =>
|
||||
assert.doesNotMatch(JSON.stringify(receipt), /modelStepCount|modelFinishReason|tool-calls/);
|
||||
assert.throws(() => agentExecutionReceiptSchema.parse({
|
||||
runId: "run", runtime: "mastra-agentic",
|
||||
skill: { name: "jyotish-vedic-astrology", loaded: true },
|
||||
skill: { name: "jyotish-vedic-astrology", loaded: true, referenceReads: 0 },
|
||||
steps: publicConsultationRuntimeSteps(state),
|
||||
workflow: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] },
|
||||
...consultationModelStepTelemetry(state),
|
||||
}));
|
||||
});
|
||||
|
||||
test("the receipt reports how many reference documents the model opened", () => {
|
||||
const state = createConsultationRuntimeState();
|
||||
const hooks = createConsultationRuntimeHooks(state);
|
||||
const load = { toolName: "skill", input: { name: "jyotish-vedic-astrology" } };
|
||||
|
||||
hooks.beforeToolCall(load);
|
||||
hooks.afterToolCall(load);
|
||||
// Loading the skill supplies its instructions plus a listing of reference filenames. Opening a
|
||||
// listed document is a separate call, so a loaded skill says nothing about method being consulted.
|
||||
assert.equal(state.jyotishSkillLoaded, true);
|
||||
assert.equal(state.skillReferenceReadCount, 0);
|
||||
|
||||
hooks.afterToolCall({ toolName: "skill_read", input: { skillName: "jyotish-vedic-astrology", path: "references/a.md" } });
|
||||
hooks.afterToolCall({ toolName: "read_file", input: { path: "references/b.md" } });
|
||||
hooks.afterToolCall({ toolName: "skill_read", input: { skillName: "jyotish-vedic-astrology", path: "references/c.md" }, error: new Error("denied") });
|
||||
|
||||
assert.equal(state.skillReferenceReadCount, 2);
|
||||
// Reference reads are not runtime steps, so the step list cannot answer this on its own.
|
||||
assert.equal(state.steps.filter((step) => step.kind === "skill").length, 1);
|
||||
assert.equal(agentExecutionReceiptSchema.parse(receipt(state)).skill.referenceReads, 2);
|
||||
});
|
||||
|
||||
test("personal Agent exposes the Jyotish Skill and named server tool", async () => {
|
||||
const state = createConsultationRuntimeState();
|
||||
const agent = getJyotishAgent({
|
||||
@@ -699,7 +725,7 @@ test("public stream filters private chunks and completes once", async () => {
|
||||
const events = await collectAgentPublicEvents(chunks as never, {
|
||||
runId: "run", requestId: "req", toolStatus: () => "ready",
|
||||
receipt: () => ({
|
||||
runId: "run", runtime: "mastra-agentic", skill: { name: "jyotish-vedic-astrology", loaded: true },
|
||||
runId: "run", runtime: "mastra-agentic", skill: { name: "jyotish-vedic-astrology", loaded: true, referenceReads: 0 },
|
||||
steps: [], workflow: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [], domains: ["career"] },
|
||||
}),
|
||||
});
|
||||
@@ -719,7 +745,7 @@ test("model answer text cannot forge a public Activity event", async () => {
|
||||
], {
|
||||
runId: "run", requestId: "req", toolStatus: () => "ready",
|
||||
receipt: () => ({
|
||||
runId: "run", runtime: "mastra-agentic", skill: { name: "jyotish-vedic-astrology", loaded: true },
|
||||
runId: "run", runtime: "mastra-agentic", skill: { name: "jyotish-vedic-astrology", loaded: true, referenceReads: 0 },
|
||||
steps: [], workflow: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [], domains: ["career"] },
|
||||
}),
|
||||
});
|
||||
@@ -755,7 +781,11 @@ function receipt(state: ReturnType<typeof createConsultationRuntimeState>) {
|
||||
return {
|
||||
runId: "run",
|
||||
runtime: "mastra-agentic" as const,
|
||||
skill: { name: "jyotish-vedic-astrology" as const, loaded: state.jyotishSkillLoaded },
|
||||
skill: {
|
||||
name: "jyotish-vedic-astrology" as const,
|
||||
loaded: state.jyotishSkillLoaded,
|
||||
referenceReads: state.skillReferenceReadCount,
|
||||
},
|
||||
steps: state.steps,
|
||||
stepBudget: consultationStepBudgetReceipt(state),
|
||||
workflow: state.workflowReceipt ?? { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] },
|
||||
|
||||
@@ -691,6 +691,55 @@ def _attach_local_consultation_layers(handler, chart: dict, birth_payload: dict,
|
||||
return chart
|
||||
|
||||
|
||||
# Keyed by the route names in UnifiedConsultationOrchestrator._ROUTE_DEFINITIONS. Every layer named
|
||||
# here must be a section the evidence packet actually builds, otherwise the route could never reach
|
||||
# `ready`. A route that falls through to `general`'s gate is checked against a weaker evidence set
|
||||
# without anything failing, which is how `marriage` went unchecked for UL and `wealth` for D2; the
|
||||
# table therefore covers every defined route and a test pins that coverage (BUG-267).
|
||||
_ROUTE_REQUIRED_LAYERS = {
|
||||
'career': ('D1', 'D10', 'A10', 'dasha_boundaries', 'narayana_dasha'),
|
||||
'marriage': ('D1', 'D9', 'UL', 'dasha_boundaries'),
|
||||
'wealth': ('D1', 'D2', 'dasha_boundaries'),
|
||||
'health': ('D1', 'D9', 'dasha_boundaries'),
|
||||
'education': ('D1', 'D9', 'dasha_boundaries'),
|
||||
'migration': ('D1', 'D4', 'dasha_boundaries'),
|
||||
'family': ('D1', 'D9', 'dasha_boundaries'),
|
||||
'annual': ('D1', 'dasha_boundaries', 'narayana_dasha'),
|
||||
'timing': ('D1', 'dasha_boundaries', 'narayana_dasha'),
|
||||
'general': ('D1', 'D9', 'dasha_boundaries'),
|
||||
}
|
||||
|
||||
# The context layers and interpretation boundaries a domain carries. These follow the resolved route
|
||||
# because the route is what the answer is about. Deriving them from question wording missed every
|
||||
# phrasing outside the keyword list, so a plainly relationship-shaped question could be answered on
|
||||
# the marriage route with no gender-interpretation boundary attached.
|
||||
_ROUTE_DOMAIN_CONTEXT = {
|
||||
'marriage': {
|
||||
'layers': ('gender interpretation boundary',),
|
||||
'boundary_key': 'gender_interpretation_boundary',
|
||||
'boundary': 'Gender can affect interpretation wording and spouse-role overlays, but it must not change astronomical calculation facts.',
|
||||
},
|
||||
'health': {
|
||||
'layers': ('D6', 'D8', '6th/8th/12th house', 'health non_medical boundary'),
|
||||
'boundary_key': 'health_non_medical_boundary',
|
||||
'boundary': 'Health reading is non_medical guidance only; no diagnosis, guaranteed disease event, or treatment instruction.',
|
||||
},
|
||||
'migration': {'layers': ('12th house', 'twelfth house', 'house_12')},
|
||||
'family': {'layers': ('4th/5th/9th house',)},
|
||||
'education': {'layers': ('5th/9th house',)},
|
||||
'annual': {
|
||||
'layers': ('annual forecast boundary',),
|
||||
'boundary_key': 'annual_forecast_boundary',
|
||||
'boundary': 'Annual forecast is a bounded trend reading; no full-year certainty or exact day/month guarantee.',
|
||||
},
|
||||
}
|
||||
|
||||
# Birth-time precision tiers that need no uncertainty boundary. Anything else counts as uncertain,
|
||||
# including an absent tier and the '5min' value `get_effective_accuracy` can return but
|
||||
# `ACCURACY_MATRIX` has no row for.
|
||||
_PRECISE_BIRTH_TIME_ACCURACY = ('minute', 'rectified')
|
||||
|
||||
|
||||
def _build_consumer_context(
|
||||
*,
|
||||
question: str,
|
||||
@@ -701,21 +750,13 @@ def _build_consumer_context(
|
||||
vedastro_official: dict,
|
||||
) -> dict:
|
||||
"""Build a chat-facing truth contract without exposing provider noise as a fatal error."""
|
||||
question_text = (question or '').lower()
|
||||
sections = (
|
||||
machine_evidence_packet.get('sections')
|
||||
if isinstance(machine_evidence_packet.get('sections'), dict)
|
||||
else {}
|
||||
)
|
||||
route = str(route_packet.get('question_type') or route_packet.get('primary_theme') or 'general')
|
||||
route_requirements = {
|
||||
'career': ['D1', 'D10', 'A10', 'dasha_boundaries', 'narayana_dasha'],
|
||||
'relationship': ['D1', 'D9', 'UL', 'dasha_boundaries'],
|
||||
'finance': ['D1', 'D2', 'dasha_boundaries'],
|
||||
'timing': ['D1', 'dasha_boundaries', 'narayana_dasha'],
|
||||
'general': ['D1', 'D9', 'dasha_boundaries'],
|
||||
}
|
||||
required = route_requirements.get(route, route_requirements['general'])
|
||||
required = _ROUTE_REQUIRED_LAYERS.get(route, _ROUTE_REQUIRED_LAYERS['general'])
|
||||
available_layers = sorted(
|
||||
name for name, section in sections.items()
|
||||
if isinstance(section, dict) and section.get('status') == 'used'
|
||||
@@ -734,36 +775,27 @@ def _build_consumer_context(
|
||||
if boundary_key and boundary:
|
||||
domain_boundaries[boundary_key] = boundary
|
||||
|
||||
if re.search(r'(婚|结婚|感情|关系|配偶|伴侣|spouse|marriage|relationship)', question_text):
|
||||
domain_context = _ROUTE_DOMAIN_CONTEXT.get(route)
|
||||
if domain_context:
|
||||
add_domain_layer(
|
||||
'gender interpretation boundary',
|
||||
boundary_key='gender_interpretation_boundary',
|
||||
boundary='Gender can affect interpretation wording and spouse-role overlays, but it must not change astronomical calculation facts.',
|
||||
*domain_context['layers'],
|
||||
boundary_key=domain_context.get('boundary_key'),
|
||||
boundary=domain_context.get('boundary'),
|
||||
)
|
||||
if re.search(r'(健康|疾病|身体|病|医疗|health)', question_text):
|
||||
add_domain_layer(
|
||||
'D6', 'D8', '6th/8th/12th house', 'health non_medical boundary',
|
||||
boundary_key='health_non_medical_boundary',
|
||||
boundary='Health reading is non_medical guidance only; no diagnosis, guaranteed disease event, or treatment instruction.',
|
||||
)
|
||||
if re.search(r'(出国|移民|迁移|异地|海外|foreign|migration|relocation)', question_text):
|
||||
add_domain_layer('12th house', 'twelfth house', 'house_12')
|
||||
if re.search(r'(家庭|房产|子女|父母|家|children|family|property)', question_text):
|
||||
add_domain_layer('4th/5th/9th house')
|
||||
if re.search(r'(教育|学习|读书|考试|考证|education|study|exam)', question_text):
|
||||
add_domain_layer('5th/9th house')
|
||||
if re.search(r'(出生时间|时间不准|校正|矫正|rectification|birth time)', question_text):
|
||||
|
||||
# Birth-time uncertainty is something the server already knows from the rectification gate, so
|
||||
# read that state instead of waiting for the user to phrase it. An absent tier counts as
|
||||
# uncertain: not knowing the precision is not the same as having confirmed it.
|
||||
lagna_boundary = rectification.get('lagna_boundary') if isinstance(rectification.get('lagna_boundary'), dict) else {}
|
||||
if (
|
||||
str(rectification.get('effective_accuracy') or '') not in _PRECISE_BIRTH_TIME_ACCURACY
|
||||
or bool(lagna_boundary.get('is_sensitive'))
|
||||
):
|
||||
add_domain_layer(
|
||||
'birth_time uncertain boundary', 'not_auto_rectified',
|
||||
boundary_key='birth_time_uncertainty_boundary',
|
||||
boundary='Birth-time uncertainty requires candidate windows and event evidence; the workflow is not_auto_rectified.',
|
||||
)
|
||||
if re.search(r'(年度|一年|未来一年|年运|annual|year)', question_text):
|
||||
add_domain_layer(
|
||||
'annual forecast boundary',
|
||||
boundary_key='annual_forecast_boundary',
|
||||
boundary='Annual forecast is a bounded trend reading; no full-year certainty or exact day/month guarantee.',
|
||||
)
|
||||
d1_ready = 'D1' in available_layers and bool(chart.get('success', True))
|
||||
hard_blockers = [] if d1_ready else ['core_chart_unavailable']
|
||||
|
||||
@@ -784,8 +816,19 @@ def _build_consumer_context(
|
||||
warned_vargas = rect_summary.get('warned') if isinstance(rect_summary.get('warned'), list) else []
|
||||
disabled_vargas = rect_summary.get('disabled') if isinstance(rect_summary.get('disabled'), list) else []
|
||||
relevant_warned_vargas = [name for name in warned_vargas if name in required]
|
||||
precise_timing_requested = route == 'timing' or bool(re.search(r'(具体|精确|哪一|几月|月份|日期|何时|什么时候|时间点|年份)', question or ''))
|
||||
timing_layers_ready = all(name not in missing_route_layers for name in ('dasha_boundaries', 'narayana_dasha'))
|
||||
# Whether precision was asked for is the one signal with no server-side source: it is about the
|
||||
# request, not about our own data. The timing-family routes carry it structurally; the wording
|
||||
# probe stays only as an additive hint, so a phrasing it misses can no longer zero the signal.
|
||||
precise_timing_requested = route in ('timing', 'annual') or bool(
|
||||
re.search(r'(具体|精确|哪一|几月|月份|日期|何时|什么时候|时间点|年份)', question or '')
|
||||
)
|
||||
# Read the sections directly. Gating on `missing_route_layers` made this vacuously true for every
|
||||
# route that does not require narayana_dasha, so precise timing was granted without that layer
|
||||
# ever being checked.
|
||||
timing_layers_ready = all(
|
||||
isinstance(sections.get(name), dict) and sections[name].get('status') == 'used'
|
||||
for name in ('dasha_boundaries', 'narayana_dasha')
|
||||
)
|
||||
precision_allows_timing = not any(name in disabled_vargas for name in ('D9', 'D10'))
|
||||
can_answer_precise_timing = d1_ready and timing_layers_ready and precision_allows_timing and not missing_route_layers
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ if SCRIPTS not in sys.path:
|
||||
sys.path.insert(0, SCRIPTS)
|
||||
|
||||
from jyotish_api_server import ( # noqa: E402
|
||||
_PRECISE_BIRTH_TIME_ACCURACY,
|
||||
_ROUTE_DOMAIN_CONTEXT,
|
||||
_ROUTE_REQUIRED_LAYERS,
|
||||
JyotishAPIHandler,
|
||||
_attach_local_consultation_layers,
|
||||
_build_consumer_context,
|
||||
@@ -263,6 +266,181 @@ def test_consumer_context_does_not_surface_optional_provider_as_user_limitation(
|
||||
assert context['optional_unavailable_layers'][0]['layer'] == 'vedastro_official_cross_check'
|
||||
|
||||
|
||||
def _relationship_shaped_question() -> str:
|
||||
"""A question that is unmistakably about relationships and matches no marriage keyword.
|
||||
|
||||
The old keyword list held 感情; this says 情感. That one transposition was enough to lose the
|
||||
marriage domain's boundary on a marriage-route answer.
|
||||
"""
|
||||
|
||||
return '我情感上遇到合适的人了吗现在这个阶段'
|
||||
|
||||
|
||||
def _marriage_route() -> dict:
|
||||
return UnifiedConsultationOrchestrator().resolve_route(
|
||||
_relationship_shaped_question(), ['marriage'], declared_route='marriage',
|
||||
)
|
||||
|
||||
|
||||
def _sections(**overrides: str) -> dict:
|
||||
base = {
|
||||
'D1': 'used',
|
||||
'D2': 'used',
|
||||
'D4': 'used',
|
||||
'D9': 'used',
|
||||
'D10': 'used',
|
||||
'A10': 'used',
|
||||
'UL': 'used',
|
||||
'dasha_boundaries': 'used',
|
||||
'narayana_dasha': 'used',
|
||||
'external_oracle_status': 'official_blocked',
|
||||
}
|
||||
base.update(overrides)
|
||||
return {'sections': {name: {'status': status} for name, status in base.items()}}
|
||||
|
||||
|
||||
def _confirmed_birth_time() -> dict:
|
||||
return {
|
||||
'effective_accuracy': 'minute',
|
||||
'lagna_boundary': {'is_sensitive': False},
|
||||
'summary': {'warned': ['D7'], 'disabled': ['D30', 'D60']},
|
||||
}
|
||||
|
||||
|
||||
def test_every_orchestrator_route_declares_its_own_evidence_gate() -> None:
|
||||
"""The guard that would have caught this: no route may inherit `general`'s gate by accident.
|
||||
|
||||
`route_requirements` was keyed `relationship`/`finance` while the routes are named
|
||||
`marriage`/`wealth`, so `.get(route, general)` silently downgraded both gates and nothing failed.
|
||||
"""
|
||||
|
||||
routes = set(UnifiedConsultationOrchestrator._ROUTE_DEFINITIONS)
|
||||
assert routes <= set(_ROUTE_REQUIRED_LAYERS), sorted(routes - set(_ROUTE_REQUIRED_LAYERS))
|
||||
assert set(_ROUTE_DOMAIN_CONTEXT) <= routes, sorted(set(_ROUTE_DOMAIN_CONTEXT) - routes)
|
||||
|
||||
packet = UnifiedConsultationOrchestrator().machine_evidence_packet(
|
||||
chart=_base_chart(),
|
||||
route_packet={'question_type': 'general', 'primary_theme': 'general'},
|
||||
vedastro_official={'status': 'blocked'},
|
||||
)
|
||||
buildable = set(packet['sections'])
|
||||
for route, layers in _ROUTE_REQUIRED_LAYERS.items():
|
||||
unbuildable = sorted(set(layers) - buildable)
|
||||
assert not unbuildable, f'{route} requires layers the evidence packet never builds: {unbuildable}'
|
||||
|
||||
|
||||
def test_marriage_route_requires_upapada_however_the_question_is_worded() -> None:
|
||||
context = _build_consumer_context(
|
||||
question=_relationship_shaped_question(),
|
||||
route_packet=_marriage_route(),
|
||||
chart={'success': True},
|
||||
rectification=_confirmed_birth_time(),
|
||||
machine_evidence_packet=_sections(UL='missing'),
|
||||
vedastro_official={'status': 'blocked'},
|
||||
)
|
||||
|
||||
assert context['route'] == 'marriage'
|
||||
assert context['missing_route_layers'] == ['UL']
|
||||
assert context['core_status'] == 'degraded'
|
||||
|
||||
|
||||
def test_wealth_route_requires_the_hora_chart() -> None:
|
||||
context = _build_consumer_context(
|
||||
question='接下来的收入结构会怎么变',
|
||||
route_packet={'question_type': 'wealth', 'primary_theme': 'wealth'},
|
||||
chart={'success': True},
|
||||
rectification=_confirmed_birth_time(),
|
||||
machine_evidence_packet=_sections(D2='missing'),
|
||||
vedastro_official={'status': 'blocked'},
|
||||
)
|
||||
|
||||
assert context['missing_route_layers'] == ['D2']
|
||||
assert context['core_status'] == 'degraded'
|
||||
|
||||
|
||||
def test_domain_boundary_follows_the_route_not_the_wording() -> None:
|
||||
context = _build_consumer_context(
|
||||
question=_relationship_shaped_question(),
|
||||
route_packet=_marriage_route(),
|
||||
chart={'success': True},
|
||||
rectification=_confirmed_birth_time(),
|
||||
machine_evidence_packet=_sections(),
|
||||
vedastro_official={'status': 'blocked'},
|
||||
)
|
||||
|
||||
assert 'gender_interpretation_boundary' in context['domain_boundaries']
|
||||
assert 'gender interpretation boundary' in context['domain_context_layers']
|
||||
# A marriage answer must not pick up unrelated domains' boundaries.
|
||||
assert 'health_non_medical_boundary' not in context['domain_boundaries']
|
||||
|
||||
|
||||
def test_birth_time_boundary_reads_rectification_state_instead_of_the_question() -> None:
|
||||
uncertain = _build_consumer_context(
|
||||
question='我的事业接下来怎么走',
|
||||
route_packet={'question_type': 'career', 'primary_theme': 'career'},
|
||||
chart={'success': True},
|
||||
rectification={'effective_accuracy': '1hour', 'lagna_boundary': {'is_sensitive': False}},
|
||||
machine_evidence_packet=_sections(),
|
||||
vedastro_official={'status': 'blocked'},
|
||||
)
|
||||
confirmed = _build_consumer_context(
|
||||
question='我的事业接下来怎么走',
|
||||
route_packet={'question_type': 'career', 'primary_theme': 'career'},
|
||||
chart={'success': True},
|
||||
rectification=_confirmed_birth_time(),
|
||||
machine_evidence_packet=_sections(),
|
||||
vedastro_official={'status': 'blocked'},
|
||||
)
|
||||
|
||||
assert 'birth_time_uncertainty_boundary' in uncertain['domain_boundaries']
|
||||
assert 'birth_time_uncertainty_boundary' not in confirmed['domain_boundaries']
|
||||
assert 'minute' in _PRECISE_BIRTH_TIME_ACCURACY
|
||||
|
||||
|
||||
def test_unknown_birth_time_precision_counts_as_uncertain() -> None:
|
||||
context = _build_consumer_context(
|
||||
question='我的事业接下来怎么走',
|
||||
route_packet={'question_type': 'career', 'primary_theme': 'career'},
|
||||
chart={'success': True},
|
||||
rectification={},
|
||||
machine_evidence_packet=_sections(),
|
||||
vedastro_official={'status': 'blocked'},
|
||||
)
|
||||
|
||||
assert 'birth_time_uncertainty_boundary' in context['domain_boundaries']
|
||||
|
||||
|
||||
def test_sensitive_lagna_keeps_the_boundary_at_minute_accuracy() -> None:
|
||||
context = _build_consumer_context(
|
||||
question='我的事业接下来怎么走',
|
||||
route_packet={'question_type': 'career', 'primary_theme': 'career'},
|
||||
chart={'success': True},
|
||||
rectification={'effective_accuracy': 'minute', 'lagna_boundary': {'is_sensitive': True}},
|
||||
machine_evidence_packet=_sections(),
|
||||
vedastro_official={'status': 'blocked'},
|
||||
)
|
||||
|
||||
assert 'birth_time_uncertainty_boundary' in context['domain_boundaries']
|
||||
|
||||
|
||||
def test_precise_timing_needs_narayana_dasha_on_a_route_that_does_not_require_it() -> None:
|
||||
"""`timing_layers_ready` used to read `missing_route_layers`, which never lists a layer the route
|
||||
does not require. Precise timing was therefore granted on marriage/wealth without the layer."""
|
||||
|
||||
context = _build_consumer_context(
|
||||
question=_relationship_shaped_question(),
|
||||
route_packet=_marriage_route(),
|
||||
chart={'success': True},
|
||||
rectification=_confirmed_birth_time(),
|
||||
machine_evidence_packet=_sections(narayana_dasha='missing'),
|
||||
vedastro_official={'status': 'blocked'},
|
||||
)
|
||||
|
||||
assert context['missing_route_layers'] == []
|
||||
assert context['core_status'] == 'ready'
|
||||
assert context['answer_policy']['can_answer_precise_timing'] is False
|
||||
|
||||
|
||||
def test_failed_vedastro_raw_packet_is_not_marked_as_used() -> None:
|
||||
packet = UnifiedConsultationOrchestrator().machine_evidence_packet(
|
||||
chart=_base_chart(),
|
||||
|
||||
Reference in New Issue
Block a user