Files
AgentLab/backend/tests/test_look_large.py
T
ThearchyHelios 0488965352 探查范围外、没有注释的表中文需求命不中:照实写进模块说明,加一条测试钉住
真实模型验收里,范围外又没有注释的表只按英文名检索,中文需求命不中(data/schema_view 的 _outside_text 只放名字、拆出的词、
注释)。先查了有没有现成的英中业务词对照可以给拆出的词补上中文说法:命名推断只拆词、不翻译,catalog.name_words 也一样,仓库
里没有这样一份对照。按约定不临时编一份大词表(编出来的覆盖不全,还会把不相干的表抬上来),改成在模块说明里照实写清这个限制、
影响多大(真库多半有注释,注释照常参与检索;范围内的表另有目录中文名、列名)和怎么绕过去(按英文名点名查看会按需加入,或者
请管理员加进探查范围、补注释或目录中文名)。

测试:test_look_large.py::test_out_of_scope_objects_without_a_comment_match_only_their_english_name 钉住现在的行为
(英文名命中、中文需求命不中没注释的表、有注释的按注释命中),以后接了词表要跟着改。这一项只改说明、不改行为,没有
「去掉修复回跑」。
2026-10-17 11:53:54 +08:00

256 lines
14 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.
"""大库下的 look、db_schema、目录搜索(docs/design/next-round/03-数据接入与目录维护.md A2)。
3,000 张表的合成库(tests/fixtures/catalog/large.py):探查范围 ord_*、sal_* 加上 5 张有数据目录的目标表,其余在范围外,
对象索引里只有名字和注释。这组守的是:
- look 的挑表在候选超过 PREFILTER_LIMIT 时只给预筛结果(BM25)、已用到的表、一跳关联:10 条脚本化需求至少 9 条挑中,
挑选提示词不到 20,000 字;挑中范围外的表按需加入,look 记录写 added_to_scope;
- 点名范围外的表按需加入、留痕(谁、哪一轮、哪一次查看、需求原文);每次查看最多 ON_DEMAND_PER_LOOK 张,超了照实说;
- 运行时 db_schema__:keyword 用同一个检索;不传 table 只列前 50 张;点名范围外的按需加入,每次运行最多 ON_DEMAND_PER_RUN 张;
- 查询照实列「未按目录检查的表」;
- 目录:GET /catalog/search 找得到范围外的表,POST /catalog/scope/add 加进来(记进规则的显式列出)。
挑选模型用一个按需求和目录行的字面重合打分的假模型(不调真模型):它只能看到交给它的那份目录,预筛漏掉的它也挑不到。
"""
from __future__ import annotations
import re
import uuid
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.data import catalog
from app.db.base import SessionLocal
from app.db.models import DataSource
from app.db.models_sources import SourceObject
from app.look.base import ON_DEMAND_PER_LOOK, ON_DEMAND_PER_RUN, PICK_LIMIT
from app.look.pick import PICK_HUMAN_PROMPT, PICK_SYSTEM_PROMPT, PICK_USED_PROMPT
from app.look.service import LookService
from app.look.tables import TableKind
from app.main import app
from tests.fixtures.catalog import large
from tests.fixtures.sources import drop_source, introspect_now
class OverlapPicker:
"""假的挑选模型:按需求里的二字片段在目录行里出现的次数给每行打分,挑前 PICK_LIMIT 行。记下每次的提示词字数。"""
def __init__(self) -> None:
self.prompts: list[int] = []
self.calls = 0
async def pick(self, *, need: str, what: str, line: str, index: str, used: str = ""):
human = PICK_HUMAN_PROMPT.format(need=need, used=PICK_USED_PROMPT.format(items=used) if used else "",
what=what, line=line, index=index)
self.prompts.append(len(PICK_SYSTEM_PROMPT) + len(human))
self.calls += 1
grams = {need[i:i + 2] for i in range(len(need) - 1) if re.match(r"[一-鿿]{2}", need[i:i + 2])}
source, scored = None, []
for row in index.splitlines():
if m := re.match(r"数据源「(.+?)」", row):
source = m.group(1)
continue
if not row.strip() or "|" not in row and not re.match(r"^[\w.]+", row):
continue
name = row.split("|", 1)[0].strip()
score = sum(1 for g in grams if g in row)
if score:
scored.append((score, source, name))
scored.sort(key=lambda x: -x[0])
return [(s, n) for _score, s, n in scored[:PICK_LIMIT]]
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest.fixture(scope="module")
def big_db(tmp_path_factory) -> str:
return str(large.build(tmp_path_factory.mktemp("large-look") / "large.db"))
@pytest.fixture
async def big(client, big_db):
"""探查范围 ord_*、sal_*(300 张)+ 5 张有目录的目标表(pinned);对象索引里写上全部注释(SQLite 没有表注释)。"""
name = f"large_{uuid.uuid4().hex[:8]}"
r = await client.post("/api/datasources", json={"name": name, "kind": "sqlite", "database": big_db,
"readonly": True})
sid = r.json()["id"]
async with SessionLocal() as session:
for t in large.TARGETS:
if t.cataloged:
await catalog.write_entry(session, sid, t.name, {"label": {"value": t.comment, "source": "human",
"status": "confirmed"}},
if_version=0, actor=None)
r = await client.put(f"/api/datasources/{sid}/introspect-scope",
json={"scope": {"mode": "rules", "include": ["ord_*", "sal_*"]}})
assert r.status_code == 200
out = await introspect_now(client, sid)
assert out["table_count"] == 305
# 探查之后再写注释:刷新对象索引会照 SQLite 的字典(没有注释)改回去
notes = large.comments()
async with SessionLocal() as session:
for obj in (await session.execute(select(SourceObject).where(SourceObject.source_id == sid))).scalars():
obj.comment = notes.get(obj.name)
await session.commit()
yield sid, name
await drop_source(sid)
async def _row(sid: str) -> DataSource:
async with SessionLocal() as session:
row = await session.get(DataSource, sid)
session.expunge(row)
return row
async def test_prefiltered_pick_hits_at_least_9_of_10_with_a_short_prompt(big):
sid, name = big
picker = OverlapPicker()
kind = await TableKind.load([await _row(sid)], picker=picker, trail={"actor": "tester", "conversation_id": "c1"})
looks = LookService([kind])
hits = 0
added: list[str] = []
for target in large.TARGETS:
result = await looks.look({"op": "look", "need": target.need})
objects = (result.record or {}).get("objects") or []
picked = {n for o in objects for n in o.get("picked", [])}
if target.name in picked:
hits += 1
for o in objects:
assert o.get("prefiltered", 0) <= 120 + 40
added += o.get("added_to_scope", [])
assert hits >= 9, f"挑中 {hits}/10"
assert picker.prompts and max(picker.prompts) < 20_000, f"挑选提示词 {max(picker.prompts)} 字"
out_of_scope = {t.name for t in large.TARGETS if not t.cataloged}
assert out_of_scope & set(added), "挑中范围外的表时按需加入,记在 added_to_scope"
rows = {r.name: r for r in (await _objects(sid)) if r.name in out_of_scope and r.in_scope}
assert rows and all(r.added_via == "look" and r.added_by == "tester" and r.added_conversation_id == "c1"
and r.added_need and r.added_ref for r in rows.values())
async def _objects(sid: str) -> list[SourceObject]:
async with SessionLocal() as session:
return list((await session.execute(select(SourceObject).where(SourceObject.source_id == sid))).scalars())
async def test_naming_out_of_scope_tables_adds_them_with_a_per_look_cap(big):
sid, name = big
kind = await TableKind.load([await _row(sid)], picker=OverlapPicker(), trail={"actor": "tester"})
looks = LookService([kind])
wanted = ["shift_roster", "complaint_ticket", "waybill_track", "pur_hdr_east", "pur_line_east", "pur_log_east"]
result = await looks.look({"op": "look", "need": "看这几张表的字段",
"targets": [{"kind": "table", "source": name, "name": n} for n in wanted]})
(obj,) = result.record["objects"]
assert len(obj["added_to_scope"]) == ON_DEMAND_PER_LOOK == 4
assert set(obj["named"]) == set(obj["added_to_scope"])
assert "按需加入探查范围" in result.text and f"一次查看最多按需加入 {ON_DEMAND_PER_LOOK} 张" in result.text
assert sorted(obj["missing"]) == sorted(wanted[4:]), "超了上限的照实写没找到、说清原因"
assert "biz_date" in result.text, "加进来的表当场给字段"
row = await _row(sid)
assert all(n in row.schema_cache["tables"] for n in obj["added_to_scope"]), "落盘,下一轮、运行时都看得到"
# 下一次查看重新算上限
again = await looks.look({"op": "look", "need": "再看两张",
"targets": [{"kind": "table", "source": name, "name": n} for n in wanted[4:]]})
assert len(again.record["objects"][0]["added_to_scope"]) == 2
async def test_db_schema_keyword_list_limit_and_per_run_cap(big):
from app.tools.datasource import build_datasource_tools
from app.tools.registry import ToolContext, Versions
sid, name = big
async with SessionLocal() as session:
tools = await build_datasource_tools([f"db_schema__{name}", f"db_query__{name}"],
ToolContext(run_id="run-cap-1", node_id="n1",
data_versions=Versions.CURRENT), session)
schema = next(t for t in tools if t.name.startswith("db_schema__"))
query = next(t for t in tools if t.name.startswith("db_query__"))
listed = await schema.coroutine()
assert listed.count("\n ") == 51 and "另有 2950 张没有列出(其中 2695 张在探查范围外" in listed and "keyword" in listed
found = await schema.coroutine(keyword="员工排班")
assert "shift_roster|员工排班表(探查范围外:table 点名它会按需加入)" in found
names = [f"pur_{e}_east" for e in ("hdr", "line", "log", "snap", "daily", "monthly", "cfg", "map", "hist")]
text = await schema.coroutine(table=names)
assert text.count("按需加入了探查范围") == ON_DEMAND_PER_RUN == 8
assert f"这次运行最多按需加入 {ON_DEMAND_PER_RUN} 张" in text, "第 9 张照实说上限用完了"
added = [r for r in await _objects(sid) if r.added_run_id == "run-cap-1"]
assert len(added) == 8 and all(r.added_via == "db_schema" and r.added_ref == "n1" for r in added)
# 查询工具看到的是同一份表结构:加进来的表不再列成「未按目录检查的表」,范围外的照实列
import json
out = json.loads(await query.coroutine(sql="SELECT a.id FROM pur_hdr_east a JOIN pur_tmp_east b ON a.id = b.id"))
assert out.get("unchecked_tables") == ["pur_tmp_east"]
# 删掉了、从来没有的分开说
assert "没有 no_such_table" in await schema.coroutine(table="no_such_table")
async def test_catalog_search_and_add_to_scope(client, big):
sid, name = big
r = await client.get(f"/api/datasources/{sid}/catalog/search", params={"q": "投诉"})
body = r.json()
assert body["indexed"] is True and body["total"] >= 1
hit = next(i for i in body["items"] if i["name"] == "complaint_ticket")
assert hit["in_scope"] is False and hit["absent_reason"] == "out_of_scope" and hit["comment"] == "客户投诉工单"
r = await client.get(f"/api/datasources/{sid}/catalog/search", params={"q": "ORD_HDR"})
assert r.json()["items"][0]["name"].startswith("ord_hdr") and r.json()["items"][0]["in_scope"] is True
r = await client.post(f"/api/datasources/{sid}/catalog/scope/add",
json={"tables": ["complaint_ticket", "nope_table", "ord_hdr_east"]})
assert r.status_code == 200, r.text
out = r.json()
assert out["added"] == ["complaint_ticket"] and out["already"] == ["ord_hdr_east"]
assert [x["name"] for x in out["refused"]] == ["nope_table"]
row = await _row(sid)
assert "complaint_ticket" in row.schema_cache["tables"]
assert "complaint_ticket" in row.options["introspect_scope"]["tables"], "记进规则的显式列出,下次探查也带上"
listed = (await client.get(f"/api/datasources/{sid}/catalog")).json()
assert any(t["table_name"] == "complaint_ticket" for t in listed["tables"])
scope = (await client.get(f"/api/datasources/{sid}/introspect-scope")).json()
trail = next(t for t in scope["on_demand"] if t["name"] == "complaint_ticket")
assert trail["via"] == "catalog" and trail["scope_reason"] == "explicit"
async def test_out_of_scope_parent_is_not_called_missing(big):
from app.data import catalog_profile
sid, _ = big
row = await _row(sid)
from app.data import catalog_profile_jobs
async with SessionLocal() as session:
outside = await catalog_profile_jobs.outside_of(session, row)
tables = row.schema_cache["tables"]
rel = {"id": "r1", "columns": ["ord_id"], "to_table": "pur_hdr_west", "to_columns": ["id"], "status": "proposed",
"source": "name"}
checks = catalog_profile.plan_checks("ord_hdr_east", tables["ord_hdr_east"], {"relations": [rel]},
tables=tables, masked=set(), outside=outside)
(relation,) = [c for c in checks if c.kind == "relation"]
assert relation.skip[0] == "out_of_scope" and "探查范围外" in relation.skip[1] and "重新探查" not in relation.skip[1]
rel["to_table"] = "really_missing"
checks = catalog_profile.plan_checks("ord_hdr_east", tables["ord_hdr_east"], {"relations": [rel]},
tables=tables, masked=set(), outside=outside)
assert [c for c in checks if c.kind == "relation"][0].skip[0] == "missing"
def test_out_of_scope_objects_without_a_comment_match_only_their_english_name():
"""范围外、又没有注释的对象只有英文名可检索(data/schema_view 模块说明写明的限制):英文名拆出的词(catalog.name_words)
没有中文说法——仓库里没有现成的英中业务词对照,不临时编词表,中文需求命不中只叫 refund_orders 的表;有注释的照常命中。
以后接了词表,这条要跟着改。"""
from types import SimpleNamespace
from app.data import schema_view
source = SimpleNamespace(schema_cache={"tables": {}}, options={})
outside = [SimpleNamespace(name="refund_orders", comment=None),
SimpleNamespace(name="cust_complaint", comment="客户投诉单")]
search = schema_view.TableSearch(source, {}, outside)
assert [h.name for h in search.search("refund", limit=5)] == ["refund_orders"], "英文名、拆出的词照常命中"
assert "refund_orders" not in [h.name for h in search.search("上周的退款", limit=5)], "没有注释、没有词表:中文需求命不中英文名"
assert [h.name for h in search.search("投诉", limit=5)] == ["cust_complaint"], "有注释的按注释命中"