Files
AgentLab/backend/tests/test_segment_missing_input.py
T
ThearchyHelios 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);关键行为做了变异验证、全部抓到。
2026-09-28 21:20:04 +08:00

144 lines
6.5 KiB
Python

"""缺输入的指标也给出链:报告里引用的指标存在、只是这次没有值(missing_input),片段显示「—」。
以前片段接口只给确定性片段出链,这种片段点开只剩一句「缺输入」:说不出是哪个输入空了、
那一格在哪次查询里。现在照样返回指标步骤、它的输入步骤和查询步骤,面板能指着那一格说
「没查到,记为空,没有兜底成 0」。引用本身写错(目录里没有这个指标)的,照旧没有链。
"""
from __future__ import annotations
import asyncio
import json
import sqlite3
import pytest
from httpx import ASGITransport, AsyncClient
from langchain_core.messages import AIMessage
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 SUM(amount) AS gmv, COUNT(*) AS orders, NULL AS refunds FROM orders"
TEXT = "销售额 [[m:gmv]],退款率 [[m:refund_rate]],新客 [[m:nope]]。"
@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(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, amount REAL);"
"INSERT INTO orders VALUES (1, 100.5), (2, 200.0);")
db.commit()
db.close()
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, row.options = str(path), "sqlite", True, True, {}
await session.commit()
source_id = row.id
await engines.invalidate(source_id)
yield
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 weekly() -> dict:
nodes = [
node("start", "input"),
node("fetch", "tool", tool="db_query__shop", args={"sql": SQL}),
# 缺输入记为空值,交给契约判档;不整张卡失败
node("card", "metrics", caliber="周报口径", on_missing="null", metrics=[
{"id": "gmv", "name": "销售额", "unit": "元", "expression": "cell(nodes.fetch, 0, 'gmv')"},
{"id": "refund_rate", "name": "退款率", "format": "percent_of_ratio", "decimals": 4,
"expression": "cell(nodes.fetch, 0, 'refunds') / cell(nodes.fetch, 0, 'orders')"}]),
node("write", "report", instructions="写周报", on_violation="flag"),
node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]),
]
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
async def run_weekly(monkeypatch) -> tuple[Run, dict]:
from app.providers import mock_model
monkeypatch.setattr(mock_model.MockChatModel, "_decide", lambda self, messages: AIMessage(content=TEXT))
run = await run_manager.start(graph=weekly(), 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:
break
assert row.status == "succeeded", row.error
doc_id = row.output["_evidence"]["doc_artifact"]
doc = json.loads((settings.data_dir / "artifacts" / doc_id[:2] / f"{doc_id}.json").read_text("utf-8"))
return row, doc
def seg_by_ref(doc: dict, ref: str) -> dict:
return next(s for s in iter_segments(doc) if s.get("ref") == ref)
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_metric_missing_its_input_still_opens_its_chain(client, monkeypatch):
row, doc = await run_weekly(monkeypatch)
seg = seg_by_ref(doc, "m:refund_rate")
assert seg["text"] == "—" and seg["state"] != "deterministic" and seg["cite"]["status"] == "unresolved"
body = await segment(client, row.id, seg["id"])
metric, refunds, orders, query = body["chain"]
assert metric["step"] == "metric" and metric["alias"] == "m:refund_rate" and metric["metric"] == "refund_rate"
assert metric["status"] == "missing_input" and metric["value"] is None and metric["rendered"] == "—"
assert metric["eid_ok"] is True and metric["hash_ok"] is True and metric["sealed"] is True
assert refunds["step"] == "input" and refunds["via"] == "tool_cell" and refunds["value"] is None
assert refunds["locator"] == {"row": 0, "column": "refunds"} and refunds["cell"] == "Q1.r0.refunds"
assert orders["value"] == 2
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()
assert query["step"] == "query" and query["artifact"] == end.data["query_artifact"]
assert query["highlight"]["cells"] == [[0, "refunds"], [0, "orders"]]
assert query["rows"] == [[300.5, 2, None]]
# 片段本身的说法不变:引用解析不了,缺输入
assert "缺输入" in body["note"] and body["segment"]["cite"]["status"] == "unresolved"
async def test_a_wrong_reference_still_has_no_chain(client, monkeypatch):
row, doc = await run_weekly(monkeypatch)
body = await segment(client, row.id, seg_by_ref(doc, "m:nope")["id"])
assert body["chain"] == [] and "目录里没有指标" in body["note"]
async def test_resolved_metrics_are_unchanged(client, monkeypatch):
row, doc = await run_weekly(monkeypatch)
body = await segment(client, row.id, seg_by_ref(doc, "m:gmv")["id"])
metric, source, query = body["chain"]
assert metric["status"] == "ok" and metric["render_ok"] is True and source["value"] == 300.5
assert query["highlight"]["cells"] == [[0, "gmv"]]