mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:21:57 +00:00
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);关键行为做了变异验证、全部抓到。
159 lines
7.2 KiB
Python
159 lines
7.2 KiB
Python
"""发布级别记在版本上(WorkflowVersion.level)。
|
||
|
||
以前「这次正式运行按不按受管出具」看的是工作流当前的 status。可 status 说的是当前画布:
|
||
受管版本发布之后在画布上改一笔,status 就退回 draft,再从那个受管版本发起的正式运行
|
||
就被当成已发布级别出具(单元格不必在契约里声明 cells 就放行)。级别要跟着版本走:
|
||
发布时记在那一版上,正式运行看它钉的版本;没有记过级别的老版本照旧看 status。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
|
||
import pytest
|
||
from httpx import ASGITransport, AsyncClient
|
||
from langchain_core.messages import AIMessage
|
||
from sqlalchemy import create_engine, select, text
|
||
|
||
from app.db.base import SessionLocal
|
||
from app.db.models import Run, RunEvent, Workflow, WorkflowVersion
|
||
from app.engine.runner import run_manager
|
||
from app.main import app
|
||
|
||
|
||
@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)
|
||
def writer(monkeypatch):
|
||
from app.providers import mock_model
|
||
|
||
monkeypatch.setattr(mock_model.MockChatModel, "_decide",
|
||
lambda model, messages: AIMessage(content="本周销售额 [[m:gmv]]。"))
|
||
|
||
|
||
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", "transform", mode="expression", expression="{'gmv': 300.5}", assign_to="kpi"),
|
||
node("card", "metrics", caliber="周报口径", metrics=[
|
||
{"id": "gmv", "name": "销售额", "unit": "元", "expression": "vars.kpi.gmv"}]),
|
||
node("write", "report", instructions="写周报"),
|
||
node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}],
|
||
contract={"report_from": "write", "metrics_from": ["card"], "required": ["gmv"], "strict": True}),
|
||
]
|
||
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
|
||
|
||
|
||
async def version_row(wf: str, version: int) -> WorkflowVersion:
|
||
async with SessionLocal() as session:
|
||
return (await session.execute(select(WorkflowVersion).where(
|
||
WorkflowVersion.workflow_id == wf, WorkflowVersion.version == version))).scalar_one()
|
||
|
||
|
||
async def notes(run_id: str) -> list[dict]:
|
||
async with SessionLocal() as session:
|
||
rows = (await session.execute(select(RunEvent).where(
|
||
RunEvent.run_id == run_id, RunEvent.type == "log").order_by(RunEvent.seq))).scalars()
|
||
return [e.data for e in rows if e.data.get("code") == "run_governance"]
|
||
|
||
|
||
async def settle(run_id: str) -> Run:
|
||
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:
|
||
return row
|
||
raise AssertionError(f"run {run_id} 没跑完:{row.status}")
|
||
|
||
|
||
@pytest.mark.parametrize("level", ["published", "governed"])
|
||
async def test_publishing_records_the_level_on_the_version(client, level):
|
||
wf = (await client.post("/api/workflows", json={"name": f"周报-{level}", "graph": weekly()})).json()["id"]
|
||
body = (await client.post(f"/api/workflows/{wf}/publish", json={"level": level})).json()
|
||
assert body["ok"] is True, body["issues"]
|
||
assert (await version_row(wf, 1)).level == level
|
||
versions = (await client.get(f"/api/workflows/{wf}/versions")).json()
|
||
assert versions[0]["level"] == level
|
||
detail = (await client.get(f"/api/workflows/{wf}/versions/1")).json()
|
||
assert detail["level"] == level and detail["published"] is True
|
||
|
||
|
||
async def test_a_blocked_publish_records_nothing(client):
|
||
graph = weekly()
|
||
graph["nodes"][-1]["data"]["config"]["contract"].pop("required")
|
||
wf = (await client.post("/api/workflows", json={"name": "周报-被拦", "graph": graph})).json()["id"]
|
||
assert (await client.post(f"/api/workflows/{wf}/publish", json={"level": "governed"})).json()["ok"] is False
|
||
assert (await version_row(wf, 1)).level is None
|
||
|
||
|
||
async def test_republishing_the_same_version_updates_its_level(client):
|
||
wf = (await client.post("/api/workflows", json={"name": "周报-升档", "graph": weekly()})).json()["id"]
|
||
await client.post(f"/api/workflows/{wf}/publish", json={"level": "published"})
|
||
await client.post(f"/api/workflows/{wf}/publish", json={"level": "governed"})
|
||
assert (await version_row(wf, 1)).level == "governed"
|
||
|
||
|
||
@pytest.mark.parametrize("level, governed", [("governed", True), ("published", False)])
|
||
async def test_editing_the_canvas_after_publishing_does_not_change_how_the_version_issues(client, level, governed):
|
||
from app.engine.governance import governed_formal
|
||
|
||
wf = (await client.post("/api/workflows", json={"name": f"周报-改画布-{level}", "graph": weekly()})).json()["id"]
|
||
assert (await client.post(f"/api/workflows/{wf}/publish", json={"level": level})).json()["ok"] is True
|
||
edited = weekly()
|
||
edited["nodes"][3]["data"]["label"] = "改过的报告"
|
||
after = (await client.patch(f"/api/workflows/{wf}", json={"graph": edited})).json()
|
||
assert after["status"] == "draft" and after["published_version"] == 1
|
||
r = await client.post("/api/runs", json={"workflow_id": wf, "run_class": "formal"})
|
||
assert r.status_code == 201, r.text
|
||
run_id = r.json()["id"]
|
||
row = await settle(run_id)
|
||
[note] = await notes(run_id)
|
||
assert note["governed"] is governed and "v1" in note["message"], note
|
||
assert await governed_formal(run_id) is governed
|
||
assert row.status == "succeeded", row.error
|
||
|
||
|
||
async def test_a_version_published_before_the_column_existed_follows_the_status(client):
|
||
"""老版本没有记级别:照旧看工作流现在的 status(test_contract_cells 那套旧规则)。"""
|
||
async with SessionLocal() as session:
|
||
w = Workflow(name="周报-老版本", graph=weekly(), status="governed", published_version=1)
|
||
session.add(w)
|
||
await session.flush()
|
||
session.add(WorkflowVersion(workflow_id=w.id, version=1, graph=weekly()))
|
||
await session.commit()
|
||
wf = w.id
|
||
r = await client.post("/api/runs", json={"workflow_id": wf, "run_class": "formal"})
|
||
run_id = r.json()["id"]
|
||
await settle(run_id)
|
||
[note] = await notes(run_id)
|
||
assert note["governed"] is True
|
||
|
||
|
||
def test_the_column_is_added_to_old_databases():
|
||
from app.db.base import _migrate
|
||
|
||
engine = create_engine("sqlite://")
|
||
with engine.begin() as conn:
|
||
conn.execute(text("CREATE TABLE workflow_versions (id VARCHAR(32) PRIMARY KEY, workflow_id VARCHAR(32), "
|
||
"version INTEGER, graph JSON, note TEXT, graph_hash VARCHAR(64))"))
|
||
conn.execute(text("INSERT INTO workflow_versions (id, workflow_id, version) VALUES ('a', 'w', 1)"))
|
||
_migrate(conn)
|
||
cols = {row[1] for row in conn.execute(text("PRAGMA table_info(workflow_versions)"))}
|
||
assert "level" in cols
|
||
assert conn.execute(text("SELECT level FROM workflow_versions")).scalar() is None
|