mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:41:58 +00:00
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 并注明理由
191 lines
7.1 KiB
Python
191 lines
7.1 KiB
Python
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
|