合入期 5 前置修复 K1b:模型接入报错的说法与修复候选收尾——修复规划只拿这次改动造成的失败否决候选、每处问题只认自己那一处的修复、给不出修复时照实说卡在哪;「不指定接入」落到演示类接入(含交给设置的核查、裁判)时写明演示用;接入名恰好是模型名时补一句;助手栏照实显示存着的设定与原因、能清掉失效的接入;检查器留空说明按节点与全图默认取值

This commit is contained in:
ThearchyHelios
2026-10-06 20:02:52 +08:00
26 changed files with 1876 additions and 302 deletions
+5 -3
View File
@@ -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,
}
+29 -15
View File
@@ -23,7 +23,8 @@ 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, build_chat_model, list_providers as all_providers, named_provider,
ModelSpec, ProviderNotConfigured, _resolve_key, assistant_unavailable, build_chat_model,
list_providers as all_providers, model_note, named_provider,
)
router = APIRouter(prefix="/api", tags=["settings"])
@@ -500,7 +501,8 @@ def verify_problem(value: Any) -> str | None:
label = VERIFY_SETTING_LABEL[key]
if key in ("provider", "model"):
if item is not None and not isinstance(item, str):
return f"「{label}」需要填写模型接入的名称或模型 ID,留空表示沿用证据裁判模型,当前为「{item}」"
# 「跟随证据裁判模型」是设置页接入下拉框空选项的原话
return f"「{label}」需要填写模型接入的名称或模型 ID,留空表示跟随证据裁判模型,当前为「{item}」"
elif key == "daily_max_usd":
if not _positive(item, whole=False):
return f"「{label}」需要填写大于 0 的金额(美元),留空表示不限,当前为「{item}」"
@@ -728,36 +730,42 @@ class SettingsIn(BaseModel):
PROVIDER_INVALID = "settings_provider_invalid"
def _provider_fields() -> dict[tuple[str, str], tuple[str, str]]:
"""设置里点名模型接入的项:(组, 键) → (设置页上的叫法, 留空时用什么)。门控、裁判、核查的叫法和设置页一致;助手的
模型由助手面板单独设定(PUT /api/copilot/model),这里只防经设置接口写进去的那一份。"""
def _provider_fields() -> dict[tuple[str, str], tuple[str, str | None]]:
"""设置里点名模型接入的项:(组, 键) → (这一项在界面上的叫法, 同一组模型那一项的叫法)。门控、裁判、核查的叫法和
设置页一致;助手的模型由「编排」页助手栏的「助手使用的模型」单独设定(PUT /api/copilot/model),只有这一个下拉框
(模型那一项为 None),这里只防经设置接口写进去的那一份。"""
from app.api.copilot import COPILOT_SETTING_KEY # 在这里导入:copilot 模块很大,设置模块被引擎各处导入
from app.providers.factory import ASSISTANT_MODEL_FIELD
return {
("run", "tool_gate_provider"): ("门控模型 · 接入", "留空表示使用默认接入的默认模型"),
(JUDGE_GROUP, "provider"): (JUDGE_LABEL["provider"], "留空表示不指定"),
(VERIFY_GROUP, "provider"): (VERIFY_SETTING_LABEL["provider"], "留空表示沿用证据裁判模型"),
(COPILOT_SETTING_KEY, "provider"): ("助手模型 · 接入", "留空表示不指定"),
("run", "tool_gate_provider"): ("门控模型 · 接入", "门控模型 · 模型"),
(JUDGE_GROUP, "provider"): (JUDGE_LABEL["provider"], JUDGE_LABEL["model"]),
(VERIFY_GROUP, "provider"): (VERIFY_SETTING_LABEL["provider"], VERIFY_SETTING_LABEL["model"]),
(COPILOT_SETTING_KEY, "provider"): (ASSISTANT_MODEL_FIELD, 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),哪里报都一样。
以前点名的接入不存在时运行悄悄换成演示模型,名字写错一个字就由演示模型写报告、做核查,现在运行时照实报错;保存时
先拦一道。留空照旧表示沿用上一级。只查这次改了的:存着的值没改(写好之后接入才被删、停用)不挡这次保存别的项——
设置页在那一项下标着「不存在」「已停用」,运行时照实报错,同设置页「只校验改过的」的做法。
先拦一道。留空照旧表示沿用上一级——说明里不再讲留空是什么意思:设置页那一格的空选项写着(「默认接入」「跟随助手
的模型」「跟随证据裁判模型」……),这里另写一遍只会对不上。只查这次改了的:存着的值没改(写好之后接入才被删、
停用)不挡这次保存别的项——设置页在那一项下标着「不存在」「已停用」,运行时照实报错,同设置页「只校验改过的」。
点名的名字恰好是某个启用接入的模型时,补一句在这一组的哪一栏用它(factory.model_note)。
名字不是字符串(接口写进来的数字一类):核查、裁判两组自己的格式检查在前面先报;门控、助手两组没有自己的检查,
在这里报,免得存下去之后读它的地方(查助手当前用哪个模型)出错。"""
providers = None
for (group, key), (label, blank) in _provider_fields().items():
for (group, key), (label, model_label) in _provider_fields().items():
sent = values.get(group)
if not isinstance(sent, dict):
continue
if (raw := sent.get(key)) is not None and not isinstance(raw, str):
return f"{group}.{key}", f"「{label}」需要填写模型接入的名称,{blank},当前为「{raw}」"
return f"{group}.{key}", f"「{label}」需要填写模型接入的名称,当前为「{raw}」"
if (name := _model_text(raw)) is None:
continue
row = await session.get(Setting, group)
@@ -769,7 +777,13 @@ async def _provider_problem(session: AsyncSession, values: dict[str, Any]) -> tu
try:
named_provider(providers, name)
except ProviderNotConfigured as e:
return f"{group}.{key}", f"「{label}」填写的{e.reason}。{e.hint};{blank}"
if model_label is None:
return f"{group}.{key}", assistant_unavailable(e)
said = f"「{label}」指定的{e.reason}。{e.hint}"
if e.model_owner:
# 这几组的模型一栏是输入框:写「填写」
said += f"。{model_note(e.name, e.model_owner, f'「{model_label}」', f'「{label}」', '填写')}"
return f"{group}.{key}", said
return None
+20 -10
View File
@@ -14,7 +14,7 @@ from app.data.catalog_impact import catalog_drift, catalog_versions_for, recorde
from app.db.base import get_session
from app.db.models import Run, Workflow, WorkflowVersion
from app.engine.governance import publish_issues
from app.engine.provider_refs import CODES as PROVIDER_CODES, load_issues, load_providers
from app.engine.provider_refs import CODES as PROVIDER_CODES, load_issues, load_providers, quiet_unset_model
from app.engine.schema import GraphSpec, NodeType, validate_graph
router = APIRouter(prefix="/api/workflows", tags=["workflows"])
@@ -22,9 +22,14 @@ router = APIRouter(prefix="/api/workflows", tags=["workflows"])
_GONE = "工作流不存在,可能已被删除"
_LEVELS = ("published", "governed")
_BAD_LEVEL = "发布级别只能是「已发布」或「受管」"
#: 剩下的挡住发布的只有点名接入的问题、人又要交给助手时的说明:换成哪个接入要人拿主意,每一处都有自己的修复
PROVIDER_LEFT = ("剩下的问题是指定的模型接入不存在或已停用,需要你决定改用哪个接入:请在每一处的修复中选择,"
"或在「设置 → 模型接入」中核对")
#: 剩下的挡住发布的只有模型接入的问题、人又要交给助手时的说明:换成哪个接入要人拿主意,助手处理不了。发布弹窗里
#: 渲染成「助手:……」,说明里不再带冒号;可能是全图默认、也可能只写了模型而提供它的接入停用了,不说「节点」「点名」。
#: 人刚点了「交给助手」,写「助手无法处理」,不写「无法交给助手处理」(读来像没交出去)
PROVIDER_LEFT = ("剩余的问题是所用的模型接入不存在或已停用,助手无法处理。"
"请在「设置 → 模型接入」中核对名称或启用相应的接入")
#: 剩下的问题里有一条带着修复(provider_refs 按候选算得出来)才补这半句:一个候选都落不稳时那里什么都不给。弹窗里那一组
#: 选项在每条问题下方,标题是「选择……改用的模型接入」;另起半句,不和前面的「或」连用
PROVIDER_LEFT_FIX = ";也可在各条问题下方选择改用的模型接入"
class WorkflowIn(BaseModel):
@@ -490,9 +495,12 @@ async def autofix(
if out["ok"]:
out["assist"] = {"ok": True, "summary": "修复后已没有阻止发布的错误,未再交给助手", "questions": []}
return out
if all(i.get("code") in PROVIDER_CODES for i in out["remaining"] if i["level"] == "error"):
left = [i for i in out["remaining"] if i["level"] == "error"]
if all(i.get("code") in PROVIDER_CODES for i in left):
# 剩下的只有点名接入的问题:换成哪个接入要人拿主意(助手的自查也不看接入表),不交给助手
out["assist"] = {"ok": False, "summary": PROVIDER_LEFT, "questions": []}
fixable = any(i.get("fix") for i in left)
out["assist"] = {"ok": False, "summary": PROVIDER_LEFT + (PROVIDER_LEFT_FIX if fixable else ""),
"questions": []}
return out
from app.api.copilot import assist_publish_fix
@@ -523,14 +531,16 @@ async def validate(payload: ValidateIn, session: AsyncSession = Depends(get_sess
"""画布上的实时校验。前端每次改动都会调它来标红。
点名的模型接入不存在或已停用(只写了模型、提供它的接入已停用也算)只给警告:探索运行仍可发起,执行到那个节点时
照实报错;发布时才挡住。"""
照实报错;发布时才挡住。这时同一节点上「没有指定模型,将使用接入「X」的默认模型」和它矛盾,不再列
(quiet_unset_model,发布一类在 governance._not_repeated 里同样处理)。"""
try:
spec = GraphSpec.model_validate(payload.graph)
except Exception as e: # noqa: BLE001
return {"ok": False, "issues": [{"level": "error", "message": f"无法解析工作流结构:{graph_error(e)}"}]}
result = validate_graph(spec).model_dump()
result["issues"] += [i.model_dump() for i in await load_issues(session, spec, level="warning")]
return result
result = validate_graph(spec)
found = await load_issues(session, spec, level="warning")
result.issues = [*quiet_unset_model(spec, result.issues, found), *found]
return result.model_dump()
@router.post("/variables")
+169 -79
View File
@@ -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]:
"""这个节点上游的某几类节点,按画布上的节点顺序。"""
@@ -517,98 +527,174 @@ 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):改用一个启用的接入,或者不再指定。点名的接入不存在、已停用
的,候选是「不指定接入」和各个启用的接入;只写了模型、提供它的接入已停用的,是「不指定模型」和各个启用的接入
(写在模型旁边的接入上)。
"""执行时取不到模型的那一处(engine/provider_refs.py):改用一个启用的接入,或者不再指定(provider_choice)。"""
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
要人选、不给默认:换成哪个接入只有人知道(同一个模型可能有两家提供,价格、配额都不同)。多数节点的检查器里没有
接入下拉框(只能选模型),全图默认、协作成员也没有,这里是界面上唯一能改它的地方。一处一条(plan_fixes 的 id
带字段):协作节点自己的接入和成员的接入各修各的。
class _ProviderPlan:
"""图里一处点名的接入(provider_refs.ProviderRef)的修复候选,一个一个算:provider_choice 要全部,provider_fixable
(问题提示指不指到发布前检查)找到第一个落得稳的就停。
点名的接入不存在、已停用的,候选是「不指定接入」和各个启用的接入;只写了模型、提供它的接入已停用的,是「不指定
模型」和各个启用的接入(写在模型旁边的接入上)。
每个候选先在副本上改,按执行时的取法(provider_refs.land)算出改动波及的每一处落到哪里:去掉以后成员跟随节点、
节点跟随全图默认,都没写按模型名找;协作节点、全图默认换了接入,没写自己接入的成员、节点跟着换。落到的接入不提供
那一处的模型、按模型名落到停用的接入或认不出的模型时,同一级写着的模型一并去掉再算——拿 A 家的模型名去调 B 家
必然失败(设置页换接入时同一个做法);还落不稳的候选不给,修完了运行照样失败的修复不能交给人。候选的说明写清执行
时改用哪里、模型去没去掉。复核(judge)另把一道关:门禁同样查只写了模型、提供它的接入已停用的那一处。
"""
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)
model = model.strip() if isinstance(model, str) and model.strip() else None
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)
改的是节点上的一处时只重算这个节点(provider_refs.node_uses:节点只读自己的配置和全图默认),不复制、不重算整张图;
改的是全图默认时各节点都可能跟着变,整张图重算(这样的引用最多几处)。"""
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:
"""这一处点名的接入改动前后没变,而它本来就不存在或已停用:落不稳不是这次改动造成的。"""
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(self.providers, use.provider.name)
except ProviderNotConfigured:
return True
return False
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
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 uses(spec):
if before.get(use.key) == use:
for use in after:
if self.before.get(use.key) == use:
continue
if not (landing := land(use, ctx.providers)).ok:
return None
if not (landing := land(use, self.providers)).ok:
if self._broken_before(use):
continue
return None, landing
landed.append((use, landing))
return landed
return landed or None, None
def settle(edits: list[tuple[str, Any]]) -> tuple[list[tuple[str, Any]], list[tuple[Any, Any]]] | 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 ctx.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("演示用的模型接入,不调用真实模型")
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
DEMO_PROVIDER = "演示用的模型接入,不调用真实模型"
_PLANNERS: dict[str, Callable[[_Ctx, dict[str, Any], GraphNode | None], dict[str, Any] | None]] = {
@@ -698,9 +784,11 @@ def public(fix: dict[str, Any]) -> dict[str, Any]:
def annotate(issues: list[Any], fixes: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""问题的字典形状,带上对应的修复 id(fix)。validate 和门禁报的同一处问题指向同一个修复。
先按(编号、节点)认(点名的模型接入一处一条,同一节点上可能有几条,再带上字段);认不出时,图级问题(没有节点)
认同一编号的第一条修复,图级修复(不落在节点上,比如改全图默认)认同一编号的每一条问题。前端 canvas/issues.fixFor
是同一个退路。
先按(编号、节点)认;认不出时,图级问题(没有节点)认同一编号的第一条修复,图级修复(不落在节点上,比如改全图
默认)认同一编号的每一条问题。前端 canvas/issues.fixFor 是同一个退路。
点名的模型接入(_PER_FIELD)一处一条修复,只认(编号、节点、字段)都对得上的那一条,没有就是没有:这一处一个
候选都落不稳时修复为空,退到同一节点、同一编号别处的修复上,接口里的 fix 就指到了另一处的修复。
"""
exact = {(f["code"], f["node_id"], f.get("field")): f["id"] for f in fixes if f["code"] in _PER_FIELD}
by_node = {(f["code"], f["node_id"]): f["id"] for f in fixes}
@@ -713,8 +801,10 @@ def annotate(issues: list[Any], fixes: list[dict[str, Any]]) -> list[dict[str, A
out = []
for issue in map(_as_dict, issues):
code = issue.get("code")
fid = exact.get((code, issue.get("node_id"), issue.get("field"))) if code else None
fid = fid or (by_node.get((code, issue.get("node_id"))) if code else None)
if code in _PER_FIELD:
out.append({**issue, "fix": exact.get((code, issue.get("node_id"), issue.get("field")))})
continue
fid = by_node.get((code, issue.get("node_id"))) if code else None
if fid is None and code:
fid = by_code.get(code) if issue.get("node_id") is None else by_graph.get(code)
out.append({**issue, "fix": fid})
+8 -3
View File
@@ -111,9 +111,9 @@ def lint_for_publish(spec: GraphSpec, *, level: str, checkers: Mapping[str, Any]
_lint_evidence(spec, flag, strict=strict)
_lint_sql(spec, flag, checkers)
if providers is not None:
from app.engine.provider_refs import issues as provider_issues, provider_refs
from app.engine.provider_refs import issues as provider_issues
for issue in provider_issues(provider_refs(spec), providers, level="error"):
for issue in provider_issues(spec, providers, level="error"):
result.add(issue.message, level="error", node_id=issue.node_id, field=issue.field, code=issue.code)
if strict and not has_contract_output:
@@ -164,7 +164,11 @@ def _not_repeated(issues: list[Any], gate: list[Any], spec: GraphSpec) -> list[A
G5 落在 agent 上,validate 那条也落在 agent 上;G4 落在喂数的沙箱代码上,validate 那条落在口径卡读它的
那条指标上,按口径卡的输入对回去。没被门禁覆盖的(比如不给口径卡供数的 agent)照旧提示。
节点点名的接入不存在或已停用(门禁的 provider.*)时,validate 那条「没有指定模型,将使用接入「X」的默认模型」
和它矛盾,同样不列(provider_refs.quiet_unset_model)。
"""
from app.engine.provider_refs import quiet_unset_model
cited = {i.node_id for i in gate if i.code == "governed.caliber_agent_cite_fields"}
compute = {i.node_id for i in gate if i.code == "governed.caliber_compute_input"}
covered = {("evidence.cite_fields_off", nid, "output_schema") for nid in cited}
@@ -172,7 +176,8 @@ def _not_repeated(issues: list[Any], gate: list[Any], spec: GraphSpec) -> list[A
if card.type == NodeType.METRICS and compute:
covered |= {("evidence.caliber_compute_input", card.id, f"metrics[{i}].expression")
for i, producer in _caliber_inputs(card, spec) if producer.id in compute}
return [i for i in issues if i.code not in _REPEATS or (i.code, i.node_id, i.field) not in covered]
kept = [i for i in issues if i.code not in _REPEATS or (i.code, i.node_id, i.field) not in covered]
return quiet_unset_model(spec, kept, gate)
def _lint_contract(
+2 -1
View File
@@ -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
+188 -71
View File
@@ -20,7 +20,13 @@
说法分两种:检查器里有这一项下拉框的几处(双盲复算、反方审稿、节点自己的结论句裁判的接入和模型,几类节点自己的模型)
说「在节点中换一个接入 / 模型」;别处(节点自己的接入、协作成员、全图默认)界面上没有下拉框,说「在发布前检查中改用
其他接入」——发布前检查给这类问题配了修复(engine/autofix.py 的 provider.*:改用一个启用的接入,或不再指定),修复
按 land 算出修完以后执行时落到哪里,落不稳的候选不给。
按 land 算出修完以后执行时落到哪里,落不稳的候选不给。一个候选都落不稳时修复为空,这时不指到发布前检查(那里什么
都不给),说去设置里核对、启用,再说卡在哪里(第一处落不稳的地方,比如成员只写了停用接入的模型)——不然人去设置里
找那个写错的名字,找不到,也看不出真正挡住修复的是别处。点名的名字恰好是某个启用接入的模型时,末尾补一句这是哪个
接入的模型、在这一处怎么用它(_model_note:按检查器里有哪几栏写,不叫人去一个没有的「接入」栏留空)。
节点没写模型、点名的接入(自己的或全图默认的)本身不存在或已停用时,校验那条「没有指定模型,将使用接入「X」的
默认模型」(schema.MODEL_UNSET)和这里的问题互相矛盾,由 quiet_unset_model 去掉前者。
接入表要读库,门禁本身不查库(governance.lint_for_publish),所以由接口这一层读出接入表交进来,同数据目录
SQL 检查器的做法。设置里点名接入的几项由 api/settings.py 在保存时查。
@@ -32,17 +38,26 @@ from typing import Any, Callable
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.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, named_provider, provider_for_model, serves,
NAMED_FIX, ProviderNotConfigured, fallback_provider, list_providers, model_fact, model_note, named_provider,
provider_for_model, serves,
)
#: 这类问题的编号:provider.missing(点名的接入不存在)、provider.disabled(已停用;只写了模型、提供它的接入停用了也是它)
CODES = frozenset({"provider.missing", "provider.disabled"})
#: 提示的后半句:这一处写的是接入还是模型、检查器里有没有这一项的下拉框
_WHERE = {("provider", True): "或在节点中换一个接入", ("model", True): "或在节点中换一个模型",
("provider", False): "或在「发布前检查」中改用其他接入", ("model", False): "或在「发布前检查」中改用其他接入"}
#: 提示的后半句:这一处写的是接入还是模型、检查器里有没有这一项的下拉框。检查器里没有下拉框的,发布前检查给了修复
#: 才指到那里(IN_CHECK),否则不写后半句
_WHERE = {"provider": "或在节点中换一个接入", "model": "或在节点中换一个模型"}
IN_CHECK = "或在「发布前检查」中改用其他接入"
#: 检查器里没有下拉框、发布前检查也给不出修复时,提示末尾说卡在哪里(Landing.why)
NO_SWITCH = "无法改用其他接入:"
#: 一个启用的接入都没有:没点名接入的执行时换成内置演示模型,点名的调不了
NO_ENABLED = "没有启用的模型接入"
#: 修完以后执行时落到演示类接入(kind 为 mock):说明里照实写。K1 要防的就是悄悄换成演示模型——落到它是人选的,
#: 得让人知道选的是什么
DEMO_LANDING = "(演示用,不调用真实模型)"
#: 检查器里有模型下拉框的几类节点(前端 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)
@@ -76,7 +91,8 @@ _TAKES_MODEL: dict[NodeType, Callable[[GraphSpec, GraphNode], bool] | None] = {
NodeType.VALIDATE: _repairs,
NodeType.RETRIEVE: lambda spec, node: _cfg(spec, node, "rerank", "off") == "model",
}
#: 核查节点:接入只读节点自己的配置(verify_model_spec),图级默认的接入是写作模型的
#: 核查节点:接入只读节点自己的配置(verify_model_spec),图级默认的接入是写作模型的。值是修复标题里的称呼
#:(「选择……的盲算模型改用的模型接入」);问题里说「……的「盲算模型 · 接入」指定的」,写检查器上那一栏的叫法
_VERIFY = {NodeType.DUAL_CHECK: "的盲算模型", NodeType.CHALLENGE: "的反方模型"}
@@ -87,7 +103,10 @@ def _name(value: Any) -> str | None:
@dataclass(frozen=True)
class ProviderRef:
"""图里写着接入或模型的一处:落在哪个节点(全图默认为 None)、哪个字段,写的名字,句子里怎么称呼这一处,检查器里
有没有这一项的下拉框;kind 是写的接入(provider)还是模型(model)。"""
有没有这一项的下拉框;kind 是写的接入(provider)还是模型(model)。
who 是修复标题里的称呼(「选择{who}改用的模型接入」);said 是「{said}指定的模型接入「X」」里的主语,默认同 who。
核查节点两者不同:指定接入的是「盲算模型 · 接入」这一栏,不是盲算模型本身。"""
node_id: str | None
field: str
@@ -95,12 +114,17 @@ class ProviderRef:
who: str
editable: bool = False
kind: str = "provider"
said: str = ""
@property
def subject(self) -> str:
return self.said or self.who
def _place(node_id: str | None, field: str, value: Any, who: str, *, editable: bool = False,
kind: str = "provider") -> ProviderRef | None:
kind: str = "provider", said: str = "") -> ProviderRef | None:
name = _name(value)
return ProviderRef(node_id, field, name, who, editable, kind) if name else None
return ProviderRef(node_id, field, name, who, editable, kind, said) if name else None
@dataclass(frozen=True)
@@ -123,48 +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)
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):
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]:
@@ -192,23 +224,94 @@ def broken(refs: list[ProviderRef], providers: list[Any]) -> list[tuple[Provider
return out
def issues(refs: list[ProviderRef], providers: list[Any], *, level: str) -> list[ValidationIssue]:
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_fixable
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。原因和运行时报的同一句,提示按这一处在界面上能不能改。"""
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.kind, ref.editable]}")
for ref, e in broken(refs, providers)]
发布一类 error。原因和运行时报的同一句,提示按这一处在界面上能不能改(_tail);点名的名字恰好是某个启用接入的
模型时补一句(_model_note)。"""
out = []
for ref, e in broken(provider_refs(spec), providers):
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]}"
f"{_tail(spec, ref, providers)}{note}"))
return out
def quiet_unset_model(spec: GraphSpec, found: list[Any], provider_issues: list[Any]) -> list[Any]:
"""去掉被接入问题盖住的「没有指定模型,将使用接入「X」的默认模型」(schema.MODEL_UNSET)。
节点没写模型、取到的接入(自己点名的,没写就是全图默认点名的,同 NodeContext.cfg)本身不存在或已停用时,那条说
会用 X 的默认模型照常执行,接入那一条说执行到这里会失败,读者无从判断哪条是真的。接入那一条带着原因和改法,留它。
found 是校验的问题(ValidationIssue),provider_issues 是 issues() 的结果;画布校验和发布一类各调一次。"""
hit = {(i.node_id, i.field) for i in provider_issues if i.code in CODES}
if not hit:
return found
nodes = spec.node_map()
def covered(issue: Any) -> bool:
if issue.code != MODEL_UNSET or (node := nodes.get(issue.node_id)) is None:
return False
own = node.config.get("provider") not in (None, "")
return ((node.id, "provider") if own else (None, "defaults.provider")) in hit
return [i for i in found if not covered(i)]
@dataclass(frozen=True)
class Landing:
"""执行时这一处落到哪里:where 是说给人听的(「工作流默认设置指定的接入「甲」」……),model 是用哪个模型(交给设置时
为 None),ok 是修复能不能把它落在这里。"""
为 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:
@@ -216,40 +319,54 @@ 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)
where = f"{use.provider.who}指定的接入「{found.name}」"
elif model:
return Landing(e.reason, model, False, why=f"{use.provider.subject}指定的{e.reason}")
where = f"{use.provider.subject}指定的接入「{found.name}」"
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}」"
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]:
"""读接入表、查一遍。图里执行时取模型的几处都没写接入和模型时不读库。"""
refs = provider_refs(spec)
if not refs:
if not provider_refs(spec):
return []
return issues(refs, await list_providers(session), level=level)
return issues(spec, await list_providers(session), level=level)
async def load_providers(session: AsyncSession, spec: GraphSpec) -> list[Any] | None:
+18 -1
View File
@@ -606,6 +606,19 @@ JUDGE_KEYS = ("provider", "model", "max_claims", "max_cost_usd", "timeout_s", "r
#: 证据相矛盾时正式运行怎么判档:degrade 降档(默认)/ withhold 不予出具。探索运行只标注
JUDGE_ON_UNSUPPORTED = ("degrade", "withhold")
#: 「没有指定模型」那条提示的编号。节点(或全图默认)点名了接入、而那个接入本身不存在或已停用时,它和接入那条问题
#: 互相矛盾(一条说照常执行、一条说会失败),接口层读了接入表以后按编号去掉它(provider_refs.quiet_unset_model)
MODEL_UNSET = "model.unset"
def _named_provider_of(spec: GraphSpec, node: GraphNode) -> str | None:
"""节点执行时点名的接入名:节点上没写(None、空串)就取全图默认,同 NodeContext.cfg;只有空白、不是字符串的不算
点了名(同 provider_refs._name)。"""
value = node.config.get("provider")
if value in (None, ""):
value = spec.defaults.get("provider")
return value.strip() or None if isinstance(value, str) else None
def claims_problem(value: Any) -> str | None:
"""claims 的取值有什么问题,没问题返回 None。校验和报告撰写节点执行时报的是同一句话。"""
@@ -929,11 +942,15 @@ def validate_graph(spec: GraphSpec) -> ValidationResult:
if node.type in (NodeType.LLM, NodeType.AGENT, NodeType.SUPERVISOR, NodeType.REPORT):
if not (cfg.get("model") or spec.defaults.get("model")):
# 点名了接入就用那个接入的默认模型,照实说是哪个:检查器、问题面板里只写「默认模型」,人会以为是第一个
# 可用的接入。那个接入本身不存在或已停用时,接口层去掉这一条,只留接入的问题(MODEL_UNSET)
named = _named_provider_of(spec, node)
result.add(
"没有指定模型,将使用默认模型",
f"没有指定模型,将使用接入「{named}」的默认模型" if named else "没有指定模型,将使用默认模型",
level="warning",
node_id=node.id,
field="model",
code=MODEL_UNSET,
)
# 表达式:用运行时同一个 parse_expression 先解析一遍。以前写错了只有跑到那一步
+72 -17
View File
@@ -34,13 +34,19 @@ class ModelSpec(BaseModel):
class ProviderNotConfigured(RuntimeError):
"""模型接入没配好。reason 说缺了什么,hint 说去哪里补;str(e) 是两句连起来的整句,
运行失败原因直接用它。设置页的测试弹窗本来就在接入的编辑框里,只取 reason、另给自己的 hint。
code 给调用方按类别分支:missing(点名的接入不存在)/ disabled / no_model / missing_key / missing_base_url。"""
code 给调用方按类别分支:missing(点名的接入不存在)/ disabled / no_model / missing_key / missing_base_url。
def __init__(self, reason: str, hint: str = "", code: str = "") -> None:
super().__init__(f"{reason}。{hint}" if hint else reason)
name、model_owner 只有 named_provider 填:点名的名字(去掉两边空白)恰好是某个启用接入的模型时,model_owner 是
那个接入的名称。str(e) 末尾补上 model_note 那一句;画布、设置页、助手各自另拼,说清在哪一栏改(同 hint)。"""
def __init__(self, reason: str, hint: str = "", code: str = "", *, name: str = "", model_owner: str = "") -> None:
note = model_note(name, model_owner) if model_owner else ""
super().__init__("。".join(part for part in (reason, hint, note) if part))
self.reason = reason
self.hint = hint
self.code = code
self.name = name
self.model_owner = model_owner
async def list_providers(session: AsyncSession) -> list[Provider]:
@@ -48,24 +54,54 @@ 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_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 是这一处模型、接入两栏的叫法,
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:
"""提供这个模型的启用接入,认法同 provider_for_model(先认模型列表、再认默认模型)。只看启用的:停用接入的模型
补了这一句,照着做也一样调不了。"""
enabled = [p for p in providers if p.enabled]
return next((p for p in enabled if any((m or {}).get("id") == name for m in (p.models or []))), None) \
or next((p for p in enabled if p.default_model == name), None)
def named_provider(providers: list[Provider], ref: Any) -> Provider:
"""按名称或 id 找显式点名的接入(providers 是 list_providers 的结果)。找不到、找到的已停用都抛
ProviderNotConfigured:点名了就只用它,不换别家。运行时取模型、设置和画布校验都按这一处认,说法一致。
名称两边的空白不算;不是字符串的(接口写进来的数字一类)当作点了一个不存在的名字。"""
名称两边的空白不算;不是字符串的(接口写进来的数字一类)当作点了一个不存在的名字。点名的名字恰好是某个启用接入
的模型时,异常带上 model_owner,报错补一句这是哪个接入的模型、怎么用它(model_note)。"""
key = ref.strip() if isinstance(ref, str) else str(ref)
found = next((p for p in providers if key in (p.id, p.name)), None) \
or next((p for p in providers if (p.name or "").strip() == key), None)
if found is not None and found.enabled:
return found
owner = _model_owner(providers, key)
trap = {"name": key, "model_owner": owner.name} if owner is not None else {}
if found is None:
raise ProviderNotConfigured(f"模型接入「{key}」不存在", f"{NAMED_FIX['missing']},或换一个接入", code="missing")
if not found.enabled:
raise ProviderNotConfigured(f"模型接入「{found.name}」已停用", f"{NAMED_FIX['disabled']},或换一个接入",
code="disabled")
return found
raise ProviderNotConfigured(f"模型接入「{key}」不存在", f"{NAMED_FIX['missing']},或换一个接入", code="missing",
**trap)
raise ProviderNotConfigured(f"模型接入「{found.name}」已停用", f"{NAMED_FIX['disabled']},或换一个接入",
code="disabled", **trap)
def serves(provider: Any, model: str) -> bool:
@@ -100,17 +136,36 @@ def fallback_provider(providers: list[Provider]) -> Provider | None:
return (real or enabled or [None])[0]
def assistant_unavailable(e: BaseException) -> str:
#: 助手的模型在界面上只有一处能改:「编排」页助手栏的「助手使用的模型」(设置页里没有这一项)。选「跟随默认」或一个
#: 模型都会清掉存着的接入。画布、问数据页、表格导入、数据目录起草报的是同一句,所以写明是哪一页
ASSISTANT_MODEL_FIELD = "助手使用的模型"
ASSISTANT_PICKER = f"「编排」页助手栏的「{ASSISTANT_MODEL_FIELD}」中"
ASSISTANT_REPICK = f"或在{ASSISTANT_PICKER}重新选择"
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:
return f"助手使用的{getattr(e, 'reason', '') or e}。{NAMED_FIX[code]},或为助手换一个模型"
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(
+22 -14
View File
@@ -6,7 +6,8 @@
- 画布校验:节点点名的接入不存在或已停用给警告,说清哪个节点、哪个接入;发布前检查、发布、自动修复的复核给错误,
挡住发布。只查执行时真会去取模型的那几处(分支「让模型分类」、校验「让模型修复」且允许重试、检索「让模型重排」
时才算;核查节点自己的开关关着不算);报告没写裁判配置时查全图默认里的那一份;
- 提示按这一处在界面上能不能改:检查器里有接入下拉框的说「在节点中换一个接入」,别处说「在发布前检查中改用」;
- 提示按这一处在界面上能不能改:检查器里有接入下拉框的说「在节点中换一个接入」,别处在发布前检查给得出修复时
说「在发布前检查中改用」(给不出时不说,见 test_provider_ref_wording);
- 发布前检查给每一处配一条要人选的修复:改用一个启用的接入,或不指定接入(修完以后执行时落到哪里、只写了模型而
提供它的接入已停用的那一处,见 test_provider_ref_landing)。
@@ -72,18 +73,25 @@ FIELDS = [
("verify", "provider", "核查模型 · 接入"),
("judge", "provider", "裁判模型 · 接入"),
("run", "tool_gate_provider", "门控模型 · 接入"),
("copilot", "provider", "助手模型 · 接入"),
("copilot", "provider", "助手使用的模型"),
]
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"].startswith(f"「{label}」填写的模型接入「{GONE}」不存在。"), body["detail"]
assert "请在「设置 → 模型接入」中核对名称,或换一个接入" in body["detail"] and "留空表示" in body["detail"]
assert body["detail"] == refused(group, label, f"模型接入「{GONE}」不存在", "请在「设置 → 模型接入」中核对名称")
assert await stored(group) is None
@@ -93,8 +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"].startswith(f"「{label}」填写的模型接入「{OFF}」已停用。"), body["detail"]
assert "请在「设置 → 模型接入」中启用它,或换一个接入" in body["detail"]
assert body["detail"] == refused(group, label, f"模型接入「{OFF}」已停用", "请在「设置 → 模型接入」中启用它")
assert await stored(group) is None
@@ -207,8 +214,9 @@ async def test_every_place_a_graph_names_a_provider_is_checked(client):
f"「协作」(多 Agent 协作)的成员「分析师」指定的模型接入「{GONE}」不存在")
assert got[("write", "judge.provider")]["message"].startswith(
f"「写周报」(报告撰写)的结论句裁判指定的模型接入「{GONE}」不存在")
assert got[("dual", "provider")]["message"].startswith(f"「复核销售额」(双盲复算)的盲算模型指定的模型接入「{GONE}」")
assert got[("chal", "provider")]["message"].startswith(f"「审稿」(反方审稿)的反方模型指定的模型接入「{OFF}」已停用")
assert got[("dual", "provider")]["message"].startswith(
f"「复核销售额」(双盲复算)的「盲算模型 · 接入」指定的模型接入「{GONE}」")
assert got[("chal", "provider")]["message"].startswith(f"「审稿」(反方审稿)的「反方模型 · 接入」指定的模型接入「{OFF}」已停用")
assert got[(None, "defaults.provider")]["message"].startswith(f"工作流默认设置指定的模型接入「{GONE}」不存在")
@@ -281,14 +289,15 @@ async def test_publish_check_reads_the_unsaved_canvas_and_autofix_keeps_the_prob
async def test_handing_only_a_provider_problem_to_the_assistant_says_it_cannot_help(client):
"""修复之后门禁不再拦、只剩点名接入的问题:不说「已没有阻止发布的错误」,说清助手处理不了、该去哪里改。"""
from app.api.workflows import PROVIDER_LEFT
"""修复之后门禁不再拦、只剩点名接入的问题:不说「已没有阻止发布的错误」,说清助手处理不了、该去哪里改(这一处
有修复,连同各条问题的修复一起说;没有修复时的说法见 test_provider_ref_wording)。"""
from app.api.workflows import PROVIDER_LEFT, PROVIDER_LEFT_FIX
wf = await _workflow(writer(None))
r = await client.post(f"/api/workflows/{wf}/autofix", json={"level": "published", "graph": writer(GONE),
"assist": True})
out = r.json()
assert out["assist"] == {"ok": False, "summary": PROVIDER_LEFT, "questions": []}
assert out["assist"] == {"ok": False, "summary": PROVIDER_LEFT + PROVIDER_LEFT_FIX, "questions": []}
assert out["ok"] is False and [i["code"] for i in provider_issues({"issues": out["remaining"]})] == [
"provider.missing"]
r = await client.post(f"/api/workflows/{wf}/autofix", json={"level": "published", "graph": writer(LIVE),
@@ -303,15 +312,14 @@ async def test_publishing_a_clean_graph_is_unaffected(client):
@pytest.mark.parametrize("group,key,label", [("run", "tool_gate_provider", "门控模型 · 接入"),
("copilot", "provider", "助手模型 · 接入")])
("copilot", "provider", "助手使用的模型")])
async def test_a_provider_name_that_is_not_text_is_refused_with_the_field(client, group, key, label):
"""门控、助手两组没有自己的格式检查:名字写成数字一类以前照样存下,查助手当前用哪个模型时就出错。"""
r = await client.put("/api/settings", json={"values": {group: {key: 123}}})
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"].startswith(f"「{label}」需要填写模型接入的名称,留空表示"), body["detail"]
assert body["detail"].endswith("当前为「123」")
assert body["detail"] == f"「{label}」需要填写模型接入的名称,当前为「123」", body["detail"]
assert await stored(group) is None
+10 -6
View File
@@ -103,8 +103,11 @@ def test_a_node_naming_only_a_model_of_a_disabled_provider_is_stopped_before_pub
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": "分析"}]),
@@ -112,10 +115,11 @@ def test_the_problem_is_recorded_once_where_the_model_is_written():
agents=[{"name": "研究员", "model": "丙一号", "system": "研究"}]),
defaults={"model": "丙一号"})
got = [(i["node_id"], i["field"], i["message"]) for i in gate(graph)]
tail = "请在「设置 → 模型接入」中启用它,或在「发布前检查」中改用其他接入"
enable = "请在「设置 → 模型接入」中启用它"
assert got == [
("team2", "agents[0].model", f"「协作二」(多 Agent 协作)的成员「研究员」指定的模型「丙一号」{STOPPED}。{tail}"),
(None, "defaults.model", f"工作流默认设置指定的模型「丙一号」{STOPPED}。{tail}"),
("team2", "agents[0].model", f"「协作二」(多 Agent 协作)的成员「研究员」指定的模型「丙一号」{STOPPED}。{enable}。"
f"无法改用其他接入:工作流默认设置指定的模型「丙一号」{STOPPED}"),
(None, "defaults.model", f"工作流默认设置指定的模型「丙一号」{STOPPED}。{enable},或在「发布前检查」中改用其他接入"),
]
@@ -127,7 +131,7 @@ def test_verify_nodes_and_the_judge_naming_only_a_model_of_a_disabled_provider_a
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[("dual", "model")] == (f"「复核销售额」(双盲复算)的「盲算模型」指定的模型「丙一号」{STOPPED}。"
"请在「设置 → 模型接入」中启用它,或在节点中换一个模型")
assert got[("write", "judge.model")].startswith(f"「写周报」(报告撰写)的结论句裁判指定的模型「丙一号」{STOPPED}")
+8 -5
View File
@@ -430,9 +430,11 @@ async def test_judging_on_demand_with_a_broken_judge_provider_says_why(monkeypat
# --------------------------------------------------------------------------
#: 助手报给人的那一句:照实说是哪个接入、在设置里能做什么,或者给助手换一个模型(不再说「尚未配置完成」)
SAID = {MISSING: f"助手使用的{REASONS[MISSING]}。请在「设置 → 模型接入」中核对名称,或为助手换一个模型",
DISABLED: f"助手使用的{REASONS[DISABLED]}。请在「设置 → 模型接入」中启用它,或为助手换一个模型"}
#: 助手报给人的那一句:照实说是哪个接入、在设置里能做什么,或者到「编排」页助手栏重新选一个模型(设置页里没有助手的
#: 接入这一项,能改的只有那个下拉框;不再说「尚未配置完成」)
REPICK = "或在「编排」页助手栏的「助手使用的模型」中重新选择"
SAID = {MISSING: f"助手使用的{REASONS[MISSING]}。请在「设置 → 模型接入」中核对名称,{REPICK}",
DISABLED: f"助手使用的{REASONS[DISABLED]}。请在「设置 → 模型接入」中启用它,{REPICK}"}
@pytest.mark.parametrize("ref", [MISSING, DISABLED])
@@ -591,8 +593,9 @@ async def test_after_the_publish_fix_stops_naming_a_disabled_provider_the_formal
[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}」"
# 没点名接入、也没写模型时落到第一个可用的接入:本文件只建了一个启用的接入,是演示类的,说明里照实写
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()
+623
View File
@@ -0,0 +1,623 @@
"""点名的模型接入(K1)收尾:说法与修复候选。
K1 让点名的接入不存在或已停用时照实报错、发布前挡住并给修复。复核留下的几处:
- 核查节点那句「……的盲算模型指定的模型接入」:指定接入的是「盲算模型 · 接入」这一栏,不是模型本身;
- 修复规划:没有候选能落稳时修复为空,问题却仍说「或在「发布前检查」中改用其他接入」;按(编号、节点、字段)认不出修复
时退到同节点、同编号别处的修复,接口里的 fix 指错;改动波及、但本来就因自己点名的接入坏着的那一处也算落不稳,两处
互相牵连时谁都给不出修复;
- 「不指定接入」落到演示类接入时,说明不提「演示用、不调用真实模型」——K1 要防的就是悄悄换成演示模型;
- 点名的接入名恰好是某个启用接入的模型:提示只说去启用那条旧接入,用户多半想要的是这个模型;
- 保存设置 422 的说法:下拉框写成「填写的」,留空那半句和设置页的叫法对不上,助手那一项叫法界面上没有;
- 「交给助手」后只剩接入问题时的说明:一句两个冒号、「核对」没有宾语;
- 同一节点同时提示「没有指定模型,将使用默认模型」和「指定的模型接入不存在」,互相矛盾;
- 助手的模型取不到时,提示指到真正能改它的地方(「编排」页助手栏的「助手使用的模型」)。
第 1 轮复核又留下的几处:
- 「不指定接入」交给设置(核查、裁判)时,落到演示类接入也没说;
- 「接入名恰好是模型名」那句叫人「接入留空」,多数节点的检查器里没有接入这一栏,模型又是下拉框却写「填写」;
- 给不出修复时只剩「核对名称」,没说真正卡住修复的是哪一处;
- 「演示用,不调用真实模型」只是淡色小字:候选另带 demo 标记,界面上加醒目的标签;
- 画布校验为每处坏掉的接入把每个候选都在整张图上算一遍,几十处同时坏掉时开销随规模平方增长;
- 助手那一项的 422 说法和助手报错、助手栏对不上;助手栏下拉下面的原因把人指回这个下拉本身;
- 裁判的格式检查仍写「留空表示不指定」;「交给助手」后只剩接入问题时的说明措辞。
纯函数的用例用虚构的接入表,不读库;接口的用例建自己的几条接入、用完删掉。接入名、节点名、模型名都是虚构的。
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import delete
from app.db.base import SessionLocal
from app.db.models import Provider, Setting, Workflow, WorkflowVersion
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
from app.engine.schema import GraphSpec
from app.main import app
from app.providers.factory import ProviderNotConfigured, assistant_unavailable, named_provider
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)
#: 停用的接入恰好叫「乙一号」——启用的乙接入提供同名的模型(开发数据里的原样:模型名被建成了一条接入)
TRAP = provider("乙一号", "旧一号", enabled=False)
TABLE = [JIA, YI, BING]
DEMO = provider("演示接入", "演示一号", kind="mock")
DEMO_NOTE = "(演示用,不调用真实模型)"
MISSING_FIX, DISABLED_FIX = "请在「设置 → 模型接入」中核对名称", "请在「设置 → 模型接入」中启用它"
IN_CHECK = ",或在「发布前检查」中改用其他接入"
NO_SWITCH = "。无法改用其他接入:"
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) -> dict[tuple, dict]:
found = provider_issues(publish_issues(GraphSpec.model_validate(graph), level="published", providers=table))
return {(i["node_id"], i["field"]): i for i in found}
def checked(graph: dict, table=TABLE) -> dict:
return check(GraphSpec.model_validate(graph), level="published", providers=table)
def fix_of(graph: dict, fid: str, table=TABLE) -> dict | None:
return next((f for f in checked(graph, table)["fixes"] if f["id"] == fid), None)
def hints(fix: dict) -> dict:
return {o["value"]: o.get("hint") for o in fix["options"]}
# --------------------------------------------------------------------------
# 核查节点:指定接入的是「盲算模型 · 接入」这一栏
# --------------------------------------------------------------------------
def test_a_verify_node_names_the_field_that_points_at_the_provider():
"""双盲复算、反方审稿:问题里写检查器上那一栏的叫法(「盲算模型 · 接入」「反方模型」),不说「盲算模型指定的接入」。
修复的标题仍按角色说(「选择……的盲算模型改用的模型接入」),读得通。"""
graph = flow(node("write", "report", "写周报", instructions="写周报", provider="甲接入"),
node("dual", "dual_check", "复核销售额", provider="戊接入"),
node("chal", "challenge", "审稿", report="write", provider="丙接入"),
node("chal2", "challenge", "复审", report="write", model="丙一号"))
got = gate(graph)
assert got[("dual", "provider")]["message"] == (
f"「复核销售额」(双盲复算)的「盲算模型 · 接入」指定的模型接入「戊接入」不存在。{MISSING_FIX},或在节点中换一个接入")
assert got[("chal", "provider")]["message"] == (
f"「审稿」(反方审稿)的「反方模型 · 接入」指定的模型接入「丙接入」已停用。{DISABLED_FIX},或在节点中换一个接入")
assert got[("chal2", "model")]["message"] == (
"「复审」(反方审稿)的「反方模型」指定的模型「丙一号」所属的模型接入「丙接入」已停用。"
f"{DISABLED_FIX},或在节点中换一个模型")
assert fix_of(graph, "provider.missing:dual:provider")["label"] == "选择「复核销售额」(双盲复算)的盲算模型改用的模型接入"
# --------------------------------------------------------------------------
# 没有修复时:提示不指到发布前检查,也不借别处的修复
# --------------------------------------------------------------------------
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}{NO_SWITCH}"
"「协作」(多 Agent 协作)的成员「研究员」指定的模型「丙一号」所属的模型接入「丙接入」已停用")
[warn] = provider_ref_issues(GraphSpec.model_validate(graph), TABLE, level="warning")
assert warn.message == issue["message"]
graph["nodes"][1]["data"]["config"]["agents"][0]["model"] = "甲二号"
[issue] = provider_issues(checked(graph)["issues"])
assert issue["fix"] == "provider.missing:team:provider"
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():
"""全图默认点名停用的丙,节点「提问」只写了丙家的模型(跟随全图默认的接入),节点「复述」自己点名丙:复述那一处有
修复(改用甲、乙),全图默认那一处没有(换掉或去掉,提问都落不稳)。全图默认那条不能认到复述的修复上去。"""
graph = flow(node("a", "llm", "提问", model="丙一号", prompt="总结"),
node("b", "llm", "复述", provider="丙接入", model="乙一号", prompt="复述"),
defaults={"provider": "丙接入"})
found = checked(graph)
got = {(i["node_id"], i["field"]): i for i in provider_issues(found["issues"])}
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}{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)
# --------------------------------------------------------------------------
# 本来就因自己点名的接入坏着的那一处,不否决别处的修复
# --------------------------------------------------------------------------
def test_a_place_broken_by_its_own_provider_does_not_veto_a_fix_elsewhere():
"""「提问」点名不存在的戊接入、模型取全图默认的丙一号(只有停用的丙提供),「复述」跟随全图默认。去掉全图默认的模型
以后,提问照样因为戊接入不存在而失败——这不是这次改动造成的,不拿它否决全图默认的修复;否则两条都没有修复,
规划器自己卡住。先修全图默认,提问的修复随之出现(不指定接入、改用甲、改用乙)。"""
graph = flow(node("a", "llm", "提问", provider="戊接入", prompt="总结"),
node("b", "llm", "复述", prompt="复述"),
defaults={"model": "丙一号"})
found = checked(graph)
got = {(i["node_id"], i["field"]): i for i in provider_issues(found["issues"])}
assert set(got) == {("a", "provider"), (None, "defaults.model")}
fid = "provider.disabled:graph:defaults.model"
assert got[(None, "defaults.model")]["fix"] == fid
assert got[("a", "provider")]["fix"] is None
assert hints(fix_of(graph, fid))[""] == "去掉这一处指定的模型,执行时改用第一个可用的模型接入「甲接入」"
out = apply_fixes(graph, [fid], {fid: ""}, level="published", providers=TABLE)
assert out["applied"] == [fid] and out["rejected"] == [], out["rejected"]
assert "model" not in (out["graph"].get("defaults") or {})
[left] = provider_issues(out["remaining"])
assert (left["node_id"], left["field"], left["fix"]) == ("a", "provider", "provider.missing:a:provider")
[fix] = [f for f in out["fixes"] if f["id"] == left["fix"]]
assert [o["value"] for o in fix["options"]] == ["", "甲接入", "乙接入"]
def test_the_place_being_fixed_is_never_waved_through():
"""被修的那一处自己换了接入引用:去掉节点的戊接入以后落到全图默认的戊接入,原因一字不差也不算修好。"""
graph = flow(node("ask", "llm", "提问", provider="戊接入", prompt="总结"), defaults={"provider": "戊接入"})
found = checked(graph)
fid = "provider.missing:ask:provider"
[fix] = [f for f in found["fixes"] if f["id"] == fid]
assert "" not in hints(fix)
out = apply_fixes(graph, [fid], {fid: ""}, level="published", providers=TABLE)
assert out["applied"] == [], out
# --------------------------------------------------------------------------
# 落到演示类接入时照实说
# --------------------------------------------------------------------------
def test_stop_naming_a_provider_that_lands_on_a_demo_provider_says_so():
"""启用的只有演示类接入:「不指定接入」落到它,说明里和「改用演示接入」那一项一样写明演示用、不调用真实模型。"""
table = [BING, DEMO]
lone = flow(node("ask", "llm", "提问", provider="丙接入", model="丙一号", prompt="总结"))
got = hints(fix_of(lone, "provider.disabled:ask:provider", table))
assert got[""] == f"去掉这一处指定的接入和模型「丙一号」,执行时改用第一个可用的模型接入「演示接入」{DEMO_NOTE}"
assert got["演示接入"] == "不提供模型「丙一号」,将改用它的默认模型「演示一号」;演示用的模型接入,不调用真实模型"
# 有启用的真实接入:去掉以后落到真实接入,不带这句
assert hints(fix_of(lone, "provider.disabled:ask:provider", [JIA, BING, DEMO]))[""] == (
"去掉这一处指定的接入和模型「丙一号」,执行时改用第一个可用的模型接入「甲接入」")
# 跟随节点点名的演示接入、按模型名落到演示接入,也一样
graph = flow(node("team", "supervisor", "协作", provider="演示接入", goal="分析",
agents=[{"name": "分析师", "provider": "丙接入", "system": "分析"}]),
node("ask", "llm", "提问", provider="丙接入", model="演示一号", prompt="总结"))
assert hints(fix_of(graph, "provider.disabled:team:agents[0].provider", table))[""] == (
f"去掉这一处指定的接入,执行时改用「协作」(多 Agent 协作)指定的接入「演示接入」{DEMO_NOTE}")
assert hints(fix_of(graph, "provider.disabled:ask:provider", table))[""] == (
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 = "「乙一号」是接入「乙接入」的模型:如需使用该模型,请在模型一栏中选择它,接入留空或选择「乙接入」"
def test_a_provider_name_that_is_really_a_model_gets_a_note():
"""停用的接入叫「乙一号」,启用的乙接入提供模型「乙一号」:照提示启用的会是那条旧接入,用户多半想要的是这个模型。
报错照旧说停用了、去哪里启用,另补一句这是哪个接入的模型、怎么用它。不存在的名字同理;只有停用的接入提供的
模型名、谁的模型都不是的名字不补。"""
table = [JIA, YI, BING, TRAP]
with pytest.raises(ProviderNotConfigured) as e:
named_provider(table, "乙一号")
assert e.value.code == "disabled" and e.value.reason == "模型接入「乙一号」已停用"
assert str(e.value) == f"模型接入「乙一号」已停用。{DISABLED_FIX},或换一个接入。{MODEL_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)
# 助手:并成一句,指到能直接选这个模型的地方(不再另起一句把「助手使用的模型」说两遍)
with pytest.raises(ProviderNotConfigured) as e:
named_provider(table, "乙一号")
assert assistant_unavailable(e.value) == (
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():
"""设置页里没有助手的接入这一项:能改的是「编排」页助手栏的「助手使用的模型」(选「跟随默认」或一个模型会清掉存着的
接入)。表格导入、数据目录的 AI 起草也用助手的模型,同一句。"""
with pytest.raises(ProviderNotConfigured) as e:
named_provider(TABLE, "戊接入")
assert assistant_unavailable(e.value) == (
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("请到「设置 → 模型接入」检查")
# --------------------------------------------------------------------------
# 接口:保存设置、交给助手、画布校验(建自己的几条接入,用完删掉)
# --------------------------------------------------------------------------
LIVE, OFF, GONE = "措辞用接入", "措辞用停用接入", "措辞用接人"
#: 停用的接入,名字恰好是 LIVE 提供的模型
NAMED_LIKE_MODEL = "措辞模型"
SETTING_KEYS = ["verify", "judge", "copilot", "run"]
NAMES = [LIVE, OFF, NAMED_LIKE_MODEL]
async def _clean() -> None:
async with SessionLocal() as session:
await session.execute(delete(Setting).where(Setting.key.in_(SETTING_KEYS)))
await session.execute(delete(Provider).where(Provider.name.in_(NAMES)))
await session.commit()
@pytest.fixture
async def rows():
await _clean()
async with SessionLocal() as session:
session.add_all([
Provider(name=LIVE, kind="mock", models=[{"id": NAMED_LIKE_MODEL}], default_model=NAMED_LIKE_MODEL,
enabled=True, extra={}),
Provider(name=OFF, kind="mock", models=[{"id": "措辞停用模型"}], default_model="措辞停用模型",
enabled=False, extra={}),
Provider(name=NAMED_LIKE_MODEL, kind="mock", models=[{"id": "措辞旧模型"}], default_model="措辞旧模型",
enabled=False, extra={}),
])
await session.commit()
yield
await _clean()
@pytest.fixture
async def client(rows):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
async def stored(key: str):
async with SessionLocal() as session:
row = await session.get(Setting, key)
return row.value if row else None
#: (组, 键, 接入这一项在界面上的叫法, 模型那一项的叫法)
SETTING_FIELDS = [
("verify", "provider", "核查模型 · 接入", "核查模型 · 模型"),
("judge", "provider", "裁判模型 · 接入", "裁判模型 · 模型"),
("run", "tool_gate_provider", "门控模型 · 接入", "门控模型 · 模型"),
]
@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},或换一个接入"
r = await client.put("/api/settings", json={"values": {group: {key: OFF}}})
assert r.json()["detail"] == f"「{label}」指定的模型接入「{OFF}」已停用。{DISABLED_FIX},或换一个接入"
# 名字恰好是启用接入的模型:补一句这是哪个接入的模型、在这一组里怎么用它
r = await client.put("/api/settings", json={"values": {group: {key: NAMED_LIKE_MODEL}}})
note = (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):
r = await client.put("/api/settings", json={"values": {group: {key: 123}}})
assert r.status_code == 422
assert r.json()["detail"] == f"「{label}」需要填写模型接入的名称,当前为「123」"
async def test_the_verify_format_check_says_blank_follows_the_judge_model_like_the_page(client):
r = await client.put("/api/settings", json={"values": {"verify": {"provider": 3}}})
assert r.status_code == 422
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)
session.add(wf)
await session.flush()
session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=graph))
await session.commit()
return wf.id
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_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})
assert r.json()["assist"] == {"ok": False, "summary": PROVIDER_LEFT + PROVIDER_LEFT_FIX, "questions": []}
# 没有一条有修复(成员只写了停用接入的模型,换掉、去掉节点的接入都落不稳):不指到修复
graph = flow(node("team", "supervisor", "协作", provider=GONE, goal="分析",
agents=[{"name": "研究员", "model": "措辞停用模型", "system": "研究"}]))
r = await client.post(f"/api/workflows/{wf}/autofix", json={"level": "published", "graph": graph, "assist": True})
out = r.json()
assert [i["fix"] for i in provider_issues(out["remaining"])] == [None]
assert out["assist"] == {"ok": False, "summary": PROVIDER_LEFT, "questions": []}
def unset_model(result: dict) -> list[dict]:
return [i for i in result["issues"] if i.get("code") == "model.unset"]
async def test_no_model_says_which_provider_and_steps_aside_when_that_provider_is_broken(client):
"""节点点名了接入、没写模型:说用这个接入的默认模型。这个接入本身不存在或已停用时只留接入那一条:一条说会照常
执行、一条说执行到这里会失败,读者无从判断。全图默认的接入同理。没点名接入的照旧说「将使用默认模型」。"""
graph = flow(node("a", "llm", "提问", provider=LIVE, prompt="总结"),
node("b", "llm", "复述", provider=GONE, prompt="复述"),
node("c", "agent", "查数", prompt="查数"),
node("d", "report", "写周报", instructions="写周报", provider=""),
defaults={"provider": OFF})
r = await client.post("/api/workflows/validate", json={"graph": graph})
got = {i["node_id"]: i for i in unset_model(r.json())}
assert set(got) == {"a"}, got
assert (got["a"]["level"], got["a"]["field"], got["a"]["message"]) == (
"warning", "model", f"没有指定模型,将使用接入「{LIVE}」的默认模型")
assert {(i["node_id"], i["field"]) for i in provider_issues(r.json()["issues"])} == {
("b", "provider"), (None, "defaults.provider")}
# 发布前检查同样只留接入那一条
wf = await _workflow(graph)
r = await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "published", "graph": graph})
assert {i["node_id"] for i in unset_model(r.json())} == {"a"}
# 全图默认的接入换成启用的:跟随它的节点说用它的默认模型;什么都没点名的照旧
graph["defaults"] = {"provider": LIVE}
r = await client.post("/api/workflows/validate", json={"graph": graph})
said = {i["node_id"]: i["message"] for i in unset_model(r.json())}
assert said["c"] == said["a"] == f"没有指定模型,将使用接入「{LIVE}」的默认模型"
graph["defaults"] = {}
r = await client.post("/api/workflows/validate", json={"graph": graph})
assert {i["node_id"]: i["message"] for i in unset_model(r.json())}["c"] == "没有指定模型,将使用默认模型"
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()
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 r.json()["panel_error"] is None
assert (await stored("copilot")) == {"provider": "", "model": ""}
+7 -3
View File
@@ -117,6 +117,9 @@ async def test_a_disabled_provider_named_like_a_model_is_not_swapped_for_the_one
with pytest.raises(ProviderNotConfigured) as e:
await resolve_provider(session, "fake-v4.1-flash", "fake-v4.1-flash")
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」是接入「云端接入」的模型:如需使用该模型,请在模型一栏中选择它,接入留空或选择「云端接入」")
assert (await resolve_provider(session, None, "fake-v4.1-flash")).id == serving.id
@@ -201,10 +204,11 @@ async def test_the_assistant_is_told_which_provider_is_broken(session):
with pytest.raises(ProviderNotConfigured) as e:
await resolve_provider(session, ref, model)
said[case] = assistant_unavailable(e.value)
assert said["missing"] == "助手使用的模型接入「甲接人」不存在。请在「设置 → 模型接入」中核对名称,或为助手换一个模型"
assert said["disabled"] == "助手使用的模型接入「停用接入」已停用。请在「设置 → 模型接入」中启用它,或为助手换一个模型"
repick = "或在「编排」页助手栏的「助手使用的模型」中重新选择"
assert said["missing"] == f"助手使用的模型接入「甲接人」不存在。请在「设置 → 模型接入」中核对名称,{repick}"
assert said["disabled"] == f"助手使用的模型接入「停用接入」已停用。请在「设置 → 模型接入」中启用它,{repick}"
assert said["by_model"] == ("助手使用的模型「m-off」所属的模型接入「停用接入」已停用。"
"请在「设置 → 模型接入」中启用它,或为助手换一个模型")
f"请在「设置 → 模型接入」中启用它,{repick}")
keyless = ProviderNotConfigured("模型接入「云端接入」没有配置 API Key", "请在接入中填写", code="missing_key")
assert assistant_unavailable(keyless) == (
"助手使用的模型尚未配置完成:模型接入「云端接入」没有配置 API Key。请到「设置 → 模型接入」检查")
+17 -5
View File
@@ -91,6 +91,21 @@ export interface ProviderTest {
[key: string]: any
}
/**
* 助手当前用哪个模型(GET / PUT /api/copilot/model)。provider、model 是存着的设定;存的接入不存在、已停用(或只存了
* 模型名、提供它的接入已停用)时实际什么都用不了:effective_* 为空,error 是和生成时报的同一句。老后端不给 error
*/
export interface CopilotModel {
configured: boolean
provider: string | null
model: string | null
effective_provider: string | null
effective_model: string | null
error?: string | null
/** 同一件事写在助手栏「助手使用的模型」那一格下面的说法:去向写「上方」,不把人指回这个下拉框本身。老后端不给 */
panel_error?: string | null
}
/** 还没保存的模型接入配置。带 id 表示在编辑已有的那个,api_key 留空沿用已存的 */
export interface ProviderDraft {
id?: string
@@ -718,12 +733,9 @@ export const api = {
layout: (graph: GraphSpec) => post<GraphSpec>('/copilot/layout', { graph }),
fromRun: (runId: string, name?: string) =>
post<any>('/copilot/from-run', { run_id: runId, name }),
getModel: () => get<{
configured: boolean; provider: string | null; model: string | null
effective_provider: string | null; effective_model: string | null
}>('/copilot/model'),
getModel: () => get<CopilotModel>('/copilot/model'),
setModel: (body: { provider?: string | null; model?: string | null }) =>
put<any>('/copilot/model', body),
put<CopilotModel>('/copilot/model', body),
/** 跑完之后复核一次。干净的运行后端直接返回 verdict='ok',不调模型 */
review: (body: { run_id: string; question: string }) =>
post<ReviewResult>('/copilot/review', body),
+33 -6
View File
@@ -7,15 +7,15 @@ import { NODE_DEFS, syntaxOf, type FieldDef, type FieldSyntax, type NodeDef } fr
import { fieldOfIssue, isCaseKeyIssue, unboundToolOf, withToolBound, type FieldRef } from './issues'
import { hintOf } from './shortcuts'
import { isActivePhase } from '../run/trace'
import { EDIT_LOCK_TEXT, useEditLock, useStudio } from '../store/studio'
import { EDIT_LOCK_TEXT, toGraph, useEditLock, useStudio } from '../store/studio'
import { datasourceTools, modelOptions, providerOfModel, useCatalog, useDatasources } from '../store/catalog'
import { api } from '../api/client'
import { IconButton, JsonInput, Modal, isComposing, useRadioGroup } from '../components/ui'
import { formatShortcut } from '../lib/keys'
import { JUDGE_FIELD_LABEL, JUDGE_ON_UNSUPPORTED_HINT, JUDGE_ON_UNSUPPORTED_LABEL, JUDGE_SETTING_TEXT, MERGE_TEXT, CATALOG_UI_TEXT, NODE_TYPE_LABEL, SUBGRAPH_UPGRADE_HELP, judgeByModelText, upgradeNewerText } from '../lib/terms'
import { JUDGE_FIELD_LABEL, JUDGE_ON_UNSUPPORTED_HINT, JUDGE_ON_UNSUPPORTED_LABEL, JUDGE_SETTING_TEXT, MERGE_TEXT, CATALOG_UI_TEXT, NODE_TYPE_LABEL, PROVIDER_REF_TEXT, SUBGRAPH_UPGRADE_HELP, judgeByModelText, upgradeNewerText } from '../lib/terms'
import { TemplateText } from './TemplateText'
import { MERGE_ALIAS, isQueryNode, mergeAlias } from './mergeInputs'
import type { NodeType, ValidationIssue, WorkflowVersion } from '../types'
import type { NodeType, Provider, ValidationIssue, WorkflowVersion } from '../types'
/** 检查器里一条落到字段上的问题 */
type FieldIssue = ValidationIssue & { at: FieldRef | null }
@@ -544,11 +544,38 @@ function SyntaxBadge({ syntax }: { syntax: FieldSyntax }) {
)
}
/**
* 模型一栏留空时执行用什么,同后端 NodeContext.cfg:节点没写的取工作流默认设置里的。工作流默认设置写了模型,用的就是那个
* 模型(后端这时也不报「没有指定模型」);否则节点点名了接入(没写就是全图默认点名的)时是那个接入的默认模型。点名的接入
* 不存在、已停用时空选项说状态、下面不写说明:检查器顶上另有接入那条问题,这里再说「使用……」就和它矛盾。全图默认不在
* 节点里,从 toGraph 带回去的那一份读。接入表还没取回来时不判状态。help 为 undefined 时用节点定义里那句
*/
function modelDefault(config: Record<string, any>, providers: Provider[]): { blank: string; help?: string | null } {
const defaults = toGraph([], []).defaults ?? {}
let value = config.provider
if (value == null || value === '') value = defaults.provider
const named = typeof value === 'string' && value.trim() ? value.trim() : null
const fallback = typeof defaults.model === 'string' && defaults.model.trim() ? defaults.model.trim() : null
// 认法同后端 named_provider:名称或 id,名称两边的空白不算
const found = named ? providers.find((p) => p.id === named || p.name === named || p.name.trim() === named) : undefined
const state = !named || !providers.length || found?.enabled ? 'ok' : found ? 'disabled' : 'missing'
const help = state !== 'ok' ? null : fallback ? PROVIDER_REF_TEXT.modelHelpDefaults(fallback)
: named ? PROVIDER_REF_TEXT.modelHelp(named) : undefined
return { blank: PROVIDER_REF_TEXT.modelBlank(named, state, fallback), help }
}
function Field({ field, nodeId, value, config, issues, onChange }: {
field: FieldDef; nodeId: string; value: any; config: Record<string, any>
issues: FieldIssue[]; onChange: (v: any) => void
}) {
const id = `f-${nodeId}-${field.key}`
// 节点定义里的模型一栏(MODEL_FIELDS):说明跟着全图默认的模型、点名的接入走(modelDefault),节点定义里那句只适用于
// 两样都没写的时候。自己画的模型栏(核查节点的反方模型、盲算模型)只读节点自己的配置、留空跟随设置,不取全图默认,
// 说明在它自己里面——这里再套一句「留空则使用接入「X」的默认模型」就和它相反
const modelField = field.type === 'model' && !field.render
const providers = useCatalog((s) => (modelField ? s.providers : NO_ROWS))
const model = modelField ? modelDefault(config, providers) : null
const help = model?.help === undefined ? field.help : model.help ?? undefined
const syntax = syntaxOf(field)
const composite = field.type === 'cases' || field.type === 'agents' || field.type === 'ioFields'
|| field.type === 'metricsList' || field.type === 'caliberFrom' || field.type === 'judge'
@@ -590,8 +617,8 @@ function Field({ field, nodeId, value, config, issues, onChange }: {
<FieldInput field={field} id={id} nodeId={nodeId} syntax={syntax} value={value} config={config}
invalid={bad} describedBy={[errorId, off ? `${id}-off` : ''].filter(Boolean).join(' ') || undefined}
disabled={!!off} issues={issues} onChange={onChange} />
{field.help && field.type !== 'switch' && (
<div className="mt-1 text-2xs leading-snug text-faint">{field.help}</div>
{help && field.type !== 'switch' && (
<div className="mt-1 text-2xs leading-snug text-faint" data-field-help="">{help}</div>
)}
{off && <div id={`${id}-off`} className="mt-1 text-2xs leading-snug" style={{ color: 'var(--st-waiting)' }}>{off}</div>}
{!!own.length && (
@@ -705,7 +732,7 @@ function FieldInput({ field, id, nodeId, syntax, value, config, invalid, describ
const groups = [...new Set(options.map((o) => o.group))]
return (
<select id={id} className="field" value={value ?? ''} {...aria} onChange={(e) => onChange(e.target.value)}>
<option value="">默认(使用第一个可用的模型接入)</option>
<option value="">{modelDefault(config, providers).blank}</option>
{groups.map((g) => (
<optgroup key={g} label={g}>
{options.filter((o) => o.group === g).map((o) => (
+11 -1
View File
@@ -12,7 +12,9 @@ import {
import { useCatalog } from '../store/catalog'
import { Modal, Spinner, toast, useRadioGroup } from '../components/ui'
import { humanizeError } from '../lib/errors'
import { CATALOG_UI_TEXT as UT, PUBLISH_FIX_TEXT as T, WORKFLOW_STATUS_HINT, WORKFLOW_STATUS_LABEL } from '../lib/terms'
import {
CATALOG_UI_TEXT as UT, PROVIDER_REF_TEXT, PUBLISH_FIX_TEXT as T, WORKFLOW_STATUS_HINT, WORKFLOW_STATUS_LABEL,
} from '../lib/terms'
import { isSqlCheckCode, sqlRuleLabel } from '../lib/sqlcheck'
import { SqlLevelBadge } from '../run/SqlChecks'
import { setLocalActor, useLocalActor } from '../lib/actor'
@@ -622,6 +624,14 @@ function FixControl({ fix, pf }: { fix: PublishFix; pf: PreflightState }) {
checked={picked(o.value)} disabled={pf.working} onChange={() => pf.choose(fix, o.value)} />
<span className="min-w-0 [overflow-wrap:anywhere]">
{o.label}
{/* 修完以后落到演示类接入:说明里那半句是淡色小字、排在长句末尾,选项名旁另加醒目的标签 */}
{o.demo && (
<span className="ml-1 whitespace-nowrap rounded border px-1 font-medium" data-fix-demo=""
title={PROVIDER_REF_TEXT.demoTagHint}
style={{ color: 'var(--warn)', borderColor: 'color-mix(in srgb, var(--warn) 45%, var(--border))' }}>
{PROVIDER_REF_TEXT.demoTag}
</span>
)}
{fix.default !== undefined && (fix.multiple
? Array.isArray(fix.default) && fix.default.some((d) => same(d, o.value))
: same(fix.default, o.value)) && (
+10 -6
View File
@@ -259,7 +259,9 @@ function normalizeFix(raw: any): PublishFix | null {
const options = asArray<any>(raw.options)
.filter((o) => o && typeof o === 'object' && 'value' in o)
.map((o) => ({ value: o.value, label: asText(o.label ?? o.value), hint: o.hint ? asText(o.hint) : null,
...(o.handoff === true ? { handoff: true } : {}) }))
...(o.handoff === true ? { handoff: true } : {}),
// 点名的模型接入:修完以后落到演示类接入(后端 autofix._demo_mark),弹窗在选项名旁加醒目的标签
...(o.demo === true ? { demo: true } : {}) }))
// 选项类没有候选就没法选:不给控件,比给一个空下拉强
if (kind === 'choice' && !options.length) return null
return {
@@ -321,16 +323,18 @@ export function isMissingEndpoint(e: unknown): boolean {
* 这条问题的修复:后端点了 fix id 就按 id 找;没点的(/publish 被拦时的回包只有 code)按 code + 节点认。
* 认不出时和后端 autofix.annotate 同一个退路:图级问题(没有节点,比如「没有带契约的出口」,
* 修复却落在唯一的出口上)认同一编号的第一条修复;图级修复(改的是全图默认,不落在任何节点上)
* 认同一编号的每一条问题。再认不出就没有
* 认同一编号的每一条问题。再认不出就没有。点名的模型接入不走退路(同 annotate)
*/
export function fixFor(issue: ValidationIssue, fixes: PublishFix[]): PublishFix | undefined {
if (issue.fix) return fixes.find((f) => f.id === issue.fix)
if (!issue.code) return undefined
const same = fixes.filter((f) => f.code === issue.code)
// 点名的接入一处一条修复:同一节点上的几处按字段认
const exact = (isProviderRefCode(issue.code)
? same.find((f) => (f.node_id ?? null) === (issue.node_id ?? null) && (f.field ?? null) === (issue.field ?? null))
: undefined) ?? same.find((f) => (f.node_id ?? null) === (issue.node_id ?? null))
if (isProviderRefCode(issue.code)) {
// 点名的接入一处一条修复,只认节点、字段都对得上的那一条(同一节点上的几处按字段分)。没有就是这一处没有修复
//(一个候选都落不稳),不退到同节点、同编号别处的修复上——退过去,这一处的问题下面就画着另一处的选项
return same.find((f) => (f.node_id ?? null) === (issue.node_id ?? null) && (f.field ?? null) === (issue.field ?? null))
}
const exact = same.find((f) => (f.node_id ?? null) === (issue.node_id ?? null))
if (exact) return exact
return issue.node_id == null ? same[0] : same.find((f) => f.node_id == null)
}
+58
View File
@@ -919,6 +919,13 @@ export const PUBLISH_FIX_TEXT = {
whole: '整个工作流',
} as const
/**
* 停用一个接入的后果。不只是下拉里看不到:点名它(或只写了它的模型)的节点、门控 / 裁判 / 核查设置都调不了它(后端
* providers/factory.named_provider)。调不了以后怎样各处不同(节点报错、门控转人工审批、裁判记未裁判、核查记缺口),
* 由设置页各组自己那一句说,这里只说「无法调用」;整句控制在 40 字以内
*/
const PROVIDER_DISABLED_EFFECT = '该接入的模型不再出现在下拉列表中,指定它或其模型的节点和设置也无法调用'
/**
* 点名的模型接入不存在或已停用(后端 providers/factory.named_provider、engine/provider_refs.py)。以前点名的接入不存在时
* 运行悄悄换成演示模型、点名停用的照常调用;现在运行照实报错,设置页、画布校验、发布前检查都提前说出来
@@ -942,6 +949,57 @@ export const PROVIDER_REF_TEXT = {
judgeBroken: '裁判调用将失败,所有结论句记为未裁判',
// 和核查一组「不存在」那句(VERIFY_SETTING_TEXT.section.providerGone)同一个后果
verifyBroken: '核查调用将失败',
/**
* 存着的接入名恰好是某个启用接入的模型(多半是把模型名填进了接入那一栏):照「启用它」去做,启用的是那条旧接入。
* 和服务端保存时 422 的那一句(factory.model_note)一字不差;两栏写这一组的叫法(「门控模型 · 模型」「门控模型 · 接入」)
*/
modelNote: (name: string, owner: string, modelField: string, providerField: string) =>
`「${name}」是接入「${owner}」的模型:如需使用该模型,请在「${modelField}」中填写,「${providerField}」留空或选择「${owner}」`,
/** 接入编辑框「启用」下面的说明、接入卡片上「已停用」的悬停说明 */
enabledHint: `停用后,${PROVIDER_DISABLED_EFFECT}`,
disabledChip: `已停用:${PROVIDER_DISABLED_EFFECT}`,
/**
* 检查器模型一栏的空选项:留空时执行用什么(同后端 NodeContext.cfg:节点没写的取工作流默认设置里的)。工作流默认设置
* 写了模型,用的就是那个模型(后端这时也不报「没有指定模型」);节点(没写就是全图默认)点名了接入,用那个接入的默认
* 模型,照实说是哪个;那个接入不存在、已停用时说状态(检查器顶上另有接入那条问题),不说「使用」。和画布校验那条
* 「没有指定模型,将使用接入「X」的默认模型」同一个说法(后端 schema.MODEL_UNSET)
*/
modelBlank: (named: string | null, state: 'ok' | 'missing' | 'disabled', fromDefaults: string | null = null) => (
state === 'missing' ? `默认(模型接入「${named}」不存在)`
: state === 'disabled' ? `默认(模型接入「${named}」已停用)`
: fromDefaults ? `默认(使用工作流默认设置的模型「${fromDefaults}」)`
: named ? `默认(使用接入「${named}」的默认模型)` : '默认(使用第一个可用的模型接入)'),
/** 同一栏下面的说明(没点名接入时用节点定义里的那句);点名的接入不存在、已停用时不写,免得和接入那条问题打架 */
modelHelp: (named: string) => `留空则使用接入「${named}」的默认模型`,
modelHelpDefaults: (model: string) => `留空则使用工作流默认设置的模型「${model}」`,
/**
* 发布前检查里修完以后落到演示类接入的候选(后端 autofix 的 demo 标记):选项名旁的醒目标签。说明里那半句「演示用,
* 不调用真实模型」照旧写,只是淡色小字、排在长句末尾——K1 要防的就是悄悄换成演示模型
*/
demoTag: '演示模型',
demoTagHint: '选择后执行时由演示模型产出,不调用真实模型',
} as const
/**
* 「编排」页助手栏里「助手使用的模型」那一格(run/Composer.tsx)。服务端报助手的模型取不到时,指的就是这里
* (providers/factory.ASSISTANT_REPICK):设置页没有助手的接入这一项,存着的接入失效了只能在这里重新选
*/
export const ASSISTANT_MODEL_TEXT = {
/** 和服务端 factory.ASSISTANT_MODEL_FIELD 一字不差:报错里让人去「助手使用的模型」中重新选择 */
label: '助手使用的模型',
follow: '跟随默认',
current: (model: string) => `(当前:${model})`,
/**
* 存着的设定下拉里列不出来(点名了接入——下拉只选模型;或者模型不在可选列表中):照实列成一项并选中它。否则下拉停在
* 「跟随默认」,看着一切正常,再选「跟随默认」也不算改动、清不掉存着的接入
*/
saved: (provider: string | null, model: string | null) => (provider
? (model ? `${model}(模型接入「${provider}」)` : `模型接入「${provider}」的默认模型`) : (model ?? '')),
note: '仅影响助手,不改变节点使用的模型。助手需要严格按格式输出,指令遵循能力较弱的模型可能无法生成工作流。',
/** 存着的设定用不了时,齿轮按钮的悬停说明(按钮上另有一个红点) */
broken: '助手使用的模型当前无法调用,点击查看原因',
/** 重新选择时保存失败:下拉退回选之前的样子(服务端存着的没变),在下面说明 */
saveFailed: '保存失败,助手使用的模型未更改',
} as const
/**
+26 -11
View File
@@ -186,7 +186,7 @@ function ProviderCard({ provider: p, kindLabel, onEdit, onRemove }: {
<span className="text-sm font-medium">{p.name}</span>
<span className="chip">{kindLabel}</span>
{p.default_model && <span className="chip mono" title="默认模型">{p.default_model}</span>}
{!p.enabled && <span className="chip" title="停用的接入不会出现在模型下拉里">已停用</span>}
{!p.enabled && <span className="chip" title={PROVIDER_REF_TEXT.disabledChip}>已停用</span>}
<span className="flex-1" />
<HealthPill record={record} checkingSince={checkingSince} />
<div className="flex items-center gap-1">
@@ -575,10 +575,18 @@ function ProviderEditor({ provider, catalog, onClose, onSaved }: {
/>
</div>
<label className="flex items-center gap-2 text-xs">
<input type="checkbox" checked={form.enabled} onChange={(e) => set({ enabled: e.target.checked })} />
启用<span className="text-2xs text-faint">停用后,该接入的模型不会出现在节点的模型下拉列表中</span>
</label>
<div>
<label className="flex items-center gap-2 text-xs">
<input type="checkbox" checked={form.enabled} aria-describedby="provider-enabled-hint"
onChange={(e) => set({ enabled: e.target.checked })} />
启用
</label>
{/* 停用的后果另起一行写在复选框下面,不放进 label:放在里面时它和「启用」是并排的两个 flex 项,长说明会把「启用」
挤成上下两个字,读屏也把整句连着当成复选框的名字念 */}
<p id="provider-enabled-hint" className="mt-0.5 pl-5 text-2xs leading-snug text-faint" data-provider-enabled-hint="">
{PROVIDER_REF_TEXT.enabledHint}
</p>
</div>
{blocked && <p className="text-2xs text-faint">还缺:{missing.join('、')}</p>}
@@ -803,13 +811,18 @@ function PrefsTab() {
const collectionMissing = !collectionNames.includes(cur.collection)
/**
* 接入那一格下面的报错:服务端按这一项拒收的话照它的整句;否则存着(或选着)的接入不存在、已停用时说清这一项会怎样。
* 只在接入表取到了时判「不存在」:表还没取回来,什么都找不到
* 只在接入表取到了时判「不存在」:表还没取回来,什么都找不到。labels 是这一组接入、模型两栏的叫法:名字恰好是某个
* 启用接入的模型时(多半是把模型名填进了接入一栏)补一句怎么用它,同服务端保存时那句,免得照「启用」去启用那条旧接入
*/
const providerError = (field: string, name: string, found: Provider | undefined, broken: string): string | undefined => {
const providerError = (field: string, name: string, found: Provider | undefined, broken: string,
labels: { provider: string; model: string }): string | undefined => {
if (fieldError?.field === field) return fieldError.message
if (!name || !providers.length) return undefined
if (!found) return `${PROVIDER_REF_TEXT.missing(name)}:${broken}`
return found.enabled ? undefined : `${PROVIDER_REF_TEXT.disabled(name)}:${broken}`
if (found?.enabled) return undefined
const state = found ? PROVIDER_REF_TEXT.disabled(name) : PROVIDER_REF_TEXT.missing(name)
const owner = providerOfModel(providers, name)
const note = owner?.enabled ? `。${PROVIDER_REF_TEXT.modelNote(name, owner.provider.name, labels.model, labels.provider)}` : ''
return `${state}:${broken}${note}`
}
// 门控模型留空时后端的挑法(resolve_provider):第一个启用的真实接入,没有就第一个启用的
const enabledProviders = providers.filter((p) => p.enabled)
@@ -817,13 +830,15 @@ function PrefsTab() {
const gateProvider = cur.gateProvider ? providers.find((p) => p.name === cur.gateProvider) : defaultProvider
const gateProviderMissing = !!cur.gateProvider && providers.length > 0 && !gateProvider
// 点名的接入不存在、已停用都调不了(停用的以前照常调用,现在运行时照实报错):两种都在这一格下面说
const gateProviderError = providerError('run.tool_gate_provider', cur.gateProvider, gateProvider, PROVIDER_REF_TEXT.gateBroken)
const gateProviderError = providerError('run.tool_gate_provider', cur.gateProvider, gateProvider, PROVIDER_REF_TEXT.gateBroken,
TOOL_GATE_TEXT)
const gateModels = (gateProvider?.models ?? []).map((m) => m.id)
// 裁判模型的接入:接入和模型都留空时后端依次用 Copilot 的模型、默认接入(judge_model_spec)。只填了模型时
// 这一级写了就整组用这一级,按模型名找接入(resolve_provider)——不再跟随 Copilot,留空那一项不能还叫「跟随 Copilot」
const judgeProvider = cur.judgeProvider ? providers.find((p) => p.name === cur.judgeProvider) : undefined
const judgeProviderMissing = !!cur.judgeProvider && providers.length > 0 && !judgeProvider
const judgeProviderError = providerError('judge.provider', cur.judgeProvider, judgeProvider, PROVIDER_REF_TEXT.judgeBroken)
const judgeProviderError = providerError('judge.provider', cur.judgeProvider, judgeProvider, PROVIDER_REF_TEXT.judgeBroken,
JUDGE_SETTING_TEXT)
const judgeModelName = cur.judgeModel.trim()
const judgeByModel = !cur.judgeProvider && !!judgeModelName
// 裁判模型没有单独配置(接入、模型都留空):后端依次用助手的模型、默认接入
+7 -3
View File
@@ -164,9 +164,13 @@ export function VerifySection({ cur, errors, onEdit, refresh }: {
const provider = cur.provider ? providers.find((p) => p.name === cur.provider) : undefined
const providerMissing = !!cur.provider && providers.length > 0 && !provider
// 点名停用的接入以前照常调用,现在核查时照实报「已停用」、记缺口:和「不存在」一样在这一格下面说。服务端按这一项
// 拒收时(errors.provider,设置页从 422 的 field 落过来)照它的整句
const providerError = errors.provider ?? (providerMissing ? S.providerGone(cur.provider)
: provider && !provider.enabled ? `${PROVIDER_REF_TEXT.disabled(cur.provider)}:${PROVIDER_REF_TEXT.verifyBroken}` : undefined)
// 拒收时(errors.provider,设置页从 422 的 field 落过来)照它的整句。名字恰好是某个启用接入的模型时补一句怎么用它
//(同设置页门控、裁判两组,和服务端保存时那句一致)
const providerTrap = provider?.enabled === false || providerMissing ? providerOfModel(providers, cur.provider) : undefined
const providerNote = providerTrap?.enabled ? `。${PROVIDER_REF_TEXT.modelNote(cur.provider, providerTrap.provider.name, T.model, T.provider)}` : ''
const providerError = errors.provider ?? (providerMissing ? `${S.providerGone(cur.provider)}${providerNote}`
: provider && !provider.enabled
? `${PROVIDER_REF_TEXT.disabled(cur.provider)}:${PROVIDER_REF_TEXT.verifyBroken}${providerNote}` : undefined)
const modelName = cur.model.trim()
const byModel = !cur.provider && !!modelName
const notSet = !cur.provider && !modelName
+69 -26
View File
@@ -1,14 +1,18 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from 'react'
import { AlertCircle, ArrowUp, CornerDownRight, Database, Settings2, Sparkles, Square, Wrench } from 'lucide-react'
import clsx from 'clsx'
import { api } from '../api/client'
import { api, type CopilotModel } from '../api/client'
import { useCatalog, useDatasources, modelOptions } from '../store/catalog'
import { EDIT_LOCK_TEXT, useEditLock, useStudio } from '../store/studio'
import { isComposing, Spinner, TechDetails, useRadioGroup } from '../components/ui'
import { formatShortcut } from '../lib/keys'
import { ASSISTANT_MODEL_TEXT as MT } from '../lib/terms'
import type { DataSource } from '../types'
import { COPILOT_PHASE_TEXT } from './decode'
/** 「助手使用的模型」下拉里代表「存着的设定」的那一项的值(模型 id 不会是它) */
const SAVED = '\u0000saved'
/**
* Copilot 的输入。
*
@@ -39,17 +43,26 @@ export function Composer({ hero, onFocusChange }: {
const lock = useEditLock()
const [text, setText] = useState('')
const [opts, setOpts] = useState(false)
const [model, setModel] = useState('')
const [effective, setEffective] = useState('')
// 存着的助手模型设定(GET /api/copilot/model):接入、模型、实际会用的模型、用不了时的原因
const [saved, setSaved] = useState<CopilotModel | null>(null)
// 重新选择时保存失败:下拉已退回选之前的样子,下面说明没改成
const [saveFailed, setSaveFailed] = useState(false)
// 只认最后一次选择的回包:连着选两次时,先发的那次晚回来也不能盖掉后一次
const picks = useRef(0)
const [useBase, setUseBase] = useState(true)
const ref = useRef<HTMLTextAreaElement>(null)
// 挂上时取一次;每次展开「助手使用的模型」再取一次:接入可能刚在设置里启用、停用或改了名。取的途中人已经重新选过
// 的话不用这一份(它是选之前的样子)。重新取回来的就是服务端现在存着的,上一次「保存失败」的说明不再留着
useEffect(() => {
void api.copilot.getModel().then((m) => {
setModel(m.model ?? '')
setEffective(m.effective_model ?? '')
}).catch(() => undefined)
}, [])
if (!opts && saved) return
let alive = true
setSaveFailed(false)
const seq = picks.current
void api.copilot.getModel().then((m) => { if (alive && seq === picks.current) setSaved(m) }).catch(() => undefined)
return () => { alive = false }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opts])
// 工具栏的 Copilot 按钮把焦点甩过来
useEffect(() => {
@@ -63,18 +76,38 @@ export function Composer({ hero, onFocusChange }: {
if (hero) ref.current?.focus()
}, [hero])
const options = modelOptions(providers)
const groups = [...new Set(options.map((o) => o.group))]
// 存着的设定下拉里列不出来:点名了接入(下拉只选模型,选了会清掉接入),或者模型不在可选列表中。照实列成一项并选中
// 它——否则下拉停在「跟随默认」,看着一切正常,接入失效了也看不出来,再选「跟随默认」不算改动、清不掉存着的接入
const custom = !!saved && (!!saved.provider || (!!saved.model && !options.some((o) => o.value === saved.model)))
const model = custom ? SAVED : saved?.model ?? ''
// 下拉下面写的原因:服务端给的这一格专用的说法(去向写「上方」),老服务端只给整句
const broken = saved?.panel_error || saved?.error || null
const pickModel = (value: string) => {
setModel(value)
void api.copilot.setModel({ model: value || null })
.then((m) => setEffective(m.effective_model ?? ''))
.catch(() => undefined)
if (value === SAVED) return
const before = saved
const seq = ++picks.current
setSaveFailed(false)
// 先按选的显示;存的是 provider 置空、只写模型(跟随默认时两项都空),回来的是服务端认下的样子。跟随默认时实际用
// 哪个模型由服务端说了算,回来之前不写「当前」
setSaved({ configured: !!value, provider: null, model: value || null, effective_provider: null,
effective_model: value || null, error: null, panel_error: null })
void api.copilot.setModel({ model: value || null }).then((m) => { if (seq === picks.current) setSaved(m) }).catch(() => {
if (seq !== picks.current) return
// 没存上:服务端存着的还是原来那一组,退回选之前的样子(原因、齿轮上的红点照旧),并说明保存失败
setSaved(before)
setSaveFailed(true)
})
}
const send = (override?: string) => {
const q = (override ?? text).trim()
if (!q || copilot.active) return
// 被正式运行拦下时(store 已经弹过提示)什么都没开始:这句话留在框里,结束后直接再发
if (!runCopilot(q, useBase && nodes.length > 0, model || undefined)) return
// 被正式运行拦下时(store 已经弹过提示)什么都没开始:这句话留在框里,结束后直接再发。
// 存着的设定列不出来时不带模型:服务端按存着的整组取(带了模型就会绕过存着的接入,和这里说的对不上)
if (!runCopilot(q, useBase && nodes.length > 0, model && model !== SAVED ? model : undefined)) return
setText('')
// 从头生成是一次性的:新图一落到画布上,下一句多半是在它上面改。停在「从头生成」
// 的话,下一句又把刚生成的图整张换掉(撤销找得回,但人得先发现)
@@ -82,8 +115,6 @@ export function Composer({ hero, onFocusChange }: {
}
const mode = useRadioGroup(MODES, useBase ? 'base' : 'fresh', (m) => setUseBase(m === 'base'))
const options = modelOptions(providers)
const groups = [...new Set(options.map((o) => o.group))]
const examples = hero ? exampleFor(nodes.length, sources) : []
return (
@@ -136,14 +167,17 @@ export function Composer({ hero, onFocusChange }: {
leading={
<>
<button
className={clsx('rounded-md p-1.5 transition-colors hover:bg-hover',
className={clsx('relative rounded-md p-1.5 transition-colors hover:bg-hover',
opts ? 'text-[var(--accent)]' : 'text-faint')}
title="助手使用的模型"
aria-label="助手使用的模型"
title={broken ? MT.broken : MT.label}
aria-label={MT.label}
aria-expanded={opts}
data-copilot-model-broken={broken ? '' : undefined}
onClick={() => setOpts((v) => !v)}
>
<Settings2 size={13} />
{/* 存着的设定用不了:收着的时候也要看得出来,不然要等发出一句话才报错 */}
{broken && <span className="absolute right-0.5 top-0.5 h-1.5 w-1.5 rounded-full bg-[var(--err)]" aria-hidden />}
</button>
{/* 两种方式并排摆出来,选中的那个就是这一句会怎么做。以前是一个 10px 的字,
点一下就翻成另一个词,看不出它是开关,更看不出另一头是什么。从头生成
@@ -169,9 +203,13 @@ export function Composer({ hero, onFocusChange }: {
{opts && (
<div className="fade-up mt-2 space-y-2 rounded-lg border bg-panel p-2.5">
<div>
<label className="label" htmlFor="copilot-model">助手使用的模型</label>
<select id="copilot-model" className="field" value={model} onChange={(e) => pickModel(e.target.value)}>
<option value="">跟随默认{effective ? `(当前:${effective})` : ''}</option>
<label className="label" htmlFor="copilot-model">{MT.label}</label>
<select id="copilot-model" className="field" value={model} onChange={(e) => pickModel(e.target.value)}
aria-invalid={broken ? true : undefined} aria-describedby={broken ? 'copilot-model-error' : undefined}>
{/* 「当前」只在选着跟随默认、用得了的时候写:选了别的模型时 effective_model 是那个模型,不是跟随默认会用的;
存着的设定列成单独一项时,这一项说的是选它以后会用哪个 */}
<option value="">{MT.follow}{model === '' && saved?.effective_model ? MT.current(saved.effective_model) : ''}</option>
{custom && <option value={SAVED}>{MT.saved(saved!.provider, saved!.model)}</option>}
{groups.map((g) => (
<optgroup key={g} label={g}>
{options.filter((o) => o.group === g).map((o) => (
@@ -180,10 +218,15 @@ export function Composer({ hero, onFocusChange }: {
</optgroup>
))}
</select>
<div className="mt-1 text-2xs leading-snug text-faint">
仅影响助手,不改变节点使用的模型。助手需要严格按格式输出,
指令遵循能力较弱的模型可能无法生成工作流。
</div>
{saveFailed && (
<div role="alert" data-copilot-model-save-failed=""
className="mt-1 text-2xs leading-relaxed text-[var(--err)]">{MT.saveFailed}</div>
)}
{broken && (
<div id="copilot-model-error" role="alert" data-copilot-model-error=""
className="mt-1 text-2xs leading-relaxed text-[var(--err)] [overflow-wrap:anywhere]">{broken}</div>
)}
<div className="mt-1 text-2xs leading-snug text-faint">{MT.note}</div>
</div>
</div>
)}
+3 -2
View File
@@ -1389,8 +1389,9 @@ export interface PublishFix {
label: string
preview?: { field?: string | null; before?: unknown; after?: unknown } | null
/** handoff:选了这一项就是「交给 Copilot」(G4 的「它在做计算」)。只认这个标记,不认值——
* 别的选项拿节点 id 当值,节点 id 恰好叫 copilot 的报告撰写节点照样是普通候选 */
options?: { value: unknown; label: string; hint?: string | null; handoff?: boolean }[]
* 别的选项拿节点 id 当值,节点 id 恰好叫 copilot 的报告撰写节点照样是普通候选。
* demo:点名的模型接入的候选,修完以后执行时落到演示类接入(不调用真实模型),选项名旁加醒目的标签 */
options?: { value: unknown; label: string; hint?: string | null; handoff?: boolean; demo?: boolean }[]
multiple?: boolean
/** 建议值:界面上标「建议」,仍然要人选 */
default?: unknown
+79 -1
View File
@@ -607,7 +607,8 @@ await section('设置 · 点名的接入已停用或不存在', async () => {
judge: { ...(stored.judge ?? {}), provider: 'demo-off', model: null },
verify: { ...(stored.verify ?? {}), provider: 'demo-off', model: null },
}
const REFUSED = '「门控模型 · 接入」填写的模型接入「demo-main」已停用。请在「设置 → 模型接入」中启用它,或换一个接入;留空表示使用默认接入的默认模型'
// 服务端的说法(api/settings._provider_problem):下拉框里选的写「指定的」,不另讲留空是什么意思(空选项写着)
const REFUSED = '「门控模型 · 接入」指定的模型接入「demo-main」已停用。请在「设置 → 模型接入」中启用它,或换一个接入'
let refuse = true
const { page, sent, errors, close } = await open('/settings/prefs', {
handlers: [
@@ -653,6 +654,83 @@ await section('设置 · 点名的接入已停用或不存在', async () => {
await close()
})
await section('设置 · 停用接入的后果写全;接入名恰好是模型名时说清怎么用这个模型', async () => {
// 停用一个接入以后,点名它(或只写了它的模型)的节点和设置都调不了它(K1):卡片上「已停用」的悬停说明、编辑框里
// 「启用」下面的说明都要写到这一层,不只是「下拉里看不到」;调不了以后怎样(报错、转人工审批、记未裁判、记缺口)各组
// 自己说,这里不笼统写「运行时报错」。停用的接入恰好叫 big-model,而 big-model 是启用的 demo-main 提供的模型:设置页
// 三格下面补一句这是哪个接入的模型、在哪一栏用它,和服务端保存时 422 的那句一致。接入表、设置都是假的
const PROVIDERS = [
{ id: 'check-trap-p1', name: 'demo-main', kind: 'openai', base_url: 'https://example.com/v1', default_model: 'big-model',
models: [{ id: 'big-model' }], enabled: true, extra: {}, api_key_masked: '', has_key: true },
{ id: 'check-trap-p2', name: 'big-model', kind: 'openai', base_url: 'https://example.com/v1', default_model: 'old-model',
models: [{ id: 'old-model' }], enabled: false, extra: {}, api_key_masked: '', has_key: true },
]
const EFFECT = '该接入的模型不再出现在下拉列表中,指定它或其模型的节点和设置也无法调用'
// 编辑框里「启用」的版式:「启用」两个字只占一行(以前长说明和它并排放在 label 里,1440 宽时把它挤成上下两个字);
// 说明不在 label 里,复选框的读屏名只是「启用」,说明由 aria-describedby 接上
const enabledLayout = (page) => page.evaluate(() => {
const hint = document.querySelector('[data-provider-enabled-hint]')
const box = [...document.querySelectorAll('[role="dialog"] input[type="checkbox"]')]
.find((el) => el.closest('label')?.textContent?.trim().startsWith('启用'))
const label = box?.closest('label')
const word = label && [...label.childNodes].find((n) => n.nodeType === 3 && n.textContent.includes('启用'))
const range = document.createRange()
if (word) range.selectNodeContents(word)
const lines = word ? new Set([...range.getClientRects()].filter((r) => r.width > 0).map((r) => Math.round(r.top))).size : 0
const d = document.querySelector('[role="dialog"]')
// 弹窗自己不出横向滚动条,「启用」一行和说明都在弹窗里
const fits = !!d && d.scrollWidth - d.clientWidth <= 0
&& [label, hint].every((el) => el && el.getBoundingClientRect().right <= d.getBoundingClientRect().right + 0.5)
return { lines, inLabel: !!label && !!hint && label.contains(hint), described: !!hint?.id && box?.getAttribute('aria-describedby') === hint.id, fits }
})
const cards = await open('/settings/providers', { handlers: [[/^GET \/providers$/, json(PROVIDERS)]], viewport: { width: 1440, height: 900 } })
const chip = cards.page.locator('[data-provider="big-model"] .chip', { hasText: '已停用' })
check('卡片上「已停用」:悬停写明点名它的节点和设置也无法调用', await chip.getAttribute('title').catch(() => '') === `已停用:${EFFECT}`,
await chip.getAttribute('title').catch(() => ''))
await cards.page.locator('[data-provider="big-model"]').getByRole('button', { name: '编辑' }).click()
const enabledHint = await cards.page.locator('[data-provider-enabled-hint]').innerText().catch(() => '')
check('编辑框「启用」下面:不只说下拉里看不到,也说点名它的节点和设置无法调用', enabledHint === `停用后,${EFFECT}`, enabledHint)
const wide = await enabledLayout(cards.page)
check('1440 宽:「启用」只占一行,说明另起一行、不在 label 里,由复选框的 aria-describedby 接上',
wide.lines === 1 && !wide.inLabel && wide.described && wide.fits, JSON.stringify(wide))
check('……复选框的读屏名只是「启用」', await cards.page.getByRole('checkbox', { name: '启用', exact: true }).count() === 1)
await shot(cards.page, 'provider-enabled-hint')
check('没有运行时报错', cards.errors.length === 0, cards.errors[0] ?? '')
await cards.close()
const phone = await open('/settings/providers', { handlers: [[/^GET \/providers$/, json(PROVIDERS)]], viewport: { width: 390, height: 844 } })
await phone.page.locator('[data-provider="big-model"]').getByRole('button', { name: '编辑' }).click()
await phone.page.locator('[data-provider-enabled-hint]').waitFor()
const narrow = await enabledLayout(phone.page)
check('390 宽:「启用」同样只占一行,说明在弹窗里折行,弹窗不出横向滚动条', narrow.lines === 1 && narrow.fits, JSON.stringify(narrow))
await shot(phone.page, 'provider-enabled-hint-390')
check('没有运行时报错', phone.errors.length === 0, phone.errors[0] ?? '')
await phone.close()
let stored = await get('/settings')
stored = {
...stored,
run: { ...(stored.run ?? {}), tool_gate_provider: 'big-model', tool_gate_model: null },
judge: { ...(stored.judge ?? {}), provider: 'big-model', model: null },
verify: { ...(stored.verify ?? {}), provider: 'big-model', model: null },
}
const { page, errors, close } = await open('/settings/prefs', {
handlers: [[/^GET \/settings$/, (route) => json(stored)(route)], [/^GET \/providers$/, json(PROVIDERS)]],
})
const said = async (id) => page.locator(`#${id}-error`).innerText().catch(() => '')
const note = (model, provider) => `「big-model」是接入「demo-main」的模型:如需使用该模型,请在「${model}」中填写,「${provider}」留空或选择「demo-main」`
check('门控模型:先说停用了、会怎样,再补一句这是 demo-main 的模型、在哪一栏用它',
await said('pref-gate-provider') === `模型接入「big-model」已停用:门控模型无法调用,所有工具调用将转为人工审批。${note('门控模型 · 模型', '门控模型 · 接入')}`,
await said('pref-gate-provider'))
check('证据裁判:同样补这一句,写裁判那两栏的叫法', (await said('pref-judge-provider')).endsWith(`。${note('裁判模型 · 模型', '裁判模型 · 接入')}`),
await said('pref-judge-provider'))
check('核查模型:同样补这一句,写核查那两栏的叫法', (await said('pref-verify-provider')).endsWith(`。${note('核查模型 · 模型', '核查模型 · 接入')}`),
await said('pref-verify-provider'))
await shot(page, 'prefs-provider-named-like-model')
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
})
await section('设置 · 智能体护栏', async () => {
// 默认步数、token 预算、金额预算(后端 engine/guards.py)。预算留空 = 不限,存 null
let stored = await get('/settings')
+125 -13
View File
@@ -199,12 +199,43 @@ const PROV_MODEL = {
],
edges: chain('in', 'look', 'team', 'done'),
}
/**
* 一处给不出修复(K1b):协作节点点名的接入不存在,成员「研究员」跟随节点的接入、自己只写了停用的 demo-off 提供的模型——
* 节点换成哪家、去掉接入,研究员都落不稳,这一处没有修复,提示也不指到发布前检查;成员「分析师」自己点名的接入不存在,
* 改用 demo-main 落得稳,有修复。两条同编号、同节点:前一条不能借后一条的修复
*/
const PROV_NOFIX = {
nodes: [
node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }),
node('team', 'supervisor', 240, '协作', { goal: '复核', provider: 'demo-old',
agents: [{ name: '分析师', provider: 'demo-old', tools: [] }, { name: '研究员', model: 'tiny-1', tools: [] }] }),
node('done', 'output', 480, '成果', { fields: [{ name: 'report', value: '{{ last_message }}' }] }),
],
edges: chain('in', '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 },
{ id: 'pv-off', name: 'demo-off', kind: 'openai_compatible', base_url: 'https://example.com/v1', default_model: 'tiny-1',
models: [{ id: 'tiny-1' }], enabled: false, extra: {}, api_key_masked: '', has_key: true },
]
/**
* 启用的只有演示类接入(K1b):「提问」点名停用的 demo-off,「不指定接入」落到演示接入,「改用 demo-mock」本身就是演示接入。
* 两个候选都带 demo 标记(后端 autofix._demo_mark),选项名旁要有醒目的「演示模型」标签,不能只靠说明末尾的淡色小字
*/
const PROV_DEMO = {
nodes: [
node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }),
node('ask', 'llm', 240, '提问', { prompt: '总结本周销售', provider: 'demo-off' }),
node('done', 'output', 480, '成果', { fields: [{ name: 'report', value: '{{ nodes.ask.text }}' }] }),
],
edges: chain('in', 'ask', 'done'),
}
const DEMO_PROVIDERS = [
PROVIDERS[1],
{ id: 'pv-mock', name: 'demo-mock', kind: 'mock', base_url: '', default_model: 'mock-1',
models: [{ id: 'mock-1' }], enabled: true, extra: {}, api_key_masked: '', has_key: false },
]
const wf = (id, name, graph, extra = {}) => ({
id, name, description: '检查脚本伪造的工作流', graph, tags: [], version: 3, is_template: false,
@@ -226,6 +257,8 @@ const FAKES = {
'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),
'pf-provnofix': wf('pf-provnofix', '__publish_check_provider_nofix__', PROV_NOFIX),
'pf-provdemo': wf('pf-provdemo', '__publish_check_provider_demo__', PROV_DEMO),
}
const clone = (x) => JSON.parse(JSON.stringify(x))
@@ -234,11 +267,13 @@ const who = (n) => `「${n.data.label}」`
const metricOptions = (m) => m.data.config.metrics.map((x) => ({ value: x.id, label: `${x.name}(${x.id})`, hint: `口径卡「${m.data.label}」` }))
/**
* 伪造的点名接入检查,形状照后端 engine/provider_refs.py 与 autofix._plan_provider:一处一条问题(字段 provider、
* 伪造的点名接入检查,形状照后端 engine/provider_refs.py 与 autofix.provider_choice:一处一条问题(字段 provider、
* agents[i].provider),门禁里是 error、带一条要人选的修复(id 是 code:节点:字段);画布校验里是 warning、没有修复。
* 候选照后端按执行时的取法算去掉、换掉以后落到哪里(只覆盖这两张图用到的情形,图里没有全图默认):节点去掉接入落到
* 候选照后端按执行时的取法算去掉、换掉以后落到哪里(只覆盖这几张图用到的情形,图里没有全图默认):节点去掉接入落到
* 第一个可用的接入;成员去掉接入跟随节点自己的接入,那个接入停用了就不给这一项;落到的接入不提供同一处写着的模型时
* 模型一并去掉(_drops 记哪几个候选要去掉,应用时用,前端收不到)
* 模型一并去掉(_drops 记哪几个候选要去掉,应用时用,前端收不到)。协作节点换掉、去掉接入时,跟着它的成员自己写着
* 没有启用接入提供的模型,一个候选都落不稳:没有修复,提示不写「或在「发布前检查」中改用其他接入」,改说卡在哪里
* (后端 provider_refs._tail:「无法改用其他接入:」加第一处落不稳的地方)。落到演示类接入的候选说明末尾照实说、带 demo 标记
*/
function providerLint(graph, providers, level) {
const issues = []
@@ -247,6 +282,7 @@ function providerLint(graph, providers, level) {
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)
const demo = (p) => (p?.kind === 'mock' ? { demo: true } : {})
for (const n of graph.nodes) {
const c = n.data.config ?? {}
const at = `${who(n)}(${kind[n.type]})`
@@ -261,26 +297,35 @@ function providerLint(graph, providers, level) {
const code = p ? 'provider.disabled' : 'provider.missing'
const id = `${code}:${r.n.id}:${r.field}`
const gate = level === 'error'
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) 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 stuckBy = r.team ? null : (r.n.data.config.agents ?? []).find((a) => !a.provider && a.model && !enabled.some((x) => serves(x, a.model)))
const stuck = !!stuckBy
if (up?.enabled && !stuck) {
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}」`) })
+ (r.team && r.n.data.config.provider ? `${r.team}指定的接入「${up.name}」` : `第一个可用的模型接入「${up.name}」`)
+ (up.kind === 'mock' ? '(演示用,不调用真实模型)' : ''), ...demo(up) })
}
for (const x of enabled) {
for (const x of stuck ? [] : 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 })
const notes = [drop ? `不提供模型「${model}」,将改用它的默认模型「${x.default_model}」` : '',
x.kind === 'mock' ? '演示用的模型接入,不调用真实模型' : ''].filter(Boolean)
options.push({ value: x.name, label: x.name, hint: notes.join(';') || null, ...demo(x) })
}
const fixable = options.length > 0
const off = stuckBy && providers.find((x) => !x.enabled && serves(x, stuckBy.model))
issues.push({ level, node_id: r.n.id, field: r.field, code, fix: gate && fixable ? id : null,
message: `${r.who}指定的模型接入「${r.name}」${p ? '已停用' : '不存在'}。请在「设置 → 模型接入」中${p ? '启用它' : '核对名称'}`
+ (fixable ? ',或在「发布前检查」中改用其他接入'
: off ? `。无法改用其他接入:${r.who}的成员「${stuckBy.name}」指定的模型「${stuckBy.model}」所属的模型接入「${off.name}」已停用` : '') })
if (gate && fixable) {
fixes.push({ id, code, node_id: r.n.id, field: r.field, kind: 'choice', label: `选择${r.who}改用的模型接入`, options, _drops: drops })
}
fixes.push({ id, code, node_id: r.n.id, field: r.field, kind: 'choice', label: `选择${r.who}改用的模型接入`, options, _drops: drops })
}
// 哪一级都没写接入、只写了模型:按模型名找,只有停用的接入提供它时同样是问题,落在写模型的那一处。修复是「不指定
// 模型」(落到第一个可用的接入)或改用一个启用的接入(写在模型旁边,模型去掉)
@@ -619,7 +664,8 @@ async function open({ id = 'pf-hard', width = 1440, height = 900, backend = 'new
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 = ['pf-prov', 'pf-provok', 'pf-provmodel'].includes(id) ? clone(PROVIDERS) : null
const providers = id === 'pf-provdemo' ? clone(DEMO_PROVIDERS)
: ['pf-prov', 'pf-provok', 'pf-provmodel', 'pf-provnofix'].includes(id) ? clone(PROVIDERS) : null
state.providers = providers
await page.route(/\/api\//, (route) => {
@@ -1721,6 +1767,72 @@ await section('点名的模型接入:一处一行、各有一组要人选的
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
await section('点名的模型接入:给不出修复的那一处不借同节点别处的修复,提示也不指到发布前检查', async () => {
const { page, errors } = await open({ id: 'pf-provnofix' })
await openDialog(page)
await waitFor(page, async () => (await dialog(page).locator('[data-preflight-issue^="provider."]').count()) === 2)
const rows = dialog(page).locator('[data-preflight-issue^="provider."]')
const own = rows.nth(0)
const member = rows.nth(1)
const ownText = await own.innerText().catch(() => '')
check('协作节点自己的接入:一行,没有修复(选项、预览按钮都没有)', await own.locator('[data-fix-choice]').count() === 0
&& await own.getAttribute('data-fix-kind') === null, ownText.replace(/\s+/g, ' ').slice(0, 100))
check('……提示不指到发布前检查:说去设置里核对,再说卡在哪里(成员只写了停用接入的模型)',
ownText.includes('指定的模型接入「demo-old」不存在。请在「设置 → 模型接入」中核对名称。无法改用其他接入:'
+ '「协作」(多 Agent 协作)的成员「研究员」指定的模型「tiny-1」所属的模型接入「demo-off」已停用')
&& !ownText.includes('发布前检查'), ownText.replace(/\s+/g, ' ').slice(0, 160))
check('成员「分析师」那一处:自己的修复画在自己这一行', await member.locator('[data-fix-choice="provider.missing:team:agents[0].provider"]').count() === 1
&& (await member.innerText()).includes('或在「发布前检查」中改用其他接入'))
check('整个弹窗里只有这一组接入的修复', await dialog(page).locator('[data-fix-choice^="provider."]').count() === 1)
await shoot(page, 'publish-provider-nofix', dialog(page))
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
await section('点名的模型接入:落到演示类接入的候选在选项名旁有醒目的「演示模型」标签', async () => {
const { page, errors } = await open({ id: 'pf-provdemo' })
await openDialog(page)
const group = dialog(page).locator('[data-fix-choice="provider.disabled:ask:provider"]')
await waitFor(page, async () => (await group.count()) === 1)
const tags = group.locator('[data-fix-demo]')
check('两个候选都落到演示接入,各有一个「演示模型」标签', await tags.count() === 2
&& (await tags.allInnerTexts()).every((t) => t.trim() === '演示模型'), String(await tags.count()))
const clear = group.locator('label', { hasText: '不指定接入' })
check('……「不指定接入」的说明里照旧写明演示用、不调用真实模型', (await clear.innerText()).includes('(演示用,不调用真实模型)'))
// 醒目:标签用提醒色,不是说明那种淡色小字;标签就在选项名旁(同一行)
const look = await group.evaluate((el) => {
const tag = el.querySelector('[data-fix-demo]')
const hint = tag.closest('label').querySelector('.text-faint')
const probe = document.createElement('span')
probe.style.color = 'var(--warn)'
el.appendChild(probe)
const warn = getComputedStyle(probe).color
probe.remove()
const name = tag.parentElement.firstChild
const range = document.createRange()
range.selectNodeContents(name)
return { tag: getComputedStyle(tag).color, hint: hint ? getComputedStyle(hint).color : '', warn,
sameLine: Math.abs(range.getBoundingClientRect().top - tag.getBoundingClientRect().top) < 6 }
})
check('……标签用提醒色(不是说明的淡色),和选项名在同一行', look.tag === look.warn && look.tag !== look.hint && look.sameLine, JSON.stringify(look))
await shoot(page, 'publish-provider-demo', dialog(page))
// 390 宽:「发布」收在「更多操作」里。标签和说明在弹窗里折行,不伸出弹窗、不出横向滚动条
const narrow = await open({ id: 'pf-provdemo', width: 390, height: 844 })
await narrow.page.getByRole('button', { name: '更多操作' }).click()
await narrow.page.locator('[role="menu"][aria-label="更多操作"] [role^="menuitem"]', { hasText: '发布' }).click()
await dialog(narrow.page).waitFor()
await waitFor(narrow.page, async () => (await dialog(narrow.page).locator('[data-fix-demo]').count()) === 2)
const fits = await narrow.page.evaluate(() => {
const d = document.querySelector('[role="dialog"]')
const box = d.getBoundingClientRect()
const tags = [...d.querySelectorAll('[data-fix-demo]')].map((t) => t.getBoundingClientRect())
return { own: d.scrollWidth - d.clientWidth,
inside: tags.every((r) => r.right <= Math.min(box.right, window.innerWidth) + 0.5 && r.left >= box.left - 0.5) }
})
check('390 宽:标签在弹窗里,弹窗不出横向滚动条', fits.own <= 0 && fits.inside, JSON.stringify(fits))
await shoot(narrow.page, 'publish-provider-demo-390', dialog(narrow.page))
check('没有运行时报错', errors.length === 0 && narrow.errors.length === 0, [...errors, ...narrow.errors].join(' | '))
})
await section('点名的模型接入:问题面板写「模型接入」,不露键名', async () => {
const { page, errors } = await open({ id: 'pf-prov' })
await page.locator('.react-flow__pane').click({ position: { x: 30, y: 30 } })
+257
View File
@@ -2607,6 +2607,263 @@ await section('合并查询:节点库认得、卡片摘要、检查器从上
await ctx.close()
})
// ================================================================ 点名的模型接入(K1b)
// 接入表、画布校验、助手的模型设定都是伪造的(一家启用、一家停用,名字都是通用名)。校验按伪造的接入表现算,形状照
// 后端:点名的接入不存在、已停用给接入那一条(provider.*);点名的接入好好的、没写模型给「将使用接入「X」的默认模型」;
// 接入那一条在时不再给「没有指定模型」(provider_refs.quiet_unset_model);工作流默认设置写了模型时不报「没有指定模型」
const MODEL_PROVIDERS = [
{ id: 'pv-main', name: 'demo-main', kind: 'openai_compatible', base_url: 'https://example.com/v1', default_model: 'big-model',
models: [{ id: 'big-model' }, { id: 'small-model' }], enabled: true, extra: {}, api_key_masked: '', has_key: true },
{ id: 'pv-off', name: 'demo-off', kind: 'openai_compatible', base_url: 'https://example.com/v1', default_model: 'tiny-1',
models: [{ id: 'tiny-1' }], enabled: false, extra: {}, api_key_masked: '', has_key: true },
]
const MODEL_GRAPH = {
nodes: [
node('start', 'input', 0, 300, '输入', { fields: [{ name: 'q', required: true }] }),
node('named', 'llm', 300, 60, '点名接入', { prompt: '{{ input.q }}', provider: 'demo-main' }),
node('gone', 'llm', 300, 220, '接入已删', { prompt: '{{ input.q }}', provider: 'demo-gone' }),
node('off', 'agent', 300, 380, '接入已停用', { prompt: '{{ input.q }}', provider: 'demo-off' }),
node('plain', 'llm', 300, 540, '没点名', { prompt: '{{ input.q }}' }),
// 反方审稿的模型一栏自己画(只读节点自己的配置、留空跟随设置),不取工作流默认设置
node('chal', 'challenge', 300, 700, '审稿', {}),
node('done', 'output', 640, 300, '成果', { fields: [{ name: 'a', value: '{{ last_message }}' }] }),
],
edges: [edge('start', 'named'), edge('start', 'gone'), edge('start', 'off'), edge('start', 'plain'),
edge('named', 'done'), edge('gone', 'done'), edge('off', 'done'), edge('plain', 'done')],
}
/** 伪造的画布校验:只管模型这一栏和点名的接入 */
const modelLint = (graph) => {
const issues = []
for (const n of graph.nodes ?? []) {
if (!['llm', 'agent'].includes(n.type)) continue
const c = n.data?.config ?? {}
const named = (c.provider || graph.defaults?.provider || '').trim()
const p = MODEL_PROVIDERS.find((x) => x.name === named)
const own = c.provider ? n.id : null
if (named && !p?.enabled) {
issues.push({ level: 'warning', node_id: own, field: own ? 'provider' : 'defaults.provider', code: p ? 'provider.disabled' : 'provider.missing',
message: `「${n.data.label}」指定的模型接入「${named}」${p ? '已停用' : '不存在'}。请在「设置 → 模型接入」中${p ? '启用它' : '核对名称'}` })
continue
}
if (!c.model && !graph.defaults?.model) {
issues.push({ level: 'warning', node_id: n.id, field: 'model', code: 'model.unset',
message: named ? `没有指定模型,将使用接入「${named}」的默认模型` : '没有指定模型,将使用默认模型' })
}
}
return { ok: true, issues }
}
// 助手的模型取不到:error 是生成时报的整句(别处也这么说),panel_error 写在助手栏那一格下面,去向是「上方」的下拉框
const ASSISTANT_BROKEN = '助手使用的模型接入「demo-gone」不存在。请在「设置 → 模型接入」中核对名称,或在「编排」页助手栏的「助手使用的模型」中重新选择'
const ASSISTANT_PANEL = '助手使用的模型接入「demo-gone」不存在。请在上方重新选择,或在「设置 → 模型接入」中核对名称'
await section('点名的模型接入:检查器模型一栏说清留空用哪个接入;助手栏照实显示存着的设定、能重新选「跟随默认」', async () => {
FAKES['st-model'] = wf('st-model', { name: '__studio_check_model__', graph: MODEL_GRAPH, version: 1 })
FAKES['st-modeldef'] = wf('st-modeldef', { name: '__studio_check_model_defaults__', graph: { ...MODEL_GRAPH, defaults: { provider: 'demo-main' } }, version: 1 })
FAKES['st-modeldm'] = wf('st-modeldm', { name: '__studio_check_model_default_model__',
graph: { ...MODEL_GRAPH, defaults: { provider: 'demo-main', model: 'small-model' } }, version: 1 })
// 助手的模型设定:存着一个已删掉的接入和一个下拉里列得出的模型(big-model),GET 照后端说明用不了;PUT 记下请求,照请求
// 存(选了模型就是那个模型、跟随默认就清掉存着的接入),fail 为真时答 500、什么都不改。模型列得出也不能直接选中它:
// 那样看着一切正常,发出去还会带上模型、绕过存着的接入
const BROKEN = { configured: true, provider: 'demo-gone', model: 'big-model', effective_provider: null, effective_model: null,
error: ASSISTANT_BROKEN, panel_error: ASSISTANT_PANEL }
const copilot = { saved: BROKEN, puts: [], fail: false }
const before = async (page) => {
const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
await page.route(/\/api\/providers(\?.*)?$/, (route) => (route.request().method() === 'GET' ? json(route, MODEL_PROVIDERS) : route.fallback()))
await page.route(/\/api\/workflows\/validate$/, (route) => json(route, modelLint(route.request().postDataJSON()?.graph ?? {})))
await page.route(/\/api\/copilot\/model$/, (route) => {
if (route.request().method() === 'PUT') {
const body = route.request().postDataJSON()
copilot.puts.push(body)
if (copilot.fail) return json(route, { detail: '数据库已锁定,请稍后重试' }, 500)
copilot.saved = body.model
? { configured: true, provider: null, model: body.model, effective_provider: 'demo-main', effective_model: body.model, error: null, panel_error: null }
: { configured: false, provider: null, model: null, effective_provider: 'demo-main', effective_model: 'big-model', error: null, panel_error: null }
}
return json(route, copilot.saved)
})
}
try {
const { ctx, page, state, errors } = await open({ path: '/studio/st-model', before, stream: [{ op: 'done', explanation: '好' }] })
const look = async (p, id) => {
const f = p.locator('[data-inspector-sheet] [data-field="model"]')
await S(p, (x) => window.__studio.getState().select(x), id)
await f.waitFor({ timeout: 4000 })
await p.waitForTimeout(150)
const select = f.locator('select option[value=""]')
return {
blank: await select.count() ? (await select.innerText()).trim() : null,
help: await f.locator('[data-field-help]').count() ? (await f.locator('[data-field-help]').innerText()).trim() : null,
text: await f.innerText(),
sheet: await p.locator('[data-inspector-sheet]').innerText(),
}
}
await waitAnalysis(page)
const named = await look(page, 'named')
check('点名了启用的接入、没写模型:空选项写「使用接入「demo-main」的默认模型」', named.blank === '默认(使用接入「demo-main」的默认模型)', named.blank)
check('……下面的说明同样写这个接入', named.help === '留空则使用接入「demo-main」的默认模型', named.help)
check('……校验那条也照实写接入', named.text.includes('没有指定模型,将使用接入「demo-main」的默认模型'), named.text.slice(0, 120))
const gone = await look(page, 'gone')
check('点名的接入不存在:空选项说状态,不说「使用」', gone.blank === '默认(模型接入「demo-gone」不存在)', gone.blank)
check('……模型一栏下面不再写说明,也没有「没有指定模型」那条', gone.help === null && !gone.text.includes('没有指定模型'), gone.text.slice(0, 120))
check('……接入那条问题在检查器顶上', gone.sheet.includes('指定的模型接入「demo-gone」不存在'), gone.sheet.slice(0, 160))
const off = await look(page, 'off')
check('点名的接入已停用:空选项写「已停用」,没有说明', off.blank === '默认(模型接入「demo-off」已停用)' && off.help === null, `${off.blank} · ${off.help}`)
const plain = await look(page, 'plain')
check('没点名接入:照旧写第一个可用的模型接入', plain.blank === '默认(使用第一个可用的模型接入)' && plain.help === '留空则使用默认模型接入',
`${plain.blank} · ${plain.help}`)
if (SHOTS) {
await look(page, 'gone')
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(200)
await page.locator('[data-inspector-sheet]').screenshot({ path: `${SHOTS}/studio-model-inspector-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
// 助手栏:存着的接入失效了,齿轮上有红点;展开后下拉照实停在存着的那一项,下面写原因
await S(page, () => window.__studio.getState().select(null))
const gear = page.getByRole('button', { name: '助手使用的模型' })
await gear.waitFor({ timeout: 4000 })
await page.waitForFunction(() => !!document.querySelector('[data-copilot-model-broken]'), null, { timeout: 4000 }).catch(() => {})
check('存着的接入用不了:齿轮上有标记,悬停写明', await page.locator('[data-copilot-model-broken]').count() === 1
&& (await gear.getAttribute('title')) === '助手使用的模型当前无法调用,点击查看原因', await gear.getAttribute('title'))
await gear.click()
const select = page.locator('#copilot-model')
await select.waitFor({ timeout: 3000 })
const pickedText = () => select.evaluate((el) => el.options[el.selectedIndex]?.textContent ?? '')
const followText = async () => (await select.locator('option[value=""]').innerText()).trim()
const picked = await pickedText()
check('下拉停在存着的设定上(接入连同模型),不显示成「跟随默认」,也不只选中同名的模型', picked === 'big-model(模型接入「demo-gone」)'
&& await select.inputValue() !== '' && await select.inputValue() !== 'big-model', picked)
check('……「跟随默认」不带「当前」:选它以后用哪个要服务端说了算', await followText() === '跟随默认')
const said = await page.locator('[data-copilot-model-error]').innerText().catch(() => '')
check('下拉下面写原因,去向是「上方」的下拉框,不把人指回这个下拉框本身', said === ASSISTANT_PANEL
&& await select.getAttribute('aria-invalid') === 'true', said)
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await page.waitForTimeout(200)
await page.locator('#copilot-model').locator('xpath=ancestor::div[contains(@class, "rounded-lg")][1]')
.screenshot({ path: `${SHOTS}/studio-copilot-model-${theme}.png` })
}
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
}
// 存着的设定列不出来时发一句话:不带模型,服务端按存着的整组取(带了模型会绕过存着的接入)
const box = page.getByLabel('描述要生成或修改的工作流')
await box.fill('把回答改短一些')
await box.press('Enter')
await until(() => state.bodies.length > 0, 3000)
check('……发出的请求不带模型', state.bodies.length === 1 && !('model' in state.bodies[0] && state.bodies[0].model), JSON.stringify(state.bodies[0] ?? {}).slice(0, 100))
await page.waitForFunction(() => !window.__studio.getState().copilot.active, null, { timeout: 4000 }).catch(() => {})
// 重新选「跟随默认」时保存失败:服务端存着的没变,下拉退回存着的那一项,原因和齿轮上的红点照旧,并说明没改成
copilot.fail = true
await select.selectOption('')
await until(() => copilot.puts.length > 0, 3000)
await page.locator('[data-copilot-model-save-failed]').waitFor({ timeout: 3000 }).catch(() => {})
check('保存失败:说明没改成', (await page.locator('[data-copilot-model-save-failed]').innerText().catch(() => '')) === '保存失败,助手使用的模型未更改')
check('……下拉退回存着的那一项,原因和齿轮上的红点照旧', await pickedText() === 'big-model(模型接入「demo-gone」)'
&& await page.locator('[data-copilot-model-error]').innerText().catch(() => '') === ASSISTANT_PANEL
&& await page.locator('[data-copilot-model-broken]').count() === 1, await pickedText())
// 收起再展开:重新取回的就是服务端现在存着的,上一次「保存失败」的说明不再留着,原因照旧
await gear.click()
await gear.click()
await select.waitFor({ timeout: 3000 })
await page.waitForTimeout(300)
check('……收起再展开:「保存失败」的说明不再留着,原因照旧', await page.locator('[data-copilot-model-save-failed]').count() === 0
&& await page.locator('[data-copilot-model-error]').innerText().catch(() => '') === ASSISTANT_PANEL)
copilot.fail = false
await select.selectOption('')
await until(() => copilot.puts.length > 1, 3000)
check('重新选「跟随默认」算一次改动:存成不指定接入和模型', copilot.puts.length === 2 && copilot.puts[1].model === null
&& !copilot.puts[1].provider, JSON.stringify(copilot.puts))
await page.waitForFunction(() => !document.querySelector('[data-copilot-model-error]'), null, { timeout: 3000 }).catch(() => {})
check('……原因、保存失败的说明、齿轮上的标记一起消失,「跟随默认」写出当前用的模型', await page.locator('[data-copilot-model-error]').count() === 0
&& await page.locator('[data-copilot-model-save-failed]').count() === 0
&& await page.locator('[data-copilot-model-broken]').count() === 0
&& await followText() === '跟随默认(当前:big-model)', await followText())
// 选了一个模型:服务端回来的「当前」就是这个模型,不能写到「跟随默认」上(跟随默认用的是 demo-main 的 big-model)
await select.selectOption('small-model')
await until(() => copilot.puts.length > 2, 3000)
await page.waitForTimeout(200)
check('选了具体模型:「跟随默认」不写「当前」', copilot.puts[2]?.model === 'small-model' && await followText() === '跟随默认'
&& await select.inputValue() === 'small-model', await followText())
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
await ctx.close()
// 全图默认点名的接入:没写自己接入的节点跟着它。反方审稿的模型一栏不取全图默认,不套这句
const second = await open({ path: '/studio/st-modeldef', before })
const defPlain = await look(second.page, 'plain')
check('全图默认点名了接入:没写自己接入的节点写「使用接入「demo-main」的默认模型」', defPlain.blank === '默认(使用接入「demo-main」的默认模型)', defPlain.blank)
const chal = await look(second.page, 'chal')
check('反方审稿的「反方模型」:不多出「留空则使用接入……」(和它自己「跟随设置」的说明相反)', chal.help === null
&& !chal.text.includes('留空则使用接入') && chal.text.includes('核查模型'), chal.text.slice(0, 140))
check('没有运行时报错', second.errors.length === 0, second.errors.slice(0, 2).join(' | '))
await second.ctx.close()
// 工作流默认设置写了模型:节点没写模型时用的就是它,不说「使用接入的默认模型」
const third = await open({ path: '/studio/st-modeldm', before })
const dmPlain = await look(third.page, 'plain')
check('全图默认写了模型:空选项和说明都写这个模型', dmPlain.blank === '默认(使用工作流默认设置的模型「small-model」)'
&& dmPlain.help === '留空则使用工作流默认设置的模型「small-model」', `${dmPlain.blank} · ${dmPlain.help}`)
const dmNamed = await look(third.page, 'named')
check('……节点自己点名了接入也一样(模型取全图默认的)', dmNamed.blank === '默认(使用工作流默认设置的模型「small-model」)'
&& dmNamed.help === '留空则使用工作流默认设置的模型「small-model」' && !dmNamed.text.includes('没有指定模型'), `${dmNamed.blank} · ${dmNamed.help}`)
const dmGone = await look(third.page, 'gone')
check('……点名的接入不存在时仍先说状态,不说「使用」', dmGone.blank === '默认(模型接入「demo-gone」不存在)' && dmGone.help === null,
`${dmGone.blank} · ${dmGone.help}`)
const dmChal = await look(third.page, 'chal')
check('……反方审稿的模型一栏同样不套这句', dmChal.help === null && !dmChal.text.includes('工作流默认设置'), dmChal.text.slice(0, 140))
if (SHOTS) {
await look(third.page, 'plain')
for (const theme of ['dark', 'light']) {
await third.page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await third.page.waitForTimeout(200)
await third.page.locator('[data-inspector-sheet]').screenshot({ path: `${SHOTS}/studio-model-defaults-${theme}.png` })
}
}
check('没有运行时报错', third.errors.length === 0, third.errors.slice(0, 2).join(' | '))
await third.ctx.close()
// 390 宽:助手栏浮在画布上,展开「助手使用的模型」、原因那一长句照样折行,不把页面撑出横向滚动
copilot.saved = BROKEN
const narrow = await open({ path: '/studio/st-model', before, width: 390, height: 844 })
await blur(narrow.page)
await narrow.page.keyboard.press('Alt+KeyA')
const narrowGear = narrow.page.getByRole('button', { name: '助手使用的模型' })
await narrowGear.waitFor({ timeout: 4000 })
// 编排页在 390 宽时工具栏本身就比视口宽(与这一块无关):比的是展开前后,展开这一块不能再多撑出横向溢出
const overflow = () => narrow.page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
const base = await overflow()
await narrowGear.click()
await narrow.page.locator('[data-copilot-model-error]').waitFor({ timeout: 3000 })
const fit = await narrow.page.evaluate(() => {
const err = document.querySelector('[data-copilot-model-error]').getBoundingClientRect()
const sel = document.getElementById('copilot-model').getBoundingClientRect()
return { over: document.documentElement.scrollWidth - document.documentElement.clientWidth,
right: Math.round(Math.max(err.right, sel.right)), vw: window.innerWidth }
})
check('390 宽:原因折行显示,下拉和原因都在视口里,展开这一块不多撑出横向溢出', fit.over <= base && fit.right <= fit.vw,
JSON.stringify({ ...fit, base }))
if (SHOTS) {
for (const theme of ['dark', 'light']) {
await narrow.page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
await narrow.page.waitForTimeout(200)
await narrow.page.screenshot({ path: `${SHOTS}/studio-copilot-model-390-${theme}.png` })
}
}
check('没有运行时报错', narrow.errors.length === 0, narrow.errors.slice(0, 2).join(' | '))
await narrow.ctx.close()
} finally {
delete FAKES['st-model']
delete FAKES['st-modeldef']
delete FAKES['st-modeldm']
}
})
check('整个检查没有弹出原生 confirm / prompt', dialogs === 0, `${dialogs} 次`)
check('检查没有写库(兜底拦下的写请求)', writes.length === 0, writes.slice(0, 4).join('、'))