diff --git a/frontend-web/src/roundtable-planning/api/flow-extract.ts b/frontend-web/src/roundtable-planning/api/flow-extract.ts index ebf2188e..503832d3 100644 --- a/frontend-web/src/roundtable-planning/api/flow-extract.ts +++ b/frontend-web/src/roundtable-planning/api/flow-extract.ts @@ -11,6 +11,41 @@ import { apiFetch } from "@/core/api/fetch-client"; import { getBackendBaseURL } from "@/core/config"; import type { FlowGraphData } from "@/roundtable-planning/lib/flow-graph"; +/** 第三步输入框意图分类结果(与后端 /intent 对应)。 */ +export type FlowIntent = "regenerate_report" | "modify_flow" | "regenerate_flow" | "qa" | "unclear"; + +/** + * 识别第三步输入框消息的意图(一次模型调用)。失败回退 "unclear"(前端发起澄清)。 + * 参考 ai_writing 的一次性 JSON 意图识别范式。 + */ +export async function classifyFlowIntent(req: { + message: string; + hasReport: boolean; + hasFlow: boolean; + model?: string; + signal?: AbortSignal; +}): Promise<{ intent: FlowIntent; reason: string }> { + try { + const res = await apiFetch(`${getBackendBaseURL()}/api/flow-extract/intent`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: req.message, + has_report: req.hasReport, + has_flow: req.hasFlow, + model: req.model, + }), + signal: req.signal, + }); + if (!res.ok) return { intent: "unclear", reason: "" }; + const data = (await res.json()) as { intent?: string; reason?: string }; + const intent = (data.intent ?? "unclear") as FlowIntent; + return { intent, reason: data.reason ?? "" }; + } catch { + return { intent: "unclear", reason: "" }; + } +} + export interface FlowExtractRequest { /** 报告 Markdown(优先数据来源)。 */ reportMd?: string | null; diff --git a/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx b/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx index 6b9e0725..e5c31fae 100644 --- a/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx +++ b/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx @@ -412,6 +412,8 @@ export function Step3SummaryMode({ mdAvailable={!!mdArtifact} flowPhase={summary.flowPhase} onRegenerateFlow={summary.regenerateFlow} + pendingClarification={summary.pendingFlowClarification} + onResolveClarification={summary.resolveFlowClarification} onRetry={redraw} onAsk={handleAsk} onPause={pause} @@ -556,6 +558,8 @@ function AgentProgressPanel({ mdAvailable, flowPhase, onRegenerateFlow, + pendingClarification, + onResolveClarification, onRetry, onAsk, onPause, @@ -576,6 +580,9 @@ function AgentProgressPanel({ flowPhase: UseStep3SummaryReturn["flowPhase"]; /** 仅重新生成流程图(模型挂了切模型后重试)。 */ onRegenerateFlow: () => void; + /** 意图模糊待澄清的原始消息(非空 → 展示选项让用户定)。 */ + pendingClarification: string | null; + onResolveClarification: UseStep3SummaryReturn["resolveFlowClarification"]; onRetry: () => void; onAsk: (message: string, files?: File[]) => void; onPause: () => void; @@ -748,6 +755,35 @@ function AgentProgressPanel({ )} {!atBottom && } + + {/* 意图模糊 → 让用户自己定(不自作主张) */} + {pendingClarification && ( +
+
+ 没太理解你的意思,你是想—— +
+
+ {( + [ + { k: "regenerate_report", t: "重新生成报告" }, + { k: "modify_flow", t: "修改流程图" }, + { k: "regenerate_flow", t: "重新生成流程图" }, + { k: "qa", t: "只是提问" }, + ] as const + ).map((o) => ( + + ))} +
+
+ )} +
{/* 已选附件预览(发送时随指令上传到总结线程,供智能体读取) */} {askFiles.length > 0 && ( diff --git a/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts b/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts index e54ca5fa..3b385db5 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts @@ -41,7 +41,7 @@ import type { StubAiMessage, } from "@/roundtable-planning/hooks/useStep2Orchestration"; import { detectFlowIntent, extractFlowGraph, hasFlowFence, serializeFlowGraph, type FlowGraphData } from "@/roundtable-planning/lib/flow-graph"; -import { streamFlowExtract } from "@/roundtable-planning/api/flow-extract"; +import { classifyFlowIntent, streamFlowExtract, type FlowIntent } from "@/roundtable-planning/api/flow-extract"; import { nowTime } from "@/roundtable-planning/lib/roundtable-constants"; /** 报告输出文件的固定虚拟路径(恢复历史快照时用它重建 stub)。 */ @@ -88,6 +88,10 @@ export interface UseStep3SummaryReturn { regenerateFlow: () => void; /** 手动编辑流程图(Phase 3:画布上改/删节点后回写 + 持久化)。 */ setFlowGraph: (next: FlowGraphData) => void; + /** 意图模糊时挂起的原始消息(非空 → 组件展示「重做报告/改图/重画/提问」选项让用户定)。 */ + pendingFlowClarification: string | null; + /** 用户在澄清里点选意图后执行。 */ + resolveFlowClarification: (choice: FlowIntent) => void; summaryThreadId: string | null; dialogues: Step2Dialogue[]; seatStubMessages: Record; @@ -417,6 +421,8 @@ export function useStep3Summary( useEffect(() => { flowGraphRef.current = flowGraph; }, [flowGraph]); + // 意图模糊时挂起待澄清的原始消息(组件渲染选项让用户自己定)。 + const [pendingFlowClarification, setPendingFlowClarification] = useState(null); const [summaryThreadId, setSummaryThreadId] = useState(null); const [dialogues, setDialogues] = useState([]); const [seatStubMessages, setSeatStubMessages] = useState< @@ -1149,7 +1155,7 @@ export function useStep3Summary( ]); const ask = useCallback( - async (message: string, files?: File[]) => { + async (message: string, files?: File[], opts?: { forcedIntent?: FlowIntent }) => { const text = message.trim(); const hasFiles = !!(files && files.length > 0); if (!text && !hasFiles) return; @@ -1160,38 +1166,54 @@ export function useStep3Summary( abort(); if (wasBusy) freezeStreaming("已打断"); - // 流程图相关消息 → 走后端「抽取/编辑 + 校验」端点(流式显示过程 → 校验后绘制), - // 不再在总结线程吐 flow-json 做实时绘制。判定: - // · 已有流程图 且(显式 flow 关键词 或 上一轮主产物是流程图)→ **问答修改**(带当前图+指令,Phase 2); - // · 还没有流程图 但显式要流程图 → **首次生成**。 - // 普通报告修改 / 问答(无 flow 上下文)仍走下方总结线程路径。 - const flowIntentNow = detectFlowIntent(text); - const curGraph = flowGraphRef.current; - if (flowIntentNow || (curGraph && flowActiveRef.current)) { - appendUserDialogue(text); - const flowAborter = new AbortController(); - aborterRef.current = flowAborter; - if (curGraph) { - await runFlowExtract(flowAborter, { instruction: text, currentGraph: curGraph }); - } else { - await runFlowExtract(flowAborter); - } - return; + // 用户气泡**立刻上屏**(不等意图分类 / 模型返回,避免连接卡顿时显得卡)——对齐第一/二步。 + // 后续各分支都不再重复追加用户气泡。forcedIntent(澄清后点选复跑)时原气泡已在、跳过避免重复。 + if (!opts?.forcedIntent) { + const fileNote = hasFiles ? `(附 ${files!.length} 个文件)` : ""; + appendUserDialogue((text || "(上传了参考文件)") + fileNote); } - // 意图引导分三档(智能体按语义最终裁决,前端只给上下文,不做硬分类): - // ① 显式命中结构化/流程图关键词 → 直出 flow-json、不写 md; - // ② 没命中关键词但上一轮主产物是流程图 → 流程图上下文引导(笼统修改默认改图,太模糊则反问); - // ③ 普通报告语境 → 语义理解修改/提问/结构化意图,太模糊则反问。 - const flowIntent = detectFlowIntent(text); - const flowContext = !flowIntent && flowActiveRef.current; - const askInstruction = flowIntent - ? FLOW_ASK_INSTRUCTION - : flowContext - ? FLOW_CONTEXT_ASK_INSTRUCTION - : SEMANTIC_ASK_INSTRUCTION; - // 流程图语境轮:不主动弹 md 沙箱预览(用户多半要看的是画布;真写了文件预览仍会自动打开)。 - const flowRound = flowIntent || flowContext; + // 意图识别(参考 ai_writing 一次性 JSON 范式):先判断用户想 重做报告 / 改流程图 / + // 重画流程图 / 提问 / 模糊待澄清,再分流——不再用关键词硬路由(避免「说重做报告却出流程图」)。 + // 带附件时跳过分类(附件多用于报告 Q&A/改报告);澄清后用户点选则用 forcedIntent 跳过分类。 + if (text && !hasFiles) { + let intent: FlowIntent | undefined = opts?.forcedIntent; + if (!intent) { + const r = await classifyFlowIntent({ + message: text, + hasReport: !!currentMdRef.current.trim(), + hasFlow: !!flowGraphRef.current, + model: selectedModel || undefined, + }); + intent = r.intent; + } + if (intent === "unclear") { + setPendingFlowClarification(text); // 组件渲染选项让用户自己定 + return; + } + if (intent === "regenerate_flow" || intent === "modify_flow") { + const flowAborter = new AbortController(); + aborterRef.current = flowAborter; + const cur = flowGraphRef.current; + if (intent === "modify_flow" && cur) { + await runFlowExtract(flowAborter, { instruction: text, currentGraph: cur }); + } else { + await runFlowExtract(flowAborter); + } + return; + } + // intent === "regenerate_report" | "qa" → 落到下方总结线程续问路径: + // **追加**用户气泡 + 在原线程上让智能体重写报告 / 增量改 / 回答(**不清空历史**)。 + // regenerate_report 不再走 run()(run 会 setDialogues([]) 清空,导致"覆盖聊天记录、 + // 不显示用户输入"的 bug);改报告/重写报告由总结智能体在续问里整文件重写。 + // intent === "qa" → 落到下方总结线程问答路径(只答不改产物)。 + } + + // 走到这里只剩 regenerate_report / qa(含带附件场景)—— 流程图操作已在上面意图分流处理。 + // 一律走报告/问答语义引导(让总结智能体在原线程上重写报告 / 增量改 / 回答), + // **不再**在总结线程里产 flow-json(流程图统一由后端抽取端点负责)。 + const askInstruction = SEMANTIC_ASK_INSTRUCTION; + const flowRound = false; runningRef.current = true; setError(null); @@ -1226,8 +1248,7 @@ export function useStep3Summary( })); } - const fileNote = hasFiles ? `(附 ${files!.length} 个文件)` : ""; - appendUserDialogue((text || "(上传了参考文件)") + fileNote); + // 用户气泡已在 ask 开头立刻追加(不等模型/分类返回),这里不再重复。 const bubbleId = appendStreamingDialogue({ sender: "方案总结报告智能体", avatarType: "blue", @@ -1287,6 +1308,7 @@ export function useStep3Summary( seatDeliveries, consensusText, coordinatorPrompt, + selectedModel, abort, freezeStreaming, appendUserDialogue, @@ -1296,6 +1318,17 @@ export function useStep3Summary( ], ); + /** 澄清后用户点选意图 → 用 forcedIntent 复跑(跳过意图分类)。 */ + const resolveFlowClarification = useCallback( + (choice: FlowIntent) => { + const msg = pendingFlowClarification; + setPendingFlowClarification(null); + if (!msg) return; + void ask(msg, undefined, { forcedIntent: choice }); + }, + [pendingFlowClarification, ask], + ); + /** 暂停当前生成:中断在途请求 + 把流式气泡收尾为「已暂停」+ 状态回 done。 */ const pause = useCallback(() => { if (!runningRef.current) return; @@ -1413,6 +1446,8 @@ export function useStep3Summary( flowPhase, regenerateFlow, setFlowGraph: updateFlowGraph, + pendingFlowClarification, + resolveFlowClarification, summaryThreadId, dialogues, seatStubMessages, diff --git a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py index 1f0f42cc..166fc90f 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py +++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py @@ -49,8 +49,8 @@ Python 三引号字符串,导致「磁盘上的验证版」与「代码里的内 from __future__ import annotations import logging +from collections.abc import Iterable from pathlib import Path -from typing import Iterable from deerflow.config.paths import get_paths @@ -74,6 +74,10 @@ SUMMARY_AGENT_ID = "roundtable-summary" # 旧版「复用 report agent + 简单粗暴字符串抽取」改为专职智能体 + 健壮抽取(见 # roundtable_orchestrator.report_json)。 DASHBOARD_AGENT_ID = "roundtable-dashboard" +# Step 3「结构化抽取」用的智能体:读「方案总结报告」/ 各席位交付,分析层级结构 → 产出 flow-json +# (也负责按指令改图 / 校验修复)。供 /api/flow-extract 调用;以后可在它身上配置「入库技能」, +# 让它分析出结构化结果后直接入库。与 report/summary 并存的另一内置单例。 +STRUCTURE_AGENT_ID = "roundtable-structure" # 验证版 SOUL.md + config.yaml 的源码资源目录(随 git 发布)。每个子目录名即 agent id, # 内含该 agent 的 config.yaml 与 SOUL.md,是 seeder 落盘时字节级复制的来源。 @@ -90,6 +94,7 @@ _FUNCTIONAL_AGENT_IDS: tuple[str, ...] = ( REPORT_AGENT_ID, SUMMARY_AGENT_ID, DASHBOARD_AGENT_ID, + STRUCTURE_AGENT_ID, ) # seeder 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。 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 new file mode 100644 index 00000000..c7c83b5b --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/SOUL.md @@ -0,0 +1,55 @@ +# 结构化抽取智能体(roundtable-structure) + +你是圆桌「方案总结」第三步的**结构化抽取智能体**。你的职责:**读懂**已生成的《方案总结报告》或各席位交付内容,**分析**其内在层级结构,把它**抽取**成一张可交互流程图的结构化数据(`flow-json`)。你也负责**按用户指令修改**这张流程图、以及**校验修复**流程图。 + +你专职「内容 → 结构」:不写报告、不联网、不派活、不画 HTML。 + +**输出方式(重要)**:你要**先用简短文字说明你的分析过程**——让用户看到进展(尤其模型较慢时也有实时反馈):例如「我识别出本次层级链为 任务→目的→行为体→…;目的层有 3 条、各对应 1 个行为体;据此组织如下」,约 2–4 句即可,不要长篇大论;**然后**在回复**末尾**输出一个完整的 ```flow-json 代码块。前端会把这段分析说明展示给用户、并据末尾的 flow-json 绘制流程图。 + +> 说明:本轮要做哪件事(首次抽取 / 校验修复 / 按指令修改)由发给你的用户消息指明;数据来源(报告全文 / 各席位交付 / 当前流程图)也在用户消息里给全。你只需专注分析内容、产出结构化结果。 + +## 一、输出契约(flow-json 形状) + +在前述简短分析说明之后,于回复**末尾**输出一个 ```flow-json 代码块,形状如下(示例仅示意结构,勿照抄内容): + +```flow-json +{"title":"流程图标题","nodes":[ + {"id":"n1","label":"任务简短名","type":"start","group":"任务","detail":"任务目标"}, + {"id":"n2","label":"目的核心短语","type":"task","group":"目的","detail":"名称+理由要点(2-4句,单行)","from":["n1"]}, + {"id":"n3","label":"行为体核心短语","type":"role","group":"行为体","detail":"名称+理由要点","from":["n2"]} +]} +``` + +字段: +- `id`:n1/n2… 唯一短串; +- `label`:≤12 字,节点标题,取该条目核心短语; +- `type`:取 `start/end/stage/task/decision/risk/deliverable/role` 之一,**仅决定配色**; +- `group`:**该节点所在的层级名称**(如 任务 / 目的 / 行为体 / 预期行为 / 驱动因素 / 叙事),**必填**——画布按它给节点打分类标签; +- `detail`:该条目的**名称 + 理由 / 说明**,要点表达完整、约 2–4 句即可(不必逐字照抄),**必须写成一行**——内部不要出现真实换行,需要分隔用「;」或「/」(避免 JSON 里出现裸换行); +- `from`:前驱节点 id 列表,只引用**前面已出现**的节点(也可用顶层 `edges:[{from,to,label}]`,两种都支持)。 + +节点按先后排列、输出**全量**数据、全程简体中文。 + +## 二、画法(严格遵守) + +① 根节点 n1 = 本次任务本身(报告 / 任务总览里的任务名称),`label` 用任务简短名,`group` 填「任务」,**不要**用「任务」「目标」这种泛称当 `label`; + +② 从第二层起按层级逐层展开:每个层级在报告 / 交付里通常列了**多条具体条目**(如 目的层有 目的1 / 目的2 / 目的3、行为体层有各自对应的行为体,后面还有 预期行为·驱动因素、叙事…),把**每一条具体条目各画成一个节点**,**不要**把一整层合并成一个笼统节点; + +③ 每个节点的 `group` 填它所在层级的名称(取自报告里该层级的真实名称); + +④ 连边严格按报告里的**对应关系**串成分支:根任务 → 各「目的」;每个「目的」→ 它对应的「行为体」;每个「行为体」→ 对应的「预期行为·驱动因素」→ 对应的「叙事」……每一组各成一条自上而下的分支(报告里有几组就有几条分支,组与组之间不要混连); + +⑤ **务必把每个层级的每一条都输出成节点、不要漏层、不要只输出开头一两个节点就停**(典型规模约 13–16 个节点); + +⑥ **只画**这些层级实体节点;**不要**把「综合分析 / 核心洞察 / 执行路线 / 建议 / 结论」以及「阶段①②③④」等章节名 / 阶段名 / 汇总段落画成节点;节点 `label` 也**绝不能**是「阶段1 / 阶段2 / 第N阶段」这类阶段编号。 + +## 三、层级顺序 + +- 若数据来源里给了「业务链总控编排提示」并写明了层级链(如 任务 → 目的 → 行为体 → 预期行为 → 驱动因素 → 叙事),**优先按它的层级**组织; +- 否则按给出的「默认层级」;都没有时,以「任务」为根、按报告 / 交付的逻辑结构合理分层。 + +## 四、修改 / 校验时 + +- **修改**:在「当前流程图」基础上**只按用户指令改动**(增 / 删 / 改节点或连线、改 label/group/detail),未被指令涉及的部分**保持原样**;删节点要同时去掉与它相关的连线;节点 id 尽量沿用原有的;输出**修改后的完整** flow-json。 +- **校验修复**:对照数据来源检查——根节点是任务本身、每层每条都在没漏、连边按对应关系成分支无悬空边、每个节点都有 `group`、没有汇总 / 阶段编号节点;发现问题就改对,没问题就原样规范化输出完整 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 new file mode 100644 index 00000000..781643e1 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/config.yaml @@ -0,0 +1,3 @@ +description: 圆桌结构化抽取智能体。读取「方案总结报告」或各席位交付内容,分析其层级结构,抽取成可交互流程图的结构化数据(flow-json);也负责按指令修改流程图、校验修复流程图。专职「内容→结构」,不写报告、不联网、不画 HTML。后续可在此智能体上配置「入库技能」,让它分析出结构化结果后直接入库。 +id: roundtable-structure +name: 结构化抽取 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 e2e0c89e..2d463c19 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/flow_extract.py +++ b/offline-backend-20260512/backend/app/gateway/routers/flow_extract.py @@ -31,6 +31,11 @@ from fastapi.responses import StreamingResponse from langchain_core.messages import HumanMessage, SystemMessage from pydantic import BaseModel, Field +from app.gateway.routers._roundtable_seed import ( + STRUCTURE_AGENT_ID, + ensure_roundtable_functional_agents, +) +from deerflow.config.agents_config import load_agent_config, load_agent_soul from deerflow.models.factory import create_chat_model logger = logging.getLogger(__name__) @@ -58,6 +63,19 @@ class FlowExtractRequest(BaseModel): instruction: str | None = Field(default=None) +class FlowIntentRequest(BaseModel): + """第三步输入框意图识别:判断用户想 重做报告 / 改流程图 / 重画流程图 / 提问 / 模糊待澄清。""" + + message: str = "" + has_report: bool = False + has_flow: bool = False + model: str | None = Field(default=None) + + +# 意图枚举(与前端路由一一对应)。 +_INTENTS = {"regenerate_report", "modify_flow", "regenerate_flow", "qa", "unclear"} + + # ── flow-json 容错解析(Python 版,对齐前端 lib/flow-graph.ts)────────────── _FENCE = "```flow-json" @@ -362,49 +380,73 @@ def _hierarchy_hint(req: FlowExtractRequest) -> str: return "层级顺序:以「任务」为根,按报告/交付的逻辑结构合理分层展开。" +# 兜底系统提示:当 roundtable-structure 的 SOUL 缺失时用它(与 SOUL 同款契约)。 +_DEFAULT_SYSTEM = ( + "你是「结构化抽取智能体」。职责:读懂《方案总结报告》/ 各席位交付内容,分析其层级结构," + "抽取成可交互流程图的结构化数据(flow-json);也按指令改图、校验修复。" + "请**先用简短文字(2-4句)说明分析过程**(识别的层级链、每层条目数、分支对应)让用户看到进展," + "**再**在回复末尾输出一个完整的 ```flow-json 代码块(除这段简短说明外不写长篇正文)。全程简体中文。\n" + + _SHAPE + + "\n" + + _STRUCTURE +) + + +def _system_prompt() -> str: + """结构化抽取的系统提示 = roundtable-structure 智能体的 SOUL(可在智能体管理页编辑、 + 未来挂入库技能);SOUL 缺失则回退内置 _DEFAULT_SYSTEM。""" + soul = load_agent_soul(STRUCTURE_AGENT_ID) + return soul.strip() if soul and soul.strip() else _DEFAULT_SYSTEM + + +def _resolve_model(payload_model: str | None) -> str | None: + """模型优先级:前端所选(payload) > 智能体配置的 model > None(用 config 默认模型)。""" + if payload_model: + return payload_model + try: + cfg = load_agent_config(STRUCTURE_AGENT_ID) + if cfg and cfg.model: + return cfg.model + except Exception: # noqa: BLE001 + pass + return None + + def _extract_messages(req: FlowExtractRequest) -> list[Any]: - sys = ( - "你是「流程图结构化抽取器」。你的唯一产物是一张流程图的 flow-json 数据(节点+连线)," - "不是文字报告、不是章节、不是结论清单。只输出一个 ```flow-json 代码块,不要写任何其它正文。全程简体中文。\n" - + _SHAPE - + "\n" - + _STRUCTURE + user = ( + _material(req) + + "\n\n" + + _hierarchy_hint(req) + + "\n\n请分析以上数据来源,按你的职责设定输出一个完整的 ```flow-json 代码块。" ) - user = _material(req) + "\n\n" + _hierarchy_hint(req) + "\n\n请据此输出一个完整的 ```flow-json 代码块。" - return [SystemMessage(content=sys), HumanMessage(content=user)] + return [SystemMessage(content=_system_prompt()), HumanMessage(content=user)] def _edit_messages(req: FlowExtractRequest) -> list[Any]: """Phase 2 问答修改:按用户指令改当前流程图(增/删/改节点或连线),其余保持不变。""" cur = json.dumps(req.current_graph or {}, ensure_ascii=False) - sys = ( - "你是「流程图编辑器」。下面给你 当前流程图(flow-json) + 数据来源 + 用户的修改指令。" - "请**按用户指令修改流程图**(可增/删/改节点、改连线、改 label/group/detail)," - "**未被指令涉及的部分保持原样**,输出**修改后的完整 flow-json**(只输出一个 ```flow-json 代码块,不要解释)。" - "节点 id 尽量沿用原有的;删节点要同时去掉与它相关的连线。全程简体中文。\n" - + _SHAPE - ) user = ( _material(req) + "\n\n===== 当前流程图(flow-json,待按指令修改)=====\n" + cur + "\n===== 当前流程图结束 =====\n\n用户修改指令:\n" + (req.instruction or "").strip() + + "\n\n请按指令修改这张流程图:未被指令涉及的部分保持原样、节点 id 尽量沿用、删节点连带删边," + + "输出**修改后的完整** ```flow-json 代码块。" ) - return [SystemMessage(content=sys), HumanMessage(content=user)] + return [SystemMessage(content=_system_prompt()), HumanMessage(content=user)] def _validate_messages(req: FlowExtractRequest, extracted: str) -> list[Any]: - sys = ( - "你是「流程图校验修复器」。下面给你 数据来源 和 一份已抽取的 flow-json。" - "请对照数据来源校验并修复这份 flow-json,输出**修复后**的完整 flow-json(只输出一个 ```flow-json 代码块,不要解释)。\n" - "校验要点:① 根节点是任务本身(group=任务);② 每个层级的每一条具体条目都在(没漏层、没漏条目);" - "③ 连边按对应关系成分支、无悬空边、根可达;④ 每个节点都有 group(层级名),label≤12字,detail 为单行的名称+理由要点;" - "⑤ 没有『综合分析/执行路线/结论』及『阶段编号』这类无关节点。发现问题就改对,没问题就原样规范化输出。\n" - + _SHAPE + user = ( + _material(req) + + "\n\n===== 已抽取的 flow-json(待校验修复)=====\n" + + extracted + + "\n===== 待校验结束 =====\n\n请对照数据来源校验并修复这份 flow-json:" + + "根节点是任务本身、每层每条都在没漏、连边按对应关系成分支无悬空边、每个节点都有 group、" + + "没有汇总/阶段编号节点;发现问题就改对、没问题就原样规范化,输出修复后的完整 ```flow-json 代码块。" ) - user = _material(req) + "\n\n===== 已抽取的 flow-json(待校验修复)=====\n" + extracted + "\n===== 待校验结束 =====" - return [SystemMessage(content=sys), HumanMessage(content=user)] + return [SystemMessage(content=_system_prompt()), HumanMessage(content=user)] # ── endpoint ───────────────────────────────────────────────────────────── @@ -431,6 +473,9 @@ def _chunk_parts(chunk: Any) -> tuple[str, str]: async def extract_flow_stream(request: Request, payload: FlowExtractRequest = Body(...)) -> StreamingResponse: """流式抽取流程图 JSON + 流式校验修复(抽取/校验过程含思考都实时吐前端)→ 返回最终结构化图。""" + ensure_roundtable_functional_agents() # 自愈:确保 roundtable-structure 的 SOUL/config 存在 + resolved_model = _resolve_model(payload.model) + async def gen() -> AsyncIterator[str]: try: # 带 instruction + current_graph → Phase 2 问答修改(按指令改当前图);否则首次抽取。 @@ -441,7 +486,7 @@ async def extract_flow_stream(request: Request, payload: FlowExtractRequest = Bo # ① 抽取 / 编辑(正文 + 思考(reasoning) 拼成 `…正文` 增量逐帧吐,前端实时显示) round1_messages = _edit_messages(payload) if is_edit else _extract_messages(payload) - model = create_chat_model(name=payload.model or None, thinking_enabled=True) + model = create_chat_model(name=resolved_model, thinking_enabled=True) extracted = "" think_open = False async for chunk in model.astream(round1_messages): @@ -469,7 +514,7 @@ async def extract_flow_stream(request: Request, payload: FlowExtractRequest = Bo validated = "" graph: dict[str, Any] | None = None try: - vmodel = create_chat_model(name=payload.model or None, thinking_enabled=True) + vmodel = create_chat_model(name=resolved_model, thinking_enabled=True) think_open = False async for chunk in vmodel.astream(_validate_messages(payload, extracted)): c, r = _chunk_parts(chunk) @@ -508,3 +553,73 @@ async def extract_flow_stream(request: Request, payload: FlowExtractRequest = Bo media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"}, ) + + +# ── 意图识别(一次性 JSON,参考 ai_writing/intent_parser 范式)───────────────── + + +def _parse_intent_json(text: str) -> dict[str, Any] | None: + """从模型输出里容错解析 {intent, reason}。strict → 去围栏/去尾逗号 → 平衡括号。""" + candidates: list[str] = [] + t = text.strip() + candidates.append(t) + m = re.search(r"```(?:json)?\s*([\s\S]+?)\s*```", t) + if m: + candidates.append(m.group(1).strip()) + s = t.find("{") + e = t.rfind("}") + if s >= 0 and e > s: + candidates.append(t[s : e + 1]) + for c in candidates: + for cand in (c, re.sub(r",(\s*[}\]])", r"\1", c)): + try: + obj = json.loads(cand) + if isinstance(obj, dict): + return obj + except Exception: # noqa: BLE001 + continue + return None + + +_INTENT_SYS = ( + "你是圆桌「方案总结」第三步输入框的**意图识别器**。第三步有两类产物:方案总结报告(Markdown) 和 结构化流程图。" + "用户在输入框发了一条消息,请判断他的**真实意图**,只输出一个 JSON 对象(不要解释、不要 markdown 代码块、用英文双引号):\n" + '{"intent":"<枚举>","reason":"<一句话依据>"}\n' + "intent 只能取其一:\n" + "- regenerate_report:想重新分析 / 重写【方案总结报告】(如「重新生成报告」「报告再写一版」「重新分析一下」);\n" + "- modify_flow:想修改**当前流程图**的某些节点/连线(如「把目的1改成X」「删掉第3个行为体」「加个验收节点」「流程图这里调整下」);\n" + "- regenerate_flow:想**重新生成整张**流程图(如「重新画流程图」「流程图重来」「重新结构化输出」);\n" + "- qa:只是提问 / 咨询,不需要改任何产物(如「为什么选 OTA 平台」「这个目的什么意思」);\n" + "- unclear:意图不明确、无法判断要改报告还是改流程图还是提问(如只说「改一下」「不对」「重来」而没指明对象)。\n" + "判断要点:结合下面给出的上下文(是否已有报告 / 是否已有流程图);当前已有流程图时,含「节点/连线/这一层/这个目的/行为体…改/删/加」之类多半是 modify_flow;" + "明确提到「报告」才是 regenerate_report;只有完全无法判断对象时才用 unclear。\n" + "示例:\n" + '「再帮我生成一次报告」→ {"intent":"regenerate_report","reason":"明确要求重新生成报告"}\n' + '「把目的1的名称改短点」→ {"intent":"modify_flow","reason":"要求修改流程图某节点"}\n' + '「流程图重新画一张」→ {"intent":"regenerate_flow","reason":"要求重画整张流程图"}\n' + '「OTA 平台为什么是直接行为体」→ {"intent":"qa","reason":"纯提问不改产物"}\n' + '「改一下」→ {"intent":"unclear","reason":"没说改报告还是流程图"}' +) + + +@router.post("/intent") +async def classify_flow_intent(request: Request, payload: FlowIntentRequest = Body(...)) -> dict[str, str]: + """识别第三步输入框消息的意图(一次模型调用)。失败 / 解析不出 → unclear,让前端发起澄清。""" + msg = (payload.message or "").strip() + if not msg: + return {"intent": "unclear", "reason": "空消息"} + ctx = f"当前上下文:{'已生成报告' if payload.has_report else '尚无报告'};{'已生成流程图' if payload.has_flow else '尚无流程图'}。" + try: + model = create_chat_model(name=_resolve_model(payload.model), thinking_enabled=False) + res = await model.ainvoke( + [SystemMessage(content=_INTENT_SYS), HumanMessage(content=ctx + "\n\n用户消息:" + msg + "\n\n请输出 JSON:")] + ) + text = res.content if isinstance(res.content, str) else _as_str(getattr(res, "text", "")) + obj = _parse_intent_json(text) or {} + intent = _as_str(obj.get("intent")).lower() + if intent not in _INTENTS: + intent = "unclear" + return {"intent": intent, "reason": _as_str(obj.get("reason"))} + except Exception: # noqa: BLE001 — 分类失败不致命,回退澄清让用户自己定 + logger.warning("flow intent classify failed", exc_info=True) + return {"intent": "unclear", "reason": "意图识别失败"}