From 5dfe2e7fd196ca5d5447b4dd9a0d7b9abe83ca00 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 19 Oct 2026 16:47:07 +0800 Subject: [PATCH] =?UTF-8?q?report.failed=5Fqueries=20=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=8A=8A=E3=80=8C=E5=86=99=E9=94=99=E4=B8=80=E6=9D=A1=E3=80=81?= =?UTF-8?q?=E4=B8=8B=E4=B8=80=E6=9D=A1=E5=B0=B1=E6=94=B9=E5=AF=B9=E4=BA=86?= =?UTF-8?q?=E3=80=8D=E5=BD=93=E5=8F=96=E6=95=B0=E5=A4=B1=E8=B4=A5=EF=BC=9A?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E4=B8=AA=E8=8A=82=E7=82=B9=E9=87=8C=EF=BC=8C?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E4=B9=8B=E5=90=8E=E8=BF=98=E6=9C=89=E4=B8=80?= =?UTF-8?q?=E6=9D=A1=E5=90=8C=E4=B8=80=E6=95=B0=E6=8D=AE=E6=BA=90=E7=9A=84?= =?UTF-8?q?=E6=88=90=E5=8A=9F=E5=B9=B3=E5=8F=B0=E6=9F=A5=E8=AF=A2=E3=80=81?= =?UTF-8?q?=E8=AF=BB=E7=9A=84=E8=A1=A8=E8=A6=86=E7=9B=96=E4=BA=86=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E9=82=A3=E6=9D=A1=E7=9A=84=E8=A1=A8=EF=BC=88=E6=8C=89?= =?UTF-8?q?=20sql=5Fsemantics.facets=20=E5=8F=96=E8=A1=A8=EF=BC=9B?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E9=82=A3=E6=9D=A1=E8=A7=A3=E6=9E=90=E4=B8=8D?= =?UTF-8?q?=E4=BA=86=E5=B0=B1=E6=8C=89=E5=90=8C=E4=B8=80=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=BA=90=E4=B9=8B=E5=90=8E=E6=9C=89=E6=88=90=E5=8A=9F=E7=9A=84?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E8=AE=A4=EF=BC=89=EF=BC=8C=E8=BF=99=E6=AC=A1?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E7=AE=97=E5=B7=B2=E8=A2=AB=E6=9B=BF=E4=BB=A3?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E8=BF=9B=E5=86=99=E4=BD=9C=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E3=80=81=E4=B8=8D=E8=AE=B0=20report=5Fqueries=5Ffailed?= =?UTF-8?q?=EF=BC=9B=E4=B8=80=E6=AC=A1=E6=88=90=E5=8A=9F=E7=9A=84=E9=83=BD?= =?UTF-8?q?=E6=B2=A1=E6=9C=89=E6=97=B6=E7=85=A7=E6=97=A7=E5=86=99=E3=80=8C?= =?UTF-8?q?=E8=BF=99=E6=AC=A1=E4=B8=80=E6=AC=A1=E6=88=90=E5=8A=9F=E7=9A=84?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E9=83=BD=E6=B2=A1=E6=9C=89=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/nodes/report.py | 46 +++++++++++++++-- backend/tests/test_report_failed_queries.py | 56 +++++++++++++++++++++ 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/backend/app/engine/nodes/report.py b/backend/app/engine/nodes/report.py index efffa7d7..cb9b5b4f 100644 --- a/backend/app/engine/nodes/report.py +++ b/backend/app/engine/nodes/report.py @@ -37,7 +37,7 @@ import asyncio import re import time from collections import Counter -from collections.abc import Iterable +from collections.abc import Iterable, Mapping from typing import Any from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage @@ -234,13 +234,18 @@ def failed_queries(state: GraphState, spec: GraphSpec, node: GraphNode) -> list[ result = outputs.get(other.id) if other.id not in allowed or (keep is not None and other.id not in keep) or not isinstance(result, dict): continue - for call in result.get("tool_calls") or []: - if not isinstance(call, dict) or call.get("repeat") or call.get("skipped") or call.get("denied"): - continue + calls = [c for c in result.get("tool_calls") or [] + if isinstance(c, dict) and not (c.get("repeat") or c.get("skipped") or c.get("denied")) + and is_platform_query(str(c.get("tool") or ""))] + for i, call in enumerate(calls): name, said = str(call.get("tool") or ""), str(call.get("result") or "").strip() - if not is_platform_query(name) or not (call.get("ok") is False or said.startswith("查询失败")): + if not _query_failed(call): continue args = call.get("args") if isinstance(call.get("args"), dict) else {} + if _superseded(name, str(args.get("sql") or ""), calls[i + 1:]): + # 写错了一条、下一条马上改对了:这次失败已被替代,不算「没有查到」(不然写作模型会写「还有一次取数失败」, + # 复核据此判有缺口) + continue reason = said.splitlines()[0] if said else "没有交回结果" found.append({"node_id": other.id, "title": other.title or other.id, "tool": name, "sql": " ".join(str(args.get("sql") or "").split())[:200], @@ -248,6 +253,37 @@ def failed_queries(state: GraphState, spec: GraphSpec, node: GraphNode) -> list[ return found +def _query_failed(call: Mapping[str, Any]) -> bool: + said = str(call.get("result") or "").strip() + return call.get("ok") is False or said.startswith("查询失败") + + +def _tables(sql: str) -> set[str] | None: + """这条 SQL 读的表(sql_semantics.facets 的 tables,按比较用的写法);解析不了的为 None。""" + from app.data.sql_semantics import facets + + f = facets(sql) + if f.parse_error or not f.tables: + return None + return {str(p.key) for p in f.tables} + + +def _superseded(tool: str, sql: str, later: list[dict[str, Any]]) -> bool: + """同一个节点里这次失败之后,还有一条成功的平台查询替代了它:同一个数据源(同一个查询工具)、读的表覆盖了失败那条读的 + 表;失败那条解析不了(取不出表)的,同一个数据源之后有成功的查询就算。""" + wanted = _tables(sql) + for call in later: + if str(call.get("tool") or "") != tool or _query_failed(call): + continue + if wanted is None: + return True + args = call.get("args") if isinstance(call.get("args"), dict) else {} + got = _tables(str(args.get("sql") or "")) + if got is not None and wanted <= got: + return True + return False + + def _failed_section(failed: list[dict[str, Any]], *, any_query: bool) -> str: """写作提示里「没有查到的数据」一节,和照实说没查到的要求。一次成功的查询都没有时,不许写单元格引用。""" lines = [f"- 「{f['title']}」{f['tool']}" + (f":{f['sql']}" if f["sql"] else "") + f"({f['reason']})" diff --git a/backend/tests/test_report_failed_queries.py b/backend/tests/test_report_failed_queries.py index 8dc03c5f..eb59b080 100644 --- a/backend/tests/test_report_failed_queries.py +++ b/backend/tests/test_report_failed_queries.py @@ -52,3 +52,59 @@ async def test_without_failed_queries_the_prompt_is_unchanged(monkeypatch): run = await finish(graph(calls)) assert "没有查到的数据" not in _prompt(seen[0]) assert not [e for e in await events(run.id, "log") if (e.data or {}).get("code") == "report_queries_failed"] + + +# -------------------------------------------------------------------------- +# 失败之后又改对了的那一次不算「没有查到」 +# -------------------------------------------------------------------------- + + +def _failed(calls: list[dict]) -> list[dict]: + from app.engine.nodes.report import failed_queries + from app.engine.schema import GraphSpec + + g = graph("[]") + spec = GraphSpec.model_validate(g) + state = {"nodes": {"agent1": {"text": "x", "tool_calls": calls}}} + return failed_queries(state, spec, spec.node_map()["write"]) + + +BAD = {"tool": "db_query__shop", "args": {"sql": "SELECT line AS 产线, SUM(qty) AS 产量 FROM output GROUP BY SUM(qty)"}, + "ok": True, "result": "查询失败:aggregate functions are not allowed in GROUP BY"} + + +def _good(sql: str, tool: str = "db_query__shop") -> dict: + return {"tool": tool, "args": {"sql": sql}, "ok": True, "result": '{"columns": ["产线", "产量"], "rows": [["甲线", 10]]}'} + + +def test_a_failure_fixed_by_the_next_query_is_superseded(): + """取数 Agent 写错一条(GROUP BY 里放了聚合),下一条马上改对、读的表覆盖了失败那条:这次失败已被替代,不进写作提示。""" + fixed = _good("SELECT line AS 产线, SUM(qty) AS 产量 FROM output GROUP BY line") + assert _failed([BAD, fixed]) == [] + # 解析不了的失败 SQL:同一个数据源之后有成功的查询,也算被替代 + garbled = {**BAD, "args": {"sql": "SELEC line FROM"}} + assert _failed([garbled, fixed]) == [] + + +def test_a_failure_not_covered_by_a_later_query_stays(): + """之后成功的查询读的是别的表(没覆盖失败那条读的表)、或者成功在失败之前:照旧列进「没有查到的数据」。""" + other = _good("SELECT COUNT(*) AS n FROM stations") + [left] = _failed([BAD, other]) + assert "aggregate functions" in left["reason"] + before = _good("SELECT line AS 产线, SUM(qty) AS 产量 FROM output GROUP BY line") + assert len(_failed([before, BAD])) == 1, "成功在失败之前,不算替代了它" + elsewhere = _good("SELECT line, SUM(qty) FROM output GROUP BY line", tool="db_query__crm") + assert len(_failed([BAD, elsewhere])) == 1, "别的数据源上的成功不算" + + +async def test_when_every_query_failed_the_writer_still_says_none_succeeded(monkeypatch): + calls = ("[{'tool': 'db_query__shop', 'args': {'sql': 'SELECT line, SUM(qty) FROM output GROUP BY SUM(qty)'}, " + "'ok': True, 'result': '查询失败:aggregate functions are not allowed'}, " + "{'tool': 'db_query__shop', 'args': {'sql': 'SELECT line, SUM(qty) FROM output GROUP BY 1'}, " + "'ok': True, 'result': '查询失败:connection reset'}]") + seen = script(monkeypatch, "没有查到产量:查询失败。") + run = await finish(graph(calls)) + first = _prompt(seen[0]) + assert "上游有 2 次数据查询失败了" in first and "这次一次成功的查询都没有" in first + [warned] = [e for e in await events(run.id, "log") if (e.data or {}).get("code") == "report_queries_failed"] + assert warned.data["failed"] == 2