Files
qiaoqichaoandClaude Code 22f8a37799 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>
2026-09-30 09:15:57 +08:00

212 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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("情景基线副本的真实估计必须为空")