feat(v2): T09 预审与推进沙盘——运行只读视图、演示情景与界面守卫

覆盖 AC-11/12/29/30/31/42/44(BR-11/29/30/41/43、E-20/21/22)。

后端:
- contracts/simulation.py:Simulation/SimulationAssumption/ScenarioDimensionState。
  契约层直接禁止 production 域携带演示估计(E-21);ready 必须有结果、
  failed 必须带错误码且不得显示结果、计算中不得预告乐观增幅。
- services/prescreen_sandbox.py:演示情景表(DEMO-TABLE-v1)与推演入口。
  组合查表不是单项加和(assert_not_additive 把这条写成可执行断言);
  只改被假设的维度,其余维度原样保留;失败保留基线并标未完成。
- services/prescreen_store.py:运行视图只读存储,复用 assistant_objects 新 kind
  prescreen_run,不新建表;只提供保存与读取,要改结果必须产生新修订。
- api/prescreen.py:新增 3 个只读端点(运行列表、运行视图、可用假设动作)
  与 1 个情景端点;请求体里没有任何概率字段。越域返回 E-21、冲突假设 E-22、
  过期修订 409。
- contracts/prescreen.py:补 E-20/21/22 三个错误码与其 HTTP 映射。

前端(挂进既有工作台「材料与问题」页,不新建路由、不新增样式表):
- src/lib/prescreen-run.ts:客户端契约、呈现判定(同刻度 3–8 维才画雷达)、
  演示值域判定、来源与数据域徽标(文字+图标+样式,去色仍可区分)。
- src/components/xingbang/PrescreenPanel.tsx:主审意见、演示值(紧邻固定说明)、
  维度雷达与等价文字列表、维度依据抽屉、假设勾选即时推演。
  旧请求晚到或结果与当前选择不一致即丢弃;请求失败保留基线并标未完成。
- scripts/test_prescreen_ui.cjs:界面源码守卫,已注册进 scripts/verify.py。

未做:运行装配结果的写入方仍是执行链路(T10 范围),当前由测试写入;
沙盘尚未接入真实执行,界面数值全部为服务端演示表值。

验证:backend 全量 unittest 469 项 OK;前端 npm run build 通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-30 09:15:57 +08:00
co-authored by Claude Code
parent 501a4d9c9b
commit 22f8a37799
12 changed files with 1297 additions and 2 deletions
+85 -1
View File
@@ -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)
+7
View File
@@ -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 仅表示结果可读取"),永远不会作为
+110
View File
@@ -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]
+211
View File
@@ -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("情景基线副本的真实估计必须为空")
+46
View File
@@ -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]
+194
View File
@@ -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()
+217
View File
@@ -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()
+78
View File
@@ -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, /<PrescreenPanel /, "工作台应渲染预审面板");
for (const banned of ["prescreen", "sandbox", "simulation"]) {
assert.ok(!fs.existsSync(path.join(root, "src/app", banned)), `不得为 T09 新建路由 src/app/${banned}`);
}
// 2. 复用既有样式,不新增样式表
assert.ok(!/^import .*\.css";/m.test(panel), "面板应复用 workspace.css 既有 class,不新增样式表");
// 3. 数值只能来自服务端:前端不得复制演示情景表(AC-30/31)
for (const [name, source] of [["panel", panel], ["lib", lib]]) {
assert.ok(!/0\.(42|58|51|64)\b/.test(source), `${name} 不得硬编码演示概率值`);
assert.ok(!/"(00|10|01|11)"\s*:/.test(source), `${name} 不得在客户端复制演示情景表`);
}
assert.match(panel, /simulation\.probability_view/, "情景数值必须取自服务端响应");
// 4. 演示值只在演示域显示,且紧邻"人工设定"说明(AC-12/30、DESIGN §38)
assert.match(panel, /demoValue\(run\?\.data_domain/, "演示值必须按数据域判定后才显示");
assert.match(panel, /未完成真实历史校准/, "未校准时必须说明为何不显示概率");
assert.match(panel, /正式审批由人工决定/, "演示值旁必须有用途边界说明");
assert.match(lib, /domain !== "demo"/, "lib 必须在非演示域返回 null");
// 5. 雷达只对同刻度 3–8 维绘制,其余给完整列表;空维度不补占位轴(AC-44)
assert.match(lib, /RADAR_MIN = 3/);
assert.match(lib, /RADAR_MAX = 8/);
assert.match(lib, /kind: "radar"/);
assert.match(lib, /EMPTY_DIMENSIONS = "当前方案尚未配置核查维度"/);
assert.match(panel, /shown\.kind === "radar" && <RadarChart/, "只有判定为雷达时才画雷达");
assert.match(panel, /shown\.kind === "empty" && <div className="ra-empty">/, "空维度用空状态,不补六轴");
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, /<input type="checkbox"/, "假设动作必须是原生可聚焦控件");
assert.match(panel, /aria-live="polite"/, "变化的数值区必须有 aria-live");
assert.match(panel, /aria-label=\{`查看 \$\{item\.id\} 的依据`\}/, "维度依据入口必须有可读名称");
assert.ok(/<svg[^>]*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");
+1
View File
@@ -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),
+2 -1
View File
@@ -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" && <><section className="xb-cards"><article><b>当前数据版本</b><strong>v{project.data_revision}</strong><small>{project.documents.length} 份材料</small></article><button className="xb-metric-card" onClick={goToFindings}><b>未处理问题</b><strong>{openFindings.length}</strong><small>{openFindings[0] ? `${openFindings[0].title} · 点击查看怎么解决` : "当前没有待处理问题"}</small><ArrowRight size={17}/></button><article><b>最近运行</b><strong>{statuses[project.runs[0]?.status] || "未检查"}</strong><small>{project.runs[0]?.created_at ? new Date(project.runs[0].created_at).toLocaleString() : "点击检查项目"}</small></article></section>
<section className="xb-panel xb-wide"><h2>AI检查结果</h2><p>先看业务结论;数据来源和解决建议可直接展开,技术信息放在最后。</p>{project.runs.length ? <><select aria-label="选择检查历史" value={chosenRun?.id || ""} onChange={e => setRunId(e.target.value)}>{project.runs.map((r: any) => <option key={r.id} value={r.id}>{new Date(r.created_at).toLocaleString()} · {statuses[r.status]} · {r.id.slice(-8)}</option>)}</select><p>本次要求:{chosenRun.requirement || "无额外要求"}</p>{["queued", "running"].includes(chosenRun.status) && <button className="ra-button" onClick={() => act(`${base}/runs/${chosenRun.id}/cancel`, {}, "已取消;迟到结果不会发布。")}>取消本次运行</button>}{chosenRun.error && <p role="alert">{chosenRun.error}</p>}{chosenRun.steps.map((step:any)=><BusinessStep key={step.step_id} step={step} evidence={evidence} statuses={statuses}/>)}{chosenRun.data_errors?.map((e:any,i:number)=><p key={i} className="xb-warning">数据读取未完成:{e.message}</p>)}<details><summary>技术追溯信息</summary><pre>{JSON.stringify({snapshot:chosenRun.snapshot_id,versions:chosenRun.component_versions,changes:chosenRun.change_summary},null,2)}</pre></details></> : <p>尚无运行记录。</p>}</section>
<details className="xb-panel xb-wide"><summary>业务历史与操作留痕</summary><AssistantHistory key={project.id} projectId={project.id} records={project.legacy_records} events={project.events} request={request}/>{project.migration_note && <p>{project.migration_note};已保留 {project.legacy_records.length} 条历史记录。</p>}</details></>}
{tab === "findings" && <><ImportBatchPanel project={project} request={request} base={base} headers={headers} busy={busy} setBusy={setBusy} setError={setError} setNotice={setNotice} onChanged={() => open(project.id)}/><section className="xb-panel xb-wide"><h2>材料档案</h2><p>上传后先查看解析结果,确认候选值才更新项目。旧版本与来源保留;扫描件无法识别时会提示补充。</p><label className="ra-field"><span>上传 TXT / CSV / DOCX / XLSX / PDF / 图片(最多 10 MB)</span><input aria-label="上传项目材料" type="file" accept=".txt,.csv,.docx,.xlsx,.pdf,.png,.jpg,.jpeg" disabled={busy} onChange={e => {if (e.target.files?.[0]) upload(e.target.files[0]); e.target.value = "";}}/></label>{project.documents.map((d: any) => <div className="xb-finding" key={d.id}><div><b>{d.name}</b><p>版本 {d.version} · {d.status} · {d.size} 字节</p></div><button className="ra-button" onClick={async () => {try {setDocument(await request(`${base}/documents/${d.id}`));} catch (e: any) {setError(e.message);}}}>查看解析</button><button className="ra-button" onClick={() => download(`${base}/documents/${d.id}/download`, d.name)}>下载原件</button></div>)}
{tab === "findings" && <><ImportBatchPanel project={project} request={request} base={base} headers={headers} busy={busy} setBusy={setBusy} setError={setError} setNotice={setNotice} onChanged={() => open(project.id)}/><PrescreenPanel project={project} request={request} busy={busy} setBusy={setBusy} setError={setError} setNotice={setNotice}/><section className="xb-panel xb-wide"><h2>材料档案</h2><p>上传后先查看解析结果,确认候选值才更新项目。旧版本与来源保留;扫描件无法识别时会提示补充。</p><label className="ra-field"><span>上传 TXT / CSV / DOCX / XLSX / PDF / 图片(最多 10 MB)</span><input aria-label="上传项目材料" type="file" accept=".txt,.csv,.docx,.xlsx,.pdf,.png,.jpg,.jpeg" disabled={busy} onChange={e => {if (e.target.files?.[0]) upload(e.target.files[0]); e.target.value = "";}}/></label>{project.documents.map((d: any) => <div className="xb-finding" key={d.id}><div><b>{d.name}</b><p>版本 {d.version} · {d.status} · {d.size} 字节</p></div><button className="ra-button" onClick={async () => {try {setDocument(await request(`${base}/documents/${d.id}`));} catch (e: any) {setError(e.message);}}}>查看解析</button><button className="ra-button" onClick={() => download(`${base}/documents/${d.id}/download`, d.name)}>下载原件</button></div>)}
{document && <div className="xb-document"><h3>{document.name}</h3>{document.warnings.map((w: string, i: number) => <p className="xb-warning" key={i}>{w}</p>)}<label className="ra-field"><span>来源确认或补件说明</span><input value={reason} onChange={e => setReason(e.target.value)} placeholder="说明已核对的期间、口径和依据"/></label>{document.candidates.map((c: any) => <div className="xb-finding" key={c.id}><div><b>{c.field} = {String(c.value ?? "缺失")}</b><p>{c.locator} · {c.period || "期间未明"} · {c.unit || "单位未明"} · {c.scope || "口径未明"}</p></div><button className="ra-button" disabled={busy || !reason.trim()} onClick={() => act(`${base}/supplements`, {candidate_ids: [c.id], revision: project.revision, reason}, "候选值已确认;请重新检查项目。")}>确认该来源</button></div>)}<details><summary>解析原文与定位</summary>{document.chunks.map((c: any, i: number) => <p key={i}><b>{c.locator}</b> {c.text}{c.formula && <small> 公式:{c.formula};缓存:{c.cached_value || "无"}</small>}</p>)}</details></div>}</section>
<section className="xb-panel xb-wide" id="project-findings"><h2>{isReviewer?"全链路评审":"问题与解决"}</h2><p>{isReviewer?"逐项查看数据来源、AI判断和客户经理说明,再选择评审结论。":"每个问题都说明影响和下一步;处理完成后重新AI预审。"}</p>{isReviewer&&<label className="ra-field xb-review-opinion"><span>常用评审意见(选择后应用到下方操作)</span><select value={reviewOpinion} onChange={e=>setReviewOpinion(e.target.value)}><option>请补充最新材料并说明数据口径</option><option>证据充分,同意AI判断</option><option>AI判断不适用于本项目,以人工核实为准</option><option>请补充现场核实记录</option></select></label>}{!project.findings.length&&<p>尚无问题记录,请先执行检查。</p>}{[...project.findings].sort((a:any,b:any)=>Number(["已解决","不适用"].includes(a.status))-Number(["已解决","不适用"].includes(b.status))).map((finding:any)=><FindingCard key={finding.id} finding={finding} evidence={evidence} isReviewer={isReviewer} busy={busy||!hasReviewClaim} reason={reason} onManager={(status:string)=>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&&<><div className="xb-form-grid"><label className="ra-field"><span>人工补充字段</span><select value={field} onChange={e => setField(e.target.value)}>{fields.map(f => <option key={f[0]} value={f[0]}>{f[1]}</option>)}</select></label><label className="ra-field"><span>确认值(留空表示资料仍缺失)</span><input value={value} onChange={e => setValue(e.target.value)} placeholder={field === "collection_dependency" ? "true 或 false" : "输入实际核对值"}/></label><label className="ra-field"><span>处理说明</span><input value={reason} onChange={e => setReason(e.target.value)} placeholder="说明材料、口径或核实情况"/></label></div><button className="ra-button primary" disabled={busy || !reason.trim()} onClick={() => act(`${base}/supplements`, {changes: {[field]: value === "" ? null : field === "collection_dependency" ? value === "true" ? true : value === "false" ? false : value : value}, finding_ids: [], revision: project.revision, reason}, "补件已保存,原来源已保留;请重新检查确认问题变化。")}>保存补件</button></>}{isReviewer&&<div className="xb-review-final"><div><CheckCircle2 size={21}/><span><b>完成全链路核查</b><small>AI只提供建议,批准或退回由评审员确认。</small></span></div><button className="ra-button" disabled={busy||!hasReviewClaim} onClick={()=>completeReview("退回")}>退回补充材料</button><button className="ra-button primary" disabled={busy||!hasReviewClaim||!currentRun} onClick={()=>completeReview("批准")}>批准并生成评审报告</button></div>}</section></>}
{tab === "report" && <section className="xb-panel xb-wide"><h2>{isReviewer ? "独立评审报告" : "AI预审报告"}</h2><p>模板、运行快照、Skills 与规则版本随报告冻结;每个历史版本都可以重新打开。</p><div className="xb-actions"><select aria-label="报告模板" value={template} onChange={e => setTemplate(e.target.value)}>{isReviewer ? <><option value="template.independent-review">独立评审报告</option><option value="template.committee-brief">上会摘要</option></> : <option value="template.ai-pre-review">AI预审意见</option>}</select><select aria-label="报告来源运行" value={reportSourceRun?.id || ""} onChange={e => setRunId(e.target.value)}>{finished.map((r: any) => <option key={r.id} value={r.id}>{new Date(r.created_at).toLocaleString()} · {statuses[r.status]}</option>)}</select><button className="ra-button primary" disabled={busy || !currentRun} onClick={async () => {const r = await act(`${base}/reports`, {run_id: reportSourceRun.id, template_id: template}); if (r) setReportId(r.id);}}><FileText size={15}/>生成报告</button></div>{chosenReport && <div className="xb-report"><select aria-label="选择报告版本" value={chosenReport.id} onChange={e => {setReportId(e.target.value); setSectionEdits({});}}>{project.reports.map((r: any) => <option key={r.id} value={r.id}>{r.template.name} · {statuses[r.status]} · {r.id.slice(-8)}</option>)}</select><p>{chosenReport.disclaimer} · {statuses[chosenReport.status]}</p><small>来源快照:{chosenReport.snapshot_id}</small><div className="xb-actions"><button className="ra-button" onClick={() => download(`${base}/reports/${chosenReport.id}/export`, `${project.id}-${chosenReport.template.name}.docx`)}>导出 Word</button><button className="ra-button" disabled={busy || !currentRun || ["finalized","submitted"].includes(chosenReport.status)} onClick={() => reportAction("regenerate", {run_id: currentRun.id})}>按最新运行更新候选</button>{isManager && <button className="ra-button primary" disabled={busy || chosenReport.status === "submitted"} onClick={() => reportAction("submit")}>提交正式评审</button>}{canReview && <button className="ra-button primary" disabled={busy || chosenReport.status === "finalized"} onClick={() => reportAction("finalize")}>确认评审定稿</button>}</div>{chosenReport.sections.map((s: any) => <article key={s.id}><h3>{s.title} {s.stale ? "· 来源已过期" : ""} {s.locked ? "· 已锁定" : ""} {s.confirmed ? "· 已确认" : ""}</h3><textarea aria-label={`编辑${s.title}`} className="xb-section-text" disabled={s.locked || ["overview", "findings"].includes(s.id) || ["finalized","submitted"].includes(chosenReport.status)} value={sectionEdits[s.id] ?? s.content} onChange={e => setSectionEdits({...sectionEdits, [s.id]: e.target.value})}/>{!["finalized","submitted"].includes(chosenReport.status) && <div className="xb-actions"><button className="ra-button" disabled={busy || s.locked || ["overview", "findings"].includes(s.id)} onClick={() => reportAction("section", {section_id: s.id, content: sectionEdits[s.id] ?? s.content, confirmed: false})}>保存修改</button><button className="ra-button" disabled={busy || s.stale} onClick={() => reportAction("section", {section_id: s.id, content: sectionEdits[s.id] ?? s.content, confirmed: true, locked: true})}>确认并锁定</button>{s.locked && <button className="ra-button" disabled={busy} onClick={() => reportAction("section", {section_id: s.id, locked: false, confirmed: false})}>解除锁定</button>}</div>}{s.candidate && <details open><summary>更新候选(未覆盖当前正文)</summary><pre>{s.candidate.content}</pre><button className="ra-button" disabled={busy} onClick={() => reportAction("section", {section_id: s.id, accept_candidate: true})}>采纳候选内容</button></details>}</article>)}</div>}</section>}
+247
View File
@@ -0,0 +1,247 @@
"use client";
import {useEffect, useRef, useState} from "react";
import {AlertTriangle} from "lucide-react";
import {PRESCREEN} from "@/lib/prescreen";
import {
EMPTY_DIMENSIONS, RECOMMENDATION_LABELS, RUN_STATUS_LABELS,
RunView, STAGE_LEGEND, Simulation, demoValue, domainBadge, presentationFor, stageName,
stateLabel,
} from "@/lib/prescreen-run";
/** 预审与推进沙盘面板(T09)。
*
* 挂在既有 /xingbang-workbench 的「材料与问题」页内,**不新建路由**
* (docs/v2/04-ui-spec.md §1)。复用 workspace.css 既有 class,不新增样式表。
*
* 三条界面铁律:
* 1. 数值只来自服务端:情景表在服务端,本组件不复制 42/58/51/64(AC-30/31)。
* 2. 真实概率为空时不显示任何百分比:演示值必须紧邻"人工设定"说明,且只在演示域(AC-12)。
* 3. 只接受与当前选择一致的响应:旧请求晚到即丢弃;请求失败保留基线并标未完成(AC-31)。
*/
export default function PrescreenPanel({project, request, busy, setBusy, setError, setNotice}: any) {
const [runs, setRuns] = useState<RunView[]>([]);
const [runId, setRunId] = useState("");
const [actions, setActions] = useState<any[]>([]);
const [selected, setSelected] = useState<string[]>([]);
const [simulation, setSimulation] = useState<Simulation | null>(null);
const [scenarioState, setScenarioState] = useState<"baseline" | "computing" | "ready" | "failed">("baseline");
const [openDimension, setOpenDimension] = useState<string>("");
const [loaded, setLoaded] = useState(false);
const seq = useRef(0);
const drawer = useRef<HTMLButtonElement | null>(null);
const opener = useRef<HTMLElement | null>(null);
const projectPath = `/projects/${encodeURIComponent(project.id)}`;
const run = runs.find((item) => item.id === runId) || null;
async function load() {
try {
const listed = await request(`${PRESCREEN}${projectPath}/runs`);
const items: RunView[] = listed.items || [];
setRuns(items);
const first = items[0] || null;
setRunId(first ? first.id : "");
setSimulation(null);
setSelected([]);
setScenarioState("baseline");
if (first) {
const catalog = await request(`${PRESCREEN}${projectPath}/runs/${encodeURIComponent(first.id)}/assumptions`);
setActions(catalog.items || []);
} else {
setActions([]);
}
} catch (e: any) {
setError(e.message);
} finally {
setLoaded(true);
}
}
useEffect(() => {load();}, [project.id]);
function selectRun(id: string) {
setRunId(id);
setSelected([]);
setSimulation(null);
setScenarioState("baseline");
setOpenDimension("");
request(`${PRESCREEN}${projectPath}/runs/${encodeURIComponent(id)}/assumptions`)
.then((catalog: any) => setActions(catalog.items || []))
.catch((e: any) => setError(e.message));
}
async function applyScenario(keys: string[]) {
if (!run) return;
const ticket = ++seq.current;
setBusy(true); setError(""); setNotice("");
setScenarioState(keys.length ? "computing" : "baseline");
try {
const result: Simulation = await request(
`${PRESCREEN}${projectPath}/runs/${encodeURIComponent(run.id)}/simulations`,
{assumptions: keys, expected_revision: run.revision});
if (ticket !== seq.current) return; // 旧请求晚到:丢弃,不覆盖新结果
const returned = (result.assumptions || []).map((item) => item.key).sort().join(",");
if (returned !== [...keys].sort().join(",")) return; // 结果与当前选择不一致:丢弃
setSimulation(result);
setScenarioState(result.status === "failed" ? "failed" : "ready");
if (result.status === "failed") setError("该情景未完成;已保留基线与原有意见。");
} catch (e: any) {
if (ticket !== seq.current) return;
setSimulation(null);
setScenarioState("failed");
setError(`${e.message}(该情景未完成,基线未改变)`);
} finally {
if (ticket === seq.current) setBusy(false);
}
}
function toggle(key: string) {
const next = selected.includes(key) ? selected.filter((item) => item !== key) : [...selected, key];
setSelected(next);
applyScenario(next);
}
function openBasis(id: string, element: HTMLElement | null) {
opener.current = element;
setOpenDimension(id);
setTimeout(() => drawer.current?.focus(), 0);
}
function closeBasis() {
setOpenDimension("");
opener.current?.focus();
}
const baseline = demoValue(run?.data_domain || "demo", run?.probability_view);
const scenario = simulation && simulation.status === "ready"
? demoValue(run?.data_domain || "demo", simulation.probability_view) : null;
const dimensions = run?.dimension_results || [];
// 刻度信息来自呈现层:运行视图只带阶段序数,因此默认同一刻度;方案带 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 scenarioStages = new Map((simulation?.dimensions || []).map((item) => [item.id, item]));
const domain = domainBadge(run?.data_domain || "demo");
return <section className="xb-panel xb-wide">
<h2>预审与推进沙盘</h2>
<p>预审意见来自本次冻结的运行;沙盘只回答"如果这些证据补上,判断会怎么变"。情景是独立结果,不会改写项目事实、正式运行或审批状态。</p>
{!loaded && <p>正在读取预审运行…</p>}
{loaded && !runs.length && <div className="ra-empty"><h2>该项目尚无预审运行</h2>
<p>预审运行由评审批次产生;没有运行时不显示示例意见,也不显示任何概率。</p></div>}
{runs.length > 1 && <label className="ra-field"><span>查看哪一次运行</span>
<select aria-label="选择预审运行" value={runId} onChange={(e) => selectRun(e.target.value)}>
{runs.map((item) => <option key={item.id} value={item.id}>{new Date((item as any).created_at).toLocaleString()} · {RUN_STATUS_LABELS[item.status] || item.status}</option>)}
</select></label>}
{run && <>
<div className="xb-finding">
<div>
<b>主审意见:{RECOMMENDATION_LABELS[run.recommendation || ""] || "尚未形成意见"}</b>
<p>方案 {run.plan_ref.id} · {run.plan_ref.version} · 运行 {RUN_STATUS_LABELS[run.status] || run.status} · 输入快照 {run.snapshot_id}</p>
<p>{domain.text}({run.data_domain === "demo" ? "所有数值为人工演示,未连接真实数据与模型" : "真实数据域不显示任何未校准概率"})</p>
{run.missing_inputs.length > 0 && <p className="xb-warning"><AlertTriangle size={13}/> 待补充 {run.missing_inputs.length} 项:{run.missing_inputs.join("、")}</p>}
{run.degradations.map((item, index) => <p className="xb-warning" key={index}>未完成项:{item.message}({item.code})</p>)}
</div>
</div>
<div className="xb-finding" aria-live="polite">
<div>
<b>{baseline ? `审批通过概率 · 演示值 ${baseline.percent}%` : "审批通过概率:暂不显示"}</b>
{baseline
? <p>{baseline.label} · 依据表 {baseline.tableVersion} · 条目 {baseline.scenarioKey}。正式审批由人工决定,演示值不得用于任何正式用途。</p>
: <p>未完成真实历史校准,因此不显示审批通过概率。当前只给建议、缺证与未完成项;数字缺失本身就是结论的一部分。</p>}
{scenario && scenarioState === "ready" && <p>所选情景 · 演示值 {scenario.percent}%({scenario.percent - (baseline?.percent || 0) >= 0 ? "+" : ""}{scenario.percent - (baseline?.percent || 0)} 百分点)· 条目 {scenario.scenarioKey}</p>}
{scenarioState === "computing" && <p>正在按当前选择重算;此期间仍显示基线,不会先给一个乐观增幅。</p>}
{scenarioState === "failed" && <p className="xb-warning"><AlertTriangle size={13}/> 该情景未完成。基线数值与原有意见保持不变。</p>}
<p>证据阶段 ≠ 审批概率:雷达面积不换算成百分比,也不参与任何加总。{STAGE_LEGEND}</p>
</div>
</div>
<h3>核查维度({dimensions.length})</h3>
{shown.kind === "empty" && <div className="ra-empty"><h2>{EMPTY_DIMENSIONS}</h2>
<p>方案没有维度时不补占位轴,也不给出任何结论。</p></div>}
{shown.kind !== "empty" && <>
<p>{shown.reason}</p>
{shown.kind === "radar" && <RadarChart dimensions={dimensions} scenarioStages={scenarioStages} />}
<div className="xb-finding">
<div>
{dimensions.map((item) => {
const overlay = scenarioStages.get(item.id);
const stage = overlay ? overlay.scenario_stage : item.stage;
return <p key={item.id}>
<b>{item.id}</b> · {stateLabel(item.state)} · 阶段 {stageName(stage)}
{overlay?.assumed ? "(本情景假设该维度交叉核验成立;原项目仍待核实)" : ""}
{" "}
<button className="ra-button" aria-label={`查看 ${item.id} 的依据`}
onClick={(e) => openBasis(item.id, e.currentTarget)}>查看依据</button>
</p>;
})}
<p>文字列表与图等价:每个维度都可在此查看阶段与依据,颜色不是唯一区分方式。</p>
</div>
</div>
</>}
<h3>推进沙盘:假设补充核验的材料</h3>
<p>勾选或取消即时重算,不需要再点运行。组合结果由完整选择决定,不是各项增量的简单相加;补件也可能发现新风险,实际判断未必变得乐观。</p>
{actions.length === 0 && <div className="ra-empty"><h2>当前方案没有可推演的假设动作</h2>
<p>假设动作必须指向本次方案真实存在的维度,因此这里不会给出必然失败的选项。</p></div>}
{actions.map((item) => <label className="ra-field" key={item.key}>
<span><input type="checkbox" checked={selected.includes(item.key)} disabled={busy}
onChange={() => toggle(item.key)}/> {item.label}</span>
<span>{item.premise}</span>
</label>)}
{simulation && <div className="xb-finding">
<div>
<b>{simulation.status === "ready" ? "所选情景" : "情景未完成"}</b>
<p>{simulation.reason}</p>
<p>仍需处理:{simulation.remaining_inputs.length ? simulation.remaining_inputs.join("、") : "无(仍需人工复核)"}</p>
<p>真实预审保持不变:{run.missing_inputs.length} 项待核实 · {RECOMMENDATION_LABELS[run.recommendation || ""] || "尚未形成意见"}</p>
</div>
<button className="ra-button" onClick={() => applyScenario([])} disabled={busy}>返回真实预审(恢复基线)</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>
<p>依据引用:{basis.basis_ids.length ? basis.basis_ids.join("、") : "本次没有可用依据,按缺证处理"}</p>
<p>读取核验记录不等于该工具亲自完成了核验;维度阶段不换算为概率。</p>
<button ref={drawer} className="ra-button" onClick={closeBasis}>关闭依据</button>
</div>}
</>}
</section>;
}
/** 六到八轴证据雷达。基线虚线、情景实线,旁边始终有等价文字列表。 */
function RadarChart({dimensions, scenarioStages}: {dimensions: any[]; scenarioStages: Map<string, any>}) {
const radius = 90;
const count = dimensions.length;
const point = (index: number, stage: number | null) => {
const angle = (index * 2 * Math.PI) / count;
const ratio = (stage === null || stage === undefined ? 0 : stage) / 4;
return [ratio * radius * Math.sin(angle), -ratio * radius * Math.cos(angle)];
};
const polygon = (stages: (number | null)[]) =>
stages.map((stage, index) => point(index, stage).map((value) => value.toFixed(2)).join(",")).join(" ");
const baselineStages = dimensions.map((item) => item.stage);
const scenarioStagesList = dimensions.map((item) => {
const overlay = scenarioStages.get(item.id);
return overlay ? overlay.scenario_stage : item.stage;
});
const hasScenario = scenarioStages.size > 0;
return <div>
<svg role="group" aria-label="证据核验阶段雷达,详细内容见下方维度按钮" viewBox="-140 -120 280 250">
{[1, 2, 3, 4].map((step) => <polygon key={step} className="radar-grid" fill="none" stroke="currentColor" opacity={0.2}
points={polygon(Array(count).fill(step))} />)}
{dimensions.map((item, index) => {
const [x, y] = point(index, 4);
return <line key={item.id} stroke="currentColor" opacity={0.2} x1={0} y1={0} x2={x} y2={y} />;
})}
<polygon data-series="baseline" fill="none" stroke="currentColor" strokeDasharray="4 4"
points={polygon(baselineStages)} />
{hasScenario && <polygon data-series="scenario" fill="currentColor" fillOpacity={0.15} stroke="currentColor"
points={polygon(scenarioStagesList)} />}
</svg>
<p>虚线为基线,实线为所选假设;两者都只是阶段,不是分数。</p>
</div>;
}
+99
View File
@@ -0,0 +1,99 @@
/** T09 预审运行与沙盘的前端契约与呈现规则。
*
* 依据:docs/v2/04-ui-spec.md §5/§9、DESIGN.md §38、13-probability-prototype.md。
* 契约字段手工对齐 backend/contracts/{prescreen,simulation}.py;**不得**据此宣称
* 类型已自动生成(同 prescreen.ts 的既有说明)。
*
* 三条前端硬规则:
* 1. 数值只来自服务端:演示情景表在服务端,本文件不复制 42/58/51/64(AC-30/31)。
* 2. 真实概率恒为空:未校准时只显示建议、缺证与降级,不显示任何"通过率"(AC-12)。
* 3. 颜色不单独传意:来源、数据域、状态都同时给文字与图标(AC-42、DESIGN §32)。
*/
export const DEMO_LABEL = "人工设定,仅演示交互;未经历史数据校准";
export const STAGE_NAMES = ["未提供", "仅有说明", "资料待核", "交叉核验", "人工复核"];
export const STAGE_LEGEND = "0 未提供 · 1 仅有说明 · 2 资料待核 · 3 交叉核验 · 4 人工复核";
export const RADAR_MIN = 3;
export const RADAR_MAX = 8;
export const EMPTY_DIMENSIONS = "当前方案尚未配置核查维度";
export type DemoEstimate = {value: number; source: "synthetic_demo"; scenario_table_version: string; scenario_key: string; label: string};
export type ProbabilityView = {target_event: string; snapshot_id: string; as_of: string; real_estimate: null; reason: "not_calibrated"; demo_estimate: DemoEstimate | null};
export type DimensionResult = {id: string; stage: number | null; state: "unknown" | "checked" | "blocked"; basis_ids: string[]};
export type RunView = {
id: string; project_id: string; data_domain: "demo" | "production"; revision: number;
snapshot_id: string; plan_ref: {id: string; version: string}; status: string;
recommendation: "advance" | "supplement" | "stop" | "insufficient_information" | null;
findings: any[]; missing_inputs: string[]; applied_experiences: any[];
probability: null; probability_view?: ProbabilityView | null;
degradations: {code: string; message: string}[]; dimension_results: DimensionResult[];
versions: Record<string, any>;
};
export type ScenarioDimensionState = {id: string; baseline_stage: number | null; scenario_stage: number | null; assumed: boolean};
export type Simulation = {
id: string; run_id: string; revision: number; selection_hash: string;
status: "queued" | "computing" | "ready" | "failed";
assumptions: {key: string; label: string; target_dimension_id: string; assumed_stage: number; premise: string}[];
baseline_probability_view: ProbabilityView; probability_view: ProbabilityView | null;
dimensions: ScenarioDimensionState[]; remaining_inputs: string[]; reason: string;
error_code: string | null;
};
export const RECOMMENDATION_LABELS: Record<string, string> = {
advance: "可推进",
supplement: "补充材料后再判断",
stop: "不建议推进",
insufficient_information: "信息不足,无法给出意见",
};
export const RUN_STATUS_LABELS: Record<string, string> = {
queued: "排队中", running: "执行中", awaiting_input: "等待补充材料",
completed: "已完成", partial: "部分完成", failed: "未完成", cancelled: "已取消",
};
/** 维度呈现方式。同刻度 3–8 维才画雷达;其余给完整列表,绝不补占位轴(AC-44)。 */
export function presentationFor(dimensions: {scale_id?: string}[]): {kind: "empty" | "radar" | "bars" | "list" | "faceted"; reason: string} {
const total = dimensions.length;
if (!total) return {kind: "empty", reason: EMPTY_DIMENSIONS};
const scales = new Set(dimensions.map((item) => item.scale_id || ""));
if (scales.size > 1) return {kind: "faceted", reason: "维度刻度不一致,按分面展示;不强行归一化,避免比较不可比的量"};
if (total < RADAR_MIN) return {kind: "bars", reason: `仅 ${total} 个维度,用状态列表比雷达更清楚`};
if (total > RADAR_MAX) return {kind: "list", reason: `${total} 个维度超出雷达可读范围,改用分组列表且全部维度仍可查看`};
return {kind: "radar", reason: "同一刻度且 3–8 维,可用雷达并附等价文字列表"};
}
export function stageName(stage: number | null | undefined): string {
if (stage === null || stage === undefined) return "未核查";
return STAGE_NAMES[stage] || `阶段 ${stage}`;
}
export function stateLabel(state: string): string {
return state === "checked" ? "已核查" : state === "blocked" ? "存在阻断项" : "待核实";
}
/** 演示值:只有 demo 域 + 带固定说明才返回;否则返回 null,界面不得显示数字。 */
export function demoValue(domain: string, view?: ProbabilityView | null) {
const demo = view?.demo_estimate;
if (domain !== "demo" || !demo) return null;
return {percent: Math.round(demo.value * 100), label: demo.label || DEMO_LABEL,
tableVersion: demo.scenario_table_version, scenarioKey: demo.scenario_key};
}
/** 来源徽标(AC-42):文字 + 图标 + 样式三者同时给,去色仍可区分。 */
export function originBadge(origin: string) {
const table: Record<string, {text: string; icon: string; tone: string}> = {
history_derived: {text: "历史提炼", icon: "history", tone: "origin-history"},
manual: {text: "人工创建", icon: "pencil", tone: "origin-manual"},
policy_import: {text: "制度导入", icon: "file-text", tone: "origin-policy"},
};
return table[origin] || {text: `来源未知(${origin || "未提供"})`, icon: "help-circle", tone: "origin-unknown"};
}
export function domainBadge(domain: string) {
return domain === "demo"
? {text: "演示数据域", icon: "flask-conical", tone: "domain-demo"}
: {text: "正式数据域", icon: "shield", tone: "domain-production"};
}
export function scenarioKeyOf(keys: string[], order: string[]): string {
const chosen = new Set(keys);
return order.map((item) => (chosen.has(item) ? "1" : "0")).join("");
}