mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:11:58 +00:00
e4f2693918
「引用即渲染」:写报告的模型只写引用标记([[m:指标id]]、[[i:输入字段]]、[[see:…]]),数值由系统按口径卡
填进去,每个数字都挂着一条能复算的证据链。本期是数字层闭环;实体、结论句的概率性证据放在后续几期。
内核(engine/evidence.py,口径卡、出具契约、表达式等)
- 口径卡记录每个指标的来源:原式、代入式(每个输入写成「值 ← 路径」)、复算结果、口径与版本
- 出具契约的数值匹配改为按位置标注;同一个值有几个出处时记为「出处不唯一」并列出候选,不再随便挑一个
- 证据 id 由内容派生,事后对不上就是被改过;零值、结构性数字(列表序号、代码块标签)单独处理
- 口径卡新增 on_missing:缺输入时默认仍是整个节点失败,可以改成记为空值、交给契约判档
报告撰写节点(report)与引用模式的出具契约
- 流式生成时 token 里就是真实数字,不会漏出原始标记;裸数字、不存在的引用当场判违规,交回去重写
(max_repairs,默认 1 次);仍不过时按 on_violation 失败(正式运行默认)或把违规标在报告里(探索运行默认)
- report.checked 事件记下核对结果,落在封存范围内;fail 模式下仍不过时带 failed,运行记录里画成失败
- 出具契约写了 report_from 就按引用核对:取回文档时复验哈希,独立重算一遍,全部带引用才判完整出具
- 成果字段逐字等于报告正文时标注 _evidence;复核只能加说明,不能改写带证据的答案(前后端各拦一道)
证据接口
- GET /api/runs/{id}/evidence 返回证据图;GET …/evidence/segments/{id} 返回一个片段的证据链
- 只认封存范围内的事件能追到的工件,不读 artifacts 表;封存被改过时不把任何一项标成已封存
- 没有报告的旧运行降级为 none 或 legacy_contract
界面
- EvidenceDoc 按块、句、片段渲染报告,片段是和正文一样的按钮;整份报告只占一个 Tab 位,
←/→ 在片段间移动、↑/↓ 按句、n/N 跳到下一处「无证据」、Enter 打开面板、Esc 关闭并把焦点还回去
- EvidencePanel:宽屏侧边、窄屏底部抽屉、360px 画布右栏里栏内展开;显示指标步骤、代入式、复算结果、
封存状态。正文不是封存的那份报告时明确说出来,不拿别的报告的封存画绿
- 出具横幅多一行「N/N 数字有出处 · 无证据 M」和「定位下一处」;没有 _evidence 的旧回答渲染一字不差
- 画布认得报告撰写节点;Copilot 手册补上口径卡、报告节点、出具契约的写法;新增模板「⑨ 可追溯周报」
验证(沙箱):tsc 干净,生产构建通过,pytest 1601 passed,check-all 16 项检查全绿(新增 check-evidence)。
103 lines
4.3 KiB
Python
103 lines
4.3 KiB
Python
"""内置模板「⑨ 可追溯周报」:tool 查库 → 解析 → 口径卡 → 报告撰写 → 出具(引用模式)。
|
|
|
|
模板既是示范也是活文档:它要能通过 validate_graph,接上一个真实的库(这里用
|
|
SQLite 建一个示例 shop 库)要能从头跑到尾、完整出具,报告里每个数都点得开。
|
|
模板里只许出现通用示例名。
|
|
"""
|
|
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_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 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"] == []
|