From 5caf47a413139117627edaa1f6682a8f4205ee44 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 18 Aug 2026 12:45:11 +0800 Subject: [PATCH] fix(consult): require the evidence the product tells the user it will use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修完 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 --- docs/BUG_HISTORY.md | 21 ++++++++- scripts/jyotish_api_server.py | 12 +++-- scripts/run_quality_gate.py | 4 ++ tests/test_consultation_consumer_context.py | 49 +++++++++++++++++++++ 4 files changed, 81 insertions(+), 5 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 0c57ee52..eb398f2f 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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 | “今日星语”永久停在“正在结合你的星盘写今天的星语”:请求被一个反向的出生时间守卫拦住,从未发出 diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index fcf201cd..cc5c3cf4 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -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 diff --git a/scripts/run_quality_gate.py b/scripts/run_quality_gate.py index d6f2831e..a98d2094 100644 --- a/scripts/run_quality_gate.py +++ b/scripts/run_quality_gate.py @@ -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 = [ diff --git a/tests/test_consultation_consumer_context.py b/tests/test_consultation_consumer_context.py index 6d4192ab..ebf347c5 100644 --- a/tests/test_consultation_consumer_context.py +++ b/tests/test_consultation_consumer_context.py @@ -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(),