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
+2 -1
View File
@@ -5,6 +5,7 @@ import AgentNav from "@/components/xingbang/AgentNav";
import AssistantHistory from "@/components/xingbang/AssistantHistory";
import ReviewerWorkspace from "@/components/xingbang/ReviewerWorkspace";
import ImportBatchPanel from "@/components/xingbang/ImportBatchPanel";
import PrescreenPanel from "@/components/xingbang/PrescreenPanel";
import {messageOf} from "@/lib/prescreen";
import {newRequestId} from "@/lib/request-id";
import "../data-access/workspace.css";
@@ -92,7 +93,7 @@ export default function XingbangWorkspace() {
{tab === "overview" && <><section className="xb-cards"><article><b>当前数据版本</b><strong>v{project.data_revision}</strong><small>{project.documents.length} 份材料</small></article><button className="xb-metric-card" onClick={goToFindings}><b>未处理问题</b><strong>{openFindings.length}</strong><small>{openFindings[0] ? `${openFindings[0].title} · 点击查看怎么解决` : "当前没有待处理问题"}</small><ArrowRight size={17}/></button><article><b>最近运行</b><strong>{statuses[project.runs[0]?.status] || "未检查"}</strong><small>{project.runs[0]?.created_at ? new Date(project.runs[0].created_at).toLocaleString() : "点击检查项目"}</small></article></section>
<section className="xb-panel xb-wide"><h2>AI检查结果</h2><p>先看业务结论;数据来源和解决建议可直接展开,技术信息放在最后。</p>{project.runs.length ? <><select aria-label="选择检查历史" value={chosenRun?.id || ""} onChange={e => setRunId(e.target.value)}>{project.runs.map((r: any) => <option key={r.id} value={r.id}>{new Date(r.created_at).toLocaleString()} · {statuses[r.status]} · {r.id.slice(-8)}</option>)}</select><p>本次要求:{chosenRun.requirement || "无额外要求"}</p>{["queued", "running"].includes(chosenRun.status) && <button className="ra-button" onClick={() => act(`${base}/runs/${chosenRun.id}/cancel`, {}, "已取消;迟到结果不会发布。")}>取消本次运行</button>}{chosenRun.error && <p role="alert">{chosenRun.error}</p>}{chosenRun.steps.map((step:any)=><BusinessStep key={step.step_id} step={step} evidence={evidence} statuses={statuses}/>)}{chosenRun.data_errors?.map((e:any,i:number)=><p key={i} className="xb-warning">数据读取未完成:{e.message}</p>)}<details><summary>技术追溯信息</summary><pre>{JSON.stringify({snapshot:chosenRun.snapshot_id,versions:chosenRun.component_versions,changes:chosenRun.change_summary},null,2)}</pre></details></> : <p>尚无运行记录。</p>}</section>
<details className="xb-panel xb-wide"><summary>业务历史与操作留痕</summary><AssistantHistory key={project.id} projectId={project.id} records={project.legacy_records} events={project.events} request={request}/>{project.migration_note && <p>{project.migration_note};已保留 {project.legacy_records.length} 条历史记录。</p>}</details></>}
{tab === "findings" && <><ImportBatchPanel project={project} request={request} base={base} headers={headers} busy={busy} setBusy={setBusy} setError={setError} setNotice={setNotice} onChanged={() => open(project.id)}/><section className="xb-panel xb-wide"><h2>材料档案</h2><p>上传后先查看解析结果,确认候选值才更新项目。旧版本与来源保留;扫描件无法识别时会提示补充。</p><label className="ra-field"><span>上传 TXT / CSV / DOCX / XLSX / PDF / 图片(最多 10 MB)</span><input aria-label="上传项目材料" type="file" accept=".txt,.csv,.docx,.xlsx,.pdf,.png,.jpg,.jpeg" disabled={busy} onChange={e => {if (e.target.files?.[0]) upload(e.target.files[0]); e.target.value = "";}}/></label>{project.documents.map((d: any) => <div className="xb-finding" key={d.id}><div><b>{d.name}</b><p>版本 {d.version} · {d.status} · {d.size} 字节</p></div><button className="ra-button" onClick={async () => {try {setDocument(await request(`${base}/documents/${d.id}`));} catch (e: any) {setError(e.message);}}}>查看解析</button><button className="ra-button" onClick={() => download(`${base}/documents/${d.id}/download`, d.name)}>下载原件</button></div>)}
{tab === "findings" && <><ImportBatchPanel project={project} request={request} base={base} headers={headers} busy={busy} setBusy={setBusy} setError={setError} setNotice={setNotice} onChanged={() => open(project.id)}/><PrescreenPanel project={project} request={request} busy={busy} setBusy={setBusy} setError={setError} setNotice={setNotice}/><section className="xb-panel xb-wide"><h2>材料档案</h2><p>上传后先查看解析结果,确认候选值才更新项目。旧版本与来源保留;扫描件无法识别时会提示补充。</p><label className="ra-field"><span>上传 TXT / CSV / DOCX / XLSX / PDF / 图片(最多 10 MB)</span><input aria-label="上传项目材料" type="file" accept=".txt,.csv,.docx,.xlsx,.pdf,.png,.jpg,.jpeg" disabled={busy} onChange={e => {if (e.target.files?.[0]) upload(e.target.files[0]); e.target.value = "";}}/></label>{project.documents.map((d: any) => <div className="xb-finding" key={d.id}><div><b>{d.name}</b><p>版本 {d.version} · {d.status} · {d.size} 字节</p></div><button className="ra-button" onClick={async () => {try {setDocument(await request(`${base}/documents/${d.id}`));} catch (e: any) {setError(e.message);}}}>查看解析</button><button className="ra-button" onClick={() => download(`${base}/documents/${d.id}/download`, d.name)}>下载原件</button></div>)}
{document && <div className="xb-document"><h3>{document.name}</h3>{document.warnings.map((w: string, i: number) => <p className="xb-warning" key={i}>{w}</p>)}<label className="ra-field"><span>来源确认或补件说明</span><input value={reason} onChange={e => setReason(e.target.value)} placeholder="说明已核对的期间、口径和依据"/></label>{document.candidates.map((c: any) => <div className="xb-finding" key={c.id}><div><b>{c.field} = {String(c.value ?? "缺失")}</b><p>{c.locator} · {c.period || "期间未明"} · {c.unit || "单位未明"} · {c.scope || "口径未明"}</p></div><button className="ra-button" disabled={busy || !reason.trim()} onClick={() => act(`${base}/supplements`, {candidate_ids: [c.id], revision: project.revision, reason}, "候选值已确认;请重新检查项目。")}>确认该来源</button></div>)}<details><summary>解析原文与定位</summary>{document.chunks.map((c: any, i: number) => <p key={i}><b>{c.locator}</b> {c.text}{c.formula && <small> 公式:{c.formula};缓存:{c.cached_value || "无"}</small>}</p>)}</details></div>}</section>
<section className="xb-panel xb-wide" id="project-findings"><h2>{isReviewer?"全链路评审":"问题与解决"}</h2><p>{isReviewer?"逐项查看数据来源、AI判断和客户经理说明,再选择评审结论。":"每个问题都说明影响和下一步;处理完成后重新AI预审。"}</p>{isReviewer&&<label className="ra-field xb-review-opinion"><span>常用评审意见(选择后应用到下方操作)</span><select value={reviewOpinion} onChange={e=>setReviewOpinion(e.target.value)}><option>请补充最新材料并说明数据口径</option><option>证据充分,同意AI判断</option><option>AI判断不适用于本项目,以人工核实为准</option><option>请补充现场核实记录</option></select></label>}{!project.findings.length&&<p>尚无问题记录,请先执行检查。</p>}{[...project.findings].sort((a:any,b:any)=>Number(["已解决","不适用"].includes(a.status))-Number(["已解决","不适用"].includes(b.status))).map((finding:any)=><FindingCard key={finding.id} finding={finding} evidence={evidence} isReviewer={isReviewer} busy={busy||!hasReviewClaim} reason={reason} onManager={(status:string)=>act(`${base}/findings/${finding.id}/actions`,{action:status,revision:finding.revision,note:reason})} onReviewer={(decision:string)=>reviewDecision(finding.id,decision)} decision={project.review_decisions?.find((item:any)=>item.finding_id===finding.id)}/>)}{!isReviewer&&<><div className="xb-form-grid"><label className="ra-field"><span>人工补充字段</span><select value={field} onChange={e => setField(e.target.value)}>{fields.map(f => <option key={f[0]} value={f[0]}>{f[1]}</option>)}</select></label><label className="ra-field"><span>确认值(留空表示资料仍缺失)</span><input value={value} onChange={e => setValue(e.target.value)} placeholder={field === "collection_dependency" ? "true 或 false" : "输入实际核对值"}/></label><label className="ra-field"><span>处理说明</span><input value={reason} onChange={e => setReason(e.target.value)} placeholder="说明材料、口径或核实情况"/></label></div><button className="ra-button primary" disabled={busy || !reason.trim()} onClick={() => act(`${base}/supplements`, {changes: {[field]: value === "" ? null : field === "collection_dependency" ? value === "true" ? true : value === "false" ? false : value : value}, finding_ids: [], revision: project.revision, reason}, "补件已保存,原来源已保留;请重新检查确认问题变化。")}>保存补件</button></>}{isReviewer&&<div className="xb-review-final"><div><CheckCircle2 size={21}/><span><b>完成全链路核查</b><small>AI只提供建议,批准或退回由评审员确认。</small></span></div><button className="ra-button" disabled={busy||!hasReviewClaim} onClick={()=>completeReview("退回")}>退回补充材料</button><button className="ra-button primary" disabled={busy||!hasReviewClaim||!currentRun} onClick={()=>completeReview("批准")}>批准并生成评审报告</button></div>}</section></>}
{tab === "report" && <section className="xb-panel xb-wide"><h2>{isReviewer ? "独立评审报告" : "AI预审报告"}</h2><p>模板、运行快照、Skills 与规则版本随报告冻结;每个历史版本都可以重新打开。</p><div className="xb-actions"><select aria-label="报告模板" value={template} onChange={e => setTemplate(e.target.value)}>{isReviewer ? <><option value="template.independent-review">独立评审报告</option><option value="template.committee-brief">上会摘要</option></> : <option value="template.ai-pre-review">AI预审意见</option>}</select><select aria-label="报告来源运行" value={reportSourceRun?.id || ""} onChange={e => setRunId(e.target.value)}>{finished.map((r: any) => <option key={r.id} value={r.id}>{new Date(r.created_at).toLocaleString()} · {statuses[r.status]}</option>)}</select><button className="ra-button primary" disabled={busy || !currentRun} onClick={async () => {const r = await act(`${base}/reports`, {run_id: reportSourceRun.id, template_id: template}); if (r) setReportId(r.id);}}><FileText size={15}/>生成报告</button></div>{chosenReport && <div className="xb-report"><select aria-label="选择报告版本" value={chosenReport.id} onChange={e => {setReportId(e.target.value); setSectionEdits({});}}>{project.reports.map((r: any) => <option key={r.id} value={r.id}>{r.template.name} · {statuses[r.status]} · {r.id.slice(-8)}</option>)}</select><p>{chosenReport.disclaimer} · {statuses[chosenReport.status]}</p><small>来源快照:{chosenReport.snapshot_id}</small><div className="xb-actions"><button className="ra-button" onClick={() => download(`${base}/reports/${chosenReport.id}/export`, `${project.id}-${chosenReport.template.name}.docx`)}>导出 Word</button><button className="ra-button" disabled={busy || !currentRun || ["finalized","submitted"].includes(chosenReport.status)} onClick={() => reportAction("regenerate", {run_id: currentRun.id})}>按最新运行更新候选</button>{isManager && <button className="ra-button primary" disabled={busy || chosenReport.status === "submitted"} onClick={() => reportAction("submit")}>提交正式评审</button>}{canReview && <button className="ra-button primary" disabled={busy || chosenReport.status === "finalized"} onClick={() => reportAction("finalize")}>确认评审定稿</button>}</div>{chosenReport.sections.map((s: any) => <article key={s.id}><h3>{s.title} {s.stale ? "· 来源已过期" : ""} {s.locked ? "· 已锁定" : ""} {s.confirmed ? "· 已确认" : ""}</h3><textarea aria-label={`编辑${s.title}`} className="xb-section-text" disabled={s.locked || ["overview", "findings"].includes(s.id) || ["finalized","submitted"].includes(chosenReport.status)} value={sectionEdits[s.id] ?? s.content} onChange={e => setSectionEdits({...sectionEdits, [s.id]: e.target.value})}/>{!["finalized","submitted"].includes(chosenReport.status) && <div className="xb-actions"><button className="ra-button" disabled={busy || s.locked || ["overview", "findings"].includes(s.id)} onClick={() => reportAction("section", {section_id: s.id, content: sectionEdits[s.id] ?? s.content, confirmed: false})}>保存修改</button><button className="ra-button" disabled={busy || s.stale} onClick={() => reportAction("section", {section_id: s.id, content: sectionEdits[s.id] ?? s.content, confirmed: true, locked: true})}>确认并锁定</button>{s.locked && <button className="ra-button" disabled={busy} onClick={() => reportAction("section", {section_id: s.id, locked: false, confirmed: false})}>解除锁定</button>}</div>}{s.candidate && <details open><summary>更新候选(未覆盖当前正文)</summary><pre>{s.candidate.content}</pre><button className="ra-button" disabled={busy} onClick={() => reportAction("section", {section_id: s.id, accept_candidate: true})}>采纳候选内容</button></details>}</article>)}</div>}</section>}