mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 05:21:57 +00:00
ba28dee1f0
- api/copilot.py:event_stream 原样搬成 async generator answer_ops(payload, *, catalog_view=None),交出的 op dict 不变;开流前的准备(数据源、目录检查器、助手模型、历史、查看服务)用自己的短会话,在第一条操作之前做完。SSE 路由只包一层:先取第一条,开流前的拒绝(模型没配好、限定的数据源都不在)照旧是 HTTP 错误
- catalog_view 一路传给查看服务和自查的 SQL 检查器(目录视图接缝的「生成过程从参数读」)
- providers/usage.py:Meter 是一个 LangChain 回调,和 get_usage_metadata_callback 同一个机制(登记过的上下文变量),按模型累计 tokens、次数,金额按价格目录;估不出的记 unpriced_calls。没直接用 get_usage_metadata_callback:with 块跨 yield 会把变量留在调用方上下文里,调用方随后发起的运行会继承它、被重复计一遍;metered 只在每一步 __anext__ 期间挂上,嵌套时并进外层
- 生成过程读到 done / look / ask 就关模型的流(后面的闲话不要),流末尾才报的用量拿不到:这一次照样计次数和已报的 token,记成估不出,不悄悄少算(遗留:真实接入上建图那几次多半是下限)
- providers/budget.py:BudgetFallback.astream 用 aclosing 包住里面那条流——以前调用方关流时里面那条要等垃圾回收才关,模型那次调用的收尾(用量回调)落在算完账之后;测试去掉它回跑会少计一次
- answer_ops 末尾多一条 {op: usage, by_model, input_tokens, output_tokens, calls, cost_usd, unpriced_calls};/copilot/review 拆出 review_of_run(问数据页、评测共用),结果带 usage(没调模型时全 0)和 retry_instruction(要不要带着原图重来、说什么,按 chat/turn.py 的决定算;ReviewIn 多一个 attempt)
- 新增 app/chat/turn.py(问数据一轮的决定,复核这边先用上;final.next、对照测试、前端照做在下一个提交);新增 tests/test_copilot_answer_ops.py(八种结局的 SSE 输出和抽出来之前录下的 fixtures/copilot_answer_ops_baseline.json 逐条相同,末尾多一条 usage;直接迭代和 SSE 同一串;开流前的拒绝仍是 HTTP 错误)、tests/test_usage_meter.py;读 SSE 的十一个测试文件的收流帮手跳过末尾那条 usage
205 lines
9.2 KiB
Python
205 lines
9.2 KiB
Python
"""问数据时可以把这一轮限定在某几个数据源上。
|
||
|
||
问数据页的数据源标签要能「点一下,这次只查这个库」。以前助手每一轮都看得到
|
||
全部数据源,追问时还会沿用上一轮接的那个库——用户明明想换一个库问,得到的
|
||
还是旧库上的答案,而且说不清为什么。
|
||
|
||
限定之后:给模型看的工具和结构只剩这几个库;图里用了范围外的库,按自查的
|
||
规则交回去改,改不掉就不自动运行。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import sqlite3
|
||
|
||
import pytest
|
||
from httpx import ASGITransport, AsyncClient
|
||
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import DataSource
|
||
from app.main import app
|
||
|
||
|
||
@pytest.fixture
|
||
async def client():
|
||
transport = ASGITransport(app=app)
|
||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||
yield c
|
||
|
||
|
||
@pytest.fixture
|
||
async def sources(tmp_path):
|
||
"""两个库:scope_shop(orders)和 scope_sales(sales_daily)。"""
|
||
made: dict[str, str] = {}
|
||
async with SessionLocal() as session:
|
||
for name, table in (("scope_shop", "orders"), ("scope_sales", "sales_daily")):
|
||
path = tmp_path / f"{name}.db"
|
||
db = sqlite3.connect(path)
|
||
db.execute(f"CREATE TABLE {table} (id INTEGER PRIMARY KEY, amount REAL)")
|
||
db.commit()
|
||
db.close()
|
||
row = DataSource(name=name, kind="sqlite", database=str(path), description=f"{table} 库",
|
||
schema_cache={"tables": {table: {"qualified": table, "columns": [
|
||
{"name": "id", "type": "INTEGER"}, {"name": "amount", "type": "REAL"}]}}})
|
||
session.add(row)
|
||
await session.flush()
|
||
made[name] = row.id
|
||
await session.commit()
|
||
yield made
|
||
async with SessionLocal() as session:
|
||
for source_id in made.values():
|
||
row = await session.get(DataSource, source_id)
|
||
if row:
|
||
await session.delete(row)
|
||
await session.commit()
|
||
|
||
|
||
class _Scripted:
|
||
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) -> tuple[int, 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:
|
||
if r.status_code != 200:
|
||
return r.status_code, [json.loads(await r.aread())]
|
||
async for line in r.aiter_lines():
|
||
# 末尾那条 usage 是这一轮助手的用量(answer_ops),不是生成的操作
|
||
if line.startswith("data: ") and not line.startswith('data: {"op": "usage"'):
|
||
events.append(json.loads(line[6:]))
|
||
return 200, events
|
||
|
||
|
||
async def test_without_a_scope_the_model_sees_every_source(client, monkeypatch, sources):
|
||
model = _Scripted(_graph_using("db_query__scope_shop"))
|
||
await _stream(client, monkeypatch, model, {"instruction": "订单多少", "intent": "answer"})
|
||
assert "db_query__scope_shop" in model.systems[0]
|
||
assert "db_query__scope_sales" in model.systems[0]
|
||
|
||
|
||
async def test_a_scope_narrows_what_the_model_sees(client, monkeypatch, sources):
|
||
model = _Scripted(_graph_using("db_query__scope_shop"))
|
||
status, events = await _stream(client, monkeypatch, model, {
|
||
"instruction": "订单多少", "intent": "answer", "datasource_ids": [sources["scope_shop"]],
|
||
})
|
||
assert status == 200
|
||
system = model.systems[0]
|
||
assert "db_query__scope_shop" in system and "orders" in system
|
||
assert "db_query__scope_sales" not in system and "sales_daily" not in system
|
||
assert "只查" in system and "scope_shop" in system, "限定要明说,不然模型会沿用上一轮的库"
|
||
final = events[-1]
|
||
assert final["op"] == "final" and final["autorun"] is True
|
||
assert not [i for i in final["issues"] if i.get("code") == "datasource_out_of_scope"]
|
||
|
||
|
||
async def test_a_scope_can_name_sources_too(client, monkeypatch, sources):
|
||
model = _Scripted(_graph_using("db_query__scope_sales"))
|
||
await _stream(client, monkeypatch, model, {
|
||
"instruction": "销售额", "intent": "answer", "datasource_ids": ["scope_sales"],
|
||
})
|
||
assert "db_query__scope_sales" in model.systems[0]
|
||
assert "db_query__scope_shop" not in model.systems[0]
|
||
|
||
|
||
async def test_using_a_source_outside_the_scope_is_sent_back(client, monkeypatch, sources):
|
||
fix = [{"op": "update_node", "id": "ask", "config": {"tools": ["db_query__scope_shop"]}},
|
||
{"op": "done"}]
|
||
model = _Scripted(_graph_using("db_query__scope_sales"), fix)
|
||
_, events = await _stream(client, monkeypatch, model, {
|
||
"instruction": "订单多少", "intent": "answer", "datasource_ids": [sources["scope_shop"]],
|
||
})
|
||
checks = [e for e in events if e.get("op") == "check"]
|
||
assert [c["status"] for c in checks] == ["repairing", "passed"], checks
|
||
out_of_scope = [i for i in checks[0]["issues"] if "scope_sales" in i["message"]]
|
||
assert out_of_scope, checks[0]
|
||
assert out_of_scope[0]["node_id"] == "ask"
|
||
assert out_of_scope[0]["code"] == "datasource_out_of_scope", "和 final.issues 同一个 code"
|
||
final = events[-1]
|
||
ask = next(n for n in final["graph"]["nodes"] if n["id"] == "ask")
|
||
assert ask["data"]["config"]["tools"] == ["db_query__scope_shop"]
|
||
assert final["autorun"] is True
|
||
|
||
|
||
async def test_a_scope_that_is_never_honoured_is_not_run(client, monkeypatch, sources):
|
||
model = _Scripted(_graph_using("db_query__scope_sales"))
|
||
_, events = await _stream(client, monkeypatch, model, {
|
||
"instruction": "订单多少", "intent": "answer", "datasource_ids": [sources["scope_shop"]],
|
||
})
|
||
final = events[-1]
|
||
assert final["autorun"] is False
|
||
out_of_scope = [i for i in final["issues"] if i.get("code") == "datasource_out_of_scope"]
|
||
assert [i["node_id"] for i in out_of_scope] == ["ask"]
|
||
assert "scope_sales" in out_of_scope[0]["message"] and "scope_shop" in out_of_scope[0]["message"]
|
||
|
||
|
||
async def test_a_scope_of_unknown_sources_is_refused_up_front(client, monkeypatch, sources):
|
||
status, body = await _stream(client, monkeypatch, _Scripted([]), {
|
||
"instruction": "订单多少", "intent": "answer", "datasource_ids": ["no_such_source"],
|
||
})
|
||
assert status == 400
|
||
detail = body[0]["detail"]
|
||
assert "数据源" in detail and "no_such_source" not in detail.split(":")[0]
|
||
# 前端按机器码认,不按「限定的数据源」这几个字的前缀认:文案改了也不失灵。
|
||
# detail 仍是一句话,老前端照旧能显示
|
||
assert isinstance(detail, str)
|
||
assert body[0]["code"] == "datasource_scope_empty"
|
||
|
||
|
||
async def test_the_one_shot_generate_refuses_an_empty_scope_with_the_same_code(client, sources):
|
||
r = await client.post("/api/copilot/generate", json={
|
||
"instruction": "订单多少", "datasource_ids": ["no_such_source"],
|
||
})
|
||
assert r.status_code == 400, r.text
|
||
assert r.json()["code"] == "datasource_scope_empty"
|
||
assert isinstance(r.json()["detail"], str)
|
||
|
||
|
||
async def test_the_one_shot_generate_honours_the_scope(client, monkeypatch, sources):
|
||
import app.api.copilot as copilot
|
||
|
||
# 非流式的 /generate 和流式走同一个生成过程(copilot_gen):模型按操作流回答
|
||
model = _Scripted([{"op": "reply", "text": "好"}])
|
||
|
||
async def _model(*_a, **_k):
|
||
return model, "mock-fast"
|
||
|
||
monkeypatch.setattr(copilot, "get_chat_model", _model)
|
||
r = await client.post("/api/copilot/generate", json={
|
||
"instruction": "订单多少", "datasource_ids": [sources["scope_shop"]],
|
||
})
|
||
assert r.status_code == 200, r.text
|
||
system = model.systems[0]
|
||
assert "db_query__scope_shop" in system and "db_query__scope_sales" not in system
|