Files
AgentLab/backend/tests/test_validate_repair.py
T
ThearchyHelios 587bf7e27c 第三波前两批:六个「看着成功其实没干活」的平台漏洞堵上,记录页能回放,管理页与画布收尾
起因是一次探索运行:Copilot 改节点时把工具绑定整份抹掉,agent 没有工具只能写「假设调用」,
校验节点的自动修复编出 total_count=0 让 schema 通过,协作成员把工具调用标记当文字输出了四轮,
轮数用完后节点把原话当成功结果交出去——运行标成已完成,数据库一次都没查。

平台可信度(先写失败测试)
- Copilot 修改节点改为合并:没写的字段保留,null 才删除;协作成员按名字合并。前后端同一套规则,
  工具集合变小时自查报警、改图回执列出变化
- 新校验规则:提示词点名了某个工具而节点没有绑定它,判错误,Copilot 自查会交回去修
- 校验修复只许改格式:冒出原文没有的值就判修复无效;修复这次模型调用计入用量
- 识别模型把工具调用写成文字的情况(DSML、<tool_call> 等标记):纠正一次,仍然如此就判失败并说明原因
- 协作团队用完轮数不再按成功交付:默认失败,可选降档,点出一次都没派到的成员
- 审批恢复后,同一节点里已经执行过的工具调用复用结果,不再重复执行

后端
- 查询时限交给数据库执行(Oracle call_timeout、PG statement_timeout、MySQL max_execution_time、
  SQLite progress handler),可按数据源配置;超时报错与实际耗时一致
- 等审批的运行可以真正取消;会话列表带最后一轮状态;测连接结果持久化;探查结构可只看不存;
  自定义工具可草稿试跑、写坏的参数定义保存时拒绝;MCP 服务改动后作废旧工具缓存;
  检索命中带贡献分解;重建索引在后台跑并报进度;报错统一收进 core/errors

前端
- 公共层:管理页共用件、失败说明(lib/explain)、健康状态进公共库;目录按表各自加载并有超时,
  一张表卡住不再拖垮整站;上传显示真实字节进度;离线横幅只播报不变的句子
- 画布:卡片只说游标所在的那一次执行;等审批时可放弃运行;团队收尾判定与未完成说清楚;
  回放用量取那一刻的值
- 编排页:Copilot 合并语义与工具变化回执;「用完轮数时」选项;正式运行期间锁住所有改图入口
- 回答流 / 问数据:新失败类型说人话并给下一步;画布与右栏互相定位;数据源范围、回收站、重新核对
- 记录页:「航迹」页签复用画布时间轴,可拖动回放、列出关键时刻;列表行带执行 / 等人 / 失败缩略条
- 外壳:统一的离开前确认(data router),导航、⌘K、快捷键、后退都会先问

验证(沙箱):tsc 干净,pytest 838 passed(两遍),14 个检查脚本中 13 个全绿;
check-ui 余 1 项旧断言(按旧日期格式找运行行),3c 更新。
2026-09-27 10:16:32 +08:00

171 lines
7.2 KiB
Python

"""校验节点的自动修复只许改格式,不许补数据。
一次真实运行:上游的查询 agent 没有工具,交上来的是「假设调用工具」加一段 SQL,
里面一个数都没有。校验第一次失败(缺 total_count),自动修复把原文喂给模型让它
「修正后重新输出」——模型就编了一个 total_count: 0,校验通过,运行成功,最后
交到用户手上的是一个凭空的 0。
修复提示词要说清楚「原文没有的值填 null」,但光靠提示词拦不住。修复之后要核对:
修复结果里出现了原文没有的数字或文字,这次修复就作废,报错写清是哪个值。
另外修复是一次真实的模型调用,它的用量以前没发 llm.end,也没算进 usage。
"""
from __future__ import annotations
import asyncio
import json
import pytest
from langchain_core.messages import AIMessage
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import Run, RunEvent
from app.engine.runner import run_manager
SCHEMA = {
"type": "object",
"properties": {"total_count": {"type": "integer"}, "table": {"type": "string"}},
"required": ["total_count"],
}
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0},
"data": {"label": nid, "config": config}}
def _graph(upstream_text: str, **validate):
return {
"nodes": [
node("start", "input"),
node("draft", "transform", mode="template", template=upstream_text),
node("check", "validate", schema=SCHEMA, source="{{ nodes.draft }}", max_retries=2,
**validate),
node("out", "output", fields=[{"name": "r", "value": "{{ nodes.check.data }}"}]),
],
"edges": [{"source": "start", "target": "draft"}, {"source": "draft", "target": "check"},
{"source": "check", "target": "out"}],
}
def _repairs_with(monkeypatch, *replies: dict) -> list[str]:
"""修复模型依次交回这些 JSON。返回它收到的提示词。"""
from app.providers import mock_model
prompts: list[str] = []
def _decide(self, messages):
prompts.append("\n".join(str(m.content) for m in messages))
reply = replies[min(len(prompts) - 1, len(replies) - 1)]
return AIMessage(content=json.dumps(reply, ensure_ascii=False))
monkeypatch.setattr(mock_model.MockChatModel, "_decide", _decide)
return prompts
async def _finish(graph) -> tuple[Run, list[RunEvent]]:
run = await run_manager.start(graph=graph, input_payload={})
for _ in range(200):
await asyncio.sleep(0.05)
async with SessionLocal() as session:
row = await session.get(Run, run.id)
if row.status in ("succeeded", "failed"):
break
async with SessionLocal() as session:
events = list((await session.execute(
select(RunEvent).where(RunEvent.run_id == run.id).order_by(RunEvent.seq)
)).scalars())
return row, events
PRETEND = "我假设调用工具 db_query__shop 执行:\nSELECT COUNT(*) FROM orders\n结果应该能反映订单总数。"
async def test_a_repair_that_invents_a_number_fails(monkeypatch):
prompts = _repairs_with(monkeypatch, {"total_count": 0})
row, events = await _finish(_graph(PRETEND))
assert row.status == "failed", f"修复编出来的 0 被当成了结果:{row.output}"
assert "修复时出现了原文没有的值:total_count=0" in (row.error or ""), row.error
# 提示词里要把规矩说在前面
assert prompts and "null" in prompts[0] and "编造" in prompts[0]
async def test_a_repair_that_only_reformats_passes(monkeypatch):
_repairs_with(monkeypatch, {"total_count": 1284, "table": "orders"})
row, _ = await _finish(_graph("查到了:orders 表一共 1,284 条记录。"))
assert row.status == "succeeded", row.error
assert json.loads(row.output["r"]) == {"total_count": 1284, "table": "orders"}
async def test_a_repair_that_admits_there_is_no_data_still_fails_the_schema(monkeypatch):
_repairs_with(monkeypatch, {"total_count": None})
row, _ = await _finish(_graph(PRETEND))
assert row.status == "failed"
assert "total_count" in (row.error or "") and "原文没有的值" not in (row.error or "")
async def test_repair_calls_are_counted(monkeypatch):
_repairs_with(monkeypatch, {"total_count": 0})
row, events = await _finish(_graph(PRETEND))
ends = [e.data for e in events if e.type == "llm.end" and e.node_id == "check"]
assert len(ends) == 2, "两次修复各是一次模型调用,都要记账"
assert row.usage.get("output_tokens", 0) >= sum(e["output_tokens"] for e in ends) > 0
async def test_numbers_written_in_chinese_count_as_present(monkeypatch):
_repairs_with(monkeypatch, {"total_count": 12, "table": "orders"})
row, _ = await _finish(_graph("orders 表里一共有十二条记录。"))
assert row.status == "succeeded", row.error
# SQL 里的数字不是证据:WHERE status > 0、LIMIT 10 这类写法到处都是,认它们的话,
# 编出来的 total_count: 0 就有了「出处」——这恰恰是「假设调用工具 + SQL」最常见的形状
@pytest.mark.parametrize("upstream, invented", [
('{"sql":"SELECT COUNT(*) FROM orders WHERE status > 0"}', {"total_count": 0}),
('假设调用工具:{"tool": "db_query__shop", "sql": "SELECT COUNT(*) FROM orders LIMIT 10"}',
{"total_count": 10}),
("我假设调用工具执行了 SELECT COUNT(*) FROM orders WHERE deleted = 0,结果还没拿到",
{"total_count": 0}),
("```sql\nSELECT COUNT(*) FROM orders\nWHERE status > 0\nLIMIT 10\n```\n查询已提交", {"total_count": 10}),
('{"sql":"SELECT COUNT(*) FROM orders WHERE status > 0"}', {"total_count": "0"}),
])
def test_numbers_inside_sql_do_not_count(upstream, invented):
from app.engine.nodes.human import _invented
assert _invented(invented, upstream, SCHEMA), (upstream, invented)
def test_numbers_outside_the_sql_still_count():
from app.engine.nodes.human import _invented
text = "执行了 SELECT COUNT(*) FROM orders WHERE status > 0,结果一共 12 条"
assert _invented({"total_count": 12}, text, SCHEMA) == []
fenced = '```json\n{"total_count": 12}\n```'
assert _invented({"total_count": 12}, fenced, SCHEMA) == []
def test_a_percentage_may_be_written_as_a_fraction():
from app.engine.nodes.human import _invented
assert _invented({"rate": 0.125, "mom": -0.03}, "转化率 12.5%,环比 -3%", {}) == []
assert _invented({"rate": 12.5}, "转化率 12.5%", {}) == []
assert _invented({"rate": 0.2}, "转化率 12.5%", {}) == ["rate=0.2"]
async def test_a_repair_that_takes_its_number_from_the_sql_fails(monkeypatch):
_repairs_with(monkeypatch, {"total_count": 0})
row, _ = await _finish(_graph('假设调用工具:{"tool": "db_query__shop", '
'"sql": "SELECT COUNT(*) FROM orders WHERE status > 0"}'))
assert row.status == "failed", f"SQL 里的 0 被当成了出处:{row.output}"
assert "修复时出现了原文没有的值:total_count=0" in (row.error or ""), row.error