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:
@@ -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)
|
||||
|
||||
@@ -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 仅表示结果可读取"),永远不会作为
|
||||
|
||||
@@ -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]
|
||||
@@ -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("情景基线副本的真实估计必须为空")
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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");
|
||||
@@ -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),
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
Reference in New Issue
Block a user