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:
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,
|
||||
],
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
Reference in new issue
Block a user