Files
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

165 lines
7.0 KiB
Python
Raw Permalink 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.
"""循环跑满自己声明的轮数,不被全局步数上限半路掐断。
开发库里的「测试 1」:while 循环 `vars.state.count <= 1000`,max_iterations 配了
1005。每轮是「循环节点 + 循环体」两步,而整次运行的步数上限(max_graph_steps)
是 200——跑到 100 轮上下 LangGraph 抛 GraphRecursionError,报错是一段英文,
叫人去调一个界面上根本碰不到的 recursion_limit。校验是通过的:这张图没写错,
是平台自己的两个上限在打架。
全局上限防的是没人把关的环;loop 节点的 max_iterations 是作者明说的轮数上限。
所以循环按自己的轮数另记一份预算(loop_steps),全局上限照旧管其余的环。
"""
from __future__ import annotations
import asyncio
import pytest
from app.core.config import settings
from app.db.base import SessionLocal
from app.db.models import Run
from app.engine.runner import run_manager
from app.engine.schema import GraphSpec, loop_steps
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "data": {"label": nid, "config": config}}
def edge(source, target, handle=None):
return {"source": source, "target": target, "sourceHandle": handle}
def counting_loop(rounds: int, cap: int) -> dict:
"""「测试 1」的骨架:while 数到 rounds,循环体一个节点。"""
return {
"nodes": [
node("start", "input", fields=[]),
node("init", "transform", expression="0", assign_to="n"),
node("lp", "loop", mode="while", condition=f"vars.n < {rounds}", max_iterations=cap),
node("inc", "transform", expression="vars.n + 1", assign_to="n"),
node("out", "output", fields=[{"name": "结果", "value": "{{ vars.n }}"}]),
],
"edges": [edge("start", "init"), edge("init", "lp"), edge("lp", "inc", "body"),
edge("inc", "lp"), edge("lp", "out", "done")],
}
def test_no_loop_no_extra_budget():
spec = GraphSpec.model_validate({
"nodes": [node("a", "input"), node("b", "output")], "edges": [edge("a", "b")]})
assert loop_steps(spec) == 0
def test_budget_covers_the_declared_rounds():
# 1005 轮 × 每轮 2 步,外加收尾的一次判定
assert loop_steps(GraphSpec.model_validate(counting_loop(1000, 1005))) >= 1005 * 2 + 1
def test_nested_loops_multiply():
"""内层循环每一轮外层都要从头跑一遍,预算得乘起来,不是加起来。"""
spec = GraphSpec.model_validate({
"nodes": [node("start", "input"),
node("outer", "loop", mode="foreach", items="[1,2,3]", max_iterations=3),
node("inner", "loop", mode="foreach", items="[1,2,3,4]", max_iterations=4),
node("work", "transform", expression="1"),
node("out", "output")],
"edges": [edge("start", "outer"), edge("outer", "inner", "body"),
edge("inner", "work", "body"), edge("work", "inner"),
edge("inner", "outer", "done"), edge("outer", "out", "done")],
})
# 实际:外层 4 次判定 + 3 轮 ×(内层 5 次判定 + 4 次干活)= 31 步
assert loop_steps(spec) >= 31
def test_nodes_after_the_loop_are_not_charged_per_round():
"""done 出口之后的节点只跑一次,不该按轮数记账——否则预算虚高,等于没有上限。"""
spec = GraphSpec.model_validate(counting_loop(3, 3))
assert loop_steps(spec) == (3 + 1) * 2 # 只有 lp 和 inc
@pytest.fixture
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
async def _finish(graph) -> Run:
run = await run_manager.start(graph=graph, input_payload={})
for _ in range(600):
async with SessionLocal() as session:
row = await session.get(Run, run.id)
if row.status in ("succeeded", "failed", "cancelled", "interrupted"):
return row
await asyncio.sleep(0.05)
raise AssertionError(f"没跑完:{row.status}")
async def test_a_loop_runs_past_the_global_step_limit(engine_up, monkeypatch):
"""全局上限压到 20 步,循环照样跑满它声明的 30 轮。"""
monkeypatch.setattr(settings, "max_graph_steps", 20)
row = await _finish(counting_loop(30, 35))
assert row.status == "succeeded", row.error
assert row.output["结果"] == "30"
async def test_an_unguarded_cycle_still_stops_and_says_why(engine_up, monkeypatch):
"""分支连回上游、没有 loop 节点把关的环,照旧由全局上限拦住——报错得是人话。"""
monkeypatch.setattr(settings, "max_graph_steps", 12)
row = await _finish({
"nodes": [node("start", "input", fields=[]),
node("t", "transform", expression="1"),
node("br", "branch", cases=[{"key": "again", "condition": "true"}]),
node("out", "output", fields=[])],
"edges": [edge("start", "t"), edge("t", "br"), edge("br", "t", "again"),
edge("br", "out", "default")],
})
assert row.status == "failed"
assert "已执行 12 步" in row.error and "「循环」节点" in row.error, row.error
assert "recursion_limit" not in row.error
# --------------------------------------------------------------------------
# 子图:自己的循环自己算预算;撞上限时报子图自己的名字和步数
# --------------------------------------------------------------------------
async def _parent_of(sub_graph: dict, name: str) -> dict:
from app.db.models import Workflow
async with SessionLocal() as session:
wf = Workflow(name=name, graph=sub_graph)
session.add(wf)
await session.commit()
wf_id = wf.id
return {
"nodes": [node("start", "input", fields=[]),
node("sub", "subgraph", workflow_id=wf_id),
node("out", "output", fields=[{"name": "结果", "value": "{{ nodes.sub.output.结果 }}"}])],
"edges": [edge("start", "sub"), edge("sub", "out")],
}
async def test_a_loop_inside_a_subgraph_gets_its_own_budget(engine_up, monkeypatch):
monkeypatch.setattr(settings, "max_graph_steps", 20)
row = await _finish(await _parent_of(counting_loop(30, 35), "数到 30"))
assert row.status == "succeeded", row.error
assert row.output["结果"] == "30"
async def test_an_unguarded_cycle_inside_a_subgraph_says_which_one(engine_up, monkeypatch):
monkeypatch.setattr(settings, "max_graph_steps", 12)
spinning = {
"nodes": [node("start", "input", fields=[]),
node("t", "transform", expression="1"),
node("br", "branch", cases=[{"key": "again", "condition": "true"}]),
node("out", "output", fields=[])],
"edges": [edge("start", "t"), edge("t", "br"), edge("br", "t", "again"),
edge("br", "out", "default")],
}
row = await _finish(await _parent_of(spinning, "空转的子图"))
assert row.status == "failed"
assert "子工作流「空转的子图」已执行 12 步" in row.error, row.error
assert "recursion_limit" not in row.error