mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:51:57 +00:00
7b04162540
四件事,其中 Excel 那条不是"多支持一种格式",是补一个和地基冲突的缺口。
**Excel / CSV → 可以用 SQL 查的表。**
这个项目有条不可回退的决策:叙述层无算术权限,所有算术下沉到 SQL 或口径卡,
叙述只能引用(engine/issuance.py)。表格传进知识库只能被切块检索,数字就成了
模型从片段里"读"出来的——复核层拦得住凭空多出的数字,拦不住"从一堆片段里
读错了一格"。所以表格必须变成表。
不新增数据源类型:kind='sqlite' 早就支持,build_url 拿 database 当文件路径。
落成 .db 文件再建一条 DataSource 指过去,下游一行都不用改——SQL 守卫、结构
探查、db_query__<name> 工具、查询快照进工件库、Copilot 的数据源清单,全都白拿。
建表走 sqlite3 直连而不是 run_query:guard 的 _ALWAYS_DENIED 里有 create,
那是对的(模型不该能建表),而导入是我们自己发起的可信操作。
四个语义是这块的全部分量:
- **类型要推,不能全存 TEXT。** SQLite 是动态类型,但列亲和性决定排序和比较:
整数列落成 TEXT 的话 '10' < '9',SUM() 在混入空串时静默出错,而没有任何
地方会报。逐列扫描推断 INTEGER/REAL/TEXT,空单元格存 NULL 不存 "",
日期存 ISO(字典序恰好等于时间序)。测试里那条 ORDER BY 要是 9<10 而不是
'10'<'9',守的就是整块的意义。
- **表头取第 1 行,但不猜。** 不做"自动找表头"的启发式:猜错的表现是列名变成
一行数据,没有任何征兆,直到某天有人发现汇总一直少一行。改成 header_row
可填,并把推断出的列名和类型当场回显——错了一眼看见。
- **同名重传原地替换,id 和 name 都不变。** 名字会成为工具名(db_query__sales)
而工具名写进了保存过的图,另起一个 sales_2 等于悄悄让那些图失效。
- **中文原样保留。** 一开始把 sheet 名清洗成了 ASCII,结果「明细」「汇总」都变成
sheet、sheet_2,模型和用户都认不出哪张是哪张——而 SQLite 的标识符本来就允许
非 ASCII,那道清洗纯属自找麻烦。现在只替换会断句的空白和标点。
实测:两 sheet 的中文 xlsx → 两张表,金额列 INTEGER,跨表 JOIN + SUM 跑通,
Copilot 的工具清单里带着表名。CSV 按 utf-8-sig / utf-8 / gb18030 依次试——
GBK 的 CSV 是 Excel 另存为的默认行为,按 UTF-8 读会得到乱码列名,而乱码不报错。
知识库那边顺手改了指路:传 .xlsx/.csv 进去不再说"不是 UTF-8 文本",
而是告诉你该传到数据源那边、以及为什么。
**PPT。** 文本框和表格分开取(表格的 has_text_frame 是 False),幻灯片之间
留空行当段落边界。.ppt/.doc/.xls 这些老二进制格式认出来再拒,说清楚"另存为
新格式"——以前它们会掉进"不是 UTF-8 文本"那个兜底,对着一个 .ppt 说这话,
用户根本不知道该怎么办。
**问数据页的「接着跑」。** 挂了那一轮不该只剩一行红字:断点一直在 checkpoint
里躺着。按钮挂在 approvalsFor 那个每轮插槽里(本来就托着「看执行过程」和
「跑一下」)。不带图——问数据页改不了图,这里就是"用原来的配置重试失败的
那一步",配置本身错了要到画布上改。实测:续的是同一次运行(8→13 条事件),
只跑了 lp 往后,轨迹里有 run.resumed。
**文档 / 工具的地址。** /knowledge/kb/:docId 是新页面——文档原来连详情视图都
没有,而切块这层做了不少事(按行边界留重叠、跨块续表头),结果一处都看不见。
现在逐段列出来带字数和 token 数,还标出"这份文档里混着两个模型建的向量"
(换模型只重建了一部分时真会发生,而那会让这部分内容悄悄退回关键词检索)。
新增 GET /kb/documents/{id}。工具那边 tab key 从 tools 改成 library,
否则地址是 /tools/tools/<id>。
验证:后端 265 passed(新增 test_tabular.py 16 条);tsc 干净;三个 check 全过,
check-ui 补了文档/工具两节;四块都在真浏览器/真后端上端到端跑过。
开发库里造的演示数据源、会话和运行已清干净。
285 lines
11 KiB
Python
285 lines
11 KiB
Python
"""Excel / CSV → 可以用 SQL 查的表。
|
|
|
|
这块存在的理由不是"多支持一种格式"。这个项目有一条不可回退的决策:叙述层
|
|
无算术权限,所有算术下沉到 SQL 或口径卡(engine/issuance.py)。表格传进知识库
|
|
只能被切块检索,数字就成了模型从片段里"读"出来的——复核层拦得住凭空多出的
|
|
数字,拦不住"从一堆片段里读错了一格"。
|
|
|
|
所以这里最要紧的一条不是"能不能导进去",是**导进去的数字能不能真的被 SQL 算**:
|
|
一个整数列如果落成 TEXT,ORDER BY 就变成字典序('10' < '9'),而没有任何
|
|
地方会报错。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import sqlite3
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from app.data.tabular import UnsupportedTable, infer_type, load_into
|
|
from app.main import app
|
|
|
|
|
|
@pytest.fixture
|
|
async def client():
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
|
yield c
|
|
|
|
|
|
def xlsx(sheets: dict[str, list[list]]) -> bytes:
|
|
from openpyxl import Workbook
|
|
|
|
book = Workbook()
|
|
book.remove(book.active)
|
|
for title, rows in sheets.items():
|
|
sheet = book.create_sheet(title)
|
|
for row in rows:
|
|
sheet.append(row)
|
|
buf = io.BytesIO()
|
|
book.save(buf)
|
|
return buf.getvalue()
|
|
|
|
|
|
def query(db, sql):
|
|
conn = sqlite3.connect(str(db))
|
|
try:
|
|
return conn.execute(sql).fetchall()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def col_types(db, table):
|
|
return {r[1]: r[2] for r in query(db, f'PRAGMA table_info("{table}")')}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 类型推断:这块的全部意义
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_infer_type_basics():
|
|
assert infer_type([1, 2, 3]) == "INTEGER"
|
|
assert infer_type(["1", "2", "3"]) == "INTEGER" # CSV 读出来全是字符串
|
|
assert infer_type([1, 2.5]) == "REAL"
|
|
assert infer_type(["1.5", "2"]) == "REAL"
|
|
assert infer_type([1, "两个"]) == "TEXT"
|
|
assert infer_type([]) == "TEXT" # 整列空的,别猜
|
|
assert infer_type([None, " ", None]) == "TEXT"
|
|
# 空单元格不该把一个数字列拖成 TEXT——那正是 SUM() 静默出错的起点
|
|
assert infer_type([1, None, 3]) == "INTEGER"
|
|
assert infer_type([1, "", 3]) == "INTEGER"
|
|
# 真/假不是数量,不该被当成 INTEGER 去求和
|
|
assert infer_type([True, False]) == "TEXT"
|
|
|
|
|
|
def test_numbers_sort_as_numbers_not_as_text(tmp_path):
|
|
"""整块设计的落点:ORDER BY 一个数字列要是 9 < 10,不是 '10' < '9'。
|
|
|
|
列落成 TEXT 的话这条会反过来,而且不报任何错——只是从此以后每一次
|
|
排序、每一次 MAX()、每一次区间筛选都悄悄错着。
|
|
"""
|
|
db = tmp_path / "t.db"
|
|
load_into(str(db), xlsx({"数据": [["名称", "销量"], ["甲", 9], ["乙", 10], ["丙", 100]]}), "a.xlsx")
|
|
|
|
table = query(db, "SELECT name FROM sqlite_master WHERE type='table'")[0][0]
|
|
assert col_types(db, table)["销量"] == "INTEGER"
|
|
assert [r[0] for r in query(db, f'SELECT "名称" FROM "{table}" ORDER BY "销量"')] == \
|
|
["甲", "乙", "丙"]
|
|
assert query(db, f'SELECT SUM("销量") FROM "{table}"')[0][0] == 119
|
|
|
|
|
|
def test_empty_cells_become_null_not_empty_string(tmp_path):
|
|
db = tmp_path / "t.db"
|
|
load_into(str(db), xlsx({"s": [["a", "b"], [1, None], [2, " "]]}), "a.xlsx")
|
|
table = query(db, "SELECT name FROM sqlite_master WHERE type='table'")[0][0]
|
|
assert query(db, f'SELECT COUNT("b") FROM "{table}"')[0][0] == 0 # COUNT 不数 NULL
|
|
assert query(db, f'SELECT SUM("a") FROM "{table}"')[0][0] == 3
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 表 / 列
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_multiple_sheets_become_multiple_tables(tmp_path):
|
|
db = tmp_path / "t.db"
|
|
report = load_into(str(db), xlsx({
|
|
"销售明细": [["月份", "金额"], ["1月", 100]],
|
|
"Summary": [["k", "v"], ["总计", 100]],
|
|
}), "a.xlsx")
|
|
assert len(report.tables) == 2
|
|
# 中文 sheet 名原样留着。清洗成 ASCII 的话「明细」「汇总」都会变成
|
|
# sheet、sheet_2——模型和用户都认不出哪张是哪张
|
|
assert {t.name for t in report.tables} == {"销售明细", "Summary"}
|
|
assert query(db, 'SELECT "金额" FROM 销售明细')[0][0] == 100 # 不加引号也查得了
|
|
|
|
|
|
def test_chinese_columns_are_kept_verbatim(tmp_path):
|
|
"""列名保持原样。转写是有损的,而 SQLite 认带引号的标识符。"""
|
|
db = tmp_path / "t.db"
|
|
load_into(str(db), xlsx({"s": [["本月销量 (万元)", "x"], [12, "a"]]}), "a.xlsx")
|
|
table = query(db, "SELECT name FROM sqlite_master WHERE type='table'")[0][0]
|
|
assert "本月销量 (万元)" in col_types(db, table)
|
|
assert query(db, f'SELECT "本月销量 (万元)" FROM "{table}"')[0][0] == 12
|
|
|
|
|
|
def test_broken_headers_get_usable_names(tmp_path):
|
|
db = tmp_path / "t.db"
|
|
load_into(str(db), xlsx({"s": [["名称", None, "名称"], ["a", "b", "c"]]}), "a.xlsx")
|
|
table = query(db, "SELECT name FROM sqlite_master WHERE type='table'")[0][0]
|
|
cols = list(col_types(db, table))
|
|
assert cols == ["名称", "col_2", "名称_2"], cols
|
|
|
|
|
|
def test_header_row_can_be_moved(tmp_path):
|
|
"""不猜表头行。猜错的表现是列名变成一行数据,而且没有任何征兆。"""
|
|
db = tmp_path / "t.db"
|
|
sheets = {"s": [["2026 年销售报表", None], ["月份", "金额"], ["1月", 100]]}
|
|
|
|
load_into(str(db), xlsx(sheets), "a.xlsx", header_row=2)
|
|
table = query(db, "SELECT name FROM sqlite_master WHERE type='table'")[0][0]
|
|
assert list(col_types(db, table)) == ["月份", "金额"]
|
|
|
|
# 取默认第 1 行就会把标题当表头——这正是要让用户当场看见的那种错
|
|
load_into(str(db), xlsx(sheets), "a.xlsx")
|
|
assert "2026 年销售报表" in col_types(db, table)
|
|
|
|
|
|
def test_ragged_rows_are_padded(tmp_path):
|
|
db = tmp_path / "t.db"
|
|
load_into(str(db), xlsx({"s": [["a", "b", "c"], [1], [1, 2, 3, 4]]}), "a.xlsx")
|
|
table = query(db, "SELECT name FROM sqlite_master WHERE type='table'")[0][0]
|
|
assert query(db, f'SELECT COUNT(*) FROM "{table}"')[0][0] == 2
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# CSV
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_csv_with_gbk_encoding(tmp_path):
|
|
"""中文环境里 GBK 的 CSV 极常见(Excel 另存为 CSV 的默认行为)。
|
|
|
|
一律按 UTF-8 读会得到乱码列名,而乱码是不报错的——只是从此搜不到、看不懂。
|
|
"""
|
|
db = tmp_path / "t.db"
|
|
raw = "月份,金额\n1月,100\n".encode("gb18030")
|
|
load_into(str(db), raw, "销售.csv")
|
|
table = query(db, "SELECT name FROM sqlite_master WHERE type='table'")[0][0]
|
|
assert "月份" in col_types(db, table)
|
|
assert query(db, f'SELECT "金额" FROM "{table}"')[0][0] == 100
|
|
|
|
|
|
def test_tsv_and_semicolon_csv(tmp_path):
|
|
db = tmp_path / "t.db"
|
|
load_into(str(db), b"a\tb\n1\t2\n", "x.tsv")
|
|
table = query(db, "SELECT name FROM sqlite_master WHERE type='table'")[0][0]
|
|
assert list(col_types(db, table)) == ["a", "b"]
|
|
|
|
|
|
def test_unsupported_formats_say_what_to_do(tmp_path):
|
|
db = tmp_path / "t.db"
|
|
with pytest.raises(UnsupportedTable, match="另存为"):
|
|
load_into(str(db), b"x", "old.xls")
|
|
with pytest.raises(UnsupportedTable, match="只认"):
|
|
load_into(str(db), b"x", "a.pdf")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 重传
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_reupload_replaces_instead_of_appending(tmp_path):
|
|
"""重传的语义是"这份数据更新了"。追加会让行数悄悄翻倍,没人会发现。"""
|
|
db = tmp_path / "t.db"
|
|
load_into(str(db), xlsx({"s": [["a"], [1], [2]]}), "a.xlsx")
|
|
load_into(str(db), xlsx({"s": [["a"], [9]]}), "a.xlsx")
|
|
table = query(db, "SELECT name FROM sqlite_master WHERE type='table'")[0][0]
|
|
assert [r[0] for r in query(db, f'SELECT "a" FROM "{table}"')] == [9]
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 接口
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
async def _upload(client, name, content, filename="a.xlsx", **form):
|
|
return await client.post(
|
|
"/api/datasources/upload",
|
|
files={"file": (filename, content,
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
|
|
data={"name": name, **form},
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_creates_a_queryable_source(client):
|
|
resp = await _upload(
|
|
client, "tab_sales",
|
|
xlsx({"明细": [["月份", "金额"], ["1月", 100], ["2月", 250]]}),
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
body = resp.json()
|
|
assert body["source"]["kind"] == "sqlite"
|
|
assert body["replaced"] is False
|
|
# 列名和类型要原样回显:表头行取错了,用户当场就该看见
|
|
cols = {c["name"]: c["type"] for c in body["tables"][0]["columns"]}
|
|
assert cols == {"月份": "TEXT", "金额": "INTEGER"}
|
|
assert body["tables"][0]["rows"] == 2
|
|
# 建完立刻探查过,否则 db_query 工具的描述里没有表清单
|
|
assert body["source"]["table_count"] >= 1
|
|
|
|
# 真能查——而且走的是和别的数据源完全相同的那条路
|
|
from app.data.engine import run_query
|
|
from app.db.base import SessionLocal
|
|
from app.db.models import DataSource
|
|
from sqlalchemy import select as _select
|
|
|
|
async with SessionLocal() as session:
|
|
row = (await session.execute(
|
|
_select(DataSource).where(DataSource.name == "tab_sales")
|
|
)).scalar_one()
|
|
result = await run_query(row, 'SELECT SUM("金额") AS s FROM "明细"')
|
|
assert result.rows[0][0] == 350
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reupload_keeps_the_same_source_and_name(client):
|
|
"""重传不能换 id 或名字——名字是工具名(db_query__x),写进了保存过的图。"""
|
|
first = await _upload(client, "tab_keep", xlsx({"s": [["a"], [1]]}))
|
|
assert first.status_code == 201
|
|
src_id = first.json()["source"]["id"]
|
|
|
|
again = await _upload(client, "tab_keep", xlsx({"s": [["a"], [7], [8]]}))
|
|
assert again.status_code == 201, again.text
|
|
assert again.json()["replaced"] is True
|
|
assert again.json()["source"]["id"] == src_id
|
|
assert again.json()["source"]["name"] == "tab_keep"
|
|
assert again.json()["tables"][0]["rows"] == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_rejects_a_name_that_cannot_be_a_tool(client):
|
|
resp = await _upload(client, "带中文的名字", xlsx({"s": [["a"], [1]]}))
|
|
assert resp.status_code == 400
|
|
assert "工具名" in resp.json()["detail"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_will_not_hijack_a_real_database(client):
|
|
"""同名的真数据库不能被一个 Excel 顶掉。"""
|
|
from app.db.base import SessionLocal
|
|
from app.db.models import DataSource
|
|
|
|
async with SessionLocal() as session:
|
|
session.add(DataSource(name="tab_real", kind="mysql", host="h", database="d"))
|
|
await session.commit()
|
|
|
|
resp = await _upload(client, "tab_real", xlsx({"s": [["a"], [1]]}))
|
|
assert resp.status_code == 409
|
|
assert "换个名字" in resp.json()["detail"]
|