Files
mini-openclaw-v2/src/components/xingbang/ExperienceCenter.tsx
T
qiaoqichaoandClaude Code 2e7e19471c feat(v2): 纠偏第二批——接通历史经验加工闭环
覆盖 AC-50/51/52(BR-48/49、E-28)。审计确认的 P1-3 缺口"历史提炼与卡片
不在实时 API 调用图上"由此关闭:提炼、候选治理、卡片进方案全部有真实调用方。

后端:
- contracts:新增 E-28 EXTRACTION_UNAVAILABLE(运行级,不进 HTTP 信封)与
  KnowledgeExtraction 记录类型。契约层强制"失败不得带候选",
  AC-50「模型不可用⇒不产生候选」是类型约束而不是文档约定。
- services/experience_extraction.py(新):真实提炼入口。模型不可用或调用失败
  → 记 E-28 且 candidate_id 为空;不产半成品候选。传输纪律同 assistant_model
  (禁进程内代理、零重试),且 tools=[] 使"越权读材料"在结构上不可能。
  自然语言修订同样必须真实调用模型,没有"把中文猜成条件"的回退路径。
- services/knowledge_cards.py:新增 current_draft/revise/list_cards 与
  registry_conditions。修订只产草稿、保留 origin 与来源、升版本记 parent_version;
  test_cases 纳入可改字段——它是 component_evaluation 对自定义 Skill 的发布门禁,
  不开放则提炼出的候选永远卡在评测门外。
- services/project_assistant.py:enqueue 改用 review_plan.assemble(),
  让 T06-b 的依赖预检(E-19 可定位文案)与维度解析不再被绕过。
- api/prescreen.py:case-timeline、knowledge-extractions、knowledge-candidates
  (创建/修订/状态机)、review-plans(装配/状态机)。角色门禁与既有
  /components 入口一致;id 与数据域由服务端生成;按 id 的路由一律在 run() 内
  查归属,否则 KeyError 会变成 500 而不是 404。
- component_registry/review_plan:下沉 current_draft/version_key 与 list_plans。

前端:
- 新增 /experience-center 独立入口(BR-50)与 ExperienceCenter 组件;
  AgentNav 增加「历史经验加工」。
- originBadge 此前零调用方、.origin-* 无任何 CSS——守卫只 grep 库文件字符串,
  于是"测试通过但功能不存在"。本批补齐消费方与样式,并用左边框样式区分四种来源,
  保证去色仍可区分(AC-42/BR-48)。
- 守卫语义按 AC-55 调整:由"禁止新建路由"改为"禁止重复页面"
  (面板不得搬到独立路由、已合并页必须保持重定向),并新增
  experience-center-ui 守卫(已登记 verify.py,共 12 项检查)。

测试:新增 36 项(提炼 15 / 候选治理 15 / 卡片到运行 6),全量 unittest 无回归;
两次同项目运行对照证明卡片确实改变了核查结果,且 basis_ids 指向 卡片id@版本。

未闭合(记录在 17 的剩余缺口):真实模型提炼尚未实测(需端点);
界面未经浏览器点击验证;例外边界未进入可求值语言;规则类候选不在本批。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 00:31:42 +08:00

257 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {useEffect, useState} from "react";
import {AlertTriangle, CheckCircle2, FileText, HelpCircle, History, Pencil, RefreshCw, Send, ShieldCheck, Wrench} from "lucide-react";
import {PRESCREEN} from "@/lib/prescreen";
import {originBadge} from "@/lib/prescreen-run";
/** 历史经验加工中心(纠偏第二批 · BR-48 / BR-49 / BR-50、AC-50/51/52)。
*
* 挂在独立路由 /experience-center 上(BR-50:三个模块各有独立入口,不得只在旧页下叠加面板)。
* 复用 workspace.css 既有 class,**不新增样式表**;来源徽标用 prescreen-run.originBadge,
* 并配 .origin-* 样式与图标,满足"文字+图标+样式三者齐全、去色仍可区分"(AC-42/BR-48)。
*
* 三条界面纪律:
* 1. **失败就是失败**:提炼记录里的 E-28 原样显示,不把失败渲染成"已生成草稿"。
* 2. **来源不可洗**:历史提炼与人工创建用不同文字+图标+底色,修订后仍显示历史来源。
* 3. **不自批**:同一个身份既编辑又发布时不显示发布按钮(后端另有强制,界面不诱导违规)。
*/
const ORIGIN_ICONS: Record<string, any> = {
history: History, pencil: Pencil, "file-text": FileText, "help-circle": HelpCircle,
};
const CARD_STATUS: Record<string, string> = {
draft: "草稿", published: "已发布", rejected: "已拒绝", withdrawn: "已撤回",
};
// 组方案时的基座核查项:与种子方案一致,保证新方案能独立跑通(不是"只放卡片"的半成品方案)。
const BASE_SKILLS = ["corporate.consistency", "corporate.cashflow", "corporate.assets", "corporate.coverage"];
function OriginBadge({origin}: {origin: string}) {
const badge = originBadge(origin);
const Icon = ORIGIN_ICONS[badge.icon] || HelpCircle;
return <span className={`xb-origin ${badge.tone}`}><Icon size={13} aria-hidden="true"/>{badge.text}</span>;
}
export default function ExperienceCenter({project, identity, send}: any) {
const [timeline, setTimeline] = useState<any>(null);
const [timelineMissing, setTimelineMissing] = useState(false);
const [extractions, setExtractions] = useState<any[]>([]);
const [candidates, setCandidates] = useState<any[]>([]);
const [plans, setPlans] = useState<any[]>([]);
const [requirement, setRequirement] = useState("");
const [picked, setPicked] = useState<string[]>([]);
const [reviseFor, setReviseFor] = useState("");
const [reviseText, setReviseText] = useState("");
const [reviseNote, setReviseNote] = useState("");
const [casesFor, setCasesFor] = useState("");
const [casesText, setCasesText] = 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 || identity?.role === "reviewer" || identity?.role === "admin");
const canPublish = Boolean(identity?.preview || identity?.role === "reviewer");
// 不自批:提议/编辑的人不能看到自己的发布按钮
const mayPublish = (card: any) => canPublish && card.edited_by !== identity?.id;
async function refresh() {
try {
const [found, cards, planList] = await Promise.all([
send("GET", `${PRESCREEN}${path}/knowledge-extractions`),
send("GET", `${PRESCREEN}${path}/knowledge-candidates`),
send("GET", `${PRESCREEN}${path}/review-plans`),
]);
setExtractions(found.items || []);
setCandidates(cards.items || []);
setPlans(planList.items || []);
} catch (e: any) {setError(e.message);}
try {
setTimeline(await send("GET", `${PRESCREEN}${path}/case-timeline`));
setTimelineMissing(false);
} catch {setTimeline(null); setTimelineMissing(true);}
setLoaded(true);
}
useEffect(() => {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 events = timeline?.events || [];
const buildTimeline = () => act(
() => send("POST", `${PRESCREEN}${path}/case-timeline`),
"已从项目材料重建时间线;缺发生时间的事件按缺证显示,不推断日期。");
const requestExtraction = () => act(
() => send("POST", `${PRESCREEN}${path}/knowledge-extractions`, {requirement, event_ids: picked}),
"提炼作业已结束;请查看下方记录——失败会给出未完成原因。");
const revise = (card: any) => act(
() => send("PATCH", `${PRESCREEN}/knowledge-candidates/${encodeURIComponent(card.id)}`,
{requirement: reviseText, note: reviseNote}),
"已形成草稿修订;不会自动发布,也不改变已发布版本。");
const saveCases = (card: any) => {
let parsed: any;
try {parsed = JSON.parse(casesText);}
catch {setError("测试样本必须是合法 JSON 数组。"); return Promise.resolve(null);}
return act(
() => send("PATCH", `${PRESCREEN}/knowledge-candidates/${encodeURIComponent(card.id)}`,
{changes: {test_cases: parsed}, note: "补充独立测试样本"}),
"测试样本已保存到草稿;发布前必须能通过隔离评测。");
};
const transition = (card: any, action: string) => act(
() => send("POST", `${PRESCREEN}/knowledge-candidates/${encodeURIComponent(card.id)}/transitions`, {action}),
action === "evaluate" ? "已完成隔离评测。" : "已发布;后续运行可使用该版本。");
const assemble = (card: any) => act(
() => send("POST", `${PRESCREEN}${path}/review-plans`, {
name: `含「${card.title}」的预审方案`,
skills: [...BASE_SKILLS, card.id], rules: ["demo.coverage"],
template: "template.ai-pre-review", audience: ["manager"], run_kind: "pre_review",
budget: {timeout_seconds: 180, max_tool_calls: 18},
}),
"方案草稿已装配;需评测与独立发布后,新运行才会使用该卡片。");
const planTransition = (plan: any, action: string) => act(
() => send("POST", `${PRESCREEN}/review-plans/${encodeURIComponent(plan.id)}/transitions`, {action}),
action === "evaluate" ? "方案评测已完成。" : "方案已发布;新运行将使用其中的卡片版本。");
return <>
{error && <div role="alert" className="ra-alert error">{error}</div>}
{notice && <div role="status" className="ra-alert">{notice}</div>}
{!loaded && <p>正在读取历史过程…</p>}
<section className="xb-panel xb-wide">
<h2>① 来源:历史过程时间线</h2>
<p>时间线由项目材料**确定性**恢复,不调用模型、不编造经过。只有最终报告时会标"过程材料不完整",不生成虚构的问询顺序。</p>
<div className="xb-actions">
<button className="ra-button" disabled={busy} onClick={buildTimeline}><RefreshCw size={15}/>从项目材料重建时间线</button>
{timeline && <span className="ra-badge">{timeline.status === "partial" ? "过程材料不完整" : "已恢复"} · {events.length} 个事件</span>}
</div>
{timelineMissing && <div className="ra-empty"><h2>尚未重建时间线</h2><p>先重建时间线,提炼才有可追溯的来源。</p></div>}
{timeline?.warnings?.map((item: string, index: number) =>
<p className="xb-warning" key={index}><AlertTriangle size={13}/> {item}</p>)}
{events.map((event: any) => <div className="xb-finding" key={event.id}>
<div>
<label className="ra-field"><span>
<input type="checkbox" aria-label={`选择来源事件 ${event.id}`} disabled={busy}
checked={picked.includes(event.id)}
onChange={(e) => setPicked(e.target.checked ? [...picked, event.id] : picked.filter((id) => id !== event.id))}/>
{" "}{event.occurred_at ? new Date(event.occurred_at).toLocaleDateString() : "未标注发生时间(缺证)"} · {event.event_type}
</span></label>
<p>{event.question || "(无问询文本)"}</p>
{(event.judgment_before || event.judgment_after) &&
<p><small>{event.judgment_before || "—"} → {event.judgment_after || "—"}</small></p>}
</div>
</div>)}
</section>
<section className="xb-panel xb-wide">
<h2>② 提炼:用真实模型生成候选</h2>
<p>提炼会**实际调用**已配置的模型。模型不可用时明确记为未完成(E-28),并且**不产生任何候选草稿**——这里不会用一个固定返回值冒充提炼成功。</p>
<label className="ra-field"><span>提炼要求(可选,只约束本次加工,不成为正式制度)</span>
<input value={requirement} disabled={busy} placeholder="例如:重点整理现金流与利润不一致的核查方法"
onChange={(e) => setRequirement(e.target.value)}/>
</label>
<div className="xb-actions">
<button className="ra-button primary" disabled={busy || !timeline} onClick={requestExtraction}><Send size={15}/>请求提炼</button>
<span className="ra-badge">已选 {picked.length} 个来源事件{timeline ? "" : "(需先重建时间线)"}</span>
</div>
{!extractions.length && <p className="ra-note">还没有提炼记录。</p>}
{extractions.map((record: any) => <div className="xb-finding" key={record.id}>
<div>
<b>{record.status === "completed" ? "提炼完成" : "提炼未完成"} · {record.model || "未调用模型"}</b>
<p>提示版本 {record.prompt_version} · 来源事件 {record.source_event_ids.length} 个 · 时间 {new Date(record.created_at).toLocaleString()}</p>
{record.candidate_id && <p>候选草稿:<code>{record.candidate_id}</code></p>}
{record.degradations?.map((item: any, index: number) =>
<p className="xb-warning" key={index}><AlertTriangle size={13}/> {item.message}({item.code})</p>)}
</div>
</div>)}
</section>
<section className="xb-panel xb-wide">
<h2>③ 候选:专家修订、评测与发布</h2>
<p>修订只产生草稿,不直接生效;发布需要先通过隔离评测,并由**另一位**有发布资格的人操作。已发布版本不可覆盖,修订会升版本并保留父版本。</p>
{!candidates.length && <div className="ra-empty"><h2>尚无知识候选</h2><p>先提炼或人工新增一张候选卡片。</p></div>}
{candidates.map((card: any) => <div className="xb-finding" key={card.id}>
<div>
<b>{card.title}</b>
<p>
<OriginBadge origin={card.origin}/>
{" "}<span className="ra-badge">{CARD_STATUS[card.status] || card.status}</span>
{" "}<span className="ra-badge">v{card.version} · revision {card.revision}</span>
{" "}<span className="ra-badge">{card.data_domain === "demo" ? "演示数据域" : "正式数据域"}</span>
</p>
<p>{card.kind === "skill" ? "Skill(核查方法)" : card.kind} · 适用条件 {card.conditions.length} 条 · 例外 {card.exceptions.length} 条
{card.parent_version ? ` · 父版本 ${card.parent_version.version}` : ""}</p>
<p><small>编辑人 {card.edited_by}{card.reviewer_id ? ` · 审核人 ${card.reviewer_id}` : ""}</small></p>
<div className="xb-actions">
<button className="ra-button" disabled={busy} onClick={() => {setReviseFor(reviseFor === card.id ? "" : card.id); setReviseText(""); setReviseNote("");}}>
自然语言修改适用范围
</button>
<button className="ra-button" disabled={busy} onClick={() => {setCasesFor(casesFor === card.id ? "" : card.id); setCasesText("");}}>
补充独立测试样本
</button>
{canReview && <button className="ra-button" disabled={busy} onClick={() => transition(card, "evaluate")}>隔离评测</button>}
{mayPublish(card) && card.status !== "published" &&
<button className="ra-button primary" disabled={busy} onClick={() => transition(card, "publish")}>
<ShieldCheck size={15}/>审核发布
</button>}
{card.status === "published" && <button className="ra-button" disabled={busy} onClick={() => assemble(card)}>
<Wrench size={15}/>选入新方案(基座核查项 + 本卡片)
</button>}
</div>
{canPublish && card.edited_by === identity?.id &&
<p className="ra-note">本卡由你本人提议或编辑,因此不显示发布按钮:发布需要独立审核人(BR-42 / AC-43)。</p>}
{reviseFor === card.id && <div className="xb-task">
<label className="ra-field"><span>用自然语言说明要改什么(必须真实调用模型解析)</span>
<input value={reviseText} disabled={busy} placeholder="例如:只适用于设备类租赁,并增加关联交易例外"
onChange={(e) => setReviseText(e.target.value)}/>
</label>
<label className="ra-field"><span>变更说明(必填,会随修订留痕)</span>
<input value={reviseNote} disabled={busy} onChange={(e) => setReviseNote(e.target.value)}/>
</label>
<button className="ra-button" disabled={busy || !reviseText.trim() || !reviseNote.trim()} onClick={() => revise(card)}>形成草稿修订</button>
</div>}
{casesFor === card.id && <div className="xb-task">
<label className="ra-field"><span>独立测试样本(JSON 数组;自定义 Skill 的发布门禁要求)</span>
<textarea className="ra-code" value={casesText} disabled={busy}
placeholder='[{"name":"缺证样本","values":{"financial.operating_cashflow":null},"expected_outputs":{},"expected_checks":{"history.repayment.dependency":"unknown"}}]'
onChange={(e) => setCasesText(e.target.value)}/>
</label>
<button className="ra-button" disabled={busy || !casesText.trim()} onClick={() => saveCases(card)}>保存样本到草稿</button>
</div>}
</div>
</div>)}
</section>
<section className="xb-panel xb-wide">
<h2>④ 方案:让卡片真正影响后续核查</h2>
<p>卡片只有在被选入**已发布方案**后才会影响运行;运行结果会冻结并记录卡片版本,核查项可追溯到 <code>卡片id@版本</code>。</p>
{!plans.length && <p className="ra-note">还没有项目自建方案。在上方已发布卡片上点"选入新方案"即可装配。</p>}
{plans.map((plan: any) => <div className="xb-finding" key={`${plan.id}@${plan.version}`}>
<div>
<b>{plan.name || plan.id}</b>
<p><span className="ra-badge">{CARD_STATUS[plan.status] || plan.status}</span> v{plan.version} · 核查项 {plan.skills.length} 个</p>
<p><small>选入的卡片:{plan.skills.filter((key: string) => !BASE_SKILLS.includes(key)).join("、") || "(无)"}</small></p>
</div>
{canReview && plan.status !== "published" &&
<button className="ra-button" disabled={busy} onClick={() => planTransition(plan, "evaluate")}>评测方案</button>}
{canPublish && plan.status !== "published" &&
<button className="ra-button primary" disabled={busy} onClick={() => planTransition(plan, "publish")}>发布方案</button>}
{plan.status === "published" && <span className="ra-badge"><CheckCircle2 size={13}/> 可用于新运行</span>}
</div>)}
</section>
</>;
}