Files
AgentLab/backend/app/seed.py
T
ThearchyHelios d071bf119f 可点击证据第三期:表名字段名和引文也点得开、可疑实体与结论句、受管门禁 G1–G5、证据审计导出
一、表结构、实体与引文

- 数据源工具查库时顺带存一份表结构快照(schema_snapshot),和查询快照一起进台账、进封存;
  台账里的查询记下 SQL 用到的表
- 报告里的表名、字段名可以点:反引号里的名字一律核对,正文里的裸名字只在明显是标识符时才自动链接,
  常见英文单词不链接;点开看到它出现在哪次查询、类型、所属的表,快照被截断时写明
- 找不到的名字标成可疑实体:在快照、SQL、结果列里都没有的记「可能是编造的名字」并给出最接近的 3 个;
  快照被截断、核对不了的记「核对不了」,不冤枉成编造
- 检索引文 [[q:K1|原文]]:原文在检索快照的命中片段里逐字出现(空白归一化后)才算有出处,点开高亮原文位置
- 自动链接的名字不算句子的引用,只有写作者写的引用才算

二、结论句与出具

- 报告节点新增 claims:off(默认,保持原样)或 require_citation(没有引用的结论句计入缺口)
- 受管级别的正式运行里,可疑实体和没有引用的结论句按「有缺口」降档,不拦截;探索运行和已发布级别只标注
- 契约的 claims 和报告节点的设置取更严的那个;受管正式运行里写 ignore 也按降档处理,并给出警告
- 出具横幅除了数字,也写出可疑实体和没有引用的结论句

三、受管门禁 G1–G5(只看图和配置)

- G1 带契约的出口,成果字段的文字只能来自报告撰写节点
- G2 模型写的文字不绕过报告撰写节点直接流进出口
- G3 报告撰写节点写明 numbers: strict、on_violation: fail、claims: require_citation
- G4 口径卡的输入不来自计算角色的沙箱代码
- G5 给口径卡供数的 agent 配 output_schema 并开 cite_fields
- 受管级别是错误、挡住发布;已发布级别只给警告;已经发布的版本照常运行,下次发布时按新规则检查
- 每条规则都接进发布前自动修复(一键修复 / 给选项 / 交给 Copilot);禁止的改动补上关 cite_fields、
  删 output_schema、放宽契约 claims;Copilot 兜底不许自己把代码节点标成 source、不许放宽报告节点
- 发布弹窗里选「交给 Copilot」时明确写「正在交给 Copilot」

四、旧运行与审计

- 没有契约的旧答案:数字和已封存的查询单元格、口径卡值按数值比对,每个数最多 3 个候选,
  默认折叠,写明「猜测的来源,不能当证据」
- 有契约的旧运行:候选只从已封存的口径卡里找,人为插进工件表的行不当证据
- 证据审计:GET /api/runs/{id}/evidence/audit 按「无证据 / 可疑实体 / 有出处 / 旧运行猜测」分组,
  可导出 JSON 和 CSV(带封存状态);记录页和画布上的报告节点显示「有出处 N · 无证据 M」

五、SQL 守卫修正(安全)

- 去注释改成单遍扫描,认得字符串和各方言的引号(单引号转义、MySQL 反斜杠与 #、PostgreSQL 的 $$ 与嵌套注释、
  Oracle 的 q'[…]'、SQLite 的方括号);以前字符串里的 -- 会被当成注释,SELECT '--'; DROP … 这类写法能骗过多语句拦截
- 看不到服务器设置时每种读法都扫一遍,读法之间切分不一致就拒绝,任何一种读法是写操作就按写操作处理
- 执行查询和工具危险判定都按数据源的方言检查

六、其他

- 修掉检查脚本里画布检查的一处竞态,并行估时按实测更新
2026-09-29 05:26:34 +08:00

450 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 换成你的口径。报告撰写节点按受管要求写明了 on_violation: fail 和 claims: "
"require_citation:探索运行里修不掉的数字违规也会让报告节点失败,而不只是标出来;正式运行里没挂引用"
"的结论句计入缺口、按档降级。",
"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"},
]),
# 受管出具要求报告撰写节点写明最严的三项(发布门禁 G3),模板照着写,拿来就能发布成受管
_n("write", "report", "报告撰写", metrics_from=["caliber"],
numbers="strict", on_violation="fail", claims="require_citation",
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)