mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 13:01:58 +00:00
9d8b41430e
- 完善口径编辑、只读试运行、统一质量验证与执行语义指纹 - 增加可执行指标约束、有预算的主动调查和过程评测 - 提取发布检查、证据读取与起草服务,支持任务停止、重试和检查点恢复 - 增加版本传递影响分析,修复并发覆盖、敏感值遮罩、异常评分及页面状态问题 - 修复 SQLite 复合查询追溯和 microVM 可选依赖缺失处理 - 纳入当前设计文档及部署配置调整 验证:相关主回归 3822 项通过,4 项跳过、8 项预期失败;补充回归通过。 前端构建、文案检查及 git diff --check 通过。 全量检查仍有环境权限、PostgreSQL 可选依赖和既有部署配置、SQLite 文件基线差异导致的未通过项。
171 lines
10 KiB
Python
171 lines
10 KiB
Python
"""分析结论从入口到证据的整条链路,接真算子、真复算(不用替身):分析卡(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"
|