mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 10:51:58 +00:00
fcdcc30d4e
一、可点击证据第二期
取数链路
- 智能体、工具节点、代码节点、知识检索取数时都记进证据台账;报告目录按台账编出 Q1… / K1…
- 报告可以引用快照里的一格([[v:Q1.r0.gmv]]),也可以由系统从快照生成整张表([[table:Q1 cols=… rows=0-4]]),
表里每一格都是可点的片段;取值一律以快照为准,取回快照时复验哈希
- 口径卡表达式新增 cell(x, row, column),lineage 精确到单元格
- 智能体开 cite_fields(配合 output_schema)后,循环结束做一次结构化抽取,每个字段标出取自哪次查询的哪一格,
再到快照里核对:一致记 verified;不一致以快照为准并告警;查不到的记为空值,永远不兜底成 0
- 代码节点新增 evidence_role(source / compute)和 code_sha;沙箱算出来的数不能被报告直接引用,要进口径卡
- 查询快照记下列类型,文本列按文本渲染(年份 "2026" 不再显示成「2,026」)
- 升级前发起的运行(停在审批上的)一律走旧逻辑:新的 task 只追加在循环之后,恢复时不会错号
证据接口与面板
- 片段链接上查询步骤:SQL、被引用的行加前后各 2 行、高亮到格;数据源可设 mask_columns 遮罩列,
遮罩在查询时记进快照,数据源事后改名或删除也照样遮罩(在有身份体系之前,遮罩只减少暴露,不是安全边界)
- 输入来源写明核对结果(与快照一致 / 模型报的值和快照不一致 / 没查到记为空);口径卡缺输入时也给出完整的链
- 受管出具要在契约里声明 cells 才能引用单元格;受管与否记在所钉版本上,改画布不再让受管版本的正式运行降级
- 口径卡可以用 caliber_from 钉住另一个已发布工作流里的口径卡,上游有新版本时按 upgrade_policy 处置,
和子工作流同一套规则;子工作流节点也补上了升版处置的配置项
Copilot、校验与报错
- 问数据的答案里有数字时,搭 input → 智能体 → 报告 → 出口,报告直接引用查过的单元格
- 写 SQL 前核对数据源的表结构,SQL 里出现表结构里没有的列会被打回
- 不再搭「智能体文字 → 转换节点解析 JSON」这种图;转换节点的 JSON 报错分清是上游模型写坏了、
模板里裸插了模型文字(给出 | json 的改法),还是模板本身写错了
- 工具节点遇到「查询失败」「SQL 被拒绝」判为失败;发起运行前检查绑定的工具是否存在,不存在时直接拒绝并给出入口
二、发布前自动修复
- 问题带上机器编号(code)和修复 id;新增只读接口 POST /api/workflows/{id}/publish-check 和 /autofix
- 修复分三类:答案唯一、不降低要求的一键修复;要人拿主意的给选项(不替人选);结构性问题交给 Copilot
- 一律先预览,不自动保存、不自动发布;删节点、删契约、放宽审批、关掉 strict、改级别等降低要求的改动一律拒绝,
Copilot 的方案也要过这一关;应用后重跑检查,错误必须变少且不能冒出新的
- 发布弹窗打开就先检查;问题面板新增「发布前检查」,平时就能看到、就地修掉
- 门禁修正:契约有 report_from 时不再要求 narrative(模板⑨可以发成受管);全图默认审批为「全部放行」时同样拦下
- 修掉画布保存会抹掉全图默认(defaults)的老问题:以前任何一次保存都会把它清空,审批 always 因此被悄悄放宽
验证(沙箱):tsc 干净,生产构建通过,pytest 2268 passed(两遍),check-all 17 项检查、3915 项全过
(新增 check-publish);关键行为做了变异验证、全部抓到。
443 lines
21 KiB
Python
443 lines
21 KiB
Python
"""证据接口二期:片段链接上查询步骤,点开一个数一路追到快照里的那一格。
|
||
|
||
- 指标步骤、输入步骤之后接查询步骤:{step:"query", alias, tool, sql, columns, rows, row_offset, total_rows,
|
||
truncated, highlight:{rows, cols}, artifact, hash_ok}
|
||
- [[v:]] 片段(含整表里的格)直接给出查询步骤
|
||
- rows 只返回被引用的行加前后各 2 行;完整快照仍走 /api/artifacts/{id}
|
||
- 数据源 options.mask_columns 里的列一律换成「已遮罩」,并列进 redacted.columns
|
||
- 只认封存范围内的事件(tool.end.query_artifact、node.finished.evidence 的 query 条目)追得到的快照
|
||
- 证据图的 evidence 列表里有查询、检索条目
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import sqlite3
|
||
|
||
import pytest
|
||
from httpx import ASGITransport, AsyncClient
|
||
from langchain_core.messages import AIMessage, ToolMessage
|
||
from sqlalchemy import select
|
||
|
||
from app.core.config import settings
|
||
from app.data.engine import engines
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import DataSource, Run, RunEvent
|
||
from app.engine.evidence import iter_segments
|
||
from app.engine.runner import run_manager
|
||
from app.main import app
|
||
|
||
SQL = "SELECT id, week, amount, email FROM orders ORDER BY id"
|
||
ROWS = [(i, "2026-W37" if i > 3 else "2026-W36", float(i * 100) + 0.5, f"u{i}@example.com") for i in range(1, 11)]
|
||
TEXT = ("这一单金额 [[v:Q1.r5.amount]],口径卡记为 [[m:gmv]]。[[see:K1]]\n\n"
|
||
"[[table:Q1 cols=id,amount rows=4-6]]")
|
||
|
||
|
||
@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
|
||
|
||
|
||
async def set_source(path, options=None):
|
||
async with SessionLocal() as session:
|
||
row = (await session.execute(select(DataSource).where(DataSource.name == "shop"))).scalar_one_or_none()
|
||
if row is None:
|
||
row = DataSource(name="shop", kind="sqlite", database=str(path), readonly=True, enabled=True)
|
||
session.add(row)
|
||
row.database, row.kind, row.enabled, row.readonly = str(path), "sqlite", True, True
|
||
row.options = dict(options or {})
|
||
await session.commit()
|
||
return row.id
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
async def shop(tmp_path):
|
||
path = tmp_path / "shop.db"
|
||
db = sqlite3.connect(path)
|
||
db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, week TEXT, amount REAL, email TEXT);")
|
||
db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?)", ROWS)
|
||
db.commit()
|
||
db.close()
|
||
source_id = await set_source(path)
|
||
await engines.invalidate(source_id)
|
||
yield path
|
||
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 weekly():
|
||
return chain(
|
||
node("start", "input"),
|
||
node("fetch", "tool", tool="db_query__shop", args={"sql": SQL}),
|
||
node("docs", "retrieve", query="退款口径", collection="docs"),
|
||
node("card", "metrics", caliber="周报口径", caliber_version="v2", metrics=[
|
||
{"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "format": "thousands",
|
||
"expression": "cell(nodes.fetch, 5, 'amount')"}]),
|
||
node("write", "report", instructions="写周报", on_violation="flag"),
|
||
node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]),
|
||
)
|
||
|
||
|
||
def script(monkeypatch, text=TEXT):
|
||
from app.providers import mock_model
|
||
|
||
monkeypatch.setattr(mock_model.MockChatModel, "_decide", lambda self, messages: AIMessage(content=text))
|
||
|
||
|
||
async def wait(run_id: str) -> Run:
|
||
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}")
|
||
|
||
|
||
def load_doc(doc_id: str) -> dict:
|
||
return json.loads((settings.data_dir / "artifacts" / doc_id[:2] / f"{doc_id}.json").read_text("utf-8"))
|
||
|
||
|
||
async def run_weekly(monkeypatch, graph=None, text=TEXT) -> tuple[Run, dict]:
|
||
from app.memory import kb
|
||
|
||
async def fake_search(session, **kw):
|
||
return [{"title": "口径说明", "ordinal": 1, "content": "退款按发生周统计", "score": 0.9, "document_id": "d1"}]
|
||
|
||
monkeypatch.setattr(kb, "search", fake_search)
|
||
script(monkeypatch, text)
|
||
row = await wait((await run_manager.start(graph=graph or weekly(), input_payload={})).id)
|
||
assert row.status == "succeeded", row.error
|
||
return row, load_doc(row.output["_evidence"]["doc_artifact"])
|
||
|
||
|
||
def seg_of(doc: dict, text: str, n: int = 0) -> dict:
|
||
return [s for s in iter_segments(doc) if s["text"] == text and s.get("cite")][n]
|
||
|
||
|
||
async def query_artifact(run_id: str, node_id: str = "fetch") -> str:
|
||
async with SessionLocal() as session:
|
||
[end] = (await session.execute(select(RunEvent).where(
|
||
RunEvent.run_id == run_id, RunEvent.type == "tool.end", RunEvent.node_id == node_id))).scalars()
|
||
return end.data["query_artifact"]
|
||
|
||
|
||
async def segment(client, run_id: str, seg_id: str) -> dict:
|
||
resp = await client.get(f"/api/runs/{run_id}/evidence/segments/{seg_id}")
|
||
assert resp.status_code == 200, resp.text
|
||
return resp.json()
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 查询步骤的形状、窗口、高亮
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def test_a_cell_segment_opens_the_query_step(client, monkeypatch):
|
||
row, doc = await run_weekly(monkeypatch)
|
||
artifact = await query_artifact(row.id)
|
||
body = await segment(client, row.id, seg_of(doc, "600.5")["id"])
|
||
|
||
[step] = body["chain"]
|
||
assert step["step"] == "query" and step["alias"] == "Q1" and step["tool"] == "db_query__shop"
|
||
assert step["sql"] == SQL and step["columns"] == ["id", "week", "amount", "email"]
|
||
assert step["artifact"] == artifact and step["hash_ok"] is True and step["sealed"] is True
|
||
assert step["node_id"] == "fetch" and step["source"] == "shop"
|
||
assert step["total_rows"] == 10 and step["truncated"] is False
|
||
assert step["column_types"] == {"id": "number", "week": "text", "amount": "number", "email": "text"}
|
||
# 第 5 行(从 0 数)加前后各 2 行
|
||
assert step["row_offset"] == 3 and step["row_index"] == [3, 4, 5, 6, 7]
|
||
assert [r[0] for r in step["rows"]] == [4, 5, 6, 7, 8]
|
||
assert step["highlight"] == {"rows": [5], "cols": ["amount"], "cells": [[5, "amount"]]}
|
||
assert body["seal"]["covered"] is True
|
||
assert body["redacted"] == {"columns": []}
|
||
assert "查询快照" in body["note"]
|
||
|
||
|
||
async def test_the_window_is_clipped_at_both_ends(client, monkeypatch):
|
||
row, doc = await run_weekly(monkeypatch, text="首行 [[v:Q1.r0.amount]],末行 [[v:Q1.r9.amount]]。")
|
||
first = (await segment(client, row.id, seg_of(doc, "100.5")["id"]))["chain"][0]
|
||
assert first["row_offset"] == 0 and first["row_index"] == [0, 1, 2]
|
||
last = (await segment(client, row.id, seg_of(doc, "1,000.5")["id"]))["chain"][0]
|
||
assert last["row_index"] == [7, 8, 9] and last["highlight"]["rows"] == [9]
|
||
|
||
|
||
async def test_table_cells_open_the_same_query_step(client, monkeypatch):
|
||
row, doc = await run_weekly(monkeypatch)
|
||
cell = seg_of(doc, "600.5", 1) # 整表里第 5 行 amount 那一格
|
||
assert cell["cite"]["kind"] == "cell"
|
||
[step] = (await segment(client, row.id, cell["id"]))["chain"]
|
||
assert step["alias"] == "Q1" and step["highlight"]["cells"] == [[5, "amount"]]
|
||
|
||
|
||
async def test_a_metric_chain_ends_in_the_query_step(client, monkeypatch):
|
||
row, doc = await run_weekly(monkeypatch)
|
||
artifact = await query_artifact(row.id)
|
||
metric, source, query = (await segment(client, row.id, seg_of(doc, "600.5元")["id"]))["chain"]
|
||
assert metric["step"] == "metric" and metric["alias"] == "m:gmv"
|
||
assert source["step"] == "input" and source["via"] == "tool_cell" and source["status"] == "verified"
|
||
assert source["artifact"] == artifact and source["locator"] == {"row": 5, "column": "amount"}
|
||
assert source["query"] == "Q1" and source["cell"] == "Q1.r5.amount"
|
||
assert query["step"] == "query" and query["artifact"] == artifact
|
||
assert query["highlight"]["cells"] == [[5, "amount"]] and query["row_index"] == [3, 4, 5, 6, 7]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 遮罩
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def test_masked_columns_are_redacted(client, monkeypatch, shop):
|
||
await set_source(shop, {"mask_columns": ["EMAIL"]})
|
||
row, doc = await run_weekly(monkeypatch)
|
||
body = await segment(client, row.id, seg_of(doc, "600.5")["id"])
|
||
[step] = body["chain"]
|
||
email = step["columns"].index("email")
|
||
assert {r[email] for r in step["rows"]} == {"已遮罩"}
|
||
assert step["rows"][2][2] == 600.5 and step["masked"] == ["email"]
|
||
assert body["redacted"]["columns"] == ["email"]
|
||
assert "不是安全边界" in body["redacted"]["note"]
|
||
|
||
|
||
async def test_mask_columns_written_as_text_are_understood(client, monkeypatch, shop):
|
||
"""数据源表单的「高级连接参数」存的是文本:「email, week」和列表是一个意思。"""
|
||
await set_source(shop, {"mask_columns": "email,week"})
|
||
row, doc = await run_weekly(monkeypatch)
|
||
body = await segment(client, row.id, seg_of(doc, "600.5")["id"])
|
||
assert body["redacted"]["columns"] == ["week", "email"]
|
||
|
||
|
||
async def rename_source(old: str, new: str) -> None:
|
||
async with SessionLocal() as session:
|
||
row = (await session.execute(select(DataSource).where(DataSource.name == old))).scalar_one()
|
||
row.name = new
|
||
await session.commit()
|
||
|
||
|
||
async def test_masks_recorded_at_query_time_survive_a_renamed_source(client, monkeypatch, shop):
|
||
"""数据源事后改名、删掉:面板按查询当时记下的遮罩处理,不能因为找不到数据源就把原值全亮出来。"""
|
||
await set_source(shop, {"mask_columns": ["email"]})
|
||
row, doc = await run_weekly(monkeypatch)
|
||
artifact = await query_artifact(row.id)
|
||
assert load_doc(artifact)["mask_columns"] == ["email"]
|
||
await rename_source("shop", "shop_renamed")
|
||
try:
|
||
body = await segment(client, row.id, seg_of(doc, "600.5")["id"])
|
||
finally:
|
||
await rename_source("shop_renamed", "shop")
|
||
[step] = body["chain"]
|
||
email = step["columns"].index("email")
|
||
assert {r[email] for r in step["rows"]} == {"已遮罩"} and step["masked"] == ["email"], step
|
||
assert body["redacted"]["columns"] == ["email"]
|
||
assert "「shop」" in step["mask_note"] and "查询当时" in step["mask_note"], step
|
||
|
||
|
||
async def test_masks_added_after_the_query_still_apply(client, monkeypatch, shop):
|
||
row, doc = await run_weekly(monkeypatch)
|
||
assert "mask_columns" not in load_doc(await query_artifact(row.id)) # 没设遮罩的快照和原来一字不差
|
||
await set_source(shop, {"mask_columns": ["week"]})
|
||
[step] = (await segment(client, row.id, seg_of(doc, "600.5")["id"]))["chain"]
|
||
assert step["masked"] == ["week"] and "mask_note" not in step
|
||
|
||
|
||
async def test_recorded_and_current_masks_are_combined(client, monkeypatch, shop):
|
||
"""查询时遮 email、事后改成遮 week:两列都遮。事后把某列从遮罩里拿掉,旧证据也不因此亮出来。"""
|
||
await set_source(shop, {"mask_columns": ["email"]})
|
||
row, doc = await run_weekly(monkeypatch)
|
||
await set_source(shop, {"mask_columns": ["week"]})
|
||
[step] = (await segment(client, row.id, seg_of(doc, "600.5")["id"]))["chain"]
|
||
assert step["masked"] == ["week", "email"], step
|
||
|
||
|
||
async def test_a_vanished_source_without_masks_is_named_but_rows_stay(client, monkeypatch):
|
||
row, doc = await run_weekly(monkeypatch)
|
||
await rename_source("shop", "shop_gone")
|
||
try:
|
||
[step] = (await segment(client, row.id, seg_of(doc, "600.5")["id"]))["chain"]
|
||
finally:
|
||
await rename_source("shop_gone", "shop")
|
||
assert step["masked"] == [] and step["rows"][2][2] == 600.5 and "「shop」" in step["mask_note"], step
|
||
|
||
|
||
async def test_the_model_does_not_see_the_mask_record(monkeypatch, shop):
|
||
"""遮罩只管证据面板:模型拿到的查询结果和原来一样,不多一个会让它以为数据被遮了的字段。"""
|
||
await set_source(shop, {"mask_columns": ["email"]})
|
||
row, _ = await run_weekly(monkeypatch)
|
||
async with SessionLocal() as session:
|
||
[end] = (await session.execute(select(RunEvent).where(
|
||
RunEvent.run_id == row.id, RunEvent.type == "tool.end", RunEvent.node_id == "fetch"))).scalars()
|
||
returned = load_doc(end.data["artifact"])
|
||
assert "mask_columns" not in json.dumps(returned, ensure_ascii=False), returned
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 只认封存范围内追得到的快照
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def strip_query(run_id: str, artifact: str) -> None:
|
||
"""从封存范围内的事件里抹掉这份快照的痕迹,再按改过的事件重算封存哈希:
|
||
模拟「封存链完好,但事件里没有这件工件」——目录里写着它,也不能认。"""
|
||
from app.core.artifact_store import manifest_hash
|
||
|
||
async with SessionLocal() as session:
|
||
run = await session.get(Run, run_id)
|
||
for event in (await session.execute(select(RunEvent).where(RunEvent.run_id == run_id))).scalars():
|
||
data = dict(event.data or {})
|
||
if event.type == "tool.end" and data.get("query_artifact") == artifact:
|
||
data.pop("query_artifact")
|
||
elif event.type == "node.finished" and data.get("evidence"):
|
||
data["evidence"] = [e for e in data["evidence"] if e.get("artifact") != artifact]
|
||
else:
|
||
continue
|
||
event.data = data
|
||
await session.commit()
|
||
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 = manifest_hash(rows)
|
||
await session.commit()
|
||
|
||
|
||
async def test_a_snapshot_outside_the_seal_is_not_served(client, monkeypatch):
|
||
row, doc = await run_weekly(monkeypatch)
|
||
artifact = await query_artifact(row.id)
|
||
await strip_query(row.id, artifact)
|
||
# 封存之后追加一条指向它的事件,也不算
|
||
async with SessionLocal() as session:
|
||
session.add(RunEvent(run_id=row.id, seq=row.manifest_seq + 5, type="tool.end", node_id="fetch", ts=0.0,
|
||
data={"tool": "db_query__shop", "query_artifact": artifact}))
|
||
await session.commit()
|
||
|
||
body = await segment(client, row.id, seg_of(doc, "600.5")["id"])
|
||
assert body["seal"]["ok"] is True
|
||
[step] = body["chain"]
|
||
assert step["artifact"] == artifact and step["sealed"] is False
|
||
assert step["rows"] == [] and step["columns"] == [] and step["sql"] is None
|
||
assert "封存" in step["note"] and body["seal"]["covered"] is False
|
||
|
||
|
||
async def test_a_tampered_snapshot_shows_no_rows(client, monkeypatch):
|
||
row, doc = await run_weekly(monkeypatch)
|
||
artifact = await query_artifact(row.id)
|
||
path = settings.data_dir / "artifacts" / artifact[:2] / f"{artifact}.json"
|
||
original = path.read_text("utf-8")
|
||
snap = json.loads(original)
|
||
snap["rows"][5][2] = 9999.5
|
||
path.write_text(json.dumps(snap, ensure_ascii=False), encoding="utf-8")
|
||
try:
|
||
[step] = (await segment(client, row.id, seg_of(doc, "600.5")["id"]))["chain"]
|
||
finally:
|
||
# 快照按内容寻址、不带运行 id:后面的测试查出同样的结果(同一毫秒跑完)会落到同一个文件上
|
||
path.write_text(original, encoding="utf-8")
|
||
assert step["hash_ok"] is False and step["rows"] == [] and "哈希" in step["note"]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# agent 字段:输入步骤带着核对状态,跳到的是全局编号的查询
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def test_agent_field_inputs_carry_their_check_and_jump_to_the_query(client, monkeypatch):
|
||
from app.providers import mock_model
|
||
|
||
schema = {"type": "object", "properties": {"gmv": {"type": "number"}, "orders": {"type": "integer"}}}
|
||
extracted = {"gmv": {"value": 601.5, "from": {"call": "Q1", "row": 0, "column": "gmv"}},
|
||
"orders": {"value": 30, "from": {"call": "Q1", "row": 0, "column": "orders"}}}
|
||
agg = "SELECT SUM(amount) AS gmv, COUNT(*) AS orders FROM orders WHERE week = '2026-W36'"
|
||
|
||
def decide(model, messages):
|
||
if model.response_format:
|
||
return AIMessage(content=json.dumps(extracted))
|
||
if not model.tools:
|
||
return AIMessage(content=TEXT_ORDERS)
|
||
if not any(isinstance(m, ToolMessage) for m in messages):
|
||
return AIMessage(content="查。", tool_calls=[{"name": "db_query__shop", "args": {"sql": agg}, "id": "c0"}])
|
||
return AIMessage(content="查完了。")
|
||
|
||
TEXT_ORDERS = "订单 [[m:orders]]。"
|
||
monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide)
|
||
graph = chain(
|
||
node("start", "input"),
|
||
node("bot", "agent", prompt="查", tools=["db_query__shop"], approval="never", output_schema=schema,
|
||
cite_fields=True, assign_to="kpi"),
|
||
node("card", "metrics", caliber="周报口径", metrics=[
|
||
{"id": "orders", "name": "订单", "unit": "单", "format": "thousands", "expression": "vars.kpi.orders"}]),
|
||
node("write", "report", instructions="写", on_violation="flag"),
|
||
node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]),
|
||
)
|
||
row = await wait((await run_manager.start(graph=graph, input_payload={})).id)
|
||
assert row.status == "succeeded", row.error
|
||
doc = load_doc(row.output["_evidence"]["doc_artifact"])
|
||
artifact = await query_artifact(row.id, "bot")
|
||
metric, source, query = (await segment(client, row.id, seg_of(doc, "3单")["id"]))["chain"]
|
||
assert source["via"] == "agent_field" and source["status"] == "mismatch" and source["model_value"] == 30
|
||
assert source["ref"] == "Q1.r0.orders" and source["cell"] == "Q1.r0.orders" and source["query"] == "Q1"
|
||
assert query["artifact"] == artifact and query["highlight"]["cells"] == [[0, "orders"]]
|
||
assert query["rows"] == [[601.5, 3]] and query["row_index"] == [0]
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 证据图
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def test_the_graph_lists_queries_and_retrievals(client, monkeypatch):
|
||
row, doc = await run_weekly(monkeypatch)
|
||
body = (await client.get(f"/api/runs/{row.id}/evidence")).json()
|
||
by_alias = {e["alias"]: e for e in body["evidence"]}
|
||
q1, k1 = by_alias["Q1"], by_alias["K1"]
|
||
assert q1["kind"] == "query" and q1["sealed"] is True and q1["node_id"] == "fetch"
|
||
assert q1["tool"] == "db_query__shop" and q1["columns"] == ["id", "week", "amount", "email"]
|
||
assert q1["rows"] == 10 and q1["source"] == "shop"
|
||
assert seg_of(doc, "600.5")["id"] in q1["cited_by"]
|
||
assert k1["kind"] == "retrieval" and k1["sealed"] is True and k1["source"] == "docs" and k1["rows"] == 1
|
||
edges = body["edges"]
|
||
assert {"from": "Q1.r5.amount", "to": "Q1", "rel": "cell_of", "report": "write"} in edges
|
||
[card_input] = [e for e in edges if e["rel"] == "input" and e["from"] == "m:gmv"]
|
||
assert card_input["cell"] == "Q1.r5.amount"
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 数据源的 mask_columns 配置
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
def test_mask_columns_is_not_a_driver_option():
|
||
"""驱动不认识 mask_columns:拼进连接串,连接就会因为未知参数被拒。"""
|
||
from types import SimpleNamespace
|
||
|
||
from app.data.engine import build_url, masked_columns
|
||
|
||
source = SimpleNamespace(kind="postgres", host="db", port=5432, database="shop", username="u", password=None,
|
||
options={"mask_columns": ["email"], "sslmode": "disable"})
|
||
assert "mask_columns" not in build_url(source) and "sslmode=disable" in build_url(source)
|
||
assert masked_columns({"mask_columns": " email ,phone、email\nnote"}) == ["email", "phone", "note"]
|
||
assert masked_columns({"mask_columns": ["email", " ", "phone"]}) == ["email", "phone"]
|
||
assert masked_columns({}) == [] and masked_columns(None) == []
|
||
|
||
|
||
async def test_datasource_form_offers_mask_columns_and_refuses_garbage(client):
|
||
kinds = {k["value"]: k for k in (await client.get("/api/datasources/kinds")).json()["kinds"]}
|
||
for kind in kinds.values():
|
||
field = next(a for a in kind["advanced"] if a["key"] == "mask_columns")
|
||
assert "不是安全边界" in field["help"]
|
||
bad = await client.post("/api/datasources", json={"name": "masked", "kind": "sqlite", "database": "/tmp/x.db",
|
||
"options": {"mask_columns": {"email": True}}})
|
||
assert bad.status_code == 422 and "遮罩" in bad.json()["detail"]
|