fix(consult): require the evidence the product tells the user it will use
修完 BUG-267 后核对同一张表时发现,前端领域注册表早就为每个领域声明了 requiredLayers, 键名正确,并且这份列表驱动界面上的 evidencePreview——也就是产品明确告诉用户「这次会用 这些证据」。服务端的门是另写的一份,两份用不同词汇描述同一个合同,彼此没有任何链接, 对照下来门少查三处:marriage 声明 A7 而门只要求 UL,wealth 声明 Ashtakavarga 而门只 要求 D2,general 声明 D10 与 D2 而门只要求 D1/D9。三者实测在真实排盘中都是 used,补进 去不会把状态推成 degraded。失败方向仍是静默放宽,因此没有任何人报错。 新增一条测试解析前端注册表,断言其中每个指向真实 section 的条目都出现在服务端的门里。 只比对真实 section:注册表里还有人看的标签(7th house/lord、negative holdout gate)和 引擎压根不产出的 D11,机械全量对齐会把每条路由钉死在 degraded。该测试 fail-closed—— 先断言 10 条路由全部解析到且列表非空,否则一次正则失配就会让它无声通过。 另外把 tests/test_consultation_consumer_context.py 加进 CORE_PYTEST_TARGETS:该文件 不在任何 CI 档位里,BUG-267 的 8 条与本条的 1 条此前都只在本地手动跑过,staging 门用的 是 --profile quick,从不运行它们。防漂移的钉子本身没人跑,等于没钉。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+19
-2
@@ -3955,9 +3955,9 @@
|
||||
- 验证:`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-259(同一函数上游的路由分歧,本条是「路由定了以后合同没跟上」)、BUG-256(同为回答契约投影/合并层的缺口,其待跟进项与本条同源)、BUG-268(本条的可观测性对照:回执里同样查不到模型有没有读方法)、BUG-269(修完本条核对同一张表时发现门仍比产品声明松三处)
|
||||
- 复发自:无
|
||||
- 修复版本:本地未提交候选
|
||||
- 修复版本:`10ae149c`(staging)
|
||||
|
||||
## BUG-268 | 模型有没有真的翻开方法文档,运行结束后无处可查:计数器数完就丢
|
||||
|
||||
@@ -3974,6 +3974,23 @@
|
||||
- 防复发:被数出来的诊断量必须有一个出口(回执或可观测日志),否则等于没数。当某个字段的作用正是「证明某件事发生过或没发生过」时,它在 schema 里应当必填:可选字段缺失与「值为 0」在下游无法区分,而这里 0 恰恰是最需要被看见的答案。另外,不要用 `steps` / `stepBudget.used` 推断模型的全部动作——这两者只记录被显式登记的三类步骤,模型的其余工具调用在其中不可见。
|
||||
- 相关记录:BUG-267(同一批手测暴露的另一处静默缺口)、BUG-255(步数预算被浪费,当时也依赖 `modelStepCount` 这一间接量定位)、BUG-258(同为「失败/结束时回执信息不足」的形态)
|
||||
- 复发自:无
|
||||
- 修复版本:`10ae149c`(staging)
|
||||
|
||||
## BUG-269 | 证据门比产品自己向用户预告的层更松:两份声明分处两端且没有任何机制保证一致
|
||||
|
||||
- 状态:resolved(本地修复,待提交与发布)
|
||||
- 首次发现:2026-08-18
|
||||
- 最近更新:2026-08-18
|
||||
- 影响面:`scripts/jyotish_api_server.py` 的 `_ROUTE_REQUIRED_LAYERS` 与 `frontend/src/lib/consultation-domain-registry.ts` 的 `requiredLayers`。涉及 `marriage`、`wealth`、`general` 三条路由的证据门,其中 `general` 是最常走的兜底路由。
|
||||
- 用户现象:暂无可见现象。与 BUG-267 同一形态——门比声明松,失败方向是静默放宽,健康运行里看不出差别。
|
||||
- 触发条件:任何走 `marriage`、`wealth`、`general` 路由的咨询,且该路由声明要用的某一层实际缺失。
|
||||
- 根因:修完 BUG-267 后核对时发现,前端领域注册表早就为每个领域声明了 `requiredLayers`,键名正确(`marriage`/`wealth`),并且这份列表会驱动界面上的 `evidencePreview`——也就是产品明确告诉用户「这次会用这些证据」。但服务端的门是另写的一份,两份用不同词汇描述同一个合同,彼此没有任何链接。对照下来门少查三处:`marriage` 声明了 A7(Darapada)而门只要求 UL,`wealth` 声明了 Ashtakavarga 而门只要求 D2,`general` 声明了 D10 与 D2 而门只要求 D1/D9。三者实测在真实排盘中都是 `used`,所以补进去不会把状态推成 `degraded`。这正是 BUG-267 里键名漂移能发生两个月的同一片土壤:合同有两份,没有一份是权威。
|
||||
- 修复:`_ROUTE_REQUIRED_LAYERS` 补上 `marriage` 的 A7、`wealth` 的 ashtakavarga、`general` 的 D10 与 D2。新增一条测试解析前端注册表,断言其中每个**指向真实 section** 的条目都出现在服务端的门里。之所以只比对真实 section:注册表里同时有人看的标签(`7th house/lord`、`negative holdout gate`)和引擎压根不产出的 D11,机械全量对齐会把每条路由钉死在 `degraded`。该测试对解析失败采取 fail-closed——先断言 10 条路由全部解析到且列表非空,否则一次正则失配就会让它无声通过。另外把 `tests/test_consultation_consumer_context.py` 加进 `run_quality_gate.py` 的 `CORE_PYTEST_TARGETS`:核对时发现该文件**不在任何 CI 档位里**,BUG-267 的 8 条与本条的 1 条此前都只在本地手动执行过,staging 门(`--profile quick`)从不运行它们。防漂移的钉子本身没人跑,等于没钉。
|
||||
- 验证:`tests/test_consultation_consumer_context.py` 17 条通过(新增 1 条)。已验证这条钉子是真守卫而非恰好通过:分别从 `marriage` 去掉 A7、`wealth` 去掉 ashtakavarga、`general` 去掉 D10,三次都报出对应路由与缺失层,恢复后通过。另用真实排盘跑了 general/marriage/wealth/career/timing/health/annual 七条路由,收紧后 `core_status` 仍为 `ready`、`missing_route_layers` 仍为 `[]`,确认没有把既有运行推成 `degraded`。未做的验证:**没有在 staging 上真实跑一次复看回执**,线上表现未观测。
|
||||
- 待跟进:D11 是另一个方向的口径不一致——前端向用户预告 `wealth` 会用 D11,而引擎从不构建这个 section,属于「承诺了不存在的证据」,与本条(门比承诺松)反向。本轮未处理。更彻底的方向是让两端读同一份声明而不是靠测试比对,但两套词汇(机器 section 名 vs 人看的标签)先得想清楚归一到哪一套。
|
||||
- 防复发:同一个合同不得有两份声明而没有一份权威。当暂时无法归一时,必须有一条测试把两份的交集钉住,并且该测试要 fail-closed——跨文件、跨语言的比对里,解析失配导致的「无声通过」比比对失败更危险。补门之前先用真实数据确认该层稳定产出:把引擎不产出的层写进必需集会把状态恒推成 `degraded`,这与放太松同样是错的,只是方向相反。写完回归测试后必须回头确认它在 CI 的哪个档位里真的会被执行:`run_quality_gate.py` 有多组 pytest 目标,staging 门只跑 `CORE_PYTEST_TARGETS`,写进 `RUNTIME_TRUTH_PYTEST_TARGETS` 或压根不进列表的文件在 staging push 上一次都不会跑。「本地全绿」不等于「门会拦住它」。
|
||||
- 相关记录:BUG-267(本条是修完它以后核对同一张表时发现的,同源同形态)、BUG-259(同为路由与合同之间的口径不一致)
|
||||
- 复发自:无
|
||||
- 修复版本:本地未提交候选
|
||||
|
||||
## BUG-269 | “今日星语”永久停在“正在结合你的星盘写今天的星语”:请求被一个反向的出生时间守卫拦住,从未发出
|
||||
|
||||
@@ -696,17 +696,23 @@ def _attach_local_consultation_layers(handler, chart: dict, birth_payload: dict,
|
||||
# `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).
|
||||
#
|
||||
# The gate must also not be weaker than what the product tells the user it will use. The frontend
|
||||
# domain registry declares each domain's layers for its evidence preview, and every entry there that
|
||||
# names a real section is required here too; a test pins that intersection (BUG-269). The registry
|
||||
# additionally names human-facing items ('7th house/lord', 'negative holdout gate') and D11, which
|
||||
# the engine does not build at all — those are deliberately not requirements.
|
||||
_ROUTE_REQUIRED_LAYERS = {
|
||||
'career': ('D1', 'D10', 'A10', 'dasha_boundaries', 'narayana_dasha'),
|
||||
'marriage': ('D1', 'D9', 'UL', 'dasha_boundaries'),
|
||||
'wealth': ('D1', 'D2', 'dasha_boundaries'),
|
||||
'marriage': ('D1', 'D9', 'UL', 'A7', 'dasha_boundaries'),
|
||||
'wealth': ('D1', 'D2', 'ashtakavarga', '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'),
|
||||
'general': ('D1', 'D9', 'D10', 'D2', 'dasha_boundaries'),
|
||||
}
|
||||
|
||||
# The context layers and interpretation boundaries a domain carries. These follow the resolved route
|
||||
|
||||
@@ -51,6 +51,10 @@ CORE_PYTEST_TARGETS = [
|
||||
"tests/test_oracle_collection_queue.py",
|
||||
"tests/test_oracle_evidence_validator.py",
|
||||
"tests/test_external_oracle_sanity_closure.py",
|
||||
# The staging gate runs the quick profile, so a guard absent from this list never runs in CI.
|
||||
# These pin the answer-truth contract every product consultation is built on, and their failure
|
||||
# mode is silent widening — nothing errors when they regress (BUG-267, BUG-269).
|
||||
"tests/test_consultation_consumer_context.py",
|
||||
]
|
||||
|
||||
RUNTIME_TRUTH_PYTEST_TARGETS = [
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
SCRIPTS = os.path.join(os.path.dirname(__file__), '..', 'scripts')
|
||||
@@ -289,8 +290,10 @@ def _sections(**overrides: str) -> dict:
|
||||
'D4': 'used',
|
||||
'D9': 'used',
|
||||
'D10': 'used',
|
||||
'A7': 'used',
|
||||
'A10': 'used',
|
||||
'UL': 'used',
|
||||
'ashtakavarga': 'used',
|
||||
'dasha_boundaries': 'used',
|
||||
'narayana_dasha': 'used',
|
||||
'external_oracle_status': 'official_blocked',
|
||||
@@ -329,6 +332,52 @@ def test_every_orchestrator_route_declares_its_own_evidence_gate() -> None:
|
||||
assert not unbuildable, f'{route} requires layers the evidence packet never builds: {unbuildable}'
|
||||
|
||||
|
||||
def _frontend_declared_layers() -> dict[str, list[str]]:
|
||||
"""Read each domain's declared layers out of the frontend registry.
|
||||
|
||||
Parsing TypeScript from a Python test is not pretty, but the alternative is what this pins
|
||||
against: two lists describing the same contract with no mechanism keeping them in step.
|
||||
"""
|
||||
|
||||
registry = os.path.join(
|
||||
os.path.dirname(__file__), '..', 'frontend', 'src', 'lib', 'consultation-domain-registry.ts',
|
||||
)
|
||||
with open(registry, encoding='utf-8') as handle:
|
||||
source = handle.read()
|
||||
declared: dict[str, list[str]] = {}
|
||||
for match in re.finditer(r'\{\s*id:\s*"(\w+)".*?requiredLayers:\s*\[(.*?)\]', source):
|
||||
declared[match.group(1)] = [
|
||||
value.strip().strip('"') for value in match.group(2).split(',') if value.strip()
|
||||
]
|
||||
return declared
|
||||
|
||||
|
||||
def test_evidence_gate_is_never_weaker_than_the_layers_the_product_promises() -> None:
|
||||
"""Whatever the frontend tells the user it will use must actually gate the answer.
|
||||
|
||||
Only entries naming a real evidence section are compared. The registry also carries human-facing
|
||||
labels ('7th house/lord', 'negative holdout gate') and D11, which the engine never builds; those
|
||||
cannot be requirements, and demanding them would pin every route to `degraded`.
|
||||
"""
|
||||
|
||||
declared = _frontend_declared_layers()
|
||||
routes = set(UnifiedConsultationOrchestrator._ROUTE_DEFINITIONS)
|
||||
# Fail closed: a parse that silently found nothing would make this test vacuously pass.
|
||||
assert routes <= set(declared), sorted(routes - set(declared))
|
||||
assert all(declared[route] for route in routes)
|
||||
|
||||
packet = UnifiedConsultationOrchestrator().machine_evidence_packet(
|
||||
chart=_base_chart(),
|
||||
route_packet={'question_type': 'general', 'primary_theme': 'general'},
|
||||
vedastro_official={'status': 'blocked'},
|
||||
)
|
||||
by_lowercase = {name.lower(): name for name in packet['sections']}
|
||||
for route in sorted(routes):
|
||||
real = [by_lowercase[v.lower()] for v in declared[route] if v.lower() in by_lowercase]
|
||||
missing = [name for name in real if name not in _ROUTE_REQUIRED_LAYERS[route]]
|
||||
assert not missing, f'{route} promises {missing} but its evidence gate does not check them'
|
||||
|
||||
|
||||
def test_marriage_route_requires_upapada_however_the_question_is_worded() -> None:
|
||||
context = _build_consumer_context(
|
||||
question=_relationship_shaped_question(),
|
||||
|
||||
Reference in New Issue
Block a user