mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:31:58 +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);关键行为做了变异验证、全部抓到。
103 lines
5.7 KiB
Python
103 lines
5.7 KiB
Python
"""画图时就能说破的几件证据链配置问题(validate_graph)。
|
||
|
||
- agent 写了 output_schema 却没开 cite_fields:Schema 不生效,assign_to 拿到的仍是自由文字
|
||
- 口径卡的输入来自沙箱代码、而它没标成取数(evidence_role 不是 source):算出来的数核对不了出处
|
||
- 报告撰写节点没指定模型:和 llm / agent 一样提示会回退到默认 provider
|
||
- 钉住别处口径卡的 caliber_from:三项要写全、版本要是具体的数,本地不用再写指标
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from app.engine.schema import GraphSpec, validate_graph
|
||
|
||
|
||
def node(nid, ntype, label=None, **config):
|
||
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0},
|
||
"data": {"label": label or nid, "config": config}}
|
||
|
||
|
||
def issues(*nodes, node_id=None):
|
||
graph = {"nodes": list(nodes),
|
||
"edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
|
||
found = validate_graph(GraphSpec.model_validate(graph)).issues
|
||
return [i for i in found if node_id is None or i.node_id == node_id]
|
||
|
||
|
||
START = node("start", "input")
|
||
OUT = node("out", "output", fields=[{"name": "r", "value": "{{ last_message }}"}])
|
||
SCHEMA = {"type": "object", "properties": {"gmv": {"type": "number"}}}
|
||
|
||
|
||
def test_output_schema_without_cite_fields_warns():
|
||
bot = node("bot", "agent", prompt="查", tools=["db_query__shop"], output_schema=SCHEMA, model="m")
|
||
[issue] = [i for i in issues(START, bot, OUT, node_id="bot") if "cite_fields" in i.message]
|
||
assert issue.level == "warning" and issue.field == "output_schema"
|
||
assert issue.message.startswith("output_schema 要开 cite_fields 才生效,会多一次抽取调用")
|
||
|
||
cited = node("bot", "agent", prompt="查", tools=["db_query__shop"], output_schema=SCHEMA, cite_fields=True,
|
||
model="m")
|
||
assert not [i for i in issues(START, cited, OUT, node_id="bot") if "cite_fields" in i.message]
|
||
plain = node("bot", "agent", prompt="查", tools=["db_query__shop"], model="m")
|
||
assert not [i for i in issues(START, plain, OUT, node_id="bot") if "cite_fields" in i.message]
|
||
|
||
|
||
def test_caliber_fed_by_a_compute_code_node_warns():
|
||
def graph(**code):
|
||
fetch = node("fetch", "code", "取数脚本", code="print('{\"gmv\": 1}')", assign_to="agg", **code)
|
||
card = node("card", "metrics", caliber="周报口径", metrics=[
|
||
{"id": "gmv", "expression": "vars.agg.gmv"}, {"id": "gmv2", "expression": "nodes.fetch.gmv * 2"}])
|
||
return issues(START, fetch, card, OUT, node_id="card")
|
||
|
||
[issue] = [i for i in graph() if "evidence_role" in i.message]
|
||
assert issue.level == "warning" and issue.field == "metrics[0].expression"
|
||
assert "「取数脚本」" in issue.message and "source" in issue.message
|
||
assert [i for i in graph(evidence_role="compute") if "evidence_role" in i.message]
|
||
assert not [i for i in graph(evidence_role="source") if "evidence_role" in i.message]
|
||
|
||
|
||
def test_evidence_role_must_be_source_or_compute():
|
||
fetch = node("fetch", "code", code="print(1)", evidence_role="取数")
|
||
[issue] = [i for i in issues(START, fetch, OUT, node_id="fetch") if i.field == "evidence_role"]
|
||
assert issue.level == "error" and "source" in issue.message and "compute" in issue.message
|
||
|
||
|
||
def test_report_without_a_model_gets_the_same_warning_as_llm():
|
||
card = node("card", "metrics", metrics=[{"id": "gmv", "expression": "1"}])
|
||
write = node("write", "report", instructions="写周报")
|
||
[issue] = [i for i in issues(START, card, write, OUT, node_id="write") if i.field == "model"]
|
||
assert issue.level == "warning" and "没有指定模型" in issue.message
|
||
|
||
|
||
def test_caliber_from_replaces_local_metrics():
|
||
pinned = {"workflow_id": "wf1", "workflow_version": 3, "node_id": "caliber"}
|
||
card = node("card", "metrics", caliber_from=pinned)
|
||
assert not [i for i in issues(START, card, OUT, node_id="card") if i.level == "error"]
|
||
|
||
both = node("card", "metrics", caliber_from=pinned, metrics=[{"id": "gmv", "expression": "1"}])
|
||
[issue] = [i for i in issues(START, both, OUT, node_id="card") if i.field == "metrics"]
|
||
assert issue.level == "warning" and "不会生效" in issue.message
|
||
|
||
for bad, field in ((["wf1"], "caliber_from"), ({"workflow_id": "wf1", "node_id": "c"}, "caliber_from.workflow_version"),
|
||
({"workflow_id": "wf1", "workflow_version": "最新", "node_id": "c"},
|
||
"caliber_from.workflow_version"),
|
||
({"workflow_id": "", "workflow_version": 1, "node_id": "c"}, "caliber_from")):
|
||
found = [i for i in issues(START, node("card", "metrics", caliber_from=bad), OUT, node_id="card")
|
||
if i.level == "error"]
|
||
assert [i.field for i in found] == [field], (bad, found)
|
||
|
||
policy = node("card", "metrics", caliber_from=pinned, upgrade_policy="auto")
|
||
[issue] = [i for i in issues(START, policy, OUT, node_id="card") if i.field == "upgrade_policy"]
|
||
assert issue.level == "error" and "recompute" in issue.message
|
||
ok = node("card", "metrics", caliber_from=pinned, upgrade_policy="dual")
|
||
assert not [i for i in issues(START, ok, OUT, node_id="card") if i.level == "error"]
|
||
|
||
|
||
def test_template_eight_marks_its_fetch_code_as_a_source():
|
||
"""模板 ⑧ 的取数代码是「数据底座」:标成 source,口径卡读它不再被提示核对不了出处。"""
|
||
from app.seed import TEMPLATES
|
||
|
||
weekly = next(t for t in TEMPLATES if t["name"].startswith("⑧"))
|
||
fetch = next(n for n in weekly["nodes"] if n["id"] == "fetch")
|
||
assert fetch["data"]["config"]["evidence_role"] == "source"
|
||
spec = GraphSpec.model_validate({"nodes": weekly["nodes"], "edges": weekly["edges"]})
|
||
assert not [i for i in validate_graph(spec).issues if "evidence_role" in i.message]
|