From 10ae149c581caddc628f9cb313f211fb23c7e056 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 18 Aug 2026 11:50:28 +0800 Subject: [PATCH] fix(consult): check the evidence gate against the route the answer is on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 七条路由的证据门都不是自己的: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 --- docs/BUG_HISTORY.md | 34 ++++ frontend/src/app/api/consult/route.ts | 12 +- frontend/src/lib/agent-observability.ts | 3 + frontend/src/lib/consultation-agent-events.ts | 4 + frontend/src/mastra/consultation-tools.ts | 1 + frontend/tests/chat-session-write.test.ts | 2 +- .../consultation-agentic-runtime.test.ts | 48 ++++- scripts/jyotish_api_server.py | 111 +++++++---- tests/test_consultation_consumer_context.py | 178 ++++++++++++++++++ 9 files changed, 347 insertions(+), 46 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index f5cce3af..832e4970 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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(同为「失败/结束时回执信息不足」的形态) +- 复发自:无 +- 修复版本:本地未提交候选 diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index 58b10a89..6f4faa54 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -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, diff --git a/frontend/src/lib/agent-observability.ts b/frontend/src/lib/agent-observability.ts index a3ac4ac2..9f0e1220 100644 --- a/frontend/src/lib/agent-observability.ts +++ b/frontend/src/lib/agent-observability.ts @@ -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(), diff --git a/frontend/src/lib/consultation-agent-events.ts b/frontend/src/lib/consultation-agent-events.ts index 5bc4d2cc..7c88ed7f 100644 --- a/frontend/src/lib/consultation-agent-events.ts +++ b/frontend/src/lib/consultation-agent-events.ts @@ -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(), diff --git a/frontend/src/mastra/consultation-tools.ts b/frontend/src/mastra/consultation-tools.ts index fc704fb1..a1877b7b 100644 --- a/frontend/src/mastra/consultation-tools.ts +++ b/frontend/src/mastra/consultation-tools.ts @@ -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 }), }; } diff --git a/frontend/tests/chat-session-write.test.ts b/frontend/tests/chat-session-write.test.ts index 650d6c6a..50704a12 100644 --- a/frontend/tests/chat-session-write.test.ts +++ b/frontend/tests/chat-session-write.test.ts @@ -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", diff --git a/frontend/tests/consultation-agentic-runtime.test.ts b/frontend/tests/consultation-agentic-runtime.test.ts index 77357c2f..6a67674d 100644 --- a/frontend/tests/consultation-agentic-runtime.test.ts +++ b/frontend/tests/consultation-agentic-runtime.test.ts @@ -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) { 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: [] }, diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index f129cdc5..fcf201cd 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -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 diff --git a/tests/test_consultation_consumer_context.py b/tests/test_consultation_consumer_context.py index 76c10d04..6d4192ab 100644 --- a/tests/test_consultation_consumer_context.py +++ b/tests/test_consultation_consumer_context.py @@ -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(),