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:
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user