Files
AgentLab/backend/app/tools/datasource.py
T
ThearchyHelios d071bf119f 可点击证据第三期:表名字段名和引文也点得开、可疑实体与结论句、受管门禁 G1–G5、证据审计导出
一、表结构、实体与引文

- 数据源工具查库时顺带存一份表结构快照(schema_snapshot),和查询快照一起进台账、进封存;
  台账里的查询记下 SQL 用到的表
- 报告里的表名、字段名可以点:反引号里的名字一律核对,正文里的裸名字只在明显是标识符时才自动链接,
  常见英文单词不链接;点开看到它出现在哪次查询、类型、所属的表,快照被截断时写明
- 找不到的名字标成可疑实体:在快照、SQL、结果列里都没有的记「可能是编造的名字」并给出最接近的 3 个;
  快照被截断、核对不了的记「核对不了」,不冤枉成编造
- 检索引文 [[q:K1|原文]]:原文在检索快照的命中片段里逐字出现(空白归一化后)才算有出处,点开高亮原文位置
- 自动链接的名字不算句子的引用,只有写作者写的引用才算

二、结论句与出具

- 报告节点新增 claims:off(默认,保持原样)或 require_citation(没有引用的结论句计入缺口)
- 受管级别的正式运行里,可疑实体和没有引用的结论句按「有缺口」降档,不拦截;探索运行和已发布级别只标注
- 契约的 claims 和报告节点的设置取更严的那个;受管正式运行里写 ignore 也按降档处理,并给出警告
- 出具横幅除了数字,也写出可疑实体和没有引用的结论句

三、受管门禁 G1–G5(只看图和配置)

- G1 带契约的出口,成果字段的文字只能来自报告撰写节点
- G2 模型写的文字不绕过报告撰写节点直接流进出口
- G3 报告撰写节点写明 numbers: strict、on_violation: fail、claims: require_citation
- G4 口径卡的输入不来自计算角色的沙箱代码
- G5 给口径卡供数的 agent 配 output_schema 并开 cite_fields
- 受管级别是错误、挡住发布;已发布级别只给警告;已经发布的版本照常运行,下次发布时按新规则检查
- 每条规则都接进发布前自动修复(一键修复 / 给选项 / 交给 Copilot);禁止的改动补上关 cite_fields、
  删 output_schema、放宽契约 claims;Copilot 兜底不许自己把代码节点标成 source、不许放宽报告节点
- 发布弹窗里选「交给 Copilot」时明确写「正在交给 Copilot」

四、旧运行与审计

- 没有契约的旧答案:数字和已封存的查询单元格、口径卡值按数值比对,每个数最多 3 个候选,
  默认折叠,写明「猜测的来源,不能当证据」
- 有契约的旧运行:候选只从已封存的口径卡里找,人为插进工件表的行不当证据
- 证据审计:GET /api/runs/{id}/evidence/audit 按「无证据 / 可疑实体 / 有出处 / 旧运行猜测」分组,
  可导出 JSON 和 CSV(带封存状态);记录页和画布上的报告节点显示「有出处 N · 无证据 M」

五、SQL 守卫修正(安全)

- 去注释改成单遍扫描,认得字符串和各方言的引号(单引号转义、MySQL 反斜杠与 #、PostgreSQL 的 $$ 与嵌套注释、
  Oracle 的 q'[…]'、SQLite 的方括号);以前字符串里的 -- 会被当成注释,SELECT '--'; DROP … 这类写法能骗过多语句拦截
- 看不到服务器设置时每种读法都扫一遍,读法之间切分不一致就拒绝,任何一种读法是写操作就按写操作处理
- 执行查询和工具危险判定都按数据源的方言检查

六、其他

- 修掉检查脚本里画布检查的一处竞态,并行估时按实测更新
2026-09-29 05:26:34 +08:00

236 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""把数据源变成 agent 能调的工具。
每个启用的数据源生成一对工具,而不是"一个通用工具带 datasource 参数":
- 工具名自带语义(`db_query__sales`),模型不会把查销售库的 SQL 发给财务库
- 工具 description 里直接塞该库的表清单,模型一眼看到有什么可查,
省掉"先问有哪些表"那一轮
- 权限边界跟着工具走:只读源生成的工具就是只读的,dangerous 标记也各自独立
代价是工具数量随数据源线性增长。真到了几十个数据源的规模,再考虑按需装载。
"""
from __future__ import annotations
import json
from typing import Any
from langchain_core.tools import StructuredTool
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.data import introspect
from app.data.engine import masked_columns, query_timeout, run_query
from app.data.guard import QueryLimits, SqlRejected, is_write
from app.db.models import DataSource
from app.tools.registry import ToolContext
QUERY_PREFIX = "db_query__"
SCHEMA_PREFIX = "db_schema__"
class _QueryArgs(BaseModel):
sql: str = Field(description="要执行的 SQL,一次一条")
limit: int | None = Field(default=None, description="最多返回多少行,默认 1000")
class _SchemaArgs(BaseModel):
# 一次能问好几张。串行跑工具时一次调用就是一步,逐张问 6 张表就是 6 步——
# 实测一次 8 步的运行里 7 步花在这上面,最后只剩一步来真查数据(run dd9927e6)
table: str | list[str] | None = Field(
default=None,
description='表名。可以一次给多张:"user" 或 ["user","roles"];留空则列出所有表',
)
def tool_names(source: DataSource) -> list[str]:
return [f"{QUERY_PREFIX}{source.name}", f"{SCHEMA_PREFIX}{source.name}"]
def _no_schema_note(source: DataSource) -> str:
"""结构拿不到时,把实情和下一步一起交给 agent。
以前这里只说"结构尚未探查,请在设置页里执行一次"。两个问题:它可能是错的
(探查执行过了,是超时失败),而且它给的下一步 agent 根本做不到——agent
进不了设置页。于是它只能自己摸:run 554a0f92 里先猜了 7 个表名、又发了
一条 SELECT 1 试连通性、再用三条 information_schema 把 schema 重建一遍,
9 次工具调用花掉 6 次、29 秒之后才碰到真正要查的数据。
那条自救路线是对的,只是它自己摸索了三轮才找到。直接告诉它。
"""
cache = source.schema_cache or {}
note = f" {introspect.why_empty(cache)}。"
candidates = cache.get("available_schemas") or []
if candidates:
# 这几个候选一直在缓存里躺着,只是以前没人交给 agent
note += (
f"当前连的库是「{source.database or '未指定'}」,"
f"这台服务器上还有这些库可选:{'、'.join(candidates[:10])}。"
)
note += (
"**不要猜表名**——直接查数据字典自己确认,"
"例如 information_schema.tables / information_schema.columns"
"(SQLite 则是 sqlite_master),一条带 WHERE 的查询就能拿到表和字段。"
)
return note
def _query_description(source: DataSource) -> str:
tables = introspect.table_names(source)
head = f"在数据源「{source.name}」上执行 SQL 查询。"
if source.description:
head += f"{source.description}。"
head += "只读。" if source.readonly else "可写(写操作需要人工审批)。"
if tables:
listed = "、".join(tables[:25])
more = f" 等 {len(tables)} 张表" if len(tables) > 25 else ""
head += f" 可用表:{listed}{more}。"
head += " 字段不确定时先查结构,不要猜字段名。"
else:
head += _no_schema_note(source)
return head
async def build_datasource_tools(
names: list[str], ctx: ToolContext, session: AsyncSession
) -> list[StructuredTool]:
"""按工具名反查数据源并构造工具。build_tools 的第四个来源。"""
wanted: dict[str, list[str]] = {}
for name in names:
for prefix in (QUERY_PREFIX, SCHEMA_PREFIX):
if name.startswith(prefix):
wanted.setdefault(name[len(prefix):], []).append(prefix)
if not wanted:
return []
rows = list((
await session.execute(
select(DataSource).where(
DataSource.name.in_(list(wanted)), DataSource.enabled.is_(True)
)
)
).scalars())
tools: list[StructuredTool] = []
for row in rows:
for prefix in wanted.get(row.name, []):
if prefix == QUERY_PREFIX:
tools.append(_make_query_tool(row, ctx))
else:
tools.append(_make_schema_tool(row))
return tools
def _make_query_tool(source: DataSource, ctx: ToolContext) -> StructuredTool:
# 数据源在 options.query_timeout_s 里配了就用它,否则取缺省。数据库按它停下语句,
# 引擎按它告诉界面上限是多少(metadata["timeout_s"])
seconds = query_timeout(source)
async def _run(sql: str, limit: int | None = None) -> str:
limits = QueryLimits(timeout_seconds=seconds, **({"max_rows": int(limit)} if limit else {}))
try:
result = await run_query(source, sql, limits=limits)
except SqlRejected as e:
# 被守卫拒掉要把原因原样交回模型——它据此改写 SQL 重试,
# 给一句笼统的"失败了"只会让它瞎猜
return f"SQL 被拒绝:{e}"
except Exception as e: # noqa: BLE001
from app.core.errors import explain, first_line
# 驱动的原话留着("no such table: x" 正是模型改写 SQL 要的线索),
# 类名和 SQLAlchemy 的包装前缀去掉:这一行也会原样出现在运行面板上
return f"查询失败:{first_line(e) or explain(e)[0]}"
payload = result.to_payload()
payload["source"] = source.name
# 表结构快照:这次查询时数据源的结构冻结下来,报告里写的表名、字段名按它核对。事后数据源
# 重新探查、改了结构,已经跑完的运行核对的还是当时那一份
if schema := await _store_schema(source, ctx):
payload["schema_artifact"] = schema
# 查询快照进工件库:出具体系的数字回指要能下钻到"这个数是哪条 SQL 查出来的"
try:
from app.core.artifact_store import put_json
# 查询当时数据源设的遮罩列一起记进快照:数据源事后改名、删掉,证据面板仍按当时的遮。
# 只进快照,不进交给模型的结果(模型看到的是原值,多个字段只会让它以为数据被遮了);
# 没设遮罩的快照和原来一字不差
mask = masked_columns(source.options)
payload["artifact"] = await put_json(
{**payload, "mask_columns": mask} if mask else payload, kind="query_snapshot",
run_id=ctx.run_id or "", node_id=ctx.node_id or "",
)
except Exception: # noqa: BLE001 - 存不下不影响查询本身
pass
return json.dumps(payload, ensure_ascii=False, default=str)
return StructuredTool(
name=f"{QUERY_PREFIX}{source.name}",
description=_query_description(source),
args_schema=_QueryArgs,
coroutine=_run,
func=None,
# 同一个工具,危不危险看这一次的 SQL。审批关卡经 registry.call_is_dangerous 问它
metadata={"dangerous_if": lambda args: is_dangerous_call(source, str(args.get("sql") or "")),
"timeout_s": seconds},
)
async def _store_schema(source: DataSource, ctx: ToolContext) -> str | None:
"""把数据源此刻的 schema_cache 存成 schema_snapshot 工件,返回工件 id。
内容寻址:结构没变的话,同一次运行里查多少次都是同一件,文件只有一份。没探查过结构
(或者探查失败、一张表都没有)返回 None:没有东西可以冻结,报告也就不核对表名。
"""
cache = source.schema_cache or {}
tables = cache.get("tables")
if not isinstance(tables, dict) or not tables:
return None
from app.core.artifact_store import put_json
from app.engine.evidence import SCHEMA_SNAPSHOT
content = {"source": source.name, "tables": tables,
**{k: cache[k] for k in ("schema", "synced_at", "truncated", "total") if k in cache}}
try:
return await put_json(content, kind=SCHEMA_SNAPSHOT, run_id=ctx.run_id or "", node_id=ctx.node_id or "",
meta={"source": source.name, "tables": len(tables)})
except Exception: # noqa: BLE001 - 存不下不影响查询本身,只是这次报告不核对表名
return None
def _make_schema_tool(source: DataSource) -> StructuredTool:
async def _run(table: str | list[str] | None = None) -> str:
wanted = [table] if isinstance(table, str) else list(table or [])
# 逗号分隔也认:"user, roles" 和 ["user","roles"] 是同一个意思,
# 而模型两种都会写。为此拒绝一次调用,纯属浪费一步
wanted = [t.strip() for one in wanted for t in str(one).split(",") if t.strip()]
if wanted:
return "\n\n".join(introspect.describe_table(source, t) for t in wanted)
tables = introspect.table_names(source)
if not tables:
# 把实情交出去,而不是让它去点一个它点不到的按钮
return f"数据源「{source.name}」的结构信息不可用。" + _no_schema_note(source)
return f"数据源「{source.name}」共 {len(tables)} 张表:\n" + "\n".join(
f" {n}" for n in tables
)
return StructuredTool(
name=f"{SCHEMA_PREFIX}{source.name}",
description=(
f"查看数据源「{source.name}」的表结构。"
"写 SQL 前先用它确认字段名。不传 table 则列出所有表;"
"**要看多张表就一次全传进来**(table 可以是数组),不要一张一张问。"
),
args_schema=_SchemaArgs,
coroutine=_run,
func=None,
)
def is_dangerous_call(source: DataSource, sql: str) -> bool:
"""这次调用要不要走人工审批:非只读源上的写操作要。
只读源上的写不需要审批——守卫和连接层都会拒,批了也写不进去。
"""
return not source.readonly and is_write(sql, dialect=source.kind)