mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:21:59 +00:00
dd00234622
- engine/origin.py:SYSTEM_ORIGINS = (eval, reconcile, recall) 和 counted_runs()(写法同 hidden_runs,coalesce 防 NULL:来源为空的老运行、外连接没对上运行的行照常计入);ledger_issue 是替用户正式出具,照常计入 - 取问题、聚类全站只留一份:新增 app/chat/questions.py(recent_questions / cluster_questions)。只取问题原文、不取整份输入(以前把含 history 的 input JSON 拿去聚,同一段对话里的不同问题会聚成一类);问数据按轮次一轮一条(conversation_turns.question),同一轮里的自动重来只是换了挂着的运行,不多算;别的探索运行取 input.question;排除系统运行和别人的私有运行。/governance/exploratory-clusters 改用它,多给 turn_ids 和 embedder(本地哈希向量的结果只是下限) - 工具白名单衰减、目录的使用次数和「被哪些运行用过」、审阅队列的使用权重(都走 catalog.query_snapshot_tables)只算 counted_runs 圈进来的运行 - 记录页:没按来源筛时默认不列这三类,按来源点名就列;新增 GET /api/runs/unlisted(同一份筛选,_filtered 和列表共用)照实给出没列出几次、各是哪种来源。界面在列表底下(空列表时也写)写「另有 N 次系统运行未列出(评测 2 次、口径对账 1 次)」,来源下拉的「全部」改说「全部来源(不含系统运行)」;文案进 lib/terms.ts 的 SYSTEM_RUNS_TEXT;check-runs 补一段 - test_personal_apps 里代表「别的探索运行」的两条运行改成画布试跑:问数据的问题按轮次取,没挂在任何一轮上的问数据运行不单独算 - 新增 tests/test_eval_origin.py(去掉 counted_runs 的过滤回跑,5 项都会失败)
233 lines
11 KiB
Python
233 lines
11 KiB
Python
"""系统派生的运行不进使用统计(docs/design/next-round/00-总方案.md X1,细案 01 做法 4)。
|
|
|
|
评测、口径对账、口径召回(engine/origin.SYSTEM_ORIGINS)是服务端替人跑的完整运行,证据点得开,但不是有人在用。这里守:
|
|
|
|
- 客户端声明不了这几种来源;ledger_issue(替用户正式出具)照常计入;
|
|
- counted_runs() 只去掉这三种,来源为空的老运行照常计入(防 NULL);
|
|
- 探索问题聚类只取问题原文(不含 history),问数据一轮一条(自动重来不多算),评测运行不进;
|
|
- 工具白名单衰减、目录的使用次数和「被哪些运行用过」、审阅队列的使用权重都不计评测运行;
|
|
- 记录页默认不列这三种,按来源点名就列;GET /api/runs/unlisted 照实说没列出几次。
|
|
|
|
表名、问题、工作流名都是虚构的。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
from sqlalchemy import delete, select
|
|
|
|
from app.data import catalog, catalog_review
|
|
from app.db.base import SessionLocal
|
|
from app.db.models import Artifact, Conversation, ConversationTurn, DataSource, Run, RunEvent, Workflow
|
|
from app.engine.origin import CLIENT_ORIGINS, ORIGIN_KINDS, SYSTEM_ORIGINS, counted_runs
|
|
from app.main import app
|
|
from tests.fixtures.sources import drop_source
|
|
|
|
BASE = datetime(2026, 9, 1, tzinfo=timezone.utc)
|
|
MARK = f"计入统计-{uuid.uuid4().hex[:6]}"
|
|
|
|
|
|
@pytest.fixture
|
|
async def client():
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
|
yield c
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
async def _clean():
|
|
yield
|
|
async with SessionLocal() as session:
|
|
await session.execute(delete(Run).where(Run.workflow_name.like(f"{MARK}%")))
|
|
await session.execute(delete(Conversation).where(Conversation.title.like(f"{MARK}%")))
|
|
await session.commit()
|
|
|
|
|
|
async def _run(origin: str | None, *, minutes: int = 0, **extra) -> str:
|
|
rid = uuid.uuid4().hex
|
|
async with SessionLocal() as session:
|
|
session.add(Run(id=rid, graph={}, workflow_name=f"{MARK}-{origin}", run_class="exploratory", status="succeeded",
|
|
origin_kind=origin, created_at=BASE + timedelta(minutes=minutes), **extra))
|
|
await session.commit()
|
|
return rid
|
|
|
|
|
|
def test_system_origins_are_server_only_and_ledger_issue_counts():
|
|
assert set(SYSTEM_ORIGINS) == {"eval", "reconcile", "recall"}
|
|
assert set(SYSTEM_ORIGINS) <= set(ORIGIN_KINDS)
|
|
assert not set(SYSTEM_ORIGINS) & set(CLIENT_ORIGINS)
|
|
assert "ledger_issue" in ORIGIN_KINDS and "ledger_issue" not in SYSTEM_ORIGINS, "替用户正式出具照常计入"
|
|
|
|
|
|
async def test_a_client_cannot_claim_a_system_origin(client):
|
|
for origin in (*SYSTEM_ORIGINS, "ledger_issue"):
|
|
r = await client.post("/api/runs", json={"graph": {"nodes": [], "edges": []}, "origin": origin})
|
|
assert r.status_code == 422 and r.json()["code"] == "origin_not_allowed", (origin, r.text)
|
|
|
|
|
|
async def test_counted_runs_keeps_legacy_null_origins():
|
|
ids = {origin: await _run(origin) for origin in (None, "chat", "ledger_issue", *SYSTEM_ORIGINS)}
|
|
async with SessionLocal() as session:
|
|
counted = set((await session.execute(
|
|
select(Run.id).where(Run.id.in_(list(ids.values())), counted_runs()))).scalars())
|
|
assert counted == {ids[None], ids["chat"], ids["ledger_issue"]}
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 探索问题聚类
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
async def _turn(conversation_id: str, question: str, run_id: str | None, seq: int, minutes: int) -> str:
|
|
async with SessionLocal() as session:
|
|
turn = ConversationTurn(conversation_id=conversation_id, seq=seq, question=question, run_id=run_id,
|
|
status="done", created_at=BASE + timedelta(minutes=minutes))
|
|
session.add(turn)
|
|
await session.commit()
|
|
return turn.id
|
|
|
|
|
|
async def _conversation() -> str:
|
|
async with SessionLocal() as session:
|
|
row = Conversation(title=f"{MARK}-对话", kind="chat")
|
|
session.add(row)
|
|
await session.commit()
|
|
return row.id
|
|
|
|
|
|
async def test_clusters_take_only_the_question_once_per_turn_and_skip_eval(client, monkeypatch):
|
|
from app.memory import embeddings
|
|
|
|
question = f"{MARK} 上周各门店的销售额"
|
|
|
|
async def by_text(texts):
|
|
# 同一句话同一个向量,别的话各不相同:聚类只看取了什么文字
|
|
return [[1.0, 0.0] if t == question else [0.0, float(i + 1)] for i, t in enumerate(texts)]
|
|
|
|
monkeypatch.setattr(embeddings, "embed_texts", by_text)
|
|
conv = await _conversation()
|
|
# 第一轮:答坏了自动重来一次——两次运行,轮次只挂着后一次;输入里的 history 各不相同也不影响
|
|
first = await _run("chat", minutes=1, input={"question": question, "history": "问:别的\n答:别的"})
|
|
retried = await _run("chat", minutes=2, input={"question": question, "history": "问:别的\n答:别的"})
|
|
turn_a = await _turn(conv, question, retried, 1, 2)
|
|
# 第二轮:同一个问题又问了一次,这次只回了一句、没有运行
|
|
turn_b = await _turn(conv, question, None, 2, 3)
|
|
# 评测作答的运行:同一个问题,不进聚类
|
|
graded = await _run("eval", minutes=4, input={"question": question})
|
|
# 画布试跑:输入里有 question 的照样收
|
|
canvas = await _run("canvas", minutes=5, input={"question": question})
|
|
|
|
body = (await client.get("/api/governance/exploratory-clusters", params={"limit": 500})).json()
|
|
mine = [c for c in body["clusters"] if c["representative"] == question]
|
|
assert len(mine) == 1, body
|
|
cluster = mine[0]
|
|
assert cluster["count"] == 3, "一轮一条(自动重来不多算)+ 画布那一次;评测不算"
|
|
assert set(cluster["turn_ids"]) == {turn_a, turn_b}
|
|
assert set(cluster["run_ids"]) == {retried, canvas}
|
|
assert first not in cluster["run_ids"] and graded not in cluster["run_ids"]
|
|
assert cluster["promote_candidate"] is True
|
|
assert body["embedder"]
|
|
|
|
|
|
async def test_the_history_never_reaches_the_clusterer(monkeypatch):
|
|
from app.api.deps import current_principal
|
|
from app.chat.questions import recent_questions
|
|
|
|
conv = await _conversation()
|
|
rid = await _run("chat", minutes=10, input={"question": f"{MARK} 本月退款", "history": "问:上周\n答:100"})
|
|
await _turn(conv, f"{MARK} 本月退款", rid, 1, 10)
|
|
async with SessionLocal() as session:
|
|
got = [q for q in await recent_questions(session, current_principal(), limit=500) if MARK in q.text]
|
|
assert [q.text for q in got] == [f"{MARK} 本月退款"]
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 工具白名单衰减
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
async def test_tool_usage_ignores_eval_runs(client):
|
|
graph = {"nodes": [{"id": "a", "type": "agent", "position": {"x": 0, "y": 0},
|
|
"data": {"label": "a", "config": {"tools": ["calc", "kb_search"]}}}], "edges": []}
|
|
async with SessionLocal() as session:
|
|
wf = Workflow(name=f"{MARK}-白名单", graph=graph)
|
|
session.add(wf)
|
|
await session.commit()
|
|
wf_id = wf.id
|
|
try:
|
|
used = await _run("canvas", workflow_id=wf_id)
|
|
graded = await _run("eval", workflow_id=wf_id)
|
|
async with SessionLocal() as session:
|
|
session.add(RunEvent(run_id=used, seq=1, type="tool.start", node_id="a", data={"tool": "calc"}, ts=0.0))
|
|
session.add(RunEvent(run_id=graded, seq=1, type="tool.start", node_id="a", data={"tool": "kb_search"}, ts=0.0))
|
|
await session.commit()
|
|
body = (await client.get("/api/governance/tool-usage", params={"workflow_id": wf_id})).json()
|
|
assert body["runs_considered"] == 1
|
|
node = body["nodes"][0]
|
|
assert node["used"] == {"calc": 1} and node["unused"] == ["kb_search"], "评测用过的工具不能让白名单显得都在用"
|
|
finally:
|
|
async with SessionLocal() as session:
|
|
await session.execute(delete(Workflow).where(Workflow.id == wf_id))
|
|
await session.commit()
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 目录的使用次数、被哪些运行用过、审阅队列的权重
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
async def test_catalog_usage_ignores_eval_runs():
|
|
name = f"eo_{uuid.uuid4().hex[:8]}"
|
|
tables = {"orders": {"qualified": "orders", "primary_key": ["id"], "comment": None, "is_view": False,
|
|
"foreign_keys": [], "unique": [],
|
|
"columns": [{"name": "id", "type": "INTEGER", "nullable": True, "comment": None}]}}
|
|
async with SessionLocal() as session:
|
|
row = DataSource(name=name, kind="sqlite", database=":memory:", schema_cache={"tables": tables})
|
|
session.add(row)
|
|
await session.commit()
|
|
sid = row.id
|
|
try:
|
|
used = await _run("chat")
|
|
graded = await _run("eval")
|
|
legacy = await _run(None)
|
|
async with SessionLocal() as session:
|
|
for rid in (used, graded, legacy):
|
|
session.add(Artifact(id=uuid.uuid4().hex, run_id=rid, kind=catalog.QUERY_SNAPSHOT_KIND,
|
|
meta={"source_id": sid, "source": name, "tables": ["orders"]}))
|
|
await session.commit()
|
|
source = await session.get(DataSource, sid)
|
|
assert await catalog.table_usage(session, source) == {"orders": 2}
|
|
counts = await catalog_review.table_usage_counts(session, source)
|
|
assert counts.queries == {"orders": 2} and counts.runs == {"orders": 2}
|
|
listed = await catalog_review.table_runs(session, source, "orders")
|
|
assert listed["total"] == 2 and {r["run_id"] for r in listed["runs"]} == {used, legacy}
|
|
finally:
|
|
await drop_source(sid)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 记录页
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
async def test_the_run_list_hides_system_runs_by_default_and_says_how_many(client):
|
|
chat = await _run("chat")
|
|
graded = [await _run("eval", minutes=i) for i in range(2)]
|
|
recon = await _run("reconcile")
|
|
legacy = await _run(None)
|
|
q = {"q": MARK, "limit": 200}
|
|
|
|
listed = {r["id"] for r in (await client.get("/api/runs", params=q)).json()}
|
|
assert {chat, legacy} <= listed and not (set(graded) | {recon}) & listed
|
|
unlisted = (await client.get("/api/runs/unlisted", params={"q": MARK})).json()
|
|
assert unlisted == {"total": 3, "by_origin": {"eval": 2, "reconcile": 1}}
|
|
|
|
# 按来源点名就列,此时没有藏起来的
|
|
only = {r["id"] for r in (await client.get("/api/runs", params={**q, "origin_kind": "eval"})).json()}
|
|
assert only == set(graded)
|
|
assert (await client.get("/api/runs/unlisted", params={"q": MARK, "origin_kind": "eval"})).json()["total"] == 0
|
|
# 同样的筛选一起作用:按状态筛时只数这个状态的
|
|
assert (await client.get("/api/runs/unlisted", params={"q": MARK, "status": "failed"})).json()["total"] == 0
|