diff --git a/backend/contracts/prescreen.py b/backend/contracts/prescreen.py index 8208658..05bc3d1 100644 --- a/backend/contracts/prescreen.py +++ b/backend/contracts/prescreen.py @@ -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 diff --git a/backend/services/assistant_worker.py b/backend/services/assistant_worker.py index 50325e1..f6ceb8e 100644 --- a/backend/services/assistant_worker.py +++ b/backend/services/assistant_worker.py @@ -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) diff --git a/backend/services/prescreen.py b/backend/services/prescreen.py index 7199c41..e459e7b 100644 --- a/backend/services/prescreen.py +++ b/backend/services/prescreen.py @@ -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") diff --git a/backend/test_prescreen_from_run.py b/backend/test_prescreen_from_run.py new file mode 100644 index 0000000..1f47dab --- /dev/null +++ b/backend/test_prescreen_from_run.py @@ -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() diff --git a/docs/v2/17-batch-delivery-records.md b/docs/v2/17-batch-delivery-records.md new file mode 100644 index 0000000..dae46a3 --- /dev/null +++ b/docs/v2/17-batch-delivery-records.md @@ -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)。 + +--- + +## 第二批:完成历史经验加工流程 + +状态:**未开始**。 + +## 第三批:按已确认原型完成新版界面 + +状态:**未开始**。 + +## 第四批:接通评审反馈与改进发布 + +状态:**未开始**。 diff --git a/scripts/test_prescreen_ui.cjs b/scripts/test_prescreen_ui.cjs index d729948..a1208bb 100644 --- a/scripts/test_prescreen_ui.cjs +++ b/scripts/test_prescreen_ui.cjs @@ -53,6 +53,12 @@ assert.match(lib, /EMPTY_DIMENSIONS = "当前方案尚未配置核查维度"/); assert.match(panel, /shown\.layout === "radar" && /, "空维度用空状态,不补六轴"); 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, /(null); const [scenarioState, setScenarioState] = useState<"baseline" | "computing" | "ready" | "failed">("baseline"); const [openDimension, setOpenDimension] = useState(""); + const [openFinding, setOpenFinding] = useState(""); const [loaded, setLoaded] = useState(false); const seq = useRef(0); const drawer = useRef(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 +

核查结果({(run.findings || []).length})

+ {!(run.findings || []).length &&

本次没有待处理事项

+

没有发现问题不等于已经通过;正式结论仍由业务流程决定。

} + {(run.findings || []).map((item: any) =>
+
+ {item.title || item.code} +

{item.detail || item.code} · 状态 {item.status || "待核实"}{item.machine_result ? ` · 核查结果 ${item.machine_result}` : ""}

+

依据 {(item.evidence_ids || []).length} 项{(item.basis_ids || []).length ? ` · 组件 ${item.basis_ids.join("、")}` : ""}

+
+ +
)} + {(run.findings || []).length > 0 &&

核查结果来自本次执行;未完成项见上方降级记录,缺证不改写成通过。

} +

核查维度({dimensions.length})

{shown.layout === "empty" &&

{EMPTY_DIMENSIONS}

方案没有维度时不补占位轴,也不给出任何结论。

} @@ -206,6 +233,19 @@ export default function PrescreenPanel({project, request, busy, setBusy, setErro } + {finding &&
+

{finding.title || finding.code} · 依据与版本

+

核查结果 {finding.machine_result || "未记录"} · 状态 {finding.status || "待核实"} · 数据域 {domain.text}

+

引用证据 {evidenceFor(finding.evidence_ids || []).length} 项:

+ {evidenceFor(finding.evidence_ids || []).map((item: any) =>

+ {item.source || "未标注来源"} · {item.locator || "未标注定位"} · v{item.version || "—"} +
{String(item.value || "").slice(0, 240)} +

)} + {!evidenceFor(finding.evidence_ids || []).length &&

本次没有可展示的原文片段:该核查项按缺证处理,不能据此判定通过或不通过。

} +

引用组件:{(finding.basis_ids || []).join("、") || "未标注"}。证据来源可定位不等于材料真实性已核验,也不换算为概率。

+ +
} + {basis &&

{basis.id} · 依据与版本

状态 {stateLabel(basis.state)} · 阶段 {stageName(basis.stage)} · 数据域 {domain.text}

diff --git a/src/lib/prescreen-run.ts b/src/lib/prescreen-run.ts index b9921c4..8faaa19 100644 --- a/src/lib/prescreen-run.ts +++ b/src/lib/prescreen-run.ts @@ -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; }; export type ScenarioDimensionState = {id: string; baseline_stage: number | null; scenario_stage: number | null; assumed: boolean};