diff --git a/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts b/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts index 3b385db5..914351a6 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts @@ -501,6 +501,11 @@ export function useStep3Summary( ); }, []); + /** 改某气泡的发送方名称(意图分类期间先建中性「调度中」气泡,定了意图再改成对应智能体名复用)。 */ + const relabelDialogue = useCallback((id: number, sender: string) => { + setDialogues((prev) => prev.map((d) => (d.id === id ? { ...d, sender } : d))); + }, []); + const appendStepIfNew = useCallback( ( bubbleId: number, @@ -989,17 +994,22 @@ export function useStep3Summary( const runFlowExtract = useCallback( async ( aborter: AbortController, - opts?: { instruction?: string; currentGraph?: FlowGraphData | null }, + opts?: { instruction?: string; currentGraph?: FlowGraphData | null; bubbleId?: number }, ): Promise => { const isEdit = !!opts?.instruction && !!opts?.currentGraph; setFlowPhase("extracting"); if (!isEdit) setFlowGraphState(null); // 编辑模式保留旧图直到新图就绪(防画布闪空) // 两个气泡:抽取/修改 + 校验——把过程(思考 `` + 文字 + flow-json 代码块)都留着展示, // 由 Step2Message 渲染(思考折叠块 + markdown 代码块),不折叠成一句话。 - const extractBubbleId = appendStreamingDialogue({ - sender: isEdit ? "结构化流程图 · 修改" : "结构化流程图 · 抽取", - avatarType: "blue", - }); + // 传入 bubbleId(意图分类期间已建的「调度中」气泡)则复用并改名,避免空窗 + 不重复建气泡。 + const extractSender = isEdit ? "结构化流程图 · 修改" : "结构化流程图 · 抽取"; + let extractBubbleId: number; + if (opts?.bubbleId != null) { + extractBubbleId = opts.bubbleId; + relabelDialogue(extractBubbleId, extractSender); + } else { + extractBubbleId = appendStreamingDialogue({ sender: extractSender, avatarType: "blue" }); + } let validateBubbleId: number | null = null; try { const graph = await streamFlowExtract({ @@ -1055,6 +1065,7 @@ export function useStep3Summary( appendStreamingDialogue, appendToDialogue, finalizeDialogue, + relabelDialogue, ], ); @@ -1176,9 +1187,14 @@ export function useStep3Summary( // 意图识别(参考 ai_writing 一次性 JSON 范式):先判断用户想 重做报告 / 改流程图 / // 重画流程图 / 提问 / 模糊待澄清,再分流——不再用关键词硬路由(避免「说重做报告却出流程图」)。 // 带附件时跳过分类(附件多用于报告 Q&A/改报告);澄清后用户点选则用 forcedIntent 跳过分类。 + // reuseBubbleId:意图分类期间先建的「调度中」气泡,定了意图后由对应分支复用(不等接口返回、像第一步)。 + let reuseBubbleId: number | null = null; if (text && !hasFiles) { let intent: FlowIntent | undefined = opts?.forcedIntent; + let dispatchBubbleId: number | null = null; if (!intent) { + // **立刻**显示「智能体调度中」气泡(不等意图接口返回),消除连接卡顿时的"卡住"感。 + dispatchBubbleId = appendStreamingDialogue({ sender: "智能体", avatarType: "blue" }); const r = await classifyFlowIntent({ message: text, hasReport: !!currentMdRef.current.trim(), @@ -1188,6 +1204,9 @@ export function useStep3Summary( intent = r.intent; } if (intent === "unclear") { + if (dispatchBubbleId != null) { + finalizeDialogue(dispatchBubbleId, "意图不太明确,请在下方选择你想做的:", "待确认"); + } setPendingFlowClarification(text); // 组件渲染选项让用户自己定 return; } @@ -1195,13 +1214,16 @@ export function useStep3Summary( const flowAborter = new AbortController(); aborterRef.current = flowAborter; const cur = flowGraphRef.current; + const reuse = dispatchBubbleId ?? undefined; if (intent === "modify_flow" && cur) { - await runFlowExtract(flowAborter, { instruction: text, currentGraph: cur }); + await runFlowExtract(flowAborter, { instruction: text, currentGraph: cur, bubbleId: reuse }); } else { - await runFlowExtract(flowAborter); + await runFlowExtract(flowAborter, { bubbleId: reuse }); } return; } + // regenerate_report | qa → 复用调度气泡走下方总结线程路径。 + reuseBubbleId = dispatchBubbleId; // intent === "regenerate_report" | "qa" → 落到下方总结线程续问路径: // **追加**用户气泡 + 在原线程上让智能体重写报告 / 增量改 / 回答(**不清空历史**)。 // regenerate_report 不再走 run()(run 会 setDialogues([]) 清空,导致"覆盖聊天记录、 @@ -1248,11 +1270,15 @@ export function useStep3Summary( })); } - // 用户气泡已在 ask 开头立刻追加(不等模型/分类返回),这里不再重复。 - const bubbleId = appendStreamingDialogue({ - sender: "方案总结报告智能体", - avatarType: "blue", - }); + // 用户气泡已在 ask 开头立刻追加。智能体气泡:复用意图分类期间的「调度中」气泡(改名), + // 没有则新建——这样提问/重做报告时分类阶段就有「调度中」反馈、不等模型返回(像第一步)。 + let bubbleId: number; + if (reuseBubbleId != null) { + bubbleId = reuseBubbleId; + relabelDialogue(bubbleId, "方案总结报告智能体"); + } else { + bubbleId = appendStreamingDialogue({ sender: "方案总结报告智能体", avatarType: "blue" }); + } setPhase("streaming"); if (hadLiveThread) { @@ -1313,6 +1339,8 @@ export function useStep3Summary( freezeStreaming, appendUserDialogue, appendStreamingDialogue, + relabelDialogue, + finalizeDialogue, streamOnce, runFlowExtract, ], 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 c7c83b5b..45d760cc 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 @@ -25,7 +25,7 @@ - `label`:≤12 字,节点标题,取该条目核心短语; - `type`:取 `start/end/stage/task/decision/risk/deliverable/role` 之一,**仅决定配色**; - `group`:**该节点所在的层级名称**(如 任务 / 目的 / 行为体 / 预期行为 / 驱动因素 / 叙事),**必填**——画布按它给节点打分类标签; -- `detail`:该条目的**名称 + 理由 / 说明**,要点表达完整、约 2–4 句即可(不必逐字照抄),**必须写成一行**——内部不要出现真实换行,需要分隔用「;」或「/」(避免 JSON 里出现裸换行); +- `detail`:该条目的**名称 + 完整理由 / 说明**——**原样取自报告、不要简化或精简理由**(理由要完整保留,不要删减内容);只需**写成一行**——内部不要出现真实换行,需要分隔用「;」或「/」(避免 JSON 里出现裸换行); - `from`:前驱节点 id 列表,只引用**前面已出现**的节点(也可用顶层 `edges:[{from,to,label}]`,两种都支持)。 节点按先后排列、输出**全量**数据、全程简体中文。 @@ -38,7 +38,7 @@ ③ 每个节点的 `group` 填它所在层级的名称(取自报告里该层级的真实名称); -④ 连边严格按报告里的**对应关系**串成分支:根任务 → 各「目的」;每个「目的」→ 它对应的「行为体」;每个「行为体」→ 对应的「预期行为·驱动因素」→ 对应的「叙事」……每一组各成一条自上而下的分支(报告里有几组就有几条分支,组与组之间不要混连); +④ **连线由系统按层级关系自动生成,你不用纠结连线**——你只需做对两件事:(a) 把每个节点的 `group` 标对层级;(b) **各层内的条目按"相同的对应顺序"排列**——即报告里相互对应的一组(目的1 ↔ 它对应的行为体 ↔ 对应的预期行为 ↔ 对应的叙事)要在各自层里**都排在第 1 位**,第 2 组都排第 2 位,依此类推。这样系统就能把 任务→各目的、目的i→行为体i→预期行为i→叙事i 正确连成每组一条自上而下的分支。`from` 字段可填可不填(系统会以层级对应关系为准); ⑤ **务必把每个层级的每一条都输出成节点、不要漏层、不要只输出开头一两个节点就停**(典型规模约 13–16 个节点); 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 2d463c19..213f5d21 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/flow_extract.py +++ b/offline-backend-20260512/backend/app/gateway/routers/flow_extract.py @@ -328,7 +328,49 @@ def parse_flow_graph(text: str) -> dict[str, Any] | None: continue keys.add(k) valid_edges.append(e) - return {"title": title, "nodes": nodes, "edges": valid_edges} + + # 连线**确定性推导**(不信任模型连边——弱模型常乱连):按 group 分层、按层级对应关系算边, + # 保证流程图连线质量与模型能力无关。能算出干净的层级连线就用它;层级不规则才回退模型连边。 + derived = _derive_layered_edges(nodes) + final_edges = derived if derived is not None else valid_edges + return {"title": title, "nodes": nodes, "edges": final_edges} + + +def _derive_layered_edges(nodes: list[dict[str, Any]]) -> list[dict[str, Any]] | None: + """按节点的 `group`(层级)确定性推导连线,**不依赖模型给的 from/edges**。 + + 规则(对应「任务 → 各目的 → 各自对应的行为体 → … → 叙事」这种分层链): + · 按 group 首次出现顺序排出层 L0,L1,…(L0 通常是「任务」单节点); + · 相邻层 a→b 连边:|a|==1 → a 扇出到 b 全部;|b|==1 → a 全部汇入 b; + |a|==|b| → 按下标一一对应(目的i→行为体i);否则该层对不规则 → 整体放弃推导(返回 None)。 + 返回 None 表示「无法可靠推导」(无 group / 只有一层 / 存在不规则层对),调用方回退模型连边。 + """ + # 按 group 分层,保持每层内节点的原始顺序 + 层的首次出现顺序。 + order: list[str] = [] + layers: dict[str, list[str]] = {} + for n in nodes: + g = _as_str(n.get("group")) + if not g: + return None # 有节点没层级 → 无法分层,回退模型连边 + if g not in layers: + layers[g] = [] + order.append(g) + layers[g].append(n["id"]) + if len(order) < 2: + return None # 只有一层,没有层间连线可推 + out: list[dict[str, Any]] = [] + for k in range(len(order) - 1): + a = layers[order[k]] + b = layers[order[k + 1]] + if len(a) == 1: + out.extend({"from": a[0], "to": nid} for nid in b) + elif len(b) == 1: + out.extend({"from": nid, "to": b[0]} for nid in a) + elif len(a) == len(b): + out.extend({"from": a[i], "to": b[i]} for i in range(len(a))) + else: + return None # 不规则层对(两边都 >1 且不等)→ 放弃推导,回退模型连边 + return out # ── prompts ──────────────────────────────────────────────────────────────── @@ -344,7 +386,7 @@ _SHAPE = ( "```\n" "字段:id(n1/n2…唯一)、label(≤12字,节点标题)、type(start/end/stage/task/decision/risk/deliverable/role 之一,仅决定配色)、" "group(该节点所在层级名称,如 任务/目的/行为体/预期行为/驱动因素/叙事,必填)、" - "detail(该条目名称+理由要点,约2-4句,**写成一行**不要内含换行)、" + "detail(该条目的名称+**完整理由/说明,原样取自报告、不要简化或精简理由**;只需写成一行——内部换行用「;」或「/」替代,不要内含真实换行)、" "from(前驱节点 id 列表,只引用前面已出现的节点)。" ) @@ -354,7 +396,8 @@ _STRUCTURE = ( "② 从第二层起按层级逐层展开:每层在报告/交付里通常列了多条具体条目(如 目的层有 目的1/2/3、行为体层有各自对应行为体,后面还有 预期行为·驱动因素、叙事…)," "把每一条具体条目各画成一个节点,不要把整层合并成一个笼统节点;" "③ 每个节点 group 填它所在层级名称(取自报告里该层级真实名称);" - "④ 连边严格按报告里的对应关系串成分支:根任务→各目的;每个目的→它对应的行为体;每个行为体→对应的预期行为·驱动因素→对应的叙事……每组各成一条自上而下的分支(有几组就几条,组间不混连);" + "④ **连线由系统按层级自动生成,你不用纠结连线**;只需:(a)group 标对层级;" + "(b)各层内条目按**相同对应顺序**排列——相互对应的一组(目的1↔对应行为体↔对应预期行为↔对应叙事)在各层都排同一位次(第1组排第1位、第2组排第2位…);from 可填可不填(系统以层级对应为准);" "⑤ 务必把每层每一条都输出成节点、不要漏层、不要只输出开头一两个节点就停(典型约13-16个节点);" "⑥ 只画这些层级实体节点,不要把『综合分析/核心洞察/执行路线/建议/结论』及『阶段①②③④』等汇总段落/阶段名画成节点。" ) @@ -442,9 +485,13 @@ def _validate_messages(req: FlowExtractRequest, extracted: str) -> list[Any]: _material(req) + "\n\n===== 已抽取的 flow-json(待校验修复)=====\n" + extracted - + "\n===== 待校验结束 =====\n\n请对照数据来源校验并修复这份 flow-json:" - + "根节点是任务本身、每层每条都在没漏、连边按对应关系成分支无悬空边、每个节点都有 group、" - + "没有汇总/阶段编号节点;发现问题就改对、没问题就原样规范化,输出修复后的完整 ```flow-json 代码块。" + + "\n===== 待校验结束 =====\n\n请对照数据来源校验并修复这份 flow-json(连线由系统按层级自动生成、你不用管连线):" + + "\n**校验要点(务必遵守)**:" + + "① **每层每条都在、没漏层没漏条目**,节点数只能 ≥ 原图、不能变少;" + + "② 每个节点的 `group`(层级名)标对;**各层内条目按相同对应顺序排列**——相互对应的一组在各层都排同一位次(第1组都排第1位…),这样系统才能正确连线;" + + "③ 每个节点的 `detail` **原样保留、不要简化或精简理由**——理由要完整,不要删减内容;" + + "④ 没有汇总/阶段编号节点;只在确有错漏时才动,没问题就原样规范化输出。" + + "\n输出修复后的完整 ```flow-json 代码块(含全部节点)。" ) return [SystemMessage(content=_system_prompt()), HumanMessage(content=user)] @@ -538,8 +585,24 @@ async def extract_flow_stream(request: Request, payload: FlowExtractRequest = Bo except Exception: # noqa: BLE001 — 校验失败不致命,回退抽取结果 logger.warning("flow-extract validation failed, fall back to extraction", exc_info=True) graph = None + extracted_graph = parse_flow_graph(extracted) + # 防回归兜底:校验**绝不能让结构变差**。若校验后的图节点或连线比抽取结果更少 + # (模型校验时把连线删没了、或漏了节点——实测会发生),直接回退用抽取结果, + # 保住所有节点与连线。校验只在「不丢东西」时才采用。 + if graph is not None and extracted_graph is not None: + if len(graph.get("nodes", [])) < len(extracted_graph.get("nodes", [])) or len( + graph.get("edges", []) + ) < len(extracted_graph.get("edges", [])): + logger.info( + "flow-extract: validation reduced nodes/edges (%d/%d → %d/%d), keep extraction", + len(extracted_graph.get("nodes", [])), + len(extracted_graph.get("edges", [])), + len(graph.get("nodes", [])), + len(graph.get("edges", [])), + ) + graph = extracted_graph if graph is None: - graph = parse_flow_graph(extracted) + graph = extracted_graph if graph is None: yield _frame({"event": "error", "detail": "未能从模型输出解析出流程图节点"}) return