覆盖 AC-46/47/48/49(BR-45/46/47、E-27)。审计确认的 P1 缺口"正常发起评审不产生 新版预审记录"由此关闭:视图由执行链出口产生,不再依赖测试或脚本注入。 后端: - services/prescreen.py:新增 screening_from_run(),把一次真实运行的执行结果装配成 预审视图;维度与阶段按**本次实际执行的组件与核查项**投影 (dimensions_and_evidence:冻结方案声明过、且本次真的执行过的技能/规则才算维度; 经验核查等方案外伪步骤不算;阶段只表达证据成熟度,不看结论好坏)。 - services/assistant_worker.py:运行出口(含失败路径)统一调用 save_prescreen_view(), 运行结束即落库视图;装配失败单独记事件,不让运行一起丢。 - contracts/prescreen.py:视图带 evidence(界面"查看依据"的来源实体); 新增运行级错误码 RUN_STEP_FAILED(E-27)。 - 维度状态用本次执行的步骤与核查项算(含跑通但无问题的项), 只看待办 finding 会把通过的维度误判成未核查。 前端:面板新增"核查结果"区(每条可打开依据抽屉,显示来源、定位、原文片段与组件版本) 与降级记录展示;没有证据时明确写"按缺证处理",不编造。 测试:backend/test_prescreen_from_run.py 12 项,用真实 enqueue + DurableWorker.process_next (临时库)断言视图由执行链产生、失败与模型不可用如实留痕、重跑新增记录。 含反例:执行前不得存在任何记录。 实际验收(浏览器渲染 + 真实运行):状态 partial、10 维(5 已核查/2 待核实/3 阻断)、 4 条核查结果、14 条证据、1 条 RUN_STEP_FAILED 未完成项;记录见 docs/v2/17-batch-delivery-records.md(含剩余缺口:界面点击未自动化、材料确认未在浏览器走完、 演示角色需 XINGBANG_DEMO_ROLE_LOGIN=1、模型未配置)。 Co-Authored-By: Claude Code <noreply@anthropic.com>
110 lines
6.3 KiB
TypeScript
110 lines
6.3 KiB
TypeScript
/** 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[];
|
||
// 本次核查实际引用的证据:界面"查看依据"的实体来源(纠偏第一批)
|
||
evidence: {id?: string; evidence_id?: string; source?: string; locator?: string;
|
||
value?: string; version?: string}[];
|
||
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)。
|
||
*
|
||
* 词汇与后端 services/review_plan.presentation() 保持一致
|
||
* (empty/list/grouped/radar/faceted),避免前后端各有一套判定导致界面
|
||
* 画出后端判定为"不该画雷达"的图。
|
||
*/
|
||
export type DimensionLayout = "empty" | "list" | "grouped" | "radar" | "faceted";
|
||
|
||
export function presentationFor(dimensions: {scale_id?: string}[]): {layout: DimensionLayout; reason: string} {
|
||
const total = dimensions.length;
|
||
if (!total) return {layout: "empty", reason: EMPTY_DIMENSIONS};
|
||
const scales = new Set(dimensions.map((item) => item.scale_id || ""));
|
||
if (scales.size > 1) return {layout: "faceted", reason: "维度刻度不一致,按分面展示;不强行归一化,避免比较不可比的量"};
|
||
if (total < RADAR_MIN) return {layout: "list", reason: `仅 ${total} 个维度,用状态列表比雷达更清楚`};
|
||
if (total > RADAR_MAX) return {layout: "grouped", reason: `${total} 个维度超出雷达可读范围,改用分组列表且全部维度仍可查看`};
|
||
return {layout: "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("");
|
||
}
|