feat: 第三步流程图后端抽取智能体 + 意图识别 + 问答/画布编辑

- 新增专职「结构化抽取智能体」roundtable-structure(种子资产 SOUL+config,注册进 seeder
  启动 seed+sync 进 DB,可在智能体管理页编辑、未来挂入库技能)。
- /api/flow-extract/stream 改用该 agent 的 SOUL(load_agent_soul,缺失回退内置默认) +
  模型(前端所选 > agent 配置 > 默认);抽取/校验/编辑统一。SOUL 先输出简要分析过程再出
  flow-json,模型慢时也有实时反馈(含 <think> 思考流)。
- 新增 /api/flow-extract/intent 意图识别(参考 ai_writing 一次性 JSON 范式):识别
  重做报告/改流程图/重画流程图/提问/模糊;前端按意图分流,模糊则发起澄清让用户点选,
  不再用关键词硬路由(修「说重做报告却出流程图」)。
- Phase 2 问答修改:edit 模式(当前图+指令→改图→校验);Phase 3 画布手动改/删节点。
- 修:用户消息发送即上屏(不等模型/分类返回);regenerate_report 不再清空对话历史;
  全屏 portal 到 body 工具栏恢复;解析兼容独立 edges 数组。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
fengxiangdebaoziandClaude Opus 4.8 committed 2026-06-17 22:19:02 +08:00
1 parent ed887f762e
commit 0890b3c375
7 files changed
+346 -62

No files matched your search

@@ -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;
@@ -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({
)}
</div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
{/* 意图模糊 → 让用户自己定(不自作主张) */}
{pendingClarification && (
<div className="shrink-0 border-t border-amber-100 bg-amber-50/60 px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-amber-800">
没太理解你的意思,你是想——
</div>
<div className="flex flex-wrap gap-1.5">
{(
[
{ k: "regenerate_report", t: "重新生成报告" },
{ k: "modify_flow", t: "修改流程图" },
{ k: "regenerate_flow", t: "重新生成流程图" },
{ k: "qa", t: "只是提问" },
] as const
).map((o) => (
<button
key={o.k}
type="button"
onClick={() => onResolveClarification(o.k)}
className="rounded-lg border border-amber-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-amber-800 transition hover:bg-amber-100"
>
{o.t}
</button>
))}
</div>
</div>
)}
<form onSubmit={handleAskSubmit} className="shrink-0 border-t border-slate-100 bg-white px-3 py-3">
{/* 已选附件预览(发送时随指令上传到总结线程,供智能体读取) */}
{askFiles.length > 0 && (
@@ -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<string, StubAiMessage[]>;
@@ -417,6 +421,8 @@ export function useStep3Summary(
useEffect(() => {
flowGraphRef.current = flowGraph;
}, [flowGraph]);
// 意图模糊时挂起待澄清的原始消息(组件渲染选项让用户自己定)。
const [pendingFlowClarification, setPendingFlowClarification] = useState<string | null>(null);
const [summaryThreadId, setSummaryThreadId] = useState<string | null>(null);
const [dialogues, setDialogues] = useState<Step2Dialogue[]>([]);
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,
@@ -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 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。
@@ -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。
@@ -0,0 +1,3 @@
description: 圆桌结构化抽取智能体。读取「方案总结报告」或各席位交付内容,分析其层级结构,抽取成可交互流程图的结构化数据(flow-json);也负责按指令修改流程图、校验修复流程图。专职「内容→结构」,不写报告、不联网、不画 HTML。后续可在此智能体上配置「入库技能」,让它分析出结构化结果后直接入库。
id: roundtable-structure
name: 结构化抽取
@@ -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) 拼成 `<think>…</think>正文` 增量逐帧吐,前端实时显示)
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": "意图识别失败"}