diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py
index 66031ae6..741b1c1b 100644
--- a/backend/app/api/copilot.py
+++ b/backend/app/api/copilot.py
@@ -248,7 +248,21 @@ def _user_message(payload: GenerateIn, *, patch: bool) -> str:
"- 但**每个节点都要用 add_node 完整输出一遍**,包括没有改动的。"
"这一轮是从空图开始搭的,只发改动操作会得到一张空图\n"
"- 之前几轮的问答由系统注入成 `{{ input.history }}`。用户用「它们」「这些」"
- "这类指代时,要让回答的节点引用它,否则模型不知道指的是上一轮那批数据"
+ "这类指代时,要让回答的节点引用它,否则模型不知道指的是上一轮那批数据\n"
+ "\n"
+ "先判断这句话该走哪条路径(三选一):\n"
+ # 以前这里没有分叉:每一句话都建一张图、跑一遍库。于是「重试」
+ # 「继续找找」这种对过程说的话,也各自重建了一整张图去查 153 张表
+ "1. **直接回答**(输出 reply):这句话不需要新数据就能答——问的是"
+ "前面几轮已经查出来的东西(口径、怎么算的、哪张表)、是对过程说的话"
+ "(「重试」「换个说法」),或者你需要先反问清楚才能动手。\n"
+ " 硬性约束:reply 里**只能复述或解释前面几轮已有的结果**,"
+ "不得新断言任何数据事实——不许凭印象给数字、给名单、给结论。"
+ "凡是要动到库才知道的,哪怕你觉得上一轮见过,也走第 2 条重新查。\n"
+ "2. **建图并执行**(done 的 run 为 true,默认):要新数据才能答的问题。\n"
+ "3. **建图但不执行**(done 的 run 为 false):用户要的是**流程本身**"
+ "——「设计一个工作流」「做一个每天能跑的」这类。他要的是这张图,"
+ "不是这一次的结果,跑一遍只是替他多花一次钱。\n"
)
return f"请设计一个工作流:{payload.instruction}"
@@ -516,9 +530,14 @@ _STREAM_PROTOCOL = """\
{"op":"remove_node","id":"..."}
{"op":"add_edge","edge":{"source":"...","target":"...","sourceHandle":"..."}}
{"op":"remove_edge","source":"...","target":"...","sourceHandle":"..."}
- {"op":"done","explanation":"两三句话说明这张图怎么跑"} ← 最后一行
+ {"op":"done","explanation":"两三句话说明这张图怎么跑","run":true} ← 最后一行
- 按执行顺序添加节点(先入口后出口);每加一个节点,立刻把连向它的边(两端都已存在的)输出出来,让图连贯地生长
-- 修改现有图时只输出改动,没提到的节点不要动"""
+- 修改现有图时只输出改动,没提到的节点不要动
+- done 里的 run:这张图建完要不要立刻执行。默认 true;用户要的是**流程本身**
+ ("设计一个每天跑的工作流")时给 false —— 他要的是这张图,不是这一次的结果
+- 如果这句话根本不需要工作流(见下面的路径约定),**第一行也是最后一行**就输出:
+ {"op":"reply","text":"直接回答的内容"}
+ 这时不要输出任何别的操作"""
def _parse_op_line(line: str) -> dict[str, Any] | None:
@@ -720,6 +739,8 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g
async def event_stream():
explanation = ""
seen_ops: list[dict[str, Any]] = []
+ # 建完要不要跑,由模型在 done.run 里表态
+ autorun = True
yield f"data: {json.dumps({'op': 'model', 'model': model_id}, ensure_ascii=False)}\n\n"
try:
async for op in _with_heartbeat(_iter_ops(model, messages)):
@@ -728,8 +749,16 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g
if kind in ("thinking", "heartbeat"):
yield f"data: {json.dumps(op, ensure_ascii=False)}\n\n"
continue
+ if kind == "reply":
+ # 这句话不需要工作流。原样转给前端,然后收流——后面那套
+ # 排版校验对着一张空图跑,只会得到"图是空的,先拖一个节点进来"
+ yield f"data: {json.dumps(op, ensure_ascii=False)}\n\n"
+ return
if kind == "done":
explanation = str(op.get("explanation", ""))
+ # 用户要的是流程本身时("设计一个每天跑的工作流"),
+ # 建完就跑等于替他多花一次钱,而他要的是那张图
+ autorun = op.get("run") is not False
seen_ops.append(op)
changed = _apply_op(nodes, edges, op)
if changed or kind in ("plan", "done"):
@@ -770,6 +799,7 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g
"graph": spec.model_dump(mode="json"),
"issues": issues,
"explanation": explanation,
+ "autorun": autorun,
}
except Exception as e: # noqa: BLE001
final = {"op": "error", "message": f"生成的图结构非法:{e}"}
diff --git a/backend/tests/test_conversations.py b/backend/tests/test_conversations.py
index fcc7cfdf..2b63e096 100644
--- a/backend/tests/test_conversations.py
+++ b/backend/tests/test_conversations.py
@@ -372,3 +372,67 @@ async def test_the_first_turn_has_nothing_to_fall_back_on(client, monkeypatch) -
assert final["op"] == "final"
assert final["graph"]["nodes"] == []
assert any("空" in str(i.get("message", "")) for i in final["issues"]), final["issues"]
+
+
+# --------------------------------------------------------------------------
+# 不是每句话都值得跑一遍库
+# --------------------------------------------------------------------------
+#
+# 以前必执行:final 一到就无条件 launch。于是「重试」「继续找找」这种对过程
+# 说的话,也各自重建了一整张图、跑一遍 153 张表的库;而一句明确要「设计一个
+# 工作流」的,建完也直接跑了——用户要的是那张图,不是这一次的结果。
+
+
+async def test_a_question_about_earlier_turns_needs_no_workflow(client, monkeypatch) -> None:
+ """reply 这条路径:不建图、不跑、也不该走到那套排版校验上。
+
+ 走到了的话,空图会撞出"图校验未通过:图是空的,先拖一个节点进来"。
+ """
+ conv_id = await _seeded(client)
+ final = await _stream_graph(client, monkeypatch, conv_id, [
+ '{"op":"reply","text":"第 1 轮走的是 role.level IN (platform, regional) 这个口径。"}',
+ ])
+ assert final["op"] == "reply"
+ assert "口径" in final["text"]
+ assert "graph" not in final
+
+
+async def test_asking_for_a_workflow_builds_it_but_does_not_run_it(client, monkeypatch) -> None:
+ """用户要的是流程本身,跑一遍只是替他多花一次钱。"""
+ conv_id = await _seeded(client)
+ final = await _stream_graph(client, monkeypatch, conv_id, [
+ '{"op":"plan","summary":"搭一个每天能跑的"}',
+ '{"op":"add_node","node":{"id":"in","type":"input","label":"入口","config":{}}}',
+ '{"op":"add_node","node":{"id":"out","type":"output","label":"成果","config":{}}}',
+ '{"op":"add_edge","edge":{"source":"in","target":"out"}}',
+ '{"op":"done","explanation":"每天跑一次","run":false}',
+ ])
+ assert final["op"] == "final"
+ assert final["autorun"] is False
+ assert [n["id"] for n in final["graph"]["nodes"]] == ["in", "out"]
+
+
+async def test_a_normal_data_question_still_runs(client, monkeypatch) -> None:
+ """默认不变:问数据就是建图 + 跑。没写 run 就当 true。"""
+ conv_id = await _seeded(client)
+ final = await _stream_graph(client, monkeypatch, conv_id, [
+ '{"op":"plan","summary":"查一下"}',
+ '{"op":"add_node","node":{"id":"in","type":"input","label":"入口","config":{}}}',
+ '{"op":"add_node","node":{"id":"out","type":"output","label":"成果","config":{}}}',
+ '{"op":"add_edge","edge":{"source":"in","target":"out"}}',
+ '{"op":"done","explanation":"跑完给结论"}',
+ ])
+ assert final["op"] == "final"
+ assert final["autorun"] is True
+
+
+def test_the_three_paths_are_spelled_out_for_the_model() -> None:
+ """路径选择全靠这段约定,尤其是那条不许凭印象给数字的硬约束。"""
+ from app.api.copilot import GenerateIn, _STREAM_PROTOCOL, _user_message
+
+ text = _user_message(GenerateIn(instruction="随便问问", intent="answer"), patch=False)
+ assert "直接回答" in text and "建图并执行" in text and "建图但不执行" in text
+ assert "不得新断言任何数据事实" in text
+ # 协议里要给得出这两个出口,否则模型没法表达
+ assert '"op":"reply"' in _STREAM_PROTOCOL
+ assert '"run":true' in _STREAM_PROTOCOL
diff --git a/frontend/src/dev/preview.tsx b/frontend/src/dev/preview.tsx
index 4ad50408..6f66c34c 100644
--- a/frontend/src/dev/preview.tsx
+++ b/frontend/src/dev/preview.tsx
@@ -95,6 +95,13 @@ function longReport(): string {
].join("\n")
}
+/** 直接回答的那一轮:没查库,界面上必须说破 */
+const NO_QUERY_TURN: StreamTurn = {
+ id: "noquery", question: "刚才那 6 个管理员是怎么算的?", phase: "done", status: "完成",
+ steps: [], noQuery: true,
+ output: { answer: "第 1 轮走的是 `role.level IN (platform, regional)` 这个口径。要换口径我重新查一遍。" },
+}
+
const LONG_TURN: StreamTurn = {
id: "long", question: "需要能够显示总结内容", phase: "done", status: "完成",
steps: [], output: { result: longReport() },
@@ -138,6 +145,9 @@ function Preview() {
if (params.get('long') === '1') {
return