Files
mini-openclaw-v2/backend/test_prescreen_from_run.py
T
qiaoqichaoandClaude Code 02289661a8 feat(v2): 纠偏第一批——执行链产出新版预审记录(真实运行验收)
覆盖 AC-46/47/48/49(BR-45/46/47、E-27)。审计确认的 P1 缺口"正常发起评审不产生
新版预审记录"由此关闭:视图由执行链出口产生,不再依赖测试或脚本注入。

后端:
- services/prescreen.py:新增 screening_from_run(),把一次真实运行的执行结果装配成
  预审视图;维度与阶段按**本次实际执行的组件与核查项**投影
  (dimensions_and_evidence:冻结方案声明过、且本次真的执行过的技能/规则才算维度;
  经验核查等方案外伪步骤不算;阶段只表达证据成熟度,不看结论好坏)。
- services/assistant_worker.py:运行出口(含失败路径)统一调用 save_prescreen_view(),
  运行结束即落库视图;装配失败单独记事件,不让运行一起丢。
- contracts/prescreen.py:视图带 evidence(界面"查看依据"的来源实体);
  新增运行级错误码 RUN_STEP_FAILED(E-27)。
- 维度状态用本次执行的步骤与核查项算(含跑通但无问题的项),
  只看待办 finding 会把通过的维度误判成未核查。

前端:面板新增"核查结果"区(每条可打开依据抽屉,显示来源、定位、原文片段与组件版本)
与降级记录展示;没有证据时明确写"按缺证处理",不编造。

测试:backend/test_prescreen_from_run.py 12 项,用真实 enqueue + DurableWorker.process_next
(临时库)断言视图由执行链产生、失败与模型不可用如实留痕、重跑新增记录。
含反例:执行前不得存在任何记录。

实际验收(浏览器渲染 + 真实运行):状态 partial、10 维(5 已核查/2 待核实/3 阻断)、
4 条核查结果、14 条证据、1 条 RUN_STEP_FAILED 未完成项;记录见
docs/v2/17-batch-delivery-records.md(含剩余缺口:界面点击未自动化、材料确认未在浏览器走完、
演示角色需 XINGBANG_DEMO_ROLE_LOGIN=1、模型未配置)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-30 12:34:45 +08:00

240 lines
12 KiB
Python

"""纠偏第一批端到端测试:正常发起预审 → 真实执行 → 页面可读。
覆盖 AC-46/47/48/49。**不使用注入记录**:运行由 `service.enqueue` 创建、
由 `DurableWorker.process_next` 执行,视图只能由执行链的出口产生(BR-45)。
"""
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api import prescreen as prescreen_api
from api import project_assistant as api
from api import risk_agent_config as catalog
from services import prescreen_store
from services.assistant_store import get, listing
from services.assistant_worker import DurableWorker
from services.project_assistant import ProjectAssistantService
HEADERS = {"X-Xingbang-Demo-Role": "manager"}
PROJECT = "DEMO-B"
BASE = "/api/xingbang/risk-agent/project-assistant"
PRESCREEN = BASE + "/prescreen"
class ExecutionChainTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.path = Path(self.temp.name)
self.service = ProjectAssistantService(self.path / "assistant.sqlite3")
self.patches = [
patch.object(api, "service", lambda: self.service),
patch.object(prescreen_api, "_service", lambda: self.service),
patch.object(catalog, "DB_PATH", self.path / "catalog.sqlite3"),
# 不接内网模型:这样"模型不可用"是真实状态,而不是被 mock 掉。
# XINGBANG_DEMO_ROLE_LOGIN=1 是演示角色登录开关:worker 的 authorized()
# 要求它,否则演示角色发起的运行会被判"权限已撤销"(见 16 §3 AC-46 说明)。
patch.dict(os.environ, {"XINGBANG_AUTH_USERS": "", "XINGBANG_REQUIRE_AUTH": "0",
"XINGBANG_LOCAL_MODEL_URL": "",
"XINGBANG_DEMO_ROLE_LOGIN": "1"}),
]
for item in self.patches:
item.start()
self.worker = DurableWorker(self.service)
app = FastAPI()
app.include_router(api.router)
app.include_router(prescreen_api.router)
self.client = TestClient(app)
def tearDown(self):
self.worker.stop()
self.client.close()
for item in reversed(self.patches):
item.stop()
self.temp.cleanup()
# ------------------------------------------------------------ 工具
def start_run(self, project=PROJECT):
response = self.client.post(f"{BASE}/projects/{project}/runs", json={}, headers=HEADERS)
self.assertIn(response.status_code, (200, 202), response.text)
return response.json()
def views(self, project=PROJECT):
response = self.client.get(f"{PRESCREEN}/projects/{project}/runs", headers=HEADERS)
self.assertEqual(response.status_code, 200, response.text)
return response.json()["items"]
def view(self, run_id, project=PROJECT):
response = self.client.get(f"{PRESCREEN}/projects/{project}/runs/{run_id}",
headers=HEADERS)
self.assertEqual(response.status_code, 200, response.text)
return response.json()
# ------------------------------------------------------------ AC-46
def test_ordinary_run_is_executed_and_becomes_readable(self):
run = self.start_run()
self.assertEqual(self.views(), [], "执行前不应存在任何预审记录")
self.assertTrue(self.worker.process_next(), "队列里应有一个可执行的运行")
rows = self.views()
self.assertEqual([item["id"] for item in rows], [run["id"]],
"正常预审执行后必须出现对应记录,且由执行链产生")
view = self.view(run["id"])
self.assertEqual(view["project_id"], PROJECT)
self.assertEqual(view["snapshot_id"], run["input_snapshot_id"])
self.assertIn(view["status"], ("completed", "partial", "failed"))
self.assertIsNone(view["probability"], "未校准就是没有概率")
if view["probability_view"] is not None:
self.assertIsNone(view["probability_view"]["real_estimate"])
self.assertIsNone(view["probability_view"]["demo_estimate"])
self.assertEqual(view["versions"]["trajectory_id"], run["id"],
"视图必须指向本次运行的轨迹,供轨迹复盘使用")
def test_view_survives_a_reload(self):
run = self.start_run()
self.worker.process_next()
first = self.view(run["id"])
again = self.view(run["id"])
self.assertEqual(first, again, "刷新页面必须读到同一份记录")
def test_run_produces_real_checks_and_reports_the_model_gap(self):
"""没有模型时:确定性核查照跑,模型类步骤失败留痕,运行记部分完成。"""
run = self.start_run()
self.worker.process_next()
with self.service.connect() as db:
stored = get(db, "run", run["id"])
self.assertTrue(stored["checks"], "确定性核查应当真的跑出核查项")
failed = [step for step in stored["steps"] if step["status"] == "failed"]
self.assertTrue(failed, "未配置模型时模型类步骤应当失败,而不是假装成功")
self.assertEqual(stored["status"], "partial", "有失败步骤时运行应为部分完成")
view = self.view(run["id"])
self.assertTrue(view["findings"] or view["dimension_results"],
"视图必须带本次执行的结果或维度,而不是空壳")
self.assertTrue(view["degradations"], "模型缺口必须记成降级项")
# ------------------------------------------------------------ AC-47/48
def test_missing_inputs_and_degradations_are_visible(self):
run = self.start_run()
self.worker.process_next()
view = self.view(run["id"])
with self.service.connect() as db:
stored = get(db, "run", run["id"])
failed = [step for step in stored["steps"] if step["status"] == "failed"]
if failed:
self.assertEqual(view["status"], "partial", "有失败步骤时运行应为部分完成")
self.assertTrue(view["degradations"], "失败必须留下降级记录")
self.assertNotEqual(view["recommendation"], "advance",
"有未完成核查时不得给出可推进结论")
for item in view["degradations"]:
self.assertTrue(item["message"], "降级记录必须可定位原因")
def test_model_engine_step_failure_is_not_disguised_as_a_result(self):
"""没有模型时,模型类技能必须失败留痕,而不是返回编造结论。"""
run = self.start_run()
self.worker.process_next()
with self.service.connect() as db:
stored = get(db, "run", run["id"])
model_steps = [step for step in stored["steps"]
if (step.get("skill") or {}).get("id") in ("corporate.analysis", "review.analysis")]
for step in model_steps:
if step["status"] == "failed":
self.assertTrue(step["errors"], "失败步骤必须记录错误")
self.assertEqual(step.get("outputs") or {}, {},
"失败不得留下'看起来有结论'的输出")
view = self.view(run["id"])
self.assertIsNone(view["probability"])
# ------------------------------------------------------------ AC-49
def test_rerun_creates_a_new_record_and_keeps_the_old_one(self):
first = self.start_run()
self.worker.process_next()
second = self.start_run()
self.worker.process_next()
rows = self.views()
self.assertEqual(len(rows), 2, "重跑必须新增记录,不得覆盖旧记录")
self.assertEqual({item["id"] for item in rows}, {first["id"], second["id"]})
old = self.view(first["id"])
self.assertEqual(old["id"], first["id"], "旧记录仍可读取")
class ViewAssemblyTests(unittest.TestCase):
"""装配规则的单元测试(fixture 允许;验收仍走上面的真实执行链)。"""
SKILLS = [{"id": "corporate.assets", "name": "设备清单存在重复项", "version": "1.0.0"}]
RULES = [{"id": "demo.coverage", "name": "演示覆盖规则", "version": "1.0.0"}]
def test_dimensions_come_from_executed_component_steps(self):
from services import prescreen
steps = [{"step_id": "corporate.assets", "skill": {"id": "corporate.assets"},
"status": "completed",
"findings": [{"code": "lease.asset.duplicate", "result": "issue",
"evidence_ids": ["EV-1"]}]},
{"step_id": "demo.coverage", "status": "completed",
"findings": [{"code": "lease.coverage.below_demo_threshold", "result": "issue",
"evidence_ids": ["EV-2"]}]}]
dimensions, items = prescreen.dimensions_and_evidence(steps, self.SKILLS, self.RULES)
self.assertEqual([item["id"] for item in dimensions], ["corporate.assets", "demo.coverage"])
self.assertEqual(dimensions[0]["label"], "设备清单存在重复项")
refs = [ref["id"] for ref in dimensions[0]["component_refs"]]
self.assertEqual(refs, ["corporate.assets", "lease.asset.duplicate"])
self.assertEqual([item["code"] for item in items][:1], ["corporate.assets"])
def test_summary_steps_are_not_dimensions(self):
from services import prescreen
steps = [{"step_id": "rules-corporate", "status": "completed", "findings": []},
{"step_id": "experience.applications", "status": "completed", "findings": []},
{"step_id": "corporate.analysis", "skill": {"id": "corporate.analysis"},
"status": "failed", "findings": []}]
skills = self.SKILLS + [{"id": "corporate.analysis", "name": "金租对公业务综合核查",
"version": "1.0.0"}]
dimensions, items = prescreen.dimensions_and_evidence(steps, skills, self.RULES)
self.assertEqual([item["id"] for item in dimensions], ["corporate.analysis"],
"汇总类伪步骤不得成为维度")
self.assertEqual(items[0]["result"], "error", "失败步骤按未完成处理")
def test_steps_outside_the_frozen_plan_are_not_dimensions(self):
from services import prescreen
steps = [{"step_id": "experience.applications",
"skill": {"id": "experience.applications", "version": "1.0.0"},
"status": "completed", "findings": []}]
dimensions, items = prescreen.dimensions_and_evidence(steps, self.SKILLS, self.RULES)
self.assertEqual(dimensions, [], "方案未声明的步骤不是核查维度")
self.assertEqual(items, [])
def test_no_executed_component_means_no_dimensions(self):
from services import prescreen
self.assertEqual(prescreen.dimensions_and_evidence([], self.SKILLS, self.RULES), ([], []))
def test_step_without_findings_counts_as_checked_not_as_passed_approval(self):
from services import prescreen
steps = [{"step_id": "corporate.assets", "skill": {"id": "corporate.assets"},
"status": "completed", "findings": []}]
dimensions, items = prescreen.dimensions_and_evidence(steps, self.SKILLS, self.RULES)
self.assertEqual(items[0]["result"], "pass")
self.assertEqual(items[0]["stage"], 1, "没有引用证据时阶段为'仅有说明'")
def test_stage_reflects_evidence_maturity_not_outcome(self):
from services import prescreen
self.assertEqual(prescreen.stage_from_check({"result": "issue", "evidence_ids": ["a"]}), 2)
self.assertEqual(prescreen.stage_from_check({"result": "pass", "evidence_ids": ["a"]}), 2)
self.assertEqual(prescreen.stage_from_check({"result": "issue"}), 1)
self.assertEqual(prescreen.stage_from_check({"result": "not_applicable"}), 0)
if __name__ == "__main__":
unittest.main()