feat: 流程图连线后端确定性推导(弱模型也连对) + 校验不删边/不简化理由 + 调度中即时反馈

- 连线质量:新增 _derive_layered_edges——连线不再信任模型,后端按节点 group 分层 +
  层级对应关系确定性推导(任务→各目的扇出、相邻等量层按下标一一对应、不规则层对回退模型边)。
  模型只需 group 标对 + 各层内按相同对应顺序排列,连线就一定对。SOUL/_STRUCTURE 据此调整。
- 校验不删边:validate 要求节点数≥原图 + gen() 兜底「校验后节点/边变少则回退抽取结果」。
- 不简化理由:_SHAPE + SOUL detail 改为完整理由原样保留、不简化/精简。
- 调度中即时反馈:ask() 意图分类前立刻建「调度中」气泡,定意图后各分支复用(relabelDialogue
  + runFlowExtract.bubbleId),不再等 intent 接口返回才出内容。
- 测试 tests/test_flow_extract_parse.py 加层级连线推导 3 例(共 10 过)。

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:56:04 +08:00
1 parent 0890b3c375
commit cc06d3f4d2
3 files changed
+112 -21

No files matched your search

@@ -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<void> => {
const isEdit = !!opts?.instruction && !!opts?.currentGraph;
setFlowPhase("extracting");
if (!isEdit) setFlowGraphState(null); // 编辑模式保留旧图直到新图就绪(防画布闪空)
// 两个气泡:抽取/修改 + 校验——把过程(思考 `<think>` + 文字 + 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,
],
@@ -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 个节点);
@@ -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