Files
AgentLab/backend/tests/test_evidence_legacy.py
ThearchyHelios d071bf119f 可点击证据第三期:表名字段名和引文也点得开、可疑实体与结论句、受管门禁 G1–G5、证据审计导出
一、表结构、实体与引文

- 数据源工具查库时顺带存一份表结构快照(schema_snapshot),和查询快照一起进台账、进封存;
  台账里的查询记下 SQL 用到的表
- 报告里的表名、字段名可以点:反引号里的名字一律核对,正文里的裸名字只在明显是标识符时才自动链接,
  常见英文单词不链接;点开看到它出现在哪次查询、类型、所属的表,快照被截断时写明
- 找不到的名字标成可疑实体:在快照、SQL、结果列里都没有的记「可能是编造的名字」并给出最接近的 3 个;
  快照被截断、核对不了的记「核对不了」,不冤枉成编造
- 检索引文 [[q:K1|原文]]:原文在检索快照的命中片段里逐字出现(空白归一化后)才算有出处,点开高亮原文位置
- 自动链接的名字不算句子的引用,只有写作者写的引用才算

二、结论句与出具

- 报告节点新增 claims:off(默认,保持原样)或 require_citation(没有引用的结论句计入缺口)
- 受管级别的正式运行里,可疑实体和没有引用的结论句按「有缺口」降档,不拦截;探索运行和已发布级别只标注
- 契约的 claims 和报告节点的设置取更严的那个;受管正式运行里写 ignore 也按降档处理,并给出警告
- 出具横幅除了数字,也写出可疑实体和没有引用的结论句

三、受管门禁 G1–G5(只看图和配置)

- G1 带契约的出口,成果字段的文字只能来自报告撰写节点
- G2 模型写的文字不绕过报告撰写节点直接流进出口
- G3 报告撰写节点写明 numbers: strict、on_violation: fail、claims: require_citation
- G4 口径卡的输入不来自计算角色的沙箱代码
- G5 给口径卡供数的 agent 配 output_schema 并开 cite_fields
- 受管级别是错误、挡住发布;已发布级别只给警告;已经发布的版本照常运行,下次发布时按新规则检查
- 每条规则都接进发布前自动修复(一键修复 / 给选项 / 交给 Copilot);禁止的改动补上关 cite_fields、
  删 output_schema、放宽契约 claims;Copilot 兜底不许自己把代码节点标成 source、不许放宽报告节点
- 发布弹窗里选「交给 Copilot」时明确写「正在交给 Copilot」

四、旧运行与审计

- 没有契约的旧答案:数字和已封存的查询单元格、口径卡值按数值比对,每个数最多 3 个候选,
  默认折叠,写明「猜测的来源,不能当证据」
- 有契约的旧运行:候选只从已封存的口径卡里找,人为插进工件表的行不当证据
- 证据审计:GET /api/runs/{id}/evidence/audit 按「无证据 / 可疑实体 / 有出处 / 旧运行猜测」分组,
  可导出 JSON 和 CSV(带封存状态);记录页和画布上的报告节点显示「有出处 N · 无证据 M」

五、SQL 守卫修正(安全)

- 去注释改成单遍扫描,认得字符串和各方言的引号(单引号转义、MySQL 反斜杠与 #、PostgreSQL 的 $$ 与嵌套注释、
  Oracle 的 q'[…]'、SQLite 的方括号);以前字符串里的 -- 会被当成注释,SELECT '--'; DROP … 这类写法能骗过多语句拦截
- 看不到服务器设置时每种读法都扫一遍,读法之间切分不一致就拒绝,任何一种读法是写操作就按写操作处理
- 执行查询和工具危险判定都按数据源的方言检查

六、其他

- 修掉检查脚本里画布检查的一处竞态,并行估时按实测更新
2026-09-29 05:26:34 +08:00

299 lines
15 KiB
Python

"""旧运行的降级展示:候选只从已封存的工件里找。
- legacy_text:没有契约、没有报告的答案,把数字按数值和封存范围内的查询快照单元格、口径卡指标
比对,每个数字最多 3 个候选,state 记 candidate,写明「猜测的来源,不能当证据」。按
(run_id, manifest_hash) 放进内存 LRU(再带上数据源遮罩的指纹:事后加了遮罩要重算),猜测放到
线程里跑,不堵事件循环
- 二期以前的运行没有 tool.end.query_artifact:查询快照的 id 只嵌在 tool_snapshot 的内容里,
顺着封存事件里的 tool_snapshot(取回时复验哈希)找
- legacy_contract:旧契约 matched 的指标详情同样只从封存的口径卡里取
- 人为插进 artifacts 表的行、封存之后追加的事件指向的工件,一律不当证据
"""
from __future__ import annotations
import asyncio
import sqlite3
import threading
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.api import evidence as evidence_api
from app.core import artifact_store
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import Artifact, DataSource, Run, RunEvent
from app.engine import runner as runner_mod
from app.engine.evidence import GUESS_NOTE, GUESS_SCHEMA
from app.engine.runner import run_manager
from app.main import app
SOURCE = "warehouse"
TOOL = f"db_query__{SOURCE}"
SQL = "SELECT region, SUM(amount) AS gmv, COUNT(*) AS order_cnt FROM orders GROUP BY region ORDER BY gmv DESC"
ANSWER = "东区销售额 400.5,西区 200,一共 7 单。"
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest.fixture
async def warehouse(tmp_path):
path = tmp_path / "warehouse.db"
db = sqlite3.connect(path)
db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, region TEXT, amount REAL);")
db.executemany("INSERT INTO orders VALUES (?, ?, ?)", [(1, "east", 100.5), (2, "west", 200.0), (3, "east", 300.0)])
db.commit()
db.close()
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == SOURCE))).scalar_one_or_none()
if row is None:
row = DataSource(name=SOURCE, kind="sqlite", database=str(path), readonly=True, enabled=True)
session.add(row)
row.database, row.kind, row.enabled, row.readonly, row.schema_cache = str(path), "sqlite", True, True, {}
row.options = {}
await session.commit()
source_id = row.id
await engines.invalidate(source_id)
yield source_id
await engines.invalidate(source_id)
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def chain(*nodes):
return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
def asked(answer=ANSWER):
return chain(node("start", "input"), node("fetch", "tool", tool=TOOL, args={"sql": SQL}),
node("out", "output", fields=[{"name": "answer", "value": answer}]))
async def finish(graph) -> Run:
run = await run_manager.start(graph=graph, input_payload={})
for _ in range(400):
await asyncio.sleep(0.05)
async with SessionLocal() as session:
row = await session.get(Run, run.id)
if row.status in ("succeeded", "failed") and row.manifest_seq is not None:
return row
raise AssertionError(f"run {run.id} 没跑完:{row.status}")
async def events(run_id: str, etype: str, node_id: str | None = None) -> list[RunEvent]:
async with SessionLocal() as session:
q = select(RunEvent).where(RunEvent.run_id == run_id, RunEvent.type == etype)
if node_id:
q = q.where(RunEvent.node_id == node_id)
return list((await session.execute(q.order_by(RunEvent.seq))).scalars())
async def reseal(run_id: str) -> None:
async with SessionLocal() as session:
run = await session.get(Run, run_id)
rows = [tuple(r) for r in await session.execute(
select(RunEvent.seq, RunEvent.type, RunEvent.node_id, RunEvent.data)
.where(RunEvent.run_id == run_id, RunEvent.seq <= run.manifest_seq).order_by(RunEvent.seq))]
run.manifest_hash = artifact_store.manifest_hash(rows)
await session.commit()
def numbers(field: dict) -> dict[str, dict]:
return {s["text"]: s for s in field["segments"] if s["kind"] == "number"}
def counting(monkeypatch) -> list[int]:
"""数一数真正猜了几次,并记下在哪个线程里猜的。"""
calls: list[int] = []
real = evidence_api.guess_sources
def spy(*args, **kwargs):
calls.append(threading.get_ident())
return real(*args, **kwargs)
monkeypatch.setattr(evidence_api, "guess_sources", spy)
return calls
# --------------------------------------------------------------------------
# legacy_text
# --------------------------------------------------------------------------
async def test_an_answer_without_a_contract_gets_guessed_candidates(client, warehouse):
row = await finish(asked())
assert row.status == "succeeded", row.error
[end] = await events(row.id, "tool.end", "fetch")
body = (await client.get(f"/api/runs/{row.id}/evidence")).json()
assert body["mode"] == "legacy_text" and body["reports"] == [] and body["evidence"] == []
legacy = body["legacy"]
assert legacy["schema"] == GUESS_SCHEMA and legacy["mode"] == "legacy_text" and legacy["note"] == GUESS_NOTE
assert legacy["note"].startswith("猜测的来源,不能当证据") and legacy["sealed"] is True
[field] = legacy["fields"]
assert field["field"] == "answer" and field["markdown"] == ANSWER
found = numbers(field)
east = found["400.5"]
assert east["state"] == "candidate" and len(east["candidates"]) <= 3
top = east["candidates"][0]
assert top["kind"] == "cell" and top["ref"] == "Q1.r0.gmv" and top["artifact"] == end.data["query_artifact"]
assert top["node_id"] == "fetch" and top["tool"] == TOOL
assert found["200"]["candidates"][0]["ref"] == "Q1.r1.gmv"
assert found["7"]["state"] == "none" and "candidates" not in found["7"]
assert legacy["stats"] == {"numbers": 3, "guessed": 2, "unguessed": 1,
"candidates": sum(len(s.get("candidates") or []) for s in found.values())}
audit = (await client.get(f"/api/runs/{row.id}/evidence/audit")).json()
assert audit["mode"] == "legacy_text"
guessed = next(g for g in audit["groups"] if g["key"] == "candidate")["rows"]
assert [(r["text"], r["field"], r["state"]) for r in guessed] == [("400.5", "answer", "candidate"),
("200", "answer", "candidate")]
assert guessed[0]["evidence"].startswith("Q1.r0.gmv") and "猜测" in guessed[0]["note"]
assert len(guessed[0]["candidates"]) == len(east["candidates"])
unguessed = next(g for g in audit["groups"] if g["key"] == "none")["rows"]
assert [(r["text"], r["kind"]) for r in unguessed] == [("7", "number")]
async def test_artifacts_outside_the_sealed_events_are_not_candidates(client, warehouse):
"""人为往 artifacts 表插一行(内容里正好有 7),再在封存之后追加一条指向它的 tool.end:都不认。"""
row = await finish(asked())
fake = await artifact_store.put_json(
{"columns": ["n"], "rows": [[7]], "row_count": 1, "truncated": False, "sql": "SELECT 7 AS n",
"source": SOURCE}, kind="query_snapshot", run_id=row.id, node_id="fetch")
async with SessionLocal() as session:
assert await session.get(Artifact, (fake, row.id)) is not None
session.add(RunEvent(run_id=row.id, seq=row.manifest_seq + 3, type="tool.end", node_id="fetch", ts=0.0,
data={"tool": TOOL, "query_artifact": fake}))
await session.commit()
body = (await client.get(f"/api/runs/{row.id}/evidence")).json()
assert body["seal"]["ok"] is True
found = numbers(body["legacy"]["fields"][0])
assert found["7"]["state"] == "none"
assert all(c["artifact"] != fake for s in found.values() for c in s.get("candidates") or [])
async def test_guesses_are_cached_per_seal_and_run_off_the_event_loop(client, warehouse, monkeypatch):
row = await finish(asked())
calls = counting(monkeypatch)
first = (await client.get(f"/api/runs/{row.id}/evidence")).json()
again = (await client.get(f"/api/runs/{row.id}/evidence")).json()
await client.get(f"/api/runs/{row.id}/evidence/audit")
assert len(calls) == 1, "同一次封存只猜一次,证据图和审计表共用"
assert again["legacy"] == first["legacy"]
assert calls[0] != threading.get_ident(), "猜测要放到线程里跑"
async def test_masked_columns_are_never_candidates_even_after_caching(client, warehouse, monkeypatch):
row = await finish(asked())
calls = counting(monkeypatch)
before = numbers((await client.get(f"/api/runs/{row.id}/evidence")).json()["legacy"]["fields"][0])
assert before["400.5"]["state"] == "candidate"
async with SessionLocal() as session:
src = await session.get(DataSource, warehouse)
src.options = {"mask_columns": ["gmv"]}
await session.commit()
after = numbers((await client.get(f"/api/runs/{row.id}/evidence")).json()["legacy"]["fields"][0])
assert len(calls) == 2, "遮罩变了,缓存的猜测作废"
assert after["400.5"]["state"] == "none" and after["200"]["state"] == "none"
assert all(c["locator"]["column"] != "gmv" for s in after.values() for c in s.get("candidates") or [])
async def test_runs_from_before_phase_two_are_followed_through_the_tool_snapshot(client, warehouse, monkeypatch):
async def no_snapshot(session):
return None
monkeypatch.setattr(runner_mod, "_agent_limits", no_snapshot)
row = await finish(asked())
assert row.status == "succeeded", row.error
[end] = await events(row.id, "tool.end", "fetch")
assert "query_artifact" not in end.data and end.data["artifact"]
body = (await client.get(f"/api/runs/{row.id}/evidence")).json()
assert body["mode"] == "legacy_text"
top = numbers(body["legacy"]["fields"][0])["400.5"]["candidates"][0]
assert top["ref"] == "Q1.r0.gmv" and top["kind"] == "cell"
snapshot = artifact_store.load(end.data["artifact"])
assert top["artifact"] in snapshot["result"]
async def test_answers_without_numbers_or_sources_stay_none(client, warehouse):
body = (await client.get(f"/api/runs/{(await finish(asked('东区最多'))).id}/evidence")).json()
assert body["mode"] == "none" and "legacy" not in body
plain = await finish(chain(node("start", "input"), node("out", "output", fields=[{"name": "r", "value": "共 7 单"}])))
body = (await client.get(f"/api/runs/{plain.id}/evidence")).json()
assert body["mode"] == "none"
# --------------------------------------------------------------------------
# legacy_contract:指标详情只从封存的口径卡里取
# --------------------------------------------------------------------------
KPI = "{'gmv': 45678.5, 'orders': 1234}"
STANDARD = [{"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "expression": "vars.kpi.gmv"},
{"id": "orders", "name": "订单数", "unit": "单", "expression": "vars.kpi.orders"}]
STORY = "本周销售额 45678.5 元,订单 1234 单,另有 999 笔待核。"
def narrated():
return chain(node("start", "input"), node("fetch", "transform", mode="expression", expression=KPI, assign_to="kpi"),
node("caliber", "metrics", caliber="周报口径", caliber_version="v2", metrics=STANDARD),
node("narr", "transform", mode="template", template=STORY, assign_to="story"),
node("out", "output", fields=[{"name": "r", "value": "{{ vars.story }}"}],
contract={"metrics_from": ["caliber"], "narrative": "{{ vars.story }}"}))
async def test_legacy_matches_name_their_sealed_metric(client):
row = await finish(narrated())
assert row.status == "succeeded", row.error
[card] = [e for e in await events(row.id, "node.finished", "caliber")]
sealed_card = next(e["artifact"] for e in card.data["evidence"] if e["kind"] == "metric_set")
# 人为插一张同名指标的「口径卡」:值不一样,不能被拿来当出处
await artifact_store.put_json({"kind": "metric_set", "caliber": "别的口径", "caliber_version": "v9",
"metrics": [{"id": "gmv", "name": "销售额", "value": 1.0}]},
kind="metric_set", run_id=row.id, node_id="caliber")
body = (await client.get(f"/api/runs/{row.id}/evidence")).json()
assert body["mode"] == "legacy_contract"
gmv = next(m for m in body["legacy"]["matched"] if m["metric"] == "gmv")
[source] = gmv["sources"]
assert source["kind"] == "metric" and source["ref"] == "m:gmv" and source["artifact"] == sealed_card
assert source["value"] == 45678.5 and source["rendered"] == "45,678.5元" and source["name"] == "销售额"
assert (source["caliber"], source["version"], source["node_id"]) == ("周报口径", "v2", "caliber")
assert source["sealed"] is True
audit = (await client.get(f"/api/runs/{row.id}/evidence/audit")).json()
assert audit["mode"] == "legacy_contract"
guessed = next(g for g in audit["groups"] if g["key"] == "candidate")["rows"]
assert [r["text"] for r in guessed] == ["45678.5", "1234"]
assert guessed[0]["evidence"].startswith("m:gmv") and "按数值匹配" in guessed[0]["note"]
bare = next(g for g in audit["groups"] if g["key"] == "none")["rows"]
assert [r["text"] for r in bare] == ["999"]
# 句子一栏取成果字段里这个数前后的原文(对上的数 trace_numbers 不给 context,以前整列是空的)
for r in guessed + bare:
assert r["field"] == "r" and r["text"] in r["sentence"] and r["sentence"] in STORY, r
assert "销售额 45678.5 元" in guessed[0]["sentence"]
async def test_legacy_matches_without_a_sealed_card_have_no_source(client):
row = await finish(narrated())
async with SessionLocal() as session:
finished = (await session.execute(select(RunEvent).where(
RunEvent.run_id == row.id, RunEvent.type == "node.finished", RunEvent.node_id == "caliber"))).scalar_one()
finished.data = {k: v for k, v in finished.data.items() if k not in ("evidence", "artifact")}
await session.commit()
await reseal(row.id)
body = (await client.get(f"/api/runs/{row.id}/evidence")).json()
assert body["seal"]["ok"] is True
assert all(m["sources"] == [] for m in body["legacy"]["matched"])