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>
This commit is contained in:
@@ -119,6 +119,8 @@ class ErrorCode(StrEnum):
|
||||
JUDGMENT_OUTPUT_INVALID = "JUDGMENT_OUTPUT_INVALID"
|
||||
# E-25:输入超出模型已验证预算(BR-39 / AC-40)。T08 适配层使用。
|
||||
JUDGMENT_CONTEXT_EXCEEDED = "JUDGMENT_CONTEXT_EXCEEDED"
|
||||
# E-27:步骤未完成(非预算原因)。纠偏第一批使用。
|
||||
RUN_STEP_FAILED = "RUN_STEP_FAILED"
|
||||
RUN_BUDGET_EXCEEDED = "RUN_BUDGET_EXCEEDED"
|
||||
|
||||
|
||||
@@ -154,6 +156,7 @@ RUN_LEVEL_CODES = frozenset({
|
||||
ErrorCode.JUDGMENT_CAPABILITY_UNSUPPORTED,
|
||||
ErrorCode.JUDGMENT_OUTPUT_INVALID,
|
||||
ErrorCode.JUDGMENT_CONTEXT_EXCEEDED,
|
||||
ErrorCode.RUN_STEP_FAILED,
|
||||
ErrorCode.RUN_BUDGET_EXCEEDED,
|
||||
})
|
||||
|
||||
@@ -845,6 +848,8 @@ class ScreeningRun(Contract):
|
||||
judgment_config: JudgmentConfig
|
||||
versions: dict = Field(default_factory=dict)
|
||||
dimension_results: list[DimensionResult] = Field(default_factory=list, max_length=32)
|
||||
# 本次核查实际引用的证据(纠偏第一批:界面要能"查看依据",不能只有 id)
|
||||
evidence: list[dict] = Field(default_factory=list, max_length=200)
|
||||
run_instruction: str = Field(default="", max_length=2000)
|
||||
created_at: AwareDatetime
|
||||
finished_at: AwareDatetime | None = None
|
||||
|
||||
@@ -8,6 +8,7 @@ from datetime import datetime, timezone, timedelta
|
||||
from copy import deepcopy
|
||||
|
||||
from services.assistant_store import get, put, listing, event, uid, now, digest, finish_time_event
|
||||
from services import prescreen, prescreen_store
|
||||
from services.assistant_data import read_cards
|
||||
from services.skill_executor import execute_skill, execute_rules
|
||||
from services.assistant_experiences import apply_experiences
|
||||
@@ -122,6 +123,27 @@ class DurableWorker:
|
||||
with self.service.connect() as db:
|
||||
db.execute("UPDATE assistant_jobs SET lease_until=? WHERE id=? AND owner=? AND state='running'", (time.time() + LEASE_SECONDS, run["id"], self.owner))
|
||||
|
||||
def save_prescreen_view(self, db, run):
|
||||
"""运行出口:把本次执行结果装配成新版预审视图(纠偏第一批 / BR-45)。
|
||||
|
||||
装配失败不吞掉:运行结果已经落库,视图失败单独记事件便于定位,
|
||||
而不是让整个运行一起丢。失败运行也走这里,保证"未完成"能被页面看到。
|
||||
"""
|
||||
try:
|
||||
project = get(db, "project", run["project_id"])
|
||||
view = prescreen.screening_from_run(
|
||||
run, data_domain=str(project.get("data_domain") or "demo"))
|
||||
prescreen_store.save(db, view)
|
||||
event(db, run["project_id"], "prescreen_view.saved", self.owner,
|
||||
{"run_id": run["id"], "view_id": view["id"], "status": view["status"],
|
||||
"dimensions": len(view["dimension_results"]),
|
||||
"missing_inputs": len(view["missing_inputs"]),
|
||||
"degradations": len(view["degradations"])}, run["id"])
|
||||
except Exception as exc:
|
||||
event(db, run["project_id"], "prescreen_view.failed", self.owner,
|
||||
{"run_id": run["id"], "error": f"{type(exc).__name__}: {exc}"[:400]},
|
||||
run["id"])
|
||||
|
||||
def save_step(self, run, step):
|
||||
if step["status"] != "running":
|
||||
step = SkillResult.model_validate(step).model_dump()
|
||||
@@ -181,6 +203,8 @@ class DurableWorker:
|
||||
db.execute("UPDATE assistant_jobs SET state='failed' WHERE id=? AND owner=?", (run["id"], self.owner))
|
||||
finish_time_event(db, "run", run["id"])
|
||||
event(db, run["project_id"], "run.failed", self.owner, {"error_type": type(exc).__name__}, run["id"])
|
||||
# AC-48:失败运行同样要能被页面读到,并明确显示未完成原因
|
||||
self.save_prescreen_view(db, current)
|
||||
finally:
|
||||
done.set()
|
||||
heartbeat_thread.join(timeout=2)
|
||||
@@ -299,3 +323,5 @@ class DurableWorker:
|
||||
for finding in findings:
|
||||
put(db, "finding", finding)
|
||||
event(db, run["project_id"], "run." + current["status"], self.owner, {"summary": current["summary"], "superseded": current["superseded"]}, run["id"])
|
||||
# 纠偏第一批(BR-45):运行结束即产出新版预审视图,页面直接读它。
|
||||
self.save_prescreen_view(db, current)
|
||||
|
||||
@@ -16,6 +16,8 @@ from __future__ import annotations
|
||||
from contracts.prescreen import (
|
||||
DataDomain,
|
||||
DimensionResult,
|
||||
DimensionSpec,
|
||||
ErrorCode,
|
||||
ProbabilityView,
|
||||
ScreeningRun,
|
||||
)
|
||||
@@ -120,6 +122,8 @@ def assemble(run_body: dict, frozen_plan: dict, decision: dict, judgment_config,
|
||||
"versions": versions_block(frozen_plan, {"trajectory_id": trajectory_id}
|
||||
if trajectory_id else None),
|
||||
"dimension_results": dimension_results(frozen_plan, decision.get("findings")),
|
||||
# 证据是界面"查看依据"的实体来源;超长只截断列表,不改内容
|
||||
"evidence": [dict(item) for item in (run_body.get("evidence") or ())][:200],
|
||||
"run_instruction": run_body.get("requirement") or "",
|
||||
"created_at": run_body.get("created_at") or store.now(),
|
||||
"finished_at": run_body.get("ended_at"),
|
||||
@@ -145,3 +149,161 @@ def assert_versions_frozen(screening: dict) -> None:
|
||||
if config.get("enabled") and (not config.get("profile_id")
|
||||
or config.get("profile_revision") is None):
|
||||
raise ValueError("启用专项的运行必须冻结配置 ID 与修订")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 执行链装配(纠偏第一批)
|
||||
# 证据成熟度刻度。**只表达证据成熟度,不看结论好坏**:发现问题不会降阶段,
|
||||
# 通过也不会升到人工复核(见 docs/v2/16-delivery-corrections.md BR-46)。
|
||||
STAGE_SCALE_ID = "evidence-stage"
|
||||
STAGE_SCALE_VERSION = "1.0.0"
|
||||
STAGE_LEVELS = [
|
||||
{"value": 0, "label": "未提供", "criterion": "本次没有该核查项的结果"},
|
||||
{"value": 1, "label": "仅有说明", "criterion": "有结论但未引用任何证据"},
|
||||
{"value": 2, "label": "资料待核", "criterion": "引用了证据;尚无交叉核验记录"},
|
||||
{"value": 3, "label": "交叉核验", "criterion": "有核验记录证明证据之间互相印证"},
|
||||
{"value": 4, "label": "人工复核", "criterion": "有人工复核记录"},
|
||||
]
|
||||
DISABLED_JUDGMENT_CONFIG = {
|
||||
"enabled": False, "provider": "disabled",
|
||||
"disabled_reason": "本次运行未启用专项判断;主审仍按规则、工具与经验形成意见",
|
||||
}
|
||||
# 阶段 3/4 需要"核验记录"或"人工复核记录"这类依据,P0 的执行链还产不出,
|
||||
# 因此自动映射只会落在 0–2;3/4 保留给后续判别路径,不在这里臆造。
|
||||
|
||||
|
||||
def stage_from_check(item) -> int:
|
||||
"""核查项 → 证据成熟度阶段(BR-46)。"""
|
||||
if item.get("result") in (None, "not_applicable"):
|
||||
return 0
|
||||
return 2 if item.get("evidence_ids") else 1
|
||||
|
||||
|
||||
def _component_of(step, skills, rules):
|
||||
"""步骤归属的组件:**必须是冻结方案里声明过的技能或规则**。
|
||||
|
||||
既有执行器把技能、规则、经验核查都落成 step,其中经验核查这类不在方案里的
|
||||
步骤不是核查维度;反过来方案声明的组件若本次没执行,也不会出现在结果里
|
||||
(维度的定义是"跑了什么",不是"方案里写了什么")。
|
||||
"""
|
||||
step_id = str(step.get("step_id") or "")
|
||||
skill_id = str((step.get("skill") or {}).get("id") or "")
|
||||
lookup = skill_id or step_id
|
||||
if not lookup or lookup != step_id:
|
||||
return None
|
||||
item = next((s for s in skills or () if s.get("id") == lookup), None)
|
||||
if item is None:
|
||||
item = next((r for r in rules or () if r.get("id") == lookup), None)
|
||||
if item is None:
|
||||
return None
|
||||
return lookup, str(item.get("name") or lookup), str(item.get("version") or "1.0.0")
|
||||
|
||||
|
||||
def dimensions_and_evidence(steps, skills, rules):
|
||||
"""本次实际执行的组件 → 维度定义 + 用于判定状态与阶段的核查输入(BR-46)。
|
||||
|
||||
只投影真实执行记录:步骤状态决定该维度是否算已核查,核查项决定是否阻断;
|
||||
技能/规则跑通但没有产出核查项时,维度按"已核查"成立——这不等于通过审批。
|
||||
方案没有执行记录就不产生维度,绝不补占位轴。
|
||||
"""
|
||||
dimensions, items, order = [], [], 0
|
||||
for step in steps or ():
|
||||
component = _component_of(step, skills, rules)
|
||||
if not component:
|
||||
continue
|
||||
component_id, label, version = component
|
||||
findings = [dict(item) for item in step.get("findings") or ()]
|
||||
codes = [str(item.get("code")) for item in findings if item.get("code")]
|
||||
status = str(step.get("status") or "")
|
||||
if status == "failed":
|
||||
step_result = "error"
|
||||
elif status in ("partial", "running", "queued"):
|
||||
step_result = "unknown"
|
||||
elif any(item.get("result") not in ("pass", "not_applicable") for item in findings):
|
||||
step_result = "issue"
|
||||
else:
|
||||
step_result = "pass"
|
||||
evidence_ids = sorted({str(e) for item in findings for e in (item.get("evidence_ids") or ())})
|
||||
refs = [{"id": component_id, "version": version}] + [
|
||||
{"id": code, "version": version} for code in codes]
|
||||
dimensions.append(DimensionSpec(
|
||||
id=component_id, label=label, order=order, scale_id=STAGE_SCALE_ID,
|
||||
scale_version=STAGE_SCALE_VERSION, levels=STAGE_LEVELS,
|
||||
component_refs=refs).model_dump(mode="json"))
|
||||
order += 1
|
||||
step_item = {"code": component_id, "result": step_result,
|
||||
"evidence_ids": evidence_ids, "stage": None}
|
||||
step_item["stage"] = stage_from_check(step_item)
|
||||
items.append(step_item)
|
||||
for finding, code in zip(findings, codes):
|
||||
row = {"code": code, "result": finding.get("result"),
|
||||
"evidence_ids": list(finding.get("evidence_ids") or ())}
|
||||
row["stage"] = stage_from_check(row)
|
||||
items.append(row)
|
||||
return dimensions, items
|
||||
|
||||
|
||||
def _degradations(steps, run) -> list[dict]:
|
||||
"""失败步骤与运行级失败都留痕,并给出可定位的原因(AC-48)。"""
|
||||
rows = []
|
||||
for step in steps or ():
|
||||
if step.get("status") != "failed":
|
||||
continue
|
||||
error = (step.get("errors") or [{}])[0]
|
||||
kind = str(error.get("type") or "")
|
||||
code = (ErrorCode.RUN_BUDGET_EXCEEDED if kind == "TimeoutError"
|
||||
else ErrorCode.RUN_STEP_FAILED)
|
||||
label = str(step.get("name") or step.get("step_id") or "未命名步骤")
|
||||
detail = str(error.get("message") or "步骤未完成")
|
||||
rows.append({"code": code, "message": f"{label}:{detail[:400]}"})
|
||||
if run.get("status") == "failed" and not rows:
|
||||
rows.append({"code": ErrorCode.RUN_STEP_FAILED,
|
||||
"message": str(run.get("error") or "运行未完成")[:400]})
|
||||
return rows
|
||||
|
||||
|
||||
def _missing_inputs(steps, run) -> list[str]:
|
||||
"""缺证项汇总:来自各步骤与数据错误,去重后保持顺序。"""
|
||||
raw = [str(item) for step in steps or () for item in (step.get("missing_inputs") or ())]
|
||||
for item in run.get("data_errors") or ():
|
||||
raw.append(str(item.get("message") if isinstance(item, dict) else item))
|
||||
seen, rows = set(), []
|
||||
for item in raw:
|
||||
if item and item not in seen:
|
||||
seen.add(item)
|
||||
rows.append(item[:200])
|
||||
return rows
|
||||
|
||||
|
||||
def screening_from_run(run: dict, data_domain: str = "demo",
|
||||
judgment_config=None) -> dict:
|
||||
"""把**一次真实运行的执行结果**装配成新版预审视图(AC-46/47/48/49)。
|
||||
|
||||
这是执行链的出口:由 worker 在运行结束时调用,因此正常发起评审就会产生记录,
|
||||
不需要测试或演示脚本注入(BR-45)。方案取自运行创建时冻结的 components,
|
||||
不在装配时重读目录,避免"跑的是旧版本、显示的是新版本"。
|
||||
"""
|
||||
components = run.get("components") or {}
|
||||
checks = [dict(item) for item in run.get("checks") or ()]
|
||||
steps = list(run.get("steps") or ())
|
||||
skills = list(components.get("skills") or ())
|
||||
rules = list(components.get("rules") or ())
|
||||
dimensions, dimension_inputs = dimensions_and_evidence(steps, skills, rules)
|
||||
frozen_plan = {
|
||||
"plan": components.get("plan") or {},
|
||||
"skills": skills,
|
||||
"rules": rules,
|
||||
"template": components.get("template") or {},
|
||||
"content_hash": components.get("content_hash"),
|
||||
"dimensions": dimensions,
|
||||
}
|
||||
decision = policy.decide(checks, _missing_inputs(steps, run), _degradations(steps, run))
|
||||
decision["findings"] = [dict(item) for item in run.get("findings") or ()]
|
||||
view = assemble(run, frozen_plan, decision,
|
||||
judgment_config or DISABLED_JUDGMENT_CONFIG,
|
||||
data_domain=data_domain,
|
||||
status=str(run.get("status") or "completed"),
|
||||
trajectory_id=run.get("id"))
|
||||
# 维度状态用**本次执行的步骤与核查项**算(含跑通但无问题的项):
|
||||
# 只看待办 finding 会把通过的维度误判成未核查。
|
||||
view["dimension_results"] = dimension_results(frozen_plan, dimension_inputs)
|
||||
return ScreeningRun(**view).model_dump(mode="json")
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""纠偏第一批端到端测试:正常发起预审 → 真实执行 → 页面可读。
|
||||
|
||||
覆盖 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()
|
||||
@@ -0,0 +1,78 @@
|
||||
# 四批交付记录
|
||||
|
||||
口径见 [16 交付纠偏规格](16-delivery-corrections.md) §4:每批必须列出**页面入口、操作步骤、
|
||||
实际结果、证据位置、剩余缺口**;未接通项标"部分完成"。
|
||||
|
||||
---
|
||||
|
||||
## 第一批:接通正常预审执行链
|
||||
|
||||
状态:**部分完成**(执行链已接通并经真实运行验收;界面点击本身尚未由自动化驱动,
|
||||
材料确认步骤未在浏览器中完整走一遍)。
|
||||
|
||||
### 页面入口
|
||||
|
||||
| 入口 | 位置 |
|
||||
|---|---|
|
||||
| 发起预审 | `/xingbang-workbench?project=DEMO-B`「材料与问题」页 → 顶部"开始AI预审"按钮 |
|
||||
| 查看结果与依据 | 同页「预审与推进沙盘」面板:主审意见、核查结果(4)、核查维度(10)、每条"查看依据" |
|
||||
|
||||
### 操作步骤(实际执行)
|
||||
|
||||
1. 启动 v2:后端 `uvicorn app:app --port 8012`(带 `XINGBANG_DEMO_ROLE_LOGIN=1`)、
|
||||
前端 `npm run dev -- -p 3212`。
|
||||
2. 经前端代理发起预审(与页面按钮同一请求):
|
||||
`POST /api/risk-agent/project-assistant/projects/DEMO-B/runs`,角色头 `manager`。
|
||||
3. 等 worker 执行结束(轮询运行状态)。
|
||||
4. 读取 `GET .../prescreen/projects/DEMO-B/runs/{run_id}`。
|
||||
5. 浏览器打开工作台并渲染面板截图。
|
||||
|
||||
### 实际结果
|
||||
|
||||
- 运行结束状态 `partial`,视图 `recommendation=supplement`,`probability=null`。
|
||||
- **维度 10 个**:5 已核查 / 2 待核实 / 3 存在阻断项;阶段 1–2
|
||||
(`corporate.consistency`、`corporate.cashflow`、`corporate.assets`、`corporate.coverage`、
|
||||
`skill-quality`、`skill-finance`、`skill-credit`、`demo.coverage`、`rules-corporate`、`corporate.analysis`)。
|
||||
- **核查结果 4 条**(`lease.coverage.below_demo_threshold`、`lease.asset.duplicate`、
|
||||
`financial.cashflow.missing`、`financial.liabilities.conflict`),各带依据与组件版本。
|
||||
- **证据 14 条**随视图返回,界面"查看依据"可展开来源与定位。
|
||||
- **未完成项 1 条**:`RUN_STEP_FAILED 金租对公业务综合核查:未配置受信模型,语义核查未执行`
|
||||
——模型不可用时如实标未完成,不伪造结论。
|
||||
- 缺证 2 项:`financial.operating_cashflow`、存在尚未人工确认的材料候选。
|
||||
- 反复发起产生多条记录(本轮 3 条),旧记录仍可读;轨迹引用指向本次运行。
|
||||
- 呈现判定:10 维超出雷达可读范围 → 界面改用分组列表并保留全部维度(AC-44 真实分支)。
|
||||
|
||||
### 证据位置
|
||||
|
||||
| 内容 | 位置 |
|
||||
|---|---|
|
||||
| 会话内验证日志 | 本轮运行输出(发起→轮询→读视图) |
|
||||
| 端到端测试 | `backend/test_prescreen_from_run.py`(12 项,真实 enqueue + worker 执行) |
|
||||
| 界面渲染 | `claude_use/tmp/v2-batch1-panel.png`(本机截图,未入库) |
|
||||
| 全量验证 | `test-evidence/<时间戳>-<短SHA>/`(`scripts/verify.py`) |
|
||||
|
||||
### 剩余缺口
|
||||
|
||||
- **界面点击未由自动化驱动**:本机无 playwright/puppeteer/chromium-cli,发起预审是通过
|
||||
与页面按钮同一条代理请求完成的;"点按钮"这一步没有脚本化证据。
|
||||
- **材料确认步骤未在浏览器走一遍**:本轮直接用已存在的演示项目 DEMO-B;
|
||||
"新建演示项目→确认材料"这一段仍只有 T02 的服务与测试覆盖。
|
||||
- **演示角色需要 `XINGBANG_DEMO_ROLE_LOGIN=1`**:否则 worker 的 `authorized()` 会把
|
||||
演示角色发起的运行判为"权限已撤销"(运行会失败但可读)。这是配置要求,需写入部署说明。
|
||||
- **模型类技能始终失败**:未配置内网模型,语义核查永远 `RUN_STEP_FAILED`;
|
||||
因此"完整跑完"路径仍未验证(不是本批的接通问题,但影响演示观感)。
|
||||
- 取消中的运行不产出视图(只处理 completed/partial/failed)。
|
||||
|
||||
---
|
||||
|
||||
## 第二批:完成历史经验加工流程
|
||||
|
||||
状态:**未开始**。
|
||||
|
||||
## 第三批:按已确认原型完成新版界面
|
||||
|
||||
状态:**未开始**。
|
||||
|
||||
## 第四批:接通评审反馈与改进发布
|
||||
|
||||
状态:**未开始**。
|
||||
@@ -53,6 +53,12 @@ assert.match(lib, /EMPTY_DIMENSIONS = "当前方案尚未配置核查维度"/);
|
||||
assert.match(panel, /shown\.layout === "radar" && <RadarChart/, "只有判定为雷达时才画雷达");
|
||||
assert.match(panel, /shown\.layout === "empty" && <div className="ra-empty">/, "空维度用空状态,不补六轴");
|
||||
assert.match(panel, /文字列表与图等价/, "图必须有等价文字列表");
|
||||
// 纠偏第一批:面板必须展示本次核查结果与依据,缺证不得改写成通过
|
||||
assert.match(panel, /核查结果(\{/, "面板必须展示本次核查结果");
|
||||
assert.match(panel, /未完成项:\{item\.message\}/, "降级记录必须显示到界面");
|
||||
assert.match(panel, /查看 \$\{item\.title \|\| item\.code\} 的依据/, "每条核查结果必须能打开依据");
|
||||
assert.match(panel, /本次没有可展示的原文片段/, "没有证据时必须说明按缺证处理,不得留白或编造");
|
||||
assert.match(panel, /没有发现问题不等于已经通过/, "空结果不得被读成通过");
|
||||
assert.match(panel, /width=\{\d+\} height=\{\d+\} viewBox=/, "雷达必须给显式尺寸,否则会撑满容器(真渲染验证过的缺陷)");
|
||||
assert.match(panel, /<text key=\{`label-\$\{item\.id\}`\}/, "雷达必须有轴标签,图不能只有形状");
|
||||
assert.match(panel, /strokeDasharray="4 4"/, "基线必须用虚线,不能只靠颜色区分");
|
||||
|
||||
@@ -26,6 +26,7 @@ export default function PrescreenPanel({project, request, busy, setBusy, setErro
|
||||
const [simulation, setSimulation] = useState<Simulation | null>(null);
|
||||
const [scenarioState, setScenarioState] = useState<"baseline" | "computing" | "ready" | "failed">("baseline");
|
||||
const [openDimension, setOpenDimension] = useState<string>("");
|
||||
const [openFinding, setOpenFinding] = useState<string>("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const seq = useRef(0);
|
||||
const drawer = useRef<HTMLButtonElement | null>(null);
|
||||
@@ -106,12 +107,21 @@ export default function PrescreenPanel({project, request, busy, setBusy, setErro
|
||||
|
||||
function openBasis(id: string, element: HTMLElement | null) {
|
||||
opener.current = element;
|
||||
setOpenFinding(""); // 两个抽屉互斥,避免同时展开两条依据
|
||||
setOpenDimension(id);
|
||||
setTimeout(() => drawer.current?.focus(), 0);
|
||||
}
|
||||
|
||||
function openFindingBasis(id: string, element: HTMLElement | null) {
|
||||
opener.current = element;
|
||||
setOpenDimension("");
|
||||
setOpenFinding(id);
|
||||
setTimeout(() => drawer.current?.focus(), 0);
|
||||
}
|
||||
|
||||
function closeBasis() {
|
||||
setOpenDimension("");
|
||||
setOpenFinding("");
|
||||
opener.current?.focus();
|
||||
}
|
||||
|
||||
@@ -123,6 +133,9 @@ export default function PrescreenPanel({project, request, busy, setBusy, setErro
|
||||
// 刻度信息来自呈现层:运行视图只带阶段序数,因此默认同一刻度;方案带 scale_id 时按实际判断。
|
||||
const shown = presentationFor(dimensions.map((item: any) => ({scale_id: item.scale_id || "stage"})));
|
||||
const basis = dimensions.find((item) => item.id === openDimension) || null;
|
||||
const finding = (run?.findings || []).find((item: any) => (item.id || item.code) === openFinding) || null;
|
||||
const evidenceFor = (ids: string[]) => (run?.evidence || []).filter(
|
||||
(item: any) => ids.includes(item.id) || ids.includes(item.evidence_id));
|
||||
const scenarioStages = new Map((simulation?.dimensions || []).map((item) => [item.id, item]));
|
||||
const domain = domainBadge(run?.data_domain || "demo");
|
||||
|
||||
@@ -163,6 +176,20 @@ export default function PrescreenPanel({project, request, busy, setBusy, setErro
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>核查结果({(run.findings || []).length})</h3>
|
||||
{!(run.findings || []).length && <div className="ra-empty"><h2>本次没有待处理事项</h2>
|
||||
<p>没有发现问题不等于已经通过;正式结论仍由业务流程决定。</p></div>}
|
||||
{(run.findings || []).map((item: any) => <div className="xb-finding" key={item.id || item.code}>
|
||||
<div>
|
||||
<b>{item.title || item.code}</b>
|
||||
<p>{item.detail || item.code} · 状态 {item.status || "待核实"}{item.machine_result ? ` · 核查结果 ${item.machine_result}` : ""}</p>
|
||||
<p>依据 {(item.evidence_ids || []).length} 项{(item.basis_ids || []).length ? ` · 组件 ${item.basis_ids.join("、")}` : ""}</p>
|
||||
</div>
|
||||
<button className="ra-button" aria-label={`查看 ${item.title || item.code} 的依据`}
|
||||
onClick={(e) => openFindingBasis(item.id || item.code, e.currentTarget)}>查看依据</button>
|
||||
</div>)}
|
||||
{(run.findings || []).length > 0 && <p>核查结果来自本次执行;未完成项见上方降级记录,缺证不改写成通过。</p>}
|
||||
|
||||
<h3>核查维度({dimensions.length})</h3>
|
||||
{shown.layout === "empty" && <div className="ra-empty"><h2>{EMPTY_DIMENSIONS}</h2>
|
||||
<p>方案没有维度时不补占位轴,也不给出任何结论。</p></div>}
|
||||
@@ -206,6 +233,19 @@ export default function PrescreenPanel({project, request, busy, setBusy, setErro
|
||||
<button className="ra-button" onClick={() => applyScenario([])} disabled={busy}>返回真实预审(恢复基线)</button>
|
||||
</div>}
|
||||
|
||||
{finding && <div role="dialog" aria-modal="false" aria-label={`${finding.title || finding.code} 的依据`}>
|
||||
<h3>{finding.title || finding.code} · 依据与版本</h3>
|
||||
<p>核查结果 {finding.machine_result || "未记录"} · 状态 {finding.status || "待核实"} · 数据域 {domain.text}</p>
|
||||
<p>引用证据 {evidenceFor(finding.evidence_ids || []).length} 项:</p>
|
||||
{evidenceFor(finding.evidence_ids || []).map((item: any) => <p key={item.id || item.evidence_id}>
|
||||
<b>{item.source || "未标注来源"}</b> · {item.locator || "未标注定位"} · v{item.version || "—"}
|
||||
<br/>{String(item.value || "").slice(0, 240)}
|
||||
</p>)}
|
||||
{!evidenceFor(finding.evidence_ids || []).length && <p>本次没有可展示的原文片段:该核查项按缺证处理,不能据此判定通过或不通过。</p>}
|
||||
<p>引用组件:{(finding.basis_ids || []).join("、") || "未标注"}。证据来源可定位不等于材料真实性已核验,也不换算为概率。</p>
|
||||
<button ref={drawer} className="ra-button" onClick={closeBasis}>关闭依据</button>
|
||||
</div>}
|
||||
|
||||
{basis && <div role="dialog" aria-modal="false" aria-label={`${basis.id} 的依据`}>
|
||||
<h3>{basis.id} · 依据与版本</h3>
|
||||
<p>状态 {stateLabel(basis.state)} · 阶段 {stageName(basis.stage)} · 数据域 {domain.text}</p>
|
||||
|
||||
@@ -26,6 +26,9 @@ export type RunView = {
|
||||
findings: any[]; missing_inputs: string[]; applied_experiences: any[];
|
||||
probability: null; probability_view?: ProbabilityView | null;
|
||||
degradations: {code: string; message: string}[]; dimension_results: DimensionResult[];
|
||||
// 本次核查实际引用的证据:界面"查看依据"的实体来源(纠偏第一批)
|
||||
evidence: {id?: string; evidence_id?: string; source?: string; locator?: string;
|
||||
value?: string; version?: string}[];
|
||||
versions: Record<string, any>;
|
||||
};
|
||||
export type ScenarioDimensionState = {id: string; baseline_stage: number | null; scenario_stage: number | null; assumed: boolean};
|
||||
|
||||
Reference in New Issue
Block a user