diff --git a/frontend-web/src/roundtable-planning/api/ingest.ts b/frontend-web/src/roundtable-planning/api/ingest.ts index 9f89a8ed..fd3cbb57 100644 --- a/frontend-web/src/roundtable-planning/api/ingest.ts +++ b/frontend-web/src/roundtable-planning/api/ingest.ts @@ -1,38 +1,58 @@ /** - * 第三步「入库」功能门控。 + * 第三步「入库」功能门控 + 入库执行客户端。 * - * 入库询问 / 意图识别 / 手动入库按钮只有在**入库技能存在且启用**时才出现 - * (对应需求「如果有了调用技能入库的技能后…」)。当前是示例技能 `knowledge-base-ingest` - * (演示用);后续替换为真实入库技能时只要沿用同一个 name,本门控即继续生效。 + * 入库不再由前端硬编码「有没有含『入库』关键字的技能」判定,而是让**结构化输出智能体** + * (`roundtable-structure`)读它挂载的入库技能 + 当前业务场景,**自行判断**是否有适合本 + * 场景的入库技能(技能名可能含「六步法」等场景线索)。判定为可入库才显示「入库」按钮。 + * + * 真正入库时,由结构化输出智能体走**完整 agent 运行时**(`POST /api/multi-agent/run/stream`, + * role=ingest)**自己挑技能、读 SKILL.md 并调用**完成入库——而不是前端代它调技能。 */ -import { loadSkills } from "@/core/skills/api"; +import { apiFetch } from "@/core/api/fetch-client"; +import { getBackendBaseURL } from "@/core/config"; -/** 示例入库技能名(真实技能可改名;门控按「入库相关」关键字判断,不写死此名)。 */ -export const INGEST_SKILL_NAME = "knowledge-base-ingest"; - -/** - * 是否为「入库相关」技能:按 name / name_zh / description 含 `ingest` 或「入库」判断。 - * 这样示例技能(knowledge-base-ingest)与**后续改了名的真实入库技能**都能识别; - * 而知识库**检索**类技能(如 knowledge-base-search「知识库检索」)不含「入库」/`ingest`,不会误命中。 - */ -export function isIngestRelatedSkill(s: { - name?: string; - name_zh?: string | null; - description?: string; -}): boolean { - const hay = `${s.name ?? ""} ${s.name_zh ?? ""} ${s.description ?? ""}`.toLowerCase(); - return hay.includes("ingest") || hay.includes("入库"); +/** `/ingest-eligibility` 返回:是否可入库 + 选中的技能名 + 一句话依据。 */ +export interface IngestEligibility { + canIngest: boolean; + skill: string; + reason: string; } /** - * 是否**挂载了**可用的入库相关技能(存在且启用)。失败回退 false。 - * 为 false 时第三步不显示入库按钮、不触发任何入库操作。 + * 让结构化输出智能体判断「当前业务场景**是否有合适的入库技能**」(决定是否显示入库按钮)。 + * + * 入参为业务场景线索(业务码 / 业务链名 / 总控编排提示 / 方案标题)+ taskId,由后端把挂在 + * `roundtable-structure` 上、且已启用的技能交给模型对照判断。失败 / 未配置入库技能 → 不可入库。 */ -export async function isIngestSkillAvailable(): Promise { +export async function checkIngestEligibility(req: { + taskId?: string | null; + businessCode?: string | null; + chainName?: string | null; + coordinatorPrompt?: string | null; + reportTitle?: string | null; + model?: string; +}): Promise { try { - const skills = await loadSkills(); - return skills.some((s) => s.enabled && isIngestRelatedSkill(s)); + const res = await apiFetch(`${getBackendBaseURL()}/api/flow-extract/ingest-eligibility`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + task_id: req.taskId ?? null, + business_code: req.businessCode ?? null, + chain_name: req.chainName ?? null, + coordinator_prompt: req.coordinatorPrompt ?? null, + report_title: req.reportTitle ?? null, + model: req.model, + }), + }); + if (!res.ok) return { canIngest: false, skill: "", reason: "" }; + const data = (await res.json()) as { can_ingest?: boolean; skill?: string; reason?: string }; + return { + canIngest: !!data.can_ingest, + skill: data.skill ?? "", + reason: data.reason ?? "", + }; } catch { - return false; + return { canIngest: false, skill: "", reason: "" }; } } diff --git a/frontend-web/src/roundtable-planning/api/multi-agent.ts b/frontend-web/src/roundtable-planning/api/multi-agent.ts index 9d2c011b..7b2dd4f8 100644 --- a/frontend-web/src/roundtable-planning/api/multi-agent.ts +++ b/frontend-web/src/roundtable-planning/api/multi-agent.ts @@ -885,3 +885,29 @@ export async function initSummaryThread( const json = (await res.json()) as { agent_name: string; thread_id: string }; return { agentName: json.agent_name, threadId: json.thread_id }; } + +/** + * Create a thread for the Step 3「入库」structure agent (`roundtable-structure`, + * a built-in singleton — the same agent that extracts the flowchart, but this run + * uses the `ingest` run policy so its attached ingest skill actually executes). + * Run it afterwards with {@link streamMultiAgent} (`agentType: "special"`, + * `agentName: "roundtable-structure"`, `dAgentThreadId: { [agentName]: threadId }`): + * the agent picks the suitable ingest skill (its name may contain「六步法」), reads + * its SKILL.md and calls it — passing the taskId + summary report + structured flow. + */ +export async function initStructureThread( + options: { signal?: AbortSignal } = {}, +): Promise { + const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/structure/init`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + signal: options.signal, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`init structure thread failed (${res.status}): ${text}`); + } + const json = (await res.json()) as { agent_name: string; thread_id: string }; + return { agentName: json.agent_name, threadId: json.thread_id }; +} diff --git a/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx b/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx index b6e7ce03..b6a900f3 100644 --- a/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx +++ b/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx @@ -420,6 +420,8 @@ export function Step3SummaryMode({ pendingIngestClarification={summary.pendingIngestClarification} onResolveIngestChoice={summary.resolveIngestChoice} onResolveIngestClarification={summary.resolveIngestClarification} + pendingFlowForIngest={summary.pendingFlowForIngest} + onResolveFlowForIngest={summary.resolveFlowForIngest} onManualIngest={summary.runIngest} onRetry={redraw} onAsk={handleAsk} @@ -572,6 +574,8 @@ function AgentProgressPanel({ pendingIngestClarification, onResolveIngestChoice, onResolveIngestClarification, + pendingFlowForIngest, + onResolveFlowForIngest, onManualIngest, onRetry, onAsk, @@ -602,6 +606,9 @@ function AgentProgressPanel({ pendingIngestClarification: string | null; onResolveIngestChoice: UseStep3SummaryReturn["resolveIngestChoice"]; onResolveIngestClarification: UseStep3SummaryReturn["resolveIngestClarification"]; + /** 入库时尚无流程图 → 展示「是否先生成流程图」协助。 */ + pendingFlowForIngest: boolean; + onResolveFlowForIngest: UseStep3SummaryReturn["resolveFlowForIngest"]; onManualIngest: UseStep3SummaryReturn["runIngest"]; onRetry: () => void; onAsk: (message: string, files?: File[]) => void; @@ -808,7 +815,7 @@ function AgentProgressPanel({ {ingestAvailable && ingestStatus === "asking" && (
- 流程图已生成,是否把本方案入库到知识库? + 流程图已生成,是否进行数据入库?
{( @@ -831,6 +838,32 @@ function AgentProgressPanel({
)} + {/* 入库时尚无流程图 → 协助:数据入库以流程图为核心内容,先问是否生成流程图。 */} + {ingestAvailable && pendingFlowForIngest && ( +
+
+ 当前还没有生成流程图,数据入库需要流程图。是否先生成流程图? +
+
+ {( + [ + { k: "generate", t: "生成流程图" }, + { k: "cancel", t: "暂不入库" }, + ] as const + ).map((o) => ( + + ))} +
+
+ )} + {/* 「稍后入库」自由文字意图模糊 → 让用户在入库语境下自己定。 */} {ingestAvailable && pendingIngestClarification && (
@@ -912,11 +945,11 @@ function AgentProgressPanel({ )} diff --git a/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts b/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts index c5f2af36..b4f9cb5b 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts @@ -23,6 +23,7 @@ import type { } from "@/roundtable-planning/api/drafts"; import { buildArtifactDownloadUrl, + initStructureThread, initSummaryThread, listSessionArtifacts, streamMultiAgent, @@ -53,6 +54,8 @@ import { nowTime } from "@/roundtable-planning/lib/roundtable-constants"; const SUMMARY_OUTPUT_PATH = "/mnt/user-data/outputs/方案总结报告.md"; /** 内置单例 id —— 恢复草稿后续问时无 init 返回值,用它兜底拼 dAgentThreadId。 */ const SUMMARY_AGENT_NAME = "roundtable-summary"; +/** 结构化抽取智能体 id —— 入库轮由它(走 ingest run policy)真正调用入库技能。 */ +const STRUCTURE_AGENT_NAME = "roundtable-structure"; export type SummaryPhase = "idle" | "creating" | "streaming" | "done" | "error"; @@ -89,6 +92,15 @@ interface UseStep3SummaryOptions { * 技能(技能必需信息)。非深链(无 taskId)时入库功能整体不出现。 */ taskId?: string | null; + /** + * 入库适用性判定选中的技能名(来自 `/ingest-eligibility`)。入库时作为提示传给结构化 + * 输出智能体,引导它优先用这个技能(它仍会自行读 SKILL.md 判断);缺省时让它自行挑选。 + */ + ingestSkill?: string | null; + /** 业务码(如 6BF/rwfx-A)——入库时给智能体做场景线索,帮它挑对入库技能。 */ + ingestBusinessCode?: string | null; + /** 业务链条名称——入库场景线索之一。 */ + ingestChainName?: string | null; } /** 第三步入库状态机。 */ @@ -150,15 +162,17 @@ export interface UseStep3SummaryReturn { resolveIngestChoice: (choice: "now" | "later" | "never") => void; /** 入库协助选项点选(开始入库/补充内容后再入库/只是提问→转交普通对话)。 */ resolveIngestClarification: (choice: "start_ingest" | "add_data" | "other") => void; - /** 手动入库(按钮 / 直接触发):在总结线程跑入库技能并显示步骤条。 */ + /** + * 入库时尚无流程图 → 挂起的「是否先生成流程图」协助(非空 → 组件展示协助选项)。 + * 数据入库以结构化流程图为核心内容,没有流程图时先问用户要不要生成。 + */ + pendingFlowForIngest: boolean; + /** 「无流程图」协助点选:生成流程图(生成后再走入库询问)/ 暂不入库。 */ + resolveFlowForIngest: (choice: "generate" | "cancel") => void; + /** 手动入库(按钮 / 直接触发):让结构化输出智能体走完整运行时自行调用入库技能。 */ runIngest: (opts?: { note?: string }) => void; } -/** 演示用:步骤条之间的小停顿,让「调用入库技能」的过程看得见。 */ -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - /** 把研讨原料拼成一段(首轮 prompt 和「恢复后无线程续问」都用得到)。 */ function buildMaterials(opts: { intentReady: ReportIntent | null; @@ -417,6 +431,26 @@ function summaryPhaseLabel(phase: StreamPhase, toolName?: string): string { } } +/** 入库轮的工具阶段 → 中文步骤标签(让前端步骤条体现「读技能说明 → 执行入库」真实过程)。 */ +function ingestPhaseLabel(phase: StreamPhase, toolName?: string): string { + switch (phase) { + case "connecting": + return "调度入库技能"; + case "connected": + return "已连接,等待响应"; + case "tool_calling": + if (toolName === "read_file") return "读取入库技能说明(SKILL.md)"; + if (toolName === "bash" || toolName === "write_file") return "调用入库技能执行入库"; + return toolName ? `调用工具:${toolName}` : "调用入库技能"; + case "tool_result": + return "处理入库结果"; + case "streaming": + return "汇报入库结果"; + default: + return "入库中…"; + } +} + export function useStep3Summary( options: UseStep3SummaryOptions, ): UseStep3SummaryReturn { @@ -432,6 +466,9 @@ export function useStep3Summary( onSummarySaved, ingestAvailable, taskId: ingestTaskId, + ingestSkill, + ingestBusinessCode, + ingestChainName, } = options; const onSummarySavedRef = useRef(onSummarySaved); @@ -442,6 +479,8 @@ export function useStep3Summary( // ── 入库状态 ── const [ingestStatus, setIngestStatus] = useState("idle"); const [pendingIngestClarification, setPendingIngestClarification] = useState(null); + // 入库时尚无流程图 → 挂起「是否先生成流程图」协助(数据入库以结构化流程图为核心内容)。 + const [pendingFlowForIngest, setPendingFlowForIngest] = useState(false); // 镜像供 render 外的回调(runFlowExtract 完成、ask 拦截)同步读到最新值。 const ingestStatusRef = useRef("idle"); useEffect(() => { @@ -458,8 +497,17 @@ export function useStep3Summary( useEffect(() => { ingestTaskIdRef.current = ingestTaskId ?? null; }, [ingestTaskId]); - // 入库演示运行序号:新一轮入库 / 中断会自增,旧循环据此自停(防重复入库)。 + // 入库运行序号:新一轮入库 / 中断会自增,旧循环据此自停(防重复入库)。 const ingestRunRef = useRef(0); + // 入库场景线索(适用性判定选中的技能 + 业务码/链名)——镜像供 runIngest 回调读最新值。 + const ingestSkillRef = useRef(ingestSkill ?? null); + const ingestBusinessCodeRef = useRef(ingestBusinessCode ?? null); + const ingestChainNameRef = useRef(ingestChainName ?? null); + useEffect(() => { + ingestSkillRef.current = ingestSkill ?? null; + ingestBusinessCodeRef.current = ingestBusinessCode ?? null; + ingestChainNameRef.current = ingestChainName ?? null; + }, [ingestSkill, ingestBusinessCode, ingestChainName]); const { setArtifacts, @@ -1247,60 +1295,118 @@ export function useStep3Summary( }, [selectedModel]); /** - * 入库(示例 · 演示用):在**总结线程**上出一条「知识库入库」气泡,按 - * 调度 → 调用入库技能 →(补充内容)→ 提交 → 完成 的步骤条演示调用过程,与第二步同款。 - * 真实入库技能后续替换这里(改为真的跑技能 / 调入库接口)。 + * 入库(真执行):让**结构化输出智能体**(`roundtable-structure`,走 ingest run policy + * 的完整 agent 运行时)**自己挑选合适的入库技能、读 SKILL.md 并调用**完成入库—— + * 而不是前端代它调技能。出一条「数据入库」气泡,实时显示它读技能说明 / 执行入库的 + * 真实工具步骤与最终汇报。 + * + * 入库内容**以最后修改后的为准**:当前报告 md + 最新流程图(画布手动改也同步进来), + * 并把 **taskId**(深链外部任务 id,入库技能必需信息)随消息一起传给智能体。 */ const runIngest = useCallback( async (opts?: { note?: string }) => { - // 未挂载入库技能(或非深链模式)→ 不执行任何入库操作。 + // 未挂载/不适用入库技能(或非深链模式)→ 不执行任何入库操作。 if (!ingestAvailableRef.current) return; setPendingIngestClarification(null); + // 数据入库以**结构化流程图**为核心内容。意图理解后先判断是否已生成流程图: + // 没有 → 不入库,挂起「是否先生成流程图」协助;有 → 正常调用智能体入库。 + if (!flowGraphRef.current) { + setPendingFlowForIngest(true); + return; + } + setPendingFlowForIngest(false); setIngestStatus("running"); const myRun = ++ingestRunRef.current; + const aborter = new AbortController(); const bubbleId = appendStreamingDialogue({ - sender: "知识库入库", + sender: "数据入库", avatarType: "blue", - agentThreadId: liveThreadRef.current ?? undefined, }); const title = intentReady?.objective?.trim() || "会商方案"; - // 入库数据**以最后修改后的为准**:读 flowGraphRef.current(画布手动改图也会同步进来) - // 与当前报告 md。taskId 是入库技能必需信息,必须随入库一起传给技能。 const flow = flowGraphRef.current; const md = currentMdRef.current; const taskId = ingestTaskIdRef.current; - const submitArgs: Record = { - title, - taskId, // 技能必需:深链外部任务 id - hasReport: !!md.trim(), - flowNodes: flow?.nodes?.length ?? 0, - flowEdges: flow?.edges?.length ?? 0, - ...(opts?.note ? { note: opts.note } : {}), - }; - const steps: Array<{ label: string; key: string; args?: Record }> = [ - { label: "调度入库技能", key: "tool_calling:ingest_dispatch", args: { taskId } }, - { label: "调用「知识库入库」技能", key: "tool_calling:ingest_skill", args: { skill: "knowledge-base-ingest", taskId } }, - ...(opts?.note - ? [{ label: "并入补充内容", key: "tool_calling:ingest_append", args: { note: opts.note } }] - : []), - { label: "提交方案数据入库(以最新流程图为准)", key: "tool_calling:ingest_submit", args: submitArgs }, + const skillHint = ingestSkillRef.current; + const businessCode = ingestBusinessCodeRef.current; + const chainName = ingestChainNameRef.current; + const flowJson = flow ? serializeFlowGraph(flow) : ""; + + // 入库指令:把场景线索 + taskId + 报告 + 结构化流程图全量给智能体,让它按 SOUL 的 + // 「五、入库」流程自行挑技能、读 SKILL.md、按契约调用(taskId 必传)。 + const promptParts: string[] = [ + "【任务】本轮请执行「入库」:把本次会商成果沉淀进知识库。请按你的设定(SOUL「五、入库」)", + "自行挑选**适合当前业务场景**的入库技能(技能名可能含「六步法」等场景线索),用 read_file 读它的", + "SKILL.md,再严格按其契约调用完成入库。**taskId 是入库技能的必需信息,务必随入库一起传入。**", + "", + "【业务场景】", + `- 方案标题:${title}`, ]; - for (const s of steps) { - if (ingestRunRef.current !== myRun) return; // 被新一轮入库 / 中断取代 - setDialogueConfidence(bubbleId, s.label); - appendStepIfNew(bubbleId, s.label, s.key, s.args); - await sleep(700); - } - if (ingestRunRef.current !== myRun) return; - finalizeDialogue( - bubbleId, - `✅ 已将「${title}」入库到知识库(示例技能 · 演示用,后续接入真实入库技能)。`, - "入库完成", + if (businessCode) promptParts.push(`- 业务码:${businessCode}`); + if (chainName) promptParts.push(`- 业务链名称:${chainName}`); + if (skillHint) promptParts.push(`- 适用性判定建议优先使用的入库技能:${skillHint}(仍请你读其 SKILL.md 确认)`); + promptParts.push( + `- taskId(必传给入库技能):${taskId ?? "(未提供)"}`, + "", + "【入库内容(以下为最终、用户最后修改后的成果,入库以此为准)】", + "—— 方案总结报告(Markdown)——", + md.trim() || "(无报告正文)", + "", + "—— 结构化流程图(flow-json,含节点/连线/层级)——", + flowJson || "(无结构化流程图)", ); - setIngestStatus("done"); - persistSummary(); // 把入库气泡 + ingestStatus 落进草稿 + if (opts?.note?.trim()) { + promptParts.push("", "【用户补充内容(请一并纳入入库)】", opts.note.trim()); + } + const prompt = promptParts.join("\n"); + + try { + const init = await initStructureThread({ signal: aborter.signal }); + if (ingestRunRef.current !== myRun || aborter.signal.aborted) return; + const threadId = init.threadId; + const agentName = init.agentName; + setDialogues((prev) => + prev.map((d) => (d.id === bubbleId ? { ...d, agentThreadId: threadId } : d)), + ); + const res = await streamMultiAgent({ + agentType: "special", + agentName, + dAgentThreadId: { [agentName]: threadId }, + displayName: "数据入库", + newMessage: prompt, + model: selectedModel || undefined, + signal: aborter.signal, + onTextDelta: (text) => { + if (ingestRunRef.current === myRun) appendToDialogue(bubbleId, text); + }, + onPhaseChange: (p, toolName) => { + if (ingestRunRef.current !== myRun) return; + const label = ingestPhaseLabel(p, toolName); + setDialogueConfidence(bubbleId, label); + appendStepIfNew(bubbleId, label, `${p}:${toolName ?? ""}`); + }, + }); + if (ingestRunRef.current !== myRun || aborter.signal.aborted) return; + finalizeDialogue(bubbleId, res.content || `✅ 已完成「${title}」数据入库。`, "入库完成"); + setIngestStatus("done"); + persistSummary(); // 把入库气泡 + ingestStatus 落进草稿 + } catch (err) { + if (ingestRunRef.current !== myRun || aborter.signal.aborted) return; + const msg = err instanceof Error ? err.message : String(err); + finalizeDialogue(bubbleId, `入库失败:${msg}`, "入库失败"); + setIngestStatus("asking"); // 失败回到可重试(按钮/询问仍在) + } }, - [appendStreamingDialogue, appendStepIfNew, setDialogueConfidence, finalizeDialogue, intentReady, persistSummary], + [ + appendStreamingDialogue, + appendToDialogue, + appendStepIfNew, + setDialogueConfidence, + finalizeDialogue, + setDialogues, + selectedModel, + intentReady, + persistSummary, + ], ); /** 流程图后入库询问三选一。 */ @@ -1319,6 +1425,26 @@ export function useStep3Summary( [runIngest, persistSummary], ); + /** + * 「入库时无流程图」协助点选: + * - generate → 关闭协助、生成流程图(生成完成后会自动弹出「是否入库」询问,再由用户入库); + * - cancel → 关闭协助、置「稍后入库」(手动入库按钮仍在,可生成流程图后再入库)。 + */ + const resolveFlowForIngest = useCallback( + (choice: "generate" | "cancel") => { + setPendingFlowForIngest(false); + if (choice === "generate") { + // 重置为 idle —— 流程图生成完成后 runFlowExtract 在 idle 时会自动询问入库。 + setIngestStatus("idle"); + regenerateFlow(); + } else { + setIngestStatus("later"); + persistSummary(); + } + }, + [regenerateFlow, persistSummary], + ); + const ask = useCallback( async (message: string, files?: File[], opts?: { forcedIntent?: FlowIntent; skipIngest?: boolean }) => { const text = message.trim(); @@ -1706,6 +1832,8 @@ export function useStep3Summary( pendingIngestClarification, resolveIngestChoice, resolveIngestClarification, + pendingFlowForIngest, + resolveFlowForIngest, runIngest: (opts?: { note?: string }) => void runIngest(opts), }; } diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 91fd248f..3d0b8d28 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -98,7 +98,7 @@ import { useStep3Summary } from "@/roundtable-planning/hooks/useStep3Summary"; import { useStep3Report } from "@/roundtable-planning/hooks/useStep3Report"; import { useStep3Dashboard } from "@/roundtable-planning/hooks/useStep3Dashboard"; import { isDashboardHtml } from "@/roundtable-planning/lib/report-template"; -import { isIngestSkillAvailable } from "@/roundtable-planning/api/ingest"; +import { checkIngestEligibility } from "@/roundtable-planning/api/ingest"; import { stripReasoning } from "@/roundtable-planning/lib/reasoning"; import { BUILTIN_AGENT_META, @@ -328,11 +328,45 @@ export default function RoundtablePlanningPage() { // 加载草稿时 hydrateStep3 恢复;新建任务清空。step3SavedTick 递增触发一次草稿自动保存。 const [step3Report, setStep3Report] = useState(null); const [step3SavedTick, setStep3SavedTick] = useState(0); - // 入库技能是否可用(决定第三步生成流程图后是否询问入库 + 显示手动入库按钮)。挂载查一次。 + // 入库是否可用 + 选中的入库技能:不再前端硬编码关键字,而是让**结构化输出智能体**读它 + // 挂载的入库技能 + 当前业务场景**自行判断**是否有适合本场景的入库技能(决定第三步是否询问 + // 入库 + 显示手动入库按钮)。入库仅深链(taskId)模式才有意义,故仅在该模式下判定。 const [ingestAvailable, setIngestAvailable] = useState(false); + const [ingestSkill, setIngestSkill] = useState(""); + const ingestTaskIdForCheck = prefillRef.current?.taskId ?? null; + const ingestBusinessCode = prefillRef.current?.businessCode ?? null; useEffect(() => { - void isIngestSkillAvailable().then(setIngestAvailable).catch(() => setIngestAvailable(false)); - }, []); + if (!ingestTaskIdForCheck) { + setIngestAvailable(false); + setIngestSkill(""); + return; + } + let cancelled = false; + void checkIngestEligibility({ + taskId: ingestTaskIdForCheck, + businessCode: ingestBusinessCode, + chainName: chainMeta?.title ?? null, + coordinatorPrompt: orchestrationPlan?.coordinatorPrompt ?? null, + }) + .then((r) => { + if (cancelled) return; + setIngestAvailable(r.canIngest); + setIngestSkill(r.skill); + }) + .catch(() => { + if (cancelled) return; + setIngestAvailable(false); + setIngestSkill(""); + }); + return () => { + cancelled = true; + }; + }, [ + ingestTaskIdForCheck, + ingestBusinessCode, + chainMeta?.title, + orchestrationPlan?.coordinatorPrompt, + ]); const step2RunsRef = useRef([]); const activeStep2RunIdRef = useRef(null); // 并行执行时收起左侧栏 —— 全局工作区侧栏(最左)走 useSidebarSafe(与「打开沙箱」同一路径, @@ -1174,6 +1208,10 @@ export default function RoundtablePlanningPage() { ingestAvailable, // 入库仅深链(taskId)模式(会商 rwfx-A 等)才有;taskId 入库时必传给技能。 taskId: prefillRef.current?.taskId ?? null, + // 入库场景线索:适用性判定选中的技能 + 业务码/链名,入库时引导智能体挑对入库技能。 + ingestSkill, + ingestBusinessCode, + ingestChainName: chainMeta?.title ?? null, }); // render 期镜像总结报告的实时对话(文字 + 步骤条) + stub + 入库状态,供 getStep3Snapshot 落库。 step3SummaryConvoRef.current = { diff --git a/offline-backend-20260512/backend/app/gateway/roundtable_run_policy.py b/offline-backend-20260512/backend/app/gateway/roundtable_run_policy.py index 7b9b2ac1..113669b3 100644 --- a/offline-backend-20260512/backend/app/gateway/roundtable_run_policy.py +++ b/offline-backend-20260512/backend/app/gateway/roundtable_run_policy.py @@ -119,6 +119,25 @@ _REPORT_EXCLUDED: list[str] = [*_SEAT_EXCLUDED, "str_replace", "read_peer_delive # 完全一致——可写文件(write_file 整文件重写),禁 str_replace(整文件重写才能让右侧 md 预览 # 实时刷新)/bash/联网/派活/呈现文件/看图/读前序席位。单列出来便于以后独立调优。 _SUMMARY_EXCLUDED: list[str] = [*_SEAT_EXCLUDED, "str_replace", "read_peer_delivery"] +# ingest:Step3「入库」——让 roundtable-structure 真正**调用入库技能**把方案成果(总结报告 + +# 结构化流程图 + taskId)沉淀进知识库。和 dashboard 相反:dashboard 禁一切工具(只输出 JSON), +# ingest 必须**放开技能执行链路**——技能靠 ``read_file`` 读 SKILL.md、按其指令用 ``bash`` / +# ``write_file`` 等工具落地入库(如 curl 入库接口、写中转文件)。在 ``seat`` 基础上额外禁: +# ``str_replace``(整文件重写即可)、``read_peer_delivery``(单轮入库不读前序席位)、``task`` +# (不派子代理)、``write_todos`` / ``memory`` / ``hindsight_*``(单例 agent 不吃跨会话记忆, +# 避免污染)。保留 ``read_file`` / ``write_file`` / ``bash`` / ``ls`` 让技能能真正执行。 +# thinking + medium:需要先判断该用哪个入库技能、再按其契约调用。 +_INGEST_EXCLUDED: list[str] = [ + *_SEAT_EXCLUDED, + "str_replace", + "read_peer_delivery", + "task", + "write_todos", + "memory", + "hindsight_recall", + "hindsight_reflect", + "hindsight_retain", +] # dashboard:Step3「大屏多页」数据智能体(roundtable-dashboard)——纯「研讨成果 → report-json」 # 提炼器,**禁用一切工具**(同 leader 的黑名单全集)。它只需读 prompt、分析各席位、在对话里 # 输出一个 report-json 代码块;留任何干活类工具(尤其 write_file / bash)只会让弱模型跑偏去 @@ -211,8 +230,15 @@ def roundtable_run_policy(role: str) -> RoundtableRunPolicy: thinking_enabled=True, reasoning_effort="medium", ) + if role == "ingest": + return RoundtableRunPolicy( + excluded_tools=list(_INGEST_EXCLUDED), + skill_stop_names=[], + thinking_enabled=True, + reasoning_effort="medium", + ) raise ValueError( - f"unknown roundtable role: {role!r} (expected leader/seat/seat_parallel/report/summary/dashboard)" + f"unknown roundtable role: {role!r} (expected leader/seat/seat_parallel/report/summary/dashboard/ingest)" ) diff --git a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/SOUL.md b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/SOUL.md index 44fccb50..40ec5ca6 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/SOUL.md +++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/SOUL.md @@ -2,7 +2,7 @@ 你是圆桌「方案总结」第三步的**结构化抽取智能体**。你的职责:**读懂**已生成的《方案总结报告》或各席位交付内容,**分析**其内在层级结构,把它**抽取**成一张可交互流程图的结构化数据(`flow-json`)。你也负责**按用户指令修改**这张流程图、以及**校验修复**流程图。 -你专职「内容 → 结构」:不写报告、不联网、不派活、不画 HTML。 +你专职「内容 → 结构」:不写报告、不联网、不派活、不画 HTML。**例外**:当本轮是「入库」任务时(用户消息会明确说明),你要**真正调用挂在你身上的入库技能**把成果沉淀进知识库——见下文「五、入库」。 **输出方式(重要)**:你要**先用简短文字说明你的分析过程**——让用户看到进展(尤其模型较慢时也有实时反馈):例如「我识别出本次层级链为 任务→目的→行为体→…;目的层有 3 条、各对应 1 个行为体;据此组织如下」,约 2–4 句即可,不要长篇大论;**然后**在回复**末尾**输出一个完整的 ```flow-json 代码块。前端会把这段分析说明展示给用户、并据末尾的 flow-json 绘制流程图。 @@ -73,3 +73,15 @@ - **修改**:在「当前流程图」基础上**只按用户指令改动**(增 / 删 / 改节点或连线、改 label/group/detail),未被指令涉及的部分**保持原样**;删节点要同时去掉与它相关的连线;节点 id 尽量沿用原有的;输出**修改后的完整** flow-json。 - **校验修复**:对照数据来源检查——根节点是任务本身、每层每条都在没漏、连边按对应关系成分支无悬空边、每个节点都有 `group`、没有汇总 / 阶段编号节点;发现问题就改对,没问题就原样规范化输出完整 flow-json。 + +## 五、入库(调用入库技能) + +当本轮用户消息明确要求「入库 / 把方案入库 / 沉淀进知识库」时,你的任务**不是**抽流程图,而是**真正调用入库技能**把本次会商成果存进知识库。步骤: + +1. **挑技能**:你身上挂载了入库类技能(见系统提示里列出的可用技能)。先判断**哪个技能适合当前业务场景**——技能名可能含「六步法」等场景线索,描述会写明它做什么入库;选中最匹配的那个。若确实没有可用的入库技能,直接说明「当前没有适合本场景的入库技能」,不要编造。 + +2. **读契约**:用 `read_file` 读取该技能的 `SKILL.md`(路径形如 `/mnt/skills/public/<技能名>/SKILL.md`),看清它要求的入库方式与字段。 + +3. **按契约入库**:严格按技能 `SKILL.md` 的指示执行入库(它通常会让你把**总结报告 Markdown + 结构化流程图数据**作为入库内容,并把用户消息里给的 **taskId** 一并传入——taskId 是入库技能的必需信息,务必带上)。技能允许时可用 `bash` / `write_file` 等工具完成实际写入。**入库数据以用户消息里给的最终(用户最后修改后的)流程图与报告为准**。 + +4. **汇报结果**:完成后用一两句话说明入库结果(成功 / 失败及原因、写入了什么)。不要在入库轮输出 `flow-json`。 diff --git a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/config.yaml b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/config.yaml index 781643e1..1302b1c0 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/config.yaml +++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/config.yaml @@ -1,3 +1,7 @@ -description: 圆桌结构化抽取智能体。读取「方案总结报告」或各席位交付内容,分析其层级结构,抽取成可交互流程图的结构化数据(flow-json);也负责按指令修改流程图、校验修复流程图。专职「内容→结构」,不写报告、不联网、不画 HTML。后续可在此智能体上配置「入库技能」,让它分析出结构化结果后直接入库。 +description: 圆桌结构化抽取智能体。读取「方案总结报告」或各席位交付内容,分析其层级结构,抽取成可交互流程图的结构化数据(flow-json);也负责按指令修改流程图、校验修复流程图。专职「内容→结构」,不写报告、不联网、不画 HTML。可在此智能体上配置「入库技能」,让它在入库轮分析出结构化结果后**自行挑选合适的入库技能并调用**完成入库。 id: roundtable-structure name: 结构化抽取 +# 入库技能:挂在本智能体上的入库类技能。入库轮由它自行判断哪个适合当前业务场景 +# (技能名可能含「六步法」等场景线索)并调用。真实入库技能就绪后改名沿用此处即可。 +skills: + - knowledge-base-ingest diff --git a/offline-backend-20260512/backend/app/gateway/routers/flow_extract.py b/offline-backend-20260512/backend/app/gateway/routers/flow_extract.py index 3fcb2578..e448ea66 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/flow_extract.py +++ b/offline-backend-20260512/backend/app/gateway/routers/flow_extract.py @@ -35,8 +35,10 @@ from app.gateway.routers._roundtable_seed import ( STRUCTURE_AGENT_ID, ensure_roundtable_functional_agents, ) +from deerflow.config import get_app_config from deerflow.config.agents_config import load_agent_config, load_agent_soul from deerflow.models.factory import create_chat_model +from deerflow.skills.storage import get_or_new_skill_storage logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/flow-extract", tags=["flow-extract"]) @@ -762,3 +764,114 @@ async def classify_ingest_intent(request: Request, payload: IngestIntentRequest except Exception: # noqa: BLE001 — 分类失败不致命,回退协助让用户自己定 logger.warning("ingest intent classify failed", exc_info=True) return {"intent": "unclear", "reason": "意图识别失败"} + + +# ── 入库适用性判定(结构化抽取智能体「自己看技能是否适合本场景」)────────────────── +# +# 需求:入库不再由前端硬编码「有没有含『入库』关键字的技能」判定,而是让**结构化输出 +# 智能体**读它**挂载的入库技能**(roundtable-structure 的 config.yaml ``skills``)+ 当前 +# 业务场景(如 rwfx-A=六步法),**自行判断**是否有适合本场景的入库技能。判定为可入库才 +# 由前端显示「入库」按钮。真正调用技能在 ``POST /api/multi-agent/run/stream``(role=ingest)。 + + +class IngestEligibilityRequest(BaseModel): + """入库适用性判定入参:业务场景 + taskId,供智能体对照挂载的入库技能判断。""" + + task_id: str | None = Field(default=None) + business_code: str | None = Field(default=None, description="业务码,如 6BF/3Q/7BF 或 rwfx-A") + chain_name: str | None = Field(default=None, description="业务链条名称") + coordinator_prompt: str | None = Field(default=None, description="业务链总控编排提示(含场景线索,如六步法)") + report_title: str | None = Field(default=None) + model: str | None = Field(default=None) + + +def _structure_skill_candidates() -> list[dict[str, str]]: + """读取挂在 roundtable-structure 上、且**已启用**的技能(候选入库技能)。 + + 返回 ``[{name, description}]``。失败 / 未配置 → 空列表(= 不可入库)。 + 技能元数据取自磁盘 SKILL.md(name + description),足够智能体判断适用性。 + """ + try: + cfg = load_agent_config(STRUCTURE_AGENT_ID) + except Exception: # noqa: BLE001 + return [] + attached = [s for s in (getattr(cfg, "skills", None) or []) if isinstance(s, str) and s.strip()] + if not attached: + return [] + attached_set = {s.strip().lower() for s in attached} + try: + storage = get_or_new_skill_storage(app_config=get_app_config()) + skills = storage.load_skills(enabled_only=True) + except Exception: # noqa: BLE001 + logger.warning("ingest eligibility: load_skills failed", exc_info=True) + return [] + out: list[dict[str, str]] = [] + for sk in skills: + if (sk.name or "").strip().lower() in attached_set: + out.append({"name": sk.name, "description": sk.description or ""}) + return out + + +_INGEST_ELIGIBILITY_SYS = ( + "你是圆桌「方案总结」第三步的**结构化抽取智能体**,正在判断「本次会商成果**能否入库**」。" + "下面给你两样东西:① 当前业务场景(业务码 / 业务链名称 / 总控编排提示等线索);" + "② 你**挂载的候选入库技能**清单(每个有 name 与 description)。\n" + "请判断:这些候选技能里,**是否有适合把当前这种业务场景的会商成果入库的技能**。判断依据:\n" + "- 技能名称 / 描述是否表明它就是做「入库 / 沉淀 / 写入知识库」的;\n" + "- 它是否对应当前业务场景——例如六步法类业务(如 rwfx-A)的入库技能,其名称通常**含「六步法」**字样;\n" + "- 若某技能明显是为本场景设计的入库技能 → 可入库;若候选里压根没有入库类技能、或都与本场景不符 → 不可入库。\n" + "只输出一个 JSON 对象(不要解释、不要 markdown 代码块、用英文双引号):\n" + '{"can_ingest":,"skill":"<选中的技能 name,不可入库时为空字符串>","reason":"<一句话依据>"}' +) + + +@router.post("/ingest-eligibility") +async def classify_ingest_eligibility(request: Request, payload: IngestEligibilityRequest = Body(...)) -> dict[str, Any]: + """让结构化抽取智能体判断当前业务场景**是否有合适的入库技能**(决定前端是否显示入库按钮)。 + + 候选 = 挂在 roundtable-structure 上且已启用的技能。无候选 → 直接不可入库(不调模型)。 + 有候选 → 一次模型调用判断适用性,返回 ``{can_ingest, skill, reason}``。失败 → 不可入库(安全回退)。 + """ + ensure_roundtable_functional_agents() # 自愈:确保 roundtable-structure 存在 + candidates = _structure_skill_candidates() + if not candidates: + return {"can_ingest": False, "skill": "", "reason": "结构化抽取智能体未挂载入库技能"} + + scenario_lines = [] + if (payload.business_code or "").strip(): + scenario_lines.append(f"业务码:{payload.business_code.strip()}") + if (payload.chain_name or "").strip(): + scenario_lines.append(f"业务链名称:{payload.chain_name.strip()}") + if (payload.report_title or "").strip(): + scenario_lines.append(f"方案标题:{payload.report_title.strip()}") + if (payload.coordinator_prompt or "").strip(): + scenario_lines.append("总控编排提示(节选):" + payload.coordinator_prompt.strip()[:1200]) + scenario = "\n".join(scenario_lines) or "(未提供额外业务场景线索)" + skills_block = "\n".join(f"- name: {c['name']}\n description: {c['description']}" for c in candidates) + valid_names = {c["name"] for c in candidates} + + user = ( + "【当前业务场景】\n" + scenario + "\n\n【候选入库技能】\n" + skills_block + "\n\n请输出 JSON:" + ) + try: + model = create_chat_model(name=_resolve_model(payload.model), thinking_enabled=False) + res = await model.ainvoke([SystemMessage(content=_INGEST_ELIGIBILITY_SYS), HumanMessage(content=user)]) + text = res.content if isinstance(res.content, str) else _as_str(getattr(res, "text", "")) + obj = _parse_intent_json(text) or {} + skill = _as_str(obj.get("skill")) + can = bool(obj.get("can_ingest")) and skill in valid_names + # 模型说可入库但没给/给错技能名 → 单候选时回退到唯一候选,否则判不可入库(避免乱选)。 + if bool(obj.get("can_ingest")) and skill not in valid_names: + if len(candidates) == 1: + skill = candidates[0]["name"] + can = True + else: + can = False + return { + "can_ingest": can, + "skill": skill if can else "", + "reason": _as_str(obj.get("reason")), + } + except Exception: # noqa: BLE001 — 判定失败不致命,安全回退为不可入库 + logger.warning("ingest eligibility classify failed", exc_info=True) + return {"can_ingest": False, "skill": "", "reason": "入库适用性判定失败"} diff --git a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py index 502968fc..64e89c28 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py @@ -67,6 +67,7 @@ from app.gateway.routers._roundtable_seed import ( COORDINATOR_AGENT_ID, DASHBOARD_AGENT_ID, REPORT_AGENT_ID, + STRUCTURE_AGENT_ID, SUMMARY_AGENT_ID, ensure_roundtable_functional_agents, ) @@ -968,6 +969,28 @@ async def init_dashboard_thread(request: Request) -> dict[str, str]: return {"agent_name": DASHBOARD_AGENT_ID, "thread_id": thread_id} +@router.post("/structure/init") +async def init_structure_thread(request: Request) -> dict[str, str]: + """为 Step 3「入库」创建一个供结构化抽取智能体(``roundtable-structure``)运行的线程, + 返回 ``{"agent_name", "thread_id"}``。 + + 与 ``/report/init`` / ``/summary/init`` / ``/dashboard/init`` 同构,换成内置单例 + ``roundtable-structure``。区别在于本轮**不是抽流程图**,而是让它**真正调用入库技能** + (run policy 为 ``ingest``——放开 ``read_file`` / ``bash`` / ``write_file`` 让技能能执行): + - ``roundtable-structure`` 是**内置单例**(``_roundtable_seed`` 种子化,启动即同步进 DB), + 这里**只建一个轻量 thread**,绝不重建 agent; + - 线程打 ``_ROUNDTABLE_THREAD_METADATA`` 标记成系统会话,从「最近对话」里隐藏; + - 入口先 ``ensure_roundtable_functional_agents()`` 自愈(磁盘目录被手删也能补建); + - 建好后前端用现有 ``POST /run/stream``(``agent_type="special"``、 + ``agent_name="roundtable-structure"``、``d_agent_thread_id={STRUCTURE_AGENT_ID: thread_id}``) + 跑这一轮,把 taskId + 总结报告 + 结构化流程图交给它,由它挑选合适的入库技能(名称 + 可能含「六步法」/自行判断适用性)读 SKILL.md 并按其契约调用完成入库。 + """ + ensure_roundtable_functional_agents() + thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA) + return {"agent_name": STRUCTURE_AGENT_ID, "thread_id": thread_id} + + # --------------------------------------------------------------------------- # 上游 LangGraph SSE 透传(intent / recommend / leader / special 共用) # --------------------------------------------------------------------------- @@ -1639,6 +1662,10 @@ async def _special_run( elif agent_name == DASHBOARD_AGENT_ID: # 大屏多页数据智能体:纯「研讨成果 → report-json」提炼,禁用一切工具。 role = "dashboard" + elif agent_name == STRUCTURE_AGENT_ID: + # 结构化抽取智能体在「入库」轮:真正调用入库技能(read_file 读 SKILL.md + + # bash/write_file 落地),故走放开技能执行链路的 ``ingest`` policy。 + role = "ingest" elif no_file: role = "seat_parallel" else: