mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
模型接入报错收尾(第 1 轮复核,后端):交给设置的核查、裁判落到演示模型照实说;给不出修复时说卡在哪里;接入名恰好是模型名那句按这一处检查器里有哪几栏写;修复规划只重算被改的节点;助手栏那一格另给一份说法;几处措辞对齐
- 「不指定接入」交给设置(核查模型、裁判模型)时:启用的接入全是演示类,设置这一级不管写的是什么,执行时落到的都是 演示模型,说明照实带上「(演示用,不调用真实模型)」、候选带 demo 标记;一个启用的接入都没有时不算落得稳(同别处 「换成内置演示模型」不算)。以前 land() 在判演示之前就把交给设置的一处当成落稳返回了; - 检查器里没有下拉框、发布前检查又给不出修复时,提示末尾补「无法改用其他接入:」加第一处落不稳的地方(Landing.why, 比如「成员「研究员」指定的模型「小模型」所属的模型接入「旧接入」已停用」),不再只剩「核对名称」让人去设置里找一个 找不到的名字; - 点名的接入名恰好是某个启用接入的模型:画布、发布前检查按这一处在检查器里有哪几栏写(provider_refs._model_note)—— 模型调用一类在节点的「模型」中选择它,接入在发布前检查里改用这个接入或选择「不指定接入」(修复里有哪项说哪项); 协作成员、全图默认只说可在发布前检查里改用;核查节点、报告自己的结论句裁判两栏都在,照设置页写这两栏的叫法。 不再叫人去一个没有的「接入」栏留空。运行时报错不分节点类型,「填写」改为「选择它」; - 修复候选修完以后落到演示类接入的带 demo 标记,发布弹窗据此在选项名旁加醒目的标签; - 性能:改一个节点的配置只影响它自己取模型的那几处,修复规划(autofix._ProviderPlan)每试一个候选只重算这个节点 (provider_refs.node_uses),不再深拷贝、重算整张图;问题提示只问给不给得出(provider_fixable,找到第一个落得稳的 就停);接入问题的引用整张图只算一遍。60 个节点都点名同一个停用接入时,画布校验里这一部分约 0.35 秒降到约 0.002 秒, 发布前检查约 0.72 秒降到约 0.01 秒,一次应用 30 条修复约 2.7 秒降到约 0.09 秒; - 助手:GET /api/copilot/model 另给 panel_error(写在助手栏「助手使用的模型」那一格下面,去向写「上方」,不把人指回这个 下拉框本身);接入名恰好是模型名时并成一句,不再两次提「助手使用的模型」;PUT /api/settings 里助手那一项的 422 用 助手报错那一句(那里只有一个选模型的下拉框,没有「换一个接入」的地方); - 裁判设置的格式检查写「留空表示跟随助手的模型」,同设置页空选项(engine/judge.py 一行,同核查那条的改法);「交给助手」 后只剩接入问题时写「助手无法处理」,有修复时另起半句「也可在各条问题下方选择改用的模型接入」。 测试:test_provider_ref_wording 加演示落在设置上、demo 标记、给不出修复时卡在哪里(含一个启用接入都没有)、各处的 接入名恰好是模型名、助手栏那一格的说法、助手那一项的 422、裁判格式检查、规划开销随节点数线性增长(数试算时算了多少处 取模型的地方);其余几个文件跟上新说法。新加的核心用例都做过去掉实现、测试变红的验证。
This commit is contained in:
@@ -525,9 +525,10 @@ async def get_copilot_model(session: AsyncSession = Depends(get_session)) -> dic
|
||||
"""当前 Copilot 会用哪个模型,以及是显式配的还是兜底来的。
|
||||
|
||||
存的接入不存在、已停用(或者只存了模型名、提供它的接入已停用)时实际什么都用不了:effective_* 为空,
|
||||
error 写明原因(和生成时报的同一句),能用时 error 为 None。"""
|
||||
error 写明原因(和生成时报的同一句),能用时 error 为 None。panel_error 是同一件事写在助手栏「助手使用的模型」
|
||||
那一格下面的说法:人就在这个下拉框旁边,去向写「上方」,不再把人指回这个下拉框本身。"""
|
||||
from app.db.models import Setting
|
||||
from app.providers.factory import resolve_provider
|
||||
from app.providers.factory import assistant_unavailable, resolve_provider
|
||||
|
||||
row = await session.get(Setting, COPILOT_SETTING_KEY)
|
||||
saved = row.value if row else {}
|
||||
@@ -537,7 +538,7 @@ async def get_copilot_model(session: AsyncSession = Depends(get_session)) -> dic
|
||||
except ProviderNotConfigured as e:
|
||||
return {"configured": configured, "provider": saved.get("provider") or None,
|
||||
"model": saved.get("model") or None, "effective_provider": None, "effective_model": None,
|
||||
"error": _unconfigured(e)}
|
||||
"error": _unconfigured(e), "panel_error": assistant_unavailable(e, here=True)}
|
||||
return {
|
||||
"configured": configured,
|
||||
"provider": saved.get("provider") or None,
|
||||
@@ -545,6 +546,7 @@ async def get_copilot_model(session: AsyncSession = Depends(get_session)) -> dic
|
||||
"effective_provider": resolved.name if resolved else None,
|
||||
"effective_model": saved.get("model") or (resolved.default_model if resolved else None),
|
||||
"error": None,
|
||||
"panel_error": None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ from app.engine.judge import sanitize_settings as judge_values, settings_problem
|
||||
from app.engine.spend import SPEND_KEY as VERIFY_SPEND, daily_spend as verify_daily_spend
|
||||
from app.providers import catalog
|
||||
from app.providers.factory import (
|
||||
ModelSpec, ProviderNotConfigured, _resolve_key, assistant_model_note, build_chat_model,
|
||||
ModelSpec, ProviderNotConfigured, _resolve_key, assistant_unavailable, build_chat_model,
|
||||
list_providers as all_providers, model_note, named_provider,
|
||||
)
|
||||
|
||||
@@ -732,8 +732,8 @@ PROVIDER_INVALID = "settings_provider_invalid"
|
||||
|
||||
def _provider_fields() -> dict[tuple[str, str], tuple[str, str | None]]:
|
||||
"""设置里点名模型接入的项:(组, 键) → (这一项在界面上的叫法, 同一组模型那一项的叫法)。门控、裁判、核查的叫法和
|
||||
设置页一致;助手的模型由「编排」页助手栏的「助手使用的模型」单独设定(PUT /api/copilot/model),只有这一个下拉框,
|
||||
这里只防经设置接口写进去的那一份。"""
|
||||
设置页一致;助手的模型由「编排」页助手栏的「助手使用的模型」单独设定(PUT /api/copilot/model),只有这一个下拉框
|
||||
(模型那一项为 None),这里只防经设置接口写进去的那一份。"""
|
||||
from app.api.copilot import COPILOT_SETTING_KEY # 在这里导入:copilot 模块很大,设置模块被引擎各处导入
|
||||
from app.providers.factory import ASSISTANT_MODEL_FIELD
|
||||
|
||||
@@ -747,7 +747,9 @@ def _provider_fields() -> dict[tuple[str, str], tuple[str, str | None]]:
|
||||
|
||||
async def _provider_problem(session: AsyncSession, values: dict[str, Any]) -> tuple[str, str] | None:
|
||||
"""这次写进来的设置里点名的模型接入有没有问题:(字段, 说明),没问题返回 None。说明和运行时报的原因、提示同一句
|
||||
(factory.named_provider),主语写这一项在设置页上的叫法;下拉框里选的,写「指定的」。
|
||||
(factory.named_provider),主语写这一项在设置页上的叫法;下拉框里选的,写「指定的」。助手那一项只有「编排」页
|
||||
助手栏一个下拉框(只选模型,选了就清掉接入),没有「换一个接入」的地方:说法同助手取不到模型时那一句
|
||||
(factory.assistant_unavailable),哪里报都一样。
|
||||
|
||||
以前点名的接入不存在时运行悄悄换成演示模型,名字写错一个字就由演示模型写报告、做核查,现在运行时照实报错;保存时
|
||||
先拦一道。留空照旧表示沿用上一级——说明里不再讲留空是什么意思:设置页那一格的空选项写着(「默认接入」「跟随助手
|
||||
@@ -775,11 +777,12 @@ async def _provider_problem(session: AsyncSession, values: dict[str, Any]) -> tu
|
||||
try:
|
||||
named_provider(providers, name)
|
||||
except ProviderNotConfigured as e:
|
||||
if model_label is None:
|
||||
return f"{group}.{key}", assistant_unavailable(e)
|
||||
said = f"「{label}」指定的{e.reason}。{e.hint}"
|
||||
if e.model_owner:
|
||||
note = model_note(e.name, e.model_owner, f"「{model_label}」", f"「{label}」") if model_label \
|
||||
else assistant_model_note(e.name, e.model_owner)
|
||||
said += f"。{note}"
|
||||
# 这几组的模型一栏是输入框:写「填写」
|
||||
said += f"。{model_note(e.name, e.model_owner, f'「{model_label}」', f'「{label}」', '填写')}"
|
||||
return f"{group}.{key}", said
|
||||
return None
|
||||
|
||||
|
||||
@@ -23,11 +23,13 @@ _GONE = "工作流不存在,可能已被删除"
|
||||
_LEVELS = ("published", "governed")
|
||||
_BAD_LEVEL = "发布级别只能是「已发布」或「受管」"
|
||||
#: 剩下的挡住发布的只有模型接入的问题、人又要交给助手时的说明:换成哪个接入要人拿主意,助手处理不了。发布弹窗里
|
||||
#: 渲染成「助手:……」,说明里不再带冒号;可能是全图默认、也可能只写了模型而提供它的接入停用了,不说「节点」「点名」
|
||||
PROVIDER_LEFT = ("剩余的问题是所用的模型接入不存在或已停用,无法交给助手处理。"
|
||||
#: 渲染成「助手:……」,说明里不再带冒号;可能是全图默认、也可能只写了模型而提供它的接入停用了,不说「节点」「点名」。
|
||||
#: 人刚点了「交给助手」,写「助手无法处理」,不写「无法交给助手处理」(读来像没交出去)
|
||||
PROVIDER_LEFT = ("剩余的问题是所用的模型接入不存在或已停用,助手无法处理。"
|
||||
"请在「设置 → 模型接入」中核对名称或启用相应的接入")
|
||||
#: 剩下的问题里有一条带着修复(provider_refs 按候选算得出来)才补这半句:一个候选都落不稳时那里什么都不给
|
||||
PROVIDER_LEFT_FIX = ",或在各条问题的修复中改用其他接入"
|
||||
#: 剩下的问题里有一条带着修复(provider_refs 按候选算得出来)才补这半句:一个候选都落不稳时那里什么都不给。弹窗里那一组
|
||||
#: 选项在每条问题下方,标题是「选择……改用的模型接入」;另起半句,不和前面的「或」连用
|
||||
PROVIDER_LEFT_FIX = ";也可在各条问题下方选择改用的模型接入"
|
||||
|
||||
|
||||
class WorkflowIn(BaseModel):
|
||||
|
||||
+141
-87
@@ -19,7 +19,7 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
from typing import Any, Callable, Iterator
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -94,6 +94,16 @@ class _Ctx:
|
||||
self.level = level
|
||||
#: 模型接入表(provider_refs.load_providers):点名的接入出了问题时,候选从里面启用的那些里挑
|
||||
self.providers = providers or []
|
||||
self._provider_refs: dict[tuple[str | None, str], Any] | None = None
|
||||
|
||||
def provider_ref(self, node_id: str | None, field: str) -> Any:
|
||||
"""图里写着接入或模型的那一处(provider_refs.ProviderRef)。整张图只算一遍:每条接入问题都去扫一遍全图,
|
||||
几十处同时坏掉时就是几十遍。"""
|
||||
if self._provider_refs is None:
|
||||
from app.engine.provider_refs import provider_refs
|
||||
|
||||
self._provider_refs = {(r.node_id, r.field): r for r in provider_refs(self.spec)}
|
||||
return self._provider_refs.get((node_id, field))
|
||||
|
||||
def upstream(self, node_id: str, *types: NodeType) -> list[GraphNode]:
|
||||
"""这个节点上游的某几类节点,按画布上的节点顺序。"""
|
||||
@@ -518,125 +528,169 @@ def _landing_text(landed: list[tuple[Any, Any]]) -> str:
|
||||
|
||||
def _plan_provider(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None:
|
||||
"""执行时取不到模型的那一处(engine/provider_refs.py):改用一个启用的接入,或者不再指定(provider_choice)。"""
|
||||
from app.engine.provider_refs import provider_refs
|
||||
|
||||
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)
|
||||
ref = ctx.provider_ref(issue.get("node_id"), str(issue.get("field") or ""))
|
||||
return provider_choice(ctx.spec, ref, ctx.providers) if ref is not None else None
|
||||
|
||||
|
||||
def provider_choice(spec: GraphSpec, ref: Any, providers: list[Any]) -> dict[str, Any] | None:
|
||||
"""图里一处点名的接入(provider_refs.ProviderRef)的修复:要人选的 choice(_edits 记每个候选怎么改),一个候选都
|
||||
落不稳时为 None。provider_refs 拿它判断问题的提示指不指到发布前检查——给不出修复就别让人去那里找。
|
||||
class _ProviderPlan:
|
||||
"""图里一处点名的接入(provider_refs.ProviderRef)的修复候选,一个一个算:provider_choice 要全部,provider_fixable
|
||||
(问题提示指不指到发布前检查)找到第一个落得稳的就停。
|
||||
|
||||
点名的接入不存在、已停用的,候选是「不指定接入」和各个启用的接入;只写了模型、提供它的接入已停用的,是「不指定
|
||||
模型」和各个启用的接入(写在模型旁边的接入上)。
|
||||
|
||||
要人选、不给默认:换成哪个接入只有人知道(同一个模型可能有两家提供,价格、配额都不同)。多数节点的检查器里没有
|
||||
接入下拉框(只能选模型),全图默认、协作成员也没有,这里是界面上唯一能改它的地方。一处一条(plan_fixes 的 id
|
||||
带字段):协作节点自己的接入和成员的接入各修各的。
|
||||
|
||||
每个候选先在副本上改,按执行时的取法(provider_refs.land)算出改动波及的每一处落到哪里:去掉以后成员跟随节点、
|
||||
节点跟随全图默认,都没写按模型名找;协作节点、全图默认换了接入,没写自己接入的成员、节点跟着换。落到的接入不提供
|
||||
那一处的模型、按模型名落到停用的接入或认不出的模型时,同一级写着的模型一并去掉再算——拿 A 家的模型名去调 B 家
|
||||
必然失败(设置页换接入时同一个做法);还落不稳的候选不给,修完了运行照样失败的修复不能交给人。候选的说明写清执行
|
||||
时改用哪里、模型去没去掉,落到演示类接入的照实说。复核(judge)另把一道关:门禁同样查只写了模型、提供它的接入
|
||||
已停用的那一处。
|
||||
必然失败(设置页换接入时同一个做法);还落不稳的候选不给,修完了运行照样失败的修复不能交给人。
|
||||
|
||||
只否决这次改动造成的失败:改动波及的一处要是点名的接入没变、而那个接入本来就不存在或已停用(比如节点点名的接入
|
||||
写错了,这次改的是它跟随的全图默认模型),它落不稳不是这次改动的事,它自己那条问题照样在、照样拦着发布。拿它否决
|
||||
的话,两处互相牵连时谁都给不出修复,规划器自己卡住。
|
||||
"""
|
||||
from app.engine.provider_refs import land, uses
|
||||
from app.providers.factory import ProviderNotConfigured, named_provider, serves
|
||||
|
||||
field = ref.field
|
||||
node = spec.node_map().get(ref.node_id) if ref.node_id is not None else 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 = spec.defaults if ref.node_id is None else (node.config if node is not None else {})
|
||||
model = _read_path(root, model_path)
|
||||
model = model.strip() if isinstance(model, str) and model.strip() else None
|
||||
before = {use.key: use for use in uses(spec)}
|
||||
改的是节点上的一处时只重算这个节点(provider_refs.node_uses:节点只读自己的配置和全图默认),不复制、不重算整张图;
|
||||
改的是全图默认时各节点都可能跟着变,整张图重算(这样的引用最多几处)。"""
|
||||
|
||||
def broken_before(use: Any) -> bool:
|
||||
def __init__(self, spec: GraphSpec, ref: Any, providers: list[Any]) -> None:
|
||||
from app.engine.provider_refs import node_uses, uses
|
||||
|
||||
self.spec, self.ref, self.providers = spec, ref, providers
|
||||
path = ref.field.removeprefix("defaults.") if ref.node_id is None else ref.field
|
||||
if ref.kind == "model":
|
||||
self.model_path, self.provider_path = path, path[: -len("model")] + "provider"
|
||||
else:
|
||||
self.provider_path, self.model_path = path, _model_path(path)
|
||||
self.prefix = "defaults." if ref.node_id is None else ""
|
||||
self.node = spec.node_map()[ref.node_id] if ref.node_id is not None else None
|
||||
model = _read_path(spec.defaults if self.node is None else self.node.config, self.model_path)
|
||||
self.model = model.strip() if isinstance(model, str) and model.strip() else None
|
||||
self.before = {use.key: use for use in (uses(spec) if self.node is None else node_uses(spec, self.node))}
|
||||
#: 一个候选都落不稳时说给人听的:第一个候选最后一次试算里第一处落不稳的地方(provider_refs.Landing.why)
|
||||
self.stuck = ""
|
||||
|
||||
def _broken_before(self, use: Any) -> bool:
|
||||
"""这一处点名的接入改动前后没变,而它本来就不存在或已停用:落不稳不是这次改动造成的。"""
|
||||
was = before.get(use.key)
|
||||
from app.providers.factory import ProviderNotConfigured, named_provider
|
||||
|
||||
was = self.before.get(use.key)
|
||||
if was is None or use.provider is None or use.provider != was.provider:
|
||||
return False
|
||||
try:
|
||||
named_provider(providers, use.provider.name)
|
||||
named_provider(self.providers, use.provider.name)
|
||||
except ProviderNotConfigured:
|
||||
return True
|
||||
return False
|
||||
|
||||
def trial(edits: list[tuple[str, Any]]) -> list[tuple[Any, Any]] | None:
|
||||
"""在副本上改,改动波及的每一处(取法变了的)执行时落到哪里;有一处因这次改动落不稳返回 None。"""
|
||||
copied = spec.model_copy(deep=True)
|
||||
target = copied.defaults if ref.node_id is None else copied.node_map()[ref.node_id].config
|
||||
for at, value in edits:
|
||||
_put_path(target, at, value)
|
||||
landed = []
|
||||
for use in uses(copied):
|
||||
if before.get(use.key) == use:
|
||||
continue
|
||||
if not (landing := land(use, providers)).ok:
|
||||
if broken_before(use):
|
||||
continue
|
||||
return None
|
||||
landed.append((use, landing))
|
||||
# 改完没有一处落到新地方:这一处没修好(比如它跟随的那一级也坏着、原因还一样)
|
||||
return landed or None
|
||||
def _trial(self, edits: list[tuple[str, Any]]) -> tuple[list[tuple[Any, Any]] | None, Any]:
|
||||
"""在副本上改,改动波及的每一处(取法变了的)执行时落到哪里:(各处落到哪里,None);有一处因这次改动落不稳
|
||||
时是(None,那一处的 Landing)。改完没有一处落到新地方也算没修好(比如它跟随的那一级也坏着、原因还一样)。"""
|
||||
from app.engine.provider_refs import land, node_uses, uses
|
||||
|
||||
def settle(edits: list[tuple[str, Any]]) -> tuple[list[tuple[str, Any]], list[tuple[Any, Any]]] | None:
|
||||
if self.node is None:
|
||||
defaults = copy.deepcopy(self.spec.defaults)
|
||||
for at, value in edits:
|
||||
_put_path(defaults, at, value)
|
||||
# 浅拷贝:节点原样共用,只换全图默认(uses 只读不写)
|
||||
after = uses(self.spec.model_copy(update={"defaults": defaults}))
|
||||
else:
|
||||
twin = self.node.model_copy(deep=True)
|
||||
for at, value in edits:
|
||||
_put_path(twin.config, at, value)
|
||||
after = node_uses(self.spec, twin)
|
||||
landed = []
|
||||
for use in after:
|
||||
if self.before.get(use.key) == use:
|
||||
continue
|
||||
if not (landing := land(use, self.providers)).ok:
|
||||
if self._broken_before(use):
|
||||
continue
|
||||
return None, landing
|
||||
landed.append((use, landing))
|
||||
return landed or None, None
|
||||
|
||||
def _settle(self, edits: list[tuple[str, Any]]) -> tuple[list[tuple[str, Any]], list[tuple[Any, Any]]] | None:
|
||||
"""先只改这一处;落不稳而同一级写着模型时,连模型一并去掉再试。返回(改法,波及的各处落到哪里),还落不稳为 None。"""
|
||||
if (landed := trial(edits)) is not None:
|
||||
landed, failed = self._trial(edits)
|
||||
if landed is None and self.model and all(at != self.model_path for at, _ in edits):
|
||||
edits = [*edits, (self.model_path, None)]
|
||||
landed, failed = self._trial(edits)
|
||||
if landed 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
|
||||
if not self.stuck and failed is not None:
|
||||
self.stuck = failed.why
|
||||
return None
|
||||
|
||||
def options(self) -> Iterator[tuple[dict[str, Any], list[tuple[str, Any]]]]:
|
||||
"""落得稳的候选(选项,改法),先「不指定」,再按接入表的顺序各个启用的接入。"""
|
||||
from app.providers.factory import serves
|
||||
|
||||
ref, model = self.ref, self.model
|
||||
if (got := self._settle([(self.model_path if ref.kind == "model" else self.provider_path, None)])) is not None:
|
||||
edits, landed = got
|
||||
what = "模型" if ref.kind == "model" else "接入" + (f"和模型「{model}」" if len(edits) > 1 else "")
|
||||
option = {"value": PROVIDER_CLEAR, "label": f"不指定{'模型' if ref.kind == 'model' else '接入'}",
|
||||
"hint": f"去掉这一处指定的{what},执行时{_landing_text(landed)}"}
|
||||
yield _demo_mark(option, landed), edits
|
||||
seen = {PROVIDER_CLEAR}
|
||||
for p in self.providers:
|
||||
if not getattr(p, "enabled", False) or not getattr(p, "name", None) or p.name in seen:
|
||||
continue
|
||||
seen.add(p.name)
|
||||
if (got := self._settle([(self.provider_path, p.name)])) is None:
|
||||
continue
|
||||
edits, landed = got
|
||||
notes = []
|
||||
if len(edits) > 1:
|
||||
# 这一处原来用的就是去掉的那个模型:换上的接入实际用哪个模型(多半是它的默认模型,上一级写了模型就是那个)
|
||||
mine = {landing.model for use, landing in landed
|
||||
if (was := self.before.get(use.key)) is not None and was.model is not None
|
||||
and (was.model.node_id, was.model.field) == (ref.node_id, self.prefix + self.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(DEMO_PROVIDER)
|
||||
yield _demo_mark({"value": p.name, "label": p.name, "hint": ";".join(notes) or None}, landed), edits
|
||||
|
||||
|
||||
def _demo_mark(option: dict[str, Any], landed: list[tuple[Any, Any]]) -> dict[str, Any]:
|
||||
"""修完以后有一处落到演示类接入(provider_refs.Landing.demo):候选带上 demo 标记,发布弹窗在选项名旁加一个
|
||||
醒目的「演示模型」标签。说明里那半句演示说明照旧写,只是淡色小字、排在长句末尾,不够醒目——K1 要防的就是
|
||||
悄悄换成演示模型。"""
|
||||
return {**option, "demo": True} if any(landing.demo for _, landing in landed) else option
|
||||
|
||||
|
||||
def provider_choice(spec: GraphSpec, ref: Any, providers: list[Any]) -> dict[str, Any] | None:
|
||||
"""图里一处点名的接入(provider_refs.ProviderRef)的修复:要人选的 choice(_edits 记每个候选怎么改),一个候选都
|
||||
落不稳时为 None。候选怎么算见 _ProviderPlan。
|
||||
|
||||
要人选、不给默认:换成哪个接入只有人知道(同一个模型可能有两家提供,价格、配额都不同)。多数节点的检查器里没有
|
||||
接入下拉框(只能选模型),全图默认、协作成员也没有,这里是界面上唯一能改它的地方。一处一条(plan_fixes 的 id
|
||||
带字段):协作节点自己的接入和成员的接入各修各的。候选的说明写清执行时改用哪里、模型去没去掉,落到演示类接入的
|
||||
照实说并带 demo 标记。复核(judge)另把一道关:门禁同样查只写了模型、提供它的接入已停用的那一处。"""
|
||||
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 providers:
|
||||
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 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(DEMO_PROVIDER)
|
||||
options.append({"value": p.name, "label": p.name, "hint": ";".join(notes) or None})
|
||||
plans[p.name] = edits
|
||||
for option, edits in _ProviderPlan(spec, ref, providers).options():
|
||||
options.append(option)
|
||||
plans[option["value"]] = edits
|
||||
if not options:
|
||||
# 一个都落不稳(比如一个启用的接入都没有):不给修复,问题照样拦着发布,提示里只说去设置里核对、启用
|
||||
# 一个都落不稳(比如一个启用的接入都没有):不给修复,问题照样拦着发布,提示里说去设置里核对、启用、卡在哪里
|
||||
return None
|
||||
return _choice(f"选择{ref.who}改用的模型接入", field, options, _edits=plans)
|
||||
return _choice(f"选择{ref.who}改用的模型接入", ref.field, options, _edits=plans)
|
||||
|
||||
|
||||
def provider_fixable(spec: GraphSpec, ref: Any, providers: list[Any]) -> tuple[bool, str]:
|
||||
"""这一处给不给得出修复:(给得出,卡在哪里)。provider_refs 拿它决定问题提示指不指到发布前检查——给不出修复就别让
|
||||
人去那里找,改说第一处落不稳的地方(Landing.why)。找到第一个落得稳的候选就停:画布每改一下都要校验,不必为了
|
||||
一句提示把每个候选都算一遍。"""
|
||||
plan = _ProviderPlan(spec, ref, providers)
|
||||
for _ in plan.options():
|
||||
return True, ""
|
||||
return False, plan.stuck
|
||||
|
||||
|
||||
#: 候选本身是演示类接入(kind 为 mock)时的说明。「不指定」落到演示类接入时说明里另带 provider_refs.DEMO_LANDING
|
||||
|
||||
@@ -843,7 +843,8 @@ def settings_problem(value: Any) -> str | None:
|
||||
label = SETTING_LABEL.get(key, key)
|
||||
if key in ("provider", "model"):
|
||||
if item is not None and not isinstance(item, str):
|
||||
return f"「{label}」需要填写模型接入的名称或模型 ID,留空表示不指定,当前为「{item}」"
|
||||
# 「跟随助手的模型」是设置页接入下拉框空选项的原话(核查一组同样照设置页写,api/settings.verify_problem)
|
||||
return f"「{label}」需要填写模型接入的名称或模型 ID,留空表示跟随助手的模型,当前为「{item}」"
|
||||
elif not _limit_ok(key, item):
|
||||
return f"「{label}」需要填写{_limit_words(key)},留空表示不限,当前为「{item}」"
|
||||
return None
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
说「在节点中换一个接入 / 模型」;别处(节点自己的接入、协作成员、全图默认)界面上没有下拉框,说「在发布前检查中改用
|
||||
其他接入」——发布前检查给这类问题配了修复(engine/autofix.py 的 provider.*:改用一个启用的接入,或不再指定),修复
|
||||
按 land 算出修完以后执行时落到哪里,落不稳的候选不给。一个候选都落不稳时修复为空,这时不指到发布前检查(那里什么
|
||||
都不给),只说去设置里核对、启用。点名的名字恰好是某个启用接入的模型时,末尾补一句这是哪个接入的模型
|
||||
(factory.model_note)。
|
||||
都不给),说去设置里核对、启用,再说卡在哪里(第一处落不稳的地方,比如成员只写了停用接入的模型)——不然人去设置里
|
||||
找那个写错的名字,找不到,也看不出真正挡住修复的是别处。点名的名字恰好是某个启用接入的模型时,末尾补一句这是哪个
|
||||
接入的模型、在这一处怎么用它(_model_note:按检查器里有哪几栏写,不叫人去一个没有的「接入」栏留空)。
|
||||
|
||||
节点没写模型、点名的接入(自己的或全图默认的)本身不存在或已停用时,校验那条「没有指定模型,将使用接入「X」的
|
||||
默认模型」(schema.MODEL_UNSET)和这里的问题互相矛盾,由 quiet_unset_model 去掉前者。
|
||||
@@ -37,10 +38,10 @@ from typing import Any, Callable
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.engine.labels import field_label, type_label
|
||||
from app.engine.labels import field_label, judge_label, type_label
|
||||
from app.engine.schema import CLAIMS_JUDGE, MODEL_UNSET, GraphNode, GraphSpec, NodeType, ValidationIssue
|
||||
from app.providers.factory import (
|
||||
NAMED_FIX, ProviderNotConfigured, fallback_provider, list_providers, model_note, named_provider,
|
||||
NAMED_FIX, ProviderNotConfigured, fallback_provider, list_providers, model_fact, model_note, named_provider,
|
||||
provider_for_model, serves,
|
||||
)
|
||||
|
||||
@@ -50,6 +51,10 @@ CODES = frozenset({"provider.missing", "provider.disabled"})
|
||||
#: 才指到那里(IN_CHECK),否则不写后半句
|
||||
_WHERE = {"provider": "或在节点中换一个接入", "model": "或在节点中换一个模型"}
|
||||
IN_CHECK = "或在「发布前检查」中改用其他接入"
|
||||
#: 检查器里没有下拉框、发布前检查也给不出修复时,提示末尾说卡在哪里(Landing.why)
|
||||
NO_SWITCH = "无法改用其他接入:"
|
||||
#: 一个启用的接入都没有:没点名接入的执行时换成内置演示模型,点名的调不了
|
||||
NO_ENABLED = "没有启用的模型接入"
|
||||
#: 修完以后执行时落到演示类接入(kind 为 mock):说明里照实写。K1 要防的就是悄悄换成演示模型——落到它是人选的,
|
||||
#: 得让人知道选的是什么
|
||||
DEMO_LANDING = "(演示用,不调用真实模型)"
|
||||
@@ -142,50 +147,56 @@ def _own_or_default(spec: GraphSpec, node: GraphNode, key: str, who: str) -> Pro
|
||||
editable=key == "model" and node.type in _MODEL_PICKER)
|
||||
|
||||
|
||||
def node_uses(spec: GraphSpec, node: GraphNode) -> list[Use]:
|
||||
"""一个节点执行时取模型的每一处(uses 里属于它的那几项)。只读这个节点自己的配置和全图默认:改一个节点的配置
|
||||
只影响它自己这几处,修复规划(autofix 的 provider.*)按这一点只重算被改的那个节点,不再每试一个候选就把整张图
|
||||
重算一遍——几十个节点都点名同一个停用接入时,那样画布每改一下都要多算上万处。"""
|
||||
out: list[Use] = []
|
||||
cfg = node.config
|
||||
who = f"「{node.title}」({type_label(node.type)})"
|
||||
if node.type in _VERIFY:
|
||||
# 接入、模型哪一项写了就整组以节点为准,都没写交给设置(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,
|
||||
said=f"{who}的「{field_label('provider', node.type)}」")
|
||||
m = _place(node.id, "model", cfg.get("model"), vwho, editable=True, kind="model",
|
||||
said=f"{who}的「{field_label('model', node.type)}」")
|
||||
out.append(Use((node.id, "verify"), vwho, p, m, None if p or m else "verify"))
|
||||
return out
|
||||
if node.type not in _TAKES_MODEL:
|
||||
return out
|
||||
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):
|
||||
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 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")
|
||||
# 节点没写裁判配置,执行时用全图默认里的那一份:问题记在全图默认上,几个报告共用时只记一条
|
||||
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 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:
|
||||
# 接入、模型哪一项写了就整组以节点为准,都没写交给设置(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,
|
||||
said=f"{who}的「{field_label('provider', node.type)}」")
|
||||
m = _place(node.id, "model", cfg.get("model"), vwho, editable=True, kind="model",
|
||||
said=f"{who}的「{field_label('model', node.type)}」")
|
||||
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):
|
||||
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 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")
|
||||
# 节点没写裁判配置,执行时用全图默认里的那一份:问题记在全图默认上,几个报告共用时只记一条
|
||||
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
|
||||
return [use for node in spec.nodes for use in node_uses(spec, node)]
|
||||
|
||||
|
||||
def provider_refs(spec: GraphSpec) -> list[ProviderRef]:
|
||||
@@ -215,22 +226,54 @@ def broken(refs: list[ProviderRef], providers: list[Any]) -> list[tuple[Provider
|
||||
|
||||
def _tail(spec: GraphSpec, ref: ProviderRef, providers: list[Any]) -> str:
|
||||
"""提示的后半句:检查器里有这一项的下拉框,指到节点;没有的,发布前检查给得出修复才指到那里——一个候选都落不稳
|
||||
时(比如一个启用的接入都没有,或者改动波及的别处都落不稳)那里什么都不给,指过去只会让人白找。"""
|
||||
时(比如一个启用的接入都没有,或者改动波及的别处都落不稳)那里什么都不给,指过去只会让人白找,改说卡在哪里。
|
||||
只问给不给得出、卡在哪里(autofix.provider_fixable,找到第一个落得稳的候选就停),不拼整条修复。"""
|
||||
if ref.editable:
|
||||
return f",{_WHERE[ref.kind]}"
|
||||
# 在这里导入:autofix 导入了门禁(governance),门禁又在 lint_for_publish 里导入本模块
|
||||
from app.engine.autofix import provider_choice
|
||||
from app.engine.autofix import provider_fixable
|
||||
|
||||
return f",{IN_CHECK}" if provider_choice(spec, ref, providers) is not None else ""
|
||||
fixable, stuck = provider_fixable(spec, ref, providers)
|
||||
if fixable:
|
||||
return f",{IN_CHECK}"
|
||||
return f"。{NO_SWITCH}{stuck}" if stuck else ""
|
||||
|
||||
|
||||
def _model_note(spec: GraphSpec, ref: ProviderRef, e: ProviderNotConfigured, providers: list[Any]) -> str:
|
||||
"""点名的名字恰好是某个启用接入的模型时补的那一句,按这一处在检查器里有哪几栏写(运行时报错、设置页的说法见
|
||||
factory.model_note)。多数节点的检查器里没有接入这一栏,叫人「接入留空」做不到:
|
||||
- 核查节点、报告自己的结论句裁判:接入、模型两栏都在(模型是输入框),照设置页的写法;
|
||||
- 检查器里有模型下拉框、没有接入一栏的节点(模型调用、Agent……):在节点的「模型」中选它,接入在发布前检查里
|
||||
改用这个接入或不指定(修复里有哪一项说哪一项);
|
||||
- 协作成员、全图默认这些两栏都没有的:发布前检查里能改用这个接入时说一句,否则只说这是哪个接入的模型。"""
|
||||
name, owner = e.name, e.model_owner
|
||||
if ref.editable:
|
||||
node = spec.node_map()[ref.node_id] if ref.node_id is not None else None
|
||||
labels = (judge_label("model"), judge_label("provider")) if ref.field.startswith("judge.") or node is None \
|
||||
else (field_label("model", node.type), field_label("provider", node.type))
|
||||
return model_note(name, owner, f"「{labels[0]}」", f"「{labels[1]}」", "填写")
|
||||
from app.engine.autofix import PROVIDER_CLEAR, provider_choice
|
||||
|
||||
fact = model_fact(name, owner)
|
||||
choice = provider_choice(spec, ref, providers)
|
||||
values = {o["value"] for o in choice["options"]} if choice else set()
|
||||
node = spec.node_map().get(ref.node_id) if ref.node_id is not None else None
|
||||
if node is not None and ref.field == "provider" and node.type in _MODEL_PICKER:
|
||||
picks = ([f"改用「{owner}」"] if owner in values else []) + \
|
||||
(["选择「不指定接入」"] if PROVIDER_CLEAR in values else [])
|
||||
if not picks:
|
||||
return fact
|
||||
return f"{fact}:如需使用该模型,请在节点的「{field_label('model')}」中选择它,并在「发布前检查」中{'或'.join(picks)}"
|
||||
return f"{fact},可在「发布前检查」中改用「{owner}」" if owner in values else fact
|
||||
|
||||
|
||||
def issues(spec: GraphSpec, providers: list[Any], *, level: str) -> list[ValidationIssue]:
|
||||
"""执行时会报错的每一处一条问题。providers 是接入表(factory.list_providers),level 由调用方定:画布校验 warning,
|
||||
发布一类 error。原因和运行时报的同一句,提示按这一处在界面上能不能改(_tail);点名的名字恰好是某个启用接入的
|
||||
模型时补一句(factory.model_note)。"""
|
||||
模型时补一句(_model_note)。"""
|
||||
out = []
|
||||
for ref, e in broken(provider_refs(spec), providers):
|
||||
note = f"。{model_note(e.name, e.model_owner)}" if e.model_owner else ""
|
||||
note = f"。{_model_note(spec, ref, e, providers)}" if e.model_owner else ""
|
||||
out.append(ValidationIssue(level=level, node_id=ref.node_id, field=ref.field, # type: ignore[arg-type]
|
||||
code=f"provider.{e.code}",
|
||||
message=f"{ref.subject}指定的{e.reason}。{NAMED_FIX[e.code]}"
|
||||
@@ -261,11 +304,14 @@ def quiet_unset_model(spec: GraphSpec, found: list[Any], provider_issues: list[A
|
||||
@dataclass(frozen=True)
|
||||
class Landing:
|
||||
"""执行时这一处落到哪里:where 是说给人听的(「工作流默认设置指定的接入「甲」」……),model 是用哪个模型(交给设置时
|
||||
为 None),ok 是修复能不能把它落在这里。落到演示类接入时 where 末尾带着 DEMO_LANDING。"""
|
||||
为 None),ok 是修复能不能把它落在这里。落到演示类接入时 demo 为真、where 末尾带着 DEMO_LANDING。落不稳时 why 说
|
||||
为什么(一句话,接在「无法改用其他接入:」后面),修复一个候选都给不出时拿第一处的 why 告诉人卡在哪里。"""
|
||||
|
||||
where: str
|
||||
model: str | None
|
||||
ok: bool
|
||||
demo: bool = False
|
||||
why: str = ""
|
||||
|
||||
|
||||
def land(use: Use, providers: list[Any]) -> Landing:
|
||||
@@ -273,34 +319,47 @@ def land(use: Use, providers: list[Any]) -> Landing:
|
||||
|
||||
ok 为真才算落得稳:点名的接入启用着、有模型可用,并且提供这一处写的模型——拿 A 家的模型名去调 B 家必然失败;
|
||||
只写了模型的,按模型名找到启用的接入。按模型名落到停用的接入(运行时报错)、模型名认不出来(落到第一个可用的
|
||||
接入,它多半不提供)、一个启用的接入都没有(悄悄换成内置演示模型)都不算。交给设置的(核查、裁判)算落得稳:
|
||||
设置保存时查过点名的接入,之后接入出了问题设置页会标出来。"""
|
||||
接入,它多半不提供)、一个启用的接入都没有(悄悄换成内置演示模型)都不算。
|
||||
|
||||
交给设置的(核查、裁判)算落得稳:设置保存时查过点名的接入,之后接入出了问题设置页会标出来。启用的接入全是演示类
|
||||
时,设置这一级不管写的是什么,执行时落到的都是演示模型(点名的启用接入、按模型名找到的、取值链最后的第一个可用的
|
||||
接入,只能是演示类),照实带上 DEMO_LANDING;一个启用的接入都没有时不算落得稳,同上面「换成内置演示模型」。"""
|
||||
if use.settings:
|
||||
return Landing(_SETTINGS[use.settings], None, True)
|
||||
enabled = [p for p in providers if getattr(p, "enabled", False)]
|
||||
if not enabled:
|
||||
return Landing(_SETTINGS[use.settings], None, False, why=NO_ENABLED)
|
||||
demo = all(getattr(p, "kind", None) == "mock" for p in enabled)
|
||||
return Landing(_SETTINGS[use.settings] + (DEMO_LANDING if demo else ""), None, True, demo=demo)
|
||||
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)
|
||||
return Landing(e.reason, model, False, why=f"{use.provider.subject}指定的{e.reason}")
|
||||
where = f"{use.provider.subject}指定的接入「{found.name}」"
|
||||
elif model:
|
||||
elif use.model is not None and model:
|
||||
try:
|
||||
found = provider_for_model(providers, model)
|
||||
except ProviderNotConfigured as e:
|
||||
return Landing(e.reason, model, False)
|
||||
return Landing(e.reason, model, False, why=f"{use.model.subject}指定的{e.reason}")
|
||||
if found is None:
|
||||
return Landing(f"认不出的模型「{model}」", model, False)
|
||||
return Landing(f"认不出的模型「{model}」", model, False,
|
||||
why=f"{use.model.subject}指定的模型「{model}」不属于任何启用的模型接入")
|
||||
where = f"提供模型「{model}」的接入「{found.name}」"
|
||||
else:
|
||||
found = fallback_provider(providers)
|
||||
if found is None:
|
||||
return Landing("内置演示模型", None, False)
|
||||
return Landing("内置演示模型", None, False, why=NO_ENABLED)
|
||||
where = f"第一个可用的模型接入「{found.name}」"
|
||||
if getattr(found, "kind", None) == "mock":
|
||||
demo = getattr(found, "kind", None) == "mock"
|
||||
if demo:
|
||||
where += DEMO_LANDING
|
||||
used = model or found.default_model
|
||||
return Landing(where, used, bool(used) and (model is None or serves(found, model)))
|
||||
if not used:
|
||||
return Landing(where, None, False, demo, why=f"模型接入「{found.name}」没有可用的模型")
|
||||
if use.model is not None and model and not serves(found, model):
|
||||
return Landing(where, model, False, demo, why=f"模型接入「{found.name}」不提供{use.model.subject}指定的模型「{model}」")
|
||||
return Landing(where, used, True, demo)
|
||||
|
||||
|
||||
async def load_issues(session: AsyncSession, spec: GraphSpec, *, level: str) -> list[ValidationIssue]:
|
||||
|
||||
@@ -54,17 +54,27 @@ async def list_providers(session: AsyncSession) -> list[Provider]:
|
||||
return list(rows.scalars())
|
||||
|
||||
|
||||
#: 点名的接入不存在、已停用时,提示的前半句:在设置里能做什么。后半句(去哪里换一个接入)看场合:运行时报错、
|
||||
#: 设置页写「或换一个接入」;画布校验、发布前检查按这一处在界面上能不能改另写(engine/provider_refs.py)
|
||||
NAMED_FIX = {"missing": "请在「设置 → 模型接入」中核对名称", "disabled": "请在「设置 → 模型接入」中启用它"}
|
||||
#: 点名的接入不存在、已停用时,在「设置 → 模型接入」里能做什么。NAMED_FIX 是整句的前半句;后半句(去哪里换一个
|
||||
#: 接入)看场合:运行时报错、设置页写「或换一个接入」;画布校验、发布前检查按这一处在界面上能不能改另写
|
||||
#:(engine/provider_refs.py);助手栏那一格下面先说「在上方重新选择」,再说去设置里做什么(assistant_unavailable)
|
||||
NAMED_ACTION = {"missing": "核对名称", "disabled": "启用它"}
|
||||
NAMED_FIX = {code: f"请在「设置 → 模型接入」中{act}" for code, act in NAMED_ACTION.items()}
|
||||
|
||||
|
||||
def model_note(name: str, owner: str, model_field: str = "模型一栏", provider_field: str = "接入") -> str:
|
||||
def model_fact(name: str, owner: str) -> str:
|
||||
"""点名的接入名恰好是某个启用接入的模型:先照实说这是哪个接入的模型,怎么用它各处另说(model_note……)。"""
|
||||
return f"「{name}」是接入「{owner}」的模型"
|
||||
|
||||
|
||||
def model_note(name: str, owner: str, model_field: str = "模型一栏", provider_field: str = "接入",
|
||||
how: str = "选择它") -> str:
|
||||
"""点名的接入名恰好是某个启用接入的模型时补的那一句:多半是把模型名填进了接入那一栏。开发数据里真有一条停用的
|
||||
接入,名字就是一个模型 id,同名模型由另一条启用的接入提供——照「启用它」的提示去做,启用的是那条旧接入(它的
|
||||
模型清单里根本没有这个模型),用户要的是这个模型。model_field、provider_field 是这一处模型、接入两栏的叫法:
|
||||
设置页写「「门控模型 · 模型」」「「门控模型 · 接入」」,运行时报错、画布不分节点类型,写「模型一栏」「接入」。"""
|
||||
return f"「{name}」是接入「{owner}」的模型:如需使用该模型,请在{model_field}中填写,{provider_field}留空或选择「{owner}」"
|
||||
模型清单里根本没有这个模型),用户要的是这个模型。model_field、provider_field 是这一处模型、接入两栏的叫法,
|
||||
how 是在模型那一栏里怎么做:设置页、核查节点的模型是输入框,写「填写」;运行时报错不分节点类型,多数节点的模型
|
||||
是下拉框,写「模型一栏」「接入」「选择它」。画布、发布前检查按这一处在检查器里有哪几栏另写(provider_refs)。"""
|
||||
return (f"{model_fact(name, owner)}:如需使用该模型,请在{model_field}中{how},"
|
||||
f"{provider_field}留空或选择「{owner}」")
|
||||
|
||||
|
||||
def _model_owner(providers: list[Provider], name: str) -> Provider | None:
|
||||
@@ -129,28 +139,33 @@ def fallback_provider(providers: list[Provider]) -> Provider | None:
|
||||
#: 助手的模型在界面上只有一处能改:「编排」页助手栏的「助手使用的模型」(设置页里没有这一项)。选「跟随默认」或一个
|
||||
#: 模型都会清掉存着的接入。画布、问数据页、表格导入、数据目录起草报的是同一句,所以写明是哪一页
|
||||
ASSISTANT_MODEL_FIELD = "助手使用的模型"
|
||||
ASSISTANT_REPICK = f"或在「编排」页助手栏的「{ASSISTANT_MODEL_FIELD}」中重新选择"
|
||||
ASSISTANT_PICKER = f"「编排」页助手栏的「{ASSISTANT_MODEL_FIELD}」中"
|
||||
ASSISTANT_REPICK = f"或在{ASSISTANT_PICKER}重新选择"
|
||||
|
||||
|
||||
def assistant_model_note(name: str, owner: str) -> str:
|
||||
"""点名的接入名恰好是某个启用接入的模型(model_note)时,助手这边的说法:它的下拉框只选模型,选了就清掉接入。"""
|
||||
return f"「{name}」是接入「{owner}」的模型,可在「{ASSISTANT_MODEL_FIELD}」中直接选择"
|
||||
|
||||
|
||||
def assistant_unavailable(e: BaseException) -> str:
|
||||
def assistant_unavailable(e: BaseException, *, here: bool = False) -> str:
|
||||
"""助手(画布上改图、表格导入与数据目录的 AI 起草)用的模型取不到时给人看的那句话。
|
||||
|
||||
点名的接入不存在、已停用(按模型名找到的接入已停用也算)照实说是哪个接入、在设置里能做什么,或者到助手栏重新
|
||||
选一个模型;以前这几种要么悄悄换成演示模型,要么各处自己判停用。点名的名字恰好是启用接入的模型时补一句可以直接
|
||||
选它。其余(缺 key、没有模型……)是接入本身没配完。"""
|
||||
选一个模型;以前这几种要么悄悄换成演示模型,要么各处自己判停用。点名的名字恰好是启用接入的模型时并成一句:先说
|
||||
在设置里能做什么,再说这是哪个接入的模型、也可以在助手栏直接选它(不再另起一句把「助手使用的模型」说两遍)。
|
||||
其余(缺 key、没有模型……)是接入本身没配完。
|
||||
|
||||
here:说给助手栏「助手使用的模型」那一格下面(GET /api/copilot/model 的 panel_error)。人就在这个下拉框旁边,
|
||||
去向写「上方」,不再把人指回「编排」页助手栏。"""
|
||||
from app.core.errors import not_configured
|
||||
|
||||
code = getattr(e, "code", "")
|
||||
if code in NAMED_FIX:
|
||||
said = f"助手使用的{getattr(e, 'reason', '') or e}。{NAMED_FIX[code]},{ASSISTANT_REPICK}"
|
||||
owner = getattr(e, "model_owner", "")
|
||||
return f"{said}。{assistant_model_note(getattr(e, 'name', ''), owner)}" if owner else said
|
||||
return f"助手使用的模型尚未配置完成:{not_configured(e, with_hint=False)}。请到「设置 → 模型接入」检查"
|
||||
said = f"助手使用的{getattr(e, 'reason', '') or e}。"
|
||||
if owner := getattr(e, "model_owner", ""):
|
||||
where = "上方" if here else ASSISTANT_PICKER
|
||||
return f"{said}{NAMED_FIX[code]};{model_fact(getattr(e, 'name', ''), owner)},也可在{where}直接选择它"
|
||||
if here:
|
||||
return f"{said}请在上方重新选择,或在「设置 → 模型接入」中{NAMED_ACTION[code]}"
|
||||
return f"{said}{NAMED_FIX[code]},{ASSISTANT_REPICK}"
|
||||
return (f"助手使用的模型尚未配置完成:{not_configured(e, with_hint=False)}。请到「设置 → 模型接入」检查"
|
||||
+ (",或在上方重新选择" if here else ""))
|
||||
|
||||
|
||||
async def resolve_provider(
|
||||
|
||||
@@ -77,13 +77,21 @@ FIELDS = [
|
||||
]
|
||||
|
||||
|
||||
def refused(group: str, label: str, reason: str, fix: str) -> str:
|
||||
"""保存时拒收的说法。助手那一项只有「编排」页助手栏一个下拉框(只选模型),没有「换一个接入」的地方:和助手取不到
|
||||
模型时报的同一句(factory.assistant_unavailable,见 test_provider_ref_wording)。"""
|
||||
if group == "copilot":
|
||||
return f"助手使用的{reason}。{fix},或在「编排」页助手栏的「助手使用的模型」中重新选择"
|
||||
return f"「{label}」指定的{reason}。{fix},或换一个接入"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("group,key,label", FIELDS)
|
||||
async def test_saving_a_provider_that_does_not_exist_is_refused_with_the_field(client, group, key, label):
|
||||
r = await client.put("/api/settings", json={"values": {group: {key: GONE}}})
|
||||
assert r.status_code == 422, r.text
|
||||
body = r.json()
|
||||
assert body["code"] == "settings_provider_invalid" and body["field"] == f"{group}.{key}"
|
||||
assert body["detail"] == f"「{label}」指定的模型接入「{GONE}」不存在。请在「设置 → 模型接入」中核对名称,或换一个接入"
|
||||
assert body["detail"] == refused(group, label, f"模型接入「{GONE}」不存在", "请在「设置 → 模型接入」中核对名称")
|
||||
assert await stored(group) is None
|
||||
|
||||
|
||||
@@ -93,7 +101,7 @@ async def test_saving_a_disabled_provider_is_refused_too(client, group, key, lab
|
||||
assert r.status_code == 422, r.text
|
||||
body = r.json()
|
||||
assert body["field"] == f"{group}.{key}"
|
||||
assert body["detail"] == f"「{label}」指定的模型接入「{OFF}」已停用。请在「设置 → 模型接入」中启用它,或换一个接入"
|
||||
assert body["detail"] == refused(group, label, f"模型接入「{OFF}」已停用", "请在「设置 → 模型接入」中启用它")
|
||||
assert await stored(group) is None
|
||||
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ 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": "分析"}]),
|
||||
@@ -117,7 +117,8 @@ def test_the_problem_is_recorded_once_where_the_model_is_written():
|
||||
got = [(i["node_id"], i["field"], i["message"]) for i in gate(graph)]
|
||||
enable = "请在「设置 → 模型接入」中启用它"
|
||||
assert got == [
|
||||
("team2", "agents[0].model", f"「协作二」(多 Agent 协作)的成员「研究员」指定的模型「丙一号」{STOPPED}。{enable}"),
|
||||
("team2", "agents[0].model", f"「协作二」(多 Agent 协作)的成员「研究员」指定的模型「丙一号」{STOPPED}。{enable}。"
|
||||
f"无法改用其他接入:工作流默认设置指定的模型「丙一号」{STOPPED}"),
|
||||
(None, "defaults.model", f"工作流默认设置指定的模型「丙一号」{STOPPED}。{enable},或在「发布前检查」中改用其他接入"),
|
||||
]
|
||||
|
||||
|
||||
@@ -12,6 +12,15 @@ K1 让点名的接入不存在或已停用时照实报错、发布前挡住并
|
||||
- 同一节点同时提示「没有指定模型,将使用默认模型」和「指定的模型接入不存在」,互相矛盾;
|
||||
- 助手的模型取不到时,提示指到真正能改它的地方(「编排」页助手栏的「助手使用的模型」)。
|
||||
|
||||
第 1 轮复核又留下的几处:
|
||||
- 「不指定接入」交给设置(核查、裁判)时,落到演示类接入也没说;
|
||||
- 「接入名恰好是模型名」那句叫人「接入留空」,多数节点的检查器里没有接入这一栏,模型又是下拉框却写「填写」;
|
||||
- 给不出修复时只剩「核对名称」,没说真正卡住修复的是哪一处;
|
||||
- 「演示用,不调用真实模型」只是淡色小字:候选另带 demo 标记,界面上加醒目的标签;
|
||||
- 画布校验为每处坏掉的接入把每个候选都在整张图上算一遍,几十处同时坏掉时开销随规模平方增长;
|
||||
- 助手那一项的 422 说法和助手报错、助手栏对不上;助手栏下拉下面的原因把人指回这个下拉本身;
|
||||
- 裁判的格式检查仍写「留空表示不指定」;「交给助手」后只剩接入问题时的说明措辞。
|
||||
|
||||
纯函数的用例用虚构的接入表,不读库;接口的用例建自己的几条接入、用完删掉。接入名、节点名、模型名都是虚构的。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -24,7 +33,7 @@ from sqlalchemy import delete
|
||||
|
||||
from app.db.base import SessionLocal
|
||||
from app.db.models import Provider, Setting, Workflow, WorkflowVersion
|
||||
from app.engine import autofix
|
||||
from app.engine import autofix, provider_refs
|
||||
from app.engine.autofix import apply_fixes, check
|
||||
from app.engine.governance import publish_issues
|
||||
from app.engine.provider_refs import issues as provider_ref_issues
|
||||
@@ -48,6 +57,7 @@ DEMO = provider("演示接入", "演示一号", kind="mock")
|
||||
DEMO_NOTE = "(演示用,不调用真实模型)"
|
||||
MISSING_FIX, DISABLED_FIX = "请在「设置 → 模型接入」中核对名称", "请在「设置 → 模型接入」中启用它"
|
||||
IN_CHECK = ",或在「发布前检查」中改用其他接入"
|
||||
NO_SWITCH = "。无法改用其他接入:"
|
||||
|
||||
|
||||
def node(nid, ntype, label=None, **config):
|
||||
@@ -114,14 +124,16 @@ def test_a_verify_node_names_the_field_that_points_at_the_provider():
|
||||
|
||||
def test_without_a_fix_the_hint_does_not_send_people_to_the_publish_check():
|
||||
"""协作节点点名的接入不存在,成员只写了停用的丙家的模型、跟随节点的接入:去掉或换掉节点的接入,成员都落不稳,
|
||||
修复为空。检查器里又没有这一栏,提示只剩去设置里核对——不说「或在「发布前检查」中改用其他接入」。画布校验的
|
||||
警告是同一句。成员换成启用的接入提供的模型,修复就有了,提示照常指到发布前检查。"""
|
||||
修复为空。检查器里又没有这一栏,提示不说「或在「发布前检查」中改用其他接入」,说去设置里核对,再说卡在哪里——
|
||||
人去设置里找「戊接入」找不到,真正挡住修复的是成员的模型所属的接入停用了。画布校验的警告是同一句。成员换成启用的
|
||||
接入提供的模型,修复就有了,提示照常指到发布前检查。"""
|
||||
graph = flow(node("team", "supervisor", "协作", provider="戊接入", goal="分析",
|
||||
agents=[{"name": "研究员", "model": "丙一号", "system": "研究"}]))
|
||||
found = checked(graph)
|
||||
[issue] = provider_issues(found["issues"])
|
||||
assert (issue["node_id"], issue["field"], issue["fix"]) == ("team", "provider", None)
|
||||
assert issue["message"] == f"「协作」(多 Agent 协作)指定的模型接入「戊接入」不存在。{MISSING_FIX}"
|
||||
assert issue["message"] == (f"「协作」(多 Agent 协作)指定的模型接入「戊接入」不存在。{MISSING_FIX}{NO_SWITCH}"
|
||||
"「协作」(多 Agent 协作)的成员「研究员」指定的模型「丙一号」所属的模型接入「丙接入」已停用")
|
||||
[warn] = provider_ref_issues(GraphSpec.model_validate(graph), TABLE, level="warning")
|
||||
assert warn.message == issue["message"]
|
||||
|
||||
@@ -131,6 +143,15 @@ def test_without_a_fix_the_hint_does_not_send_people_to_the_publish_check():
|
||||
assert issue["message"] == f"「协作」(多 Agent 协作)指定的模型接入「戊接入」不存在。{MISSING_FIX}{IN_CHECK}"
|
||||
|
||||
|
||||
def test_without_any_enabled_provider_the_hint_says_so():
|
||||
"""一个启用的接入都没有:去掉接入以后执行时只能换成内置演示模型,不算修好,也没有别的接入可换。提示照实说卡在
|
||||
这里,不只说去设置里启用这个接入。"""
|
||||
graph = flow(node("ask", "llm", "提问", provider="丙接入", prompt="总结"))
|
||||
[issue] = provider_issues(checked(graph, [BING])["issues"])
|
||||
assert issue["fix"] is None
|
||||
assert issue["message"] == f"「提问」(模型调用)指定的模型接入「丙接入」已停用。{DISABLED_FIX}{NO_SWITCH}没有启用的模型接入"
|
||||
|
||||
|
||||
def test_an_issue_without_its_own_fix_does_not_borrow_another_places_fix():
|
||||
"""全图默认点名停用的丙,节点「提问」只写了丙家的模型(跟随全图默认的接入),节点「复述」自己点名丙:复述那一处有
|
||||
修复(改用甲、乙),全图默认那一处没有(换掉或去掉,提问都落不稳)。全图默认那条不能认到复述的修复上去。"""
|
||||
@@ -142,10 +163,47 @@ def test_an_issue_without_its_own_fix_does_not_borrow_another_places_fix():
|
||||
assert set(got) == {("b", "provider"), (None, "defaults.provider")}
|
||||
assert got[("b", "provider")]["fix"] == "provider.disabled:b:provider"
|
||||
assert got[(None, "defaults.provider")]["fix"] is None
|
||||
assert got[(None, "defaults.provider")]["message"] == f"工作流默认设置指定的模型接入「丙接入」已停用。{DISABLED_FIX}"
|
||||
# 卡在「提问」:它只写了丙家的模型,去掉全图默认的接入以后按模型名找,找到的还是停用的丙
|
||||
assert got[(None, "defaults.provider")]["message"] == (
|
||||
f"工作流默认设置指定的模型接入「丙接入」已停用。{DISABLED_FIX}{NO_SWITCH}"
|
||||
"「提问」(模型调用)指定的模型「丙一号」所属的模型接入「丙接入」已停用")
|
||||
assert [f["id"] for f in found["fixes"]] == ["provider.disabled:b:provider"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 很多处同时坏掉:每试一个候选只重算被改的那个节点
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_checking_many_broken_places_does_not_redo_the_whole_graph_per_candidate(monkeypatch):
|
||||
"""几十个节点都点名同一个停用的接入(导入别处导出的工作流时常见):画布校验要给每一处说给不给得出修复,发布前检查
|
||||
再给每一处列候选。改一个节点的配置只影响它自己取模型的那几处,每试一个候选只重算这个节点,开销随节点数线性增长;
|
||||
以前每试一个候选都把整张图重算一遍,随节点数平方增长(60 个节点时画布每改一下要多算约 0.35 秒)。数的是一共算了
|
||||
多少处取模型的地方(provider_refs.Use),节点数翻 4 倍时不能超过约 4 倍。"""
|
||||
made = {"n": 0}
|
||||
|
||||
class Counted(provider_refs.Use):
|
||||
def __init__(self, *args, **kwargs):
|
||||
made["n"] += 1
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(provider_refs, "Use", Counted)
|
||||
table = [provider(f"接入{i}", f"模型{i}") for i in range(6)] + [BING]
|
||||
|
||||
def cost(n: int) -> tuple[int, int]:
|
||||
spec = GraphSpec.model_validate(flow(*[node(f"n{i}", "llm", f"节点{i}", provider="丙接入", prompt="总结")
|
||||
for i in range(n)]))
|
||||
made["n"] = 0
|
||||
assert len(provider_ref_issues(spec, table, level="warning")) == n
|
||||
lint = made["n"]
|
||||
made["n"] = 0
|
||||
assert len(check(spec, level="published", providers=table)["fixes"]) == n
|
||||
return lint, made["n"]
|
||||
|
||||
small, big = cost(8), cost(32)
|
||||
assert big[0] <= 5 * small[0] and big[1] <= 5 * small[1], (small, big)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 本来就因自己点名的接入坏着的那一处,不否决别处的修复
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -212,11 +270,51 @@ def test_stop_naming_a_provider_that_lands_on_a_demo_provider_says_so():
|
||||
f"去掉这一处指定的接入,执行时改用提供模型「演示一号」的接入「演示接入」{DEMO_NOTE}")
|
||||
|
||||
|
||||
def test_stop_naming_a_provider_handed_to_settings_says_when_it_lands_on_a_demo_model():
|
||||
"""核查节点、报告自己的结论句裁判去掉接入以后交给设置(核查模型、裁判模型)。启用的接入全是演示类时,设置这一级
|
||||
不管写的是什么,执行时落到的都是演示模型(取值链最后是第一个可用的接入):说明里和模型调用那一处一样写明演示用、
|
||||
不调用真实模型,候选也带 demo 标记。有启用的真实接入时不带;一个启用的接入都没有时,设置这一级只能换成内置演示
|
||||
模型(或者调不了),不算修好、不给这一项。"""
|
||||
graph = flow(node("ask", "llm", "提问", provider="丙接入", prompt="总结"),
|
||||
node("write", "report", "写周报", instructions="写周报", claims="judge", judge={"provider": "丙接入"}),
|
||||
node("dual", "dual_check", "复核销售额", provider="丙接入"))
|
||||
demo_only = [BING, DEMO]
|
||||
got = {fid: hints(fix_of(graph, fid, demo_only)) for fid in (
|
||||
"provider.disabled:ask:provider", "provider.disabled:write:judge.provider", "provider.disabled:dual:provider")}
|
||||
assert got["provider.disabled:ask:provider"][""] == (
|
||||
f"去掉这一处指定的接入,执行时改用第一个可用的模型接入「演示接入」{DEMO_NOTE}")
|
||||
assert got["provider.disabled:write:judge.provider"][""] == f"去掉这一处指定的接入,执行时改用设置中的裁判模型{DEMO_NOTE}"
|
||||
assert got["provider.disabled:dual:provider"][""] == f"去掉这一处指定的接入,执行时改用设置中的核查模型{DEMO_NOTE}"
|
||||
clear = next(o for o in fix_of(graph, "provider.disabled:dual:provider", demo_only)["options"] if o["value"] == "")
|
||||
assert clear.get("demo") is True
|
||||
|
||||
real = [JIA, BING, DEMO]
|
||||
assert hints(fix_of(graph, "provider.disabled:dual:provider", real))[""] == "去掉这一处指定的接入,执行时改用设置中的核查模型"
|
||||
clear = next(o for o in fix_of(graph, "provider.disabled:dual:provider", real)["options"] if o["value"] == "")
|
||||
assert "demo" not in clear
|
||||
|
||||
assert fix_of(graph, "provider.disabled:dual:provider", [BING]) is None
|
||||
assert fix_of(graph, "provider.disabled:write:judge.provider", [BING]) is None
|
||||
|
||||
|
||||
def test_candidates_that_land_on_a_demo_provider_carry_a_mark():
|
||||
"""落到演示类接入的候选带 demo 标记(发布弹窗在选项名旁加醒目的标签),落到真实接入的不带。"""
|
||||
table = [JIA, BING, DEMO]
|
||||
graph = flow(node("ask", "llm", "提问", provider="丙接入", prompt="总结"))
|
||||
fix = fix_of(graph, "provider.disabled:ask:provider", table)
|
||||
marks = {o["value"]: o.get("demo", False) for o in fix["options"]}
|
||||
assert marks == {"": False, "甲接入": False, "演示接入": True}
|
||||
# 只剩演示接入可用:「不指定接入」也落到它
|
||||
fix = fix_of(graph, "provider.disabled:ask:provider", [BING, DEMO])
|
||||
assert {o["value"]: o.get("demo", False) for o in fix["options"]} == {"": True, "演示接入": True}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 点名的接入名恰好是某个启用接入的模型
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
MODEL_NOTE = "「乙一号」是接入「乙接入」的模型:如需使用该模型,请在模型一栏中填写,接入留空或选择「乙接入」"
|
||||
#: 运行时报错不分节点类型:多数节点的模型是下拉框,写「选择它」
|
||||
MODEL_NOTE = "「乙一号」是接入「乙接入」的模型:如需使用该模型,请在模型一栏中选择它,接入留空或选择「乙接入」"
|
||||
|
||||
|
||||
def test_a_provider_name_that_is_really_a_model_gets_a_note():
|
||||
@@ -231,23 +329,77 @@ def test_a_provider_name_that_is_really_a_model_gets_a_note():
|
||||
with pytest.raises(ProviderNotConfigured) as e:
|
||||
named_provider(table, " 甲二号 ")
|
||||
assert str(e.value) == (f"模型接入「甲二号」不存在。{MISSING_FIX},或换一个接入。"
|
||||
"「甲二号」是接入「甲接入」的模型:如需使用该模型,请在模型一栏中填写,接入留空或选择「甲接入」")
|
||||
"「甲二号」是接入「甲接入」的模型:如需使用该模型,请在模型一栏中选择它,接入留空或选择「甲接入」")
|
||||
for name in ("丙一号", "戊接入"):
|
||||
with pytest.raises(ProviderNotConfigured) as e:
|
||||
named_provider(table, name)
|
||||
assert "是接入" not in str(e.value), str(e.value)
|
||||
|
||||
# 画布、发布前检查:同一句补在提示后面
|
||||
graph = flow(node("ask", "llm", "提问", provider="乙一号", prompt="总结"))
|
||||
[issue] = provider_issues(checked(graph, table)["issues"])
|
||||
assert issue["message"] == (f"「提问」(模型调用)指定的模型接入「乙一号」已停用。{DISABLED_FIX}{IN_CHECK}。{MODEL_NOTE}")
|
||||
|
||||
# 助手:指到能直接选这个模型的地方
|
||||
# 助手:并成一句,指到能直接选这个模型的地方(不再另起一句把「助手使用的模型」说两遍)
|
||||
with pytest.raises(ProviderNotConfigured) as e:
|
||||
named_provider(table, "乙一号")
|
||||
assert assistant_unavailable(e.value) == (
|
||||
f"助手使用的模型接入「乙一号」已停用。{DISABLED_FIX},或在「编排」页助手栏的「助手使用的模型」中重新选择。"
|
||||
"「乙一号」是接入「乙接入」的模型,可在「助手使用的模型」中直接选择")
|
||||
f"助手使用的模型接入「乙一号」已停用。{DISABLED_FIX};"
|
||||
"「乙一号」是接入「乙接入」的模型,也可在「编排」页助手栏的「助手使用的模型」中直接选择它")
|
||||
|
||||
|
||||
def test_the_model_note_on_the_canvas_points_to_fields_that_exist_there():
|
||||
"""画布、发布前检查里那句按这一处在检查器里有哪几栏写,不叫人去一个没有的「接入」栏留空:
|
||||
- 模型调用这类节点只有模型下拉框:在节点的「模型」中选它,接入在发布前检查里改用乙接入或不指定(修复里有哪项说哪项);
|
||||
- 协作成员、全图默认两栏都没有:只说发布前检查里可以改用乙接入;
|
||||
- 核查节点、报告自己的结论句裁判两栏都在(模型是输入框):照设置页的写法,写这个节点上两栏的叫法;
|
||||
- 给不出修复的:只说这是哪个接入的模型。"""
|
||||
table = [JIA, YI, BING, TRAP]
|
||||
fact = "「乙一号」是接入「乙接入」的模型"
|
||||
stopped = f"指定的模型接入「乙一号」已停用。{DISABLED_FIX}"
|
||||
graph = flow(node("ask", "llm", "提问", provider="乙一号", prompt="总结"),
|
||||
node("team", "supervisor", "协作", provider="甲接入", goal="分析",
|
||||
agents=[{"name": "分析师", "provider": "乙一号", "system": "分析"}]),
|
||||
node("write", "report", "写周报", instructions="写周报", claims="judge", judge={"provider": "乙一号"}),
|
||||
node("dual", "dual_check", "复核销售额", provider="乙一号"),
|
||||
node("chal", "challenge", "审稿", report="write", provider="乙一号"))
|
||||
got = {(i["node_id"], i["field"]): i["message"] for i in provider_issues(checked(graph, table)["issues"])}
|
||||
assert got[("ask", "provider")] == (
|
||||
f"「提问」(模型调用){stopped}{IN_CHECK}。{fact}:如需使用该模型,请在节点的「模型」中选择它,"
|
||||
"并在「发布前检查」中改用「乙接入」或选择「不指定接入」")
|
||||
assert got[("team", "agents[0].provider")] == (
|
||||
f"「协作」(多 Agent 协作)的成员「分析师」{stopped}{IN_CHECK}。{fact},可在「发布前检查」中改用「乙接入」")
|
||||
assert got[("write", "judge.provider")] == (
|
||||
f"「写周报」(报告撰写)的结论句裁判{stopped},或在节点中换一个接入。"
|
||||
f"{fact}:如需使用该模型,请在「裁判模型」中填写,「裁判模型 · 接入」留空或选择「乙接入」")
|
||||
assert got[("dual", "provider")] == (
|
||||
f"「复核销售额」(双盲复算)的「盲算模型 · 接入」{stopped},或在节点中换一个接入。"
|
||||
f"{fact}:如需使用该模型,请在「盲算模型」中填写,「盲算模型 · 接入」留空或选择「乙接入」")
|
||||
assert got[("chal", "provider")].endswith(
|
||||
f"{fact}:如需使用该模型,请在「反方模型」中填写,「反方模型 · 接入」留空或选择「乙接入」")
|
||||
# 画布校验的警告是同一句
|
||||
warned = {(i.node_id, i.field): i.message
|
||||
for i in provider_ref_issues(GraphSpec.model_validate(graph), table, level="warning")}
|
||||
assert warned == got
|
||||
|
||||
# 全图默认也点名「乙一号」:「提问」去掉自己的接入以后落到全图默认那个停用的接入,修复里没有「不指定接入」,只说改用
|
||||
defaulted = flow(node("ask", "llm", "提问", provider="乙一号", prompt="总结"),
|
||||
node("plain", "llm", "复述", prompt="复述"), defaults={"provider": "乙一号"})
|
||||
got = {(i["node_id"], i["field"]): i["message"] for i in provider_issues(checked(defaulted, table)["issues"])}
|
||||
assert got[("ask", "provider")].endswith(
|
||||
f"{fact}:如需使用该模型,请在节点的「模型」中选择它,并在「发布前检查」中改用「乙接入」")
|
||||
assert got[(None, "defaults.provider")] == f"工作流默认设置{stopped}{IN_CHECK}。{fact},可在「发布前检查」中改用「乙接入」"
|
||||
|
||||
# 成员自己写着甲家的模型:协作节点改用乙接入时成员落不稳,修复里只有「不指定接入」和甲接入
|
||||
mixed = flow(node("team", "supervisor", "协作", provider="乙一号", goal="分析",
|
||||
agents=[{"name": "研究员", "model": "甲二号", "system": "研究"}]))
|
||||
[issue] = provider_issues(checked(mixed, table)["issues"])
|
||||
assert issue["message"].endswith(
|
||||
f"{fact}:如需使用该模型,请在节点的「模型」中选择它,并在「发布前检查」中选择「不指定接入」")
|
||||
|
||||
# 给不出修复(成员只写了停用的丙家的模型,换掉、去掉节点的接入都落不稳):只说这是哪个接入的模型
|
||||
stuck = flow(node("team", "supervisor", "协作", provider="乙一号", goal="分析",
|
||||
agents=[{"name": "研究员", "model": "丙一号", "system": "研究"}]))
|
||||
[issue] = provider_issues(checked(stuck, table)["issues"])
|
||||
assert issue["fix"] is None
|
||||
assert issue["message"] == (
|
||||
f"「协作」(多 Agent 协作){stopped}{NO_SWITCH}"
|
||||
f"「协作」(多 Agent 协作)的成员「研究员」指定的模型「丙一号」所属的模型接入「丙接入」已停用。{fact}")
|
||||
|
||||
|
||||
def test_the_assistant_hint_points_to_the_dropdown_that_can_change_it():
|
||||
@@ -259,6 +411,24 @@ def test_the_assistant_hint_points_to_the_dropdown_that_can_change_it():
|
||||
f"助手使用的模型接入「戊接入」不存在。{MISSING_FIX},或在「编排」页助手栏的「助手使用的模型」中重新选择")
|
||||
|
||||
|
||||
def test_under_the_assistant_dropdown_the_hint_points_up_not_back_to_itself():
|
||||
"""助手栏「助手使用的模型」那一格下面:人就在这个下拉框旁边,去向写「上方」,不再说「「编排」页助手栏的「助手使用的
|
||||
模型」」。接入本身没配完(缺 key……)时也补一句可以在上方重新选。"""
|
||||
table = [JIA, YI, BING, TRAP]
|
||||
for name, said in (
|
||||
("戊接入", "助手使用的模型接入「戊接入」不存在。请在上方重新选择,或在「设置 → 模型接入」中核对名称"),
|
||||
("丙接入", "助手使用的模型接入「丙接入」已停用。请在上方重新选择,或在「设置 → 模型接入」中启用它"),
|
||||
("乙一号", f"助手使用的模型接入「乙一号」已停用。{DISABLED_FIX};「乙一号」是接入「乙接入」的模型,也可在上方直接选择它"),
|
||||
):
|
||||
with pytest.raises(ProviderNotConfigured) as e:
|
||||
named_provider(table, name)
|
||||
assert assistant_unavailable(e.value, here=True) == said
|
||||
assert "助手栏" not in said
|
||||
keyless = ProviderNotConfigured("模型接入「甲接入」缺少 API Key", "请在「设置 → 模型接入」中填写", code="missing_key")
|
||||
assert assistant_unavailable(keyless, here=True).endswith("请到「设置 → 模型接入」检查,或在上方重新选择")
|
||||
assert assistant_unavailable(keyless).endswith("请到「设置 → 模型接入」检查")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 接口:保存设置、交给助手、画布校验(建自己的几条接入,用完删掉)
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -306,19 +476,18 @@ async def stored(key: str):
|
||||
return row.value if row else None
|
||||
|
||||
|
||||
#: (组, 键, 接入这一项在界面上的叫法, 模型那一项的叫法;助手只有一个下拉框)
|
||||
#: (组, 键, 接入这一项在界面上的叫法, 模型那一项的叫法)
|
||||
SETTING_FIELDS = [
|
||||
("verify", "provider", "核查模型 · 接入", "核查模型 · 模型"),
|
||||
("judge", "provider", "裁判模型 · 接入", "裁判模型 · 模型"),
|
||||
("run", "tool_gate_provider", "门控模型 · 接入", "门控模型 · 模型"),
|
||||
("copilot", "provider", "助手使用的模型", None),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("group,key,label,model_label", SETTING_FIELDS)
|
||||
async def test_saving_a_broken_provider_is_worded_like_the_settings_page(client, group, key, label, model_label):
|
||||
"""下拉框里选的接入写「指定的」,不写「填写的」;留空那半句和设置页空选项的叫法对不上,不再说(那一格就在下拉框
|
||||
下面,空选项写着留空的意思)。助手那一项叫「助手使用的模型」,界面上没有「助手模型 · 接入」。"""
|
||||
下面,空选项写着留空的意思)。这几组的模型一栏是输入框,名字恰好是模型时写「填写」。"""
|
||||
r = await client.put("/api/settings", json={"values": {group: {key: GONE}}})
|
||||
assert r.status_code == 422, r.text
|
||||
assert r.json()["detail"] == f"「{label}」指定的模型接入「{GONE}」不存在。{MISSING_FIX},或换一个接入"
|
||||
@@ -327,12 +496,27 @@ async def test_saving_a_broken_provider_is_worded_like_the_settings_page(client,
|
||||
# 名字恰好是启用接入的模型:补一句这是哪个接入的模型、在这一组里怎么用它
|
||||
r = await client.put("/api/settings", json={"values": {group: {key: NAMED_LIKE_MODEL}}})
|
||||
note = (f"「{NAMED_LIKE_MODEL}」是接入「{LIVE}」的模型:"
|
||||
f"如需使用该模型,请在「{model_label}」中填写,「{label}」留空或选择「{LIVE}」") if model_label else \
|
||||
f"「{NAMED_LIKE_MODEL}」是接入「{LIVE}」的模型,可在「助手使用的模型」中直接选择"
|
||||
f"如需使用该模型,请在「{model_label}」中填写,「{label}」留空或选择「{LIVE}」")
|
||||
assert r.json()["detail"] == f"「{label}」指定的模型接入「{NAMED_LIKE_MODEL}」已停用。{DISABLED_FIX},或换一个接入。{note}"
|
||||
assert await stored(group) is None
|
||||
|
||||
|
||||
async def test_saving_a_broken_assistant_provider_is_worded_like_the_assistant(client):
|
||||
"""助手那一项只有「编排」页助手栏一个下拉框(只选模型),没有「换一个接入」的地方:422 的说法和助手取不到模型时
|
||||
报的同一句,名字恰好是模型时并成一句。"""
|
||||
picker = "「编排」页助手栏的「助手使用的模型」中"
|
||||
r = await client.put("/api/settings", json={"values": {"copilot": {"provider": GONE}}})
|
||||
assert r.status_code == 422, r.text
|
||||
assert r.json()["detail"] == f"助手使用的模型接入「{GONE}」不存在。{MISSING_FIX},或在{picker}重新选择"
|
||||
r = await client.put("/api/settings", json={"values": {"copilot": {"provider": OFF}}})
|
||||
assert r.json()["detail"] == f"助手使用的模型接入「{OFF}」已停用。{DISABLED_FIX},或在{picker}重新选择"
|
||||
r = await client.put("/api/settings", json={"values": {"copilot": {"provider": NAMED_LIKE_MODEL}}})
|
||||
assert r.json()["detail"] == (f"助手使用的模型接入「{NAMED_LIKE_MODEL}」已停用。{DISABLED_FIX};"
|
||||
f"「{NAMED_LIKE_MODEL}」是接入「{LIVE}」的模型,也可在{picker}直接选择它")
|
||||
assert "换一个接入" not in r.json()["detail"]
|
||||
assert await stored("copilot") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("group,key,label", [("run", "tool_gate_provider", "门控模型 · 接入"),
|
||||
("copilot", "provider", "助手使用的模型")])
|
||||
async def test_a_provider_name_that_is_not_text_is_worded_without_the_blank_half(client, group, key, label):
|
||||
@@ -347,6 +531,13 @@ async def test_the_verify_format_check_says_blank_follows_the_judge_model_like_t
|
||||
assert r.json()["detail"] == "「核查模型 · 接入」需要填写模型接入的名称或模型 ID,留空表示跟随证据裁判模型,当前为「3」"
|
||||
|
||||
|
||||
async def test_the_judge_format_check_says_blank_follows_the_assistant_model_like_the_page(client):
|
||||
"""设置页裁判那一组接入下拉框的空选项是「跟随助手的模型」,不是「不指定」。"""
|
||||
r = await client.put("/api/settings", json={"values": {"judge": {"provider": 3}}})
|
||||
assert r.status_code == 422
|
||||
assert r.json()["detail"] == "「裁判模型 · 接入」需要填写模型接入的名称或模型 ID,留空表示跟随助手的模型,当前为「3」"
|
||||
|
||||
|
||||
async def _workflow(graph: dict) -> str:
|
||||
async with SessionLocal() as session:
|
||||
wf = Workflow(name="措辞检查", graph=graph, version=1)
|
||||
@@ -358,14 +549,15 @@ async def _workflow(graph: dict) -> str:
|
||||
|
||||
|
||||
async def test_handing_only_provider_problems_to_the_assistant_is_one_sentence_per_step(client):
|
||||
"""发布弹窗里渲染成「助手:……」:说明自己不再带冒号,「核对」有宾语,不说「节点」(可能是全图默认)。有修复时指到
|
||||
各条问题的修复;没有修复时不提。"""
|
||||
"""发布弹窗里渲染成「助手:……」:说明自己不再带冒号,「核对」有宾语,不说「节点」(可能是全图默认)。人刚点了
|
||||
「交给助手」,说「助手无法处理」,不说「无法交给助手处理」(读来像没交出去)。有修复时补半句指到各条问题下方的
|
||||
选项(那一组的标题是「选择……改用的模型接入」),不和前一个「或」连用;没有修复时不提。"""
|
||||
from app.api.workflows import PROVIDER_LEFT, PROVIDER_LEFT_FIX
|
||||
|
||||
assert ":" not in PROVIDER_LEFT + PROVIDER_LEFT_FIX
|
||||
assert PROVIDER_LEFT == ("剩余的问题是所用的模型接入不存在或已停用,无法交给助手处理。"
|
||||
assert PROVIDER_LEFT == ("剩余的问题是所用的模型接入不存在或已停用,助手无法处理。"
|
||||
"请在「设置 → 模型接入」中核对名称或启用相应的接入")
|
||||
assert PROVIDER_LEFT_FIX == ",或在各条问题的修复中改用其他接入"
|
||||
assert PROVIDER_LEFT_FIX == ";也可在各条问题下方选择改用的模型接入"
|
||||
graph = flow(node("ask", "llm", "提问", provider=GONE, prompt="总结"))
|
||||
wf = await _workflow(graph)
|
||||
r = await client.post(f"/api/workflows/{wf}/autofix", json={"level": "published", "graph": graph, "assist": True})
|
||||
@@ -416,13 +608,16 @@ async def test_no_model_says_which_provider_and_steps_aside_when_that_provider_i
|
||||
|
||||
|
||||
async def test_the_assistant_model_lookup_explains_a_provider_named_like_a_model(client):
|
||||
"""error 是生成时报的同一句(别处也这么说);panel_error 写在助手栏那一格下面,去向是「上方」的下拉框。"""
|
||||
async with SessionLocal() as session:
|
||||
session.add(Setting(key="copilot", value={"provider": NAMED_LIKE_MODEL, "model": ""}))
|
||||
await session.commit()
|
||||
got = (await client.get("/api/copilot/model")).json()
|
||||
assert got["error"] == (
|
||||
f"助手使用的模型接入「{NAMED_LIKE_MODEL}」已停用。{DISABLED_FIX},或在「编排」页助手栏的「助手使用的模型」中重新选择。"
|
||||
f"「{NAMED_LIKE_MODEL}」是接入「{LIVE}」的模型,可在「助手使用的模型」中直接选择")
|
||||
note = f"「{NAMED_LIKE_MODEL}」是接入「{LIVE}」的模型"
|
||||
assert got["error"] == (f"助手使用的模型接入「{NAMED_LIKE_MODEL}」已停用。{DISABLED_FIX};"
|
||||
f"{note},也可在「编排」页助手栏的「助手使用的模型」中直接选择它")
|
||||
assert got["panel_error"] == f"助手使用的模型接入「{NAMED_LIKE_MODEL}」已停用。{DISABLED_FIX};{note},也可在上方直接选择它"
|
||||
# 在助手栏重新选「跟随默认」:存着的接入一并清掉,不再报错
|
||||
r = await client.put("/api/copilot/model", json={"model": None})
|
||||
assert r.json()["error"] is None and (await stored("copilot")) == {"provider": "", "model": ""}
|
||||
assert r.json()["error"] is None and r.json()["panel_error"] is None
|
||||
assert (await stored("copilot")) == {"provider": "", "model": ""}
|
||||
|
||||
@@ -119,7 +119,7 @@ async def test_a_disabled_provider_named_like_a_model_is_not_swapped_for_the_one
|
||||
assert e.value.code == "disabled" and "fake-v4.1-flash" in str(e.value)
|
||||
# 照「启用它」去做启用的是那条旧接入:另补一句这个名字是哪个接入的模型、怎么用它
|
||||
assert e.value.model_owner == "云端接入" and str(e.value).endswith(
|
||||
"「fake-v4.1-flash」是接入「云端接入」的模型:如需使用该模型,请在模型一栏中填写,接入留空或选择「云端接入」")
|
||||
"「fake-v4.1-flash」是接入「云端接入」的模型:如需使用该模型,请在模型一栏中选择它,接入留空或选择「云端接入」")
|
||||
assert (await resolve_provider(session, None, "fake-v4.1-flash")).id == serving.id
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user