Files
AgentLab/backend/app/engine/nodes/control.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

191 lines
7.1 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 json
from typing import Any
from app.core.events import EventType
from app.db.base import SessionLocal
from app.engine.context import NodeContext, NodeError
from app.engine.expressions import ExpressionError, eval_condition
from app.engine.state import GraphState, message_text, template_context
from app.providers.factory import ModelSpec, get_chat_model
# 分支和循环的"决定"写在 nodes[node_id] 里,编译器的路由函数再读出来。
# 路由函数本身不能有副作用,所以判断必须发生在节点执行阶段。
DECISION_KEY = "__decision__"
async def run_branch(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
"""条件分支。两种模式:表达式判断,或让模型做语义分类。"""
cases = ctx.cfg("cases", []) or []
if not cases:
raise NodeError(ctx.node.id, "「条件分支」节点没有配置任何条件")
mode = ctx.cfg("mode", "expression")
tctx = template_context(state)
chosen = "default"
reason = ""
if mode == "llm":
chosen, reason = await _classify_with_llm(state, ctx, cases)
else:
for case in cases:
expr = case.get("condition", "")
key = case.get("key") or case.get("label") or "case"
if not expr:
continue
try:
if eval_condition(expr, tctx):
chosen, reason = key, f"命中条件:{expr}"
break
except ExpressionError as e:
raise NodeError(ctx.node.id, f"分支条件有误({expr}):{e}") from e
else:
reason = "所有条件都不满足,走「其他」出口"
ctx.emit(EventType.EDGE_TAKEN, branch=chosen, reason=reason, mode=mode)
return {
"nodes": {ctx.node.id: {DECISION_KEY: chosen, "branch": chosen, "reason": reason}},
}
async def _classify_with_llm(
state: GraphState, ctx: NodeContext, cases: list[dict[str, Any]]
) -> tuple[str, str]:
"""让模型在给定选项里挑一个。用结构化输出保证它只会吐出合法的 key。"""
options = [
{"key": c.get("key") or f"case{i}", "描述": c.get("label") or c.get("condition") or ""}
for i, c in enumerate(cases)
]
keys = [o["key"] for o in options] + ["default"]
subject = ctx.render_str(ctx.cfg("input", "{{ last_message }}"), state)
instruction = ctx.render_str(ctx.cfg("instruction", ""), state)
prompt = (
f"{instruction}\n\n" if instruction else ""
) + (
"请把下面的内容归入最合适的一类。\n\n"
f"可选类别:\n{json.dumps(options, ensure_ascii=False, indent=2)}\n\n"
f"内容:\n{subject}\n\n"
"只返回 JSON:{\"key\": <类别key>, \"reason\": <一句话理由>}"
)
async with SessionLocal() as session:
model, _ = await get_chat_model(
session,
ModelSpec(
provider=ctx.cfg("provider"),
model=ctx.cfg("model"),
max_tokens=512,
),
)
schema = {
"title": "classify", # langchain 靠 title 识别 JSON Schema dict
"type": "object",
"properties": {
"key": {"type": "string", "enum": keys},
"reason": {"type": "string"},
},
"required": ["key"],
}
try:
value = await model.with_structured_output(schema).ainvoke(prompt)
if not isinstance(value, dict):
value = json.loads(message_text(value))
except Exception: # noqa: BLE001 - 结构化不支持就退回自由文本再解析
raw = message_text(await model.ainvoke(prompt))
value = {}
for key in keys:
if key in raw:
value = {"key": key, "reason": raw[:200]}
break
chosen = str(value.get("key") or "default")
if chosen not in keys:
chosen = "default"
return chosen, str(value.get("reason", ""))
async def run_loop(state: GraphState, ctx: NodeContext) -> dict[str, Any]:
"""循环控制。
画布上的接法:loop 节点的 `body` 出口连向循环体,循环体最后一个节点再连回 loop 节点;
`done` 出口连向循环结束后要走的分支。每进一次这个节点就推进一次计数。
"""
mode = ctx.cfg("mode", "foreach")
node_id = ctx.node.id
loops = dict(state.get("loops") or {})
prev = loops.get(node_id) if isinstance(loops.get(node_id), dict) else {}
# 上次已经走过 done 出口还又进来:是外层循环的下一轮把它重新带进来,从头数。
# 以前计数原样留着接着往上加,内层的 max_iterations 于是成了整次运行合计的
# 上限——运行 87daca70 的内层每拍该跑两个子拍,第四拍起一进来就撞上限直接
# 退出;嵌套的 foreach 更糟,外层第二轮起内层一项都不跑,也不报错
cursor = 0 if prev.get("finished") else int(prev.get("index", 0))
max_iter = int(ctx.cfg("max_iterations", 10) or 10)
tctx = template_context(state)
item_var = ctx.cfg("item_var", "item")
updates_vars: dict[str, Any] = {}
keep_going = False
current_item: Any = None
total = None
if mode == "foreach":
items = ctx.render(ctx.cfg("items", ""), state)
if isinstance(items, str):
try:
items = json.loads(items)
except json.JSONDecodeError:
items = [line for line in items.splitlines() if line.strip()]
if not isinstance(items, list):
items = [items] if items not in (None, "") else []
total = len(items)
keep_going = cursor < len(items) and cursor < max_iter
if keep_going:
current_item = items[cursor]
updates_vars = {item_var: current_item, f"{item_var}_index": cursor}
else: # while
condition = ctx.cfg("condition", "")
if cursor >= max_iter:
keep_going = False
elif not condition:
keep_going = False
else:
try:
keep_going = eval_condition(condition, tctx)
except ExpressionError as e:
raise NodeError(ctx.node.id, f"循环条件有误:{e}") from e
hit_limit = cursor >= max_iter
if hit_limit:
ctx.emit(
EventType.LOG, level="warn",
message=f"循环已达上限 {max_iter} 次,已强制结束", code="loop_limit",
)
decision = "body" if keep_going else "done"
ctx.emit(
EventType.EDGE_TAKEN,
branch=decision,
iteration=cursor,
total=total,
mode=mode,
)
result = {
DECISION_KEY: decision,
"iteration": cursor,
"total": total,
"item": current_item,
"finished": not keep_going,
}
updates: dict[str, Any] = {
"nodes": {node_id: result},
# 走完了 index 留在最后一轮,读 loops.x.index 的照旧拿到轮数;finished 让下次进来重数
"loops": {node_id: {"index": cursor + 1 if keep_going else cursor, "finished": not keep_going}},
}
if updates_vars:
updates["vars"] = updates_vars
return updates