覆盖 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>
212 lines
10 KiB
Python
212 lines
10 KiB
Python
"""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("情景基线副本的真实估计必须为空")
|