feat(v2): 纠偏第四批——会议结果录入与改进发布接真实知识发布入口

- api/prescreen.py:transition_improvement 注入 _knowledge_publisher,
  release 委托 knowledge_cards.publish → component_registry.release;
  按 proposed_revision_ref 以「组件id@版本」精确解析草稿(不取"最新草稿",
  否则会出现"审核的是 A、发布的是 B");客户端自报 released_ref 一律 422;
  非 knowledge 目标 409 诚实拒绝;发布与候选状态同一事务,失败保留旧版。
- TrajectoryPanel:新增会议结果录入(追加式更正走 supersedes_id;
  未匹配单列且不计入分母)。
- ImprovementPanel(新增):偏差→候选→两道评测门→独立审核→发布;
  请求体不含版本号,无自动上线,不自批。
- 界面并入「轨迹复盘」,不新增第四个导航入口(AC-53)。
- 守卫:新增 scripts/test_improvement_panel.cjs(verify.py 13→14 项);
  test_module_shell_ui.cjs 的「只读」断言改为分别锁死「回放只读」与「结果只可追加」。
- 测试:新增 backend/test_improvement_release.py(AC-57 端到端 + 三条反例);
  test_improvements.py 补「自报版本号被拒」。
- 文档:17 第四批交付记录、15 §7 交接、03 §9.4 发布事务边界。

验证:scripts/verify.py 14/14 PASS(backend-unit 572 项),证据
test-evidence/20261001-013517-3e15bda/;scripts/xingbang_batch4_smoke.py
对真实服务器实跑通过(发布指针实测移到新版本)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-01 02:21:11 +08:00
co-authored by Claude Code
parent 3e15bda8cd
commit f775c6cbe4
13 changed files with 1269 additions and 91 deletions
+14 -6
View File
@@ -2,6 +2,7 @@
import {useEffect, useState} from "react";
import AgentNav from "@/components/xingbang/AgentNav";
import TrajectoryPanel from "@/components/xingbang/TrajectoryPanel";
import ImprovementPanel from "@/components/xingbang/ImprovementPanel";
import {messageOf} from "@/lib/prescreen";
import "../data-access/workspace.css";
@@ -9,6 +10,9 @@ import "../data-access/workspace.css";
*
* 原 `/trajectory` 是合并后的重定向桩,本页是**新路由**,不复用也不复活那个旧页
* (AC-55 的"防重复页面")。只负责项目上下文与请求助手,回放逻辑在 TrajectoryPanel。
*
* 纠偏第四批把「会议记录」与「反向改进」并入本模块(14 §7 本就把三者写在一节),
* 因此**不新增第四个导航入口**(AC-53 要求恰好三个)。
*/
export default function TrajectoryCenterPage() {
const [identity, setIdentity] = useState<any>(null);
@@ -26,9 +30,11 @@ export default function TrajectoryCenterPage() {
...(role ? {"X-Xingbang-Demo-Role": role} : {})};
}
async function request(path: string, body?: any) {
// 必须支持 PATCH:改进候选的"自然语言修改适用范围"走的是既有候选端点的 PATCH。
// 形参与 /experience-center 一致,两个模块不各写一份请求助手。
async function send(method: string, path: string, body?: any) {
const response = await fetch(`/api/risk-agent/project-assistant${path}`,
{method: body === undefined ? "GET" : "POST", headers: headers(),
{method, headers: headers(),
body: body === undefined ? undefined : JSON.stringify(body)});
const data = await response.json();
if (!response.ok) throw Error(messageOf(data));
@@ -40,7 +46,7 @@ export default function TrajectoryCenterPage() {
try {
const [context, list] = await Promise.all([
fetch("/api/risk-agent/access/context", {headers: headers(false)}).then((r) => r.ok ? r.json() : null).catch(() => null),
request("/projects"),
send("GET", "/projects"),
]);
setIdentity(context);
const items = list.items || list || [];
@@ -48,7 +54,7 @@ export default function TrajectoryCenterPage() {
const wanted = new URLSearchParams(location.search).get("project") || items[0]?.id || "";
if (wanted) {
setProjectId(wanted);
setProject(await request(`/projects/${encodeURIComponent(wanted)}`));
setProject(await send("GET", `/projects/${encodeURIComponent(wanted)}`));
}
} catch (e: any) {setError(e.message);} finally {setLoaded(true);}
})();
@@ -57,7 +63,7 @@ export default function TrajectoryCenterPage() {
async function open(id: string) {
setProjectId(id); setError("");
try {
setProject(await request(`/projects/${encodeURIComponent(id)}`));
setProject(await send("GET", `/projects/${encodeURIComponent(id)}`));
history.replaceState({}, "", `?project=${encodeURIComponent(id)}`);
} catch (e: any) {setError(e.message);}
}
@@ -86,7 +92,9 @@ export default function TrajectoryCenterPage() {
{projects.map((item) => <option key={item.id} value={item.id}>{item.id} · {item.customer || ""}</option>)}
</select>
</label>
{project && <TrajectoryPanel key={project.id} project={project} request={request}/>}
{project && <TrajectoryPanel key={project.id} project={project} send={send}/>}
{project && <ImprovementPanel key={`imp-${project.id}`} project={project}
identity={identity} send={send}/>}
</>}
</main>
</div>;
@@ -0,0 +1,292 @@
"use client";
import {useEffect, useState} from "react";
import {AlertTriangle, ArrowUpRight, CheckCircle2, Gauge, ShieldCheck, Wrench} from "lucide-react";
import {PRESCREEN} from "@/lib/prescreen";
/** 改进对照(纠偏第四批 · BR-51 / AC-57;04-ui-spec §9 的「改进对照 / ImprovementPanel」)。
*
* 挂在 /trajectory-center 之内——14 §7 本就把「轨迹、会议记录与反向改进」写在同一节,
* AC-53 又要求恰好三个模块入口,因此**不新增第四个导航入口**。
*
* 五条界面纪律:
* 1. **发布不由界面给版本号**:release 请求体只有 action;版本由服务端调用真实知识发布入口
* 产生。界面里没有任何地方提交 released_ref——否则又变成"返回一个版本号"。
* 2. **两道门分开**:草稿的「隔离评测」与候选的「旧/新对照」是两件事,缺任一门都发布不了。
* 3. **不自批**:提出者不显示"通过审核"按钮(后端另有强制,界面不诱导违规)。
* 4. **不提供自动上线**:没有"一键发布/自动上线",也不在发布后自动发起运行。
* 5. **泄漏不是成功**:对照被判泄漏时按失败显示,不渲染成"评测完成"。
*/
const STATUS_LABEL: Record<string, string> = {
draft: "草稿", evaluating: "评测中", ready: "待提交审核", in_review: "待审核",
approved: "已通过审核", rejected: "已拒绝", released: "已发布", withdrawn: "已撤回",
};
const TARGETS: [string, string, boolean][] = [
["knowledge", "知识(Skill/Rule 卡片)", true],
["orchestration", "编排配置", false],
["question_profile", "题库", false],
];
const SAMPLE_PLACEHOLDER = `[
{"id": "S-1", "split": "dev", "observed": true,
"baseline_pass": false, "candidate_pass": true,
"predicted_at": "2026-09-29T01:00:00+00:00",
"label_revealed_at": "2026-09-29T03:00:00+00:00",
"source_in_dev": false}
]`;
/** 未完成门禁的只读镜像;服务端返回的文案为准(这里只用于提前提示)。 */
function blockersOf(candidate: any): string[] {
const list: string[] = [];
const contrast = candidate.contrast;
if (!contrast) list.push("缺少旧/新对照证据");
else {
if (contrast.contaminated?.length) list.push(`对照样本存在泄漏:${contrast.contaminated.join("、")}`);
if (!contrast.independent) list.push("对照不是独立评测(同模型自评不算证据)");
if (contrast.verdict === "regressed") list.push("对照显示退化,不得发布");
if (contrast.verdict === "inconclusive") list.push("对照结论不明确,不得发布");
}
if (candidate.status !== "approved") list.push(`当前状态为「${STATUS_LABEL[candidate.status] || candidate.status}」,未通过审核`);
return list;
}
export default function ImprovementPanel({project, identity, send}: any) {
const [runs, setRuns] = useState<any[]>([]);
const [outcomes, setOutcomes] = useState<any[]>([]);
const [cards, setCards] = useState<any[]>([]);
const [candidates, setCandidates] = useState<any[]>([]);
const [draft, setDraft] = useState<any>({target: "knowledge", target_ref: "", change_summary: ""});
const [evaluationFor, setEvaluationFor] = useState("");
const [samples, setSamples] = useState("");
const [rejectFor, setRejectFor] = useState("");
const [reason, setReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [loaded, setLoaded] = useState(false);
const path = `/projects/${encodeURIComponent(project.id)}`;
const canReview = Boolean(identity?.preview || ["admin", "reviewer"].includes(identity?.role));
const canPublish = Boolean(identity?.preview || identity?.role === "reviewer");
// 不自批:提出者看不到自己的"通过审核"(后端 must_not_self_approve 另有强制)
const mayApprove = (item: any) => canReview && item.proposed_by !== identity?.id;
async function refresh() {
try {
const [found, list] = await Promise.all([
send("GET", `${PRESCREEN}${path}/improvements`),
send("GET", `${PRESCREEN}${path}/knowledge-candidates`),
]);
setCandidates(found.items || []);
setCards(list.items || []);
} catch (e: any) {setError(e.message);}
try {setOutcomes((await send("GET", `${PRESCREEN}${path}/review-outcomes`)).items || []);}
catch {setOutcomes([]);}
setLoaded(true);
}
useEffect(() => {
setRuns((project?.runs || []).filter((item: any) => item.id));
refresh();
}, [project.id]);
async function act(work: () => Promise<any>, message: string) {
setBusy(true); setError(""); setNotice("");
try {const result = await work(); setNotice(message); await refresh(); return result;}
catch (e: any) {setError(e.message); return null;} finally {setBusy(false);}
}
const draftCards = cards.filter((card: any) => card.status === "draft");
const proposedVersion = draftCards.find((card: any) => card.id === draft.target_ref);
// 被改进的是"当前已发布的版本",拟发布的是那张草稿——两者是不同版本,不能混为一谈
const published = proposedVersion && cards.find(
(card: any) => card.status === "published" && card.id === proposedVersion.id);
const propose = () => act(
() => send("POST", `${PRESCREEN}${path}/improvements`, {
target: draft.target,
target_ref: published
? {id: published.id, version: published.version}
: {id: proposedVersion.id, version: proposedVersion.version},
proposed_revision_ref: {id: proposedVersion.id, version: proposedVersion.version},
change_summary: draft.change_summary,
source_run_ids: draft.source_run_id ? [draft.source_run_id] : [],
outcome_ids: draft.outcome_id ? [draft.outcome_id] : [],
}),
"改进候选已提出(草稿)。发布仍需通过旧/新对照与独立审核两道门。");
const transition = (item: any, action: string, extra: any = {}) => act(
() => send("POST", `${PRESCREEN}/improvements/${encodeURIComponent(item.id)}/transitions`,
{action, ...extra}),
action === "evaluate" ? "旧/新对照已提交;泄漏与指标由服务端判定。"
: action === "submit" ? "已提交审核,等待独立审核人处理。"
: action === "approve" ? "已通过审核;仍需发布动作才真正生效。"
: action === "reject" ? "已拒绝,并留下原因。"
: "发布请求已提交。");
const submitEvaluation = (item: any) => {
let parsed: any;
try {parsed = JSON.parse(samples);}
catch {setError("对照样本必须是合法 JSON 数组。"); return Promise.resolve(null);}
return transition(item, "evaluate", {evaluation: {
baseline_ref: {id: "assistant.current", version: "1.0.0"},
candidate_ref: {id: "prescreen.v2", version: "0.2.0"},
dataset_ref: "fixtures/cases/manifest.json", split_ref: "split.v1",
independent: true, samples: parsed}});
};
// 只传组件 id 会评测到"当前最新草稿";候选声明的却是某个具体版本,
// 因此这里用 `组件id@版本` 精确定位,避免"评测的是 A、发布的是 B"。
const evaluateDraft = (item: any) => act(
() => send("POST", `${PRESCREEN}/knowledge-candidates/`
+ `${encodeURIComponent(item.proposed_revision_ref.id + "@" + item.proposed_revision_ref.version)}/transitions`,
{action: "evaluate"}),
"草稿隔离评测已完成(这是发布门禁之一,与候选的旧/新对照不是同一道门)。");
const publishedRef = (item: any) => item.released_ref
? `${item.released_ref.id}@${item.released_ref.version}` : "";
return <section className="xb-panel xb-wide">
<h2>改进对照与发布</h2>
<p>从可核验的偏差出发:选来源运行或会议结果 → 提出候选修订 → 固定旧版做对照 → 独立审核 →
**由服务端调用真实知识发布入口**。发布只影响后续新运行;旧运行的冻结版本不变,仍可回放。</p>
{error && <div role="alert" className="ra-alert error">{error}</div>}
{notice && <div role="status" className="ra-alert">{notice}</div>}
{!loaded && <p>正在读取改进候选…</p>}
<div className="xb-task">
<h4>① 偏差来源与候选修订</h4>
<p className="ra-note">候选必须引用来源运行或会议结果——凭空提出的改动不进入审核。
新版本草稿在<a href={`/experience-center?project=${encodeURIComponent(project.id)}`}>历史经验加工</a>里修订或新增;
本面板只负责把它送进对照与审核。</p>
<label className="ra-field"><span>来源运行(可选)</span>
<select aria-label="来源运行" value={draft.source_run_id || ""} disabled={busy}
onChange={(e) => setDraft({...draft, source_run_id: e.target.value})}>
<option value="">不引用运行</option>
{runs.map((item: any) => <option key={item.id} value={item.id}>
{new Date(item.created_at).toLocaleString()} · {item.status}
</option>)}
</select></label>
<label className="ra-field"><span>来源会议结果(可选)</span>
<select aria-label="来源会议结果" value={draft.outcome_id || ""} disabled={busy}
onChange={(e) => setDraft({...draft, outcome_id: e.target.value})}>
<option value="">不引用会议结果</option>
{outcomes.map((item: any) => <option key={item.id} value={item.id}>
{item.raw_code} · {new Date(item.occurred_at).toLocaleString()} · {item.status}
</option>)}
</select></label>
<label className="ra-field"><span>改进目标</span>
<select aria-label="改进目标" value={draft.target} disabled={busy}
onChange={(e) => setDraft({...draft, target: e.target.value})}>
{TARGETS.map(([value, text, enabled]) =>
<option key={value} value={value} disabled={!enabled}>
{text}{enabled ? "" : "(尚无真实发布入口,不可发布)"}
</option>)}
</select></label>
<label className="ra-field"><span>要发布的草稿版本(来自历史经验加工)</span>
<select aria-label="要发布的草稿版本" value={draft.target_ref} disabled={busy}
onChange={(e) => setDraft({...draft, target_ref: e.target.value})}>
<option value="">选择一张草稿卡片</option>
{draftCards.map((card: any) => <option key={card.id} value={card.id}>
{card.title} · v{card.version}
</option>)}
</select></label>
<label className="ra-field"><span>变更说明(必填,会随候选取证)</span>
<input value={draft.change_summary} disabled={busy} aria-label="变更说明"
onChange={(e) => setDraft({...draft, change_summary: e.target.value})}/></label>
<div className="xb-actions">
<button className="ra-button" disabled={busy || !proposedVersion || !draft.change_summary.trim()}
onClick={propose}><Wrench size={15}/>提出改进候选</button>
</div>
{!draftCards.length && <p className="ra-note">当前没有草稿卡片可发布:先在上方链接里修订或新增一张草稿。</p>}
</div>
<h4>② 候选:对照、审核与发布</h4>
{!candidates.length && <div className="ra-empty"><h2>尚无改进候选</h2><p>先在上一步提出一个候选。</p></div>}
{candidates.map((item: any) => {
const blockers = blockersOf(item);
return <div className="xb-finding" key={item.id}>
<div>
<b>{item.change_summary}</b>
<p>
<span className="ra-badge">{STATUS_LABEL[item.status] || item.status}</span>{" "}
<span className="ra-badge">目标 {item.target}</span>{" "}
<span className="ra-badge">拟发布 {item.proposed_revision_ref.id}@{item.proposed_revision_ref.version}</span>
</p>
<p><small>提出人 {item.proposed_by}{item.reviewer_id ? ` · 审核人 ${item.reviewer_id}` : ""}</small></p>
{item.contrast && <p className={item.contrast.contaminated?.length ? "xb-warning" : ""}>
{item.contrast.contaminated?.length && <AlertTriangle size={13}/>}
{" "}对照结论 <b>{item.contrast.verdict}</b>({item.contrast.independent ? "独立评测" : "非独立评测"},
样本 {item.contrast.sample_size} 条)
{item.contrast.contaminated?.length ? ` · 泄漏样本 ${item.contrast.contaminated.join("、")},整次作废` : ""}
</p>}
{blockers.length > 0 && <div className="xb-warning">
<b><AlertTriangle size={13}/> 未完成门禁(以服务端返回为准)</b>
<ul>{blockers.map((text) => <li key={text}>{text}</li>)}</ul>
</div>}
{item.status === "released" && <p className="xb-warning">
<CheckCircle2 size={13}/> 已发布真实版本 <code>{publishedRef(item)}</code>:
后续新运行将使用该版本,旧运行的冻结版本不变、仍可回放。
</p>}
<div className="xb-actions">
<button className="ra-button" disabled={busy} onClick={() => evaluateDraft(item)}>
<Gauge size={15}/>对草稿做隔离评测
</button>
<button className="ra-button" disabled={busy}
onClick={() => {setEvaluationFor(evaluationFor === item.id ? "" : item.id); setSamples("");}}>
提交旧/新对照
</button>
{item.status !== "in_review" && item.status !== "released" &&
<button className="ra-button" disabled={busy} onClick={() => transition(item, "submit")}>
提交审核
</button>}
{mayApprove(item) && item.status !== "released" &&
<button className="ra-button" disabled={busy} onClick={() => transition(item, "approve")}>
<ShieldCheck size={15}/>通过审核
</button>}
{canReview && item.status !== "released" &&
<button className="ra-button" disabled={busy}
onClick={() => {setRejectFor(rejectFor === item.id ? "" : item.id); setReason("");}}>
拒绝
</button>}
{canPublish && item.status === "approved" &&
<button className="ra-button primary" disabled={busy} onClick={() => transition(item, "release")}>
<ArrowUpRight size={15}/>审核发布
</button>}
</div>
{canReview && item.proposed_by === identity?.id &&
<p className="ra-note">本候选由你本人提出,因此不显示"通过审核":审核需要独立的另一位(BR-42 / AC-43)。</p>}
{evaluationFor === item.id && <div className="xb-task">
<label className="ra-field"><span>固定版本的旧/新对照样本(JSON 数组;泄漏与指标由服务端判定)</span>
<textarea className="ra-code" value={samples} disabled={busy}
placeholder={SAMPLE_PLACEHOLDER}
onChange={(e) => setSamples(e.target.value)}/>
</label>
<button className="ra-button" disabled={busy || !samples.trim()} onClick={() => submitEvaluation(item)}>
提交对照并评测
</button>
</div>}
{rejectFor === item.id && <div className="xb-task">
<label className="ra-field"><span>拒绝原因(必填)</span>
<input value={reason} disabled={busy} onChange={(e) => setReason(e.target.value)}/>
</label>
<button className="ra-button" disabled={busy || !reason.trim()}
onClick={() => transition(item, "reject", {reason})}>确认拒绝</button>
</div>}
</div>
</div>;
})}
<p className="ra-note">
发布由服务端调用真实知识发布入口,**版本号不由界面提交**;发布失败会保留旧版本,
候选也不会被标成已发布。这里没有"自动上线",发布后也不会自动发起运行。
</p>
</section>;
}
+142 -26
View File
@@ -1,17 +1,18 @@
"use client";
import {useEffect, useState} from "react";
import {AlertTriangle, Clock, FileText, RefreshCw} from "lucide-react";
import {AlertTriangle, Clock, FileText, Plus, RefreshCw} from "lucide-react";
import {PRESCREEN} from "@/lib/prescreen";
/** 轨迹复盘(AC-54 的"可回放轨迹";T10 的只读回放语义在界面上落地)。
/** 轨迹复盘(AC-54 的"可回放轨迹";纠偏第四批补 AC-56 的会议结果录入)。
*
* 三条界面纪律,直接对应后端已有的规则:
* 1. **只读**:这里没有任何写操作;回放不改运行、不改历史结果。
* 四条界面纪律,直接对应后端已有的规则:
* 1. **回放只读**:回放投影既有运行,不改运行、不改历史结果。本面板唯一的写操作是
* 在下方**追加**一条会议结果——追加不是改写。
* 2. **失败保留**:failed / skipped 步骤照常列出并带错误码,不因为"看起来不好"被过滤。
* 3. **成本未知留空**:没有成本口径时显示"未记录",**不填 0**——0 会被读成"零成本"。
* 4. **未匹配单列**:找不到材料版本的会议结果单独一组,既不当否决、也不进通过率分母。
*
* 不展示模型内部推理:轨迹只记录可观察动作与版本,界面也不假装能看到思维链。
* 会议结果的**录入**属第四批;本页只读展示已保存的记录。
*/
const STATUS_LABEL: Record<string, string> = {
@@ -22,21 +23,38 @@ const KIND_LABEL: Record<string, string> = {
plan: "方案", skill: "Skill", tool: "工具", rule: "规则",
judgment: "专项判断", synthesis: "综合",
};
// 与后端 EventOutcome 一致;留空表示"由服务端按原始结果码的别名归一,未知就记 unknown"
const NORMALIZED = [
["", "未指定(由服务端按原始结果码归一)"],
["approved", "通过"], ["rejected", "否决"], ["conditional", "附条件通过"],
["deferred", "暂缓"], ["withdrawn", "撤回"], ["not_submitted", "未送审"],
["unknown", "未知(不推断成通过或否决)"],
];
export default function TrajectoryPanel({project, request}: any) {
const EMPTY_FORM = {
raw_code: "", meeting_id: "", normalized: "", reviewed_snapshot_id: "",
occurred_at: "", supersedes_id: "", conditions: "",
};
export default function TrajectoryPanel({project, identity, send}: any) {
const [runId, setRunId] = useState("");
const [trajectory, setTrajectory] = useState<any>(null);
const [outcomes, setOutcomes] = useState<any[]>([]);
const [outcomes, setOutcomes] = useState<any>(null);
const [form, setForm] = useState<any>(EMPTY_FORM);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [loaded, setLoaded] = useState(false);
const runs = (project?.runs || []).filter((item: any) => item.id);
const path = `/projects/${encodeURIComponent(project.id)}`;
// 记录人可追加会议结果;只读访客不显示录入表单(后端每次动作仍会重验)
const canRecord = Boolean(identity?.preview
|| ["manager", "reviewer", "admin"].includes(identity?.role));
async function load(id: string, cursor?: string) {
setBusy(true); setError("");
try {
const page = await request(`${PRESCREEN}${path}/runs/${encodeURIComponent(id)}/trajectory`
const page = await send("GET", `${PRESCREEN}${path}/runs/${encodeURIComponent(id)}/trajectory`
+ (cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""));
setTrajectory((current: any) => cursor && current
? {...page, steps: [...current.steps, ...page.steps]}
@@ -44,17 +62,49 @@ export default function TrajectoryPanel({project, request}: any) {
} catch (e: any) {setError(e.message);} finally {setBusy(false); setLoaded(true);}
}
async function loadOutcomes() {
try {
setOutcomes(await send("GET", `${PRESCREEN}${path}/review-outcomes`));
} catch {setOutcomes({items: [], matched: [], unmatched: []});}
}
useEffect(() => {
if (!runs.length) {setTrajectory(null); setLoaded(true); return;}
const first = runs[0].id;
setRunId(first);
load(first);
request(`${PRESCREEN}${path}/review-outcomes`)
.then((data: any) => setOutcomes(data.items || []))
.catch(() => setOutcomes([]));
if (runs.length) {
const first = runs[0].id;
setRunId(first);
load(first);
} else {setTrajectory(null); setLoaded(true);}
setForm(EMPTY_FORM); setNotice("");
loadOutcomes();
}, [project.id]);
async function saveOutcome() {
setBusy(true); setError(""); setNotice("");
try {
const body: any = {
raw_code: form.raw_code, meeting_id: form.meeting_id,
conditions: form.conditions.split(",").map((item: string) => item.trim()).filter(Boolean),
};
if (form.normalized) body.normalized = form.normalized;
if (form.reviewed_snapshot_id) body.reviewed_snapshot_id = form.reviewed_snapshot_id;
if (form.supersedes_id) body.supersedes_id = form.supersedes_id;
if (form.occurred_at) body.occurred_at = new Date(form.occurred_at).toISOString();
const saved = await send("POST", `${PRESCREEN}${path}/review-outcomes`, body);
setNotice(saved.status === "matched"
? `已保存并关联材料版本 ${saved.reviewed_snapshot_id};计入分母前仍需口径映射。`
: "已保存为**未匹配**:找不到对应材料版本,单列且不计入通过率分母。");
setForm(EMPTY_FORM);
await loadOutcomes();
} catch (e: any) {setError(e.message);} finally {setBusy(false);}
}
const steps = trajectory?.steps || [];
const matched = outcomes?.matched || [];
const unmatched = outcomes?.unmatched || [];
const counts = outcomes?.counts_toward_denominator;
const snapshots = runs.filter((item: any) => item.input_snapshot_id);
const fill = (key: string, value: string) => setForm((current: any) => ({...current, [key]: value}));
return <section className="xb-panel xb-wide">
<h2>运行轨迹回放</h2>
<p>一次运行是一条轨迹,多次工具与模型调用是其中的步骤。这里只记录**可观察的动作与版本**,不包含模型内部推理;回放是只读的,不会改动这次运行。</p>
@@ -110,17 +160,83 @@ export default function TrajectoryPanel({project, request}: any) {
</div>}
</>}
<h3>会议结果对照(只读)</h3>
<p>AI 轨迹与会议记录并列查看。找不到材料版本的结果单列,既不当否决、也不进通过率分母。<b>录入属第四批</b>,这里只读。</p>
{!outcomes.length && <p className="ra-note">尚未保存会议结果。</p>}
{outcomes.map((item: any) => <div className="xb-finding" key={item.id}>
<div>
<b>{item.raw_code} · {item.status === "matched" ? "已关联材料版本" : "未匹配(单列)"}</b>
<p>{item.normalized ? `归一化结果 ${item.normalized}` : "口径未映射,保留原码"}
{item.label === true ? " · 计入通过" : item.label === false ? " · 计入未通过" : " · 不计入分母"}</p>
<p><small><Clock size={12} aria-hidden="true"/> {new Date(item.occurred_at).toLocaleString()} · 记录人 {item.recorded_by}
{item.supersedes_id ? ` · 更正自 ${item.supersedes_id}` : ""}</small></p>
<h3>会议结果对照</h3>
<p>AI 轨迹与人工会议记录并列查看。匹配只基于可核验的项目、材料版本:找不到材料版本的结果**单列**,
既不当否决、也不进已观察通过率的分母。更正只能**追加**并指向旧记录,历史不被覆盖。</p>
{notice && <div role="status" className="ra-alert">{notice}</div>}
{!outcomes && <p>正在读取会议结果…</p>}
{outcomes && !matched.length && !unmatched.length && <p className="ra-note">尚未保存会议结果。</p>}
{matched.length > 0 && <>
<h4>已关联材料版本({matched.length})</h4>
{matched.map((item: any) => <OutcomeRow key={item.id} item={item}/>)}
</>}
{unmatched.length > 0 && <>
<h4>未匹配材料版本({unmatched.length})· 单列,不计入分母</h4>
{unmatched.map((item: any) => <OutcomeRow key={item.id} item={item}/>)}
</>}
{counts && <p className="ra-note">
计入已观察分母 {counts.observed} 条;排除 {counts.excluded.length} 条(未送审、撤回、附条件、未知
与未匹配均不计入)。{counts.note}
</p>}
{canRecord && <div className="xb-task">
<h4>追加会议结果</h4>
<p className="ra-note">原始结果码必填;未提供时服务端不会把它推断成通过或否决。找不到对应材料版本时如实记为未匹配。</p>
<label className="ra-field"><span>原始结果码(如:通过 / 否决 / 附条件通过 / 撤回)</span>
<input value={form.raw_code} disabled={busy} aria-label="原始结果码"
onChange={(e) => fill("raw_code", e.target.value)}/></label>
<label className="ra-field"><span>会议编号</span>
<input value={form.meeting_id} disabled={busy} aria-label="会议编号"
onChange={(e) => fill("meeting_id", e.target.value)}/></label>
<label className="ra-field"><span>归一化口径</span>
<select aria-label="归一化口径" value={form.normalized} disabled={busy}
onChange={(e) => fill("normalized", e.target.value)}>
{NORMALIZED.map(([value, text]) => <option key={value} value={value}>{text}</option>)}
</select></label>
<label className="ra-field"><span>对应的材料版本(该次运行的输入快照)</span>
<select aria-label="对应材料版本" value={form.reviewed_snapshot_id} disabled={busy}
onChange={(e) => fill("reviewed_snapshot_id", e.target.value)}>
<option value="">无法对应材料版本(记为未匹配)</option>
{snapshots.map((item: any) => <option key={item.id} value={item.input_snapshot_id}>
{item.input_snapshot_id} · 运行于 {new Date(item.created_at).toLocaleString()}
</option>)}
</select></label>
<label className="ra-field"><span>会议时间(可空,空则记保存时刻)</span>
<input type="datetime-local" value={form.occurred_at} disabled={busy} aria-label="会议时间"
onChange={(e) => fill("occurred_at", e.target.value)}/></label>
<label className="ra-field"><span>更正哪条记录(可空;更正只能新增,不覆盖历史)</span>
<select aria-label="更正哪条记录" value={form.supersedes_id} disabled={busy}
onChange={(e) => fill("supersedes_id", e.target.value)}>
<option value="">不是更正,新增一条</option>
{(outcomes?.items || []).map((item: any) => <option key={item.id} value={item.id}>
{item.raw_code} · {new Date(item.occurred_at).toLocaleString()} · {item.status}
</option>)}
</select></label>
<label className="ra-field"><span>附加条件(逗号分隔,可空)</span>
<input value={form.conditions} disabled={busy} aria-label="附加条件"
onChange={(e) => fill("conditions", e.target.value)}/></label>
<div className="xb-actions">
<button className="ra-button primary" disabled={busy || !form.raw_code.trim() || !form.meeting_id.trim()}
onClick={saveOutcome}><Plus size={15}/>保存会议结果</button>
</div>
</div>)}
</div>}
{!canRecord && <p className="ra-note">当前身份为只读查看:录入会议结果需要记录人身份。</p>}
</section>;
}
function OutcomeRow({item}: any) {
return <div className="xb-finding">
<div>
<b>{item.raw_code} · {item.status === "matched" ? "已关联材料版本" : "未匹配(单列)"}</b>
<p>{item.normalized ? `归一化结果 ${item.normalized}` : "口径未映射,保留原码"}
{item.label === true ? " · 计入通过" : item.label === false ? " · 计入未通过" : " · 不计入分母"}</p>
<p><small><Clock size={12} aria-hidden="true"/> {new Date(item.occurred_at).toLocaleString()} · 记录人 {item.recorded_by}
{item.supersedes_id ? ` · 更正自 ${item.supersedes_id}` : ""}</small></p>
</div>
</div>;
}