覆盖 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>
257 lines
16 KiB
TypeScript
257 lines
16 KiB
TypeScript
"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>
|
||
</>;
|
||
}
|