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 } + if (params.get('noquery') === '1') { + return + } return (
diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx index 34cf8699..16e9dfb4 100644 --- a/frontend/src/pages/ChatPage.tsx +++ b/frontend/src/pages/ChatPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react' -import { Database, ListTree, MessageSquare, Send, Square } from 'lucide-react' +import { Database, ListTree, MessageSquare, Play, Send, Square } from 'lucide-react' import { api } from '../api/client' import { ApprovalCard } from '../run/RunPanel' import { AssistantStream, StreamEmpty, type StreamTurn } from '../run/AssistantStream' @@ -25,7 +25,7 @@ import { useToast } from '../components/ui' * dense 一个开关。两边各写一套的话,同一次运行在两个页面会讲出不同的故事。 */ export function ChatPage() { - const { busy, ask, stop, load, loadSteps, turnsOf } = useChat() + const { busy, ask, stop, load, loadSteps, runNow, turnsOf } = useChat() const currentId = useConversations((s) => s.currentId) const create = useConversations((s) => s.create) const turns = useChat((s) => (currentId ? s.byConversation[currentId] : undefined)) @@ -73,6 +73,11 @@ export function ChatPage() { ? () => void loadSteps(currentId, t.id) : undefined } + onRun={ + currentId && turnsOf(currentId).find((x) => x.id === t.id)?.pendingRun + ? () => runNow(currentId, t.id) + : undefined + } /> )} onOpenGraph={(graph) => { @@ -132,6 +137,7 @@ function toStreamTurn(turn: ChatTurn): StreamTurn { phase: turn.phase === 'waiting' ? 'waiting' : turn.phase === 'error' ? 'error' : running ? 'running' : 'done', + // ready(建好了没跑)在流里按"完成"画,差别体现在下面那个「跑一下」上 status: turn.status, // 建图的步骤在前,跑图的在后——这正是发生的顺序 steps: [...decodeCopilot(turn.ops), ...decodeRun(turn.events)], @@ -142,6 +148,7 @@ function toStreamTurn(turn: ChatTurn): StreamTurn { runClass: turn.run?.run_class, graph: turn.graph, graphNote: turn.explanation, + noQuery: turn.noQuery, } } @@ -151,19 +158,25 @@ function toStreamTurn(turn: ChatTurn): StreamTurn { * 从库里恢复的轮次没有事件流——一次会话几十轮,把每轮的事件都预加载回来 * 是几十个请求换一堆没人看的步骤。所以默认只显示问题和答案,想看过程再取。 */ -function Approvals({ turnId, runId, onShowSteps }: { +function Approvals({ turnId, runId, onShowSteps, onRun }: { turnId: string runId?: string onShowSteps?: () => void + onRun?: () => void }) { const conversationId = useConversations((s) => s.currentId) const approvals = useCatalog((s) => s.approvals) const reattach = useChat((s) => s.reattach) const pending = approvals.filter((a) => a.run_id === runId && a.status === 'pending') - if (!runId) return null + if (!runId && !onRun) return null return ( <> + {onRun && ( + + )} {onShowSteps && (