mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 06:02:01 +00:00
8059cd5147
连接层 - 所有 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 认上传的表格,显示当前版本;上传的表格不再给「探查结构」
175 lines
7.8 KiB
Python
175 lines
7.8 KiB
Python
"""期 1 合并后的集成:版本底座(table_versions)发布出来的快照,经连接层(engine)查询。
|
|
|
|
两边是分开开发的:连接层用替身对象测了 immutable 和哈希核对,版本底座测了发布和钉版本。
|
|
这里把真的 SourceView 交给真的 EngineCache,确认几件只有接起来才看得出的事:
|
|
- 快照按 immutable 只读打开,写错的双引号列名照样报错(关 DQS 对快照也生效);
|
|
- 快照文件被改过,下一次建连接就拒绝查询,不会读到改过的数据;
|
|
- 重传之后,钉在旧快照上的查询读旧数据、当前版本读新数据,两份连接互不串。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import os
|
|
import stat
|
|
import uuid
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
from app.core.config import settings
|
|
from app.data import table_versions
|
|
from app.data.engine import SnapshotTampered, engine_args, engines, run_query
|
|
from app.db.base import SessionLocal
|
|
from app.db.models import DataSource, Run
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def store(monkeypatch, tmp_path):
|
|
data = tmp_path / "data"
|
|
(data / "uploads").mkdir(parents=True)
|
|
monkeypatch.setattr(settings, "data_dir", data)
|
|
yield data
|
|
|
|
|
|
def xlsx(rows: list[list]) -> bytes:
|
|
from openpyxl import Workbook
|
|
|
|
book = Workbook()
|
|
sheet = book.active
|
|
sheet.title = "明细"
|
|
for row in rows:
|
|
sheet.append(row)
|
|
buf = io.BytesIO()
|
|
book.save(buf)
|
|
return buf.getvalue()
|
|
|
|
|
|
def rows_v1() -> list[list]:
|
|
return [["区域", "数量"], ["分区甲", 1], ["分区乙", 2], ["标记", uuid.uuid4().int % 1000]]
|
|
|
|
|
|
async def publish(name: str, raw: bytes):
|
|
async with SessionLocal() as session:
|
|
row = (await session.execute(select(DataSource).where(DataSource.name == name))).scalar_one_or_none()
|
|
if row is None:
|
|
row = DataSource(id=uuid.uuid4().hex, name=name, kind="sqlite", readonly=True, origin="upload")
|
|
result = await table_versions.publish_upload(session, row, raw, "明细.xlsx",
|
|
header_row=1, mixed="reject", raw_mode=False)
|
|
return row.id, result
|
|
|
|
|
|
async def resolve(source_id: str, pinned: str | None = None):
|
|
async with SessionLocal() as session:
|
|
row = await session.get(DataSource, source_id)
|
|
return await table_versions.resolve_source(session, row, pinned)
|
|
|
|
|
|
def name() -> str:
|
|
return f"p1int_{uuid.uuid4().hex[:10]}"
|
|
|
|
|
|
async def test_snapshot_view_opens_immutable_and_rejects_misspelled_columns():
|
|
sid, _ = await publish(name(), xlsx(rows_v1()))
|
|
view = await resolve(sid)
|
|
try:
|
|
assert view.immutable is True and view.readonly is True and view.expected_sha256
|
|
url, _ = engine_args(view)
|
|
assert "mode=ro" in url and "immutable=1" in url
|
|
assert (await run_query(view, 'SELECT SUM("数量") FROM "明细" WHERE "区域" != \'标记\'')).rows[0][0] == 3
|
|
# 写错列名:关 DQS 以后报错,不会把 "数目" 当成字符串、静默返回 0
|
|
with pytest.raises(Exception, match="no such column"):
|
|
await run_query(view, 'SELECT SUM("数目") FROM "明细"')
|
|
finally:
|
|
await engines.invalidate(sid)
|
|
|
|
|
|
async def test_tampered_snapshot_is_refused_on_next_connection():
|
|
sid, _ = await publish(name(), xlsx(rows_v1()))
|
|
view = await resolve(sid)
|
|
try:
|
|
await run_query(view, 'SELECT COUNT(*) FROM "明细"')
|
|
await engines.invalidate(sid)
|
|
path = view.database
|
|
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
|
with open(path, "r+b") as f: # 改动文件末尾的一个字节:数据页内容变了,哈希随之变
|
|
f.seek(-1, os.SEEK_END)
|
|
last = f.read(1)
|
|
f.seek(-1, os.SEEK_END)
|
|
f.write(bytes([last[0] ^ 0xFF]))
|
|
with pytest.raises(SnapshotTampered):
|
|
await run_query(view, 'SELECT COUNT(*) FROM "明细"')
|
|
finally:
|
|
await engines.invalidate(sid)
|
|
|
|
|
|
async def test_pinned_old_snapshot_and_current_snapshot_do_not_share_connections():
|
|
src = name()
|
|
sid, first = await publish(src, xlsx(rows_v1()))
|
|
# 一条钉在旧快照上的运行(状态是中断,等着续跑):有它引用,重传后的回收不会删掉旧快照
|
|
async with SessionLocal() as session:
|
|
session.add(Run(workflow_name="钉版本", status="interrupted",
|
|
data_versions={sid: {"snapshot": first.snapshot_id, "name": src}}))
|
|
await session.commit()
|
|
_, second = await publish(src, xlsx([["区域", "数量"], ["分区甲", 100], ["标记", uuid.uuid4().int % 1000]]))
|
|
assert first.snapshot_id != second.snapshot_id
|
|
old = await resolve(sid, first.snapshot_id)
|
|
new = await resolve(sid)
|
|
try:
|
|
q = 'SELECT SUM("数量") FROM "明细" WHERE "区域" != \'标记\''
|
|
assert (await run_query(new, q)).rows[0][0] == 100
|
|
assert (await run_query(old, q)).rows[0][0] == 3
|
|
# 先查新再查旧、再查新:两份连接各走各的缓存键
|
|
assert (await run_query(new, q)).rows[0][0] == 100
|
|
finally:
|
|
await engines.invalidate(sid)
|
|
|
|
|
|
async def test_unshaped_note_reaches_every_text_the_model_and_the_judge_read():
|
|
"""按原样导入的表:「未规整」的说明不只在 db_schema 查单表时看得到——查询工具的描述、db_schema 的表清单、
|
|
报告的写作目录、裁判的摘录里都要标出(R4、D16),而且不含数字(H11)。"""
|
|
from app.core.artifact_store import load as load_artifact
|
|
from app.engine import judge
|
|
from app.engine.evidence import build_catalog, catalog_prompt, extract_numbers, schema_ledger_entry
|
|
from app.tools.datasource import build_datasource_tools
|
|
from app.tools.registry import ToolContext, Versions
|
|
from tests.test_tabular import crosstab
|
|
|
|
src = name()
|
|
async with SessionLocal() as session:
|
|
row = DataSource(id=uuid.uuid4().hex, name=src, kind="sqlite", readonly=True, origin="upload")
|
|
await table_versions.publish_upload(session, row, crosstab(), "客流.xlsx",
|
|
header_row=4, mixed="null", raw_mode=True)
|
|
sid = row.id
|
|
tools = {t.name: t for t in await build_datasource_tools(
|
|
[f"db_query__{src}", f"db_schema__{src}"], ToolContext(data_versions=Versions.CURRENT), session)}
|
|
try:
|
|
query, schema = tools[f"db_query__{src}"], tools[f"db_schema__{src}"]
|
|
assert "客流汇总 按原样导入、未经规整" in query.description and "不能直接对列求和" in query.description
|
|
assert "客流汇总(按原样导入、未经规整,不能直接对列求和)" in await schema.coroutine()
|
|
assert extract_numbers(table_versions.UNSHAPED_NOTE) == []
|
|
|
|
import json
|
|
payload = json.loads(await query.coroutine(sql='SELECT COUNT(*) AS n FROM "客流汇总"'))
|
|
frozen = schema_ledger_entry(payload["schema_artifact"], node_id="fetch", exec_no=1)
|
|
assert frozen is not None
|
|
catalog = build_catalog(nodes={}, ledger=[frozen])
|
|
prompt = catalog_prompt(catalog, loader=load_artifact)
|
|
assert "客流汇总(按原样导入、未经规整:同一列里混有不同口径的行,不能直接对列求和)" in prompt
|
|
|
|
excerpt = judge._excerpt("t:客流汇总", catalog["t:客流汇总"], [], load_artifact, None, catalog)
|
|
assert f"表的说明:{table_versions.UNSHAPED_NOTE}" in excerpt
|
|
finally:
|
|
await engines.invalidate(sid)
|
|
|
|
|
|
async def test_shaped_tables_carry_no_unshaped_note():
|
|
"""规整的表不带这句:别的表、手工源的表结构照旧。"""
|
|
from app.tools.datasource import _query_description
|
|
|
|
sid, _ = await publish(name(), xlsx(rows_v1()))
|
|
view = await resolve(sid)
|
|
try:
|
|
assert "未经规整" not in _query_description(view)
|
|
finally:
|
|
await engines.invalidate(sid)
|