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:
@@ -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