mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 05:51: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);关键行为做了变异验证、全部抓到。
160 lines
6.3 KiB
Python
160 lines
6.3 KiB
Python
"""代码节点在证据链里的位置:evidence_role 和 code_sha。
|
|
|
|
沙箱里跑的代码可以是「取数」(读文件、调接口,产出本身就是源数据),也可以是「计算」
|
|
(拿上游的数加减乘除)。后者正是叙述层不该有的算术权限,只是换了个地方——所以:
|
|
|
|
- evidence_role 缺省为 compute,只能写 source / compute
|
|
- 产出带 code_sha:实际送进沙箱的那份代码(渲染之后、审批改过之后)的 sha256
|
|
- 台账记一条 node_output,工件就是节点产出工件本身
|
|
- 报告不能直接引用代码节点的产出:那种数要进口径卡
|
|
- 升级前发起的运行一字不改
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
|
|
from app.core.artifact_store import canonical_json, content_hash
|
|
from app.db.base import SessionLocal
|
|
from app.db.models import Run, RunEvent
|
|
from app.engine import runner as runner_mod
|
|
from app.engine.context import NodeContext, NodeError, RunContext
|
|
from app.engine.evidence import CODE_REASON, build_catalog, compose_doc
|
|
from app.engine.nodes.tools import run_code
|
|
from app.engine.runner import run_manager
|
|
from app.engine.schema import GraphNode, GraphSpec
|
|
from app.sandbox.base import ExecResult
|
|
|
|
CODE = "print({{ vars.n }} * 2)"
|
|
|
|
|
|
class _FakeSandbox:
|
|
def __init__(self, result: ExecResult) -> None:
|
|
self.result = result
|
|
self.calls: list[str] = []
|
|
|
|
async def run(self, code, **kwargs): # noqa: ANN001, ANN003, ANN201
|
|
self.calls.append(code)
|
|
return self.result
|
|
|
|
|
|
def _ctx(monkeypatch, *, limits=None, stdout="42\n", **config) -> tuple[NodeContext, _FakeSandbox]:
|
|
import app.engine.nodes.tools as tools
|
|
|
|
sandbox = _FakeSandbox(ExecResult(stdout=stdout))
|
|
monkeypatch.setattr(tools, "sandbox_manager", sandbox)
|
|
node = GraphNode(id="calc", type="code", data={"label": "计算", "config": {
|
|
"language": "python", "code": CODE, "isolation": "fast", **config}})
|
|
spec = GraphSpec(nodes=[node], edges=[])
|
|
run = RunContext(run_id="r1", thread_id="t1", spec=spec, agent_limits=limits)
|
|
return NodeContext(node=node, run=run), sandbox
|
|
|
|
|
|
def _run(ctx, state=None) -> dict:
|
|
return asyncio.run(run_code(state or {"vars": {"n": 21}}, ctx))
|
|
|
|
|
|
def test_role_defaults_to_compute_and_code_sha_is_of_the_rendered_code(monkeypatch):
|
|
ctx, sandbox = _ctx(monkeypatch, limits={})
|
|
updates = _run(ctx)
|
|
payload = updates["nodes"]["calc"]
|
|
assert sandbox.calls == ["print(21 * 2)"]
|
|
assert payload["code_sha"] == content_hash("print(21 * 2)")
|
|
[entry] = updates["evidence"]
|
|
assert entry == {"kind": "node_output", "node_id": "calc", "exec": 1,
|
|
"artifact": content_hash(canonical_json(payload)), "code_sha": payload["code_sha"],
|
|
"role": "compute", "language": "python"}
|
|
|
|
|
|
def test_source_role_is_recorded(monkeypatch):
|
|
ctx, _ = _ctx(monkeypatch, limits={}, evidence_role="source")
|
|
[entry] = _run(ctx)["evidence"]
|
|
assert entry["role"] == "source"
|
|
|
|
|
|
def test_unknown_role_is_refused(monkeypatch):
|
|
ctx, sandbox = _ctx(monkeypatch, limits={}, evidence_role="fetch")
|
|
with pytest.raises(NodeError) as info:
|
|
_run(ctx)
|
|
assert "evidence_role" in str(info.value) and "source" in str(info.value)
|
|
assert sandbox.calls == [], "角色写错了还去跑沙箱"
|
|
|
|
|
|
def test_runs_from_before_the_upgrade_are_untouched(monkeypatch):
|
|
ctx, _ = _ctx(monkeypatch, limits=None, evidence_role="source")
|
|
updates = _run(ctx)
|
|
assert "evidence" not in updates
|
|
assert "code_sha" not in updates["nodes"]["calc"]
|
|
|
|
|
|
def test_code_entries_cannot_be_cited_by_a_report(monkeypatch):
|
|
ctx, _ = _ctx(monkeypatch, limits={}, evidence_role="source")
|
|
updates = _run(ctx)
|
|
catalog = build_catalog(nodes=updates["nodes"], ledger=updates["evidence"])
|
|
assert not [a for a in catalog if a.startswith("Q")]
|
|
doc = compose_doc("总数 [[v:N:calc.text]]。", catalog)
|
|
[violation] = doc["violations"]
|
|
assert violation["code"] == "unresolved_ref" and CODE_REASON in violation["message"]
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 跑一次真的运行:台账里的工件就是 node.finished 里那件节点产出
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
async def engine_up():
|
|
await run_manager.setup()
|
|
yield
|
|
await run_manager.shutdown()
|
|
|
|
|
|
async def _run_graph(monkeypatch) -> Run:
|
|
import app.engine.nodes.tools as tools
|
|
|
|
monkeypatch.setattr(tools, "sandbox_manager", _FakeSandbox(ExecResult(stdout='{"total": 42}\n')))
|
|
graph = {
|
|
"nodes": [
|
|
{"id": "start", "type": "input", "data": {"config": {}}},
|
|
{"id": "calc", "type": "code", "data": {"label": "计算", "config": {
|
|
"language": "python", "code": "print('{\"total\": 42}')", "isolation": "fast"}}},
|
|
{"id": "out", "type": "output", "data": {"config": {"fields": [{"name": "r", "value": "{{ nodes.calc.text }}"}]}}},
|
|
],
|
|
"edges": [{"source": "start", "target": "calc"}, {"source": "calc", "target": "out"}],
|
|
}
|
|
run = await run_manager.start(graph=graph, input_payload={})
|
|
for _ in range(300):
|
|
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("没跑完")
|
|
|
|
|
|
async def _finished(run_id: str) -> dict:
|
|
async with SessionLocal() as session:
|
|
rows = list((await session.execute(select(RunEvent).where(
|
|
RunEvent.run_id == run_id, RunEvent.type == "node.finished", RunEvent.node_id == "calc"))).scalars())
|
|
return rows[0].data
|
|
|
|
|
|
async def test_ledger_artifact_is_the_node_output_artifact(engine_up, monkeypatch):
|
|
row = await _run_graph(monkeypatch)
|
|
assert row.status == "succeeded", row.error
|
|
finished = await _finished(row.id)
|
|
[entry] = finished["evidence"]
|
|
assert entry["artifact"] == finished["artifact"] and entry["role"] == "compute"
|
|
|
|
|
|
async def test_old_runs_write_no_code_entry(engine_up, monkeypatch):
|
|
async def no_snapshot(session):
|
|
return None
|
|
|
|
monkeypatch.setattr(runner_mod, "_agent_limits", no_snapshot)
|
|
row = await _run_graph(monkeypatch)
|
|
assert row.status == "succeeded", row.error
|
|
assert "evidence" not in await _finished(row.id)
|