mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:01:58 +00:00
发布前修复点名的模型接入:按执行时的取法算修完落到哪里,只写了模型而接入已停用的也拦下
- 「不指定接入」以前只去掉接入、留下同一处写着的模型,复核又只查点名的接入:按模型名落回停用的接入,或者成员跟随节点、节点跟随全图默认,拿别家的接入去调这个模型。检查、发布都通过,正式运行在那一处照样失败 - 现在每个候选(不指定、改用某个启用的接入)先在副本上改,按执行器的取法(成员先看自己、再看节点、再看全图默认;节点看全图默认;都没写按模型名找;核查和裁判整组交给设置)算出改动波及的每一处用哪个接入、哪个模型。落到的接入不提供那一处的模型、按模型名落到停用接入或认不出的模型、落到内置演示模型的,连同一级写着的模型一并去掉再算,还落不稳的候选不给;候选说明写清执行时改用哪里、模型去没去掉、换上的接入实际用哪个模型 - 门禁把哪一级都没写接入、只写了模型而提供它的接入已停用的那一处也记成 provider.disabled(落在写模型的字段,原因和运行时同一句):复核拒得掉这类修复,检查器里只能选模型的节点发布前也查得出来;这一处配「不指定模型」或改用一个启用接入的修复 - 按模型名找接入、没写时退回哪个接入抽成 factory.provider_for_model、fallback_provider,运行时和门禁共用一份 - 问题面板、修复预览写得出「模型」「成员「x」的模型」;check-publish 的伪造门禁跟上新的候选,补只写模型的一段 - 测试:复核发现的几种形状逐一钉住(含发布前检查修好、保存、发布后正式运行成功的端到端一条);依赖旧做法的两条测试改成新的正确行为
This commit is contained in:
@@ -427,9 +427,9 @@ async def _sql_checkers(session: AsyncSession, spec: GraphSpec) -> dict[str, Any
|
||||
|
||||
|
||||
async def _providers(session: AsyncSession, spec: GraphSpec) -> list[Any]:
|
||||
"""模型接入表:节点点名的接入不存在或已停用时,发布、发布前检查、自动修复的复核给 error、挡住发布,修复从启用的
|
||||
接入里列候选(engine/provider_refs.py、autofix 的 provider.*)。和 _sql_checkers 一样由接口这一层读好交给门禁,
|
||||
门禁本身不查库;三处传同一份。图里没点名任何接入时不读库。"""
|
||||
"""模型接入表:节点点名的接入不存在或已停用、只写了模型而提供它的接入已停用时,发布、发布前检查、自动修复的复核
|
||||
给 error、挡住发布,修复从启用的接入里列候选(engine/provider_refs.py、autofix 的 provider.*)。和 _sql_checkers
|
||||
一样由接口这一层读好交给门禁,门禁本身不查库;三处传同一份。图里取模型的几处都没写接入和模型时不读库。"""
|
||||
return await load_providers(session, spec)
|
||||
|
||||
|
||||
@@ -522,7 +522,8 @@ class ValidateIn(BaseModel):
|
||||
async def validate(payload: ValidateIn, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
|
||||
"""画布上的实时校验。前端每次改动都会调它来标红。
|
||||
|
||||
点名的模型接入不存在或已停用只给警告:探索运行仍可发起,执行到那个节点时照实报错;发布时才挡住。"""
|
||||
点名的模型接入不存在或已停用(只写了模型、提供它的接入已停用也算)只给警告:探索运行仍可发起,执行到那个节点时
|
||||
照实报错;发布时才挡住。"""
|
||||
try:
|
||||
spec = GraphSpec.model_validate(payload.graph)
|
||||
except Exception as e: # noqa: BLE001
|
||||
|
||||
@@ -462,7 +462,7 @@ def _plan_not_object(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -
|
||||
return _assist(f"「{_title(node)}」的出具契约不是一个 JSON 对象", "contract")
|
||||
|
||||
|
||||
#: 「不指定接入」那个候选的值:去掉这一处点名的接入
|
||||
#: 「不指定接入」(只写了模型的那一处是「不指定模型」)那个候选的值:去掉这一处写的接入(模型)
|
||||
PROVIDER_CLEAR = ""
|
||||
|
||||
|
||||
@@ -506,44 +506,109 @@ def _model_path(path: str) -> str:
|
||||
return path[: -len("provider")] + "model"
|
||||
|
||||
|
||||
def _serves(provider: Any, model: str) -> bool:
|
||||
models = [m.get("id") for m in getattr(provider, "models", None) or [] if isinstance(m, dict)]
|
||||
return model in models or model == getattr(provider, "default_model", None)
|
||||
def _landing_text(landed: list[tuple[Any, Any]]) -> str:
|
||||
"""修完以后执行时改用哪里(provider_refs.land 的说法):各处落到同一处只说一次,落到不同处逐处说。"""
|
||||
groups: dict[str, list[str]] = {}
|
||||
for use, landing in landed:
|
||||
groups.setdefault(landing.where, []).append(use.who)
|
||||
if len(groups) == 1:
|
||||
return f"改用{next(iter(groups))}"
|
||||
return ";".join(f"{'、'.join(whos)}改用{where}" for where, whos in groups.items())
|
||||
|
||||
|
||||
def _plan_provider(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None:
|
||||
"""点名的模型接入不存在或已停用(engine/provider_refs.py):改用一个启用的接入,或者不再指定。
|
||||
"""执行时取不到模型的那一处(engine/provider_refs.py):改用一个启用的接入,或者不再指定。点名的接入不存在、已停用
|
||||
的,候选是「不指定接入」和各个启用的接入;只写了模型、提供它的接入已停用的,是「不指定模型」和各个启用的接入
|
||||
(写在模型旁边的接入上)。
|
||||
|
||||
要人选、不给默认:换成哪个接入只有人知道(同一个模型可能有两家提供,价格、配额都不同)。多数节点的检查器里没有
|
||||
接入下拉框(只能选模型),全图默认、协作成员也没有,这里是界面上唯一能改它的地方。同一处写着模型、选的接入
|
||||
又不提供它时,模型一并去掉、改用那个接入的默认模型——拿 A 家的模型名去调 B 家必然失败(设置页换接入时同一个
|
||||
做法),候选的说明里写清。一处一条(plan_fixes 的 id 带字段):协作节点自己的接入和成员的接入各修各的。
|
||||
接入下拉框(只能选模型),全图默认、协作成员也没有,这里是界面上唯一能改它的地方。一处一条(plan_fixes 的 id
|
||||
带字段):协作节点自己的接入和成员的接入各修各的。
|
||||
|
||||
每个候选先在副本上改,按执行时的取法(provider_refs.land)算出改动波及的每一处落到哪里:去掉以后成员跟随节点、
|
||||
节点跟随全图默认,都没写按模型名找;协作节点、全图默认换了接入,没写自己接入的成员、节点跟着换。落到的接入不提供
|
||||
那一处的模型、按模型名落到停用的接入或认不出的模型时,同一级写着的模型一并去掉再算——拿 A 家的模型名去调 B 家
|
||||
必然失败(设置页换接入时同一个做法);还落不稳的候选不给,修完了运行照样失败的修复不能交给人。候选的说明写清执行
|
||||
时改用哪里、模型去没去掉。复核(judge)另把一道关:门禁同样查只写了模型、提供它的接入已停用的那一处。
|
||||
"""
|
||||
from app.engine.provider_refs import provider_refs
|
||||
from app.engine.provider_refs import land, provider_refs, uses
|
||||
from app.providers.factory import serves
|
||||
|
||||
field = str(issue.get("field") or "")
|
||||
ref = next((r for r in provider_refs(ctx.spec) if r.node_id == issue.get("node_id") and r.field == field), None)
|
||||
if ref is None:
|
||||
return None
|
||||
path = field.removeprefix("defaults.") if ref.node_id is None else field
|
||||
if ref.kind == "model":
|
||||
model_path, provider_path = path, path[: -len("model")] + "provider"
|
||||
else:
|
||||
provider_path, model_path = path, _model_path(path)
|
||||
prefix = "defaults." if ref.node_id is None else ""
|
||||
root = ctx.spec.defaults if ref.node_id is None else (node.config if node is not None else {})
|
||||
model = _read_path(root, _model_path(path))
|
||||
model = _read_path(root, model_path)
|
||||
model = model.strip() if isinstance(model, str) and model.strip() else None
|
||||
options: list[dict[str, Any]] = [{"value": PROVIDER_CLEAR, "label": "不指定接入",
|
||||
"hint": "去掉这一处指定的接入,按上一级的设置或模型名找接入"}]
|
||||
drop: list[str] = []
|
||||
before = {use.key: use for use in uses(ctx.spec)}
|
||||
|
||||
def trial(edits: list[tuple[str, Any]]) -> list[tuple[Any, Any]] | None:
|
||||
"""在副本上改,改动波及的每一处(取法变了的)执行时落到哪里;有一处落不稳返回 None。"""
|
||||
spec = ctx.spec.model_copy(deep=True)
|
||||
target = spec.defaults if ref.node_id is None else spec.node_map()[ref.node_id].config
|
||||
for at, value in edits:
|
||||
_put_path(target, at, value)
|
||||
landed = []
|
||||
for use in uses(spec):
|
||||
if before.get(use.key) == use:
|
||||
continue
|
||||
if not (landing := land(use, ctx.providers)).ok:
|
||||
return None
|
||||
landed.append((use, landing))
|
||||
return landed
|
||||
|
||||
def settle(edits: list[tuple[str, Any]]) -> tuple[list[tuple[str, Any]], list[tuple[Any, Any]]] | None:
|
||||
"""先只改这一处;落不稳而同一级写着模型时,连模型一并去掉再试。返回(改法,波及的各处落到哪里),还落不稳为 None。"""
|
||||
if (landed := trial(edits)) is not None:
|
||||
return edits, landed
|
||||
if model and all(at != model_path for at, _ in edits):
|
||||
edits = [*edits, (model_path, None)]
|
||||
if (landed := trial(edits)) is not None:
|
||||
return edits, landed
|
||||
return None
|
||||
|
||||
options: list[dict[str, Any]] = []
|
||||
plans: dict[str, list[tuple[str, Any]]] = {}
|
||||
if (got := settle([(model_path if ref.kind == "model" else provider_path, None)])) is not None:
|
||||
edits, landed = got
|
||||
what = "模型" if ref.kind == "model" else "接入" + (f"和模型「{model}」" if len(edits) > 1 else "")
|
||||
options.append({"value": PROVIDER_CLEAR, "label": f"不指定{'模型' if ref.kind == 'model' else '接入'}",
|
||||
"hint": f"去掉这一处指定的{what},执行时{_landing_text(landed)}"})
|
||||
plans[PROVIDER_CLEAR] = edits
|
||||
for p in ctx.providers:
|
||||
if not getattr(p, "enabled", False) or not getattr(p, "name", None):
|
||||
if not getattr(p, "enabled", False) or not getattr(p, "name", None) or p.name in plans:
|
||||
continue
|
||||
if (got := settle([(provider_path, p.name)])) is None:
|
||||
continue
|
||||
edits, landed = got
|
||||
notes = []
|
||||
if model and not _serves(p, model):
|
||||
drop.append(p.name)
|
||||
default = getattr(p, "default_model", None)
|
||||
notes.append(f"不提供模型「{model}」,将改用它的默认模型" + (f"「{default}」" if default else ""))
|
||||
if len(edits) > 1:
|
||||
# 这一处原来用的就是去掉的那个模型:换上的接入实际用哪个模型(多半是它的默认模型,上一级写了模型就是那个)
|
||||
mine = {landing.model for use, landing in landed
|
||||
if (was := before.get(use.key)) is not None and was.model is not None
|
||||
and (was.model.node_id, was.model.field) == (ref.node_id, prefix + model_path)}
|
||||
used = next(iter(mine)) if len(mine) == 1 else None
|
||||
said = [f"不提供模型「{model}」" if not serves(p, model) else f"模型「{model}」一并去掉"]
|
||||
if used:
|
||||
said.append(f"将改用{'它的默认模型' if used == p.default_model else '模型'}「{used}」")
|
||||
elif not serves(p, model):
|
||||
said.append("这一处的模型一并去掉")
|
||||
notes.append(",".join(said))
|
||||
if getattr(p, "kind", None) == "mock":
|
||||
notes.append("演示用的模型接入,不调用真实模型")
|
||||
options.append({"value": p.name, "label": p.name, "hint": ";".join(notes) or None})
|
||||
return _choice(f"选择{ref.who}改用的模型接入", field, options, _path=path, _drop_model=drop)
|
||||
plans[p.name] = edits
|
||||
if not options:
|
||||
# 一个都落不稳(比如一个启用的接入都没有):不给修复,问题照样拦着发布,说明里写了去设置里启用
|
||||
return None
|
||||
return _choice(f"选择{ref.who}改用的模型接入", field, options, _edits=plans)
|
||||
|
||||
|
||||
_PLANNERS: dict[str, Callable[[_Ctx, dict[str, Any], GraphNode | None], dict[str, Any] | None]] = {
|
||||
@@ -759,8 +824,8 @@ def _edit_judge_budget(graph: dict[str, Any], fix: dict[str, Any], value: Any) -
|
||||
|
||||
|
||||
def _edit_provider(graph: dict[str, Any], fix: dict[str, Any], value: Any) -> list[tuple[str | None, str, Any, Any]]:
|
||||
"""点名的模型接入(provider.*):落在节点上改节点配置,落在全图默认上改 defaults(节点 id 记 None,字段写全路径
|
||||
defaults.x)。选「不指定接入」去掉这一项;选的接入不提供同一处写着的模型时,模型一并去掉。"""
|
||||
"""点名的模型接入(provider.*):照规划时为选中的候选算好的改法写(_plan_provider 的 _edits:写上或去掉接入、
|
||||
连带去掉的模型)。落在节点上改节点配置,落在全图默认上改 defaults(节点 id 记 None,字段写全路径 defaults.x)。"""
|
||||
if fix["node_id"] is None:
|
||||
if not isinstance(graph.get("defaults"), dict):
|
||||
graph["defaults"] = {}
|
||||
@@ -770,12 +835,11 @@ def _edit_provider(graph: dict[str, Any], fix: dict[str, Any], value: Any) -> li
|
||||
if node is None:
|
||||
return []
|
||||
root, prefix = _config(node), ""
|
||||
path = str(fix.get("_path") or "")
|
||||
after = value if isinstance(value, str) and value != PROVIDER_CLEAR else None
|
||||
edits: list[tuple[str | None, str, Any, Any]] = [(fix["node_id"], prefix + path, _put_path(root, path, after), after)]
|
||||
if after is not None and after in (fix.get("_drop_model") or ()):
|
||||
if (model := _put_path(root, _model_path(path), None)) is not None:
|
||||
edits.append((fix["node_id"], prefix + _model_path(path), model, None))
|
||||
edits: list[tuple[str | None, str, Any, Any]] = []
|
||||
for path, after in (fix.get("_edits") or {}).get(value) or ():
|
||||
before = _put_path(root, path, after)
|
||||
if before is not None or after is not None:
|
||||
edits.append((fix["node_id"], prefix + path, before, copy.deepcopy(after)))
|
||||
return edits
|
||||
|
||||
|
||||
|
||||
@@ -45,8 +45,8 @@ def lint_for_publish(spec: GraphSpec, *, level: str, checkers: Mapping[str, Any]
|
||||
门禁本身不查库。给了的话,调用工具节点里写死的 SQL 对照目录查一遍(_lint_sql)。
|
||||
|
||||
providers 是模型接入表(provider_refs.load_providers),同样由调用方读好传进来。给了的话,节点点名的接入
|
||||
不存在或已停用的每一处一条 error(provider.missing / provider.disabled),两档都挡:正式运行到那里一定失败,
|
||||
或者少一块核查。不给(一键升级、助手自查这类只看图本身的地方)就不查。
|
||||
不存在或已停用、只写了模型而提供它的接入已停用的每一处一条 error(provider.missing / provider.disabled),
|
||||
两档都挡:正式运行到那里一定失败,或者少一块核查。不给(一键升级、助手自查这类只看图本身的地方)就不查。
|
||||
"""
|
||||
result = ValidationResult()
|
||||
strict = level == "governed"
|
||||
|
||||
@@ -4,19 +4,23 @@
|
||||
就该看得到:画布校验给警告(探索运行仍可发起,执行到那里报错),发布、发布前检查、自动修复的复核给错误、挡住
|
||||
发布——正式运行到那里一定失败,或者少一块核查。
|
||||
|
||||
查的是执行时真会去取模型的那几处,取法和执行器一致:
|
||||
查的是执行时真会去取模型的那几处,取法和执行器一致(uses):
|
||||
- 模型调用、Agent、报告撰写、多 Agent 协作的接入;条件分支只在「让模型分类」时、结构校验只在「让模型修复」且允许
|
||||
重试时(max_retries 为 0 一次都不修)、知识检索只在「让模型重排」时才取模型。这几类节点没写自己的接入时取全图
|
||||
默认(NodeContext.cfg);
|
||||
- 多 Agent 协作各成员的接入;报告撰写交给模型裁判结论句时,「结论句裁判」里的接入(节点没写裁判配置时取全图默认
|
||||
里的那一份,同 nodes/report.report_judge);
|
||||
- 双盲复算、反方审稿的接入(核查模型):只读节点自己的配置,不取全图默认。节点自己的开关关着时执行器直接跳过、
|
||||
不取模型,不查;设置里的总开关不看——它随时会被打开,打开时这一处就会失败;
|
||||
- 全图默认的接入:有节点会取到它时才查。
|
||||
重试时(max_retries 为 0 一次都不修)、知识检索只在「让模型重排」时才取模型。这几类节点没写自己的接入、模型时
|
||||
各自取全图默认(NodeContext.cfg);
|
||||
- 多 Agent 协作各成员:自己没写的接入、模型各自跟随节点(再到全图默认,nodes/multi._member);报告撰写交给模型
|
||||
裁判结论句时,「结论句裁判」里的接入、模型(节点没写裁判配置时取全图默认里的那一份,同 nodes/report.report_judge);
|
||||
- 双盲复算、反方审稿的接入、模型(核查模型):只读节点自己的配置,不取全图默认。节点自己的开关关着时执行器直接
|
||||
跳过、不取模型,不查;设置里的总开关不看——它随时会被打开,打开时这一处就会失败;
|
||||
- 全图默认的接入、模型:有节点会取到它时才查。
|
||||
取到了接入的,查点名的那个接入(不存在、已停用);哪一级都没写接入、只写了模型的,按模型名找接入,只有停用的接入
|
||||
提供它时同样是问题(provider.disabled,落在写模型的那一处)——运行时 resolve_provider 报的就是这一句,而多数节点的
|
||||
检查器里只能选模型,这是最常见的写法。核查、裁判的接入、模型都没写时交给设置,不归这里查。
|
||||
|
||||
说法分两种:检查器里有接入下拉框的几处(双盲复算、反方审稿、节点自己的结论句裁判)说「在节点中换一个接入」;
|
||||
别处(节点自己的接入、协作成员、全图默认)界面上没有下拉框,说「在发布前检查中改用其他接入」——发布前检查给这类
|
||||
问题配了修复(engine/autofix.py 的 provider.*:改用一个启用的接入,或不再指定)。
|
||||
说法分两种:检查器里有这一项下拉框的几处(双盲复算、反方审稿、节点自己的结论句裁判的接入和模型,几类节点自己的模型)
|
||||
说「在节点中换一个接入 / 模型」;别处(节点自己的接入、协作成员、全图默认)界面上没有下拉框,说「在发布前检查中改用
|
||||
其他接入」——发布前检查给这类问题配了修复(engine/autofix.py 的 provider.*:改用一个启用的接入,或不再指定),修复
|
||||
按 land 算出修完以后执行时落到哪里,落不稳的候选不给。
|
||||
|
||||
接入表要读库,门禁本身不查库(governance.lint_for_publish),所以由接口这一层读出接入表交进来,同数据目录
|
||||
SQL 检查器的做法。设置里点名接入的几项由 api/settings.py 在保存时查。
|
||||
@@ -30,12 +34,19 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.engine.labels import field_label, type_label
|
||||
from app.engine.schema import CLAIMS_JUDGE, GraphNode, GraphSpec, NodeType, ValidationIssue
|
||||
from app.providers.factory import NAMED_FIX, ProviderNotConfigured, list_providers, named_provider
|
||||
from app.providers.factory import (
|
||||
NAMED_FIX, ProviderNotConfigured, fallback_provider, list_providers, named_provider, provider_for_model, serves,
|
||||
)
|
||||
|
||||
#: 这类问题的编号:provider.missing(点名的接入不存在)、provider.disabled(已停用)
|
||||
#: 这类问题的编号:provider.missing(点名的接入不存在)、provider.disabled(已停用;只写了模型、提供它的接入停用了也是它)
|
||||
CODES = frozenset({"provider.missing", "provider.disabled"})
|
||||
#: 提示的后半句:这一处在检查器里有没有接入下拉框
|
||||
_WHERE = {True: "或在节点中换一个接入", False: "或在「发布前检查」中改用其他接入"}
|
||||
#: 提示的后半句:这一处写的是接入还是模型、检查器里有没有这一项的下拉框
|
||||
_WHERE = {("provider", True): "或在节点中换一个接入", ("model", True): "或在节点中换一个模型",
|
||||
("provider", False): "或在「发布前检查」中改用其他接入", ("model", False): "或在「发布前检查」中改用其他接入"}
|
||||
#: 检查器里有模型下拉框的几类节点(前端 nodeDefs 的 MODEL_FIELDS;条件分支只在「让模型分类」时显示,也只在那时才查)
|
||||
_MODEL_PICKER = frozenset({NodeType.LLM, NodeType.AGENT, NodeType.SUPERVISOR, NodeType.BRANCH, NodeType.REPORT})
|
||||
#: 核查、裁判的接入和模型都没写时执行用设置里的哪一项(api/settings.verify_model_spec、judge.judge_model_spec)
|
||||
_SETTINGS = {"verify": "设置中的核查模型", "judge": "设置中的裁判模型"}
|
||||
|
||||
|
||||
def _cfg(spec: GraphSpec, node: GraphNode, key: str, default: Any = None) -> Any:
|
||||
@@ -75,80 +86,166 @@ def _name(value: Any) -> str | None:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderRef:
|
||||
"""图里点名接入的一处:落在哪个节点(全图默认为 None)、哪个字段,点的名字,句子里怎么称呼这一处,
|
||||
检查器里有没有这一处的接入下拉框。"""
|
||||
"""图里写着接入或模型的一处:落在哪个节点(全图默认为 None)、哪个字段,写的名字,句子里怎么称呼这一处,检查器里
|
||||
有没有这一项的下拉框;kind 是写的接入(provider)还是模型(model)。"""
|
||||
|
||||
node_id: str | None
|
||||
field: str
|
||||
name: str
|
||||
who: str
|
||||
editable: bool = False
|
||||
kind: str = "provider"
|
||||
|
||||
|
||||
def provider_refs(spec: GraphSpec) -> list[ProviderRef]:
|
||||
"""图里执行时会用到的接入点名,按画布上节点的顺序,全图默认排最后。"""
|
||||
out: list[ProviderRef] = []
|
||||
falls_back = False
|
||||
default_judge = False
|
||||
def _place(node_id: str | None, field: str, value: Any, who: str, *, editable: bool = False,
|
||||
kind: str = "provider") -> ProviderRef | None:
|
||||
name = _name(value)
|
||||
return ProviderRef(node_id, field, name, who, editable, kind) if name else None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Use:
|
||||
"""执行时取一次模型:key 是(节点 id,哪一次:node、agents[i]、judge、verify),who 是句子里怎么称呼它;接入、模型
|
||||
各取自哪一处(哪一级都没写为 None)。settings 是核查、裁判的接入和模型都没写、交给设置的那一项(verify / judge)。"""
|
||||
|
||||
key: tuple[str, str]
|
||||
who: str
|
||||
provider: ProviderRef | None
|
||||
model: ProviderRef | None
|
||||
settings: str | None = None
|
||||
|
||||
|
||||
def _own_or_default(spec: GraphSpec, node: GraphNode, key: str, who: str) -> ProviderRef | None:
|
||||
"""节点的 provider / model 取自哪一处,同 NodeContext.cfg:节点上没写(None、空串)就取全图默认。"""
|
||||
if node.config.get(key) in (None, ""):
|
||||
return _place(None, f"defaults.{key}", spec.defaults.get(key), field_label("defaults"), kind=key)
|
||||
return _place(node.id, key, node.config.get(key), who, kind=key,
|
||||
editable=key == "model" and node.type in _MODEL_PICKER)
|
||||
|
||||
|
||||
def uses(spec: GraphSpec) -> list[Use]:
|
||||
"""图里执行时取模型的每一处,按画布上节点的顺序;只算执行时真会取模型的(见模块说明)。"""
|
||||
out: list[Use] = []
|
||||
for node in spec.nodes:
|
||||
cfg = node.config
|
||||
who = f"「{node.title}」({type_label(node.type)})"
|
||||
if node.type in _VERIFY:
|
||||
if cfg.get("enabled") is not False and (name := _name(cfg.get("provider"))):
|
||||
out.append(ProviderRef(node.id, "provider", name, who + _VERIFY[node.type], editable=True))
|
||||
# 接入、模型哪一项写了就整组以节点为准,都没写交给设置(verify_model_spec)
|
||||
if cfg.get("enabled") is not False:
|
||||
vwho = who + _VERIFY[node.type]
|
||||
p = _place(node.id, "provider", cfg.get("provider"), vwho, editable=True)
|
||||
m = _place(node.id, "model", cfg.get("model"), vwho, editable=True, kind="model")
|
||||
out.append(Use((node.id, "verify"), vwho, p, m, None if p or m else "verify"))
|
||||
continue
|
||||
if node.type not in _TAKES_MODEL:
|
||||
continue
|
||||
provider, model = _own_or_default(spec, node, "provider", who), _own_or_default(spec, node, "model", who)
|
||||
cond = _TAKES_MODEL[node.type]
|
||||
if cond is None or cond(spec, node):
|
||||
if name := _name(cfg.get("provider")):
|
||||
out.append(ProviderRef(node.id, "provider", name, who))
|
||||
else:
|
||||
falls_back = True
|
||||
out.append(Use((node.id, "node"), who, provider, model))
|
||||
if node.type == NodeType.SUPERVISOR and isinstance(cfg.get("agents"), list):
|
||||
# 成员自己写了(非空)就用自己的,否则跟随节点,同 nodes/multi._member 的 `cfg.get(...) or ctx.cfg(...)`
|
||||
for i, member in enumerate(cfg["agents"]):
|
||||
if isinstance(member, dict) and (name := _name(member.get("provider"))):
|
||||
label = _name(member.get("name")) or f"第 {i + 1} 位"
|
||||
out.append(ProviderRef(node.id, f"agents[{i}].provider", name, f"{who}的成员「{label}」"))
|
||||
if not isinstance(member, dict):
|
||||
continue
|
||||
label = _name(member.get("name")) or f"第 {i + 1} 位"
|
||||
mwho = f"{who}的成员「{label}」"
|
||||
own_p, own_m = member.get("provider"), member.get("model")
|
||||
out.append(Use((node.id, f"agents[{i}]"), mwho,
|
||||
_place(node.id, f"agents[{i}].provider", own_p, mwho) if own_p else provider,
|
||||
_place(node.id, f"agents[{i}].model", own_m, mwho, kind="model") if own_m else model))
|
||||
if node.type == NodeType.REPORT and _cfg(spec, node, "claims", "off") == CLAIMS_JUDGE:
|
||||
judge = _cfg(spec, node, "judge")
|
||||
if judge is not cfg.get("judge"):
|
||||
# 节点没写裁判配置,执行时用全图默认里的那一份:问题记在全图默认上,几个报告共用时只记一条
|
||||
default_judge = True
|
||||
elif isinstance(judge, dict) and (name := _name(judge.get("provider"))):
|
||||
out.append(ProviderRef(node.id, "judge.provider", name, f"{who}的{field_label('judge')}",
|
||||
editable=True))
|
||||
if falls_back and (name := _name(spec.defaults.get("provider"))):
|
||||
out.append(ProviderRef(None, "defaults.provider", name, field_label("defaults")))
|
||||
judge = spec.defaults.get("judge")
|
||||
if default_judge and isinstance(judge, dict) and (name := _name(judge.get("provider"))):
|
||||
out.append(ProviderRef(None, "defaults.judge.provider", name,
|
||||
f"{field_label('defaults')}中的{field_label('judge')}"))
|
||||
# 节点没写裁判配置,执行时用全图默认里的那一份:问题记在全图默认上,几个报告共用时只记一条
|
||||
own = judge is cfg.get("judge")
|
||||
nid, at, jwho = (node.id, "judge", f"{who}的{field_label('judge')}") if own else \
|
||||
(None, "defaults.judge", f"{field_label('defaults')}中的{field_label('judge')}")
|
||||
judge = judge if isinstance(judge, dict) else {}
|
||||
p = _place(nid, f"{at}.provider", judge.get("provider"), jwho, editable=own)
|
||||
m = _place(nid, f"{at}.model", judge.get("model"), jwho, editable=own, kind="model")
|
||||
out.append(Use((node.id, "judge"), jwho, p, m, None if p or m else "judge"))
|
||||
return out
|
||||
|
||||
|
||||
def provider_refs(spec: GraphSpec) -> list[ProviderRef]:
|
||||
"""执行时要查的那几处,按画布上节点的顺序,全图默认排最后,同一处只记一次:取到了接入的是那个接入;哪一级都没写
|
||||
接入、只写了模型的是那个模型(按模型名找接入)。"""
|
||||
found: dict[tuple[str | None, str], ProviderRef] = {}
|
||||
for use in uses(spec):
|
||||
if (ref := use.provider or use.model) is not None:
|
||||
found.setdefault((ref.node_id, ref.field), ref)
|
||||
return sorted(found.values(), key=lambda r: r.node_id is None)
|
||||
|
||||
|
||||
def broken(refs: list[ProviderRef], providers: list[Any]) -> list[tuple[ProviderRef, ProviderNotConfigured]]:
|
||||
"""点名的接入不存在或已停用的那几处,带着 named_provider 报的错(code 是 missing / disabled)。"""
|
||||
"""执行时会报错的那几处,带着运行时报的那个错(code 是 missing / disabled):点名的接入按名字找(named_provider),
|
||||
只写了模型的按模型名找(provider_for_model,认不出的模型名运行时放行,不算)。"""
|
||||
out: list[tuple[ProviderRef, ProviderNotConfigured]] = []
|
||||
for ref in refs:
|
||||
try:
|
||||
named_provider(providers, ref.name)
|
||||
if ref.kind == "model":
|
||||
provider_for_model(providers, ref.name)
|
||||
else:
|
||||
named_provider(providers, ref.name)
|
||||
except ProviderNotConfigured as e:
|
||||
out.append((ref, e))
|
||||
return out
|
||||
|
||||
|
||||
def issues(refs: list[ProviderRef], providers: list[Any], *, level: str) -> list[ValidationIssue]:
|
||||
"""点名的接入不存在或已停用的每一处一条问题。providers 是接入表(factory.list_providers),level 由调用方定:
|
||||
画布校验 warning,发布一类 error。原因和运行时报的同一句(named_provider),提示按这一处在界面上能不能改。"""
|
||||
"""执行时会报错的每一处一条问题。providers 是接入表(factory.list_providers),level 由调用方定:画布校验 warning,
|
||||
发布一类 error。原因和运行时报的同一句,提示按这一处在界面上能不能改。"""
|
||||
return [ValidationIssue(level=level, node_id=ref.node_id, field=ref.field, # type: ignore[arg-type]
|
||||
code=f"provider.{e.code}",
|
||||
message=f"{ref.who}指定的{e.reason}。{NAMED_FIX[e.code]},{_WHERE[ref.editable]}")
|
||||
message=f"{ref.who}指定的{e.reason}。{NAMED_FIX[e.code]},{_WHERE[ref.kind, ref.editable]}")
|
||||
for ref, e in broken(refs, providers)]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Landing:
|
||||
"""执行时这一处落到哪里:where 是说给人听的(「工作流默认设置指定的接入「甲」」……),model 是用哪个模型(交给设置时
|
||||
为 None),ok 是修复能不能把它落在这里。"""
|
||||
|
||||
where: str
|
||||
model: str | None
|
||||
ok: bool
|
||||
|
||||
|
||||
def land(use: Use, providers: list[Any]) -> Landing:
|
||||
"""按执行器的取法(get_chat_model → resolve_provider → build_chat_model)算这一处执行时用哪个接入、哪个模型。
|
||||
|
||||
ok 为真才算落得稳:点名的接入启用着、有模型可用,并且提供这一处写的模型——拿 A 家的模型名去调 B 家必然失败;
|
||||
只写了模型的,按模型名找到启用的接入。按模型名落到停用的接入(运行时报错)、模型名认不出来(落到第一个可用的
|
||||
接入,它多半不提供)、一个启用的接入都没有(悄悄换成内置演示模型)都不算。交给设置的(核查、裁判)算落得稳:
|
||||
设置保存时查过点名的接入,之后接入出了问题设置页会标出来。"""
|
||||
if use.settings:
|
||||
return Landing(_SETTINGS[use.settings], None, True)
|
||||
model = use.model.name if use.model is not None else None
|
||||
if use.provider is not None:
|
||||
try:
|
||||
found = named_provider(providers, use.provider.name)
|
||||
except ProviderNotConfigured as e:
|
||||
return Landing(e.reason, model, False)
|
||||
where = f"{use.provider.who}指定的接入「{found.name}」"
|
||||
elif model:
|
||||
try:
|
||||
found = provider_for_model(providers, model)
|
||||
except ProviderNotConfigured as e:
|
||||
return Landing(e.reason, model, False)
|
||||
if found is None:
|
||||
return Landing(f"认不出的模型「{model}」", model, False)
|
||||
where = f"提供模型「{model}」的接入「{found.name}」"
|
||||
else:
|
||||
found = fallback_provider(providers)
|
||||
if found is None:
|
||||
return Landing("内置演示模型", None, False)
|
||||
where = f"第一个可用的模型接入「{found.name}」"
|
||||
used = model or found.default_model
|
||||
return Landing(where, used, bool(used) and (model is None or serves(found, model)))
|
||||
|
||||
|
||||
async def load_issues(session: AsyncSession, spec: GraphSpec, *, level: str) -> list[ValidationIssue]:
|
||||
"""读接入表、查一遍。图里没点名任何接入时不读库。"""
|
||||
"""读接入表、查一遍。图里执行时取模型的几处都没写接入和模型时不读库。"""
|
||||
refs = provider_refs(spec)
|
||||
if not refs:
|
||||
return []
|
||||
@@ -156,6 +253,6 @@ async def load_issues(session: AsyncSession, spec: GraphSpec, *, level: str) ->
|
||||
|
||||
|
||||
async def load_providers(session: AsyncSession, spec: GraphSpec) -> list[Any] | None:
|
||||
"""发布、发布前检查、自动修复交给门禁的接入表(governance.lint_for_publish 的 providers)。图里没点名任何接入时
|
||||
不读库,给空表:没有要查的,修复也用不上候选。"""
|
||||
"""发布、发布前检查、自动修复交给门禁的接入表(governance.lint_for_publish 的 providers)。图里执行时取模型的几处
|
||||
都没写接入和模型时不读库,给空表:没有要查的,修复也用不上候选。"""
|
||||
return await list_providers(session) if provider_refs(spec) else []
|
||||
|
||||
@@ -68,6 +68,38 @@ def named_provider(providers: list[Provider], ref: Any) -> Provider:
|
||||
return found
|
||||
|
||||
|
||||
def serves(provider: Any, model: str) -> bool:
|
||||
"""这个接入提供不提供这个模型:模型列表里有,或者是它的默认模型(按模型名找接入的同一个认法)。"""
|
||||
return any((m or {}).get("id") == model for m in (provider.models or [])) or provider.default_model == model
|
||||
|
||||
|
||||
def provider_for_model(providers: list[Provider], model: str) -> Provider | None:
|
||||
"""没点名接入时按模型名找提供它的接入(providers 是 list_providers 的结果):先认模型列表、再认默认模型,只看启用的。
|
||||
|
||||
只有停用的接入提供它时抛 ProviderNotConfigured(disabled)——这时候不能默默换一家:拿着 A 家的模型名去调 B 家,
|
||||
必然 404,而且报错完全指不到真正的原因。认不出来的模型名返回 None:用户可能手填了一个目录里还没有的新模型,由
|
||||
调用方退回 fallback_provider。运行时取模型(resolve_provider)和画布校验、发布前检查(engine/provider_refs.py)
|
||||
按这一处认,说法一致。"""
|
||||
enabled = [p for p in providers if p.enabled]
|
||||
found = next((p for p in enabled if any((m or {}).get("id") == model for m in (p.models or []))), None) \
|
||||
or next((p for p in enabled if p.default_model == model), None)
|
||||
if found is not None:
|
||||
return found
|
||||
off = next((p for p in providers if not p.enabled and serves(p, model)), None)
|
||||
if off is not None:
|
||||
raise ProviderNotConfigured(f"模型「{model}」所属的模型接入「{off.name}」已停用",
|
||||
"请在「设置 → 模型接入」中启用它,或为节点换一个模型", code="disabled")
|
||||
return None
|
||||
|
||||
|
||||
def fallback_provider(providers: list[Provider]) -> Provider | None:
|
||||
"""没点名接入、也没写模型(或模型名认不出来)时用哪个:第一个启用的真实接入,没有就第一个启用的演示接入;一个启用
|
||||
的都没有返回 None(get_chat_model 换成内置演示模型)。"""
|
||||
enabled = [p for p in providers if p.enabled]
|
||||
real = [p for p in enabled if p.kind != "mock"]
|
||||
return (real or enabled or [None])[0]
|
||||
|
||||
|
||||
def assistant_unavailable(e: BaseException) -> str:
|
||||
"""助手(画布上改图、表格导入与数据目录的 AI 起草)用的模型取不到时给人看的那句话。
|
||||
|
||||
@@ -98,33 +130,14 @@ async def resolve_provider(
|
||||
设置里的数字一类)按点名了一个不存在的接入报,不当成没给。
|
||||
"""
|
||||
providers = await list_providers(session)
|
||||
enabled = [p for p in providers if p.enabled]
|
||||
|
||||
if ref is not None and (not isinstance(ref, str) or ref.strip()):
|
||||
return named_provider(providers, ref)
|
||||
|
||||
if model:
|
||||
for p in enabled:
|
||||
if any((m or {}).get("id") == model for m in (p.models or [])):
|
||||
return p
|
||||
for p in enabled:
|
||||
if p.default_model == model:
|
||||
return p
|
||||
# 模型认得出来,但它所属的 provider 被停用了。这时候不能默默换一家 ——
|
||||
# 拿着 A 家的模型名去调 B 家,必然 404,而且报错完全指不到真正的原因。
|
||||
for p in providers:
|
||||
if not p.enabled and (
|
||||
any((m or {}).get("id") == model for m in (p.models or []))
|
||||
or p.default_model == model
|
||||
):
|
||||
raise ProviderNotConfigured(
|
||||
f"模型「{model}」所属的模型接入「{p.name}」已停用",
|
||||
"请在「设置 → 模型接入」中启用它,或为节点换一个模型", code="disabled",
|
||||
)
|
||||
# 认不出来的模型名就放行:用户可能手填了一个目录里还没有的新模型
|
||||
|
||||
real = [p for p in enabled if p.kind != "mock"]
|
||||
return (real or enabled or [None])[0]
|
||||
# 模型认得出来、所属的接入却停用了:provider_for_model 照实报错,不默默换一家。认不出来的模型名放行
|
||||
if model and (found := provider_for_model(providers, model)) is not None:
|
||||
return found
|
||||
return fallback_provider(providers)
|
||||
|
||||
|
||||
def _resolve_key(provider: Provider) -> str | None:
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
挡住发布。只查执行时真会去取模型的那几处(分支「让模型分类」、校验「让模型修复」且允许重试、检索「让模型重排」
|
||||
时才算;核查节点自己的开关关着不算);报告没写裁判配置时查全图默认里的那一份;
|
||||
- 提示按这一处在界面上能不能改:检查器里有接入下拉框的说「在节点中换一个接入」,别处说「在发布前检查中改用」;
|
||||
- 发布前检查给每一处配一条要人选的修复:改用一个启用的接入,或不指定接入。
|
||||
- 发布前检查给每一处配一条要人选的修复:改用一个启用的接入,或不指定接入(修完以后执行时落到哪里、只写了模型而
|
||||
提供它的接入已停用的那一处,见 test_provider_ref_landing)。
|
||||
|
||||
接入名、节点名都是虚构的。
|
||||
"""
|
||||
@@ -426,11 +427,16 @@ async def test_each_broken_provider_gets_a_choice_to_switch_or_stop_naming_one(c
|
||||
("write", "provider", GONE, LIVE), ("write", "model", "核对旧模型", None)]
|
||||
assert provider_issues({"issues": out["remaining"]}) == []
|
||||
|
||||
# 不指定接入:哪一级都没写接入,按模型名找;「核对旧模型」没有哪个接入提供,执行时会落到第一个可用的接入去调它,
|
||||
# 所以模型一并去掉,说明里写清改用哪里(详见 test_provider_ref_landing)
|
||||
clear = fix["options"][0]
|
||||
assert clear["hint"].startswith("去掉这一处指定的接入和模型「核对旧模型」,执行时改用第一个可用的模型接入「"), clear
|
||||
out = await _fix(client, wf, graph, fix["id"], "")
|
||||
assert out["applied"] == [fix["id"]] and out["ok"] is True, out["rejected"]
|
||||
config = config_of(out["graph"], "write")
|
||||
assert "provider" not in config and config["model"] == "核对旧模型"
|
||||
assert [(c["field"], c["before"], c["after"]) for c in out["changes"]] == [("provider", GONE, None)]
|
||||
assert "provider" not in config and "model" not in config
|
||||
assert [(c["field"], c["before"], c["after"]) for c in out["changes"]] == [("provider", GONE, None),
|
||||
("model", "核对旧模型", None)]
|
||||
|
||||
|
||||
async def test_a_team_node_and_its_member_are_fixed_one_place_at_a_time(client):
|
||||
@@ -472,16 +478,18 @@ async def test_the_graph_default_provider_has_its_own_fix(client):
|
||||
(None, "defaults.provider", OFF, LIVE), (None, "defaults.model", "核对停用模型", None)]
|
||||
|
||||
|
||||
async def test_stop_naming_a_provider_is_refused_when_the_graph_default_is_broken_too(client):
|
||||
"""节点不再指定接入就改取全图默认:全图默认的接入也坏了,修完冒出新问题,这一项不采用并说清原因。"""
|
||||
async def test_stop_naming_a_provider_is_not_offered_when_the_graph_default_is_broken_too(client):
|
||||
"""节点不再指定接入就改取全图默认:全图默认的接入也坏了,修完照样失败,「不指定接入」这一项不给;直接把这个值发给
|
||||
接口也不采用。换成启用的接入照常给。"""
|
||||
graph = chain(node("start", "input"), node("ask", "llm", "提问", provider=GONE, prompt="总结"),
|
||||
node("out", "output", fields=[{"name": "结论", "value": "{{ last_message }}"}]),
|
||||
defaults={"provider": OFF})
|
||||
wf = await _workflow(graph)
|
||||
r = await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "published", "graph": graph})
|
||||
[issue] = provider_issues(r.json()) # 唯一的节点写了自己的接入,全图默认的没人用,不提
|
||||
[fix] = [f for f in r.json()["fixes"] if f["id"] == issue["fix"]]
|
||||
values = [o["value"] for o in fix["options"]]
|
||||
assert "" not in values and LIVE in values, values
|
||||
out = await _fix(client, wf, graph, issue["fix"], "")
|
||||
assert out["applied"] == []
|
||||
[(fid, reason)] = [(x["fix_id"], x["reason"]) for x in out["rejected"]]
|
||||
assert fid == issue["fix"] and reason.startswith("修改后出现了新的问题:工作流默认设置指定的模型接入"), reason
|
||||
assert out["applied"] == [] and [x["fix_id"] for x in out["rejected"]] == [issue["fix"]]
|
||||
assert config_of(out["graph"], "ask")["provider"] == GONE and out["changes"] == []
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
"""发布前修复点名的模型接入:修完以后执行时落到哪个接入,按执行器的取法算,落不稳的候选不给。
|
||||
|
||||
上一轮的「不指定接入」只去掉接入、留下同一处写着的模型,复核又只查点名的接入,于是检查、发布都通过,正式运行在那一处
|
||||
照样失败:
|
||||
- 节点点名停用的丙、模型写丙家的模型:去掉接入后按模型名找,找到的还是停用的丙;
|
||||
- 协作节点点名启用的甲、成员点名停用的丙并写丙家的模型:成员去掉接入后跟随节点,拿甲去调丙家的模型;
|
||||
- 节点点名丙并写丙家的模型、全图默认点名乙:同样拿乙去调丙家的模型;
|
||||
- 双盲复算、反方审稿、结论句裁判同理:按模型名落到停用的接入,记缺口或未裁判。
|
||||
|
||||
现在:
|
||||
- 门禁把「没有一级写接入、只写了模型,提供它的接入已停用」也记成 provider.disabled,落在写模型的那一处,原因和运行时
|
||||
resolve_provider 报的同一句:只写模型的节点(多数节点的检查器里只能选模型)发布前查得出来,复核也拒得掉这类修复;
|
||||
- 修复的每个候选先在副本上改,按执行时的取法算出波及的每一处落到哪里(成员先看自己、再看节点、再看全图默认;节点看
|
||||
全图默认;都没写按模型名找;核查、裁判整组交给设置)。落到的接入不提供这一处的模型、按模型名落到停用的接入或认不出
|
||||
的模型,就把同一级写着的模型一并去掉再算;还落不稳的候选不给。候选说明写清执行时改用哪里、模型去没去掉;
|
||||
- 只写了模型的那一处也有修复:改用一个启用的接入(模型换成它的),或不指定模型。
|
||||
|
||||
纯函数:接入表是虚构的几行,不读库。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine import autofix
|
||||
from app.engine.autofix import apply_fixes, check
|
||||
from app.engine.governance import publish_issues
|
||||
from app.engine.schema import GraphSpec
|
||||
|
||||
|
||||
def provider(name: str, *models: str, enabled: bool = True, kind: str = "openai_compatible") -> SimpleNamespace:
|
||||
return SimpleNamespace(id=f"pv-{name}", name=name, kind=kind, enabled=enabled,
|
||||
models=[{"id": m} for m in models], default_model=models[0] if models else None)
|
||||
|
||||
|
||||
#: 甲排在最前:没写接入也没写模型时落到它(第一个可用的接入)
|
||||
JIA = provider("甲接入", "甲一号", "甲二号")
|
||||
YI = provider("乙接入", "乙一号")
|
||||
BING = provider("丙接入", "丙一号", enabled=False)
|
||||
TABLE = [JIA, YI, BING]
|
||||
STOPPED = "所属的模型接入「丙接入」已停用"
|
||||
|
||||
|
||||
def node(nid, ntype, label=None, **config):
|
||||
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}}
|
||||
|
||||
|
||||
def flow(*middle, defaults=None) -> dict:
|
||||
nodes = [node("start", "input"), *middle,
|
||||
node("out", "output", fields=[{"name": "结论", "value": "{{ last_message }}"}])]
|
||||
graph = {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
|
||||
if defaults:
|
||||
graph["defaults"] = defaults
|
||||
return graph
|
||||
|
||||
|
||||
def provider_issues(issues) -> list[dict]:
|
||||
return [i for i in map(autofix._as_dict, issues) if str(i.get("code") or "").startswith("provider.")]
|
||||
|
||||
|
||||
def gate(graph: dict, table=TABLE) -> list[dict]:
|
||||
return provider_issues(publish_issues(GraphSpec.model_validate(graph), level="published", providers=table))
|
||||
|
||||
|
||||
def fix_of(graph: dict, fid: str, table=TABLE) -> dict | None:
|
||||
found = check(GraphSpec.model_validate(graph), level="published", providers=table)
|
||||
return next((f for f in found["fixes"] if f["id"] == fid), None)
|
||||
|
||||
|
||||
def hints(fix: dict) -> dict:
|
||||
return {o["value"]: o.get("hint") for o in fix["options"]}
|
||||
|
||||
|
||||
def apply(graph: dict, choices: dict, table=TABLE) -> dict:
|
||||
return apply_fixes(graph, list(choices), choices, level="published", providers=table)
|
||||
|
||||
|
||||
def config_of(graph: dict, nid: str) -> dict:
|
||||
return next(n for n in graph["nodes"] if n["id"] == nid)["data"]["config"]
|
||||
|
||||
|
||||
def changes(out: dict) -> list[tuple]:
|
||||
return [(c["node_id"], c["field"], c["before"], c["after"]) for c in out["changes"]]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 门禁:只写了模型、提供它的接入已停用
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_node_naming_only_a_model_of_a_disabled_provider_is_stopped_before_publishing():
|
||||
"""检查器里多数节点只能选模型:选的是停用接入的模型,运行时 resolve_provider 报「所属的模型接入已停用」,发布前就该看到。"""
|
||||
[issue] = gate(flow(node("ask", "llm", "提问", model="丙一号", prompt="总结")))
|
||||
assert (issue["level"], issue["code"], issue["node_id"], issue["field"]) == (
|
||||
"error", "provider.disabled", "ask", "model")
|
||||
assert issue["message"] == ("「提问」(模型调用)指定的模型「丙一号」所属的模型接入「丙接入」已停用。"
|
||||
"请在「设置 → 模型接入」中启用它,或在节点中换一个模型")
|
||||
# 启用的接入提供它;模型名认不出来(手填的新模型,运行时退回第一个可用的接入);同一处点名了启用的接入(以点名的为准,
|
||||
# 不按模型名找):都不提
|
||||
for config in ({"model": "甲二号"}, {"model": "自填的新模型"}, {"model": "丙一号", "provider": "甲接入"}):
|
||||
assert gate(flow(node("ask", "llm", "提问", prompt="总结", **config))) == [], config
|
||||
|
||||
|
||||
def test_the_problem_is_recorded_once_where_the_model_is_written():
|
||||
"""全图默认里的模型几个节点共用时只记一条;协作成员、全图默认里的模型在检查器里改不了,提示指到发布前检查。
|
||||
节点点名了启用的接入时,跟随它的成员、取全图默认模型的节点以点名的为准,不提。"""
|
||||
graph = flow(node("a", "llm", "提问", prompt="总结"), node("b", "agent", "查数", prompt="查数"),
|
||||
node("team", "supervisor", "协作", provider="甲接入", goal="分析",
|
||||
agents=[{"name": "分析师", "model": "丙一号", "system": "分析"}]),
|
||||
node("team2", "supervisor", "协作二", goal="分析",
|
||||
agents=[{"name": "研究员", "model": "丙一号", "system": "研究"}]),
|
||||
defaults={"model": "丙一号"})
|
||||
got = [(i["node_id"], i["field"], i["message"]) for i in gate(graph)]
|
||||
tail = "请在「设置 → 模型接入」中启用它,或在「发布前检查」中改用其他接入"
|
||||
assert got == [
|
||||
("team2", "agents[0].model", f"「协作二」(多 Agent 协作)的成员「研究员」指定的模型「丙一号」{STOPPED}。{tail}"),
|
||||
(None, "defaults.model", f"工作流默认设置指定的模型「丙一号」{STOPPED}。{tail}"),
|
||||
]
|
||||
|
||||
|
||||
def test_verify_nodes_and_the_judge_naming_only_a_model_of_a_disabled_provider_are_checked_too():
|
||||
"""双盲复算、反方审稿、结论句裁判只写了模型时按模型名找接入(整组以节点为准,不交给设置)。"""
|
||||
graph = flow(node("write", "report", "写周报", instructions="写周报", provider="甲接入", claims="judge",
|
||||
judge={"model": "丙一号"}),
|
||||
node("dual", "dual_check", "复核销售额", model="丙一号"),
|
||||
node("chal", "challenge", "审稿", report="write", model="丙一号"))
|
||||
got = {(i["node_id"], i["field"]): i["message"] for i in gate(graph)}
|
||||
assert set(got) == {("write", "judge.model"), ("dual", "model"), ("chal", "model")}
|
||||
assert got[("dual", "model")] == (f"「复核销售额」(双盲复算)的盲算模型指定的模型「丙一号」{STOPPED}。"
|
||||
"请在「设置 → 模型接入」中启用它,或在节点中换一个模型")
|
||||
assert got[("write", "judge.model")].startswith(f"「写周报」(报告撰写)的结论句裁判指定的模型「丙一号」{STOPPED}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 「不指定接入」:按执行时的取法算出去掉以后落到哪里
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_stop_naming_a_disabled_provider_drops_the_model_only_it_provides_too():
|
||||
"""复核发现的原样:节点点名停用的丙、模型写丙家的。只去掉接入的话,按模型名找到的还是停用的丙。"""
|
||||
graph = flow(node("ask", "llm", "提问", provider="丙接入", model="丙一号", prompt="总结"))
|
||||
fid = "provider.disabled:ask:provider"
|
||||
assert hints(fix_of(graph, fid))[""] == "去掉这一处指定的接入和模型「丙一号」,执行时改用第一个可用的模型接入「甲接入」"
|
||||
out = apply(graph, {fid: ""})
|
||||
assert out["applied"] == [fid] and out["ok"] is True, out["rejected"]
|
||||
assert config_of(out["graph"], "ask") == {"prompt": "总结"}
|
||||
assert changes(out) == [("ask", "provider", "丙接入", None), ("ask", "model", "丙一号", None)]
|
||||
assert gate(out["graph"]) == []
|
||||
|
||||
# 写的模型有启用的接入提供:只去掉接入,按模型名落到提供它的那一家,模型留着
|
||||
graph = flow(node("ask", "llm", "提问", provider="丙接入", model="乙一号", prompt="总结"))
|
||||
assert hints(fix_of(graph, fid))[""] == "去掉这一处指定的接入,执行时改用提供模型「乙一号」的接入「乙接入」"
|
||||
out = apply(graph, {fid: ""})
|
||||
assert out["ok"] is True and config_of(out["graph"], "ask") == {"prompt": "总结", "model": "乙一号"}, out["rejected"]
|
||||
|
||||
|
||||
def test_a_member_that_stops_naming_a_provider_follows_its_team_node():
|
||||
"""成员去掉接入后跟随节点点名的甲:甲不提供成员写的模型,模型一并去掉(不然拿甲去调丙家的模型);甲提供的就留着。"""
|
||||
graph = flow(node("team", "supervisor", "协作", provider="甲接入", goal="分析",
|
||||
agents=[{"name": "分析师", "provider": "丙接入", "model": "丙一号", "system": "分析"},
|
||||
{"name": "复核员", "provider": "丙接入", "model": "甲二号", "system": "复核"}]))
|
||||
first, second = "provider.disabled:team:agents[0].provider", "provider.disabled:team:agents[1].provider"
|
||||
assert hints(fix_of(graph, first))[""] == ("去掉这一处指定的接入和模型「丙一号」,"
|
||||
"执行时改用「协作」(多 Agent 协作)指定的接入「甲接入」")
|
||||
assert hints(fix_of(graph, second))[""] == "去掉这一处指定的接入,执行时改用「协作」(多 Agent 协作)指定的接入「甲接入」"
|
||||
out = apply(graph, {first: "", second: ""})
|
||||
assert out["applied"] == [first, second] and out["ok"] is True, out["rejected"]
|
||||
assert config_of(out["graph"], "team")["agents"] == [{"name": "分析师", "system": "分析"},
|
||||
{"name": "复核员", "model": "甲二号", "system": "复核"}]
|
||||
|
||||
|
||||
def test_stop_naming_a_provider_under_a_graph_default_drops_a_model_the_default_does_not_provide():
|
||||
graph = flow(node("ask", "llm", "提问", provider="丙接入", model="丙一号", prompt="总结"), defaults={"provider": "乙接入"})
|
||||
fid = "provider.disabled:ask:provider"
|
||||
assert hints(fix_of(graph, fid))[""] == ("去掉这一处指定的接入和模型「丙一号」,"
|
||||
"执行时改用工作流默认设置指定的接入「乙接入」")
|
||||
out = apply(graph, {fid: ""})
|
||||
assert out["ok"] is True and config_of(out["graph"], "ask") == {"prompt": "总结"}, out["rejected"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ntype,extra", [("dual_check", {}), ("challenge", {"report": "write"})])
|
||||
def test_a_verify_node_that_stops_naming_a_provider_goes_to_the_settings_not_a_disabled_provider(ntype, extra):
|
||||
"""核查节点只写了模型时按模型名找接入:丙家的模型留着就又落到停用的丙。连模型一起去掉,整组交给设置里的核查模型。"""
|
||||
graph = flow(node("write", "report", "写周报", instructions="写周报", provider="甲接入"),
|
||||
node("v", ntype, "核查", provider="丙接入", model="丙一号", **extra))
|
||||
fid = "provider.disabled:v:provider"
|
||||
assert hints(fix_of(graph, fid))[""] == "去掉这一处指定的接入和模型「丙一号」,执行时改用设置中的核查模型"
|
||||
out = apply(graph, {fid: ""})
|
||||
assert out["applied"] == [fid] and gate(out["graph"]) == [], out["rejected"]
|
||||
assert config_of(out["graph"], "v") == extra
|
||||
|
||||
|
||||
def test_a_judge_that_stops_naming_a_provider_goes_to_the_settings_not_a_disabled_provider():
|
||||
graph = flow(node("write", "report", "写周报", instructions="写周报", provider="甲接入", claims="judge",
|
||||
judge={"provider": "丙接入", "model": "丙一号", "max_claims": 5}))
|
||||
fid = "provider.disabled:write:judge.provider"
|
||||
assert hints(fix_of(graph, fid))[""] == "去掉这一处指定的接入和模型「丙一号」,执行时改用设置中的裁判模型"
|
||||
out = apply(graph, {fid: ""})
|
||||
assert out["applied"] == [fid] and gate(out["graph"]) == [], out["rejected"]
|
||||
assert config_of(out["graph"], "write")["judge"] == {"max_claims": 5}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 换接入:跟着这一处的别处也要落得稳
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_switch_that_would_send_another_place_a_model_it_does_not_provide_is_not_offered():
|
||||
"""协作节点换接入,没写接入的成员跟着换:成员自己写的乙家模型甲不提供,「改用甲」不给;「不指定接入」各处分别
|
||||
落到哪里写清。"""
|
||||
graph = flow(node("team", "supervisor", "协作", provider="丙接入", model="丙一号", goal="分析",
|
||||
agents=[{"name": "分析师", "model": "乙一号", "system": "分析"}]))
|
||||
got = hints(fix_of(graph, "provider.disabled:team:provider"))
|
||||
assert set(got) == {"", "乙接入"}
|
||||
assert got["乙接入"] == "不提供模型「丙一号」,将改用它的默认模型「乙一号」"
|
||||
assert got[""] == ("去掉这一处指定的接入和模型「丙一号」,执行时「协作」(多 Agent 协作)改用第一个可用的模型接入「甲接入」;"
|
||||
"「协作」(多 Agent 协作)的成员「分析师」改用提供模型「乙一号」的接入「乙接入」")
|
||||
|
||||
# 全图默认同理:自己写了乙家模型的节点跟着换到甲,甲不提供
|
||||
graph = flow(node("a", "llm", "提问", prompt="总结"), node("b", "llm", "复述", model="乙一号", prompt="复述"),
|
||||
defaults={"provider": "丙接入"})
|
||||
fid = "provider.disabled:graph:defaults.provider"
|
||||
got = hints(fix_of(graph, fid))
|
||||
assert set(got) == {"", "乙接入"} and got["乙接入"] is None
|
||||
assert got[""] == ("去掉这一处指定的接入,执行时「提问」(模型调用)改用第一个可用的模型接入「甲接入」;"
|
||||
"「复述」(模型调用)改用提供模型「乙一号」的接入「乙接入」")
|
||||
out = apply(graph, {fid: "乙接入"})
|
||||
assert out["ok"] is True and out["graph"]["defaults"] == {"provider": "乙接入"}, out["rejected"]
|
||||
|
||||
|
||||
def test_switching_names_the_model_actually_used_when_a_higher_level_writes_one():
|
||||
"""成员换到甲、自己的丙家模型去掉以后,模型跟随节点写的甲二号,不是甲的默认模型:说明照实写。"""
|
||||
graph = flow(node("team", "supervisor", "协作", model="甲二号", goal="分析",
|
||||
agents=[{"name": "分析师", "provider": "丙接入", "model": "丙一号", "system": "分析"}]))
|
||||
got = hints(fix_of(graph, "provider.disabled:team:agents[0].provider"))
|
||||
assert got["甲接入"] == "不提供模型「丙一号」,将改用模型「甲二号」"
|
||||
|
||||
|
||||
def test_no_choice_lands_on_the_built_in_demo_model():
|
||||
"""一个启用的接入都没有:去掉接入就会悄悄换成内置演示模型,这样的修复不给(问题照样拦着发布)。"""
|
||||
graph = flow(node("ask", "llm", "提问", provider="丙接入", model="丙一号", prompt="总结"))
|
||||
found = check(GraphSpec.model_validate(graph), level="published", providers=[BING])
|
||||
[issue] = provider_issues(found["issues"])
|
||||
assert issue["fix"] is None and found["ok"] is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 只写了模型的那一处:改用一个启用的接入,或不指定模型
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_place_naming_only_a_model_gets_a_choice_of_provider_or_no_model():
|
||||
graph = flow(node("ask", "llm", "提问", model="丙一号", prompt="总结"))
|
||||
fid = "provider.disabled:ask:model"
|
||||
fix = fix_of(graph, fid)
|
||||
assert (fix["kind"], fix["field"], fix["label"]) == ("choice", "model", "选择「提问」(模型调用)改用的模型接入")
|
||||
assert [o["label"] for o in fix["options"]] == ["不指定模型", "甲接入", "乙接入"]
|
||||
got = hints(fix)
|
||||
assert got[""] == "去掉这一处指定的模型,执行时改用第一个可用的模型接入「甲接入」"
|
||||
assert got["乙接入"] == "不提供模型「丙一号」,将改用它的默认模型「乙一号」"
|
||||
out = apply(graph, {fid: "乙接入"})
|
||||
assert out["ok"] is True and config_of(out["graph"], "ask") == {"prompt": "总结", "provider": "乙接入"}, out["rejected"]
|
||||
assert changes(out) == [("ask", "provider", None, "乙接入"), ("ask", "model", "丙一号", None)]
|
||||
out = apply(graph, {fid: ""})
|
||||
assert out["ok"] is True and config_of(out["graph"], "ask") == {"prompt": "总结"}, out["rejected"]
|
||||
|
||||
|
||||
def test_a_graph_default_model_is_fixed_once_for_every_node_that_uses_it():
|
||||
graph = flow(node("a", "llm", "提问", prompt="总结"), node("b", "agent", "查数", prompt="查数"),
|
||||
defaults={"model": "丙一号"})
|
||||
fid = "provider.disabled:graph:defaults.model"
|
||||
out = apply(graph, {fid: "甲接入"})
|
||||
assert out["ok"] is True and out["graph"]["defaults"] == {"provider": "甲接入"}, out["rejected"]
|
||||
assert changes(out) == [(None, "defaults.provider", None, "甲接入"), (None, "defaults.model", "丙一号", None)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 复核照样把关
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_review_refuses_a_fix_that_leaves_a_place_on_a_disabled_provider(monkeypatch):
|
||||
"""修复规则自己只去掉接入(上一轮的做法)时,门禁查出那一处按模型名落到停用的丙,整条丢弃、说清原因。"""
|
||||
def only_the_provider(graph, fix, value):
|
||||
config = autofix._node(graph, fix["node_id"])["data"]["config"]
|
||||
return [(fix["node_id"], "provider", config.pop("provider"), None)]
|
||||
|
||||
monkeypatch.setitem(autofix._EDITORS, "provider.disabled", only_the_provider)
|
||||
graph = flow(node("ask", "llm", "提问", provider="丙接入", model="丙一号", prompt="总结"))
|
||||
out = apply(graph, {"provider.disabled:ask:provider": ""})
|
||||
assert out["applied"] == [] and out["changes"] == [] and out["graph"] == graph
|
||||
[rejected] = out["rejected"]
|
||||
assert rejected["reason"] == (f"修改后出现了新的问题:「提问」(模型调用)指定的模型「丙一号」{STOPPED}。"
|
||||
"请在「设置 → 模型接入」中启用它,或在节点中换一个模型")
|
||||
@@ -565,3 +565,44 @@ async def test_a_scheduled_formal_run_naming_a_broken_provider_fails_and_is_stil
|
||||
async with SessionLocal() as session:
|
||||
await session.execute(delete(Schedule).where(Schedule.id == sid))
|
||||
await session.commit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 发布前检查里修好的图,正式运行不再失败在那一处
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_after_the_publish_fix_stops_naming_a_disabled_provider_the_formal_run_succeeds(monkeypatch, client):
|
||||
"""复核发现的原样:节点点名停用的接入、模型写它家的。以前发布前检查里选「不指定接入」只去掉接入,检查、保存、发布都
|
||||
通过,正式运行按模型名找到的还是那个停用的接入,照样失败。现在模型一并去掉,候选说明写清执行时改用哪个接入,正式
|
||||
运行就用它。"""
|
||||
called = script(monkeypatch)
|
||||
spec = chain(node("start", "input"),
|
||||
node("ask", "llm", "写日报", provider=DISABLED, model="停用演示模型", prompt="写今天的门店日报"),
|
||||
node("out", "output", fields=[{"name": "日报", "value": "{{ last_message }}"}]))
|
||||
async with SessionLocal() as session:
|
||||
wf = Workflow(name=f"门店日报-{uuid.uuid4().hex[:6]}", graph=spec, version=1)
|
||||
session.add(wf)
|
||||
await session.flush()
|
||||
session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=spec))
|
||||
await session.commit()
|
||||
wid = wf.id
|
||||
r = await client.post(f"/api/workflows/{wid}/publish-check", json={"level": "published"})
|
||||
[issue] = [i for i in r.json()["issues"] if i.get("code") == "provider.disabled"]
|
||||
[fix] = [f for f in r.json()["fixes"] if f["id"] == issue["fix"]]
|
||||
[clear] = [o for o in fix["options"] if o["value"] == ""]
|
||||
# 没点名接入、也没写模型时落到第一个可用的接入:本文件只建了一个启用的接入
|
||||
assert clear["hint"] == f"去掉这一处指定的接入和模型「停用演示模型」,执行时改用第一个可用的模型接入「{WRITER_PROVIDER}」"
|
||||
r = await client.post(f"/api/workflows/{wid}/autofix", json={"level": "published", "apply": [fix["id"]],
|
||||
"choices": {fix["id"]: ""}})
|
||||
fixed = r.json()
|
||||
assert fixed["applied"] == [fix["id"]] and fixed["ok"] is True, fixed["rejected"]
|
||||
assert (await client.patch(f"/api/workflows/{wid}", json={"graph": fixed["graph"]})).status_code == 200
|
||||
r = await client.post(f"/api/workflows/{wid}/publish", json={"level": "published"})
|
||||
assert r.json()["ok"] is True, r.text
|
||||
r = await client.post("/api/runs", json={"workflow_id": wid, "run_class": "formal"})
|
||||
assert r.status_code == 201, r.text
|
||||
run = await wait(r.json()["id"])
|
||||
assert run.status == "succeeded" and run.run_class == "formal", run.error
|
||||
assert called == [WRITER_MODEL]
|
||||
no_mock(await events(run.id), run.output)
|
||||
|
||||
@@ -617,9 +617,13 @@ function UpgradeValue({ value, field, nameOf, className, ...data }: {
|
||||
|
||||
const SUB_LABEL: Record<string, string> = {
|
||||
condition: '的条件', key: '的标识', tools: '的工具', system: '的系统提示', provider: PROVIDER_REF_TEXT.memberField,
|
||||
model: PROVIDER_REF_TEXT.memberModel,
|
||||
}
|
||||
/** 节点定义里没有、问题却会落上去的字段:点名的模型接入(多数节点的检查器里没有这一栏)。不写的话露出键名 */
|
||||
const FIELD_FALLBACK: Record<string, string> = { provider: PROVIDER_REF_TEXT.field }
|
||||
/**
|
||||
* 节点定义里没有、问题却会落上去的字段:点名的模型接入(多数节点的检查器里没有这一栏),以及只写了模型、提供它的接入
|
||||
* 已停用时的模型(知识检索、结构校验的检查器里没有模型这一栏)。不写的话露出键名
|
||||
*/
|
||||
const FIELD_FALLBACK: Record<string, string> = { provider: PROVIDER_REF_TEXT.field, model: PROVIDER_REF_TEXT.model }
|
||||
|
||||
function fieldLabel(field: FieldRef | null | undefined, type?: string, config?: Record<string, any>): string {
|
||||
if (!field) return ''
|
||||
|
||||
@@ -247,8 +247,8 @@ function normalizeIssue(raw: any): ValidationIssue | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* 点名的模型接入不存在或已停用(后端 engine/provider_refs.py)。一处一条问题、一条修复:同一个协作节点上自己的接入和
|
||||
* 成员的接入是两处,合并、找修复都要连字段一起认
|
||||
* 点名的模型接入不存在或已停用,或者只写了模型、提供它的接入已停用(后端 engine/provider_refs.py)。一处一条问题、
|
||||
* 一条修复:同一个协作节点上自己的接入和成员的接入是两处,合并、找修复都要连字段一起认
|
||||
*/
|
||||
export const isProviderRefCode = (code?: string | null): boolean => code === 'provider.missing' || code === 'provider.disabled'
|
||||
|
||||
@@ -397,8 +397,9 @@ const CONTRACT_KEYS: Record<string, string> = {
|
||||
/** 不在节点定义的字段表里、由检查器自己画的几项(子工作流钉的版本、升版处置),以及图级的全图默认 */
|
||||
const EXTRA_FIELDS: Record<string, string> = {
|
||||
workflow_version: '固定版本', upgrade_policy: '上游发布新版本时', defaults: '工作流默认设置',
|
||||
// 多数节点的检查器里没有接入这一栏(只有双盲复算、反方审稿有),修复点名接入时照样要写得出叫法
|
||||
provider: PROVIDER_REF_TEXT.field,
|
||||
// 多数节点的检查器里没有接入这一栏(只有双盲复算、反方审稿有),修复点名接入时照样要写得出叫法;修复连带去掉的
|
||||
// 模型也一样(知识检索、结构校验的检查器里没有模型这一栏)
|
||||
provider: PROVIDER_REF_TEXT.field, model: PROVIDER_REF_TEXT.model,
|
||||
}
|
||||
/** 点号后面那一段的叫法:契约里的键,全图默认里的审批策略,列表项里的名称、取值、表达式 */
|
||||
const SUB_KEYS: Record<string, string> = {
|
||||
|
||||
@@ -928,6 +928,11 @@ export const PROVIDER_REF_TEXT = {
|
||||
field: '模型接入',
|
||||
/** 协作成员、修复预览里点号后面那一段:「成员「分析师」的模型接入」「工作流默认设置 · 模型接入」 */
|
||||
memberField: '的模型接入',
|
||||
/**
|
||||
* 只写了模型、提供它的接入已停用时,问题落在写模型的那一处:成员的「成员「分析师」的模型」;节点定义里没有模型这一栏的
|
||||
* (让模型重排的知识检索、让模型修复的结构校验)照样写得出「模型」
|
||||
*/
|
||||
memberModel: '的模型',
|
||||
model: '模型',
|
||||
judge: '结论句裁判',
|
||||
/** 设置页那一格下面:存着的接入不存在、已停用时先说状态,再说这一项因此会怎样(各组自己的后果) */
|
||||
|
||||
+126
-17
@@ -186,6 +186,19 @@ const PROV_OK = {
|
||||
],
|
||||
edges: chain('in', 'ask', 'done'),
|
||||
}
|
||||
/**
|
||||
* 只写了模型、提供它的接入已停用(K1):让模型重排的检索写的 tiny-1 只有停用的 demo-off 提供,协作成员同样只写了
|
||||
* tiny-1。检索的检查器里没有模型这一栏、成员也没有:问题面板和修复预览照样要写得出「模型」
|
||||
*/
|
||||
const PROV_MODEL = {
|
||||
nodes: [
|
||||
node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }),
|
||||
node('look', 'retrieve', 240, '检索', { query: '{{ input.week }}', rerank: 'model', model: 'tiny-1' }),
|
||||
node('team', 'supervisor', 480, '协作', { goal: '复核', agents: [{ name: '分析师', model: 'tiny-1', tools: [] }] }),
|
||||
node('done', 'output', 720, '成果', { fields: [{ name: 'report', value: '{{ last_message }}' }] }),
|
||||
],
|
||||
edges: chain('in', 'look', 'team', 'done'),
|
||||
}
|
||||
const PROVIDERS = [
|
||||
{ id: 'pv-main', name: 'demo-main', kind: 'openai_compatible', base_url: 'https://example.com/v1', default_model: 'big-model',
|
||||
models: [{ id: 'big-model' }], enabled: true, extra: {}, api_key_masked: '', has_key: true },
|
||||
@@ -212,6 +225,7 @@ const FAKES = {
|
||||
'pf-sql': wf('pf-sql', '__publish_check_sql__', SQLWF),
|
||||
'pf-prov': wf('pf-prov', '__publish_check_provider__', PROV),
|
||||
'pf-provok': wf('pf-provok', '__publish_check_provider_ok__', PROV_OK),
|
||||
'pf-provmodel': wf('pf-provmodel', '__publish_check_provider_model__', PROV_MODEL),
|
||||
}
|
||||
|
||||
const clone = (x) => JSON.parse(JSON.stringify(x))
|
||||
@@ -221,18 +235,24 @@ const metricOptions = (m) => m.data.config.metrics.map((x) => ({ value: x.id, la
|
||||
|
||||
/**
|
||||
* 伪造的点名接入检查,形状照后端 engine/provider_refs.py 与 autofix._plan_provider:一处一条问题(字段 provider、
|
||||
* agents[i].provider),门禁里是 error、带一条要人选的修复(id 是 code:节点:字段);画布校验里是 warning、没有修复
|
||||
* agents[i].provider),门禁里是 error、带一条要人选的修复(id 是 code:节点:字段);画布校验里是 warning、没有修复。
|
||||
* 候选照后端按执行时的取法算去掉、换掉以后落到哪里(只覆盖这两张图用到的情形,图里没有全图默认):节点去掉接入落到
|
||||
* 第一个可用的接入;成员去掉接入跟随节点自己的接入,那个接入停用了就不给这一项;落到的接入不提供同一处写着的模型时
|
||||
* 模型一并去掉(_drops 记哪几个候选要去掉,应用时用,前端收不到)
|
||||
*/
|
||||
function providerLint(graph, providers, level) {
|
||||
const issues = []
|
||||
const fixes = []
|
||||
const refs = []
|
||||
const kind = { llm: '模型调用', supervisor: '多 Agent 协作' }
|
||||
const serves = (p, m) => (p.models ?? []).some((x) => x.id === m) || p.default_model === m
|
||||
const enabled = providers.filter((x) => x.enabled)
|
||||
for (const n of graph.nodes) {
|
||||
const c = n.data.config ?? {}
|
||||
if (c.provider) refs.push({ n, field: 'provider', name: c.provider, who: `${who(n)}(${kind[n.type]})` })
|
||||
const at = `${who(n)}(${kind[n.type]})`
|
||||
if (c.provider) refs.push({ n, field: 'provider', name: c.provider, who: at, holder: c })
|
||||
;(c.agents ?? []).forEach((a, i) => {
|
||||
if (a.provider) refs.push({ n, field: `agents[${i}].provider`, name: a.provider, who: `${who(n)}(${kind[n.type]})的成员「${a.name}」` })
|
||||
if (a.provider) refs.push({ n, field: `agents[${i}].provider`, name: a.provider, who: `${at}的成员「${a.name}」`, holder: a, team: at })
|
||||
})
|
||||
}
|
||||
for (const r of refs) {
|
||||
@@ -244,11 +264,50 @@ function providerLint(graph, providers, level) {
|
||||
issues.push({ level, node_id: r.n.id, field: r.field, code, fix: gate ? id : null,
|
||||
message: `${r.who}指定的模型接入「${r.name}」${p ? '已停用' : '不存在'}。请在「设置 → 模型接入」中${p ? '启用它' : '核对名称'},`
|
||||
+ '或在「发布前检查」中改用其他接入' })
|
||||
if (gate) {
|
||||
fixes.push({ id, code, node_id: r.n.id, field: r.field, kind: 'choice', label: `选择${r.who}改用的模型接入`,
|
||||
options: [{ value: '', label: '不指定接入', hint: '去掉这一处指定的接入,按上一级的设置或模型名找接入' },
|
||||
...providers.filter((x) => x.enabled).map((x) => ({ value: x.name, label: x.name, hint: null }))] })
|
||||
if (!gate) continue
|
||||
const model = r.holder.model
|
||||
const options = []
|
||||
const drops = []
|
||||
const up = r.team && r.n.data.config.provider ? providers.find((x) => x.name === r.n.data.config.provider) : enabled[0]
|
||||
if (up?.enabled) {
|
||||
const drop = !!model && !serves(up, model)
|
||||
if (drop) drops.push('')
|
||||
options.push({ value: '', label: '不指定接入', hint: `去掉这一处指定的接入${drop ? `和模型「${model}」` : ''},执行时改用`
|
||||
+ (r.team && r.n.data.config.provider ? `${r.team}指定的接入「${up.name}」` : `第一个可用的模型接入「${up.name}」`) })
|
||||
}
|
||||
for (const x of enabled) {
|
||||
const drop = !!model && !serves(x, model)
|
||||
if (drop) drops.push(x.name)
|
||||
options.push({ value: x.name, label: x.name, hint: drop ? `不提供模型「${model}」,将改用它的默认模型「${x.default_model}」` : null })
|
||||
}
|
||||
fixes.push({ id, code, node_id: r.n.id, field: r.field, kind: 'choice', label: `选择${r.who}改用的模型接入`, options, _drops: drops })
|
||||
}
|
||||
// 哪一级都没写接入、只写了模型:按模型名找,只有停用的接入提供它时同样是问题,落在写模型的那一处。修复是「不指定
|
||||
// 模型」(落到第一个可用的接入)或改用一个启用的接入(写在模型旁边,模型去掉)
|
||||
const models = []
|
||||
for (const n of graph.nodes) {
|
||||
const c = n.data.config ?? {}
|
||||
const at = `${who(n)}(${n.type === 'retrieve' ? '知识检索' : kind[n.type]})`
|
||||
if (!c.provider && c.model && (n.type !== 'retrieve' || c.rerank === 'model')) {
|
||||
models.push({ n, field: 'model', name: c.model, who: at, holder: c, editable: n.type !== 'retrieve' })
|
||||
}
|
||||
;(c.agents ?? []).forEach((a, i) => {
|
||||
if (!c.provider && !a.provider && a.model) models.push({ n, field: `agents[${i}].model`, name: a.model, who: `${at}的成员「${a.name}」`, holder: a })
|
||||
})
|
||||
}
|
||||
for (const r of models) {
|
||||
const off = enabled.some((x) => serves(x, r.name)) ? null : providers.find((x) => !x.enabled && serves(x, r.name))
|
||||
if (!off) continue
|
||||
const id = `provider.disabled:${r.n.id}:${r.field}`
|
||||
const gate = level === 'error'
|
||||
issues.push({ level, node_id: r.n.id, field: r.field, code: 'provider.disabled', fix: gate ? id : null,
|
||||
message: `${r.who}指定的模型「${r.name}」所属的模型接入「${off.name}」已停用。请在「设置 → 模型接入」中启用它,`
|
||||
+ (r.editable ? '或在节点中换一个模型' : '或在「发布前检查」中改用其他接入') })
|
||||
if (!gate || !enabled.length) continue
|
||||
fixes.push({ id, code: 'provider.disabled', node_id: r.n.id, field: r.field, kind: 'choice', label: `选择${r.who}改用的模型接入`,
|
||||
options: [{ value: '', label: '不指定模型', hint: `去掉这一处指定的模型,执行时改用第一个可用的模型接入「${enabled[0].name}」` },
|
||||
...enabled.map((x) => ({ value: x.name, label: x.name, hint: `不提供模型「${r.name}」,将改用它的默认模型「${x.default_model}」` }))],
|
||||
_drops: ['', ...enabled.map((x) => x.name)] })
|
||||
}
|
||||
return { issues, fixes }
|
||||
}
|
||||
@@ -411,14 +470,23 @@ function autofix(body, { unpublished = false, followsNever = false, providers =
|
||||
const to = `{{ nodes.${v}.text }}`
|
||||
change('fields[0].value', c.fields[0].value, to); c.fields[0].value = to
|
||||
} else if (fix.code.startsWith('provider.')) {
|
||||
// 点名的接入:改用选的那个,选「不指定接入」就去掉这一项
|
||||
// 点名的接入:改用选的那个,选「不指定接入」就去掉这一项;落到的接入不提供同一处写着的模型时模型一并去掉。
|
||||
// 只写了模型的那一处:改用的接入写在模型旁边、模型去掉,「不指定模型」只去掉模型
|
||||
const v = body.choices?.[id]
|
||||
if (v === undefined) { rejected.push({ fix_id: id, reason: `这一处需要你选择:${fix.label}` }); continue }
|
||||
const m = /^agents\[(\d+)\]\.provider$/.exec(fix.field)
|
||||
if (!fix.options.some((o) => o.value === v)) { rejected.push({ fix_id: id, reason: `所选的「${v}」不在候选项中` }); continue }
|
||||
const m = /^agents\[(\d+)\]\.(provider|model)$/.exec(fix.field)
|
||||
const holder = m ? c.agents[Number(m[1])] : c
|
||||
change(fix.field, holder.provider ?? null, v || null)
|
||||
if (v) holder.provider = v
|
||||
else delete holder.provider
|
||||
const base = fix.field.replace(/(provider|model)$/, '')
|
||||
if (fix.field.endsWith('provider') || v) {
|
||||
change(`${base}provider`, holder.provider ?? null, v || null)
|
||||
if (v) holder.provider = v
|
||||
else delete holder.provider
|
||||
}
|
||||
if (fix._drops.includes(v) && holder.model) {
|
||||
change(`${base}model`, holder.model, null)
|
||||
delete holder.model
|
||||
}
|
||||
} else if (fix.code === 'contract.required_missing') {
|
||||
const v = body.choices?.[id]
|
||||
if (!Array.isArray(v) || !v.length) { rejected.push({ fix_id: id, reason: `这一处需要你选择:${fix.label}` }); continue }
|
||||
@@ -550,8 +618,8 @@ async function open({ id = 'pf-hard', width = 1440, height = 900, backend = 'new
|
||||
// validate:'ok' | 'fail'(校验接口 500,后端断开时和保存一起失败)。检查中途改它,下一次校验就按新的回
|
||||
const state = { checks: [], autofixes: [], patches: [], publishes: [], upgrades: [], version: FAKES[id].version, graph: clone(FAKES[id].graph), save, backend, upgrade,
|
||||
validate: 'ok', validates: 0, deleted: [] }
|
||||
// 点名接入的那两张图:接入表伪造,校验、门禁、修复都按它现算
|
||||
const providers = id === 'pf-prov' || id === 'pf-provok' ? clone(PROVIDERS) : null
|
||||
// 点名接入、只写了模型的那几张图:接入表伪造,校验、门禁、修复都按它现算
|
||||
const providers = ['pf-prov', 'pf-provok', 'pf-provmodel'].includes(id) ? clone(PROVIDERS) : null
|
||||
state.providers = providers
|
||||
|
||||
await page.route(/\/api\//, (route) => {
|
||||
@@ -1624,9 +1692,14 @@ await section('点名的模型接入:一处一行、各有一组要人选的
|
||||
check('……结论按三处算:3 处问题将被门禁拦截', summary.includes('3 处问题将被门禁拦截'), summary)
|
||||
const member = dialog(page).locator('[data-fix-choice="provider.missing:team:agents[0].provider"]')
|
||||
const memberText = await member.innerText().catch(() => '')
|
||||
check('每一处一组选项:不指定接入、启用的接入;停用的不在候选里', await dialog(page).locator('[data-fix-choice^="provider."]').count() === 3
|
||||
&& await member.locator('input[type="radio"]').count() === 2 && memberText.includes('不指定接入') && memberText.includes('demo-main')
|
||||
&& !memberText.includes('demo-off'), memberText)
|
||||
const askText = await dialog(page).locator('[data-fix-choice="provider.missing:ask:provider"]').innerText().catch(() => '')
|
||||
check('每一处一组选项:启用的接入在、停用的不在候选里', await dialog(page).locator('[data-fix-choice^="provider."]').count() === 3
|
||||
&& memberText.includes('demo-main') && !memberText.includes('demo-off') && askText.includes('demo-main') && !askText.includes('demo-off'),
|
||||
`${askText} | ${memberText}`)
|
||||
check('……「不指定接入」写清执行时改用哪里、写着的模型一并去掉', askText.includes('不指定接入')
|
||||
&& askText.includes('去掉这一处指定的接入和模型「old-model」,执行时改用第一个可用的模型接入「demo-main」'), askText)
|
||||
check('……成员去掉接入会跟随节点上停用的 demo-off:不给这一项,只剩启用的接入',
|
||||
await member.locator('input[type="radio"]').count() === 1 && !memberText.includes('不指定接入'), memberText)
|
||||
check('……标题说清是哪一处', memberText.includes('选择「协作」(多 Agent 协作)的成员「分析师」改用的模型接入'), memberText)
|
||||
check('……不替人选', await member.locator('input:checked').count() === 0)
|
||||
await member.locator('label', { hasText: 'demo-main' }).click()
|
||||
@@ -1662,6 +1735,42 @@ await section('点名的模型接入:问题面板写「模型接入」,不
|
||||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||||
})
|
||||
|
||||
await section('只写了模型、提供它的接入已停用:问题面板写「模型」,修复给「不指定模型」,预览不露键名', async () => {
|
||||
const { page, state, errors } = await open({ id: 'pf-provmodel' })
|
||||
await page.locator('.react-flow__pane').click({ position: { x: 30, y: 30 } })
|
||||
await page.keyboard.press('Alt+KeyP')
|
||||
await page.locator('#dock-problems').waitFor()
|
||||
await waitFor(page, async () => (await page.locator('#dock-problems [data-problem-field]').count()) >= 2)
|
||||
const fields = await page.locator('#dock-problems [data-problem-field]').allInnerTexts()
|
||||
check('字段写「模型」「成员「分析师」的模型」(检索、成员的检查器里都没有模型这一栏)',
|
||||
fields.some((t) => t.trim() === '模型') && fields.some((t) => t.trim() === '成员「分析师」的模型'), fields.join(' | '))
|
||||
const panel = await page.locator('#dock-problems').innerText()
|
||||
check('……说清是哪个模型、哪个接入停用了,没有一处露出键名 model', panel.includes('「检索」(知识检索)指定的模型「tiny-1」所属的模型接入「demo-off」已停用')
|
||||
&& !/\bmodel\b/.test(panel), panel.slice(0, 160))
|
||||
await shoot(page, 'problems-provider-model', page.locator('section[aria-label="问题"]'))
|
||||
|
||||
await openDialog(page)
|
||||
const look = dialog(page).locator('[data-fix-choice="provider.disabled:look:model"]')
|
||||
await look.waitFor()
|
||||
const lookText = await look.innerText().catch(() => '')
|
||||
check('修复:「不指定模型」写清执行时改用哪里,启用的接入写清换成它的哪个模型', lookText.includes('不指定模型')
|
||||
&& lookText.includes('去掉这一处指定的模型,执行时改用第一个可用的模型接入「demo-main」')
|
||||
&& lookText.includes('不提供模型「tiny-1」,将改用它的默认模型「big-model」') && !lookText.includes('demo-off'), lookText)
|
||||
// 「不指定模型」的说明里也写着 demo-main:按选项文字开头认
|
||||
await look.locator('label').filter({ hasText: /^demo-main/ }).click()
|
||||
await look.locator('[data-fix-action="choice"]').click()
|
||||
await dialog(page).locator('[data-fix-preview="ready"]').waitFor()
|
||||
const lines = await dialog(page).locator('[data-fix-change]').allInnerTexts()
|
||||
check('预览两行:写上接入、去掉模型,都写叫法', lines.length === 2 && /「检索」\s*·\s*模型接入:/.test(lines[0]) && lines[0].includes('demo-main')
|
||||
&& /「检索」\s*·\s*模型:\s*tiny-1/.test(lines[1]) && !lines.some((t) => /provider|model\b/.test(t)), lines.join(' | '))
|
||||
await dialog(page).locator('[data-fix-apply]').click()
|
||||
await waitFor(page, async () => state.patches.length === 1)
|
||||
const saved = cfgOf(state.patches[0]?.graph, 'look')
|
||||
check('应用:检索改用 demo-main、去掉停用接入的模型', saved?.provider === 'demo-main' && !('model' in (saved ?? {})), JSON.stringify(saved ?? {}))
|
||||
await shoot(page, 'publish-provider-model', dialog(page))
|
||||
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
||||
})
|
||||
|
||||
await section('点名的模型接入:在设置里删了接入,回到画布重新校验', async () => {
|
||||
const { page, state, errors } = await open({ id: 'pf-provok' })
|
||||
const providerIssues = () => page.evaluate(() => window.__studio.getState().issues
|
||||
|
||||
Reference in New Issue
Block a user