From ebf1d63269562b03da6abc3ab76cce1f2fde2eab Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 19 Oct 2026 08:47:53 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A9=E6=89=8B=E7=9A=84=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E6=89=8B=E5=86=8C=E8=AE=B2=E9=A9=B3=E5=9B=9E=E5=87=BA=E5=8F=A3?= =?UTF-8?q?=EF=BC=9A=E5=93=AA=E4=BA=9B=E8=8A=82=E7=82=B9=E8=83=BD=E6=8E=A5?= =?UTF-8?q?=EF=BC=88=E5=AE=A1=E6=89=B9=E3=80=81=E8=A1=A5=E5=85=85=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E3=80=81=E7=BC=96=E8=BE=91=E7=A8=BF=E4=BB=B6=E3=80=81?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E7=A1=AE=E8=AE=A4=E3=80=81=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=92=8C=E5=B7=A5=E5=85=B7=E5=AE=A1=E6=89=B9=E3=80=81=E5=8F=8C?= =?UTF-8?q?=E7=9B=B2=E8=A3=81=E5=86=B3=EF=BC=89=EF=BC=8C=E8=BF=9E=E7=BA=BF?= =?UTF-8?q?=E5=86=99=20sourceHandle=3D"rejected"=EF=BC=8C=E4=BB=80?= =?UTF-8?q?=E4=B9=88=E6=97=B6=E5=80=99=E6=8E=A5=EF=BC=88=E9=A9=B3=E5=9B=9E?= =?UTF-8?q?=E5=90=8E=E8=A6=81=E9=80=9A=E7=9F=A5=E3=80=81=E9=80=80=E5=9B=9E?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E3=80=81=E8=B5=B0=E5=8F=A6=E4=B8=80=E6=9D=A1?= =?UTF-8?q?=E8=B7=AF=EF=BC=89=EF=BC=8C=E4=B8=8D=E6=8E=A5=E6=97=B6=E6=80=8E?= =?UTF-8?q?=E6=A0=B7=EF=BC=88=E8=BF=90=E8=A1=8C=E7=BB=93=E6=9D=9F=E3=80=81?= =?UTF-8?q?=E8=AE=B0=E4=B8=BA=E8=A2=AB=E9=A9=B3=E5=9B=9E=EF=BC=9B=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E6=A8=A1=E5=BC=8F=E8=BF=99=E4=B8=80=E8=B7=AF=E5=88=B0?= =?UTF-8?q?=E6=AD=A4=E4=B8=BA=E6=AD=A2=EF=BC=9B=E5=86=99=E4=BA=86=E9=A9=B3?= =?UTF-8?q?=E5=9B=9E=E5=8D=B3=E7=BB=88=E6=AD=A2=E5=B0=B1=E5=88=AB=E6=8E=A5?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E9=A9=B3=E5=9B=9E=E9=82=A3=E6=9D=A1=E8=B7=AF?= =?UTF-8?q?=E5=BF=85=E9=A1=BB=E8=B5=B0=E5=88=B0=E6=88=90=E6=9E=9C=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E6=88=96=E7=BB=93=E6=9D=9F=EF=BC=8C=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E9=87=8D=E8=AF=95=E3=80=81=E5=87=BA=E9=94=99=E6=97=B6=E7=BB=A7?= =?UTF-8?q?=E7=BB=AD=E4=B8=8D=E7=AE=A1=E9=A9=B3=E5=9B=9E=E3=80=82=E4=BB=A5?= =?UTF-8?q?=E5=89=8D=E6=89=8B=E5=86=8C=E5=8F=AA=E6=9C=89=E4=B8=80=E5=8F=A5?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E7=9A=84=20approved=20/=20rejected=EF=BC=8C?= =?UTF-8?q?=E5=8A=A9=E6=89=8B=E7=94=BB=E5=87=BA=E6=9D=A5=E7=9A=84=E5=9B=BE?= =?UTF-8?q?=E9=A9=B3=E5=9B=9E=E5=B0=B1=E6=95=B4=E8=BD=AE=E7=BB=93=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/copilot.py | 14 +++- .../tests/test_copilot_reject_exit_guide.py | 84 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_copilot_reject_exit_guide.py diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index 63ee57f6..540c082a 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -327,7 +327,19 @@ skip_if。这些字段按受限的 Python 表达式求值,变量直接写路 - edges 里每条边 {source, target, sourceHandle?} - branch 节点的出边必须带 sourceHandle,取值是 cases 里的 key,另外要有一条 sourceHandle="default" 兜底 - loop 节点:sourceHandle="body" 连循环体,循环体最后一个节点再连回 loop 节点;sourceHandle="done" 连循环结束后的去向 -- human 节点(approve 模式):sourceHandle 用 "approved" 和 "rejected" +- human 节点(approve 模式):批准走 sourceHandle "approved",驳回走下面的「驳回」出口 +- 「驳回」出口:会被人驳回的节点可以多接一条 sourceHandle="rejected" 的边,驳回(包括超时驳回)之后沿它往下走。 + 能接的只有:human(审批、补充输入、编辑稿件三种模式)、tool(工具调用审批、写外部系统前的动作确认)、 + code(代码执行审批)、dual_check(双盲复算的裁决);别的节点接了画布校验会报错。 + · 什么时候接:驳回之后还要做事——通知申请人(接一个通知或写说明的节点,再到成果节点)、退回修改(连回要改的上游 + 那一步,改完再到这个节点问一次)、改走另一条路(比如出一份「已退回」的成果)。驳回时这一步照常结束,下游用 + {{ nodes.关卡id.note }} 取驳回备注、{{ nodes.关卡id.actor }} 取谁驳回的;动作确认被驳回时写入不会发出 + · 不接时:补充输入、编辑稿件、动作确认、代码和工具审批、双盲裁决被驳回,运行就此结束、记为被驳回(是人的决定,不算失败); + 审批模式不接时这一路到审批为止、不再往下走(运行照常结束,没有成果)。审批写了 stop_on_reject(驳回即终止运行)就不要再接驳回出口: + 驳回时运行直接结束,那条边走不到 + · 驳回那条路必须走到成果节点,或者在一个不再往下连的节点结束(退回修改绕回上游、改完再问一次也算),否则驳回之后 + 只能一直绕下去,发布检查会报错 + · 驳回不是故障:「失败重试」「出错时继续」不管驳回,不要为了驳回之后接着走去配它们 - 一个节点连出多条普通边 = 并行执行,多条边汇入同一节点 = 等待汇聚 """ diff --git a/backend/tests/test_copilot_reject_exit_guide.py b/backend/tests/test_copilot_reject_exit_guide.py new file mode 100644 index 00000000..e9b9cee7 --- /dev/null +++ b/backend/tests/test_copilot_reject_exit_guide.py @@ -0,0 +1,84 @@ +"""助手的节点手册要教「驳回」出口(docs/design/orchestration-round/00-总方案.md §3)。 + +手册里没写的出口,助手就不会接:驳回之后要通知申请人、退回修改、改走另一条路的,画出来的图只会在驳回时整轮结束。 +这里不只查关键词,还拿校验认的那一份(会被驳回的节点类型、出口的 sourceHandle、发布检查的「走不到结束」)来对, +再照手册的写法搭一张图,校验、发布检查都过。 +""" +from __future__ import annotations + +import re + +from app.api.copilot import NODE_REFERENCE +from app.engine.schema import GATE_TYPES, REJECT_HANDLE, GraphSpec, validate_graph + + +def _reject_entry() -> str: + start = NODE_REFERENCE.index("「驳回」出口") + return NODE_REFERENCE[start:NODE_REFERENCE.index("\n- 一个节点连出多条普通边", start)] + + +def test_the_guide_names_every_node_that_can_be_rejected_and_the_handle(): + entry = _reject_entry() + assert f'sourceHandle="{REJECT_HANDLE}"' in entry + # 能接驳回出口的节点类型(schema.GATE_TYPES)一个不漏,按 type 字段写 + assert all(f"{t}" in entry for t in GATE_TYPES), [t for t in GATE_TYPES if str(t) not in entry] + # 六种关卡都点到:审批、补充输入、编辑稿件、动作确认、代码 / 工具审批、双盲裁决 + for kind in ("审批", "补充输入", "编辑稿件", "动作确认", "代码执行审批", "工具调用审批", "裁决"): + assert kind in entry, kind + + +def test_the_guide_says_when_to_connect_it_and_what_happens_without_it(): + entry = _reject_entry() + # 什么时候接:驳回后要通知、退回修改、走另一条路 + assert "通知" in entry and "退回修改" in entry and "另一条路" in entry + # 不接时:运行结束、记被驳回(不算失败);审批模式没设驳回即终止的老规矩照实说 + assert "记为被驳回" in entry and "不算失败" in entry + assert "stop_on_reject" in entry + # 驳回那条路必须走到成果节点或结束(发布检查 reject_exit.no_end),绕回去改完再问一次也可以 + assert "成果节点" in entry and "不再往下连" in entry and "发布检查" in entry + # 驳回不是故障:失败重试、出错时继续不管它 + assert "失败重试" in entry + # 驳回之后下游取得到谁、备注 + assert "{{ nodes.关卡id.note }}" in entry + + +def test_the_guide_does_not_leak_internal_names(): + entry = _reject_entry() + for internal in ("reject_exit", "NodeRejected", "end_reason", "cancelled", "__decision__", "GATE_TYPES"): + assert internal not in entry, internal + + +def test_the_old_one_line_rule_is_replaced_not_duplicated(): + # 以前只有一句「human 节点(approve 模式):sourceHandle 用 approved 和 rejected」:驳回出口说一处,不留两种说法 + assert len(re.findall(r'sourceHandle="rejected"', NODE_REFERENCE)) == 1 + + +def _node(nid: str, ntype: str, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}} + + +def test_a_graph_drawn_the_way_the_guide_says_passes_the_checks(): + """照手册写:审批批准走 approved,驳回接 sourceHandle="rejected" 到「通知申请人」再到一个成果节点;工具的确认被驳回 + 退回上游修改(连回起草,改完再问一次)。画布校验、各级发布检查都不报驳回出口的问题。""" + from app.engine.governance import lint_for_publish + + graph = { + "nodes": [_node("start", "input"), _node("draft", "llm", prompt="起草"), + _node("gate", "human", mode="approve", title="可以吗"), + _node("send", "tool", tool="calculator", args={"expression": "1+1"}, approval="always"), + _node("tell", "llm", prompt="通知申请人:{{ nodes.gate.note }}"), + _node("done", "output", fields=[{"name": "结果", "value": "{{ nodes.send.text }}"}]), + _node("back", "output", fields=[{"name": "结果", "value": "{{ nodes.tell.text }}"}])], + "edges": [{"source": "start", "target": "draft"}, {"source": "draft", "target": "gate"}, + {"source": "gate", "target": "send", "sourceHandle": "approved"}, + {"source": "gate", "target": "tell", "sourceHandle": "rejected"}, + {"source": "tell", "target": "back"}, + {"source": "send", "target": "done"}, + {"source": "send", "target": "draft", "sourceHandle": "rejected"}], + } + spec = GraphSpec.model_validate(graph) + codes = {i.code for i in validate_graph(spec).issues if i.level == "error"} + assert not {c for c in codes if c and "reject" in c}, codes + for level in ("draft", "published", "governed"): + issues = lint_for_publish(spec, level=level).issues + assert not [i for i in issues if (i.code or "").startswith("reject_exit")], level