"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 = { history: History, pencil: Pencil, "file-text": FileText, "help-circle": HelpCircle, }; const CARD_STATUS: Record = { 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 ; } export default function ExperienceCenter({project, identity, send}: any) { const [timeline, setTimeline] = useState(null); const [timelineMissing, setTimelineMissing] = useState(false); const [extractions, setExtractions] = useState([]); const [candidates, setCandidates] = useState([]); const [plans, setPlans] = useState([]); const [requirement, setRequirement] = useState(""); const [picked, setPicked] = useState([]); 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, 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 &&
{error}
} {notice &&
{notice}
} {!loaded &&

正在读取历史过程…

}

① 来源:历史过程时间线

时间线由项目材料**确定性**恢复,不调用模型、不编造经过。只有最终报告时会标"过程材料不完整",不生成虚构的问询顺序。

{timeline && {timeline.status === "partial" ? "过程材料不完整" : "已恢复"} · {events.length} 个事件}
{timelineMissing &&

尚未重建时间线

先重建时间线,提炼才有可追溯的来源。

} {timeline?.warnings?.map((item: string, index: number) =>

{item}

)} {events.map((event: any) =>

{event.question || "(无问询文本)"}

{(event.judgment_before || event.judgment_after) &&

{event.judgment_before || "—"} → {event.judgment_after || "—"}

}
)}

② 提炼:用真实模型生成候选

提炼会**实际调用**已配置的模型。模型不可用时明确记为未完成(E-28),并且**不产生任何候选草稿**——这里不会用一个固定返回值冒充提炼成功。

已选 {picked.length} 个来源事件{timeline ? "" : "(需先重建时间线)"}
{!extractions.length &&

还没有提炼记录。

} {extractions.map((record: any) =>
{record.status === "completed" ? "提炼完成" : "提炼未完成"} · {record.model || "未调用模型"}

提示版本 {record.prompt_version} · 来源事件 {record.source_event_ids.length} 个 · 时间 {new Date(record.created_at).toLocaleString()}

{record.candidate_id &&

候选草稿:{record.candidate_id}

} {record.degradations?.map((item: any, index: number) =>

{item.message}({item.code})

)}
)}

③ 候选:专家修订、评测与发布

修订只产生草稿,不直接生效;发布需要先通过隔离评测,并由**另一位**有发布资格的人操作。已发布版本不可覆盖,修订会升版本并保留父版本。

{!candidates.length &&

尚无知识候选

先提炼或人工新增一张候选卡片。

} {candidates.map((card: any) =>
{card.title}

{" "}{CARD_STATUS[card.status] || card.status} {" "}v{card.version} · revision {card.revision} {" "}{card.data_domain === "demo" ? "演示数据域" : "正式数据域"}

{card.kind === "skill" ? "Skill(核查方法)" : card.kind} · 适用条件 {card.conditions.length} 条 · 例外 {card.exceptions.length} 条 {card.parent_version ? ` · 父版本 ${card.parent_version.version}` : ""}

编辑人 {card.edited_by}{card.reviewer_id ? ` · 审核人 ${card.reviewer_id}` : ""}

{canReview && } {mayPublish(card) && card.status !== "published" && } {card.status === "published" && }
{canPublish && card.edited_by === identity?.id &&

本卡由你本人提议或编辑,因此不显示发布按钮:发布需要独立审核人(BR-42 / AC-43)。

} {reviseFor === card.id &&
} {casesFor === card.id &&