mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:41:57 +00:00
271 lines
12 KiB
Python
271 lines
12 KiB
Python
"""目录视图接缝(data/catalog_view.py;细案 01 做法 5,总方案 X7)。
|
|
|
|
目录在四处被读:运行时的 db_schema / db_query(目录同时冻结进表结构快照)、助手查看的表详情、自查的 SQL 检查器、双盲复算。
|
|
这里守:四种视图(当前目录、只用已核实和已确认的项、关、目录快照)在四处给出的是同一份;缺省是当前目录、行为不变;运行
|
|
从 Run.options 读视图(随发起定下,审批恢复、续跑沿用),冻结快照的 meta 记下不是缺省的视图;lint:这几个消费模块不许
|
|
绕过视图直接 read_catalog、直接查 CatalogNote。
|
|
|
|
库、表、列、目录内容都是虚构的。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import re
|
|
import sqlite3
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
from app.core.artifact_store import put_json
|
|
from app.data import catalog, catalog_view
|
|
from app.data.catalog import make_item
|
|
from app.data.catalog_view import CatalogSnapshotMissing, CatalogView, CatalogViewInvalid
|
|
from app.data.introspect import introspect
|
|
from app.db.base import SessionLocal
|
|
from app.db.models import Artifact, DataSource, Run
|
|
from app.engine.runner import run_manager
|
|
from tests.fixtures.sources import drop_source
|
|
|
|
APP = Path(__file__).resolve().parents[1] / "app"
|
|
|
|
#: 已确认的一项、推断的一项、驳回的一项:三种视图各留哪些一眼看得出
|
|
NOTES = {
|
|
"label": make_item("门店订单", "human"), # confirmed
|
|
"grain": make_item("每笔订单一行", "llm"), # proposed
|
|
"description": make_item("旧说明", "llm", "rejected"), # rejected
|
|
"columns": {"amount": {"label": make_item("实收金额", "human")},
|
|
"status": {"label": make_item("订单状态", "llm")}},
|
|
}
|
|
SNAPSHOT_NOTES = {"label": make_item("快照里的订单", "human")}
|
|
|
|
VIEWS = {
|
|
"live": CatalogView(),
|
|
"reviewed": CatalogView(statuses="reviewed"),
|
|
"off": CatalogView(mode="off"),
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
async def shop(tmp_path):
|
|
path = tmp_path / "view_shop.db"
|
|
db = sqlite3.connect(path)
|
|
db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, amount REAL NOT NULL, status INTEGER);"
|
|
"INSERT INTO orders VALUES (1, 30.0, 1), (2, 20.0, 9);")
|
|
db.commit()
|
|
db.close()
|
|
async with SessionLocal() as session:
|
|
row = DataSource(name=f"viewshop_{uuid.uuid4().hex[:6]}", kind="sqlite", database=str(path))
|
|
session.add(row)
|
|
await session.commit()
|
|
row.schema_cache = await introspect(row)
|
|
await session.commit()
|
|
await session.refresh(row)
|
|
await catalog.write_entry(session, row.id, "orders", NOTES, if_version=0, actor=None)
|
|
session.expunge(row)
|
|
yield row
|
|
await drop_source(row.id)
|
|
|
|
|
|
async def _snapshot_view(source) -> CatalogView:
|
|
sid = await put_json({"sources": {source.id: {"tables": {"orders": {"version": 7, "notes": SNAPSHOT_NOTES}}}}},
|
|
kind=catalog_view.SNAPSHOT_KIND)
|
|
return CatalogView(mode="snapshot", snapshot=sid)
|
|
|
|
|
|
def _says(text: str) -> dict[str, bool]:
|
|
return {word: word in text for word in ("门店订单", "每笔订单一行", "旧说明", "实收金额", "订单状态", "快照里的订单")}
|
|
|
|
|
|
EXPECT = {
|
|
"live": {"门店订单": True, "每笔订单一行": True, "旧说明": False, "实收金额": True, "订单状态": True, "快照里的订单": False},
|
|
"reviewed": {"门店订单": True, "每笔订单一行": False, "旧说明": False, "实收金额": True, "订单状态": False,
|
|
"快照里的订单": False},
|
|
"off": dict.fromkeys(("门店订单", "每笔订单一行", "旧说明", "实收金额", "订单状态", "快照里的订单"), False),
|
|
"snapshot": {"门店订单": False, "每笔订单一行": False, "旧说明": False, "实收金额": False, "订单状态": False,
|
|
"快照里的订单": True},
|
|
}
|
|
|
|
|
|
async def _all_views(source) -> dict[str, CatalogView]:
|
|
return {**VIEWS, "snapshot": await _snapshot_view(source)}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 视图本身
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_parse_and_defaults():
|
|
assert CatalogView.parse(None) is catalog_view.LIVE and catalog_view.LIVE.is_default
|
|
assert CatalogView.parse({"mode": "off"}) == CatalogView(mode="off")
|
|
assert catalog_view.describe(None) is None and catalog_view.describe(CatalogView()) is None
|
|
assert catalog_view.describe(CatalogView(mode="off")) == {"mode": "off", "statuses": "all"}
|
|
assert catalog_view.of_options({"catalog_view": {"statuses": "reviewed"}}) == CatalogView(statuses="reviewed")
|
|
for bad in ({"mode": "nope"}, {"statuses": "some"}, {"mode": "snapshot"}, {"mode": "live", "snapshot": "x"},
|
|
{"extra": 1}, "off"):
|
|
with pytest.raises(CatalogViewInvalid):
|
|
CatalogView.parse(bad)
|
|
|
|
|
|
async def test_a_missing_snapshot_is_an_error_not_the_live_catalog(shop):
|
|
with pytest.raises(CatalogSnapshotMissing):
|
|
await catalog_view.read(CatalogView(mode="snapshot", snapshot="0" * 64), shop)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 四处一致
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
async def test_runtime_db_schema_follows_the_view(shop):
|
|
from app.tools.datasource import SCHEMA_PREFIX, build_datasource_tools
|
|
from app.tools.registry import ToolContext, Versions
|
|
|
|
for name, view in (await _all_views(shop)).items():
|
|
ctx = ToolContext(run_id="", node_id="", data_versions=Versions.CURRENT, catalog_view=view)
|
|
async with SessionLocal() as session:
|
|
[tool] = await build_datasource_tools([f"{SCHEMA_PREFIX}{shop.name}"], ctx, session)
|
|
assert _says(await tool.coroutine(table="orders")) == EXPECT[name], name
|
|
|
|
|
|
async def test_look_table_detail_follows_the_view(shop):
|
|
from app.look.service import LookService
|
|
from app.look.tables import TableKind
|
|
|
|
for name, view in (await _all_views(shop)).items():
|
|
looks = LookService([await TableKind.load([shop], catalog_view=view)])
|
|
got = await looks.look({"op": "look", "need": "订单", "targets": [{"kind": "table", "name": "orders"}]})
|
|
assert _says(got.text) == EXPECT[name], name
|
|
|
|
|
|
async def test_sql_checkers_follow_the_view(shop):
|
|
from app.data.sqlcheck import load_checkers
|
|
|
|
for name, view in (await _all_views(shop)).items():
|
|
async with SessionLocal() as session:
|
|
checkers = await load_checkers(session, [shop], catalog_view=view)
|
|
if name == "off":
|
|
assert checkers == {}
|
|
continue
|
|
notes = checkers[shop.name]._raw["orders"]
|
|
assert _says(str(notes)) | {"旧说明": False} == EXPECT[name] | {"旧说明": False}, name
|
|
if name == "reviewed":
|
|
# 驳回的项原样留着交给检查器(它自己按驳回处理),推断的去掉
|
|
assert notes["description"]["status"] == "rejected" and "grain" not in notes
|
|
|
|
|
|
async def test_dual_check_blind_catalog_follows_the_view(shop):
|
|
from app.engine.nodes.dual_check import BLIND_NO_CATALOG_PROMPT, _catalog_text, _source_info, _Sources
|
|
|
|
for name, view in (await _all_views(shop)).items():
|
|
sources = _Sources(view)
|
|
text = await _catalog_text(sources, shop.name)
|
|
assert _says(text) == EXPECT[name], name
|
|
info = await _source_info(sources, shop.name)
|
|
assert ("orders" in info["notes"]) == (name != "off"), name
|
|
if name == "off":
|
|
# 关目录:表清单(表结构里的事实)照给,目录的说明一句都没有
|
|
assert "orders" in text and "数据目录(" not in text and BLIND_NO_CATALOG_PROMPT not in text
|
|
|
|
|
|
async def test_the_default_is_the_live_catalog_everywhere(shop):
|
|
"""不给视图:和以前一样读当前目录。"""
|
|
from app.engine.nodes.dual_check import _catalog_text, _Sources
|
|
from app.look.service import LookService
|
|
from app.look.tables import TableKind
|
|
from app.tools.datasource import _catalog_of
|
|
|
|
assert _says(str({t: e.notes for t, e in (await _catalog_of(shop)).items()}))["门店订单"]
|
|
looks = LookService([await TableKind.load([shop])])
|
|
got = await looks.look({"op": "look", "need": "订单", "targets": [{"kind": "table", "name": "orders"}]})
|
|
assert _says(got.text) == EXPECT["live"]
|
|
assert _says(await _catalog_text(_Sources(), shop.name)) == EXPECT["live"]
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 运行:从 Run.options 读视图,冻结快照记下
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def _graph(source: str) -> dict:
|
|
def node(nid, ntype, **config):
|
|
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
|
|
|
|
return {"nodes": [node("start", "input"),
|
|
node("q", "tool", tool=f"db_query__{source}", args={"sql": "SELECT SUM(amount) AS 金额 FROM orders"},
|
|
assign_to="rows"),
|
|
node("out", "output", fields=[{"name": "rows", "value": "{{ vars.rows }}"}])],
|
|
"edges": [{"source": "start", "target": "q"}, {"source": "q", "target": "out"}]}
|
|
|
|
|
|
async def _frozen(options: dict | None, source) -> tuple[dict, dict]:
|
|
from app.core.artifact_store import load
|
|
from app.engine.evidence import SCHEMA_SNAPSHOT
|
|
from app.engine.launch import launch_run
|
|
|
|
async with SessionLocal() as session:
|
|
run = await launch_run(session, graph=_graph(source.name), input_payload={}, actor="测试",
|
|
origin_kind="canvas", options=options)
|
|
run_id = run.id
|
|
for _ in range(200):
|
|
async with SessionLocal() as session:
|
|
status = (await session.get(Run, run_id)).status
|
|
if status in ("succeeded", "failed", "cancelled"):
|
|
break
|
|
await asyncio.sleep(0.05)
|
|
assert status == "succeeded", status
|
|
async with SessionLocal() as session:
|
|
ref = (await session.execute(select(Artifact).where(
|
|
Artifact.run_id == run_id, Artifact.kind == SCHEMA_SNAPSHOT))).scalars().first()
|
|
assert ref is not None
|
|
return load(ref.id), ref.meta or {}
|
|
|
|
|
|
async def test_a_run_reads_its_view_from_options_and_the_freeze_says_so(shop):
|
|
await run_manager.setup()
|
|
try:
|
|
content, meta = await _frozen(None, shop)
|
|
assert "orders" in content.get("catalog", {}), "缺省:当前目录照旧冻结"
|
|
assert "catalog_view" not in meta, "缺省视图不记,meta 和以前一样"
|
|
|
|
content, meta = await _frozen({"catalog_view": {"mode": "off"}}, shop)
|
|
assert "catalog" not in content, "关目录:快照里没有目录"
|
|
assert meta["catalog_view"] == {"mode": "off", "statuses": "all"}
|
|
|
|
content, meta = await _frozen({"catalog_view": {"statuses": "reviewed"}}, shop)
|
|
frozen = content["catalog"]["orders"]["notes"]
|
|
assert "grain" not in frozen and frozen["label"]["value"] == "门店订单"
|
|
assert meta["catalog_view"] == {"mode": "live", "statuses": "reviewed"}
|
|
finally:
|
|
await run_manager.shutdown()
|
|
|
|
|
|
def test_of_run_reads_the_run_options():
|
|
from types import SimpleNamespace
|
|
|
|
from app.engine.runner import RUN_OPTIONS_KEY
|
|
|
|
run = SimpleNamespace(extra={RUN_OPTIONS_KEY: {"catalog_view": {"mode": "off"}}})
|
|
assert catalog_view.of_run(run) == CatalogView(mode="off")
|
|
assert catalog_view.of_run(SimpleNamespace(extra={})) == catalog_view.LIVE
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# lint:消费模块只经视图读目录
|
|
# --------------------------------------------------------------------------
|
|
|
|
#: 给模型、给自查读目录的模块(四处消费 + 它们的帮手)。要读目录一律经 data/catalog_view.py
|
|
CONSUMERS = ("tools/datasource.py", "look/tables.py", "data/sqlcheck.py", "engine/nodes/dual_check.py",
|
|
"engine/verify_agent.py", "api/copilot_context.py", "tools/analysis.py")
|
|
FORBIDDEN = re.compile(r"\bread_catalogs?\s*\(|\bread_entry\s*\(|\bCatalogNote\b")
|
|
|
|
|
|
@pytest.mark.parametrize("module", CONSUMERS)
|
|
def test_consumers_never_read_the_catalog_directly(module):
|
|
text = (APP / module).read_text(encoding="utf-8")
|
|
code = "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#"))
|
|
hits = [m.group(0) for m in FORBIDDEN.finditer(code)]
|
|
assert not hits, f"{module} 绕过了目录视图:{hits}"
|