diff --git a/backend/api/prescreen.py b/backend/api/prescreen.py index 7ba7f48..46d1946 100644 --- a/backend/api/prescreen.py +++ b/backend/api/prescreen.py @@ -41,7 +41,7 @@ from contracts.prescreen import ( S1_ERROR_RETRYABLE, ) from services import assistant_store as store -from services import fact_conflict, material_import +from services import fact_conflict, material_import, prescreen_sandbox, prescreen_store from services.assistant_settings import flags from services.fact_conflict import FactConflictUnresolved from services.project_assistant import ProjectAssistantService @@ -84,6 +84,13 @@ class ResolveInput(Input): expected_revision: int = Field(ge=1) +class SimulationInput(Input): + """T09 沙盘请求:只传假设动作键,不传任何概率或维度值。""" + + assumptions: list[str] = Field(default_factory=list, max_length=8) + expected_revision: int | None = Field(default=None, ge=1) + + # ---------------------------------------------------------------- 错误信封 def _envelope(request: Request, code: ErrorCode, message: str) -> JSONResponse: request_id = request.headers.get("x-request-id") or store.uid("req") @@ -368,3 +375,80 @@ def resolve_fact_conflict(conflict_id: str, payload: ResolveInput, request: Requ return resolved return run(request, action) + + +# ---------------------------------------------------------------- 运行视图(T09) +@router.get("/projects/{project_id}/runs") +def list_runs(project_id: str, request: Request, limit: int = 20): + """项目内已装配的预审运行视图列表;没有运行就返回空列表,不造空壳。""" + blocked = scope(request, project_id) + if blocked is not None: + return blocked + + def action(): + with _db() as db: + items = prescreen_store.listing(db, project_id, _limit(limit)) + return {"items": items} + + return run(request, action) + + +@router.get("/projects/{project_id}/runs/{run_id}") +def get_run(project_id: str, run_id: str, request: Request): + """只读运行视图。没有运行就是 404,不返回一个空壳运行充数。""" + blocked = scope(request, project_id) + if blocked is not None: + return blocked + + def action(): + with _db() as db: + return prescreen_store.load(db, project_id, run_id) + + return run(request, action) + + +@router.get("/projects/{project_id}/runs/{run_id}/assumptions") +def list_assumptions(project_id: str, run_id: str, request: Request): + """本次方案可用的假设动作。只返回指向该运行真实存在维度的动作(E-22)。""" + blocked = scope(request, project_id) + if blocked is not None: + return blocked + + def action(): + with _db() as db: + view = prescreen_store.load(db, project_id, run_id) + return {"items": prescreen_sandbox.available_assumptions(view), + "scenario_table_version": prescreen_sandbox.DEMO_TABLE_VERSION, + "label": prescreen_sandbox.DEMO_LABEL} + + return run(request, action) + + +@router.post("/projects/{project_id}/runs/{run_id}/simulations", status_code=201) +def create_simulation(project_id: str, run_id: str, payload: SimulationInput, + request: Request): + """按完整假设组合产出一条情景;真实运行只被读取(BR-11 / AC-11)。 + + 演示值只在 demo 域返回(E-21);假设冲突或指向不存在的维度按 E-22 拒绝。 + 请求体里没有任何概率字段——数值一律由服务端查表给出。 + """ + blocked = scope(request, project_id) + if blocked is not None: + return blocked + + def action(): + with _db() as db: + view = prescreen_store.load(db, project_id, run_id) + if payload.expected_revision is not None \ + and view.get("revision") != payload.expected_revision: + raise store.Conflict("运行已有新版本,请刷新后重试") + try: + result = prescreen_sandbox.simulate(view, payload.assumptions) + except prescreen_sandbox.DemoEstimateForbidden as exc: + return _envelope(request, ErrorCode.DEMO_ESTIMATE_FORBIDDEN, str(exc)) + except prescreen_sandbox.AssumptionConflict as exc: + return _envelope(request, ErrorCode.SCENARIO_ASSUMPTION_CONFLICT, str(exc)) + prescreen_sandbox.assert_baseline_untouched(view, result) + return result + + return run(request, action) diff --git a/backend/contracts/prescreen.py b/backend/contracts/prescreen.py index 279709f..5127f5b 100644 --- a/backend/contracts/prescreen.py +++ b/backend/contracts/prescreen.py @@ -105,6 +105,10 @@ class ErrorCode(StrEnum): FACT_CONFLICT_UNRESOLVED = "FACT_CONFLICT_UNRESOLVED" JUDGMENT_CONFIG_INVALID = "JUDGMENT_CONFIG_INVALID" EGRESS_NOT_APPROVED = "EGRESS_NOT_APPROVED" + # T09 沙盘与维度:E-20/21/22 都是"可立即纠正的请求问题",走 422。 + DIMENSION_SCHEMA_CONFLICT = "DIMENSION_SCHEMA_CONFLICT" + DEMO_ESTIMATE_FORBIDDEN = "DEMO_ESTIMATE_FORBIDDEN" + SCENARIO_ASSUMPTION_CONFLICT = "SCENARIO_ASSUMPTION_CONFLICT" # 运行级编码:记录在运行体(degradations / judgment)里,**不构成 HTTP 错误**, # 因此刻意不进 S1_ERROR_HTTP;见下方 RUN_LEVEL_CODES。 JUDGMENT_UNAVAILABLE = "JUDGMENT_UNAVAILABLE" @@ -133,6 +137,9 @@ S1_ERROR_HTTP: dict[ErrorCode, int] = { ErrorCode.FACT_CONFLICT_UNRESOLVED: 422, ErrorCode.JUDGMENT_CONFIG_INVALID: 422, ErrorCode.EGRESS_NOT_APPROVED: 403, + ErrorCode.DIMENSION_SCHEMA_CONFLICT: 422, + ErrorCode.DEMO_ESTIMATE_FORBIDDEN: 422, + ErrorCode.SCENARIO_ASSUMPTION_CONFLICT: 422, } # 这些编码只出现在运行结果里("HTTP 200 仅表示结果可读取"),永远不会作为 diff --git a/backend/contracts/simulation.py b/backend/contracts/simulation.py new file mode 100644 index 0000000..6ddd8e3 --- /dev/null +++ b/backend/contracts/simulation.py @@ -0,0 +1,110 @@ +"""T09 推进沙盘契约:假设动作与情景结果。 + +Owner:本文件(docs/v2/03-detailed-design.md §9.3 Simulation、 +docs/v2/04-ui-spec.md §9、13-probability-prototype.md)。 + +三条不可让渡的规则: +1. **模拟不改真实运行**:情景是独立对象,基线以 `baseline_probability_view` + 副本随行携带;任何情况下都不写回 `ScreeningRun.probability`(BR-11 / AC-11)。 +2. **演示值只属于 demo 域**:`probability_view.demo_estimate` 在 production + 域直接构造失败(E-21 / AC-30);真实估计在 P0 恒为空。 +3. **组合不是加和**:结果由完整动作组合选出(`selection_hash`), + 不按单个动作的增量累加(BR-30 / AC-31)。 +""" + +from __future__ import annotations + +import re +from typing import Annotated, Literal + +from pydantic import Field, model_validator + +from contracts.prescreen import ( + DESC_MAX, + ID_MAX, + AwareDatetime, + Contract, + DataDomain, + ErrorCode, + ProbabilityView, +) + +SHA256 = re.compile(r"^[0-9a-f]{64}$") +Identifier = Annotated[str, Field(min_length=1, max_length=ID_MAX)] + +MAX_ASSUMPTIONS = 8 + + +class SimulationAssumption(Contract): + """一条假设动作。必须写明它改变哪个维度、改到什么阶段、前提是什么。 + + `target_dimension_id` 只能指向本次冻结方案里真实存在的维度:假设不能凭空 + 创造维度,也不能顺手改动别的轴(13 §P-17)。 + """ + + key: Identifier + label: str = Field(min_length=1, max_length=DESC_MAX) + target_dimension_id: Identifier + assumed_stage: int = Field(ge=0, le=4) + premise: str = Field(min_length=1, max_length=DESC_MAX) + + +class ScenarioDimensionState(Contract): + """情景下的维度阶段。基线值与情景值同时给出,界面据此画虚线与实线。""" + + id: Identifier + baseline_stage: int | None = Field(default=None, ge=0, le=4) + scenario_stage: int | None = Field(default=None, ge=0, le=4) + assumed: bool = False + + +class Simulation(Contract): + """一次情景推演的结果视图。**不是**对真实运行的修改。""" + + id: Identifier + run_id: Identifier + project_id: Identifier + data_domain: DataDomain + revision: int = Field(ge=1) + # 完整动作组合的摘要:结果必须对应当前选择,而不是"最新到达的响应" + selection_hash: str = Field(pattern=SHA256.pattern) + status: Literal["queued", "computing", "ready", "failed"] + assumptions: list[SimulationAssumption] = Field(default_factory=list, + max_length=MAX_ASSUMPTIONS) + # 基线副本:模拟结束后界面据此恢复,真实运行未被触碰 + baseline_probability_view: ProbabilityView + probability_view: ProbabilityView | None = None + dimensions: list[ScenarioDimensionState] = Field(default_factory=list, max_length=32) + remaining_inputs: list[str] = Field(default_factory=list, max_length=200) + reason: str = Field(default="", max_length=DESC_MAX) + error_code: ErrorCode | None = None + frozen_versions: dict = Field(default_factory=dict) + created_at: AwareDatetime + + @model_validator(mode="after") + def _check_simulation(self) -> Simulation: + if self.status == "ready" and self.probability_view is None: + raise ValueError("就绪的情景必须给出概率视图") + if self.status == "failed": + if self.error_code is None: + raise ValueError("失败的情景必须带错误码,不能只留空结果") + if self.probability_view is not None: + raise ValueError("失败的情景不得显示任何结果") + if self.status in ("queued", "computing") and self.probability_view is not None: + raise ValueError("情景尚未算完时不得先给一个乐观增幅") + # 演示值只在 demo 域:越域直接构造失败,而不是靠调用方记得不传(AC-30) + for view in (self.probability_view, self.baseline_probability_view): + demo = (view.demo_estimate if view else None) or None + if demo is not None and self.data_domain != DataDomain.demo: + raise ValueError("演示估计只允许出现在演示数据域(E-21)") + keys = [item.key for item in self.assumptions] + if len(set(keys)) != len(keys): + raise ValueError("同一假设动作不得重复计入组合") + targets = [item.target_dimension_id for item in self.assumptions] + if len(set(targets)) != len(targets): + raise ValueError("一次情景只允许每个维度有一个假设动作,冲突假设应被拒绝") + return self + + def unchanged_dimensions(self) -> list[str]: + """未被假设触碰的维度 id:界面必须显示它们没有变化,而不是消失。""" + return [item.id for item in self.dimensions if not item.assumed] diff --git a/backend/services/prescreen_sandbox.py b/backend/services/prescreen_sandbox.py new file mode 100644 index 0000000..06252ff --- /dev/null +++ b/backend/services/prescreen_sandbox.py @@ -0,0 +1,211 @@ +"""T09 推进沙盘服务:把"假设补件"算成独立情景,不触碰真实运行。 + +依据:docs/v2/13-probability-prototype.md §2/§4、04-ui-spec.md §5/§9、 +03-detailed-design.md §9.3;BR-11/29/30、E-21/22、AC-11/12/30/31/44。 + +设计要点: +1. **演示值查表,不累加**。结果由完整动作组合选出(`selection_hash`)。表里 + 00/10/01/11 是四条独立条目,不是 42+16+9=67 的算术和;`assert_not_additive` + 把这条约束写成可执行断言,防止后来者把表换成"每项加分"。 +2. **真实运行只读**。`simulate()` 接受运行视图 dict,返回新对象;基线的演示值 + 以副本随行携带,任何时候都不写回运行的 `probability`。 +3. **越域即拒绝**。production 域不得有演示估计(E-21);假设指向不存在的维度、 + 同一维度两个互斥假设,都按 E-22 拒绝,而不是忽略掉一个。 +""" + +from __future__ import annotations + +import hashlib +import json + +from contracts.prescreen import DataDomain, ErrorCode, ProbabilityView +from contracts.simulation import Simulation, SimulationAssumption +from services import assistant_store as store + +DEMO_TABLE_VERSION = "DEMO-TABLE-v1" +DEMO_SOURCE = "synthetic_demo" +# 说明文字固定:界面必须紧邻数值展示,避免被当作真实概率(DESIGN.md §38) +DEMO_LABEL = "人工设定,仅演示交互;未经历史数据校准" + +STAGE_NAMES = ("未提供", "仅有说明", "资料待核", "交叉核验", "人工复核") + + +class DemoEstimateForbidden(ValueError): + """当前数据域不允许使用演示概率(E-21)。""" + + +class AssumptionConflict(ValueError): + """所选推进条件冲突、缺少前提或指向不存在的维度(E-22)。""" + + +# 假设动作登记表。`dimension_id` 必须与冻结方案里的维度 id 一致,否则拒绝。 +ASSUMPTIONS: dict[str, dict] = { + "assume_bank_flow": { + "label": "假设银行流水逐笔匹配", + "dimension_id": "DC-CASH", + "assumed_stage": 3, + "premise": "假设核验支持账期变化解释;只改变回款核验维度", + }, + "assume_title": { + "label": "假设设备权属证明一致", + "dimension_id": "DC-TITLE", + "assumed_stage": 3, + "premise": "假设合同、发票与设备清单核验一致;只改变权属维度", + }, +} + +# 演示情景表(13 §3)。key = 流水假设 + 权属假设的组合位。 +DEMO_SCENARIOS: dict[str, dict] = { + "00": {"value": 42, "remaining": ["回款核验仍待核实", "设备权属仍待核实"], + "reason": "未选择假设。回款与设备权属证据仍有缺口,保持演示基线。"}, + "10": {"value": 58, "remaining": ["设备权属仍待核实"], + "reason": "假设银行流水逐笔核验后支持账期变化解释,回款不确定性减小;" + "设备权属缺口仍在。演示变化 +16 个百分点。"}, + "01": {"value": 51, "remaining": ["实际回款仍待核实"], + "reason": "假设合同、发票与清单一致且经核验,权属争议得到回应;" + "实际回款仍缺银行流水。演示变化 +9 个百分点。"}, + "11": {"value": 64, "remaining": ["仍需整体人工复核"], + "reason": "假设回款与权属核验均支持项目解释。组合情景为 64%,变化 +22 " + "个百分点;组合影响不简单相加,仍需综合复核其他风险。"}, +} + + +def scenario_key(keys) -> str: + """把动作组合压成表键:按登记顺序取位,未选为 0。""" + order = list(ASSUMPTIONS) + unknown = sorted(set(keys or ()) - set(order)) + if unknown: + raise AssumptionConflict(f"未登记的假设动作:{unknown}") + return "".join("1" if item in set(keys or ()) else "0" for item in order) + + +def selection_hash(run_id: str, revision: int, keys) -> str: + """完整动作组合的摘要。前端只接受与当前选择一致的结果(AC-31)。""" + payload = json.dumps({"run_id": run_id, "revision": revision, + "key": scenario_key(keys)}, ensure_ascii=False, + sort_keys=True) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def assert_demo_domain(data_domain) -> None: + value = data_domain.value if hasattr(data_domain, "value") else str(data_domain) + if value != DataDomain.demo.value: + raise DemoEstimateForbidden("当前数据域不允许使用演示概率;真实概率需完成校准后另立验证") + + +def assert_not_additive() -> None: + """组合值必须来自查表:任何"单项增量相加"的实现都会在这里失败。""" + baseline = DEMO_SCENARIOS["00"]["value"] + pair = DEMO_SCENARIOS["11"]["value"] + added = baseline + (DEMO_SCENARIOS["10"]["value"] - baseline) \ + + (DEMO_SCENARIOS["01"]["value"] - baseline) + if pair == added: + raise AssertionError("演示情景表退化成单项加和,须回到按完整组合查表(BR-30)") + + +def _view(snapshot_id: str, key: str, as_of: str | None = None) -> ProbabilityView: + row = DEMO_SCENARIOS[key] + return ProbabilityView( + snapshot_id=snapshot_id, as_of=as_of or store.now(), + demo_estimate={"value": row["value"] / 100, "source": DEMO_SOURCE, + "scenario_table_version": DEMO_TABLE_VERSION, + "scenario_key": key, "label": DEMO_LABEL}) + + +def _assumptions_for(keys, dimension_ids) -> list[SimulationAssumption]: + known = set(dimension_ids) + chosen: list[SimulationAssumption] = [] + for key in ASSUMPTIONS: + if key not in set(keys or ()): + continue + item = ASSUMPTIONS[key] + if item["dimension_id"] not in known: + raise AssumptionConflict( + f"假设动作 {key} 指向本次方案不存在的维度 {item['dimension_id']}") + chosen.append(SimulationAssumption( + key=key, label=item["label"], target_dimension_id=item["dimension_id"], + assumed_stage=item["assumed_stage"], premise=item["premise"])) + return chosen + + +def _dimension_states(run_view: dict, assumptions) -> list[dict]: + """只覆盖被假设的维度;其余维度原样保留并标为未变(13 §P-17)。""" + assumed = {item.target_dimension_id: item for item in assumptions} + rows = [] + for item in run_view.get("dimension_results") or (): + hit = assumed.get(item["id"]) + rows.append({"id": item["id"], + "baseline_stage": item.get("stage"), + "scenario_stage": hit.assumed_stage if hit else item.get("stage"), + "assumed": hit is not None}) + return rows + + +def failed(run_view: dict, keys, message: str, code: ErrorCode, + revision: int | None = None) -> dict: + """情景失败:保留基线、标未完成,不得回退成"演示表默认值"(13 §44)。""" + revision = revision or int(run_view.get("revision") or 1) + baseline = _baseline_view(run_view) + body = Simulation( + id=store.uid("simulation"), run_id=run_view["id"], project_id=run_view["project_id"], + data_domain=run_view["data_domain"], revision=revision, + selection_hash=selection_hash(run_view["id"], revision, keys), + status="failed", assumptions=[], baseline_probability_view=baseline, + probability_view=None, dimensions=_dimension_states(run_view, []), + remaining_inputs=list(run_view.get("missing_inputs") or ()), + reason=message, error_code=code, + frozen_versions=dict(run_view.get("versions") or ()), created_at=store.now()) + return body.model_dump(mode="json") + + +def available_assumptions(run_view: dict) -> list[dict]: + """列出本次方案可用的假设动作。 + + 只返回目标维度真实存在于该运行的动作:界面因此不会给出点了必然失败的选项, + 服务端也不靠"忽略无效动作"来掩饰不匹配(E-22)。 + """ + known = {item["id"] for item in run_view.get("dimension_results") or ()} + return [{"key": key, "label": item["label"], "dimension_id": item["dimension_id"], + "assumed_stage": item["assumed_stage"], "premise": item["premise"]} + for key, item in ASSUMPTIONS.items() if item["dimension_id"] in known] + + +def _baseline_view(run_view: dict) -> ProbabilityView: + snapshot_id = run_view.get("snapshot_id") or run_view.get("input_snapshot_id") + if not snapshot_id: + raise ValueError("运行缺少输入快照,无法建立情景基线") + return _view(snapshot_id, "00") + + +def simulate(run_view: dict, keys, revision: int | None = None) -> dict: + """按完整动作组合产出一条情景;真实运行只被读取,不被改写。""" + assert_demo_domain(run_view["data_domain"]) + revision = revision or int(run_view.get("revision") or 1) + dimension_ids = [item["id"] for item in run_view.get("dimension_results") or ()] + assumptions = _assumptions_for(keys, dimension_ids) + key = scenario_key(keys) + row = DEMO_SCENARIOS[key] + baseline = _baseline_view(run_view) + + body = Simulation( + id=store.uid("simulation"), run_id=run_view["id"], project_id=run_view["project_id"], + data_domain=run_view["data_domain"], revision=revision, + selection_hash=selection_hash(run_view["id"], revision, keys), + status="ready", assumptions=assumptions, + baseline_probability_view=baseline, + probability_view=_view(baseline.snapshot_id, key, baseline.as_of), + dimensions=_dimension_states(run_view, assumptions), + remaining_inputs=list(row["remaining"]), reason=row["reason"], + frozen_versions=dict(run_view.get("versions") or ()), created_at=store.now()) + return body.model_dump(mode="json") + + +def assert_baseline_untouched(run_view: dict, simulation: dict) -> None: + """装配出口的自检:情景不得改写运行的概率字段(BR-11 / AC-11)。""" + if run_view.get("probability") is not None: + raise AssertionError("情景推演不得给真实运行写入概率") + if (run_view.get("probability_view") or {}).get("real_estimate") is not None: + raise AssertionError("情景推演不得给真实运行写入真实估计") + baseline = simulation.get("baseline_probability_view") or {} + if baseline.get("real_estimate") is not None: + raise AssertionError("情景基线副本的真实估计必须为空") diff --git a/backend/services/prescreen_store.py b/backend/services/prescreen_store.py new file mode 100644 index 0000000..f53d36e --- /dev/null +++ b/backend/services/prescreen_store.py @@ -0,0 +1,46 @@ +"""T09 预审运行视图的只读存储:把装配好的 ScreeningRun 投影存下来供界面读取。 + +依据:docs/v2/03-detailed-design.md §9.2、BR-13;AC-12/30。 + +**不新建表**:复用既有 `assistant_objects`(新 kind `prescreen_run`),旧库结构不变。 +运行视图是既有 run 对象的**只读投影**,因此这里只提供保存与读取, +不提供就地修改:要改结果必须产生新的运行修订(BR-13:过程修订不覆盖旧结果)。 +""" + +from __future__ import annotations + +from contracts.prescreen import ScreeningRun +from services import assistant_store as store + +KIND = "prescreen_run" + + +def save(db, screening: dict) -> dict: + """保存一条已装配的运行视图;非契约形状直接拒绝,不落库。""" + body = ScreeningRun(**screening).model_dump(mode="json") + store.put(db, KIND, body, expected=screening.get("_expected_revision")) + return body + + +def load(db, project_id: str, run_id: str) -> dict: + """按项目读取运行视图。不存在或不属于该项目 → KeyError(由接口层转 404)。""" + body = store.get(db, KIND, run_id) + if body.get("project_id") != project_id: + raise KeyError("运行不存在或不属于该项目") + return body + + +def latest(db, project_id: str) -> dict | None: + """项目最近一条运行视图;没有就返回 None,不编造空壳运行。""" + rows = [item for item in store.listing(db, KIND, project_id)] + if not rows: + return None + return max(rows, key=lambda item: (item.get("created_at") or "", item.get("revision") or 0)) + + +def listing(db, project_id: str, limit: int = 20) -> list[dict]: + """项目内运行视图,新的在前。用于界面选择要查看/推演的那一条运行。""" + rows = [item for item in store.listing(db, KIND, project_id)] + rows.sort(key=lambda item: (item.get("created_at") or "", item.get("revision") or 0), + reverse=True) + return rows[:limit] diff --git a/backend/test_prescreen_sandbox.py b/backend/test_prescreen_sandbox.py new file mode 100644 index 0000000..437acb8 --- /dev/null +++ b/backend/test_prescreen_sandbox.py @@ -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() diff --git a/backend/test_prescreen_sandbox_api.py b/backend/test_prescreen_sandbox_api.py new file mode 100644 index 0000000..f189806 --- /dev/null +++ b/backend/test_prescreen_sandbox_api.py @@ -0,0 +1,217 @@ +"""T09 沙盘接口层测试:运行只读视图与情景端点(AC-11/12/30/31)。 + +与 test_prescreen_api.py 同一套路:只挂 prescreen 路由到独立 app,库指向临时文件, +不触碰真实库、不启动服务。 +""" + +import copy +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from fastapi import FastAPI +from fastapi.exceptions import RequestValidationError +from fastapi.testclient import TestClient + +from api import prescreen as api +from services import assistant_store as store +from services import judgment_provider as jp +from contracts.prescreen import ScreeningRun +from services import prescreen, prescreen_policy, prescreen_store +from services.project_assistant import ProjectAssistantService + +HEADERS = {"X-Xingbang-Demo-Role": "manager"} +PROJECT = "DEMO-B" + +DIMENSIONS = [ + {"id": "DC-CASH", "component_refs": [{"id": "corporate.cashflow"}]}, + {"id": "DC-TITLE", "component_refs": [{"id": "corporate.title"}]}, +] +FINDINGS = [ + {"code": "corporate.cashflow", "result": "unknown", "stage": 1, + "evidence_ids": ["EV-1"]}, + {"code": "corporate.title", "result": "unknown", "stage": 1, + "evidence_ids": ["EV-2"]}, +] + + +def screening(data_domain="demo"): + plan = { + "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, + } + decision = prescreen_policy.decide(FINDINGS) + decision["findings"] = FINDINGS + decision["missing_inputs"] = ["近六个月银行流水", "设备权属材料"] + return prescreen.assemble( + {"id": "run-1", "project_id": PROJECT, "created_at": store.now(), + "input_snapshot_id": "snap-1", "revision": 3}, + plan, decision, jp.resolve_config(False), data_domain=data_domain) + + +class SandboxApiTests(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.dict(os.environ, {"XINGBANG_AUTH_USERS": "", "XINGBANG_REQUIRE_AUTH": "0", + "XINGBANG_LOCAL_MODEL_URL": ""}), + ] + for p in self.patches: + p.start() + app = FastAPI() + app.add_exception_handler(RequestValidationError, api.validation_handler) + app.include_router(api.router) + self.client = TestClient(app) + self.base = "/api/xingbang/risk-agent/project-assistant/prescreen" + self.store_run() + + def tearDown(self): + self.client.close() + for p in reversed(self.patches): + p.stop() + self.temp.cleanup() + + def store_run(self, data_domain="demo"): + self.view = screening(data_domain=data_domain) + with store.transaction(self.service.db_path) as db: + prescreen_store.save(db, self.view) + return self.view + + def post(self, path, body=None): + return self.client.post(self.base + path, json=body if body is not None else {}, + headers=HEADERS) + + def get(self, path): + return self.client.get(self.base + path, headers=HEADERS) + + def error(self, response): + body = response.json() + self.assertIn("error", body, body) + return body["error"] + + # ------------------------------------------------------------ 运行只读视图 + def test_run_view_is_readable(self): + response = self.get(f"/projects/{PROJECT}/runs/run-1") + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["id"], "run-1") + self.assertIsNone(body["probability"]) + self.assertIsNone(body["probability_view"]["real_estimate"]) + self.assertIsNone(body["probability_view"]["demo_estimate"], + "真实运行视图不得携带演示值") + + def test_unknown_run_is_404_without_leaking_existence(self): + response = self.get(f"/projects/{PROJECT}/runs/run-missing") + self.assertEqual(response.status_code, 404) + self.assertEqual(self.error(response)["code"], "NOT_FOUND") + + def test_run_of_another_project_is_404(self): + response = self.get("/projects/DEMO-C/runs/run-1") + self.assertEqual(response.status_code, 404) + + def test_foreign_project_is_404(self): + response = self.get("/projects/OTHER-9/runs/run-1") + self.assertEqual(response.status_code, 404) + + def test_run_listing_is_newest_first(self): + with store.transaction(self.service.db_path) as db: + older = dict(screening(), id="run-old", revision=1, + created_at="2026-09-29T01:00:00+00:00") + prescreen_store.save(db, ScreeningRun(**older).model_dump(mode="json")) + response = self.get(f"/projects/{PROJECT}/runs") + self.assertEqual(response.status_code, 200) + self.assertEqual([item["id"] for item in response.json()["items"]], + ["run-1", "run-old"]) + + def test_available_assumptions_only_reference_existing_dimensions(self): + response = self.get(f"/projects/{PROJECT}/runs/run-1/assumptions") + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(sorted(item["key"] for item in body["items"]), + ["assume_bank_flow", "assume_title"]) + self.assertEqual(body["scenario_table_version"], "DEMO-TABLE-v1") + self.assertIn("人工设定", body["label"]) + + trimmed = dict(screening()) + trimmed["dimension_results"] = [item for item in trimmed["dimension_results"] + if item["id"] != "DC-TITLE"] + with store.transaction(self.service.db_path) as db: + prescreen_store.save(db, ScreeningRun(**trimmed).model_dump(mode="json")) + trimmed_response = self.get(f"/projects/{PROJECT}/runs/run-1/assumptions") + self.assertEqual([item["key"] for item in trimmed_response.json()["items"]], + ["assume_bank_flow"]) + + # ------------------------------------------------------------ 情景 + def test_simulation_returns_demo_estimate_only(self): + response = self.post(f"/projects/{PROJECT}/runs/run-1/simulations", + {"assumptions": ["assume_bank_flow"]}) + self.assertEqual(response.status_code, 201) + body = response.json() + demo = body["probability_view"]["demo_estimate"] + self.assertEqual(demo["value"], 0.58) + self.assertEqual(demo["scenario_table_version"], "DEMO-TABLE-v1") + self.assertEqual(demo["scenario_key"], "10") + self.assertIn("人工设定", demo["label"]) + self.assertIsNone(body["probability_view"]["real_estimate"]) + self.assertEqual(body["baseline_probability_view"]["demo_estimate"]["value"], 0.42) + rows = {item["id"]: item for item in body["dimensions"]} + self.assertEqual(rows["DC-CASH"]["scenario_stage"], 3) + self.assertEqual(rows["DC-TITLE"]["scenario_stage"], + rows["DC-TITLE"]["baseline_stage"]) + + def test_combination_uses_the_table_not_the_sum(self): + response = self.post(f"/projects/{PROJECT}/runs/run-1/simulations", + {"assumptions": ["assume_bank_flow", "assume_title"]}) + self.assertEqual(response.json()["probability_view"]["demo_estimate"]["value"], 0.64) + + def test_empty_assumptions_return_the_baseline_scenario(self): + response = self.post(f"/projects/{PROJECT}/runs/run-1/simulations", {}) + self.assertEqual(response.status_code, 201) + demo = response.json()["probability_view"]["demo_estimate"] + self.assertEqual(demo["scenario_key"], "00") + self.assertEqual(demo["value"], 0.42) + + def test_simulation_does_not_touch_the_real_run(self): + before = copy.deepcopy(self.get(f"/projects/{PROJECT}/runs/run-1").json()) + self.post(f"/projects/{PROJECT}/runs/run-1/simulations", + {"assumptions": ["assume_title"]}) + after = self.get(f"/projects/{PROJECT}/runs/run-1").json() + self.assertEqual(before, after, "情景推演不得改动真实运行视图") + self.assertIsNone(after["probability"]) + self.assertIsNone(after["probability_view"]["demo_estimate"]) + + def test_conflicting_assumption_is_rejected(self): + response = self.post(f"/projects/{PROJECT}/runs/run-1/simulations", + {"assumptions": ["assume_everything"]}) + self.assertEqual(response.status_code, 422) + self.assertEqual(self.error(response)["code"], "SCENARIO_ASSUMPTION_CONFLICT") + + def test_production_domain_cannot_use_demo_estimates(self): + self.store_run(data_domain="production") + response = self.post(f"/projects/{PROJECT}/runs/run-1/simulations", + {"assumptions": ["assume_bank_flow"]}) + self.assertEqual(response.status_code, 422) + self.assertEqual(self.error(response)["code"], "DEMO_ESTIMATE_FORBIDDEN") + + def test_stale_revision_is_a_conflict(self): + response = self.post(f"/projects/{PROJECT}/runs/run-1/simulations", + {"assumptions": [], "expected_revision": 2}) + self.assertEqual(response.status_code, 409) + self.assertEqual(self.error(response)["code"], "REVISION_CONFLICT") + + def test_unknown_field_in_request_is_rejected(self): + response = self.post(f"/projects/{PROJECT}/runs/run-1/simulations", + {"assumptions": [], "probability": 0.9}) + self.assertEqual(response.status_code, 422) + self.assertEqual(self.error(response)["code"], "INVALID_REQUEST") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_prescreen_ui.cjs b/scripts/test_prescreen_ui.cjs new file mode 100644 index 0000000..07d42c9 --- /dev/null +++ b/scripts/test_prescreen_ui.cjs @@ -0,0 +1,78 @@ +/** + * T09 预审工作台与沙盘界面源码守卫。 + * + * 这是**源码断言**,不是浏览器端到端测试(docs/v2/06-validation.md §2 已明确 + * 现有 UI 脚本的性质)。它锁住的是可机械核对的约束:数值只能来自服务端、 + * 演示值只在演示域、雷达只画 3–8 维、乱序响应丢弃、失败保留基线、 + * 图的等价文字与键盘可达。 + */ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); + +const root = path.resolve(__dirname, ".."); +const read = (file) => fs.readFileSync(path.join(root, file), "utf8"); + +const panel = read("src/components/xingbang/PrescreenPanel.tsx"); +const lib = read("src/lib/prescreen-run.ts"); +const workbench = read("src/app/xingbang-workbench/page.tsx"); + +// 1. 挂载在既有工作台内,不新建路由(04-ui-spec §1:不恢复已合并的旧页面) +assert.match(workbench, /import PrescreenPanel from "@\/components\/xingbang\/PrescreenPanel"/, "工作台应导入预审面板"); +assert.match(workbench, //, "空维度用空状态,不补六轴"); +assert.match(panel, /文字列表与图等价/, "图必须有等价文字列表"); + +// 6. 乱序与过期响应必须丢弃(AC-31) +assert.match(panel, /ticket !== seq\.current/, "旧请求晚到必须丢弃"); +assert.match(panel, /returned !== \[\.\.\.keys\]\.sort\(\)\.join\(","\)/, "结果与当前选择不一致时必须丢弃"); + +// 7. 失败保留基线并标未完成;计算中不得先给乐观增幅 +assert.match(panel, /该情景未完成。基线数值与原有意见保持不变/, "失败必须保留基线"); +assert.match(panel, /不会先给一个乐观增幅/, "计算中不得预告结果"); +assert.match(panel, /不是各项增量的简单相加/, "沙盘必须说明组合非加和"); + +// 8. 键盘可达与无障碍 +assert.match(panel, /]*aria-label=/.test(panel.replace(/\n/g, " ")), "雷达必须有文字等价说明"); + +// 9. 来源与数据域用文字+图标共同区分,不靠颜色单独传意(AC-42) +assert.match(lib, /text: "历史提炼"/); +assert.match(lib, /text: "人工创建"/); +assert.match(lib, /text: "制度导入"/); +assert.match(lib, /icon: "history"/); +assert.match(panel, /domain\.text/, "数据域必须以文字呈现"); + +// 10. 阶段是序数,不得换算成概率 +assert.match(lib, /雷达面积不换算成百分比|STAGE_LEGEND/, "必须说明阶段与概率无关"); +assert.ok(!/probability\s*[:=]\s*[0-9]/.test(panel), "面板不得写入任何概率数值"); + +console.log("prescreen-ui: PASS"); diff --git a/scripts/verify.py b/scripts/verify.py index f1d4150..fabf4b0 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -213,6 +213,7 @@ def main() -> int: ("cloud-assistant-access-ui", [executable("node"), "scripts/test_cloud_assistant_access.cjs"], ROOT), ("ui-readability", [executable("node"), "scripts/test_ui_readability.cjs"], ROOT), ("prescreen-panel-ui", [executable("node"), "scripts/test_prescreen_panel.cjs"], ROOT), + ("prescreen-ui", [executable("node"), "scripts/test_prescreen_ui.cjs"], ROOT), ("frontend-build", [npm, "run", "build"], ROOT), ("compose-local-config", [docker, "compose", "-f", "docker-compose.yml", "config", "--quiet"], ROOT), ("compose-aliyun-config", [docker, "compose", "--env-file", "deploy/aliyun.env.example", "-f", "deploy/docker-compose.aliyun.yml", "config", "--quiet"], ROOT), diff --git a/src/app/xingbang-workbench/page.tsx b/src/app/xingbang-workbench/page.tsx index 2c857b3..03ace45 100644 --- a/src/app/xingbang-workbench/page.tsx +++ b/src/app/xingbang-workbench/page.tsx @@ -5,6 +5,7 @@ import AgentNav from "@/components/xingbang/AgentNav"; import AssistantHistory from "@/components/xingbang/AssistantHistory"; import ReviewerWorkspace from "@/components/xingbang/ReviewerWorkspace"; import ImportBatchPanel from "@/components/xingbang/ImportBatchPanel"; +import PrescreenPanel from "@/components/xingbang/PrescreenPanel"; import {messageOf} from "@/lib/prescreen"; import {newRequestId} from "@/lib/request-id"; import "../data-access/workspace.css"; @@ -92,7 +93,7 @@ export default function XingbangWorkspace() { {tab === "overview" && <>
当前数据版本v{project.data_revision}{project.documents.length} 份材料
最近运行{statuses[project.runs[0]?.status] || "未检查"}{project.runs[0]?.created_at ? new Date(project.runs[0].created_at).toLocaleString() : "点击检查项目"}

AI检查结果

先看业务结论;数据来源和解决建议可直接展开,技术信息放在最后。

{project.runs.length ? <>

本次要求:{chosenRun.requirement || "无额外要求"}

{["queued", "running"].includes(chosenRun.status) && }{chosenRun.error &&

{chosenRun.error}

}{chosenRun.steps.map((step:any)=>)}{chosenRun.data_errors?.map((e:any,i:number)=>

数据读取未完成:{e.message}

)}
技术追溯信息
{JSON.stringify({snapshot:chosenRun.snapshot_id,versions:chosenRun.component_versions,changes:chosenRun.change_summary},null,2)}
:

尚无运行记录。

}
业务历史与操作留痕{project.migration_note &&

{project.migration_note};已保留 {project.legacy_records.length} 条历史记录。

}
} - {tab === "findings" && <> open(project.id)}/>

材料档案

上传后先查看解析结果,确认候选值才更新项目。旧版本与来源保留;扫描件无法识别时会提示补充。

{project.documents.map((d: any) =>
{d.name}

版本 {d.version} · {d.status} · {d.size} 字节

)} + {tab === "findings" && <> open(project.id)}/>

材料档案

上传后先查看解析结果,确认候选值才更新项目。旧版本与来源保留;扫描件无法识别时会提示补充。

{project.documents.map((d: any) =>
{d.name}

版本 {d.version} · {d.status} · {d.size} 字节

)} {document &&

{document.name}

{document.warnings.map((w: string, i: number) =>

{w}

)}{document.candidates.map((c: any) =>
{c.field} = {String(c.value ?? "缺失")}

{c.locator} · {c.period || "期间未明"} · {c.unit || "单位未明"} · {c.scope || "口径未明"}

)}
解析原文与定位{document.chunks.map((c: any, i: number) =>

{c.locator} {c.text}{c.formula && 公式:{c.formula};缓存:{c.cached_value || "无"}}

)}
}

{isReviewer?"全链路评审":"问题与解决"}

{isReviewer?"逐项查看数据来源、AI判断和客户经理说明,再选择评审结论。":"每个问题都说明影响和下一步;处理完成后重新AI预审。"}

{isReviewer&&}{!project.findings.length&&

尚无问题记录,请先执行检查。

}{[...project.findings].sort((a:any,b:any)=>Number(["已解决","不适用"].includes(a.status))-Number(["已解决","不适用"].includes(b.status))).map((finding:any)=>act(`${base}/findings/${finding.id}/actions`,{action:status,revision:finding.revision,note:reason})} onReviewer={(decision:string)=>reviewDecision(finding.id,decision)} decision={project.review_decisions?.find((item:any)=>item.finding_id===finding.id)}/>)}{!isReviewer&&<>
}{isReviewer&&
完成全链路核查AI只提供建议,批准或退回由评审员确认。
}
} {tab === "report" &&

{isReviewer ? "独立评审报告" : "AI预审报告"}

模板、运行快照、Skills 与规则版本随报告冻结;每个历史版本都可以重新打开。

{chosenReport &&

{chosenReport.disclaimer} · {statuses[chosenReport.status]}

来源快照:{chosenReport.snapshot_id}
{isManager && }{canReview && }
{chosenReport.sections.map((s: any) =>

{s.title} {s.stale ? "· 来源已过期" : ""} {s.locked ? "· 已锁定" : ""} {s.confirmed ? "· 已确认" : ""}