Files
mini-openclaw-v2/backend/test_prescreen_run.py
T
qiaoqichaoandClaude Code bd389ebd31 feat(v2): T07-c 预审运行装配与运行级集成,T07 收尾
T07 第三块。ScreeningRun 是**既有 run 对象的受约束投影**,不是第二份运行存储;
AC-13/14 由既有 enqueue 实现,本批只验证其成立、不重做。

新增契约:Degradation、DimensionResult、ProbabilityView、ScreeningRun。
契约层的硬约束:`probability` 与 `probability_view.real_estimate` 的类型就是
`None`,传任何数值都被 pydantic 拒绝——"未校准就没有概率"不靠自觉;
演示估计必须标 synthetic_demo 且带"人工设定"说明、取值 0–1。

新增 services/prescreen.py:
- assemble():把冻结方案、政策结论、降级与维度状态收成契约合法的运行视图
- versions_block():冻结 plan_hash 与各组件版本;题库与主审模型未接入时**留空**,
  不编造版本号
- dimension_results():维度状态由本次核查结果推出
- assert_versions_frozen():未冻结方案版本或专项修订的运行不得执行

**实现中被测试抓到一个真实错误**:初版把"被引用组件未报告结果"当作已核查
(状态 checked)。这是把静默当成通过,违背 BR-07。现改为:任一被引用组件没有
结果即 unknown;issue 优先于 unknown(具体阻断比信息缺失更该被看到)。

测试:新增 test_prescreen_run.py 20 项,其中 4 项直接验证既有 enqueue 的
AC-13(新证据→新快照+新运行、旧运行留存、旧 revision 冲突)与 AC-14(同键同载荷
返回同一运行、异载荷 409)。全量 410 项 OK(390 → 410);verify.py 10/10 PASS。

T07 三块完成,卡片验收对照:AC-05/06/07/12/45(T07-a)· AC-09/10/21(T07-b)·
AC-13/14 验证(T07-c)。AC-08 的跨项目引用拒绝已在 T03 覆盖。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 20:38:02 +08:00

218 lines
11 KiB
Python

"""T07-c 预审运行装配测试:AC-13/14/45 与冻结语义。
AC-13(新证据 → 新运行,旧运行留存)与 AC-14(幂等键)由既有 enqueue 实现,
本文件**验证它确实成立**而不是重新实现;其余断言针对装配与冻结。
"""
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from services import assistant_store as store
from services import component_registry as registry
from services import judgment_provider as jp
from services import prescreen, prescreen_policy
from services.project_assistant import ProjectAssistantService
def frozen_plan(dimensions=None):
return {
"plan": {"id": "corporate.default", "version": "1.0.0"},
"skills": [{"id": "corporate.cashflow", "version": "1.0.0", "code": "financial.cashflow.missing"}],
"rules": [{"id": "demo.coverage", "version": "1.0.0",
"conditions": [{"code": "lease.coverage.below_demo_threshold"}]}],
"template": {"id": "template.ai-pre-review", "version": "1.0.0"},
"content_hash": "abc123",
"dimensions": dimensions or [],
}
def run_body(**over):
body = {"id": "run-1", "project_id": "DEMO-B", "created_at": store.now(),
"input_snapshot_id": "snap-1", "requirement": "重点核对现金流"}
body.update(over)
return body
class AssemblyTests(unittest.TestCase):
def setUp(self):
self.config = jp.resolve_config(False)
def assemble(self, decision=None, plan=None, status="completed"):
decision = decision if decision is not None else prescreen_policy.decide([])
return prescreen.assemble(run_body(), plan or frozen_plan(), decision,
self.config, status=status)
def test_assembled_run_is_contract_valid(self):
screening = self.assemble()
self.assertEqual(screening["id"], "run-1")
self.assertEqual(screening["plan_ref"], {"id": "corporate.default", "version": "1.0.0"})
self.assertEqual(screening["snapshot_id"], "snap-1")
def test_probability_is_null_and_reason_recorded(self):
screening = self.assemble()
self.assertIsNone(screening["probability"])
self.assertEqual(screening["probability_reason"], "not_calibrated")
self.assertIsNone(screening["probability_view"]["real_estimate"])
self.assertEqual(screening["probability_view"]["reason"], "not_calibrated")
def test_demo_estimate_must_be_labelled_and_cannot_become_real(self):
view = prescreen.probability_view("snap-1", demo_estimate={
"value": 0.42, "source": "synthetic_demo", "scenario_table_version": "v1",
"scenario_key": "k", "label": "人工设定、未经真实历史校准"})
self.assertEqual(view["demo_estimate"]["value"], 0.42)
self.assertIsNone(view["real_estimate"])
for bad in ({"value": 0.42, "source": "model", "label": "x"}, # 来源不符
{"value": 0.42, "source": "synthetic_demo"}, # 缺说明
{"value": 1.5, "source": "synthetic_demo", "label": "x"}): # 越界
with self.subTest(bad=bad):
with self.assertRaises(Exception):
prescreen.probability_view("snap-1", demo_estimate=bad)
def test_judgment_config_is_frozen_into_the_run(self):
screening = self.assemble()
self.assertEqual(screening["judgment_config"]["provider"], "disabled")
self.assertFalse(screening["judgment_config"]["enabled"])
self.assertTrue(screening["judgment_config"]["disabled_reason"])
def test_versions_block_records_plan_and_hash(self):
screening = self.assemble()
self.assertEqual(screening["versions"]["plan"]["id"], "corporate.default")
self.assertEqual(screening["versions"]["plan_hash"], "abc123")
self.assertIsNone(screening["versions"]["questions"], "题库版本未接入时留空,不编造")
def test_hard_block_is_carried_into_the_run(self):
decision = prescreen_policy.decide(
[{"code": "lease.coverage.below_demo_threshold", "result": "issue",
"basis_version": "demo.coverage@1.0.0"}],
hard_rule_codes=["lease.coverage.below_demo_threshold"])
screening = self.assemble(decision)
self.assertEqual(screening["recommendation"], "stop")
def test_failed_run_must_carry_degradations(self):
with self.assertRaises(ValueError) as ctx:
self.assemble(prescreen_policy.decide([]), status="failed")
self.assertIn("降级记录", str(ctx.exception))
def test_unfinished_run_cannot_carry_a_recommendation(self):
decision = prescreen_policy.decide([])
with self.assertRaises(ValueError) as ctx:
self.assemble(decision, status="running")
self.assertIn("未结束时不得给出建议", str(ctx.exception))
def test_missing_snapshot_is_refused(self):
with self.assertRaises(ValueError) as ctx:
prescreen.assemble(run_body(input_snapshot_id=None), frozen_plan(),
prescreen_policy.decide([]), self.config)
self.assertIn("输入快照", str(ctx.exception))
def test_junk_versions_are_not_invented(self):
block = prescreen.versions_block({"plan": {"id": "p", "version": "1"}})
self.assertIsNone(block["model"], "主审模型未配置时留空,不编造版本")
self.assertIsNone(block["questions"])
class DimensionResultTests(unittest.TestCase):
def test_unknown_when_no_component_reports(self):
rows = prescreen.dimension_results(
frozen_plan([{"id": "d1", "component_refs": [{"id": "corporate.cashflow"}]}]),
[])
self.assertEqual(rows[0]["state"], "unknown")
self.assertIsNone(rows[0]["stage"])
def test_issue_marks_the_dimension_blocked(self):
rows = prescreen.dimension_results(
frozen_plan([{"id": "d1", "component_refs": [{"id": "corporate.cashflow"}]}]),
[{"code": "corporate.cashflow", "result": "issue", "stage": 2,
"evidence_ids": ["ev-1"]}])
self.assertEqual(rows[0]["state"], "blocked")
self.assertEqual(rows[0]["stage"], 2)
self.assertEqual(rows[0]["basis_ids"], ["ev-1"])
def test_pass_marks_checked_and_unknown_beats_pass(self):
plan = frozen_plan([{"id": "d1", "component_refs": [{"id": "a"}, {"id": "b"}]}])
rows = prescreen.dimension_results(plan, [{"code": "a", "result": "pass"}])
self.assertEqual(rows[0]["state"], "unknown", "部分无结果时不得算已核查")
rows = prescreen.dimension_results(plan, [{"code": "a", "result": "pass"},
{"code": "b", "result": "pass"}])
self.assertEqual(rows[0]["state"], "checked")
def test_dimension_evidence_is_traceable(self):
rows = prescreen.dimension_results(
frozen_plan([{"id": "d1", "component_refs": [{"id": "a"}]}]),
[{"code": "a", "result": "pass", "evidence_ids": ["ev-9"]}])
self.assertIn("ev-9", rows[0]["basis_ids"])
class FrozenVersionsGuardTests(unittest.TestCase):
def test_run_without_a_plan_ref_is_refused(self):
with self.assertRaises(ValueError) as ctx:
prescreen.assert_versions_frozen({"plan_ref": {"id": "", "version": ""},
"judgment_config": {"enabled": False}})
self.assertIn("未冻结评审方案版本", str(ctx.exception))
def test_enabled_judgment_without_a_frozen_revision_is_refused(self):
with self.assertRaises(ValueError) as ctx:
prescreen.assert_versions_frozen({
"plan_ref": {"id": "p", "version": "1"},
"judgment_config": {"enabled": True, "profile_id": None,
"profile_revision": None}})
self.assertIn("必须冻结配置 ID 与修订", str(ctx.exception))
class ExistingRunGuaranteesTests(unittest.TestCase):
"""AC-13/14 由既有 enqueue 提供——这里验证其成立,不重新实现。"""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.path = Path(self._tmp.name)
self.service = ProjectAssistantService(self.path / "assistant.sqlite3")
def tearDown(self):
self._tmp.cleanup()
def identity(self):
return {"id": "demo-manager", "role": "manager", "projects": ["*"], "preview": False}
def test_new_evidence_produces_a_new_snapshot_and_keeps_the_old_run(self):
# AC-13:新证据版本 → 新快照 + 新运行,旧运行留存
first = self.service.get_project("DEMO-B")
run_one = self.service.enqueue("DEMO-B", self.identity(), "第一轮")
self.service.supplement("DEMO-B", "demo-manager",
{"collection_evidence": "已补充到账凭证"}, [],
self.service.get_project("DEMO-B")["revision"], "补件说明")
run_two = self.service.enqueue("DEMO-B", self.identity(), "第二轮")
self.assertNotEqual(run_one["input_snapshot_id"], run_two["input_snapshot_id"])
with self.service.connect() as db:
snapshots = store.listing(db, "input_snapshot", "DEMO-B")
runs = store.listing(db, "run", "DEMO-B")
self.assertGreaterEqual(len(snapshots), 2, "两次运行各自冻结快照")
self.assertIn(run_one["id"], {item["id"] for item in runs}, "旧运行必须留存")
self.assertNotEqual(first["revision"], self.service.get_project("DEMO-B")["revision"])
def test_same_idempotency_key_replays_the_same_run(self):
# AC-14:同键同载荷返回同一运行
one = self.service.enqueue("DEMO-B", self.identity(), "重复要求", idempotency_key="k1")
two = self.service.enqueue("DEMO-B", self.identity(), "重复要求", idempotency_key="k1")
self.assertEqual(one["id"], two["id"])
def test_same_key_with_different_payload_conflicts(self):
self.service.enqueue("DEMO-B", self.identity(), "要求甲", idempotency_key="k2")
with self.assertRaises(store.Conflict):
self.service.enqueue("DEMO-B", self.identity(), "要求乙", idempotency_key="k2")
def test_stale_revision_is_rejected(self):
# AC-13 的另一半:依据旧 revision 更新会冲突
project = self.service.get_project("DEMO-B")
self.service.supplement("DEMO-B", "demo-manager",
{"collection_evidence": "先改一次"}, [], project["revision"], "说明")
with self.assertRaises(store.Conflict):
self.service.supplement("DEMO-B", "demo-manager",
{"collection_evidence": "再用旧版本改"}, [],
project["revision"], "说明")
if __name__ == "__main__":
unittest.main()