feat(v2): T09 预审与推进沙盘——运行只读视图、演示情景与界面守卫

覆盖 AC-11/12/29/30/31/42/44(BR-11/29/30/41/43、E-20/21/22)。

后端:
- contracts/simulation.py:Simulation/SimulationAssumption/ScenarioDimensionState。
  契约层直接禁止 production 域携带演示估计(E-21);ready 必须有结果、
  failed 必须带错误码且不得显示结果、计算中不得预告乐观增幅。
- services/prescreen_sandbox.py:演示情景表(DEMO-TABLE-v1)与推演入口。
  组合查表不是单项加和(assert_not_additive 把这条写成可执行断言);
  只改被假设的维度,其余维度原样保留;失败保留基线并标未完成。
- services/prescreen_store.py:运行视图只读存储,复用 assistant_objects 新 kind
  prescreen_run,不新建表;只提供保存与读取,要改结果必须产生新修订。
- api/prescreen.py:新增 3 个只读端点(运行列表、运行视图、可用假设动作)
  与 1 个情景端点;请求体里没有任何概率字段。越域返回 E-21、冲突假设 E-22、
  过期修订 409。
- contracts/prescreen.py:补 E-20/21/22 三个错误码与其 HTTP 映射。

前端(挂进既有工作台「材料与问题」页,不新建路由、不新增样式表):
- src/lib/prescreen-run.ts:客户端契约、呈现判定(同刻度 3–8 维才画雷达)、
  演示值域判定、来源与数据域徽标(文字+图标+样式,去色仍可区分)。
- src/components/xingbang/PrescreenPanel.tsx:主审意见、演示值(紧邻固定说明)、
  维度雷达与等价文字列表、维度依据抽屉、假设勾选即时推演。
  旧请求晚到或结果与当前选择不一致即丢弃;请求失败保留基线并标未完成。
- scripts/test_prescreen_ui.cjs:界面源码守卫,已注册进 scripts/verify.py。

未做:运行装配结果的写入方仍是执行链路(T10 范围),当前由测试写入;
沙盘尚未接入真实执行,界面数值全部为服务端演示表值。

验证:backend 全量 unittest 469 项 OK;前端 npm run build 通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-30 09:15:57 +08:00
co-authored by Claude Code
parent 501a4d9c9b
commit 22f8a37799
12 changed files with 1297 additions and 2 deletions
+194
View File
@@ -0,0 +1,194 @@
"""T09 推进沙盘测试:AC-11/12/30/31/44(BR-11/29/30、E-21/22)。
覆盖两条主线:
1. 情景是独立对象——基线副本随行,真实运行与它的快照/概率字段全程不变。
2. 演示值查表且有版本——组合不是单项加和,越域直接拒绝,说明文字固定。
"""
import copy
import unittest
from contracts.prescreen import ErrorCode
from contracts.simulation import Simulation
from services import judgment_provider as jp
from services import prescreen, prescreen_policy
from services import prescreen_sandbox as sandbox
DIMENSIONS = [
{"id": "DC-CASH", "component_refs": [{"id": "corporate.cashflow"}]},
{"id": "DC-TITLE", "component_refs": [{"id": "corporate.title"}]},
{"id": "DC-DEAL", "component_refs": [{"id": "corporate.deal"}]},
]
FINDINGS = [
{"code": "corporate.cashflow", "result": "unknown", "stage": 1,
"evidence_ids": ["EV-1"]},
{"code": "corporate.title", "result": "unknown", "stage": 1,
"evidence_ids": ["EV-2"]},
{"code": "corporate.deal", "result": "checked", "stage": 2,
"evidence_ids": ["EV-3"]},
]
def frozen_plan():
return {
"plan": {"id": "corporate.default", "version": "1.0.0"},
"skills": [{"id": "corporate.cashflow", "version": "1.0.0"}],
"rules": [],
"template": {"id": "template.ai-pre-review", "version": "1.0.0"},
"content_hash": "abc123",
"dimensions": DIMENSIONS,
}
def run_view(data_domain="demo"):
"""用真实装配入口产出一条运行视图,避免测试自造第二份形状。"""
decision = prescreen_policy.decide(FINDINGS)
decision["findings"] = FINDINGS
decision["missing_inputs"] = ["近六个月银行流水", "设备权属材料"]
return prescreen.assemble(
{"id": "run-9", "project_id": "DEMO-026", "created_at": "2026-09-30T01:00:00+00:00",
"input_snapshot_id": "snap-9", "revision": 3},
frozen_plan(), decision, jp.resolve_config(False), data_domain=data_domain)
class BaselineIsolationTests(unittest.TestCase):
"""AC-11:模拟不修改原运行,也不动它的快照。"""
def test_run_view_is_not_mutated_by_simulation(self):
view = run_view()
before = copy.deepcopy(view)
sandbox.simulate(view, ["assume_bank_flow", "assume_title"])
self.assertEqual(view, before, "情景推演不得改写真实运行对象")
def test_simulation_carries_its_own_baseline_copy(self):
view = run_view()
result = sandbox.simulate(view, ["assume_bank_flow"])
baseline = result["baseline_probability_view"]
self.assertEqual(baseline["demo_estimate"]["value"], 0.42)
self.assertIsNone(baseline["real_estimate"])
self.assertIsNone(result["probability_view"]["real_estimate"],
"演示情景的真实估计必须为空")
sandbox.assert_baseline_untouched(view, result)
def test_real_run_probability_stays_empty(self):
view = run_view()
self.assertIsNone(view["probability"])
self.assertIsNone(view["probability_view"]["real_estimate"])
self.assertIsNone(view["probability_view"].get("demo_estimate"),
"真实运行不得携带演示值")
class DemoTableTests(unittest.TestCase):
"""AC-30/12:演示值必须有表版本与固定说明,且组合不是加和。"""
def test_table_matches_the_agreed_prototype_values(self):
values = {key: row["value"] for key, row in sandbox.DEMO_SCENARIOS.items()}
self.assertEqual(values, {"00": 42, "10": 58, "01": 51, "11": 64})
def test_combination_is_not_the_sum_of_single_deltas(self):
sandbox.assert_not_additive()
baseline = sandbox.DEMO_SCENARIOS["00"]["value"]
single = (sandbox.DEMO_SCENARIOS["10"]["value"] - baseline) \
+ (sandbox.DEMO_SCENARIOS["01"]["value"] - baseline)
self.assertEqual(baseline + single, 67)
self.assertEqual(sandbox.DEMO_SCENARIOS["11"]["value"], 64,
"组合值必须来自查表,不是 42+16+9")
def test_every_demo_estimate_is_labeled_and_versioned(self):
result = sandbox.simulate(run_view(), ["assume_title"])
demo = result["probability_view"]["demo_estimate"]
self.assertEqual(demo["source"], "synthetic_demo")
self.assertEqual(demo["scenario_table_version"], sandbox.DEMO_TABLE_VERSION)
self.assertEqual(demo["scenario_key"], "01")
self.assertIn("人工设定", demo["label"])
def test_production_domain_is_refused_before_any_result(self):
with self.assertRaises(sandbox.DemoEstimateForbidden):
sandbox.simulate(run_view(data_domain="production"), ["assume_bank_flow"])
def test_contract_itself_refuses_demo_estimate_outside_demo_domain(self):
result = sandbox.simulate(run_view(), ["assume_bank_flow"])
broken = dict(result, data_domain="production")
with self.assertRaises(ValueError):
Simulation(**broken)
self.assertEqual(ErrorCode.DEMO_ESTIMATE_FORBIDDEN.value,
"DEMO_ESTIMATE_FORBIDDEN")
class AssumptionGuardTests(unittest.TestCase):
"""AC-31:冲突假设拒绝;组合摘要随选择变化。"""
def test_unregistered_assumption_is_rejected(self):
with self.assertRaises(sandbox.AssumptionConflict):
sandbox.simulate(run_view(), ["assume_everything"])
def test_assumption_for_missing_dimension_is_rejected(self):
view = run_view()
view["dimension_results"] = [item for item in view["dimension_results"]
if item["id"] != "DC-TITLE"]
with self.assertRaises(sandbox.AssumptionConflict) as ctx:
sandbox.simulate(view, ["assume_title"])
self.assertIn("DC-TITLE", str(ctx.exception))
def test_selection_hash_follows_the_full_combination(self):
view = run_view()
first = sandbox.simulate(view, ["assume_bank_flow"])
again = sandbox.simulate(view, ["assume_bank_flow"])
other = sandbox.simulate(view, ["assume_bank_flow", "assume_title"])
self.assertEqual(first["selection_hash"], again["selection_hash"])
self.assertNotEqual(first["selection_hash"], other["selection_hash"])
def test_new_revision_changes_the_selection_hash(self):
view = run_view()
self.assertNotEqual(sandbox.selection_hash("run-9", 3, ["assume_bank_flow"]),
sandbox.selection_hash("run-9", 4, ["assume_bank_flow"]))
class DimensionOverlayTests(unittest.TestCase):
"""AC-44/13-P-17:只改被假设的维度,其余维度原样保留并标明未变。"""
def test_only_assumed_dimensions_change(self):
result = sandbox.simulate(run_view(), ["assume_bank_flow"])
rows = {item["id"]: item for item in result["dimensions"]}
self.assertEqual(rows["DC-CASH"]["baseline_stage"], 1)
self.assertEqual(rows["DC-CASH"]["scenario_stage"], 3)
self.assertTrue(rows["DC-CASH"]["assumed"])
self.assertEqual(rows["DC-TITLE"]["scenario_stage"],
rows["DC-TITLE"]["baseline_stage"])
self.assertFalse(rows["DC-TITLE"]["assumed"])
def test_unchanged_dimensions_are_still_listed(self):
result = sandbox.simulate(run_view(), ["assume_bank_flow"])
body = Simulation(**result)
self.assertEqual(sorted(body.unchanged_dimensions()), ["DC-DEAL", "DC-TITLE"])
def test_no_assumption_returns_the_baseline_scenario(self):
result = sandbox.simulate(run_view(), [])
self.assertEqual(result["probability_view"]["demo_estimate"]["value"], 0.42)
self.assertEqual(result["probability_view"]["demo_estimate"]["scenario_key"], "00")
self.assertTrue(all(not item["assumed"] for item in result["dimensions"]))
class FailureKeepsBaselineTests(unittest.TestCase):
"""AC-31:失败保留基线并标未完成,不得回退成演示表默认值。"""
def test_failed_simulation_keeps_baseline_and_marks_incomplete(self):
result = sandbox.failed(run_view(), ["assume_bank_flow"],
"情景计算未完成,已保留基线与原有结论",
ErrorCode.JUDGMENT_UNAVAILABLE)
self.assertEqual(result["status"], "failed")
self.assertEqual(result["error_code"], ErrorCode.JUDGMENT_UNAVAILABLE)
self.assertIsNone(result["probability_view"], "失败不得显示任何情景数值")
self.assertEqual(result["baseline_probability_view"]["demo_estimate"]["value"], 0.42)
self.assertEqual(result["remaining_inputs"], ["近六个月银行流水", "设备权属材料"])
def test_contract_refuses_optimistic_values_while_computing(self):
result = sandbox.simulate(run_view(), ["assume_bank_flow"])
pending = dict(result, status="computing")
with self.assertRaises(ValueError):
Simulation(**pending)
if __name__ == "__main__":
unittest.main()