diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index 5b8803d2..af9a5274 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -7,6 +7,7 @@ from typing import Any from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.db.base import get_session @@ -19,6 +20,92 @@ from app.tools.registry import all_specs router = APIRouter(prefix="/api/copilot", tags=["copilot"]) +async def _tool_catalog(session: AsyncSession) -> str: + """给 Copilot 看的工具清单。 + + all_specs() 只有静态注册的内置工具,数据源工具是运行时按库动态生成的 + (db_query__<源>),不补进来 Copilot 就不知道它们存在,只会退回让用户 + 自己写 SQL 的代码节点——接了数据库等于白接。 + """ + lines = [ + f"- {name}({spec.category}):{spec.description}" + for name, spec in sorted(all_specs().items()) + ] + + from app.data import introspect as _introspect + from app.db.models import DataSource + from app.tools.datasource import QUERY_PREFIX, SCHEMA_PREFIX + + rows = list(( + await session.execute( + select(DataSource).where(DataSource.enabled.is_(True)).order_by(DataSource.name) + ) + ).scalars()) + for row in rows: + tables = _introspect.table_names(row) + listed = "、".join(tables[:12]) + (f" 等 {len(tables)} 个对象" if len(tables) > 12 else "") + lines.append( + f"- {QUERY_PREFIX}{row.name}(数据库):在「{row.name}」上执行 SQL。" + f"{row.description or ''}" + + (f" 可查:{listed}" if tables else " ⚠ 结构未探查") + ) + lines.append( + f"- {SCHEMA_PREFIX}{row.name}(数据库):查看「{row.name}」的表结构" + ) + return "\n".join(lines) + + +# 数据源摘要的字符预算。超了就退成"只列对象名",字段让 Copilot 用 +# db_schema 工具按需查。实测一个 53 对象的库带字段约 4000 字符, +# 接三五个库就会把真正的需求淹掉——system prompt 里塞满 schema 而挤掉 +# 用户想要什么,是本末倒置。 +_DATASOURCE_BUDGET_CHARS = 6000 +# detail 模式下每个库最多列这么多对象(与 introspect.summary 的默认值一致)。 +# 超过就说明会截断,那还不如切紧凑模式把对象名列全。 +_DETAIL_TABLE_LIMIT = 40 + + +async def _datasource_section(session: AsyncSession) -> str: + """数据源与结构摘要。没有数据源时返回空串,不占 prompt。""" + from app.data import introspect as _introspect + from app.db.models import DataSource + + rows = list(( + await session.execute( + select(DataSource).where(DataSource.enabled.is_(True)).order_by(DataSource.name) + ) + ).scalars()) + if not rows: + return "" + + # detail 模式每个库最多列 40 个对象,多出来的直接看不见。对象一多, + # "40 张表带字段"反而不如"全部表只给名字"——Copilot 找不到那张表, + # 字段写得再全也没用。所以超出截断阈值就整体切到紧凑模式。 + detail = all(len(_introspect.table_names(row)) <= _DETAIL_TABLE_LIMIT for row in rows) + blocks = [_introspect.summary(row, detail=detail) for row in rows] + if detail and sum(len(b) for b in blocks) > _DATASOURCE_BUDGET_CHARS: + detail = False + blocks = [_introspect.summary(row, detail=False) for row in rows] + if not detail: + # Copilot 仍然知道有哪些对象可查,要字段时在图里放一个 db_schema 节点, + # 或者交给运行期的 agent 自己探 + blocks.append( + " (对象较多,上面只列了名字。写 SQL 前用 db_schema 工具确认字段,别猜)" + ) + return ( + "\n\n已接入的数据源(涉及取数时优先用它们,而不是让用户自己写 SQL 的代码节点):\n" + + "\n".join(blocks) + + "\n\n写 SQL 的硬性要求:\n" + # 这三条都是真实踩过的:Oracle 上漏 schema 前缀直接 ORA-00942, + # 写惯 MySQL 的模型会顺手写 LIMIT,而一条没有行数限制的查询能拖垮生产库 + "- 表名照抄上面的全名(含 schema 前缀),漏掉前缀在 Oracle 上会直接报 ORA-00942\n" + "- 认准方言:Oracle 用 FETCH FIRST n ROWS ONLY,不是 LIMIT\n" + "- 一次只写一条语句;分号拼接会被拒\n" + "- 字段拿不准就在图里先放一个 db_schema 工具节点,别猜字段名\n" + "- 取数结果要进口径卡(metrics 节点)才能被叙述引用,别让 llm 节点直接对数字做算术\n" + ) + + NODE_REFERENCE = """\ 可用节点类型(type 字段): - input:入口。config.fields = [{name, required, default, description}] @@ -180,9 +267,15 @@ GRAPH_SCHEMA: dict[str, Any] = { "id": {"type": "string"}, "type": {"type": "string", "enum": [t.value for t in NodeType]}, "label": {"type": "string"}, - "config": {"type": "object"}, + # additionalProperties 必须显式写 true:config 的键随节点类型 + # 千变万化(tool 有 tool/args,code 有 language/code…),没法 + # 提前枚举成 properties。而不少 provider 的结构化输出实现在 + # 遇到没有 properties 的 object 时按"不允许额外字段"处理, + # 于是模型填好的 config 被整个过滤成 {}——生成的图每个节点都 + # 是空壳,校验直接挂在"工具节点还没选工具"。 + "config": {"type": "object", "additionalProperties": True}, }, - "required": ["id", "type"], + "required": ["id", "type", "config"], }, }, "edges": { @@ -211,14 +304,12 @@ async def generate( 产物会经过和手工编排完全相同的校验与排版,所以生成完就是可运行、可读的, 而不是一段还要人去修的 JSON。 """ - tool_list = "\n".join( - f"- {name}({spec.category}):{spec.description}" - for name, spec in sorted(all_specs().items()) - ) + tool_list = await _tool_catalog(session) + datasources = await _datasource_section(session) system = ( "你是一个 agent 工作流编排专家。根据用户需求产出一张可执行的工作流图。\n\n" - f"{NODE_REFERENCE}\n\n可用工具:\n{tool_list}\n\n" + f"{NODE_REFERENCE}\n\n可用工具:\n{tool_list}{datasources}\n\n" "要求:\n" "1. 必须有且只有一个 input 节点和至少一个 output 节点\n" "2. 节点 id 用简短英文小写下划线,例如 fetch_data、summarize\n" @@ -460,13 +551,11 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g """ from fastapi.responses import StreamingResponse - tool_list = "\n".join( - f"- {name}({spec.category}):{spec.description}" - for name, spec in sorted(all_specs().items()) - ) + tool_list = await _tool_catalog(session) + datasources = await _datasource_section(session) system = ( "你是一个 agent 工作流编排专家。根据用户需求,以操作流的方式逐步搭出一张可执行的工作流图。\n\n" - f"{NODE_REFERENCE}\n\n可用工具:\n{tool_list}\n\n" + f"{NODE_REFERENCE}\n\n可用工具:\n{tool_list}{datasources}\n\n" "结构要求:\n" "1. 必须有且只有一个 input 节点和至少一个 output 节点\n" "2. 节点 id 用简短英文小写下划线;label 用中文,一眼看懂\n" diff --git a/backend/app/data/introspect.py b/backend/app/data/introspect.py index 4cd50b7a..ad281c8c 100644 --- a/backend/app/data/introspect.py +++ b/backend/app/data/introspect.py @@ -187,19 +187,42 @@ async def introspect(source: Any, *, schema: str | None = None) -> dict[str, Any return payload -def summary(source: Any, *, max_tables: int = 40) -> str: +def summary(source: Any, *, max_tables: int = 40, detail: bool = True) -> str: + """结构摘要。detail=False 只给对象名,不列字段——库多了要靠它压 token。 + + 实测一个 53 对象的库:带字段 2715 token,只给名字 956 token(35%)。 + 接三五个库的差别就是 15k 和 4.5k,后者才塞得进 system prompt。 + """ + if not detail: + cache = source.schema_cache or {} + names = table_names(source) + if not names: + return _empty_summary(source, cache) + head = ( + f"- {source.name}({source.kind},{'只读' if source.readonly else '可写'}):" + f"{source.description or '未填说明'}" + ) + return head + "\n 对象:" + "、".join(names) + return _detailed_summary(source, max_tables=max_tables) + + +def _empty_summary(source: Any, cache: dict[str, Any]) -> str: + hint = "" + candidates = cache.get("available_schemas") or [] + if candidates: + hint = f"|该账号名下没有对象,数据可能在这些 schema:{'、'.join(candidates[:8])}" + return ( + f"- {source.name}({source.kind}):{source.description or '未填说明'}" + f"|尚未探查结构{hint}" + ) + + +def _detailed_summary(source: Any, *, max_tables: int = 40) -> str: """给 Copilot 的紧凑摘要。没探查过就明说,别让它对着空气编表名。""" cache = source.schema_cache or {} tables = cache.get("tables") or {} if not tables: - hint = "" - candidates = cache.get("available_schemas") or [] - if candidates: - hint = f"|该账号名下没有对象,数据可能在这些 schema:{'、'.join(candidates[:8])}" - return ( - f"- {source.name}({source.kind}):{source.description or '未填说明'}" - f"|尚未探查结构{hint}" - ) + return _empty_summary(source, cache) schema = cache.get("schema") lines = [ diff --git a/backend/tests/test_copilot_context.py b/backend/tests/test_copilot_context.py new file mode 100644 index 00000000..61b43aa4 --- /dev/null +++ b/backend/tests/test_copilot_context.py @@ -0,0 +1,105 @@ +"""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