mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 07:02:00 +00:00
557 lines
26 KiB
Python
557 lines
26 KiB
Python
"""助手建图时按需求挑表、组装数据源上下文(数据目录阶段 1B)。
|
||
|
||
背景:大库(几十上百张表)超出详细摘要的阈值时,以前只给模型看表名,助手一个字段都看不到,只能照着
|
||
名字编字段、猜怎么连表。现在先用一次轻量的模型调用按需求挑出相关的表,再沿关系图补上连接用的中间表,
|
||
挑中的表给全部字段、数据目录和可以照抄的连接条件,其余表只列名字。
|
||
|
||
这组守的是:
|
||
- 挑表、补路径、组装的结果(合成景区库:入园记录和渠道之间要经关联表 channel_visits)
|
||
- 挑表失败(报错、超时、返回不存在的表、返回为空)一律退回只列表名,生成照常进行
|
||
- 小库保持详细模式,额外带上目录
|
||
- 流式输出里有 context 操作;自查修正沿用同一份上下文,不再重新挑表
|
||
- 非流式生成、发布前修复、升级共用同一套组装
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import sqlite3
|
||
import uuid
|
||
|
||
import pytest
|
||
from httpx import ASGITransport, AsyncClient
|
||
|
||
from app.data import catalog
|
||
from app.data.catalog import INFERRED_MARK, make_item
|
||
from app.data.engine import engines
|
||
from app.data.introspect import introspect
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import DataSource
|
||
from app.main import app
|
||
from tests.fixtures.catalog import scenic
|
||
|
||
|
||
@pytest.fixture
|
||
async def client():
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||
yield c
|
||
|
||
|
||
async def _add_source(database: str, *, prefix: str = "p1b") -> DataSource:
|
||
async with SessionLocal() as session:
|
||
row = DataSource(name=f"{prefix}_{uuid.uuid4().hex[:8]}", kind="sqlite", database=database,
|
||
description="景区业务库")
|
||
session.add(row)
|
||
await session.commit()
|
||
row.schema_cache = await introspect(row)
|
||
await session.commit()
|
||
await session.refresh(row)
|
||
session.expunge(row)
|
||
return row
|
||
|
||
|
||
async def _drop_source(row: DataSource) -> None:
|
||
await engines.invalidate(row.id)
|
||
async with SessionLocal() as session:
|
||
live = await session.get(DataSource, row.id)
|
||
if live is not None:
|
||
await session.delete(live)
|
||
await session.commit()
|
||
|
||
|
||
@pytest.fixture
|
||
async def scenic_source(scenic_db):
|
||
"""接上合成景区库(50 张表 + 1 个视图),用完删掉:留着会让同一进程里别的测试也进入大库模式。"""
|
||
row = await _add_source(scenic_db)
|
||
yield row
|
||
await _drop_source(row)
|
||
|
||
|
||
@pytest.fixture
|
||
async def small_source(tmp_path):
|
||
"""两张表的小库:orders.customer_id 只能靠命名推断连到 customers。"""
|
||
path = tmp_path / "small.db"
|
||
db = sqlite3.connect(path)
|
||
db.executescript(
|
||
"CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL);"
|
||
"CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER NOT NULL, amount REAL NOT NULL,"
|
||
" ordered_at TEXT NOT NULL);")
|
||
db.commit()
|
||
db.close()
|
||
row = await _add_source(str(path), prefix="p1b_small")
|
||
yield row
|
||
await _drop_source(row)
|
||
|
||
|
||
class Picker:
|
||
"""挑表用的假模型:结构化输出返回自己,ainvoke 记下收到的提示词,按设定回复、报错或拖延。"""
|
||
|
||
def __init__(self, reply=None, *, error: Exception | None = None, delay: float = 0.0) -> None:
|
||
self.reply, self.error, self.delay = reply, error, delay
|
||
self.calls: list[str] = []
|
||
|
||
def with_structured_output(self, schema, **_):
|
||
assert schema["title"]
|
||
return self
|
||
|
||
async def ainvoke(self, messages):
|
||
self.calls.append("\n".join(text for _role, text in messages))
|
||
if self.delay:
|
||
await asyncio.sleep(self.delay)
|
||
if self.error is not None:
|
||
raise self.error
|
||
return self.reply
|
||
|
||
|
||
def _use_picker(monkeypatch, picker: Picker) -> list:
|
||
"""挑表模型换成假的,返回每次拿模型时的规格(核对思考关、额度小)。"""
|
||
import app.api.copilot_context as copilot_context
|
||
|
||
specs: list = []
|
||
|
||
async def _model(_session, spec):
|
||
specs.append(spec)
|
||
return picker, "fake-pick"
|
||
|
||
monkeypatch.setattr(copilot_context, "get_chat_model", _model)
|
||
return specs
|
||
|
||
|
||
class Scripted:
|
||
"""按剧本吐操作流的建图模型:第一轮吐 first,收到自查请求时吐 fix。记下每次的 system 和 human。"""
|
||
|
||
def __init__(self, first: list[dict], fix: list[dict] | None = None) -> None:
|
||
self.first = [json.dumps(o, ensure_ascii=False) for o in first]
|
||
self.fix = [json.dumps(o, ensure_ascii=False) for o in (fix or [{"op": "done"}])]
|
||
self.systems: list[str] = []
|
||
self.humans: list[str] = []
|
||
|
||
async def astream(self, messages):
|
||
from langchain_core.messages import AIMessageChunk
|
||
|
||
self.systems.append(next(t for r, t in messages if r == "system"))
|
||
human = next(t for r, t in messages if r == "human")
|
||
self.humans.append(human)
|
||
for line in (self.fix if "上线前自查" in human else self.first):
|
||
yield AIMessageChunk(content=line + "\n")
|
||
|
||
|
||
def _graph_using(tool: str) -> list[dict]:
|
||
return [
|
||
{"op": "add_node", "node": {"id": "start", "type": "input", "label": "输入", "config": {}}},
|
||
{"op": "add_node", "node": {"id": "ask", "type": "agent", "label": "查库", "config": {
|
||
"prompt": "{{ input.question }}", "tools": [tool], "assign_to": "answer"}}},
|
||
{"op": "add_edge", "edge": {"source": "start", "target": "ask"}},
|
||
{"op": "add_node", "node": {"id": "out", "type": "output", "label": "成果", "config": {
|
||
"fields": [{"name": "answer", "value": "{{ vars.answer }}"}]}}},
|
||
{"op": "add_edge", "edge": {"source": "ask", "target": "out"}},
|
||
{"op": "done", "explanation": "查一下", "run": True},
|
||
]
|
||
|
||
|
||
async def _stream(client, monkeypatch, model, body: dict) -> list[dict]:
|
||
import app.api.copilot as copilot
|
||
|
||
async def _model(*_a, **_k):
|
||
return model, "mock-fast"
|
||
|
||
monkeypatch.setattr(copilot, "get_chat_model", _model)
|
||
events: list[dict] = []
|
||
async with client.stream("POST", "/api/copilot/generate-stream", json=body) as r:
|
||
assert r.status_code == 200, await r.aread()
|
||
async for line in r.aiter_lines():
|
||
if line.startswith("data: "):
|
||
events.append(json.loads(line[6:]))
|
||
return events
|
||
|
||
|
||
#: 需求和假模型挑出的表:入园记录、渠道。两者之间没有直接关联,要经关联表 channel_visits
|
||
NEED = "各渠道上月的入园人次"
|
||
PICKED = {"tables": [{"table": "visits"}, {"table": "channels"}]}
|
||
#: 三张表各自独有的字段(describe_table 的写法:列名两个空格类型)
|
||
SELECTED_COLUMNS = ("visitor_count INTEGER", "visit_time TEXT", "ticket_no TEXT", "channel_type TEXT",
|
||
"channel_code TEXT", "attributed_share REAL")
|
||
#: 没挑中的表独有的字段:不该出现在提示词里
|
||
OTHER_COLUMNS = ("store_code", "sku", "on_hand", "refund_amount")
|
||
JOINS = ("channel_visits.visit_id = visits.id", "channel_visits.channel_id = channels.id")
|
||
|
||
|
||
def _context_op(events: list[dict]) -> dict:
|
||
found = [e for e in events if e.get("op") == "context"]
|
||
assert len(found) == 1, [e.get("op") for e in events]
|
||
return found[0]
|
||
|
||
|
||
# ---------------------------------------------------------------- 组装
|
||
|
||
|
||
async def test_pick_brings_in_the_bridge_table_and_spells_out_fields_and_joins(scenic_source):
|
||
from app.api import copilot_context
|
||
|
||
picker = Picker(PICKED)
|
||
plan = await copilot_context.plan_context([scenic_source])
|
||
assert plan.needs_pick
|
||
ctx = await plan.resolve(picker, need=NEED)
|
||
text = ctx.section()
|
||
|
||
assert len(picker.calls) == 1
|
||
# 挑表的输入是需求加紧凑表目录(每张表一行),不是全部字段
|
||
assert NEED in picker.calls[0] and "channel_visits" in picker.calls[0]
|
||
assert "attributed_share" not in picker.calls[0]
|
||
|
||
(source,) = ctx.sources
|
||
assert source.selected_by == "model"
|
||
assert source.tables == ["visits", "channels", "channel_visits"]
|
||
for col in SELECTED_COLUMNS:
|
||
assert col in text, col
|
||
for col in OTHER_COLUMNS:
|
||
assert col not in text, col
|
||
for cond in JOINS:
|
||
assert cond in text, cond
|
||
# 其余表只给名字,让模型知道还有别的表
|
||
assert "stores" in text and "parks" in text and "v_daily_visits" in text
|
||
# 写 SQL 的硬性要求保留
|
||
assert "写 SQL 的硬性要求" in text and "FETCH FIRST n ROWS ONLY" in text
|
||
|
||
|
||
async def test_inferred_relations_are_marked_and_catalog_is_attached(scenic_source):
|
||
"""visits.gate_id → gates 没有外键约束,是按列名推断的,连接条件要标出来;目录里推断的项同样要标。"""
|
||
from app.api import copilot_context
|
||
|
||
async with SessionLocal() as session:
|
||
await catalog.write_entry(session, scenic_source.id, "visits", {
|
||
"label": make_item("入园记录", "llm"), "grain": make_item("每张门票每次检票一行", "human")},
|
||
if_version=0, actor="王敏")
|
||
plan = await copilot_context.plan_context([scenic_source])
|
||
ctx = await plan.resolve(Picker({"tables": [{"table": "visits"}, {"table": "gates"}]}), need="各入口闸机的入园人次")
|
||
text = ctx.section()
|
||
assert f"visits.gate_id = gates.id(多对一){INFERRED_MARK}" in text
|
||
assert f"中文名:入园记录{INFERRED_MARK}" in text
|
||
assert "粒度:每张门票每次检票一行" in text and f"每张门票每次检票一行{INFERRED_MARK}" not in text
|
||
|
||
|
||
async def test_rejected_relations_are_not_used_for_join_paths(scenic_source):
|
||
"""人工驳回的关系不能拿来补路径:驳回 channel_visits → visits 后,关联表不会因为补路径被带进来。"""
|
||
from app.api import copilot_context
|
||
|
||
rels = [dict(r, status="rejected") if r["to_table"] == "visits" else r
|
||
for r in catalog.fk_relations(scenic_source.schema_cache, "channel_visits")]
|
||
async with SessionLocal() as session:
|
||
await catalog.write_entry(session, scenic_source.id, "channel_visits", {"relations": rels},
|
||
if_version=0, actor="王敏")
|
||
plan = await copilot_context.plan_context([scenic_source])
|
||
ctx = await plan.resolve(Picker(PICKED), need=NEED)
|
||
assert ctx.sources[0].tables == ["visits", "channels"]
|
||
assert "channel_visits.visit_id = visits.id" not in ctx.section()
|
||
|
||
|
||
async def test_picks_are_capped_and_paths_respect_the_total_limit(scenic_source):
|
||
from app.api import copilot_context
|
||
|
||
names = list(scenic_source.schema_cache["tables"])
|
||
plan = await copilot_context.plan_context([scenic_source])
|
||
ctx = await plan.resolve(Picker({"tables": [{"table": n} for n in reversed(names)]}), need="全部都要")
|
||
# 挑中的按模型给的顺序截到上限;补中间表之后合计也不超过总上限
|
||
assert ctx.sources[0].picked == list(reversed(names))[:copilot_context.PICK_LIMIT]
|
||
assert ctx.sources[0].tables[:copilot_context.PICK_LIMIT] == ctx.sources[0].picked
|
||
assert len(ctx.sources[0].tables) <= copilot_context.CONTEXT_TABLE_LIMIT
|
||
|
||
# 两两都要补中间表的一组:总数不超过上限
|
||
spread = ["reviews", "channels", "stores", "tour_groups", "coupons", "products"]
|
||
ctx = await plan.resolve(Picker({"tables": [{"table": n} for n in spread]}), need="x")
|
||
assert set(spread) <= set(ctx.sources[0].tables)
|
||
assert len(ctx.sources[0].tables) <= copilot_context.CONTEXT_TABLE_LIMIT
|
||
|
||
|
||
async def test_tables_used_by_the_current_graph_are_always_included(scenic_source):
|
||
"""改图、追问时现有工作流已经查了哪些表是确定的事实:挑表成功时并进来,挑表失败时也给它们的字段。"""
|
||
from app.api import copilot_context
|
||
|
||
graph = {"nodes": [{"id": "q", "type": "tool", "data": {"label": "查门店", "config": {
|
||
"tool": f"db_query__{scenic_source.name}", "args": {"sql": "SELECT s.name FROM stores s"}}}}], "edges": []}
|
||
plan = await copilot_context.plan_context([scenic_source], graph=graph)
|
||
picker = Picker({"tables": [{"table": "visits"}]})
|
||
ctx = await plan.resolve(picker, need="再按入园人次排一下")
|
||
assert ctx.sources[0].tables[:2] == ["stores", "visits"]
|
||
assert "当前工作流已用到" in picker.calls[0] and "stores" in picker.calls[0]
|
||
|
||
failed = await plan.resolve(Picker(error=RuntimeError("网关超时")), need="再按入园人次排一下")
|
||
assert failed.sources[0].selected_by == "fallback"
|
||
assert failed.sources[0].tables == ["stores"]
|
||
assert "store_code TEXT" in failed.section()
|
||
|
||
|
||
async def test_several_sources_pick_with_source_names(scenic_db, tmp_path):
|
||
from app.api import copilot_context
|
||
|
||
a = await _add_source(scenic_db)
|
||
b = await _add_source(str(scenic.build(tmp_path / "b.db")))
|
||
try:
|
||
plan = await copilot_context.plan_context([a, b])
|
||
picker = Picker({"tables": [{"source": a.name, "table": "visits"}, {"source": b.name, "table": "orders"},
|
||
f"{b.name}.payments", {"source": "没有这个库", "table": "refunds"}]})
|
||
ctx = await plan.resolve(picker, need="订单和入园")
|
||
assert f"数据源「{a.name}」" in picker.calls[0] and f"数据源「{b.name}」" in picker.calls[0]
|
||
by_name = {s.source.name: s for s in ctx.sources}
|
||
assert by_name[a.name].tables == ["visits"]
|
||
assert by_name[b.name].tables == ["orders", "payments"]
|
||
op = ctx.op()
|
||
assert [s["source"] for s in op["sources"]] == [a.name, b.name]
|
||
assert all(s["selected_by"] == "model" for s in op["sources"])
|
||
finally:
|
||
await _drop_source(a)
|
||
await _drop_source(b)
|
||
|
||
|
||
async def test_small_source_keeps_detail_and_carries_the_catalog(small_source):
|
||
from app.api import copilot_context
|
||
|
||
async with SessionLocal() as session:
|
||
await catalog.write_entry(session, small_source.id, "orders", {
|
||
"label": make_item("订单", "llm"),
|
||
"columns": {"amount": {"unit": make_item("元", "human")}}}, if_version=0, actor="王敏")
|
||
picker = Picker(error=AssertionError("小库不该挑表"))
|
||
plan = await copilot_context.plan_context([small_source])
|
||
assert not plan.needs_pick
|
||
ctx = await plan.resolve(picker, need="订单总额")
|
||
text = ctx.section()
|
||
assert not picker.calls
|
||
# 详细模式的写法照旧:每张表一行带字段
|
||
assert "· orders[表]" in text and "· customers[表]" in text
|
||
# 额外带上目录和连接条件
|
||
assert f"中文名:订单{INFERRED_MARK}" in text and "单位:元" in text
|
||
assert f"orders.customer_id = customers.id(多对一){INFERRED_MARK}" in text
|
||
op = ctx.op()
|
||
assert op["sources"] == [{"source": small_source.name, "source_id": small_source.id,
|
||
"tables": ["customers", "orders"], "selected_by": "all", "total": 2,
|
||
"catalog_tables": 1}]
|
||
|
||
|
||
# ---------------------------------------------------------------- 回退:绝不阻断生成
|
||
|
||
|
||
@pytest.mark.parametrize("picker", [
|
||
Picker(error=RuntimeError("网关超时")),
|
||
Picker(PICKED, delay=5),
|
||
Picker({"tables": [{"table": "no_such_table"}, {"table": "也没有这张"}]}),
|
||
Picker({"tables": []}),
|
||
Picker("我觉得都需要"),
|
||
], ids=["error", "timeout", "unknown", "empty", "garbage"])
|
||
async def test_pick_failures_fall_back_to_names_only(client, monkeypatch, scenic_source, picker):
|
||
import app.api.copilot_context as copilot_context
|
||
|
||
monkeypatch.setattr(copilot_context, "PICK_TIMEOUT_S", 0.2)
|
||
_use_picker(monkeypatch, picker)
|
||
model = Scripted(_graph_using(f"db_query__{scenic_source.name}"))
|
||
events = await _stream(client, monkeypatch, model, {
|
||
"instruction": NEED, "intent": "answer", "datasource_ids": [scenic_source.id]})
|
||
|
||
op = _context_op(events)
|
||
assert op["sources"] == [{"source": scenic_source.name, "source_id": scenic_source.id, "tables": [],
|
||
"selected_by": "fallback", "total": scenic.TABLE_COUNT + len(scenic.VIEWS),
|
||
"catalog_tables": 0, "reason": op["sources"][0]["reason"]}]
|
||
assert op["sources"][0]["reason"]
|
||
system = model.systems[0]
|
||
# 和以前一样只列表名、提醒先查字段
|
||
assert "channel_visits" in system and "attributed_share" not in system
|
||
assert "写 SQL 前用 db_schema 工具确认字段" in system
|
||
# 生成照常进行
|
||
assert events[-1]["op"] == "final", events[-1]
|
||
|
||
|
||
|
||
# ---------------------------------------------------------------- 流式:context 操作、自查沿用同一份上下文
|
||
|
||
|
||
async def test_stream_reports_the_context_and_prompt_carries_selected_tables(client, monkeypatch, scenic_source):
|
||
from app.api import copilot_context
|
||
|
||
picker = Picker(PICKED)
|
||
specs = _use_picker(monkeypatch, picker)
|
||
model = Scripted(_graph_using(f"db_query__{scenic_source.name}"))
|
||
events = await _stream(client, monkeypatch, model, {
|
||
"instruction": NEED, "intent": "answer", "datasource_ids": [scenic_source.id]})
|
||
|
||
op = _context_op(events)
|
||
assert op["sources"] == [{"source": scenic_source.name, "source_id": scenic_source.id,
|
||
"tables": ["visits", "channels", "channel_visits"], "selected_by": "model",
|
||
"total": scenic.TABLE_COUNT + len(scenic.VIEWS), "catalog_tables": 0}]
|
||
assert isinstance(op["elapsed_ms"], int) and op["elapsed_ms"] >= 0
|
||
# context 在模型首帧之后、第一条图操作之前
|
||
kinds = [e["op"] for e in events]
|
||
assert kinds.index("model") < kinds.index("context") < kinds.index("add_node")
|
||
|
||
# 挑表用同一个助手模型,关掉思考、额度取小
|
||
(spec,) = specs
|
||
assert spec.thinking == "off" and spec.max_tokens == copilot_context.PICK_MAX_TOKENS
|
||
assert spec.max_tokens <= 2048
|
||
|
||
system = model.systems[0]
|
||
for col in SELECTED_COLUMNS:
|
||
assert col in system, col
|
||
for col in OTHER_COLUMNS:
|
||
assert col not in system, col
|
||
for cond in JOINS:
|
||
assert cond in system, cond
|
||
# 工具清单里列出的可查对象,先列挑中的表
|
||
tool_line = next(line for line in system.splitlines() if line.startswith(f"- db_query__{scenic_source.name}"))
|
||
assert "可查:visits、channels、channel_visits" in tool_line
|
||
|
||
|
||
async def test_self_check_repair_reuses_the_same_context(client, monkeypatch, scenic_source):
|
||
picker = Picker(PICKED)
|
||
_use_picker(monkeypatch, picker)
|
||
tool = f"db_query__{scenic_source.name}"
|
||
first = [
|
||
{"op": "add_node", "node": {"id": "start", "type": "input", "label": "输入", "config": {}}},
|
||
{"op": "add_node", "node": {"id": "q", "type": "tool", "label": "查入园", "config": {
|
||
"tool": tool, "args": {"sql": "SELECT v.no_such_col FROM visits v"}, "assign_to": "rows"}}},
|
||
{"op": "add_edge", "edge": {"source": "start", "target": "q"}},
|
||
{"op": "add_node", "node": {"id": "out", "type": "output", "label": "成果", "config": {
|
||
"fields": [{"name": "rows", "value": "{{ vars.rows }}"}]}}},
|
||
{"op": "add_edge", "edge": {"source": "q", "target": "out"}},
|
||
{"op": "done", "explanation": "查一下", "run": False},
|
||
]
|
||
fix = [{"op": "update_node", "id": "q", "config": {"args": {"sql": "SELECT v.visitor_count FROM visits v"}}},
|
||
{"op": "done"}]
|
||
model = Scripted(first, fix)
|
||
events = await _stream(client, monkeypatch, model, {
|
||
"instruction": NEED, "datasource_ids": [scenic_source.id]})
|
||
|
||
assert any(e.get("op") == "check" and e.get("status") == "repairing" for e in events)
|
||
assert len(model.systems) == 2 and "上线前自查" in model.humans[1]
|
||
# 修正轮带的是同一份上下文,没有重新挑表
|
||
assert model.systems[1] == model.systems[0]
|
||
assert "attributed_share REAL" in model.systems[1]
|
||
assert len(picker.calls) == 1
|
||
|
||
|
||
async def test_context_op_says_how_much_of_each_source_has_a_catalog(scenic_source):
|
||
"""context 操作里每个源带上有数据目录的表数(catalog_tables)和源的 id:没有目录的大库,界面提示先去起草目录。
|
||
|
||
真实运行里数据源一张目录都没有,挑表和 SQL 检查都用不上目录,用户也不知道该先去起草。只算有可见项的表:
|
||
整份被驳回的不算有目录。"""
|
||
from app.api import copilot_context
|
||
|
||
async def op() -> dict:
|
||
plan = await copilot_context.plan_context([scenic_source])
|
||
return (await plan.resolve(Picker(PICKED), need=NEED)).op()["sources"][0]
|
||
|
||
first = await op()
|
||
assert first["catalog_tables"] == 0 and first["source_id"] == scenic_source.id
|
||
async with SessionLocal() as session:
|
||
await catalog.write_entry(session, scenic_source.id, "visits", {
|
||
"label": make_item("入园记录", "llm")}, if_version=0, actor="王敏")
|
||
rejected = make_item("门店", "llm")
|
||
rejected["status"] = "rejected"
|
||
await catalog.write_entry(session, scenic_source.id, "stores", {"label": rejected}, if_version=0, actor="王敏")
|
||
assert (await op())["catalog_tables"] == 1
|
||
|
||
|
||
async def test_small_sources_do_not_call_the_picker(client, monkeypatch, small_source):
|
||
picker = Picker(error=AssertionError("小库不该挑表"))
|
||
_use_picker(monkeypatch, picker)
|
||
model = Scripted(_graph_using(f"db_query__{small_source.name}"))
|
||
events = await _stream(client, monkeypatch, model, {
|
||
"instruction": "订单总额", "intent": "answer", "datasource_ids": [small_source.id]})
|
||
op = _context_op(events)
|
||
assert op["sources"][0]["selected_by"] == "all" and "elapsed_ms" not in op
|
||
assert not picker.calls
|
||
assert "· orders[表]" in model.systems[0]
|
||
|
||
|
||
# ---------------------------------------------------------------- 非流式和两个兜底入口:同一套组装,不发 context
|
||
|
||
|
||
async def test_one_shot_generate_uses_the_same_context(client, monkeypatch, scenic_source):
|
||
import app.api.copilot as copilot
|
||
|
||
picker = Picker(PICKED)
|
||
_use_picker(monkeypatch, picker)
|
||
seen: list = []
|
||
|
||
class _OneShot:
|
||
def with_structured_output(self, *_a, **_k):
|
||
return self
|
||
|
||
async def ainvoke(self, messages):
|
||
seen.append(messages)
|
||
return {"nodes": [], "edges": []}
|
||
|
||
async def _model(*_a, **_k):
|
||
return _OneShot(), "mock-fast"
|
||
|
||
monkeypatch.setattr(copilot, "get_chat_model", _model)
|
||
r = await client.post("/api/copilot/generate", json={"instruction": NEED, "datasource_ids": [scenic_source.id]})
|
||
assert r.status_code == 200, r.text
|
||
assert len(picker.calls) == 1 and NEED in picker.calls[0]
|
||
system = next(t for role, t in seen[0] if role == "system")
|
||
for cond in JOINS:
|
||
assert cond in system
|
||
assert "attributed_share REAL" in system
|
||
|
||
|
||
def _node(nid, ntype, label, **config):
|
||
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label, "config": config}}
|
||
|
||
|
||
async def test_publish_fix_shares_the_context(monkeypatch, scenic_source):
|
||
"""发布前修复:现有图查的表一定带上,需求写的是挡住发布的问题;不发 context 操作(返回值里没有)。"""
|
||
import app.api.copilot as copilot
|
||
|
||
picker = Picker({"tables": [{"source": scenic_source.name, "table": "visits"},
|
||
{"source": scenic_source.name, "table": "channels"}]})
|
||
_use_picker(monkeypatch, picker)
|
||
model = Scripted([{"op": "plan", "summary": "看一下"}, {"op": "done", "explanation": "无法修改"}])
|
||
|
||
async def _model(*_a, **_k):
|
||
return model, "mock-fast"
|
||
|
||
monkeypatch.setattr(copilot, "get_chat_model", _model)
|
||
graph = {"nodes": [
|
||
_node("start", "input", "周期"),
|
||
_node("fetch", "tool", "取数", tool=f"db_query__{scenic_source.name}",
|
||
args={"sql": "SELECT SUM(amount) AS gmv FROM store_sales"}),
|
||
_node("write", "report", "报告撰写", instructions="写周报", numbers="strict", on_violation="fail"),
|
||
_node("done", "output", "出具", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]),
|
||
_node("raw", "output", "原始数", fields=[{"name": "数", "value": "{{ nodes.fetch }}"}]),
|
||
], "edges": [{"id": f"e{i}", "source": a, "target": b} for i, (a, b) in enumerate(
|
||
[("start", "fetch"), ("fetch", "write"), ("write", "done"), ("fetch", "raw")])]}
|
||
async with SessionLocal() as session:
|
||
out = await copilot.assist_publish_fix(session, graph, level="governed")
|
||
assert model.systems, out
|
||
assert len(picker.calls) == 1 and "发布前检查" in picker.calls[0]
|
||
system = model.systems[0]
|
||
assert "attributed_share REAL" in system and "sold_at TEXT" in system
|
||
assert "context" not in json.dumps(out, ensure_ascii=False)
|
||
|
||
|
||
async def test_upgrade_shares_the_context(monkeypatch, scenic_source):
|
||
import app.api.copilot as copilot
|
||
|
||
picker = Picker({"tables": [{"source": scenic_source.name, "table": "no_such_table"}]})
|
||
_use_picker(monkeypatch, picker)
|
||
model = Scripted([{"op": "plan", "summary": "看一下"}, {"op": "done", "explanation": "不改"}])
|
||
|
||
async def _model(*_a, **_k):
|
||
return model, "mock-fast"
|
||
|
||
monkeypatch.setattr(copilot, "get_chat_model", _model)
|
||
graph = {"nodes": [
|
||
_node("start", "input", "输入"),
|
||
_node("fetch", "tool", "查库", tool=f"db_query__{scenic_source.name}",
|
||
args={"sql": "SELECT SUM(amount) AS a, COUNT(*) AS b FROM store_sales"}),
|
||
_node("calc", "code", "算比率", language="python", assign_to="calc",
|
||
code="import json\nprint(json.dumps({'ratio': 3 / 4}))"),
|
||
_node("card", "metrics", "口径卡", metrics=[{"id": "ratio", "name": "客单价", "expression": "vars.calc.ratio"}]),
|
||
_node("write", "report", "报告撰写", instructions="写一句话"),
|
||
_node("out", "output", "成果", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]),
|
||
], "edges": [{"id": f"e{i}", "source": a, "target": b} for i, (a, b) in enumerate(
|
||
[("start", "fetch"), ("fetch", "calc"), ("calc", "card"), ("card", "write"), ("write", "out")])]}
|
||
async with SessionLocal() as session:
|
||
await copilot.assist_upgrade(session, graph, level="published")
|
||
assert model.systems and len(picker.calls) == 1
|
||
# 挑表失败也不挡:现有图查的表照样给全字段,其余表只列名字
|
||
system = model.systems[0]
|
||
assert "sold_at TEXT" in system and "attributed_share" not in system
|