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>
This commit is contained in:
2026-10-01 00:31:42 +08:00
co-authored by Claude Code
parent 350146d9bc
commit 2e7e19471c
20 changed files with 2195 additions and 14 deletions
+2 -1
View File
@@ -1,7 +1,7 @@
"use client";
import {useEffect, useState} from "react";
import {BarChart3, Bot, Inbox, LayoutDashboard, Settings2} from "lucide-react";
import {BarChart3, Bot, History, Inbox, LayoutDashboard, Settings2} from "lucide-react";
import IdentitySelector from "./IdentitySelector";
import "./navigation.css";
@@ -45,6 +45,7 @@ export default function AgentNav({active,project,identity:provided}:{active:stri
<a href="/" className={active==="assistant"?"selected":""}><Bot size={16}/>业务助手</a>
<a href={projectLink("/xingbang-workbench",project)} className={active==="overview"?"selected":""}><LayoutDashboard size={16}/>项目工作台</a>
{canUsePool&&<a href="/review-pool" className={active==="pool"?"selected":""}><Inbox size={16}/>项目评审池</a>}
<a href={projectLink("/experience-center",project)} className={active==="experience"?"selected":""}><History size={16}/>历史经验加工</a>
<a href={projectLink("/operations-center",project)} className={active==="time"?"selected":""}><BarChart3 size={16}/>业务时效</a>
{canConfigure&&<a href="/data-access" className={active==="risk"?"selected":""}><Settings2 size={16}/>配置治理</a>}
<span className="xb-nav-spacer"/>
@@ -0,0 +1,256 @@
"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>
</>;
}