Files
AgentLab/backend/app/data/tabular.py
T
ThearchyHelios 03c18c4d78 界面文案整改:口语改书面、术语统一、不再露出实现细节;修掉几处说错的提示;新增文案检查防回潮
一、文案(前端约 1600 处,后端传到界面的约 880 处)
- 术语统一:工作流层面用「运行」(继续运行、重新运行、试运行),节点和工具层面用「执行」,不再用「跑」;
  总时长 / 执行时长 / 等待审批;服务端;助手;Agent;token;工作流;固定版本;模型接入;系统提示;批准 / 驳回;受管级别
- 口语改书面:取不到、没取回来、读不懂、连不上、多半、照样、兜底、揉成这类说法改成「无法 / 未能 / 失败 / 请」;去掉拟人写法
- 不再露出实现细节:界面上不写 checkpoint、同一拍、哈希(凭证处除外),不写改版历史(「以前…」「老数据」);
  报错、校验、门禁、自动修复里裸写的配置键名和枚举值换成界面上的中文标签,由新的 backend/app/engine/labels.py 统一提供;
  发布弹窗里结论句的选项不再是 off / require_citation
- 同一件事只留一种说法:生成阶段文案、出具档位说明、画布锁定提示各合成一份
- 去掉界面上的工业场景示例(MES、ERP、「张工」「王工」)和真实产品名,换成通用示例

二、说错的提示(都有测试证明)
- 数据库测连接失败时的专用提示以前从不出现(按报错文字全等比较,永远比不上):改为按错误类型判断,不再拼出重复的前缀
- 「设置 → 供应商」改指「设置 → 模型接入」;「在设置里建数据源」改指「数据」页
- 缺少 API Key、模型接入已停用的报错不再夹英文,写明去哪里填
- 两处原因说错(「这种引用在后续版本支持」「这一版还不支持」)改成真实原因
- 节点说明里不准确的说法改正:代码节点在 microVM 或系统沙箱中执行;报告撰写、口径卡的说明不再说过头
- 导出的证据 CSV 里两列同名,第三列改为「片段编号」

三、前后端对齐
- 前端按后端原文识别日志、报错的地方,新旧两种写法都认得(历史运行里存的是旧文字),逐条补了新旧原文的核对
- 后端交回模型的原指令放进单独的 for_model 字段,前端在解析响应时去掉,不会显示到界面上

四、检查
- 检查脚本里按原文匹配的断言改成新文案;改完会变成永远成立的反向断言逐条修正,并做了变异验证
- 新增 check-copy(静态扫描前端界面字符串和后端上界面的文案):再出现「跑」「后端」「多半」「令牌」、
  界面上的 Copilot、裸写的配置键名、半角标点等就报错;确需保留的写进 scripts/copy-allow.json 并注明理由
2026-09-30 08:05:15 +08:00

350 lines
13 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.
"""把 Excel / CSV 变成一个可以用 SQL 查的 SQLite 库。
**为什么不走知识库那条路。** 这个项目有一条不可回退的决策:叙述层无算术权限,
所有算术下沉到 SQL 或口径卡,叙述只能引用(见 engine/issuance.py)。表格数据
传进知识库只能被切块检索,于是数字是模型从片段里"读"出来的——复核层
(engine/review.py)拦得住凭空多出的数字,拦不住"从一堆片段里读错了一格"。
所以表格必须变成表。
**为什么落成 SQLite 而不是新造一种数据源。** kind='sqlite' 早就支持,
engine.build_url 拿 database 当文件路径。落成 .db 文件再建一条 DataSource,
下游一行都不用改:SQL 守卫、结构探查、db_query__<name> 工具、查询快照进
工件库、Copilot 的数据源清单,全都白拿。
**建表必须用这里的直连,不能走 data.engine.run_query。** guard 的
_ALWAYS_DENIED 里有 create,那是对的——模型不该能建表。而导入是我们自己
发起的可信操作,两件事不该共用一条通道。
"""
from __future__ import annotations
import csv
import io
import re
import sqlite3
import zipfile
from dataclasses import dataclass, field
from typing import Any
from app.core.errors import describe_exception
#: 扫多少行来推断列类型。全表扫一遍对几十万行的文件太慢,而类型这种东西
#: 前几千行看不出来的,后面多半也看不出来——真看错了,SQLite 的动态类型
#: 也还能把值原样存下去,不会丢数据
_TYPE_SAMPLE = 5000
_CSV = {".csv", ".tsv"}
_EXCEL = {".xlsx", ".xlsm"}
_LEGACY_EXCEL = {".xls"}
class UnsupportedTable(ValueError):
"""认不出或读不了。message 直接给用户看。"""
@dataclass
class LoadedTable:
name: str
columns: list[str]
types: list[str]
rows: int
#: 原始 sheet 名。清洗成表名之后,用户要能对上是哪一张
source_name: str = ""
@dataclass
class LoadReport:
tables: list[LoadedTable] = field(default_factory=list)
@property
def total_rows(self) -> int:
return sum(t.rows for t in self.tables)
def _ext(filename: str) -> str:
idx = filename.rfind(".")
return filename[idx:].lower() if idx >= 0 else ""
# --------------------------------------------------------------------------
# 表名 / 列名
# --------------------------------------------------------------------------
def _table_name(raw: str, taken: set[str]) -> str:
"""sheet 名 → 表名。中文原样保留,只替换真正会断句的字符。
一开始清洗成了纯 ASCII,结果「明细」「汇总」两张表都变成 sheet、sheet_2——
模型和用户都认不出哪张是哪张。而 SQLite 的标识符本来就允许非 ASCII,
`FROM 明细` 不加引号也解析得了,所以那道清洗纯属自找麻烦。
真需要处理的只有空白和标点:`销售 明细` 不引号就断成两个 token。
正则的词字符类按 Unicode 判定,汉字算词字符,正好留下。
"""
slug = re.sub(r"[^\w]+", "_", (raw or "").strip(), flags=re.UNICODE).strip("_")
if not slug:
slug = "sheet"
elif slug[0].isdigit():
slug = f"t_{slug}" # 标识符不能数字开头
base, n = slug[:48], 2
slug = base
while slug in taken:
slug = f"{base}_{n}"
n += 1
taken.add(slug)
return slug
def _clean_headers(raw_row: list[Any]) -> list[str]:
"""表头单元格 → 列名。**保持原样**,只治两种病态。
不转写、不 slug 化:Excel 表头几乎都是中文,转写是有损的,而 SQLite 认
带引号的标识符,db_schema__<name> 又会把准确列名交给模型。
"""
out: list[str] = []
seen: dict[str, int] = {}
for i, cell in enumerate(raw_row):
name = str(cell).strip() if cell is not None else ""
# 空表头给个位置名。合并单元格、末尾多出来的空列都会走到这里
if not name:
name = f"col_{i + 1}"
# 双引号会把带引号的标识符打断,是唯一必须动的字符
name = name.replace('"', "'")
if name in seen:
seen[name] += 1
name = f"{name}_{seen[name]}"
else:
seen[name] = 1
out.append(name)
return out
# --------------------------------------------------------------------------
# 类型推断
# --------------------------------------------------------------------------
_INT_RE = re.compile(r"^[+-]?\d{1,18}$")
_FLOAT_RE = re.compile(r"^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$")
def _norm(value: Any) -> Any:
"""单元格 → 要存进去的值。空的一律 NULL。
空字符串和 NULL 必须分清楚:存成 "" 的话,一个本该是数字的列就被一个
空单元格拖成了 TEXT,然后 SUM() 静默地不再是你以为的那个 SUM()。
"""
if value is None:
return None
if isinstance(value, str):
text = value.strip()
return text or None
return value
def infer_type(values: list[Any]) -> str:
"""一列的 SQLite 类型。
SQLite 是动态类型,但列**亲和性**决定比较和排序的行为:整数列存成 TEXT,
ORDER BY 就变成字典序,'10' 排在 '9' 前面;而这正是"Excel 里的数字能不能
真的被 SQL 算"的分界线。所以这一步不能省。
日期统一成 TEXT——SQLite 没有日期类型,而 ISO 格式的字典序恰好等于时间序,
存成 ISO 字符串既能排序也能比较。
"""
import datetime as _dt
seen = False
all_int = all_float = True
for value in values:
norm = _norm(value)
if norm is None:
continue
seen = True
if isinstance(norm, bool):
# bool 是 int 的子类,但"真/假"不是数量,不该被当成 INTEGER 去求和
return "TEXT"
if isinstance(norm, (_dt.datetime, _dt.date, _dt.time)):
return "TEXT"
if isinstance(norm, int):
all_float = all_float and True
continue
if isinstance(norm, float):
all_int = False
continue
text = str(norm)
if all_int and not _INT_RE.match(text):
all_int = False
if all_float and not _FLOAT_RE.match(text):
all_float = False
if not all_float:
return "TEXT"
if not seen:
return "TEXT" # 整列都是空的,别猜
return "INTEGER" if all_int else ("REAL" if all_float else "TEXT")
def _coerce(value: Any, sql_type: str) -> Any:
"""按推断出的类型把值转过去。转不动就原样存——宁可类型不齐,不可丢数据。"""
import datetime as _dt
norm = _norm(value)
if norm is None:
return None
if isinstance(norm, (_dt.datetime, _dt.date, _dt.time)):
return norm.isoformat()
if sql_type == "INTEGER":
try:
return int(str(norm))
except (TypeError, ValueError):
return str(norm)
if sql_type == "REAL":
try:
return float(str(norm))
except (TypeError, ValueError):
return str(norm)
return norm if isinstance(norm, (int, float)) else str(norm)
# --------------------------------------------------------------------------
# 读文件
# --------------------------------------------------------------------------
#: CSV 的编码。中文环境里 GBK 系的 CSV 极其常见(Excel 另存为 CSV 的默认行为),
#: 一律按 UTF-8 读会得到一堆乱码列名——而乱码是不报错的,只是搜不到、看不懂。
#: 不引入 chardet:按真实场景的频次逐个试就够了,最后 latin-1 保证不抛异常。
_ENCODINGS = ("utf-8-sig", "utf-8", "gb18030", "latin-1")
def _decode_csv(raw: bytes) -> str:
for enc in _ENCODINGS:
try:
return raw.decode(enc)
except UnicodeDecodeError:
continue
return raw.decode("latin-1", errors="replace")
def _read_csv(raw: bytes, filename: str, *, header_row: int) -> list[tuple[str, list[list[Any]]]]:
text = _decode_csv(raw)
delimiter = "\t" if _ext(filename) == ".tsv" else None
if delimiter is None:
try:
delimiter = csv.Sniffer().sniff(text[:8192], delimiters=",;\t|").delimiter
except csv.Error:
delimiter = "," # 嗅不出来就按最常见的来
rows = [list(r) for r in csv.reader(io.StringIO(text), delimiter=delimiter)]
stem = filename.rsplit("/", 1)[-1].rsplit(".", 1)[0]
return [(stem, rows[header_row - 1:])]
def _read_excel(raw: bytes, *, header_row: int) -> list[tuple[str, list[list[Any]]]]:
try:
from openpyxl import load_workbook
except ImportError as e:
raise UnsupportedTable(
"读取 Excel 需要安装解析库:pip install 'agentlab-backend[docs]'"
) from e
try:
# read_only 走流式,几十万行也不会把整个工作簿读进内存;
# data_only 取公式算出来的值而不是公式本身——用户要的是数
book = load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
except Exception as e: # noqa: BLE001
# 这句话原样进上传的 400:异常类名留在日志(from e),界面上只说能照着做的
reason = "文件不是有效的 .xlsx(可能已损坏,或只是修改了扩展名)" if isinstance(e, zipfile.BadZipFile) \
else describe_exception(e)
raise UnsupportedTable(f"无法打开该 Excel 文件:{reason}。请在 Excel 中另存为 .xlsx 或 .csv 后重新上传") from e
out: list[tuple[str, list[list[Any]]]] = []
for sheet in book.worksheets:
rows = [list(r) for r in sheet.iter_rows(min_row=header_row, values_only=True)]
# 整张空的 sheet 就跳过,不建空表。Excel 里常挂着几张没删的空白页
if any(any(_norm(c) is not None for c in row) for row in rows):
out.append((sheet.title, rows))
book.close()
if not out:
raise UnsupportedTable("该 Excel 文件中没有包含内容的工作表")
return out
def read_tables(
raw: bytes, filename: str, *, header_row: int = 1
) -> list[tuple[str, list[list[Any]]]]:
"""文件字节 → [(sheet 名, 行)]。第一行是表头。"""
if header_row < 1:
raise UnsupportedTable("表头行号从 1 开始")
ext = _ext(filename)
if ext in _EXCEL:
return _read_excel(raw, header_row=header_row)
if ext in _CSV:
return _read_csv(raw, filename, header_row=header_row)
if ext in _LEGACY_EXCEL:
raise UnsupportedTable(
f"无法读取 {filename}:.xls 是旧版二进制格式,需要外部转换器。"
"请在 Excel 中另存为 .xlsx 或 .csv 后重新上传。"
)
raise UnsupportedTable(
f"无法读取 {filename}:仅支持 Excel(.xlsx)和 CSV / TSV。"
)
# --------------------------------------------------------------------------
# 建库
# --------------------------------------------------------------------------
def load_into(
db_path: str, raw: bytes, filename: str, *, header_row: int = 1
) -> LoadReport:
"""把文件里的每张表写进 db_path 指的 SQLite 库。
同名表**整张替换**(DROP + CREATE)。重传的语义是"这份数据更新了",
追加会让行数悄悄翻倍,而没有任何地方会提示。
用 sqlite3 直连而不是 data.engine:那条路上有 SQL 守卫,而守卫拒绝
CREATE——那是对的,模型不该能建表。导入是我们自己发起的可信操作。
"""
sheets = read_tables(raw, filename, header_row=header_row)
report = LoadReport()
taken: set[str] = set()
conn = sqlite3.connect(db_path)
try:
for sheet_name, rows in sheets:
if not rows:
continue
headers = _clean_headers(rows[0])
body = rows[1:]
if not headers:
continue
# 行长参差是常态:Excel 的末尾空列、CSV 的缺尾逗号。按表头补齐/截断
width = len(headers)
body = [(row + [None] * width)[:width] for row in body]
sample = body[:_TYPE_SAMPLE]
types = [infer_type([row[i] for row in sample]) for i in range(width)]
table = _table_name(sheet_name, taken)
cols = ", ".join(f'"{h}" {t}' for h, t in zip(headers, types))
conn.execute(f'DROP TABLE IF EXISTS "{table}"')
conn.execute(f'CREATE TABLE "{table}" ({cols})')
if body:
placeholders = ", ".join("?" * width)
conn.executemany(
f'INSERT INTO "{table}" VALUES ({placeholders})',
([_coerce(row[i], types[i]) for i in range(width)] for row in body),
)
report.tables.append(
LoadedTable(
name=table, columns=headers, types=types,
rows=len(body), source_name=sheet_name,
)
)
conn.commit()
finally:
conn.close()
if not report.tables:
raise UnsupportedTable("文件中没有数据行")
return report