Files
AgentLab/backend/app/seed.py
T
ThearchyHelios 03c18c4d78 界面文案整改:口语改书面、术语统一、不再露出实现细节;修掉几处说错的提示;新增文案检查防回潮
一、文案(前端约 1600 处,后端传到界面的约 880 处)
- 术语统一:工作流层面用「运行」(继续运行、重新运行、试运行),节点和工具层面用「执行」,不再用「跑」;
  总时长 / 执行时长 / 等待审批;服务端;助手;Agent;token;工作流;固定版本;模型接入;系统提示;批准 / 驳回;受管级别
- 口语改书面:取不到、没取回来、读不懂、连不上、多半、照样、兜底、揉成这类说法改成「无法 / 未能 / 失败 / 请」;去掉拟人写法
- 不再露出实现细节:界面上不写 checkpoint、同一拍、哈希(凭证处除外),不写改版历史(「以前…」「老数据」);
  报错、校验、门禁、自动修复里裸写的配置键名和枚举值换成界面上的中文标签,由新的 backend/app/engine/labels.py 统一提供;
  发布弹窗里结论句的选项不再是 off / require_citation
- 同一件事只留一种说法:生成阶段文案、出具档位说明、画布锁定提示各合成一份
- 去掉界面上的工业场景示例(MES、ERP、「张工」「王工」)和真实产品名,换成通用示例

二、说错的提示(都有测试证明)
- 数据库测连接失败时的专用提示以前从不出现(按报错文字全等比较,永远比不上):改为按错误类型判断,不再拼出重复的前缀
- 「设置 → 供应商」改指「设置 → 模型接入」;「在设置里建数据源」改指「数据」页
- 缺少 API Key、模型接入已停用的报错不再夹英文,写明去哪里填
- 两处原因说错(「这种引用在后续版本支持」「这一版还不支持」)改成真实原因
- 节点说明里不准确的说法改正:代码节点在 microVM 或系统沙箱中执行;报告撰写、口径卡的说明不再说过头
- 导出的证据 CSV 里两列同名,第三列改为「片段编号」

三、前后端对齐
- 前端按后端原文识别日志、报错的地方,新旧两种写法都认得(历史运行里存的是旧文字),逐条补了新旧原文的核对
- 后端交回模型的原指令放进单独的 for_model 字段,前端在解析响应时去掉,不会显示到界面上

四、检查
- 检查脚本里按原文匹配的断言改成新文案;改完会变成永远成立的反向断言逐条修正,并做了变异验证
- 新增 check-copy(静态扫描前端界面字符串和后端上界面的文案):再出现「跑」「后端」「多半」「令牌」、
  界面上的 Copilot、裸写的配置键名、半角标点等就报错;确需保留的写进 scripts/copy-allow.json 并注明理由
2026-09-30 08:05:15 +08:00

447 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": "用口径卡计算指标、按出具契约三档出具的示例:取数固定,报告只能引用指标清单中的数字,"
"出具时逐一核对,无法核对的数字会被标出并降档出具。",
"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": "报告中的每个数字都可追溯出处:「调用工具」节点查询数据,口径卡登记指标,报告撰写节点只写"
"引用标记,数字由系统填入,成果节点按引用逐段核对后三档出具。使用前请接入名为 shop 的数据源(含 orders "
"表的 week、amount、refunded 列),或上传同名表格,再按你的口径修改 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"},
]),
# 受管出具要求报告撰写节点写明最严的三项(发布门禁 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": "对列表逐项处理后汇总,演示「循环」节点「循环体」和「结束」两个出口的连法。",
"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="演示模型(无需 API 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)