mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:21:58 +00:00
fcdcc30d4e
一、可点击证据第二期
取数链路
- 智能体、工具节点、代码节点、知识检索取数时都记进证据台账;报告目录按台账编出 Q1… / K1…
- 报告可以引用快照里的一格([[v:Q1.r0.gmv]]),也可以由系统从快照生成整张表([[table:Q1 cols=… rows=0-4]]),
表里每一格都是可点的片段;取值一律以快照为准,取回快照时复验哈希
- 口径卡表达式新增 cell(x, row, column),lineage 精确到单元格
- 智能体开 cite_fields(配合 output_schema)后,循环结束做一次结构化抽取,每个字段标出取自哪次查询的哪一格,
再到快照里核对:一致记 verified;不一致以快照为准并告警;查不到的记为空值,永远不兜底成 0
- 代码节点新增 evidence_role(source / compute)和 code_sha;沙箱算出来的数不能被报告直接引用,要进口径卡
- 查询快照记下列类型,文本列按文本渲染(年份 "2026" 不再显示成「2,026」)
- 升级前发起的运行(停在审批上的)一律走旧逻辑:新的 task 只追加在循环之后,恢复时不会错号
证据接口与面板
- 片段链接上查询步骤:SQL、被引用的行加前后各 2 行、高亮到格;数据源可设 mask_columns 遮罩列,
遮罩在查询时记进快照,数据源事后改名或删除也照样遮罩(在有身份体系之前,遮罩只减少暴露,不是安全边界)
- 输入来源写明核对结果(与快照一致 / 模型报的值和快照不一致 / 没查到记为空);口径卡缺输入时也给出完整的链
- 受管出具要在契约里声明 cells 才能引用单元格;受管与否记在所钉版本上,改画布不再让受管版本的正式运行降级
- 口径卡可以用 caliber_from 钉住另一个已发布工作流里的口径卡,上游有新版本时按 upgrade_policy 处置,
和子工作流同一套规则;子工作流节点也补上了升版处置的配置项
Copilot、校验与报错
- 问数据的答案里有数字时,搭 input → 智能体 → 报告 → 出口,报告直接引用查过的单元格
- 写 SQL 前核对数据源的表结构,SQL 里出现表结构里没有的列会被打回
- 不再搭「智能体文字 → 转换节点解析 JSON」这种图;转换节点的 JSON 报错分清是上游模型写坏了、
模板里裸插了模型文字(给出 | json 的改法),还是模板本身写错了
- 工具节点遇到「查询失败」「SQL 被拒绝」判为失败;发起运行前检查绑定的工具是否存在,不存在时直接拒绝并给出入口
二、发布前自动修复
- 问题带上机器编号(code)和修复 id;新增只读接口 POST /api/workflows/{id}/publish-check 和 /autofix
- 修复分三类:答案唯一、不降低要求的一键修复;要人拿主意的给选项(不替人选);结构性问题交给 Copilot
- 一律先预览,不自动保存、不自动发布;删节点、删契约、放宽审批、关掉 strict、改级别等降低要求的改动一律拒绝,
Copilot 的方案也要过这一关;应用后重跑检查,错误必须变少且不能冒出新的
- 发布弹窗打开就先检查;问题面板新增「发布前检查」,平时就能看到、就地修掉
- 门禁修正:契约有 report_from 时不再要求 narrative(模板⑨可以发成受管);全图默认审批为「全部放行」时同样拦下
- 修掉画布保存会抹掉全图默认(defaults)的老问题:以前任何一次保存都会把它清空,审批 always 因此被悄悄放宽
验证(沙箱):tsc 干净,生产构建通过,pytest 2268 passed(两遍),check-all 17 项检查、3915 项全过
(新增 check-publish);关键行为做了变异验证、全部抓到。
317 lines
16 KiB
Python
317 lines
16 KiB
Python
"""Copilot 的节点手册里要有口径卡、报告撰写和出具契约的写法,以及搭图规则。
|
|
|
|
手册里没有的节点,Copilot 就不会用:以前手册里连口径卡都没有,只在数据源那一节
|
|
提了一句「取数结果要进口径卡」,于是它搭出来的图全是 agent → llm → output,叙述里
|
|
的数字没有一个能回指。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from app.api.copilot import NODE_REFERENCE, GenerateIn, _user_message
|
|
from app.engine.evidence import render_number
|
|
|
|
|
|
def test_node_reference_covers_calibers_reports_and_contracts():
|
|
ref = NODE_REFERENCE
|
|
for words in (
|
|
["- metrics:口径卡", "expression", "decimals", "format", "on_missing"],
|
|
["- report:报告撰写", "[[m:指标id]]", "[[i:", "metrics_from", "on_violation", "不要用 llm"],
|
|
["report_from", "required", "strict"],
|
|
["搭图规则", "口径卡 → report → output", "SUM / COUNT", "code 只做格式转换"],
|
|
):
|
|
assert all(w in ref for w in words), [w for w in words if w not in ref]
|
|
# percent_of_ratio 的小数位按比率算:照「两位小数」写 2 的话 0.0235 显示成 2%,0.004 直接显示不出来
|
|
assert "percent_of_ratio 的 decimals 按比率算:要显示两位百分比(2.35%)写 4" in ref
|
|
assert render_number(0.0235, decimals=4, fmt="percent_of_ratio") == "2.35%"
|
|
assert render_number(0.0235, decimals=2, fmt="percent_of_ratio") == "2%"
|
|
# 成果字段要原样引用报告:前后拼了字就没法逐段对应证据
|
|
assert "{{ nodes.报告节点id.text }}" in ref
|
|
# 老规矩还在
|
|
assert "不要写 max_steps" in ref
|
|
|
|
|
|
def test_report_model_follows_the_same_model_rule():
|
|
assert "llm / agent / supervisor / report 的 config.model" in NODE_REFERENCE
|
|
|
|
|
|
def test_answer_branch_mentions_traceable_reports():
|
|
text = _user_message(GenerateIn(instruction="上周销售额多少", intent="answer"), patch=False)
|
|
assert "report" in text and "口径卡" in text and "report_from" in text
|
|
# 二期起问数也带报告:agent 查过的单元格能被 report 直接 [[v:]] 引用,答案里的数逐个点得开
|
|
assert "input → agent → report → output" in text
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 二期:agent 的结构化字段、单元格引用、cell()、沙箱代码的角色
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_node_reference_covers_phase_two_evidence():
|
|
ref = NODE_REFERENCE
|
|
for words in (
|
|
["output_schema", "cite_fields: true", "vars."], # agent 交结构化字段
|
|
["[[v:Q1.r0.", "[[table:Q1 cols=", "rows=0-4"], # 报告引用单元格、整表
|
|
["cell(nodes.", "cell() "], # 口径卡直接取查询结果的一格
|
|
["evidence_role", "source", "compute"], # 沙箱代码的角色
|
|
["cells: true"], # 受管出具要声明 cells
|
|
["不要用 transform 解析 agent / llm 的文字"],
|
|
):
|
|
assert all(w in ref for w in words), [w for w in words if w not in ref]
|
|
# 旧的「transform 解析查询结果」「agent 的回答进不了口径卡」不再是推荐写法
|
|
assert "agent 的回答是自由文本,进不了口径卡" not in ref
|
|
assert 'template="{{ nodes.取数节点id }}"' not in ref
|
|
|
|
|
|
def test_answer_branch_builds_agent_report_for_numbers_only():
|
|
text = _user_message(GenerateIn(instruction="上周销售额多少", intent="answer"), patch=False)
|
|
assert "input → agent → report → output" in text and "[[v:Q1.r0." in text
|
|
assert "只问表结构、清单" in text and "不加 report" in text
|
|
assert "不要用 transform 解析 agent / llm 的文字" in text
|
|
assert "output_schema" in text and "cite_fields" in text
|
|
|
|
|
|
def _source(**tables):
|
|
from types import SimpleNamespace
|
|
|
|
return SimpleNamespace(name="shop", schema_cache={"tables": {
|
|
name: {"qualified": name, "columns": [{"name": c} for c in cols]} for name, cols in tables.items()}})
|
|
|
|
|
|
def _spec(*nodes):
|
|
from app.engine.schema import GraphSpec
|
|
|
|
return GraphSpec.model_validate({"nodes": list(nodes), "edges": [
|
|
{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]})
|
|
|
|
|
|
def _node(nid, ntype, **config):
|
|
return {"id": nid, "type": ntype, "data": {"label": nid, "config": config}}
|
|
|
|
|
|
def test_sql_columns_missing_from_the_schema_are_sent_back():
|
|
from app.api.copilot import authored_issues
|
|
|
|
shop = _source(orders=["id", "week", "amount", "refunded"])
|
|
fetch = _node("fetch", "tool", tool="db_query__shop",
|
|
args={"sql": "SELECT SUM(amt) AS gmv, COUNT(*) AS n FROM orders WHERE wk = '{{ input.week }}'"})
|
|
spec = _spec(_node("start", "input"), fetch, _node("out", "output", fields=[]))
|
|
[issue] = authored_issues(spec, [shop])
|
|
assert issue["level"] == "error" and issue["code"] == "sql_unknown_column"
|
|
assert issue["node_id"] == "fetch" and issue["field"] == "args.sql"
|
|
assert "amt、wk" in issue["message"] and "amount" in issue["message"] and "db_schema__shop" in issue["message"]
|
|
|
|
good = _node("fetch", "tool", tool="db_query__shop",
|
|
args={"sql": "SELECT SUM(amount) AS gmv, COUNT(*) AS n FROM orders WHERE week = '{{ input.week }}' "
|
|
"GROUP BY week ORDER BY gmv DESC"})
|
|
assert authored_issues(_spec(_node("start", "input"), good, _node("out", "output", fields=[])), [shop]) == []
|
|
# 结构没探查过、或者表不在结构里:判断不了,不打回
|
|
assert authored_issues(spec, [_source()]) == []
|
|
other = _node("fetch", "tool", tool="db_query__shop", args={"sql": "SELECT amt FROM refunds"})
|
|
assert authored_issues(_spec(_node("start", "input"), other, _node("out", "output", fields=[])), [shop]) == []
|
|
|
|
|
|
def test_sql_the_user_wrote_and_this_round_did_not_touch_is_left_alone():
|
|
from app.api.copilot import authored_issues
|
|
|
|
shop = _source(orders=["id", "amount"])
|
|
fetch = _node("fetch", "tool", tool="db_query__shop", args={"sql": "SELECT amt FROM orders"})
|
|
spec = _spec(_node("start", "input"), fetch, _node("out", "output", fields=[]))
|
|
assert authored_issues(spec, [shop], baseline=[fetch]) == []
|
|
assert authored_issues(spec, [shop], baseline=[{**fetch, "data": {"config": {"tool": "db_query__shop"}}}])
|
|
|
|
|
|
def test_parsing_agent_text_is_sent_back():
|
|
from app.api.copilot import authored_issues
|
|
|
|
spec = _spec(
|
|
_node("start", "input"),
|
|
_node("query_logs", "agent", prompt="查", tools=["db_query__shop"], assign_to="answer"),
|
|
_node("parse", "transform", mode="json", template="{{ nodes.query_logs.text }}", assign_to="q"),
|
|
_node("card", "metrics", metrics=[{"id": "n", "expression": "vars.q.n"}]),
|
|
_node("out", "output", fields=[]),
|
|
)
|
|
[issue] = authored_issues(spec, [])
|
|
assert issue["code"] == "parse_model_text" and issue["level"] == "error" and issue["node_id"] == "parse"
|
|
assert "cite_fields" in issue["message"] and "不要用整形节点解析 agent / llm 的文字" in issue["message"]
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 端到端:搭完自查,SQL 里猜的列名被打回、改好才自动运行
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
class _Scripted:
|
|
"""第一轮吐 first;收到自查请求(human 里有「上线前自查」)时吐 fix。"""
|
|
|
|
def __init__(self, first, fix):
|
|
self.first, self.fix = first, fix
|
|
self.calls: list[str] = []
|
|
|
|
async def astream(self, messages):
|
|
from langchain_core.messages import AIMessageChunk
|
|
|
|
human = next(text for role, text in messages if role == "human")
|
|
self.calls.append(human)
|
|
for op in (self.fix if "上线前自查" in human else self.first):
|
|
yield AIMessageChunk(content=json.dumps(op, ensure_ascii=False) + "\n")
|
|
|
|
|
|
def _ops(sql):
|
|
return [
|
|
{"op": "plan", "summary": "查销售额"},
|
|
{"op": "add_node", "node": {"id": "start", "type": "input", "label": "输入", "config": {}}},
|
|
{"op": "add_node", "node": {"id": "fetch", "type": "tool", "label": "取数",
|
|
"config": {"tool": "db_query__sales", "args": {"sql": sql}}}},
|
|
{"op": "add_edge", "edge": {"source": "start", "target": "fetch"}},
|
|
{"op": "add_node", "node": {"id": "out", "type": "output", "label": "成果",
|
|
"config": {"fields": [{"name": "r", "value": "{{ nodes.fetch }}"}]}}},
|
|
{"op": "add_edge", "edge": {"source": "fetch", "target": "out"}},
|
|
{"op": "done", "explanation": "查一下", "run": True},
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
async def sales(tmp_path):
|
|
from sqlalchemy import select
|
|
|
|
from app.db.base import SessionLocal
|
|
from app.db.models import DataSource
|
|
|
|
cache = {"tables": {"orders": {"qualified": "orders", "columns": [
|
|
{"name": "id", "type": "INTEGER"}, {"name": "week", "type": "TEXT"}, {"name": "amount", "type": "REAL"}]}}}
|
|
async with SessionLocal() as session:
|
|
row = (await session.execute(select(DataSource).where(DataSource.name == "sales"))).scalar_one_or_none()
|
|
if row is None:
|
|
row = DataSource(name="sales", kind="sqlite", database=str(tmp_path / "sales.db"), readonly=True)
|
|
session.add(row)
|
|
row.enabled, row.schema_cache = True, cache
|
|
await session.commit()
|
|
yield
|
|
|
|
|
|
async def test_guessed_columns_are_sent_back_and_fixed_before_running(sales, monkeypatch):
|
|
import app.api.copilot as copilot
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from app.main import app
|
|
|
|
model = _Scripted(_ops("SELECT SUM(sales_amount) AS gmv FROM orders"),
|
|
[{"op": "update_node", "id": "fetch",
|
|
"config": {"args": {"sql": "SELECT SUM(amount) AS gmv FROM orders"}}},
|
|
{"op": "done", "explanation": "改好了"}])
|
|
|
|
async def _model(*_a, **_k):
|
|
return model, "mock-fast"
|
|
|
|
monkeypatch.setattr(copilot, "get_chat_model", _model)
|
|
events: list[dict] = []
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
async with client.stream("POST", "/api/copilot/generate-stream",
|
|
json={"instruction": "上周销售额多少", "intent": "answer"}) as r:
|
|
async for line in r.aiter_lines():
|
|
if line.startswith("data: "):
|
|
events.append(json.loads(line[6:]))
|
|
checks = [e for e in events if e["op"] == "check"]
|
|
assert [c["status"] for c in checks] == ["repairing", "passed"], checks
|
|
[issue] = checks[0]["issues"]
|
|
assert issue["code"] == "sql_unknown_column" and "sales_amount" in issue["message"]
|
|
assert "sales_amount" in model.calls[1] and "amount" in model.calls[1]
|
|
final = events[-1]
|
|
sql = next(n for n in final["graph"]["nodes"] if n["id"] == "fetch")["data"]["config"]["args"]["sql"]
|
|
assert sql == "SELECT SUM(amount) AS gmv FROM orders" and final["autorun"] is True
|
|
|
|
|
|
def test_model_text_inside_template_quotes_is_sent_back_with_the_json_filter_fix():
|
|
"""模板把 agent 的文字放在引号里拼 JSON:打回时给的改法是 | json,不是去换结构化输出。"""
|
|
from app.api.copilot import authored_issues
|
|
|
|
spec = _spec(
|
|
_node("start", "input"),
|
|
_node("query_logs", "agent", prompt="查", tools=["db_query__shop"], assign_to="answer"),
|
|
_node("parse", "transform", mode="json", template='{"summary": "{{ nodes.query_logs.text }}"}',
|
|
assign_to="q"),
|
|
_node("out", "output", fields=[]),
|
|
)
|
|
[issue] = authored_issues(spec, [])
|
|
assert issue["code"] == "parse_model_text" and issue["level"] == "error"
|
|
assert "{{ nodes.query_logs.text | json }}" in issue["message"]
|
|
assert "cite_fields" not in issue["message"] and "不要用整形节点解析" not in issue["message"]
|
|
|
|
|
|
#: 都是能跑的 SQL:关键字、运算符、排序规则名、窗口名、表达式后面不写 AS 的别名,都不是列
|
|
VALID_SQL = [
|
|
"SELECT id FROM orders ORDER BY status COLLATE NOCASE",
|
|
"SELECT id FROM orders WHERE status COLLATE NOCASE = 'x'",
|
|
"SELECT id FROM orders ORDER BY region COLLATE RTRIM",
|
|
"SELECT id FROM orders WHERE status = 'x' COLLATE utf8mb4_bin",
|
|
"SELECT created_at AT TIME ZONE 'UTC' FROM orders",
|
|
"SELECT id FROM orders WHERE status ISNULL",
|
|
"SELECT id FROM orders WHERE status NOTNULL",
|
|
"SELECT id FROM orders WHERE region GLOB 'E*'",
|
|
"SELECT id FROM orders WHERE region REGEXP '^E'",
|
|
"SELECT id FROM orders WHERE region RLIKE '^E'",
|
|
"SELECT id FROM orders WHERE region SIMILAR TO 'E%'",
|
|
"SELECT id FROM orders WHERE region SOUNDS LIKE 'x'",
|
|
"SELECT amount DIV 2 FROM orders",
|
|
"SELECT amount MOD 2 FROM orders",
|
|
"SELECT id FROM orders WHERE amount > 0 XOR status = 'x'",
|
|
"SELECT region, SUM(amount) FROM orders GROUP BY GROUPING SETS ((region), ())",
|
|
"SELECT FIRST_VALUE(amount) IGNORE NULLS OVER (ORDER BY id) FROM orders",
|
|
"SELECT FIRST_VALUE(amount) RESPECT NULLS OVER (ORDER BY id) FROM orders",
|
|
"SELECT RANK() OVER (ORDER BY amount RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE TIES) FROM orders",
|
|
"SELECT RANK() OVER (ORDER BY amount GROUPS BETWEEN 1 PRECEDING AND CURRENT ROW EXCLUDE NO OTHERS) FROM orders",
|
|
"SELECT id, SUM(amount) OVER w FROM orders WINDOW w AS (PARTITION BY region)",
|
|
"SELECT SUM(amount) OVER w AS total FROM orders WINDOW w AS (PARTITION BY region)",
|
|
"SELECT id FROM orders WHERE amount > 0 MINUS SELECT id FROM orders",
|
|
"SELECT id FROM orders WHERE created_at >= NOW() - INTERVAL 7 DAYS",
|
|
"SELECT id FROM orders WHERE status = ANY (ARRAY['a'])",
|
|
"SELECT id FROM orders WHERE MATCH (status) AGAINST ('x' IN BOOLEAN MODE)",
|
|
"SELECT id FROM orders WHERE created_at OVERLAPS created_at",
|
|
"SELECT id FROM orders WHERE status = 'x' FOR UPDATE SKIP LOCKED",
|
|
"SELECT id FROM orders WHERE status = 'x' FOR SHARE NOWAIT",
|
|
"SELECT id FROM orders WHERE status = 'x' LOCK IN SHARE MODE",
|
|
"SELECT SQL_CALC_FOUND_ROWS id FROM orders",
|
|
"SELECT DISTINCTROW id FROM orders",
|
|
"SELECT id FROM orders WHERE region = N'x'",
|
|
"SELECT CONVERT(region USING utf8mb4) FROM orders",
|
|
"SELECT id FROM orders WHERE status = 'x' START WITH id = 1 CONNECT BY PRIOR id = customer_id",
|
|
"SELECT [Total Sales] FROM orders",
|
|
"SELECT id FROM orders WHERE region = @wanted",
|
|
"SELECT id FROM orders WHERE created_at > DATEADD(dd, -7, GETDATE())",
|
|
"SELECT DATEDIFF(wk, created_at, GETDATE()) FROM orders",
|
|
# 表达式后面不写 AS 的别名:前面是 END、NULL、TRUE、CURRENT_DATE 这种「一个值到此为止」的词
|
|
"SELECT CASE WHEN status = 'x' THEN 1 ELSE 0 END flag FROM orders",
|
|
"SELECT CASE WHEN status = 'x' THEN 1 ELSE 0 END flag, id FROM orders",
|
|
"SELECT NULL placeholder, id FROM orders",
|
|
"SELECT TRUE flag, id FROM orders",
|
|
"SELECT CURRENT_DATE today, id FROM orders",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("sql", VALID_SQL)
|
|
def test_valid_sql_is_not_sent_back(sql):
|
|
from app.api.copilot import sql_unknown_columns
|
|
|
|
shop = _source(orders=["id", "customer_id", "amount", "status", "created_at", "region"])
|
|
assert sql_unknown_columns(sql, shop) is None, sql_unknown_columns(sql, shop)
|
|
|
|
|
|
@pytest.mark.parametrize("sql, unknown", [
|
|
# 别名照样认得出来,打错的列照样打回
|
|
("SELECT CASE WHEN statsu = 'x' THEN 1 ELSE 0 END flag FROM orders", ["statsu"]),
|
|
("SELECT id FROM orders ORDER BY regoin COLLATE NOCASE", ["regoin"]),
|
|
("SELECT id, SUM(amt) OVER w FROM orders WINDOW w AS (PARTITION BY region)", ["amt"]),
|
|
("SELECT id FROM orders WHERE statsu ISNULL", ["statsu"]),
|
|
# 日期部分的缩写只在 DATEADD 这类函数的第一个参数上放过
|
|
("SELECT DATEADD(dd, 1, created_at) FROM orders WHERE wk = 1", ["wk"]),
|
|
])
|
|
def test_typos_next_to_those_constructs_are_still_caught(sql, unknown):
|
|
from app.api.copilot import sql_unknown_columns
|
|
|
|
shop = _source(orders=["id", "customer_id", "amount", "status", "created_at", "region"])
|
|
found = sql_unknown_columns(sql, shop)
|
|
assert found is not None and found[0] == unknown, found
|