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()