fischer-agentkit/src/agentkit/tools/bitable_tool.py

594 lines
24 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.

"""BitableTool — Agent tool for bitable data ingestion and CRUD via HTTP.
Implements KTD5 (REST API boundary even when co-deployed) and KTD11
(internal service token auth). The tool uses ``httpx.AsyncClient`` to call
the bitable REST API; it never imports BitableService directly.
Actions: create_table, import_excel, import_database, collect_api,
upsert_records, query_records, create_view, update_view,
update_field, delete_view.
Batch chunking: upsert and import operations send at most ``BATCH_SIZE``
records per HTTP request. On partial failure, the result includes
``successful_count`` and ``resume_from`` for breakpoint continuation.
"""
from __future__ import annotations
import asyncio
import logging
import httpx
from agentkit.bitable.ingestion.excel import ParsedSheet, parse_excel, parse_excel_url
from agentkit.bitable.ingestion.database import import_table as import_db_table
from agentkit.bitable.ingestion.api_collector import transform_records
from agentkit.tools.base import Tool
logger = logging.getLogger(__name__)
BATCH_SIZE = 500
class BitableTool(Tool):
"""Agent tool for bitable operations via REST API.
Args:
base_url: Bitable API base URL (e.g. ``http://localhost:18001/api/v1/bitable``).
internal_token: Service token for KTD11 auth. If ``None``, requests
go unauthenticated (will fail if the server requires auth).
"""
def __init__(self, base_url: str, internal_token: str | None = None) -> None:
super().__init__(
name="bitable",
description=(
"Create and manage bitable (multi-dimensional spreadsheet) tables, "
"ingest data from Excel files, databases, or API responses, and "
"query records. Actions: create_table, import_excel, "
"import_database, collect_api, upsert_records, query_records, "
"create_view, update_view, update_field, delete_view."
),
input_schema={
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"create_table",
"import_excel",
"import_database",
"collect_api",
"upsert_records",
"query_records",
"create_view",
"update_view",
"update_field",
"delete_view",
],
"description": "Bitable operation to perform.",
},
"table_name": {
"type": "string",
"description": "Name for the new bitable table (create_table, import_excel, import_database).",
},
"description": {
"type": "string",
"description": "Table description (create_table).",
},
"file_path": {
"type": "string",
"description": "Path to .xlsx file (import_excel).",
},
"file_url": {
"type": "string",
"description": "URL to download .xlsx file (import_excel).",
},
"connection_string": {
"type": "string",
"description": "Database connection string (import_database).",
},
"table_names": {
"type": "array",
"items": {"type": "string"},
"description": "Source table names to import (import_database).",
},
"table_id": {
"type": "string",
"description": "Target bitable table ID (collect_api, upsert_records, query_records, create_view).",
},
"records": {
"type": "array",
"description": "Records to write (collect_api, upsert_records).",
},
"field_mapping": {
"type": "object",
"description": "Mapping {source_key: bitable_field_id} (collect_api).",
},
"primary_key_field_id": {
"type": "string",
"description": "Field ID of the primary key (upsert_records, collect_api).",
},
"resume_from": {
"type": "integer",
"description": "Skip this many records before resuming a failed batch (upsert_records, collect_api).",
},
"cursor": {
"type": "string",
"description": "Pagination cursor (query_records).",
},
"limit": {
"type": "integer",
"description": "Max records to return (query_records).",
},
"view_id": {
"type": "string",
"description": "View ID (update_view, delete_view).",
},
"view_type": {
"type": "string",
"enum": ["grid", "kanban", "gantt", "gallery", "form"],
"description": "View type (create_view). Defaults to grid.",
},
"field_id": {
"type": "string",
"description": "Field ID (update_field).",
},
"name": {
"type": "string",
"description": "Name for a view or field (create_view, update_view, update_field).",
},
"type": {
"type": "string",
"description": "Field type for update_field (e.g. text, number, date).",
},
"config": {
"type": "object",
"description": "View/field config dict (create_view, update_view, update_field).",
},
},
"required": ["action"],
},
)
self._base_url = base_url.rstrip("/")
self._internal_token = internal_token
self._client: httpx.AsyncClient | None = None
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
headers: dict[str, str] = {}
if self._internal_token:
headers["X-Internal-Token"] = self._internal_token
self._client = httpx.AsyncClient(
base_url=self._base_url,
headers=headers,
timeout=60.0,
)
return self._client
async def close(self) -> None:
if self._client is not None and not self._client.is_closed:
await self._client.aclose()
async def execute(self, **kwargs) -> dict[str, object]:
action = kwargs.get("action")
handlers = {
"create_table": self._create_table,
"import_excel": self._import_excel,
"import_database": self._import_database,
"collect_api": self._collect_api,
"upsert_records": self._upsert_records,
"query_records": self._query_records,
"create_view": self._create_view,
"update_view": self._update_view,
"update_field": self._update_field,
"delete_view": self._delete_view,
}
handler = handlers.get(action)
if handler is None:
return {"success": False, "error": f"Unknown action: {action!r}"}
try:
return await handler(**kwargs)
except httpx.HTTPStatusError as e:
return {
"success": False,
"error": f"Bitable API error {e.response.status_code}: {e.response.text[:500]}",
}
except httpx.ConnectError as e:
return {"success": False, "error": f"Cannot connect to bitable API: {e}"}
except Exception as e:
return {"success": False, "error": f"{action} failed: {e}"}
# ------------------------------------------------------------------
# create_table
# ------------------------------------------------------------------
async def _create_table(self, **kwargs) -> dict[str, object]:
table_name = kwargs.get("table_name")
if not table_name:
return {"success": False, "error": "Missing required field: table_name"}
client = await self._get_client()
resp = await client.post(
"/tables",
json={"name": table_name, "description": kwargs.get("description", "")},
)
resp.raise_for_status()
data = resp.json()
return {"success": True, "table": data["table"]}
# ------------------------------------------------------------------
# import_excel
# ------------------------------------------------------------------
async def _import_excel(self, **kwargs) -> dict[str, object]:
file_path = kwargs.get("file_path")
file_url = kwargs.get("file_url")
if not file_path and not file_url:
return {"success": False, "error": "Either file_path or file_url is required"}
# Parse Excel — offload sync I/O to thread pool (P2 #21-23).
if file_path:
sheets = await asyncio.to_thread(parse_excel, file_path)
else:
sheets = await asyncio.to_thread(parse_excel_url, file_url)
if not sheets:
return {"success": False, "error": "Excel file has no sheets with data"}
results: list[dict[str, object]] = []
for sheet in sheets:
result = await self._import_sheet(sheet)
results.append(result)
return {"success": True, "sheets": results}
async def _import_sheet(self, sheet: ParsedSheet) -> dict[str, object]:
"""Create a bitable table from a parsed sheet and upsert all rows."""
client = await self._get_client()
# 1. Create table
resp = await client.post("/tables", json={"name": sheet.name})
resp.raise_for_status()
table_id = resp.json()["table"]["id"]
# 2. Create fields
field_name_to_id: dict[str, str] = {}
for col_name, field_type in zip(sheet.columns, sheet.field_types):
resp = await client.post(
f"/tables/{table_id}/fields",
json={"name": col_name, "field_type": field_type, "owner": "agent"},
)
resp.raise_for_status()
field_id = resp.json()["field"]["id"]
field_name_to_id[col_name] = field_id
# 3. Map record keys to field IDs and batch upsert
mapped_records = [
{field_name_to_id[k]: v for k, v in rec.items() if k in field_name_to_id}
for rec in sheet.records
]
if not mapped_records:
return {
"table_id": table_id,
"table_name": sheet.name,
"field_count": len(field_name_to_id),
"record_count": 0,
}
# Use first field as PK fallback (import_excel doesn't require a PK)
# If no PK is set, upsert won't work — use create_records instead
upsert_result = await self._batch_create_records(table_id, mapped_records)
return {
"table_id": table_id,
"table_name": sheet.name,
"field_count": len(field_name_to_id),
"record_count": upsert_result["successful_count"],
**upsert_result,
}
async def _batch_create_records(
self, table_id: str, records: list[dict[str, object]]
) -> dict[str, object]:
"""Create records in batches via POST /tables/{id}/records."""
client = await self._get_client()
total = len(records)
successful = 0
errors: list[dict[str, object]] = []
for start in range(0, total, BATCH_SIZE):
batch = records[start : start + BATCH_SIZE]
try:
resp = await client.post(
f"/tables/{table_id}/records",
json={"records": batch},
)
resp.raise_for_status()
successful += len(batch)
except httpx.HTTPStatusError as e:
errors.append(
{
"batch_start": start,
"batch_size": len(batch),
"status": e.response.status_code,
"error": e.response.text[:300],
}
)
break # stop on first failure
return {
"successful_count": successful,
"total": total,
"resume_from": successful,
**({"errors": errors} if errors else {}),
}
# ------------------------------------------------------------------
# import_database
# ------------------------------------------------------------------
async def _import_database(self, **kwargs) -> dict[str, object]:
conn_str = kwargs.get("connection_string")
table_names = kwargs.get("table_names")
if not conn_str:
return {"success": False, "error": "Missing required field: connection_string"}
if not table_names:
return {"success": False, "error": "Missing required field: table_names"}
results: list[dict[str, object]] = []
for src_table in table_names:
try:
# Offload sync DB reflection to thread pool (P2 #21-23).
reflected = await asyncio.to_thread(import_db_table, conn_str, src_table)
result = await self._import_reflected_table(reflected)
results.append(result)
except ConnectionError as e:
return {"success": False, "error": str(e), "imported": results}
except Exception as e:
results.append({"table_name": src_table, "success": False, "error": str(e)})
return {"success": True, "tables": results}
async def _import_reflected_table(self, reflected: dict[str, object]) -> dict[str, object]:
"""Create a bitable table from reflected DB data and upsert rows."""
client = await self._get_client()
table_name = reflected["table_name"]
# 1. Create table
resp = await client.post("/tables", json={"name": table_name})
resp.raise_for_status()
table_id = resp.json()["table"]["id"]
# 2. Create fields
field_name_to_id: dict[str, str] = {}
pk_field_id: str | None = None
for fdef in reflected["fields"]:
resp = await client.post(
f"/tables/{table_id}/fields",
json={
"name": fdef["name"],
"field_type": fdef["field_type"],
"owner": "agent",
},
)
resp.raise_for_status()
fid = resp.json()["field"]["id"]
field_name_to_id[fdef["name"]] = fid
if fdef.get("is_primary_key"):
pk_field_id = fid
# 3. Set primary key
if pk_field_id:
await client.patch("/tables/" + table_id, json={"primary_key_field_id": pk_field_id})
# 4. Map and upsert records
mapped = [
{field_name_to_id[k]: v for k, v in rec.items() if k in field_name_to_id}
for rec in reflected["records"]
]
if not mapped:
return {
"table_id": table_id,
"table_name": table_name,
"record_count": 0,
"success": True,
}
if pk_field_id:
upsert = await self._batch_upsert(table_id, mapped, pk_field_id)
else:
upsert = await self._batch_create_records(table_id, mapped)
return {
"table_id": table_id,
"table_name": table_name,
"record_count": upsert["successful_count"],
"success": True,
**upsert,
}
# ------------------------------------------------------------------
# collect_api
# ------------------------------------------------------------------
async def _collect_api(self, **kwargs) -> dict[str, object]:
table_id = kwargs.get("table_id")
records = kwargs.get("records")
field_mapping = kwargs.get("field_mapping")
pk_field_id = kwargs.get("primary_key_field_id")
resume_from = kwargs.get("resume_from", 0)
if not table_id:
return {"success": False, "error": "Missing required field: table_id"}
if not records:
return {"success": False, "error": "Missing required field: records"}
if not field_mapping:
return {"success": False, "error": "Missing required field: field_mapping"}
if not pk_field_id:
return {"success": False, "error": "Missing required field: primary_key_field_id"}
transformed = transform_records(records, field_mapping)
if resume_from > 0:
transformed = transformed[resume_from:]
result = await self._batch_upsert(table_id, transformed, pk_field_id)
return {"success": True, **result}
# ------------------------------------------------------------------
# upsert_records
# ------------------------------------------------------------------
async def _upsert_records(self, **kwargs) -> dict[str, object]:
table_id = kwargs.get("table_id")
records = kwargs.get("records")
pk_field_id = kwargs.get("primary_key_field_id")
resume_from = kwargs.get("resume_from", 0)
if not table_id:
return {"success": False, "error": "Missing required field: table_id"}
if not records:
return {"success": False, "error": "Missing required field: records"}
if not pk_field_id:
return {"success": False, "error": "Missing required field: primary_key_field_id"}
batch = records[resume_from:] if resume_from > 0 else records
result = await self._batch_upsert(table_id, batch, pk_field_id)
return {"success": True, **result}
async def _batch_upsert(
self, table_id: str, records: list[dict[str, object]], pk_field_id: str
) -> dict[str, object]:
"""Upsert records in batches of BATCH_SIZE via POST /tables/{id}/upsert."""
client = await self._get_client()
total = len(records)
successful = 0
errors: list[dict[str, object]] = []
for start in range(0, total, BATCH_SIZE):
batch = records[start : start + BATCH_SIZE]
try:
resp = await client.post(
f"/tables/{table_id}/upsert",
json={
"records": batch,
"primary_key_field_id": pk_field_id,
},
)
resp.raise_for_status()
data = resp.json()
successful += data.get("inserted", 0) + data.get("updated", 0)
except httpx.HTTPStatusError as e:
errors.append(
{
"batch_start": start,
"batch_size": len(batch),
"status": e.response.status_code,
"error": e.response.text[:300],
}
)
break
return {
"successful_count": successful,
"total": total,
"resume_from": successful,
**({"errors": errors} if errors else {}),
}
# ------------------------------------------------------------------
# query_records
# ------------------------------------------------------------------
async def _query_records(self, **kwargs) -> dict[str, object]:
table_id = kwargs.get("table_id")
if not table_id:
return {"success": False, "error": "Missing required field: table_id"}
client = await self._get_client()
params: dict[str, object] = {}
if kwargs.get("cursor"):
params["cursor"] = kwargs["cursor"]
if kwargs.get("limit"):
params["limit"] = kwargs["limit"]
resp = await client.get(f"/tables/{table_id}/records", params=params)
resp.raise_for_status()
data = resp.json()
return {
"success": True,
"records": data["records"],
"next_cursor": data.get("next_cursor"),
}
# ------------------------------------------------------------------
# View & field CRUD (U6: agent parity with REST endpoints)
# ------------------------------------------------------------------
async def _create_view(self, **kwargs) -> dict[str, object]:
table_id = kwargs.get("table_id")
name = kwargs.get("name")
if not table_id:
return {"success": False, "error": "Missing required field: table_id"}
if not name:
return {"success": False, "error": "Missing required field: name"}
view_type = kwargs.get("view_type") or "grid"
config = kwargs.get("config") or {}
client = await self._get_client()
resp = await client.post(
f"/tables/{table_id}/views",
json={"name": name, "view_type": view_type, "config": config},
)
resp.raise_for_status()
return {"success": True, "view": resp.json()["view"]}
async def _update_view(self, **kwargs) -> dict[str, object]:
view_id = kwargs.get("view_id")
if not view_id:
return {"success": False, "error": "Missing required field: view_id"}
payload: dict[str, object] = {}
if kwargs.get("name") is not None:
payload["name"] = kwargs["name"]
if kwargs.get("config") is not None:
payload["config"] = kwargs["config"]
client = await self._get_client()
resp = await client.patch(f"/views/{view_id}", json=payload)
resp.raise_for_status()
return {"success": True, "view": resp.json()["view"]}
async def _update_field(self, **kwargs) -> dict[str, object]:
field_id = kwargs.get("field_id")
if not field_id:
return {"success": False, "error": "Missing required field: field_id"}
# DR-5: type 变更不被支持 — UpdateFieldRequest 当前只接受 name + config
# 传入 type 会被 Pydantic extra=ignore 静默丢弃(用户以为改了类型,实际无效果)。
# 显式拒绝并返回错误,避免静默失败的 UX 陷阱。
# 升级路径:如需支持类型变更,扩展 UpdateFieldRequest 接受 field_type +
# 实现数据迁移逻辑(类型转换 + 校验)。
if kwargs.get("type") is not None:
return {
"success": False,
"error": (
"Unsupported field type change — field type is immutable after "
"creation. To change a field's type, delete and recreate it."
),
"code": "UNSUPPORTED_FIELD_TYPE_CHANGE",
}
payload: dict[str, object] = {}
if kwargs.get("name") is not None:
payload["name"] = kwargs["name"]
if kwargs.get("config") is not None:
payload["config"] = kwargs["config"]
client = await self._get_client()
resp = await client.patch(f"/fields/{field_id}", json=payload)
resp.raise_for_status()
return {"success": True, "field": resp.json()["field"]}
async def _delete_view(self, **kwargs) -> dict[str, object]:
view_id = kwargs.get("view_id")
if not view_id:
return {"success": False, "error": "Missing required field: view_id"}
client = await self._get_client()
resp = await client.delete(f"/views/{view_id}")
resp.raise_for_status()
# 204 No Content has an empty body; report a stable success shape.
return {"success": True, "deleted": True}