Files
AgentLab/backend/tests/test_analysis_e2e_evidence.py
T
ThearchyHelios 9d8b41430e 完善口径管理、主动调查与后台任务,修复通用问题
- 完善口径编辑、只读试运行、统一质量验证与执行语义指纹
- 增加可执行指标约束、有预算的主动调查和过程评测
- 提取发布检查、证据读取与起草服务,支持任务停止、重试和检查点恢复
- 增加版本传递影响分析,修复并发覆盖、敏感值遮罩、异常评分及页面状态问题
- 修复 SQLite 复合查询追溯和 microVM 可选依赖缺失处理
- 纳入当前设计文档及部署配置调整

验证:相关主回归 3822 项通过,4 项跳过、8 项预期失败;补充回归通过。
前端构建、文案检查及 git diff --check 通过。
全量检查仍有环境权限、PostgreSQL 可选依赖和既有部署配置、SQLite 文件基线差异导致的未通过项。
2026-11-06 17:14:45 +08:00

171 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""分析结论从入口到证据的整条链路,接真算子、真复算(不用替身):分析卡(W1-c)、agent 分析工具(W1-c)、口径卡指标上的
两期可比性(W1-c)各发起一次运行,算子是 W1-a 的,证据接入(W1-b)的片段接口、证据图、审计、报告导出、可验证报告都按
封存的输入复算一遍、逐字段相同。
库是虚构的零售库 shop(tests/test_analysis_card:orders(region, week, amount),华东、华南、华北两周)。写作模型换成脚本
(MockChatModel._decide),不调用任何模型。
"""
from __future__ import annotations
import json
import re
from httpx import ASGITransport, AsyncClient
from langchain_core.messages import AIMessage, ToolMessage
from app.analysis import contract
from app.core import artifact_store
from app.engine.evidence import iter_segments
from app.main import app
from tests.test_analysis_card import REGION, WEEKLY, edge, engine_up, node, run_graph, shop # noqa: F401 - 夹具按名字引入
from tests.test_analysis_tools import DECOMPOSE, DECOMPOSE_ARGS, SQL, TOOL
TEXT = "## 异动\n\n[[a:A1]]。华东占 [[a:A1.top_share|pct]],变化 [[v:A1.r0.delta]]。[[see:A1]]"
WHERE = {"id": "where", "op": "decompose", "mode": "additive", "from": "by_region",
"params": {"by": ["region"], "period": "week", "base": "2026-W41", "target": "2026-W42", "measure": "gmv",
"headline": {"base": "weekly.r0.gmv", "target": "weekly.r1.gmv"}, "label": "销售额"},
"hypothesis": "下降是不是集中在某个地区"}
#: 判定语的出处链:判定 → 方法 → 计算 → 输入(两格总数 + 拆解的那份快照,后面接两份快照的查询步骤)→ 复算 → 封存
CHAIN = ["analysis", "analysis_method", "analysis_calc", "analysis_input", "analysis_input", "analysis_input", "query",
"query", "analysis_recompute", "analysis_seal"]
#: 片段:判定语(系统渲染)、具名标量、结果表的一格
SEGMENTS = {"a:A1": ("analysis", "销售额下降 194(-9.2%),主要来自华东(-120,占 61.9%)"),
"a:A1.top_share|pct": ("number", "61.9%"), "v:A1.r0.delta": ("number", "-120")}
def _report(*before, contract_=None):
"""报告撰写 → 成果(带契约,报告来自 write);before 是报告前面的节点,按顺序连成一条。"""
nodes = [node("start", "input"), *before, node("write", "report", instructions="写周报", on_violation="flag"),
node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}],
contract=contract_ or {"report_from": "write"})]
ids = [n["id"] for n in nodes]
return {"nodes": nodes, "edges": [edge(a, b) for a, b in zip(ids, ids[1:])]}
def _segments(row):
doc = artifact_store.load(row.output["_evidence"]["doc_artifact"])
return {s["ref"]: s for s in iter_segments(doc) if s.get("ref")}
def _embedded(html: str, element: str) -> dict:
return json.loads(re.search(rf'<script type="application/json" id="{element}">(.*?)</script>', html, re.S).group(1))
async def test_a_card_conclusion_opens_and_rechecks_everywhere(monkeypatch, shop):
from app.providers import mock_model
monkeypatch.setattr(mock_model.MockChatModel, "_decide", lambda self, messages: AIMessage(content=TEXT))
graph = _report(node("weekly", "tool", tool="db_query__shop", args={"sql": WEEKLY}),
node("by_region", "tool", tool="db_query__shop", args={"sql": REGION}),
node("card", "analysis", items=[WHERE]))
row = await run_graph(graph)
assert row.status == "succeeded", row.error
segs = _segments(row)
assert {ref: (s["kind"], s["text"]) for ref, s in segs.items()} == SEGMENTS
assert not [s for s in segs.values() if s.get("issue")]
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
body = (await client.get(f"/api/runs/{row.id}/evidence/segments/{segs['a:A1']['id']}")).json()
assert [s["step"] for s in body["chain"]] == CHAIN
judged = body["chain"][0]
assert (judged["origin"], judged["item"]) == ("card", "where")
again = next(s for s in body["chain"] if s["step"] == "analysis_recompute")
assert again["status"] == "same" and again["method"] == "decompose.additive@2", again
inputs = [s for s in body["chain"] if s["step"] == "analysis_input"]
assert [s["role"] for s in inputs] == ["headline_base", "headline_target", "data"]
assert all(s["sealed"] for s in inputs)
assert [s["closure"]["closed"] for s in inputs if s.get("closure")] == [True, True]
assert body["seal"]["covered"] is True, body["seal"]
graph_view = (await client.get(f"/api/runs/{row.id}/evidence")).json()
a1 = next(e for e in graph_view["evidence"] if e["alias"] == "A1")
assert {k: a1.get(k) for k in ("kind", "op", "verdict", "item", "sealed")} == {
"kind": "analysis", "op": "decompose", "verdict": "driver", "item": "where", "sealed": True}
# 报告导出:判定语这一处的出处链同样复算一致
data = _embedded((await client.get(f"/api/runs/{row.id}/report.html")).text, "report-data")
chain = data["chains"][data["refs"][segs["a:A1"]["id"]]]
assert next(s for s in chain if s["step"] == "analysis_recompute")["status"] == "same"
# 可验证报告:结论按原字节嵌入、导出时服务端复算一致;分析卡那一项的 analysis.end 带规范字符串
bundle = await client.get(f"/api/runs/{row.id}/bundle.html")
assert bundle.status_code == 200, bundle.text[:300]
b = _embedded(bundle.text, "bundle-data")
items = [a for a in b["artifacts"] if a["kind"] == contract.ARTIFACT_KIND]
assert items and all("b64" in a and a["recheck"]["status"] == "same" for a in items)
ends = [e for e in b["events"] if e["type"] == "analysis.end"]
assert ends and all("canonical" in e for e in ends)
audit = (await client.get(f"/api/runs/{row.id}/evidence/audit")).json()
rows = [r for g in audit["groups"] for r in g["rows"] if r.get("ref") == "a:A1"]
assert [(r["kind"], r["evidence_kind"], r["sealed"]) for r in rows] == [("analysis", "analysis", True)]
async def test_an_agent_conclusion_opens_and_rechecks_everywhere(monkeypatch, shop):
from app.providers import mock_model
calls = [(TOOL, {"sql": SQL}), (DECOMPOSE, DECOMPOSE_ARGS)]
def decide(model, messages):
if not model.tools:
return AIMessage(content=TEXT)
done = sum(isinstance(m, ToolMessage) for m in messages)
if done < len(calls):
name, args = calls[done]
return AIMessage(content="", tool_calls=[{"name": name, "args": args, "id": f"call_{done}"}])
return AIMessage(content="销售额下降,主要来自华东。")
monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide)
row = await run_graph(_report(node("bot", "agent", prompt="销售额为什么下降", tools=[TOOL, DECOMPOSE],
approval="never")))
assert row.status == "succeeded", row.error
segs = _segments(row)
assert {ref: (s["kind"], s["text"]) for ref, s in segs.items()} == SEGMENTS
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
body = (await client.get(f"/api/runs/{row.id}/evidence/segments/{segs['a:A1']['id']}")).json()
judged = body["chain"][0]
assert (judged["origin"], judged["call_id"]) == ("agent", "call_1")
again = next(s for s in body["chain"] if s["step"] == "analysis_recompute")
assert again["status"] == "same", again
b = _embedded((await client.get(f"/api/runs/{row.id}/bundle.html")).text, "bundle-data")
# 分析工具的 tool.end(预览里是结论的前一截)和结论同一个认法:结论随文件附带得了才带内容
ends = [e for e in b["events"] if e["type"] == "tool.end" and e["node_id"] == "bot"]
assert len(ends) == 2 and all("canonical" in e for e in ends)
assert [a["recheck"]["status"] for a in b["artifacts"] if a["kind"] == contract.ARTIFACT_KIND] == ["same"]
async def test_a_metric_comparability_opens_and_rechecks_everywhere(monkeypatch, shop):
from app.providers import mock_model
this = "SELECT SUM(amount) AS gmv FROM orders WHERE week = '2026-W42'"
last = "SELECT SUM(amount) AS gmv FROM orders WHERE week = '2026-W41'"
wow = "(cell(nodes.this_week, 0, 'gmv') - cell(nodes.last_week, 0, 'gmv')) / cell(nodes.last_week, 0, 'gmv')"
monkeypatch.setattr(mock_model.MockChatModel, "_decide", lambda self, messages: AIMessage(content="环比 [[m:wow]]。"))
graph = _report(node("this_week", "tool", tool="db_query__shop", args={"sql": this}),
node("last_week", "tool", tool="db_query__shop", args={"sql": last}),
node("card", "metrics", caliber="周报口径",
metrics=[{"id": "wow", "name": "环比", "expression": wow, "format": "percent_of_ratio",
"decimals": 3}]))
row = await run_graph(graph)
assert row.status == "succeeded", row.error
segs = _segments(row)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
body = (await client.get(f"/api/runs/{row.id}/evidence/segments/{segs['m:wow']['id']}")).json()
view = body["chain"][0]["comparability"]
# 表里只有周次这一列文字、没有业务日期:窗口长度、整期、星期构成核对不了,不判不可比
assert (view["verdict"], view["phrase"], view["reasons"]) == (
"conditional", "两期有条件可比:窗口长度、整期、星期构成核对不了", [])
assert view["sealed"] is True and view["hash_ok"] is True and view["method"] == "comparable@2"
steps = view["steps"]
assert [s["step"] for s in steps] == ["analysis", "analysis_method", "analysis_calc", "analysis_input",
"analysis_input", "analysis_recompute", "analysis_seal"]
assert steps[-2]["status"] == "same"
assert (steps[-1]["via"], steps[-1]["sealed"]) == ("metric_set", True)
b = _embedded((await client.get(f"/api/runs/{row.id}/bundle.html")).text, "bundle-data")
found = [a for a in b["artifacts"] if a["kind"] == contract.ARTIFACT_KIND]
assert len(found) == 1 and "b64" in found[0] and found[0]["recheck"]["status"] == "same"