Files
AgentLab/backend/app/tools/registry.py
T
ThearchyHelios 8059cd5147 Excel 导入改造期 1:止血与版本底座——写错的列名不再静默得 0,导入不再悄悄读错,每次上传生成不可变的版本,运行固定发起时的数据版本
连接层
- 所有 SQLite 数据源关闭双引号字符串兼容(DQS),写错的双引号列名直接报错;设置失败就拒绝连接,启动时自检
- 连接缓存按数据源加版本快照分键、按源整体失效、最多 32 个;版本快照以 immutable 只读打开,建连接前核对文件哈希,并在借出连接时核对文件指纹,被改过就拒绝查询
- 结构探查遇到取不了列的视图时逐个对象重取,只跳过坏的那个并注明原因

解析(新 xlsx_scan + tabular)
- 先流式扫描真实边界和版式(不信 dimension),再按边界读值,不整表进内存;按 Excel 自身上限设硬上限,并发解析最多两个
- 隐藏和深度隐藏的工作表不导入;稀疏区域、无缓存值的公式、同名工作表、DTD 拒收
- 去掉空行和两侧空列;列名表名统一规范化(新 names 模块)并保留原表头,撞名不再互相覆盖
- 前导零、超长数字按文本;千分位按数字;日期不带 T;带单位的数字文本写进回执
- 数字列混入非数字、交叉表和多块结构必须由用户决定(422),可选存空值或按原样导入(未规整);STRICT 建表,整份在一个事务里写完

版本底座(新 table_versions、raw_store)
- 每次上传写新库文件、落盘、0444,事务里写构建、导入记录、快照后才切指针;失败时旧版完整可查
- 原件按哈希存档,不提供下载,可以清除;上传源不许改连接字段、不再重新探查;手工 SQLite 源不许指向上传目录和应用自己的数据文件(按文件身份判断)
- 启动时清理半成品与孤儿文件、给老上传补初始版本;被运行引用的版本不回收,发起运行与回收互斥
- 上传接口的解析移到线程池

运行固定数据版本
- 发起时把图里(含子工作流)用到的上传源的当前快照记进运行记录和 run.started,续跑、审批恢复沿用;数据源工具按固定的快照生成描述、表结构并查询
- 固定的版本没了、源被删除后同名重建,都明确报错,不改用当前版本或同名的新源;查询快照记 data_version

证据层
- 新报告记 entity_syntax: 2,[[t:明细]]、[[c:明细.金额]] 和 SQL 里不加引号的中文表名都认得,名字按 SQLite 的口径查找;老报告按原规则复核,结果不变
- 写作规则改为中文表名、字段名一律用 [[t:]] / [[c:]];未规整的表在工具描述、写作目录和裁判摘录里标出

前端
- 上传弹窗接上导入前的决定(混合列、交叉表)和导入回执(跳过的隐藏表、类型转换、警告、原表头对照、未规整标记),上传前告知原件会保存
- 数据源卡片按 origin 认上传的表格,显示当前版本;上传的表格不再给「探查结构」
2026-10-01 13:42:04 +08:00

369 lines
14 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.
from __future__ import annotations
import inspect
import json
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Awaitable, Callable
from langchain_core.tools import BaseTool, StructuredTool
from pydantic import BaseModel, ValidationError
ToolFunc = Callable[..., Awaitable[Any]]
class Versions(Enum):
"""ToolContext.data_versions 除了运行固定的那个 dict 之外的两个取值。"""
#: 不在运行里(工具库试用、试用前的副作用检查):用数据源的当前版本。这类调用要显式传它
CURRENT = "current"
#: 缺省值:构造 ToolContext 的地方没说用哪一版。在运行里漏传了固定的版本就是这样——上传源遇到它
#: 报错,不退回当前版本:静默改用当前版本,同一次运行就可能前后查到两版数据而没人察觉
UNSET = "unset"
@dataclass
class ToolContext:
"""一次调用里工具能用到的运行期依赖。"""
run_id: str = ""
node_id: str = ""
sandbox_session: str = "default"
memory_scope: str = "default"
collection: str = "default"
#: 往运行的事件流里发一条事件:节点传 NodeContext.emit(同步);协程函数也认,调用方 await 它的返回值
emit: Callable[..., Any] | None = None
#: 用哪一版上传表格。运行里是固定的版本(runs.data_versions:{源 id: {"snapshot": 快照 id, "name": 源名}}),
#: 由构造 ToolContext 的节点从运行上下文取来(tools/datasource.py 的 run_versions);同一段执行里各节点
#: 拿到的是同一个 dict,运行中途第一次用到的源固定下来后,后面的节点都看得到。
#: None 是升级前发起的运行,用当前版本;Versions.CURRENT 是不在运行里的调用,用当前版本;
#: 缺省 Versions.UNSET 是谁都没给——上传源遇到它报错,见 Versions
data_versions: dict[str, Any] | Versions | None = Versions.UNSET
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class ToolSpec:
name: str
description: str
category: str
args_schema: type[BaseModel]
func: ToolFunc
# 需要人工确认才执行(写文件、跑代码、发请求这类有副作用的)
dangerous: bool = False
# 函数第一个参数是否接收 ToolContext
needs_context: bool = True
def json_schema(self) -> dict[str, Any]:
return self.args_schema.model_json_schema()
_REGISTRY: dict[str, ToolSpec] = {}
def register(
name: str,
description: str,
category: str,
args_schema: type[BaseModel],
*,
dangerous: bool = False,
needs_context: bool = True,
) -> Callable[[ToolFunc], ToolFunc]:
def deco(func: ToolFunc) -> ToolFunc:
_REGISTRY[name] = ToolSpec(
name=name,
description=description,
category=category,
args_schema=args_schema,
func=func,
dangerous=dangerous,
needs_context=needs_context,
)
return func
return deco
def all_specs() -> dict[str, ToolSpec]:
import app.tools.builtin # noqa: F401 触发内置工具注册
return dict(_REGISTRY)
def get_spec(name: str) -> ToolSpec | None:
return all_specs().get(name)
def call_is_dangerous(tool: BaseTool | None, name: str, args: dict[str, Any]) -> bool:
"""这一次调用要不要人工确认。所有审批关卡都问这一个函数。
内置工具危不危险是固定的(ToolSpec.dangerous)。动态工具要看这一次传了
什么:同一个 db_query__<源>,SELECT 不用确认,可写源上的 DELETE 必须确认。
它们把判定挂在工具的 metadata["dangerous_if"] 上。以前没有这一层,数据源
工具查不到 ToolSpec,于是在任何审批模式下都被当成安全的。
"""
spec = get_spec(name)
if spec is not None:
return spec.dangerous
judge = ((tool.metadata or {}) if tool is not None else {}).get("dangerous_if")
return bool(judge and judge(args))
def _stringify(value: Any) -> str:
"""工具结果最终是喂给模型的,所以统一成字符串;结构化数据转紧凑 JSON。"""
if isinstance(value, str):
return value
if value is None:
return ""
return json.dumps(value, ensure_ascii=False, default=str, indent=2)
def build_tool(spec: ToolSpec, ctx: ToolContext) -> BaseTool:
"""把注册表里的定义 + 运行期上下文,绑成一个 LangChain 工具。"""
async def _run(**kwargs: Any) -> str:
if spec.needs_context:
result = await spec.func(ctx, **kwargs)
else:
result = await spec.func(**kwargs)
return _stringify(result)
return StructuredTool(
name=spec.name,
description=spec.description,
args_schema=spec.args_schema,
coroutine=_run,
# 只给异步实现;同步路径直接报错,避免在事件循环里被阻塞调用
func=None,
)
async def build_tools(
names: list[str], ctx: ToolContext, *, session: Any = None
) -> list[BaseTool]:
"""按名字列表组装工具。
四种来源:内置(静态注册)、MCP(mcp:<server>/<tool>)、数据源
(db_query__<源名> / db_schema__<源名>)、自定义(数据库里的 CustomTool)。
后两种都是运行时从库里读出来动态构造的,不在静态注册表里。
"""
from app.tools.datasource import QUERY_PREFIX, SCHEMA_PREFIX
specs = all_specs()
tools: list[BaseTool] = []
mcp_names: list[str] = []
datasource_names: list[str] = []
custom_names: list[str] = []
for name in names:
if name.startswith("mcp:"):
mcp_names.append(name)
elif name in specs:
tools.append(build_tool(specs[name], ctx))
elif name.startswith((QUERY_PREFIX, SCHEMA_PREFIX)):
datasource_names.append(name)
else:
custom_names.append(name)
if datasource_names and session is not None:
from app.tools.datasource import build_datasource_tools
tools.extend(await build_datasource_tools(datasource_names, ctx, session))
if mcp_names:
from app.tools.mcp_manager import mcp_manager
tools.extend(await mcp_manager.get_tools(mcp_names))
if custom_names and session is not None:
from app.tools.custom import build_custom_tools
tools.extend(await build_custom_tools(custom_names, ctx, session))
return tools
class ToolBuildError(ValueError):
"""绑定的工具建不出来:它自己的配置写坏了(比如自定义工具的参数定义)。
message 直接给人看:是哪个工具、坏在哪、去哪里改。节点据此报一句清楚的错,
而不是让 schema 转换在半路炸出一句「'str' object has no attribute 'get'」。
"""
class ToolArgsError(ValueError):
"""工具参数对不上 schema。
message 是直接给人和模型看的:报错里必须说清楚"收到了什么"和"它接受什么",
因为看到这句话的下一步动作就是照着改参数名。不要再往外包一层。
"""
_TYPE_LABELS = {
"string": "字符串", "integer": "整数", "number": "数字",
"boolean": "布尔", "array": "数组", "object": "对象",
}
def _type_label(prop: dict[str, Any]) -> str:
"""JSON Schema 的类型写成人话。可选字段是 anyOf[T, null],要摊开看。"""
names: list[str] = []
for node in [prop, *(prop.get("anyOf") or [])]:
kind = node.get("type")
if isinstance(kind, str) and kind != "null":
names.append(_TYPE_LABELS.get(kind, kind))
return "/".join(dict.fromkeys(names)) or "任意"
def args_model_of(tool: BaseTool) -> type[BaseModel] | None:
"""工具的参数模型。
MCP 这类动态工具的 args_schema 可能直接是一份 JSON Schema dict,拿不到
pydantic 模型——那种只能原样放行,不能因为校验不了就拒绝调用。
"""
schema = getattr(tool, "args_schema", None)
if isinstance(schema, type) and issubclass(schema, BaseModel):
return schema
return None
def describe_args(
schema: type[BaseModel],
got: dict[str, Any] | None = None,
error: ValidationError | None = None,
) -> str:
"""把"哪里不对、它到底要什么"写成一句能照着改的话。
字段说明取 Field(description=...),各工具定义里本来就写了,这里只是搬到报错里
——模型拿到这句话就能在下一步自己改对,不用人工介入。
返回的永远是一句完整的话("参数有误:…。可用参数:…"),调用方直接拼在
工具名后面即可,不用猜这次会不会缺个主语。
"""
js = schema.model_json_schema()
props: dict[str, Any] = js.get("properties") or {}
required = [k for k in (js.get("required") or []) if k in props]
fields: list[str] = []
for key, prop in props.items():
tag = "必填" if key in required else "可选"
desc = str(prop.get("description") or "").strip()
fields.append(f"{key}({tag},{_type_label(prop)})" + (f" — {desc}" if desc else ""))
tail = "可用参数:" + ";".join(fields) if fields else "该工具不接受任何参数。"
got = dict(got or {})
unknown = [k for k in got if k not in props]
missing = [k for k in required if k not in got]
problems: list[str] = []
if unknown:
problems.append(f"{'、'.join(unknown)} 不是该工具的参数")
if missing:
problems.append(f"缺少必填参数 {'、'.join(missing)}")
for err in error.errors() if error else ():
loc = ".".join(str(p) for p in (err.get("loc") or ()))
if not loc or loc in unknown or loc in missing:
continue # 上面两条已经说过了,不重复
# 用 schema 里的类型说,不要把 pydantic 那句英文原样甩出去
problems.append(
f"{loc} 的值不合法,应为{_type_label(props[loc])}"
if loc in props
else f"{loc} 的值不合法({err.get('msg', '')})"
)
head = "参数有误:" + ";".join(problems) + "。" if problems else "参数有误。"
return f"{head}{tail}"
def prepare_args(
schema: type[BaseModel], args: dict[str, Any]
) -> tuple[dict[str, Any], str | None]:
"""校验工具参数,必要时纠正一个明显写错的参数名。
返回 (可直接展开给函数的 payload, 纠正说明或 None)。纠正说明不为空时调用方
**必须**把它说出去——参数还错在配置里/还错在模型脑子里,替它跑通一次却不吭声,
下一次照样踩。
多余的参数名在这里是硬错误,哪怕校验能过:pydantic 默认把认不出的键悄悄丢掉,
于是全可选字段的 schema 上 {"tabel": "orders"} 会"成功"执行一次什么都没查的调用。
这种静默失败比报错难查得多。
纠正只在候选唯一时发生。多于一个就有排列组合,猜对了也是运气,不如把 schema
摊开让人或模型自己改。
"""
args = dict(args or {})
js = schema.model_json_schema()
props: dict[str, Any] = js.get("properties") or {}
required = [k for k in (js.get("required") or []) if k in props]
unknown = [k for k in args if k not in props]
if not unknown:
try:
return schema(**args).model_dump(), None
except ValidationError as e:
raise ToolArgsError(describe_args(schema, args, e)) from e
# 少了哪个必填的,就是多出来那个想写的;一个必填都不缺时,退而看还空着哪个可选字段
missing = [k for k in required if k not in args] or [k for k in props if k not in args]
residual: ValidationError | None = None
if len(unknown) == 1 and len(missing) == 1:
fixed = dict(args)
fixed[missing[0]] = fixed.pop(unknown[0])
try:
payload = schema(**fixed).model_dump()
except ValidationError as e:
# 改完名还是不合法。把这些毛病一并说出来,省得对方改对了名字
# 再回来撞一次类型——describe_args 会跳过已经报过的那几个字段
residual = e
else:
return payload, f"参数名 {unknown[0]} 不存在,已按唯一候选 {missing[0]} 执行"
raise ToolArgsError(describe_args(schema, args, residual))
async def call_tool(
name: str,
args: dict[str, Any],
ctx: ToolContext,
session: Any = None,
*,
on_fix: Callable[[str], None] | None = None,
) -> Any:
"""直接调用一个工具(工具节点、以及设置页的"试一下"按钮用)。
两条分支都先过 args_schema。动态工具那条以前是裸 `**args` 展开的,参数名写错
时抛的是 TypeError,报错里只剩内部闭包名——`_make_query_tool.<locals>._run() got
an unexpected keyword argument 'query'` 既指不到是哪个工具,也说不出该填什么。
on_fix 在参数名被纠正时回调一次。call_tool 不该知道事件系统长什么样,
所以只把这件事交出去,由调用方决定说给谁听。
"""
spec = get_spec(name)
if spec:
payload, note = prepare_args(spec.args_schema, args)
if note and on_fix:
on_fix(note)
if spec.needs_context:
return await spec.func(ctx, **payload)
return await spec.func(**payload)
tools = await build_tools([name], ctx, session=session)
if not tools:
raise KeyError(f"找不到工具 {name}")
tool = tools[0]
model = args_model_of(tool)
if model is None:
payload = dict(args)
else:
payload, note = prepare_args(model, args)
if note and on_fix:
on_fix(note)
if tool.coroutine:
return await tool.coroutine(**payload)
result = tool.invoke(payload)
if inspect.isawaitable(result):
return await result
return result