mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 14:52:02 +00:00
941723b17c
- 身份与工具:登录页建第一个管理员(认人接口的 401 说明能不能建);搭建者有只读的人和组名录,选人给候选并核对名字;授权三行带标题;端点请求头名校验(空名、非法名存不进、发不出时说清原因);写入能力在工具页试运行一律演练、不发送,HTTP 状态照实说成败;写记忆不算只读、查询能力只能选同一端点上的只读能力;审批与遮罩的说法和实际规则一致;开登录时不要署名、导入和发布记登录的人;使用者不显示推演等只给搭建者的操作,403 按是否开登录说话;登录页只认一次人 - 流程与实例:图里用到的岗位(派给谁、超时升级给谁、发送确认派给谁)全部派生、没绑人的发布时报错;写外部系统的参数按能力声明的类型收拢后再算哈希和发送,收不成就不发;发起人的实例页只看这一步的状态,处理人才给待办卡;会签进度带登录核实和时间,升级后照实写规则;实例状态写流程的结论(已退回、未确认、等待核实写入结果),核实生效后不再挂失败原因;流程应用首页不派生最近封存运行的结果块,看板没数据时是安静的空态;样例⑩的金额带类型和下限、待办标题带金额,表单上下限前后端一起生效 - 看板与证据:定时任务模板加 ISO 周、上一 ISO 周、本月、上月;出处链穿过原样解析查询结果的整形节点,追不到原始数据的照实说停在哪里,样例⑨改用 cell();应用首页和版本历史都能做成应用;指标块单位不重复;版本列表带退役说明;定时任务页头跟着刷新;删视图要确认、新视图能起名;编辑器属性用中文名、术语带解释 - 指标出具(用户 2026-10-15 拍板):契约只写「指标来自」、上游没有报告撰写节点、成果里没有模型写的话时按指标出具——必需指标齐、指标输入追到原始数据、成果字段里的数回指得上才完整出具;受管级要求每个指标静态地追到原始数据(governed.metric_untraced);出具横幅和应用印章写明按指标 - 受管工作流降为已发布只给管理员(Q8 顺延,服务端同样拦);工作流接口带当前发布版的级别,画布上改过之后也认得出受管工作流
149 lines
6.6 KiB
Python
149 lines
6.6 KiB
Python
"""内置模板「⑨ 可追溯周报」:tool 查库 → 口径卡 → 报告撰写 → 出具(引用模式)。
|
|
|
|
模板既是示范也是活文档:它要能通过 validate_graph,接上一个真实的库(这里用
|
|
SQLite 建一个示例 shop 库)要能从头跑到尾、完整出具,报告里每个数都点得开。
|
|
口径卡用 cell() 按列名取查询结果的格,每个指标的输入都一路追到查询快照(engine/lineage.py):
|
|
模板教的就是这种写法。模板里只许出现通用示例名。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import sqlite3
|
|
|
|
import pytest
|
|
from langchain_core.messages import AIMessage
|
|
from sqlalchemy import select
|
|
|
|
from app.db.base import SessionLocal
|
|
from app.db.models import DataSource, Run
|
|
from app.engine.runner import run_manager
|
|
from app.engine.schema import GraphSpec, validate_graph
|
|
from app.seed import TEMPLATES
|
|
|
|
NAME = "⑨ 可追溯周报"
|
|
|
|
|
|
def template() -> dict:
|
|
return next(t for t in TEMPLATES if t["name"] == NAME)
|
|
|
|
|
|
def graph() -> dict:
|
|
t = template()
|
|
return {"nodes": t["nodes"], "edges": t["edges"]}
|
|
|
|
|
|
def test_template_passes_validation():
|
|
report = validate_graph(GraphSpec.model_validate(graph()))
|
|
assert report.ok, [i.message for i in report.issues if i.level == "error"]
|
|
types = {n["id"]: n["type"] for n in template()["nodes"]}
|
|
assert list(types.values()).count("report") == 1 and "metrics" in types.values()
|
|
out = next(n for n in template()["nodes"] if n["type"] == "output")
|
|
contract = out["data"]["config"]["contract"]
|
|
report_id = next(k for k, v in types.items() if v == "report")
|
|
assert contract["report_from"] == report_id and contract["strict"] is True
|
|
# 成果字段原样引用报告正文,才能逐段对应证据
|
|
assert {"name": "周报", "value": f"{{{{ nodes.{report_id}.text }}}}"} in out["data"]["config"]["fields"]
|
|
|
|
|
|
def test_every_metric_of_the_template_reaches_the_query_snapshot():
|
|
"""口径卡的每个指标都用 cell() 取数:静态地认(和应用检查同一套),没有一个输入停在半路。"""
|
|
from app.apps.tree import node_map
|
|
from app.engine.lineage import static_stops
|
|
|
|
nodes = node_map(graph())
|
|
[card] = [n for n in nodes.values() if n["type"] == "metrics"]
|
|
metrics = card["config"]["metrics"]
|
|
assert metrics and all("cell(nodes.fetch" in m["expression"] for m in metrics)
|
|
assert {m["id"]: static_stops(nodes, m["expression"]) for m in metrics} == {m["id"]: [] for m in metrics}
|
|
|
|
|
|
async def test_every_metric_of_a_run_is_traced_to_the_snapshot(engine_up, shop, monkeypatch, client):
|
|
"""跑一遍:每个指标的输入都是单元格取数、与快照一致;证据接口的指标步骤说输入都追到了原始数据。"""
|
|
from app.providers import mock_model
|
|
|
|
monkeypatch.setattr(mock_model.MockChatModel, "_decide",
|
|
lambda self, messages: AIMessage(content="销售额 [[m:gmv]]。[[see:m:gmv]]"))
|
|
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:
|
|
break
|
|
assert row.status == "succeeded", row.error
|
|
for metric in ("gmv", "orders", "wow", "aov", "refund_rate"):
|
|
chain = (await client.get(f"/api/runs/{run.id}/evidence/metrics/caliber/{metric}")).json()["chain"]
|
|
step = chain[0]
|
|
assert step["step"] == "metric" and step["untraced"] == [], (metric, step.get("untraced"))
|
|
inputs = [s for s in chain if s["step"] == "input"]
|
|
assert inputs and all(i["via"] == "tool_cell" and i["status"] == "verified" for i in inputs), metric
|
|
assert chain[-1]["step"] == "query"
|
|
|
|
|
|
def test_template_is_listed_once_and_keeps_template_eight():
|
|
names = [t["name"] for t in TEMPLATES]
|
|
assert names.count(NAME) == 1
|
|
assert any(n.startswith("⑧ 周报") for n in names) # 老模板不改,免得覆盖用户复制过的那一份
|
|
|
|
|
|
@pytest.fixture
|
|
async def client():
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from app.main import app
|
|
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
|
yield c
|
|
|
|
|
|
@pytest.fixture
|
|
async def engine_up():
|
|
await run_manager.setup()
|
|
yield
|
|
await run_manager.shutdown()
|
|
|
|
|
|
@pytest.fixture
|
|
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, refunded INTEGER);")
|
|
rows = [(i, "2026-W37", 100.0 + i, 1 if i % 10 == 0 else 0) for i in range(1, 41)]
|
|
rows += [(100 + i, "2026-W36", 90.0 + i, 0) for i in range(1, 31)]
|
|
db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?)", rows)
|
|
db.commit()
|
|
db.close()
|
|
async with SessionLocal() as session:
|
|
existing = (await session.execute(select(DataSource).where(DataSource.name == "shop"))).scalar_one_or_none()
|
|
if existing is None:
|
|
session.add(DataSource(name="shop", kind="sqlite", database=str(path), readonly=True, enabled=True))
|
|
else:
|
|
existing.database, existing.kind, existing.enabled = str(path), "sqlite", True
|
|
await session.commit()
|
|
yield
|
|
|
|
|
|
async def test_template_runs_end_to_end_and_issues_formally(engine_up, shop, monkeypatch):
|
|
from app.providers import mock_model
|
|
|
|
text = ("## 本周概览\n\n[[i:week]] 销售额 [[m:gmv]],环比 [[m:wow]]。[[see:m:gmv,m:wow]]\n\n"
|
|
"- 订单 [[m:orders]],客单价 [[m:aov]]。[[see:m:orders,m:aov]]\n"
|
|
"- 退款率 [[m:refund_rate]]。[[see:m:refund_rate]]")
|
|
monkeypatch.setattr(mock_model.MockChatModel, "_decide", lambda self, messages: AIMessage(content=text))
|
|
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:
|
|
break
|
|
assert row.status == "succeeded", row.error
|
|
issuance = row.output["_issuance"]
|
|
assert issuance["mode"] == "citations" and issuance["tier"] == "formal", issuance
|
|
# 40 单,金额 101..140 → 4820;上周 30 单 91..120 → 3165;退款 4 单
|
|
report = row.output["周报"]
|
|
for shown in ("2026-W37", "4,820.00元", "52.3%", "40单", "120.50元", "10.00%"):
|
|
assert shown in report, report
|
|
assert row.output["_evidence"]["fields"] == ["周报"]
|
|
assert issuance["matched_numbers"] == 5 and issuance["unmatched_numbers"] == []
|