Files
AgentLab/backend/tests/test_copilot_context.py
T
ThearchyHelios a6ee525148 Copilot 编排期看得见数据库了,附带修一个让非流式生成整体失效的 bug
阶段②。之前 agent 是运行时才摸索表结构,每跑一次重探一遍——慢,而且同一张图
两次运行走的 SQL 可能不一样,没法钉口径。现在 Copilot 建图时就知道有哪些库、
哪些表,生成的 SQL 直接能跑。

## 先修一个更要紧的:非流式生成的 config 全是空的

GRAPH_SCHEMA 里 config 写的是 {"type": "object"},没有 properties 也没有
additionalProperties。不少 provider 的结构化输出实现遇到这种 object 会按
"不允许额外字段"处理,于是模型填好的 config 被整个过滤成 {}——生成的图每个
节点都是空壳,一提交就挂在"工具节点还没选工具"。

config 的键随节点类型千变万化(tool 有 tool/args,code 有 language/code…),
没法枚举成 properties,只能显式声明 additionalProperties=true。流式那条路径
走操作流协议、不经过 structured output,所以这个 bug 一直藏着没露头。

## 注入什么

- 工具清单补上动态的 db_query__<源> / db_schema__<源>。all_specs() 只有静态
  注册的内置工具,不补进来 Copilot 根本不知道这些工具存在,只会退回让用户自己
  写 SQL 的代码节点——接了数据库等于白接。
- 数据源摘要(含 schema 全名),外加四条写 SQL 的硬性要求。这几条都是真实
  踩过的:漏 schema 前缀在 Oracle 上直接 ORA-00942,写惯 MySQL 的模型会顺手
  写 LIMIT,分号拼接会被守卫拒掉。

## token 预算:按对象数自适应,不是按字符数

实测一个 53 对象的库,带字段 2715 token、只给对象名 956 token(35%)。
但真正的决策点不是字符数——detail 模式每库只列 40 个对象,多出来的 Copilot
根本看不见。对 53 个对象的库,"40 张表带字段"不如"53 张表只给名字":
找不到那张表,字段写得再全也没用。截断丢信息比省 token 更糟。

所以先按对象数判断(超过 40 就整体切紧凑),再用字符预算兜多库叠加的情况。
真实库实测:紧凑模式 1219 token,53 个对象全部可见。

## 实测

完整模式:一句话需求 → Copilot 出图 → 不改一个字直接跑通真实 Oracle。
生成的 SQL 三处细节全对——ANALYTICS. 前缀、FETCH FIRST(不是 LIMIT)、单条语句。

紧凑模式(看不到字段):Copilot 没有硬猜字段,而是生成了一个带
db_schema + db_query 两个工具的 agent 节点,让运行时先查结构再写 SQL——
正是 prompt 里"字段拿不准就先放一个 db_schema 节点"那条。跑通,结论正确。

测试 72 项。
2026-09-17 12:16:54 +08:00

106 lines
4.0 KiB
Python

"""Copilot 的数据源上下文注入。
这组守的是"Copilot 知道有哪些库、写得出能跑的 SQL"。断了的表现很隐蔽:
它不会报错,只会退回让用户自己写 SQL 的代码节点——接了数据库等于白接。
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from app.api.copilot import GRAPH_SCHEMA, _DATASOURCE_BUDGET_CHARS
from app.data.introspect import summary
def _source(n_tables: int, *, name: str = "bi", schema: str | None = "ANALYTICS"):
tables = {
f"t{i}": {
"qualified": f"{schema}.t{i}" if schema else f"t{i}",
"schema": schema,
"columns": [
{"name": f"c{j}", "type": "VARCHAR", "nullable": True, "comment": None}
for j in range(20)
],
"primary_key": [], "comment": None, "is_view": True,
}
for i in range(n_tables)
}
return SimpleNamespace(
id="x", name=name, kind="oracle", readonly=True,
description="测试库", options={}, schema_cache={"tables": tables, "schema": schema},
)
def test_graph_schema_allows_arbitrary_config_keys():
"""config 的键随节点类型千变万化,没法枚举成 properties。
不显式写 additionalProperties=True,某些 provider 的结构化输出会按
"不允许额外字段"处理,把 config 整个过滤成 {}——生成的图每个节点都是
空壳,校验挂在"工具节点还没选工具"。这条回归过一次,代价是整条非流式
生成路径不可用。
"""
node = GRAPH_SCHEMA["properties"]["nodes"]["items"]
assert node["properties"]["config"]["additionalProperties"] is True
assert "config" in node["required"]
def test_summary_detail_mode_lists_columns():
text = summary(_source(3))
assert "ANALYTICS.t0" in text and "c0" in text
def test_summary_compact_mode_drops_columns():
"""库多了要靠它压 token:只留对象名,字段让 db_schema 按需查。"""
text = summary(_source(50), detail=False)
assert "ANALYTICS.t0" in text
assert "c0" not in text
assert len(text) < len(summary(_source(50)))
def test_compact_mode_is_substantially_smaller():
"""压缩比要真的有效,否则降级没意义。实测约 35%。"""
full = summary(_source(53))
compact = summary(_source(53), detail=False)
assert len(compact) < len(full) * 0.5
def test_qualified_names_appear_in_summary():
"""摘要里必须是带 schema 的全名——模型照抄,漏前缀就是 ORA-00942。"""
assert "ANALYTICS.t0" in summary(_source(2))
def test_summary_without_schema_uses_bare_names():
"""没有 schema 概念的库不要硬拼前缀,否则生成的 SQL 反而错。"""
text = summary(_source(2, schema=None))
assert "t0" in text and "None.t0" not in text
def test_budget_constant_is_sane():
"""预算太小会让单个库就触发降级,太大等于没有预算。"""
assert 3000 <= _DATASOURCE_BUDGET_CHARS <= 20000
@pytest.mark.parametrize("n_tables,expect_detail", [(3, True), (40, True), (53, False)])
def test_object_count_decides_detail_level(n_tables, expect_detail):
"""对象超过截断阈值就切紧凑模式。
关键取舍:detail 模式每库只列 40 个对象,多出来的 Copilot 根本看不见。
对 53 个对象的库,"40 张表带字段"不如"53 张表只给名字"——找不到那张表,
字段写得再全也没用。截断丢信息比省 token 更糟。
"""
from app.api.copilot import _DETAIL_TABLE_LIMIT
from app.data.introspect import table_names
src = _source(n_tables)
assert (len(table_names(src)) <= _DETAIL_TABLE_LIMIT) is expect_detail
def test_many_sources_trigger_compact_mode():
"""单库不超标,几个库加起来超预算时整体降级。"""
sources = [_source(35, name=f"db{i}") for i in range(6)]
full = sum(len(summary(s)) for s in sources)
assert full > _DATASOURCE_BUDGET_CHARS
compact = sum(len(summary(s, detail=False)) for s in sources)
assert compact < _DATASOURCE_BUDGET_CHARS