Files
AgentLab/backend/app/seed.py
T
ThearchyHelios 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);关键行为做了变异验证、全部抓到。
2026-09-28 21:20:04 +08:00

446 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import logging
import os
from typing import Any
from sqlalchemy import func, select
from app.core.crypto import encrypt
from app.db.base import SessionLocal
from app.db.models import Provider, Skill, Workflow, WorkflowVersion
from app.engine.schema import GraphSpec
from app.providers import catalog
logger = logging.getLogger(__name__)
def _n(node_id: str, node_type: str, label: str, **config: Any) -> dict[str, Any]:
return {
"id": node_id,
"type": node_type,
"position": {"x": 0, "y": 0},
"data": {"label": label, "config": config},
}
def _e(source: str, target: str, handle: str | None = None) -> dict[str, Any]:
edge: dict[str, Any] = {"source": source, "target": target}
if handle:
edge["sourceHandle"] = handle
return edge
# --------------------------------------------------------------------------
# 内置模板:打开就有得玩,也是各种节点用法的活文档
# --------------------------------------------------------------------------
TEMPLATES: list[dict[str, Any]] = [
{
"name": "① 最小问答",
"description": "一个模型节点跑通全流程。先从这个开始改。",
"tags": ["入门"],
"nodes": [
_n("start", "input", "提问", fields=[{"name": "question", "required": True,
"description": "你想问什么"}]),
_n("answer", "llm", "回答", system="你是一个耐心、准确的助手。",
prompt="{{ input.question }}", assign_to="answer"),
_n("done", "output", "成果", fields=[{"name": "回答", "value": "{{ vars.answer }}"}]),
],
"edges": [_e("start", "answer"), _e("answer", "done")],
},
{
"name": "② 研究助手(Agent + 工具)",
"description": "Agent 自主决定搜什么、抓哪篇,再汇总成带出处的简报。",
"tags": ["agent", "工具"],
"nodes": [
_n("start", "input", "研究主题", fields=[{"name": "topic", "required": True}]),
_n("research", "agent", "调研 Agent",
system="你是严谨的研究员。先搜索再抓取原文,只写有依据的内容。",
prompt="围绕这个主题做调研:{{ input.topic }}",
tools=["web_search", "web_fetch", "current_time"],
max_steps=8, approval="never", assign_to="findings"),
_n("brief", "llm", "写成简报",
system="你是一位把复杂信息讲清楚的编辑。",
prompt="把下面的调研结果整理成中文简报,分「结论 / 关键发现 / 待确认」三段,"
"每条结论后面标注来源链接:\n\n{{ vars.findings }}",
assign_to="brief"),
_n("done", "output", "简报", fields=[
{"name": "简报", "value": "{{ vars.brief }}"},
{"name": "调研过程", "value": "{{ nodes.research.tool_calls | json }}"}]),
],
"edges": [_e("start", "research"), _e("research", "brief"), _e("brief", "done")],
},
{
"name": "③ 带人工审批的发布流",
"description": "起草 → 人工把关 → 通过就发布、驳回就改写。演示中断与断点恢复。",
"tags": ["人工审批", "分支"],
"nodes": [
_n("start", "input", "主题", fields=[{"name": "topic", "required": True}]),
_n("draft", "llm", "起草", system="你是品牌文案。",
prompt="为「{{ input.topic }}」写一条 100 字以内的对外公告。",
assign_to="draft"),
_n("review", "human", "人工把关", mode="approve",
title="这条公告可以发吗?",
message="草稿:\n\n{{ vars.draft }}"),
_n("publish", "transform", "标记发布", mode="template",
template="✅ 已批准发布\n\n{{ vars.draft }}", assign_to="final"),
_n("rewrite", "llm", "按意见改写",
prompt="下面这条公告被驳回了,理由是「{{ nodes.review.note }}」。请据此改写:\n\n{{ vars.draft }}",
assign_to="final"),
_n("done", "output", "成果", fields=[
{"name": "结果", "value": "{{ vars.final }}"},
{"name": "是否通过", "value": "{{ nodes.review.approved }}"}]),
],
"edges": [
_e("start", "draft"), _e("draft", "review"),
_e("review", "publish", "approved"), _e("review", "rewrite", "rejected"),
_e("publish", "done"), _e("rewrite", "done"),
],
},
{
"name": "④ 知识库问答(RAG)",
"description": "先检索再回答,检索不到就明说。需要先在知识库页上传文档。",
"tags": ["RAG", "知识库"],
"nodes": [
_n("start", "input", "提问", fields=[{"name": "question", "required": True},
{"name": "collection", "default": "default"}]),
_n("search", "retrieve", "检索知识库", query="{{ input.question }}",
collection="{{ input.collection }}", limit=5, alpha=0.5, assign_to="context"),
_n("gate", "branch", "检索到东西了吗", mode="expression",
cases=[{"key": "hit", "condition": "nodes.search.count > 0", "label": "有命中"}]),
_n("answer", "llm", "基于资料回答",
system="只根据提供的资料回答,每句话后面用 [编号] 标注依据。资料不足就直接说不确定。",
prompt="资料:\n{{ vars.context }}\n\n问题:{{ input.question }}",
assign_to="answer"),
_n("miss", "transform", "没有资料", mode="template",
template="知识库里没有找到与「{{ input.question }}」相关的内容,请先上传相关文档。",
assign_to="answer"),
_n("done", "output", "答案", fields=[
{"name": "答案", "value": "{{ vars.answer }}"},
{"name": "引用片段", "value": "{{ nodes.search.hits | json }}"}]),
],
"edges": [
_e("start", "search"), _e("search", "gate"),
_e("gate", "answer", "hit"), _e("gate", "miss", "default"),
_e("answer", "done"), _e("miss", "done"),
],
},
{
"name": "⑤ 写代码跑数据(沙箱 + 校验)",
"description": "模型写 Python,沙箱里真跑,输出还要过 JSON Schema 校验,不合格自动返工。",
"tags": ["沙箱", "结构化"],
"nodes": [
_n("start", "input", "分析需求", fields=[
{"name": "task", "required": True, "default": "统计 1 到 100 里所有质数的个数与总和"}]),
_n("write", "llm", "写代码",
system="你是数据工程师。只输出可直接运行的 Python 代码,不要解释、不要 markdown 围栏。"
"结果必须用 print(json.dumps(...)) 输出一个 JSON 对象。",
prompt="任务:{{ input.task }}", assign_to="code"),
_n("run", "code", "沙箱执行", language="python", code="{{ vars.code }}",
timeout=30, network=False, fail_fast=False, assign_to="result"),
_n("check", "validate", "校验结果", source="{{ nodes.run.stdout }}",
max_retries=2, repair_with_llm=True, fail_fast=False,
schema={"type": "object", "minProperties": 1}),
_n("done", "output", "成果", fields=[
{"name": "结果", "value": "{{ nodes.check.data | json }}"},
{"name": "代码", "value": "{{ vars.code }}"},
{"name": "校验通过", "value": "{{ nodes.check.valid }}"}]),
],
"edges": [_e("start", "write"), _e("write", "run"), _e("run", "check"), _e("check", "done")],
},
{
"name": "⑥ 多 Agent 协作",
"description": "调度者按进展分派任务给研究员、批评家和写手,直到目标达成。",
"tags": ["多agent"],
"nodes": [
_n("start", "input", "目标", fields=[{"name": "goal", "required": True}]),
_n("team", "supervisor", "协作团队", goal="{{ input.goal }}", max_rounds=6,
agents=[
{"name": "researcher", "description": "查资料、找事实",
"system": "你是研究员,负责收集事实和数据,给出来源。",
"tools": ["web_search", "web_fetch"], "max_steps": 4},
{"name": "critic", "description": "挑毛病、找漏洞",
"system": "你是批评家。指出前面工作里的事实错误、逻辑漏洞和遗漏,要具体。",
"tools": [], "max_steps": 2},
{"name": "writer", "description": "整合成最终文稿",
"system": "你是写手,把团队成果整合成结构清晰的中文文稿。",
"tools": [], "max_steps": 2},
], assign_to="result"),
_n("done", "output", "成果", fields=[
{"name": "结果", "value": "{{ vars.result }}"},
{"name": "协作记录", "value": "{{ nodes.team.transcript | json }}"}]),
],
"edges": [_e("start", "team"), _e("team", "done")],
},
{
"name": "⑧ 周报(口径卡 + 三档出具)",
"description": "模板定骨架、agent 填关节的完整示范:取数固定、口径卡算数、"
"叙述只许引用清单里的数、出具时逐数回指——现编的数字会被点名并降档。",
"tags": ["出具", "口径卡", "周报"],
"nodes": [
_n("start", "input", "周期", fields=[
{"name": "week", "default": "2026-W37", "description": "报告周期"}]),
# 取数节点:产出本身就是源数据(evidence_role=source),口径卡读它记得下出处
_n("fetch", "code", "取数(数据底座)", language="python", network=False,
assign_to="agg", evidence_role="source",
code=(
"# 固定层:取数与汇总。真实场景换成对数据库/API 的查询,\n"
"# 结果会作为快照落工件库,出具溯源指回这里。\n"
"import json\n"
"print(json.dumps({'orders': 1234, 'amount': 45678.5,\n"
" 'amount_prev': 42010.0, 'returns': 29}))"
)),
_n("caliber", "metrics", "周报口径卡",
caliber="周报口径", caliber_version="v1", assign_to="m",
metrics=[
{"id": "orders", "name": "订单数", "unit": "单",
"expression": "vars.agg.orders"},
{"id": "amount", "name": "销售额", "unit": "元",
"expression": "vars.agg.amount"},
{"id": "wow", "name": "环比增幅", "unit": "%",
"expression": "round((vars.agg.amount - vars.agg.amount_prev) / vars.agg.amount_prev * 100, 1)"},
{"id": "return_rate", "name": "退货率", "unit": "%",
"expression": "round(vars.agg.returns / vars.agg.orders * 100, 2)"},
]),
_n("narrate", "llm", "叙述(关节层)",
system="你是周报撰写人。只允许引用下面指标清单里出现的数值,"
"一个清单外的数字都不要写——包括字数统计、序号补充说明等元内容。"
"只输出周报正文本身,不要任何撰写说明。"
"你的自由度在于:挑哪些变化值得说、怎么归因。",
prompt="{{ nodes.caliber.text }}\n\n请为 {{ input.week }} 写一段简短的周报正文。",
assign_to="report"),
_n("done", "output", "出具",
fields=[
{"name": "周报", "value": "{{ vars.report }}"},
{"name": "指标清单", "value": "{{ nodes.caliber.text }}"}],
contract={
"metrics_from": ["caliber"],
"narrative": "{{ vars.report }}",
"required": ["amount", "orders"],
"expected": ["wow", "return_rate"],
"allow_numbers": ["37", "120"],
"strict": False,
}),
],
"edges": [
_e("start", "fetch"), _e("fetch", "caliber"),
_e("caliber", "narrate"), _e("narrate", "done"),
],
},
{
"name": "⑨ 可追溯周报",
"description": "报告里的每个数都点得开出处:tool 节点查库、口径卡登记指标、报告撰写节点只写引用"
"标记,数字由系统从口径卡取出来渲染,出口按引用逐段核对后三档出具。取数节点查的是示例数据源 "
"shop 的 orders 表(week、amount、refunded 三列):先接入你的库(或者上传一张表格、命名为 "
"shop),再把 SQL 换成你的口径。",
"tags": ["出具", "口径卡", "周报", "证据"],
"nodes": [
_n("start", "input", "周期", fields=[
{"name": "week", "default": "2026-W37", "description": "报告周期(ISO 周)"},
{"name": "prev_week", "default": "2026-W36", "description": "对比的上一周期"}]),
# 聚合放在 SQL 里做完,口径卡只做标量运算。列的顺序就是口径卡里 rows[0][n] 的 n
_n("fetch", "tool", "取数(查库)", tool="db_query__shop", args={"sql": (
"SELECT\n"
" SUM(CASE WHEN week = '{{ input.week }}' THEN amount ELSE 0 END) AS gmv,\n"
" SUM(CASE WHEN week = '{{ input.prev_week }}' THEN amount ELSE 0 END) AS gmv_prev,\n"
" SUM(CASE WHEN week = '{{ input.week }}' THEN 1 ELSE 0 END) AS order_cnt,\n"
" SUM(CASE WHEN week = '{{ input.week }}' AND refunded = 1 THEN 1 ELSE 0 END) AS refund_cnt\n"
"FROM orders")}),
# 查询工具交回来的是 JSON 文本 {columns, rows},解析成对象口径卡才取得到里面的数
_n("parse", "transform", "解析查询结果", mode="json", template="{{ nodes.fetch }}",
assign_to="q"),
_n("caliber", "metrics", "周报口径卡", caliber="周报口径", caliber_version="v1",
metrics=[
{"id": "gmv", "name": "销售额", "unit": "元", "decimals": 2, "format": "thousands",
"expression": "vars.q.rows[0][0]"},
{"id": "orders", "name": "订单数", "unit": "单", "format": "thousands",
"expression": "vars.q.rows[0][2]"},
# 分母为 0 时记为空值(缺输入),交给契约按 expected 降档,而不是整张卡失败
{"id": "wow", "name": "环比增幅", "unit": "%", "decimals": 1, "format": "plain",
"expression": "round((vars.q.rows[0][0] - vars.q.rows[0][1]) / vars.q.rows[0][1] * 100, 1)"
" if vars.q.rows[0][1] else None"},
{"id": "aov", "name": "客单价", "unit": "元", "decimals": 2, "format": "thousands",
"expression": "round(vars.q.rows[0][0] / vars.q.rows[0][2], 2) if vars.q.rows[0][2] else None"},
{"id": "refund_rate", "name": "退款率", "decimals": 4, "format": "percent_of_ratio",
"expression": "vars.q.rows[0][3] / vars.q.rows[0][2] if vars.q.rows[0][2] else None"},
]),
_n("write", "report", "报告撰写", metrics_from=["caliber"],
system="你是周报撰写人:结论先行,只写有数据支撑的判断。",
instructions="为 {{ input.week }} 写一份简短的周报:先用一两句话总结本周,"
"再分点说销售额、订单、客单价和退款的变化。"),
_n("done", "output", "出具",
fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}],
contract={
"report_from": "write",
"metrics_from": ["caliber"],
"required": ["gmv", "orders"],
"expected": ["wow", "aov", "refund_rate"],
"strict": True,
}),
],
"edges": [
_e("start", "fetch"), _e("fetch", "parse"), _e("parse", "caliber"),
_e("caliber", "write"), _e("write", "done"),
],
},
{
"name": "⑦ 批量处理(循环)",
"description": "对列表逐项处理再汇总。演示 loop 节点的 body / done 两个出口怎么接。",
"tags": ["循环"],
"nodes": [
_n("start", "input", "条目列表", fields=[
{"name": "items", "required": True,
"default": ["降低获客成本", "提升续费率", "缩短上线周期"],
"description": "JSON 数组或每行一条"}]),
_n("each", "loop", "逐条循环", mode="foreach", items="{{ input.items }}",
item_var="item", max_iterations=10),
_n("handle", "llm", "处理单条",
prompt="针对「{{ vars.item }}」给出一条可执行的建议,不超过 40 字。",
assign_to="one"),
_n("collect", "transform", "累积结果", mode="expression",
expression="(vars.collected or '') + '· ' + str(vars.one) + '\\n'",
assign_to="collected"),
_n("done", "output", "汇总", fields=[
{"name": "逐条建议", "value": "{{ vars.collected }}"},
{"name": "处理条数", "value": "{{ nodes.each.iteration }}"}]),
],
"edges": [
_e("start", "each"),
_e("each", "handle", "body"), _e("handle", "collect"), _e("collect", "each"),
_e("each", "done", "done"),
],
},
]
SKILLS: list[dict[str, Any]] = [
{
"name": "结构化分析",
"description": "把开放问题拆成结论先行的分析",
"instructions": (
"分析任何问题时遵循:\n"
"1. 先给结论,再给理由,不要铺垫\n"
"2. 区分「事实」「推断」「假设」,并明确标注\n"
"3. 给出至少一个反面视角\n"
"4. 指出当前信息不足以判断的部分,不要硬答"
),
"tags": ["通用"],
},
{
"name": "批判性审阅",
"description": "挑毛病而不是夸奖",
"instructions": (
"审阅内容时:\n"
"1. 只指出问题,不要复述优点\n"
"2. 每条问题写清楚「问题是什么 / 为什么是问题 / 怎么改」\n"
"3. 按严重程度排序,最致命的放最前面\n"
"4. 没有问题就直说没有,不要为凑数硬挑"
),
"tags": ["审阅"],
},
{
"name": "中文写作",
"description": "干净、不翻译腔的中文",
"instructions": (
"写中文时:\n"
"1. 不用「进行」「作出」这类空动词,直接用实义动词\n"
"2. 少用被动句和长定语从句\n"
"3. 术语首次出现时用括号给出英文原文\n"
"4. 能用短句就不写长句"
),
"tags": ["写作"],
},
]
async def seed_defaults() -> None:
"""首次启动时灌入默认 provider、模板工作流和 Skill。
已经存在的不会被覆盖 —— 这个函数每次启动都会跑,必须是幂等的。
"""
async with SessionLocal() as session:
# --- provider ---
count = (await session.execute(select(func.count(Provider.id)))).scalar() or 0
if count == 0:
session.add(
Provider(
name="Mock(无需 Key)",
kind="mock",
models=catalog.MOCK_MODELS,
default_model="mock-fast",
enabled=True,
)
)
# 环境里已有 Anthropic 凭据就自动配好,省得再手填一遍
token = os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN")
if token:
base_url = os.environ.get("ANTHROPIC_BASE_URL")
is_gateway = bool(base_url) and not os.environ.get("ANTHROPIC_API_KEY")
session.add(
Provider(
name="Anthropic" + ("(网关)" if is_gateway else ""),
kind="anthropic",
base_url=base_url,
api_key=encrypt(token),
models=catalog.ANTHROPIC_MODELS,
default_model="claude-opus-5",
enabled=True,
# 中转网关通常认 Authorization: Bearer 而不是 x-api-key
extra={"auth_style": "bearer"} if is_gateway else {},
)
)
logger.info("已从环境变量自动创建 Anthropic provider")
if os.environ.get("OPENAI_API_KEY"):
session.add(
Provider(
name="OpenAI",
kind="openai",
api_key=encrypt(os.environ["OPENAI_API_KEY"]),
models=catalog.OPENAI_MODELS,
default_model="gpt-5.2",
enabled=True,
)
)
await session.commit()
# --- Skill ---
existing_skills = {
s for (s,) in await session.execute(select(Skill.name))
}
for skill in SKILLS:
if skill["name"] not in existing_skills:
session.add(Skill(**skill))
await session.commit()
# --- 模板工作流 ---
from app.engine.layout import auto_layout
existing = {n for (n,) in await session.execute(select(Workflow.name))}
added = 0
for template in TEMPLATES:
if template["name"] in existing:
continue
spec = auto_layout(
GraphSpec.model_validate(
{"nodes": template["nodes"], "edges": template["edges"]}
)
)
graph = spec.model_dump(mode="json")
workflow = Workflow(
name=template["name"],
description=template["description"],
graph=graph,
tags=template["tags"],
is_template=True,
)
session.add(workflow)
await session.flush()
session.add(
WorkflowVersion(workflow_id=workflow.id, version=1, graph=graph, note="内置模板")
)
added += 1
if added:
await session.commit()
logger.info("已导入 %d 个内置模板", added)