diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py
index 14e19738..3a937ed1 100644
--- a/backend/app/api/copilot.py
+++ b/backend/app/api/copilot.py
@@ -20,7 +20,7 @@ from app.api.coded import DATASOURCE_SCOPE_EMPTY, CodedHTTPException
from app.api.copilot_context import DATASOURCE_BUDGET_CHARS as _DATASOURCE_BUDGET_CHARS # noqa: F401
from app.api.copilot_context import DETAIL_TABLE_LIMIT as _DETAIL_TABLE_LIMIT # noqa: F401
# 起别名:本文件底下有个叫 explain 的接口,generate 里又有个局部变量叫 raw,同名会互相盖掉
-from app.core.errors import explain as explain_error, graph_error, not_configured, raw as raw_error
+from app.core.errors import explain as explain_error, graph_error, raw as raw_error
from app.data.sqlcheck import CHECK_CODES
from app.db.base import get_session
from app.db.models import Workflow
@@ -522,20 +522,29 @@ async def copilot_model_spec(
@router.get("/model")
async def get_copilot_model(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
- """当前 Copilot 会用哪个模型,以及是显式配的还是兜底来的。"""
+ """当前 Copilot 会用哪个模型,以及是显式配的还是兜底来的。
+
+ 存的接入不存在、已停用(或者只存了模型名、提供它的接入已停用)时实际什么都用不了:effective_* 为空,
+ error 写明原因(和生成时报的同一句),能用时 error 为 None。"""
from app.db.models import Setting
from app.providers.factory import resolve_provider
row = await session.get(Setting, COPILOT_SETTING_KEY)
saved = row.value if row else {}
configured = bool(saved.get("provider") or saved.get("model"))
- resolved = await resolve_provider(session, saved.get("provider"), saved.get("model"))
+ try:
+ resolved = await resolve_provider(session, saved.get("provider"), saved.get("model"))
+ 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)}
return {
"configured": configured,
"provider": saved.get("provider") or None,
"model": saved.get("model") or None,
"effective_provider": resolved.name if resolved else None,
"effective_model": saved.get("model") or (resolved.default_model if resolved else None),
+ "error": None,
}
@@ -562,7 +571,10 @@ async def set_copilot_model(
def _unconfigured(e: ProviderNotConfigured) -> str:
- return f"助手使用的模型尚未配置完成:{not_configured(e, with_hint=False)}。请到「设置 → 模型接入」检查"
+ # 点名的接入不存在、已停用照实说是哪个、去哪里改,其余说「尚未配置完成」(表格导入、数据目录的 AI 起草同一句)
+ from app.providers.factory import assistant_unavailable
+
+ return assistant_unavailable(e)
class GenerateOut(BaseModel):
diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py
index 5c98ba86..ef0a3a17 100644
--- a/backend/app/api/settings.py
+++ b/backend/app/api/settings.py
@@ -10,18 +10,21 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api import health
+from app.api.coded import CodedHTTPException
from app.core.errors import explain, not_configured, raw
from app.core.config import settings as app_settings
from app.core.crypto import encrypt, mask
from app.db.base import get_session
from app.db.models import Provider, Setting
from app.engine.judge import JUDGE_DEFAULTS, MAX_TOKENS as JUDGE_MAX_TOKENS
-from app.engine.judge import SETTING_GROUP as JUDGE_GROUP, SPEND_KEY as JUDGE_SPEND
+from app.engine.judge import SETTING_GROUP as JUDGE_GROUP, SETTING_LABEL as JUDGE_LABEL, SPEND_KEY as JUDGE_SPEND
from app.engine.judge import daily_spend, judge_model_spec, judge_settings
from app.engine.judge import sanitize_settings as judge_values, settings_problem as judge_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
+from app.providers.factory import (
+ ModelSpec, ProviderNotConfigured, _resolve_key, build_chat_model, list_providers as all_providers, named_provider,
+)
router = APIRouter(prefix="/api", tags=["settings"])
@@ -721,6 +724,55 @@ class SettingsIn(BaseModel):
values: dict[str, Any]
+#: 点名的模型接入不存在或已停用时 PUT /api/settings 回 422 的机读码,field 是出错的那一项(verify.provider……)
+PROVIDER_INVALID = "settings_provider_invalid"
+
+
+def _provider_fields() -> dict[tuple[str, str], tuple[str, str]]:
+ """设置里点名模型接入的项:(组, 键) → (设置页上的叫法, 留空时用什么)。门控、裁判、核查的叫法和设置页一致;助手的
+ 模型由助手面板单独设定(PUT /api/copilot/model),这里只防经设置接口写进去的那一份。"""
+ from app.api.copilot import COPILOT_SETTING_KEY # 在这里导入:copilot 模块很大,设置模块被引擎各处导入
+
+ return {
+ ("run", "tool_gate_provider"): ("门控模型 · 接入", "留空表示使用默认接入的默认模型"),
+ (JUDGE_GROUP, "provider"): (JUDGE_LABEL["provider"], "留空表示不指定"),
+ (VERIFY_GROUP, "provider"): (VERIFY_SETTING_LABEL["provider"], "留空表示沿用证据裁判模型"),
+ (COPILOT_SETTING_KEY, "provider"): ("助手模型 · 接入", "留空表示不指定"),
+ }
+
+
+async def _provider_problem(session: AsyncSession, values: dict[str, Any]) -> tuple[str, str] | None:
+ """这次写进来的设置里点名的模型接入有没有问题:(字段, 说明),没问题返回 None。说明和运行时报的原因、提示同一句
+ (factory.named_provider)。
+
+ 以前点名的接入不存在时运行悄悄换成演示模型,名字写错一个字就由演示模型写报告、做核查,现在运行时照实报错;保存时
+ 先拦一道。留空照旧表示沿用上一级。只查这次改了的:存着的值没改(写好之后接入才被删、停用)不挡这次保存别的项——
+ 设置页在那一项下标着「不存在」「已停用」,运行时照实报错,同设置页「只校验改过的」的做法。
+
+ 名字不是字符串(接口写进来的数字一类):核查、裁判两组自己的格式检查在前面先报;门控、助手两组没有自己的检查,
+ 在这里报,免得存下去之后读它的地方(查助手当前用哪个模型)出错。"""
+ providers = None
+ for (group, key), (label, blank) 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}」"
+ if (name := _model_text(raw)) is None:
+ continue
+ row = await session.get(Setting, group)
+ saved = row.value if row is not None and isinstance(row.value, dict) else {}
+ if _model_text(saved.get(key)) == name:
+ continue
+ if providers is None:
+ providers = await all_providers(session)
+ try:
+ named_provider(providers, name)
+ except ProviderNotConfigured as e:
+ return f"{group}.{key}", f"「{label}」填写的{e.reason}。{e.hint};{blank}"
+ return None
+
+
@router.put("/settings")
async def put_settings(
payload: SettingsIn, session: AsyncSession = Depends(get_session)
@@ -737,6 +789,8 @@ async def put_settings(
raise HTTPException(422, problem)
# 存规范化之后的:末尾带 / 的地址拼链接会多出一个斜杠
payload.values[NOTIFY_GROUP] = notify_values(payload.values[NOTIFY_GROUP])
+ if found := await _provider_problem(session, payload.values):
+ raise CodedHTTPException(422, found[1], PROVIDER_INVALID, field=found[0])
for key, value in payload.values.items():
if key in (TRUST_KEY, "limits", JUDGE_SPEND, VERIFY_SPEND):
# 信任三档只走 PUT /api/tools/trust:设置页整组回写时带着一份旧的,
diff --git a/backend/app/api/workflows.py b/backend/app/api/workflows.py
index e24a6be8..348e8cd2 100644
--- a/backend/app/api/workflows.py
+++ b/backend/app/api/workflows.py
@@ -14,6 +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.schema import GraphSpec, NodeType, validate_graph
router = APIRouter(prefix="/api/workflows", tags=["workflows"])
@@ -21,6 +22,9 @@ router = APIRouter(prefix="/api/workflows", tags=["workflows"])
_GONE = "工作流不存在,可能已被删除"
_LEVELS = ("published", "governed")
_BAD_LEVEL = "发布级别只能是「已发布」或「受管」"
+#: 剩下的挡住发布的只有点名接入的问题、人又要交给助手时的说明:换成哪个接入要人拿主意,每一处都有自己的修复
+PROVIDER_LEFT = ("剩下的问题是指定的模型接入不存在或已停用,需要你决定改用哪个接入:请在每一处的修复中选择,"
+ "或在「设置 → 模型接入」中核对")
class WorkflowIn(BaseModel):
@@ -334,7 +338,8 @@ async def publish_workflow(
return {"ok": False, "issues": [{"level": "error", "message": f"无法解析工作流结构:{graph_error(e)}"}]}
checkers = await _sql_checkers(session, spec)
- issues = [i.model_dump() for i in publish_issues(spec, level=payload.level, checkers=checkers)]
+ issues = [i.model_dump() for i in publish_issues(spec, level=payload.level, checkers=checkers,
+ providers=await _providers(session, spec))]
if any(i["level"] == "error" for i in issues):
return {"ok": False, "level": payload.level, "version": version, "issues": issues}
@@ -421,6 +426,13 @@ async def _sql_checkers(session: AsyncSession, spec: GraphSpec) -> dict[str, Any
return await load_checkers_by_name(session, graph_sources(spec.nodes))
+async def _providers(session: AsyncSession, spec: GraphSpec) -> list[Any]:
+ """模型接入表:节点点名的接入不存在或已停用、只写了模型而提供它的接入已停用时,发布、发布前检查、自动修复的复核
+ 给 error、挡住发布,修复从启用的接入里列候选(engine/provider_refs.py、autofix 的 provider.*)。和 _sql_checkers
+ 一样由接口这一层读好交给门禁,门禁本身不查库;三处传同一份。图里取模型的几处都没写接入和模型时不读库。"""
+ return await load_providers(session, spec)
+
+
def _unreadable(level: str, e: Exception) -> dict[str, Any]:
return {"level": level, "ok": False, "fixes": [], "issues": [
{"level": "error", "node_id": None, "edge_id": None, "field": None, "code": None, "fix": None,
@@ -443,8 +455,8 @@ async def publish_check(
except Exception as e: # noqa: BLE001
return _unreadable(payload.level, e)
versions, cards = await _gate_context(session, spec)
- return check(spec, level=payload.level, versions=versions, cards=cards,
- checkers=await _sql_checkers(session, spec))
+ return check(spec, level=payload.level, versions=versions, cards=cards, checkers=await _sql_checkers(session, spec),
+ providers=await _providers(session, spec))
@router.post("/{workflow_id}/autofix")
@@ -469,14 +481,18 @@ async def autofix(
"remaining": bad["issues"], "fixes": [], "handoff": [], "assist": None, "ok": False}
versions, cards = await _gate_context(session, spec)
checkers = await _sql_checkers(session, spec)
+ providers = await _providers(session, spec)
out = apply_fixes(graph, payload.apply, payload.choices, level=payload.level, versions=versions, cards=cards,
- checkers=checkers)
+ checkers=checkers, providers=providers)
out["assist"] = None
# 人在选项里选了「交给 Copilot」(handoff),和点「交给 Copilot」是一回事
if payload.assist or out["handoff"]:
if out["ok"]:
- out["assist"] = {"ok": True, "summary": "修复后已没有阻止发布的错误,未再交给助手",
- "questions": []}
+ 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"):
+ # 剩下的只有点名接入的问题:换成哪个接入要人拿主意(助手的自查也不看接入表),不交给助手
+ out["assist"] = {"ok": False, "summary": PROVIDER_LEFT, "questions": []}
return out
from app.api.copilot import assist_publish_fix
@@ -488,8 +504,10 @@ async def autofix(
out["changes"] += diff_changes(out["graph"], fixed, fix_id="assist", label="助手的修改")
out["ops"] += helped["ops"]
out["applied"].append("assist")
+ fixed_spec = GraphSpec.model_validate(fixed)
after = apply_fixes(fixed, [], level=payload.level, versions=versions, cards=cards,
- checkers=await _sql_checkers(session, GraphSpec.model_validate(fixed)))
+ checkers=await _sql_checkers(session, fixed_spec),
+ providers=await _providers(session, fixed_spec))
out.update(graph=fixed, remaining=after["remaining"], fixes=after["fixes"], ok=after["ok"])
elif helped["reason"]:
out["rejected"].append({"fix_id": "assist", "reason": helped["reason"]})
@@ -501,14 +519,18 @@ class ValidateIn(BaseModel):
@router.post("/validate")
-async def validate(payload: ValidateIn) -> dict[str, Any]:
- """画布上的实时校验。前端每次改动都会调它来标红。"""
+async def validate(payload: ValidateIn, session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
+ """画布上的实时校验。前端每次改动都会调它来标红。
+
+ 点名的模型接入不存在或已停用(只写了模型、提供它的接入已停用也算)只给警告:探索运行仍可发起,执行到那个节点时
+ 照实报错;发布时才挡住。"""
try:
spec = GraphSpec.model_validate(payload.graph)
except Exception as e: # noqa: BLE001
return {"ok": False, "issues": [{"level": "error", "message": f"无法解析工作流结构:{graph_error(e)}"}]}
- result = validate_graph(spec)
- return result.model_dump()
+ result = validate_graph(spec).model_dump()
+ result["issues"] += [i.model_dump() for i in await load_issues(session, spec, level="warning")]
+ return result
@router.post("/variables")
diff --git a/backend/app/data/catalog.py b/backend/app/data/catalog.py
index e4d821e6..fb7e7063 100644
--- a/backend/app/data/catalog.py
+++ b/backend/app/data/catalog.py
@@ -1509,34 +1509,30 @@ async def resolve_draft_model(session: AsyncSession) -> tuple[Any, str]:
起草出来的目录会被当成真的写进库里。数据层不导入接口层,设置键取 recipe_ai 里那份(与助手的同值,
那边有测试核对)。
"""
- from app.core.errors import not_configured
from app.data.recipe_ai import COPILOT_SETTING_KEY
from app.db.models import Setting
- from app.providers.factory import ModelSpec, ProviderNotConfigured, build_chat_model, resolve_provider
+ from app.providers.factory import (
+ ModelSpec, ProviderNotConfigured, assistant_unavailable, build_chat_model, resolve_provider,
+ )
row = await session.get(Setting, COPILOT_SETTING_KEY)
saved = (row.value if row else None) or {}
spec = ModelSpec(provider=saved.get("provider") or None, model=saved.get("model") or None,
max_tokens=MODEL_MAX_TOKENS, temperature=0, thinking="off")
- unconfigured = "助手使用的模型尚未配置完成:{reason}。请到「设置 → 模型接入」检查"
try:
+ # 存的接入名不存在、已停用,只存了模型名、提供它的接入已停用:resolve_provider 都会报出来
provider = await resolve_provider(session, spec.provider, spec.model)
except ProviderNotConfigured as e:
- raise CatalogModelUnavailable(unconfigured.format(reason=not_configured(e, with_hint=False))) from e
+ raise CatalogModelUnavailable(assistant_unavailable(e)) from e
if provider is None:
- if spec.provider:
- raise CatalogModelUnavailable(f"助手设置里的模型接入「{spec.provider}」不存在。请到「设置 → 模型接入」检查")
raise CatalogModelUnavailable("未配置模型接入,无法由助手起草数据目录。请到「设置 → 模型接入」添加")
if provider.kind == "mock":
raise CatalogModelUnavailable("当前只有演示用的模型接入,无法用于起草数据目录。"
"请到「设置 → 模型接入」添加真实的模型接入")
- if not provider.enabled:
- raise CatalogModelUnavailable(f"助手使用的模型接入「{provider.name}」已停用。"
- "请到「设置 → 模型接入」启用它,或为助手换一个模型")
try:
model = build_chat_model(provider, spec)
except ProviderNotConfigured as e:
- raise CatalogModelUnavailable(unconfigured.format(reason=not_configured(e, with_hint=False))) from e
+ raise CatalogModelUnavailable(assistant_unavailable(e)) from e
return model, spec.model or provider.default_model or ""
diff --git a/backend/app/data/recipe_ai.py b/backend/app/data/recipe_ai.py
index 54cba7ba..44ec1f92 100644
--- a/backend/app/data/recipe_ai.py
+++ b/backend/app/data/recipe_ai.py
@@ -253,10 +253,10 @@ class _BudgetFallback:
def _unconfigured(e: BaseException) -> str:
- """写法照 copilot._unconfigured。"""
- from app.core.errors import not_configured
+ """和 copilot._unconfigured 同一句(providers/factory.assistant_unavailable)。"""
+ from app.providers.factory import assistant_unavailable
- return f"助手使用的模型尚未配置完成:{not_configured(e, with_hint=False)}。请到「设置 → 模型接入」检查"
+ return assistant_unavailable(e)
async def resolve_draft_model(session: AsyncSession) -> tuple[Any | None, str, AiAvailability]:
@@ -271,21 +271,13 @@ async def resolve_draft_model(session: AsyncSession) -> tuple[Any | None, str, A
try:
provider = await resolve_provider(session, spec.provider, spec.model)
except ProviderNotConfigured as e:
- # 只存了模型名、提供它的接入已停用:resolve_provider 自己会报出来
+ # 存的接入名不存在、已停用,只存了模型名、提供它的接入已停用:resolve_provider 都会报出来
return None, "", AiAvailability(False, reason=_unconfigured(e))
if provider is None:
- if spec.provider:
- reason = f"助手设置里的模型接入「{spec.provider}」不存在。请到「设置 → 模型接入」检查"
- else:
- reason = "未配置模型接入,无法使用 AI 起草。请到「设置 → 模型接入」添加"
- return None, "", AiAvailability(False, reason=reason)
+ return None, "", AiAvailability(False, reason="未配置模型接入,无法使用 AI 起草。请到「设置 → 模型接入」添加")
if provider.kind == "mock":
return None, "", AiAvailability(False, reason="当前只有演示用的模型接入,无法用于 AI 起草。"
"请到「设置 → 模型接入」添加真实的模型接入")
- if not provider.enabled:
- # 存的是接入名时 resolve_provider 会把停用的接入原样返回,build_chat_model 也不查 enabled
- return None, "", AiAvailability(False, reason=f"助手使用的模型接入「{provider.name}」已停用。"
- "请到「设置 → 模型接入」启用它,或为助手换一个模型")
try:
big = build_chat_model(provider, spec)
small = build_chat_model(provider, spec.model_copy(update={"max_tokens": AI_FALLBACK_MAX_TOKENS}))
diff --git a/backend/app/engine/autofix.py b/backend/app/engine/autofix.py
index db2fd86a..1054356e 100644
--- a/backend/app/engine/autofix.py
+++ b/backend/app/engine/autofix.py
@@ -54,6 +54,7 @@ _ORDER = (
"governed.caliber_agent_cite_fields", "governed.caliber_compute_input", "governed.agent_approval_never",
"governed.default_approval_never", "governed.subgraph_unpinned", "governed.supervisor", "contract.not_object",
"governed.text_bypass", "governed.caliber_agent_schema", "governed.caliber_model_input",
+ "provider.missing", "provider.disabled",
)
#: 修复落在全图上、不落在任何节点上的问题:问题各挂在跟随全图默认的那几个节点上(点一条能定位),
@@ -85,12 +86,14 @@ _GRAPH_OPS = frozenset({"add_node", "update_node", "add_edge"})
class _Ctx:
def __init__(self, spec: GraphSpec, versions: dict[str, Any] | None, cards: dict[str, Any] | None,
- level: str = "governed") -> None:
+ level: str = "governed", providers: list[Any] | None = None) -> None:
self.spec = spec
self.nodes = spec.node_map()
self.versions = versions or {}
self.cards = cards or {}
self.level = level
+ #: 模型接入表(provider_refs.load_providers):点名的接入出了问题时,候选从里面启用的那些里挑
+ self.providers = providers or []
def upstream(self, node_id: str, *types: NodeType) -> list[GraphNode]:
"""这个节点上游的某几类节点,按画布上的节点顺序。"""
@@ -459,6 +462,155 @@ def _plan_not_object(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -
return _assist(f"「{_title(node)}」的出具契约不是一个 JSON 对象", "contract")
+#: 「不指定接入」(只写了模型的那一处是「不指定模型」)那个候选的值:去掉这一处写的接入(模型)
+PROVIDER_CLEAR = ""
+
+
+def _path_steps(path: str) -> list[str | int]:
+ """agents[0].provider → ['agents', 0, 'provider']。"""
+ return [int(i) if i else key for i, key in re.findall(r"\[(\d+)\]|([^.\[\]]+)", path)]
+
+
+def _step(root: Any, step: str | int) -> Any:
+ if isinstance(step, int):
+ return root[step] if isinstance(root, list) and step < len(root) else None
+ return root.get(step) if isinstance(root, dict) else None
+
+
+def _read_path(root: Any, path: str) -> Any:
+ for step in _path_steps(path):
+ if (root := _step(root, step)) is None:
+ return None
+ return root
+
+
+def _put_path(root: dict[str, Any], path: str, value: Any) -> Any:
+ """按路径写(provider、judge.provider、agents[0].provider),value 为 None 时去掉这一项。返回原值;
+ 路径上缺了哪一层就什么都不写,返回 None。"""
+ *heads, last = _path_steps(path)
+ cur: Any = root
+ for step in heads:
+ cur = _step(cur, step)
+ if not isinstance(cur, dict) or not isinstance(last, str):
+ return None
+ before = copy.deepcopy(cur.get(last))
+ if value is None:
+ cur.pop(last, None)
+ else:
+ cur[last] = copy.deepcopy(value)
+ return before
+
+
+def _model_path(path: str) -> str:
+ """接入旁边的模型:provider → model,judge.provider → judge.model,agents[0].provider → agents[0].model。"""
+ return path[: -len("provider")] + "model"
+
+
+def _landing_text(landed: list[tuple[Any, Any]]) -> str:
+ """修完以后执行时改用哪里(provider_refs.land 的说法):各处落到同一处只说一次,落到不同处逐处说。"""
+ groups: dict[str, list[str]] = {}
+ for use, landing in landed:
+ groups.setdefault(landing.where, []).append(use.who)
+ if len(groups) == 1:
+ return f"改用{next(iter(groups))}"
+ return ";".join(f"{'、'.join(whos)}改用{where}" for where, whos in groups.items())
+
+
+def _plan_provider(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None:
+ """执行时取不到模型的那一处(engine/provider_refs.py):改用一个启用的接入,或者不再指定。点名的接入不存在、已停用
+ 的,候选是「不指定接入」和各个启用的接入;只写了模型、提供它的接入已停用的,是「不指定模型」和各个启用的接入
+ (写在模型旁边的接入上)。
+
+ 要人选、不给默认:换成哪个接入只有人知道(同一个模型可能有两家提供,价格、配额都不同)。多数节点的检查器里没有
+ 接入下拉框(只能选模型),全图默认、协作成员也没有,这里是界面上唯一能改它的地方。一处一条(plan_fixes 的 id
+ 带字段):协作节点自己的接入和成员的接入各修各的。
+
+ 每个候选先在副本上改,按执行时的取法(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)
+ landed = []
+ for use in uses(spec):
+ if before.get(use.key) == use:
+ continue
+ if not (landing := land(use, ctx.providers)).ok:
+ return None
+ landed.append((use, landing))
+ return landed
+
+ def settle(edits: list[tuple[str, Any]]) -> tuple[list[tuple[str, Any]], list[tuple[Any, Any]]] | None:
+ """先只改这一处;落不稳而同一级写着模型时,连模型一并去掉再试。返回(改法,波及的各处落到哪里),还落不稳为 None。"""
+ if (landed := trial(edits)) is not None:
+ return edits, landed
+ if model and all(at != model_path for at, _ in edits):
+ edits = [*edits, (model_path, None)]
+ if (landed := trial(edits)) is not None:
+ return edits, landed
+ return None
+
+ options: list[dict[str, Any]] = []
+ plans: dict[str, list[tuple[str, Any]]] = {}
+ if (got := settle([(model_path if ref.kind == "model" else provider_path, None)])) is not None:
+ edits, landed = got
+ what = "模型" if ref.kind == "model" else "接入" + (f"和模型「{model}」" if len(edits) > 1 else "")
+ options.append({"value": PROVIDER_CLEAR, "label": f"不指定{'模型' if ref.kind == 'model' else '接入'}",
+ "hint": f"去掉这一处指定的{what},执行时{_landing_text(landed)}"})
+ plans[PROVIDER_CLEAR] = edits
+ for p in ctx.providers:
+ if not getattr(p, "enabled", False) or not getattr(p, "name", None) 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
+ if not options:
+ # 一个都落不稳(比如一个启用的接入都没有):不给修复,问题照样拦着发布,说明里写了去设置里启用
+ return None
+ return _choice(f"选择{ref.who}改用的模型接入", field, options, _edits=plans)
+
+
_PLANNERS: dict[str, Callable[[_Ctx, dict[str, Any], GraphNode | None], dict[str, Any] | None]] = {
"governed.agent_approval_never": _plan_approval,
"governed.default_approval_never": _plan_default_approval,
@@ -486,8 +638,14 @@ _PLANNERS: dict[str, Callable[[_Ctx, dict[str, Any], GraphNode | None], dict[str
"governed.caliber_agent_cite_fields": _plan_cite_fields,
"governed.caliber_agent_schema": _plan_agent_schema,
"governed.caliber_model_input": _plan_model_input,
+ # 点名的模型接入不存在或已停用(只在调用方交了接入表时才有这类问题)
+ "provider.missing": _plan_provider,
+ "provider.disabled": _plan_provider,
}
+#: 点名的模型接入这一类:一处一条修复,复核时只认那个字段上的那一条(同一节点上别的字段可能还有同类问题)
+_PER_FIELD = frozenset({"provider.missing", "provider.disabled"})
+
def _as_dict(issue: Any) -> dict[str, Any]:
return issue.model_dump() if isinstance(issue, BaseModel) else dict(issue)
@@ -506,14 +664,16 @@ def _target(ctx: _Ctx, issue: dict[str, Any]) -> str | None:
def plan_fixes(spec: GraphSpec, issues: list[Any], *, level: str, versions: dict[str, Any] | None = None,
- cards: dict[str, list[dict[str, Any]]] | None = None) -> list[dict[str, Any]]:
+ cards: dict[str, list[dict[str, Any]]] | None = None,
+ providers: list[Any] | None = None) -> list[dict[str, Any]]:
"""这批问题能怎么修:Fix 列表,按问题出现的顺序,同一处只出一条。
versions:子工作流 id → 它当前的发布版本号(没发布过为 None)。
cards:钉在别处(caliber_from)的口径卡节点 id → 那一版里的指标定义,用来列 required 的候选。
+ providers:模型接入表,点名的接入出了问题时列候选(governance.lint_for_publish 的同一份)。
返回的 Fix 里以 _ 开头的键是应用时要用的内部信息,交给前端前用 public() 去掉。
"""
- ctx = _Ctx(spec, versions, cards, level)
+ ctx = _Ctx(spec, versions, cards, level, providers)
warned = _WARNING_FIXES.get(level, frozenset())
out: dict[str, dict[str, Any]] = {}
for issue in map(_as_dict, issues):
@@ -522,7 +682,7 @@ def plan_fixes(spec: GraphSpec, issues: list[Any], *, level: str, versions: dict
if planner is None or (issue.get("level") != "error" and code not in warned):
continue
target = _target(ctx, issue)
- fid = f"{code}:{target or 'graph'}"
+ fid = f"{code}:{target or 'graph'}" + (f":{issue.get('field') or ''}" if code in _PER_FIELD else "")
if fid in out:
continue
fix = planner(ctx, issue, ctx.nodes.get(target) if target else None)
@@ -538,9 +698,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
+ 是同一个退路。
"""
+ 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}
by_code: dict[str, str] = {}
by_graph: dict[str, str] = {}
@@ -551,7 +713,8 @@ 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 = by_node.get((code, issue.get("node_id"))) if code else None
+ 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 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})
@@ -560,13 +723,14 @@ def annotate(issues: list[Any], fixes: list[dict[str, Any]]) -> list[dict[str, A
def check(spec: GraphSpec, *, level: str, versions: dict[str, Any] | None = None,
cards: dict[str, list[dict[str, Any]]] | None = None,
- checkers: dict[str, Any] | None = None) -> dict[str, Any]:
+ checkers: dict[str, Any] | None = None, providers: list[Any] | None = None) -> dict[str, Any]:
"""发布前检查:和真正发布同一套口径的问题,以及能怎么修。只读。
- checkers:基于数据目录的 SQL 检查器(data/sqlcheck),和发布接口传的是同一份(governance.lint_for_publish)。
+ checkers:基于数据目录的 SQL 检查器(data/sqlcheck),providers:模型接入表,都和发布接口传的是同一份
+ (governance.lint_for_publish)。
"""
- issues = publish_issues(spec, level=level, checkers=checkers)
- fixes = plan_fixes(spec, issues, level=level, versions=versions, cards=cards)
+ issues = publish_issues(spec, level=level, checkers=checkers, providers=providers)
+ fixes = plan_fixes(spec, issues, level=level, versions=versions, cards=cards, providers=providers)
return {"level": level, "ok": not any(i.level == "error" for i in issues),
"issues": annotate(issues, fixes), "fixes": [public(f) for f in fixes]}
@@ -659,6 +823,26 @@ def _edit_judge_budget(graph: dict[str, Any], fix: dict[str, Any], value: Any) -
return [(str(node["id"]), "judge", before, copy.deepcopy(after))]
+def _edit_provider(graph: dict[str, Any], fix: dict[str, Any], value: Any) -> list[tuple[str | None, str, Any, Any]]:
+ """点名的模型接入(provider.*):照规划时为选中的候选算好的改法写(_plan_provider 的 _edits:写上或去掉接入、
+ 连带去掉的模型)。落在节点上改节点配置,落在全图默认上改 defaults(节点 id 记 None,字段写全路径 defaults.x)。"""
+ if fix["node_id"] is None:
+ if not isinstance(graph.get("defaults"), dict):
+ graph["defaults"] = {}
+ root, prefix = graph["defaults"], "defaults."
+ else:
+ node = _node(graph, fix["node_id"])
+ if node is None:
+ return []
+ root, prefix = _config(node), ""
+ edits: list[tuple[str | None, str, Any, Any]] = []
+ for path, after in (fix.get("_edits") or {}).get(value) or ():
+ before = _put_path(root, path, after)
+ if before is not None or after is not None:
+ edits.append((fix["node_id"], prefix + path, before, copy.deepcopy(after)))
+ return edits
+
+
#: 每类问题怎么改图。大多是同一种「按节点字段写值」,改全图默认、改成果字段的各走一格;留着这张表是为了
#: 某类修复要特殊处理时只换它自己的那一格(测试里也借它模拟一条出错的修复规则)
_EDITORS: dict[str, Callable[[dict[str, Any], dict[str, Any], Any], list[tuple[str | None, str, Any, Any]]]] = {
@@ -666,6 +850,7 @@ _EDITORS: dict[str, Callable[[dict[str, Any], dict[str, Any], Any], list[tuple[s
}
_EDITORS["governed.exit_text_source"] = _edit_exit_fields
_EDITORS["governed.judge_budget"] = _edit_judge_budget
+_EDITORS["provider.missing"] = _EDITORS["provider.disabled"] = _edit_provider
def _handoff(fix: dict[str, Any], value: Any) -> bool:
@@ -697,7 +882,9 @@ def _op(graph: dict[str, Any], node_id: str | None, edits: list[tuple[str | None
顶层字段(整份新值);改全图默认的是 update_defaults,defaults 写改过的那几项。"""
if node_id is None:
defaults = graph.get("defaults") if isinstance(graph.get("defaults"), dict) else {}
- keys = dict.fromkeys(path.split(".", 1)[1] for _, path, _, _ in edits if path.startswith("defaults."))
+ # 顶层那一项整份写:defaults.judge.provider 记 judge
+ keys = dict.fromkeys(re.split(r"[.\[]", path.split(".", 1)[1], maxsplit=1)[0]
+ for _, path, _, _ in edits if path.startswith("defaults."))
return {"op": "update_defaults", "defaults": {k: copy.deepcopy(defaults.get(k)) for k in keys}}
config = _config(_node(graph, node_id) or {})
tops = dict.fromkeys(re.split(r"[.\[]", path, maxsplit=1)[0] for _, path, _, _ in edits)
@@ -707,17 +894,18 @@ def _op(graph: dict[str, Any], node_id: str | None, edits: list[tuple[str | None
def apply_fixes(graph: dict[str, Any] | GraphSpec, fix_ids: list[str], choices: dict[str, Any] | None = None, *,
level: str, versions: dict[str, Any] | None = None,
cards: dict[str, list[dict[str, Any]]] | None = None,
- checkers: dict[str, Any] | None = None) -> dict[str, Any]:
+ checkers: dict[str, Any] | None = None, providers: list[Any] | None = None) -> dict[str, Any]:
"""在副本上逐个应用修复,每一个都复核:降低要求、错误没变少或者冒出新错误的,丢弃并写明原因。
返回 {graph, changes, ops, applied, rejected, handoff, remaining, fixes, ok}。graph 只是预览,不落库。
handoff 是人在 choice 里选了「交给 Copilot」的修复 id:图不动,调用方据此把剩下的 error 交给 Copilot。
+ checkers、providers 同 check()。
"""
raw = graph.model_dump(mode="json") if isinstance(graph, GraphSpec) else graph
current = copy.deepcopy(raw)
spec = GraphSpec.model_validate(current)
- issues = publish_issues(spec, level=level, checkers=checkers)
- plan = plan_fixes(spec, issues, level=level, versions=versions, cards=cards)
+ issues = publish_issues(spec, level=level, checkers=checkers, providers=providers)
+ plan = plan_fixes(spec, issues, level=level, versions=versions, cards=cards, providers=providers)
known = {f["id"] for f in plan}
rank = {code: i for i, code in enumerate(_ORDER)}
wanted = sorted(dict.fromkeys(fix_ids), key=lambda fid: rank.get(fid.split(":")[0], len(rank)))
@@ -748,11 +936,12 @@ def apply_fixes(graph: dict[str, Any] | GraphSpec, fix_ids: list[str], choices:
except Exception: # noqa: BLE001 - 修复规则把图改坏了:丢弃,不交给人一张存不回去的图
rejected.append({"fix_id": fid, "reason": "修复后的工作流结构无法解析,已放弃这项修复"})
continue
- trial_issues = publish_issues(trial_spec, level=level, checkers=checkers)
+ trial_issues = publish_issues(trial_spec, level=level, checkers=checkers, providers=providers)
# choice 里人亲手选的值不算「替人决定」(比如把沙箱代码标成取数);别的降低要求照样拦
chosen = {(str(nid), path) for nid, path, _, _ in edits} if fix["kind"] == "choice" else set()
- why = ";".join(forbidden_changes(current, trial, chosen=chosen)) or judge(
- issues, trial_issues, target=(fix["code"], fix["node_id"]))
+ target = (fix["code"], fix["node_id"], fix.get("field")) if fix["code"] in _PER_FIELD \
+ else (fix["code"], fix["node_id"])
+ why = ";".join(forbidden_changes(current, trial, chosen=chosen)) or judge(issues, trial_issues, target=target)
if why:
rejected.append({"fix_id": fid, "reason": why})
continue
@@ -763,7 +952,7 @@ def apply_fixes(graph: dict[str, Any] | GraphSpec, fix_ids: list[str], choices:
"before": before, "after": after, "label": fix["label"]} for nid, path, before, after in edits]
if edits:
ops.append(_op(current, fix["node_id"], edits))
- plan = plan_fixes(spec, issues, level=level, versions=versions, cards=cards)
+ plan = plan_fixes(spec, issues, level=level, versions=versions, cards=cards, providers=providers)
return {"graph": current, "changes": changes, "ops": ops, "applied": applied, "rejected": rejected,
"handoff": handoff, "remaining": annotate(issues, plan), "fixes": [public(f) for f in plan],
"ok": not any(i.level == "error" for i in issues)}
@@ -812,11 +1001,12 @@ def worse(before: list[Any], after: list[Any]) -> str | None:
return None
-def judge(before: list[Any], after: list[Any], *, target: tuple[str, str | None] | None = None) -> str | None:
+def judge(before: list[Any], after: list[Any], *, target: tuple[Any, ...] | None = None) -> str | None:
"""改完是不是更好了:不能冒出新的 error,error 不能变多;要修的那一处必须没了。
target 是 (问题编号, 节点 id):修的是一条警告(strict)时,error 不增加、那条警告消失就算更好;
修的是 error,或者不指定 target(Copilot 兜底),error 必须变少。返回 None 表示更好,否则是原因。
+ 一处一条的修复(点名的模型接入)再带上字段 (问题编号, 节点 id, 字段):同一节点上别的字段还有同类问题不算没修好。
"""
if why := worse(before, after):
return why
@@ -824,11 +1014,13 @@ def judge(before: list[Any], after: list[Any], *, target: tuple[str, str | None]
a = [_as_dict(i) for i in after]
b_err, a_err = _errors(before, after)
if target is not None:
- code, node_id = target
+ code, node_id, *at = target
+ field = at[0] if at else None
def hit(i: dict[str, Any]) -> bool:
# 图级修复(node_id 为 None,改全图默认)管的是这个编号下挂在各个节点上的每一条
- return i.get("code") == code and (node_id is None or i.get("node_id") in (node_id, None))
+ return i.get("code") == code and (node_id is None or i.get("node_id") in (node_id, None)) \
+ and (field is None or i.get("field") == field)
still = [i for i in a if hit(i)]
if still:
diff --git a/backend/app/engine/governance.py b/backend/app/engine/governance.py
index 708f0e46..7b583de3 100644
--- a/backend/app/engine/governance.py
+++ b/backend/app/engine/governance.py
@@ -30,7 +30,8 @@ UPGRADE_POLICIES = {"recompute", "dual", "incomparable"}
UPGRADE_POLICY_LABELS = {k: option_label("upgrade_policy", k) for k in ("recompute", "dual", "incomparable")}
-def lint_for_publish(spec: GraphSpec, *, level: str, checkers: Mapping[str, Any] | None = None) -> ValidationResult:
+def lint_for_publish(spec: GraphSpec, *, level: str, checkers: Mapping[str, Any] | None = None,
+ providers: list[Any] | None = None) -> ValidationResult:
"""发布门禁。
published:基础可运行性之外只给警告。
@@ -42,6 +43,10 @@ def lint_for_publish(spec: GraphSpec, *, level: str, checkers: Mapping[str, Any]
checkers 是各数据源基于数据目录的 SQL 检查器(data/sqlcheck.load_checkers_by_name),由调用方读好传进来:
门禁本身不查库。给了的话,调用工具节点里写死的 SQL 对照目录查一遍(_lint_sql)。
+
+ providers 是模型接入表(provider_refs.load_providers),同样由调用方读好传进来。给了的话,节点点名的接入
+ 不存在或已停用、只写了模型而提供它的接入已停用的每一处一条 error(provider.missing / provider.disabled),
+ 两档都挡:正式运行到那里一定失败,或者少一块核查。不给(一键升级、助手自查这类只看图本身的地方)就不查。
"""
result = ValidationResult()
strict = level == "governed"
@@ -105,6 +110,11 @@ 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
+
+ for issue in provider_issues(provider_refs(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:
result.add(
@@ -114,13 +124,14 @@ def lint_for_publish(spec: GraphSpec, *, level: str, checkers: Mapping[str, Any]
return result
-def publish_issues(spec: GraphSpec, *, level: str, checkers: Mapping[str, Any] | None = None) -> list[Any]:
+def publish_issues(spec: GraphSpec, *, level: str, checkers: Mapping[str, Any] | None = None,
+ providers: list[Any] | None = None) -> list[Any]:
"""发布时拦的全部问题:validate_graph 和 lint_for_publish 的并集,顺序也照发布接口。
- 发布、发布前检查、自动修复复核都走这一个口径——检查说能发的,真发布时不会被拦。checkers 见 lint_for_publish:
- 这几处都要传同一份,否则检查和发布对 SQL 的判断不一致。
+ 发布、发布前检查、自动修复复核都走这一个口径——检查说能发的,真发布时不会被拦。checkers、providers 见
+ lint_for_publish:这几处都要传同一份,否则检查和发布对 SQL、对模型接入的判断不一致。
"""
- gate = lint_for_publish(spec, level=level, checkers=checkers).issues
+ gate = lint_for_publish(spec, level=level, checkers=checkers, providers=providers).issues
return [*_not_repeated(validate_graph(spec).issues, gate, spec), *gate]
diff --git a/backend/app/engine/provider_refs.py b/backend/app/engine/provider_refs.py
new file mode 100644
index 00000000..7014745a
--- /dev/null
+++ b/backend/app/engine/provider_refs.py
@@ -0,0 +1,258 @@
+"""工作流里点名的模型接入:不存在或已停用的,画布校验、发布前检查、发布时照实提出来。
+
+运行时点名的接入不存在或已停用直接报错(providers/factory.named_provider),以前是悄悄换成演示模型。这类问题发布前
+就该看得到:画布校验给警告(探索运行仍可发起,执行到那里报错),发布、发布前检查、自动修复的复核给错误、挡住
+发布——正式运行到那里一定失败,或者少一块核查。
+
+查的是执行时真会去取模型的那几处,取法和执行器一致(uses):
+- 模型调用、Agent、报告撰写、多 Agent 协作的接入;条件分支只在「让模型分类」时、结构校验只在「让模型修复」且允许
+ 重试时(max_retries 为 0 一次都不修)、知识检索只在「让模型重排」时才取模型。这几类节点没写自己的接入、模型时
+ 各自取全图默认(NodeContext.cfg);
+- 多 Agent 协作各成员:自己没写的接入、模型各自跟随节点(再到全图默认,nodes/multi._member);报告撰写交给模型
+ 裁判结论句时,「结论句裁判」里的接入、模型(节点没写裁判配置时取全图默认里的那一份,同 nodes/report.report_judge);
+- 双盲复算、反方审稿的接入、模型(核查模型):只读节点自己的配置,不取全图默认。节点自己的开关关着时执行器直接
+ 跳过、不取模型,不查;设置里的总开关不看——它随时会被打开,打开时这一处就会失败;
+- 全图默认的接入、模型:有节点会取到它时才查。
+取到了接入的,查点名的那个接入(不存在、已停用);哪一级都没写接入、只写了模型的,按模型名找接入,只有停用的接入
+提供它时同样是问题(provider.disabled,落在写模型的那一处)——运行时 resolve_provider 报的就是这一句,而多数节点的
+检查器里只能选模型,这是最常见的写法。核查、裁判的接入、模型都没写时交给设置,不归这里查。
+
+说法分两种:检查器里有这一项下拉框的几处(双盲复算、反方审稿、节点自己的结论句裁判的接入和模型,几类节点自己的模型)
+说「在节点中换一个接入 / 模型」;别处(节点自己的接入、协作成员、全图默认)界面上没有下拉框,说「在发布前检查中改用
+其他接入」——发布前检查给这类问题配了修复(engine/autofix.py 的 provider.*:改用一个启用的接入,或不再指定),修复
+按 land 算出修完以后执行时落到哪里,落不稳的候选不给。
+
+接入表要读库,门禁本身不查库(governance.lint_for_publish),所以由接口这一层读出接入表交进来,同数据目录
+SQL 检查器的做法。设置里点名接入的几项由 api/settings.py 在保存时查。
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+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.providers.factory import (
+ NAMED_FIX, ProviderNotConfigured, fallback_provider, list_providers, 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): "或在「发布前检查」中改用其他接入"}
+#: 检查器里有模型下拉框的几类节点(前端 nodeDefs 的 MODEL_FIELDS;条件分支只在「让模型分类」时显示,也只在那时才查)
+_MODEL_PICKER = frozenset({NodeType.LLM, NodeType.AGENT, NodeType.SUPERVISOR, NodeType.BRANCH, NodeType.REPORT})
+#: 核查、裁判的接入和模型都没写时执行用设置里的哪一项(api/settings.verify_model_spec、judge.judge_model_spec)
+_SETTINGS = {"verify": "设置中的核查模型", "judge": "设置中的裁判模型"}
+
+
+def _cfg(spec: GraphSpec, node: GraphNode, key: str, default: Any = None) -> Any:
+ """和 NodeContext.cfg 同一个规则:节点上没写(None、空串)就取全图默认。"""
+ value = node.config.get(key)
+ return spec.defaults.get(key, default) if value in (None, "") else value
+
+
+def _repairs(spec: GraphSpec, node: GraphNode) -> bool:
+ """结构校验会不会让模型修复:允许修复,而且至少重试一次(nodes/human.run_validate 同一个取值)。认不出的重试次数
+ 按会修复算:执行时它照样报错,这里宁可多提一句。"""
+ if not _cfg(spec, node, "repair_with_llm", True):
+ return False
+ try:
+ return int(_cfg(spec, node, "max_retries", 2) or 0) > 0
+ except (TypeError, ValueError):
+ return True
+
+
+#: 按 NodeContext.cfg 取接入的节点 → 执行时真会去取模型的条件(None 是总会取)
+_TAKES_MODEL: dict[NodeType, Callable[[GraphSpec, GraphNode], bool] | None] = {
+ NodeType.LLM: None,
+ NodeType.AGENT: None,
+ NodeType.REPORT: None,
+ NodeType.SUPERVISOR: None,
+ NodeType.BRANCH: lambda spec, node: _cfg(spec, node, "mode", "expression") == "llm",
+ NodeType.VALIDATE: _repairs,
+ NodeType.RETRIEVE: lambda spec, node: _cfg(spec, node, "rerank", "off") == "model",
+}
+#: 核查节点:接入只读节点自己的配置(verify_model_spec),图级默认的接入是写作模型的
+_VERIFY = {NodeType.DUAL_CHECK: "的盲算模型", NodeType.CHALLENGE: "的反方模型"}
+
+
+def _name(value: Any) -> str | None:
+ return value.strip() or None if isinstance(value, str) else None
+
+
+@dataclass(frozen=True)
+class ProviderRef:
+ """图里写着接入或模型的一处:落在哪个节点(全图默认为 None)、哪个字段,写的名字,句子里怎么称呼这一处,检查器里
+ 有没有这一项的下拉框;kind 是写的接入(provider)还是模型(model)。"""
+
+ node_id: str | None
+ field: str
+ name: str
+ who: str
+ editable: bool = False
+ kind: str = "provider"
+
+
+def _place(node_id: str | None, field: str, value: Any, who: str, *, editable: bool = False,
+ kind: str = "provider") -> ProviderRef | None:
+ name = _name(value)
+ return ProviderRef(node_id, field, name, who, editable, kind) if name else None
+
+
+@dataclass(frozen=True)
+class Use:
+ """执行时取一次模型:key 是(节点 id,哪一次:node、agents[i]、judge、verify),who 是句子里怎么称呼它;接入、模型
+ 各取自哪一处(哪一级都没写为 None)。settings 是核查、裁判的接入和模型都没写、交给设置的那一项(verify / judge)。"""
+
+ key: tuple[str, str]
+ who: str
+ provider: ProviderRef | None
+ model: ProviderRef | None
+ settings: str | None = None
+
+
+def _own_or_default(spec: GraphSpec, node: GraphNode, key: str, who: str) -> ProviderRef | None:
+ """节点的 provider / model 取自哪一处,同 NodeContext.cfg:节点上没写(None、空串)就取全图默认。"""
+ if node.config.get(key) in (None, ""):
+ return _place(None, f"defaults.{key}", spec.defaults.get(key), field_label("defaults"), kind=key)
+ return _place(node.id, key, node.config.get(key), who, kind=key,
+ editable=key == "model" and node.type in _MODEL_PICKER)
+
+
+def uses(spec: GraphSpec) -> list[Use]:
+ """图里执行时取模型的每一处,按画布上节点的顺序;只算执行时真会取模型的(见模块说明)。"""
+ out: list[Use] = []
+ for node in spec.nodes:
+ cfg = node.config
+ who = f"「{node.title}」({type_label(node.type)})"
+ if node.type in _VERIFY:
+ # 接入、模型哪一项写了就整组以节点为准,都没写交给设置(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
+
+
+def provider_refs(spec: GraphSpec) -> list[ProviderRef]:
+ """执行时要查的那几处,按画布上节点的顺序,全图默认排最后,同一处只记一次:取到了接入的是那个接入;哪一级都没写
+ 接入、只写了模型的是那个模型(按模型名找接入)。"""
+ found: dict[tuple[str | None, str], ProviderRef] = {}
+ for use in uses(spec):
+ if (ref := use.provider or use.model) is not None:
+ found.setdefault((ref.node_id, ref.field), ref)
+ return sorted(found.values(), key=lambda r: r.node_id is None)
+
+
+def broken(refs: list[ProviderRef], providers: list[Any]) -> list[tuple[ProviderRef, ProviderNotConfigured]]:
+ """执行时会报错的那几处,带着运行时报的那个错(code 是 missing / disabled):点名的接入按名字找(named_provider),
+ 只写了模型的按模型名找(provider_for_model,认不出的模型名运行时放行,不算)。"""
+ out: list[tuple[ProviderRef, ProviderNotConfigured]] = []
+ for ref in refs:
+ try:
+ if ref.kind == "model":
+ provider_for_model(providers, ref.name)
+ else:
+ named_provider(providers, ref.name)
+ except ProviderNotConfigured as e:
+ out.append((ref, e))
+ return out
+
+
+def issues(refs: list[ProviderRef], providers: list[Any], *, level: str) -> list[ValidationIssue]:
+ """执行时会报错的每一处一条问题。providers 是接入表(factory.list_providers),level 由调用方定:画布校验 warning,
+ 发布一类 error。原因和运行时报的同一句,提示按这一处在界面上能不能改。"""
+ 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)]
+
+
+@dataclass(frozen=True)
+class Landing:
+ """执行时这一处落到哪里:where 是说给人听的(「工作流默认设置指定的接入「甲」」……),model 是用哪个模型(交给设置时
+ 为 None),ok 是修复能不能把它落在这里。"""
+
+ where: str
+ model: str | None
+ ok: bool
+
+
+def land(use: Use, providers: list[Any]) -> Landing:
+ """按执行器的取法(get_chat_model → resolve_provider → build_chat_model)算这一处执行时用哪个接入、哪个模型。
+
+ ok 为真才算落得稳:点名的接入启用着、有模型可用,并且提供这一处写的模型——拿 A 家的模型名去调 B 家必然失败;
+ 只写了模型的,按模型名找到启用的接入。按模型名落到停用的接入(运行时报错)、模型名认不出来(落到第一个可用的
+ 接入,它多半不提供)、一个启用的接入都没有(悄悄换成内置演示模型)都不算。交给设置的(核查、裁判)算落得稳:
+ 设置保存时查过点名的接入,之后接入出了问题设置页会标出来。"""
+ if use.settings:
+ return Landing(_SETTINGS[use.settings], None, True)
+ model = use.model.name if use.model is not None else None
+ if use.provider is not None:
+ try:
+ found = named_provider(providers, use.provider.name)
+ except ProviderNotConfigured as e:
+ return Landing(e.reason, model, False)
+ where = f"{use.provider.who}指定的接入「{found.name}」"
+ elif model:
+ try:
+ found = provider_for_model(providers, model)
+ except ProviderNotConfigured as e:
+ return Landing(e.reason, model, False)
+ if found is None:
+ return Landing(f"认不出的模型「{model}」", model, False)
+ where = f"提供模型「{model}」的接入「{found.name}」"
+ else:
+ found = fallback_provider(providers)
+ if found is None:
+ return Landing("内置演示模型", None, False)
+ where = f"第一个可用的模型接入「{found.name}」"
+ used = model or found.default_model
+ return Landing(where, used, bool(used) and (model is None or serves(found, model)))
+
+
+async def load_issues(session: AsyncSession, spec: GraphSpec, *, level: str) -> list[ValidationIssue]:
+ """读接入表、查一遍。图里执行时取模型的几处都没写接入和模型时不读库。"""
+ refs = provider_refs(spec)
+ if not refs:
+ return []
+ return issues(refs, await list_providers(session), level=level)
+
+
+async def load_providers(session: AsyncSession, spec: GraphSpec) -> list[Any] | None:
+ """发布、发布前检查、自动修复交给门禁的接入表(governance.lint_for_publish 的 providers)。图里执行时取模型的几处
+ 都没写接入和模型时不读库,给空表:没有要查的,修复也用不上候选。"""
+ return await list_providers(session) if provider_refs(spec) else []
diff --git a/backend/app/providers/factory.py b/backend/app/providers/factory.py
index 0f7e3cee..f6cb0a66 100644
--- a/backend/app/providers/factory.py
+++ b/backend/app/providers/factory.py
@@ -34,7 +34,7 @@ class ModelSpec(BaseModel):
class ProviderNotConfigured(RuntimeError):
"""模型接入没配好。reason 说缺了什么,hint 说去哪里补;str(e) 是两句连起来的整句,
运行失败原因直接用它。设置页的测试弹窗本来就在接入的编辑框里,只取 reason、另给自己的 hint。
- code 给调用方按类别分支: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)
@@ -48,6 +48,71 @@ async def list_providers(session: AsyncSession) -> list[Provider]:
return list(rows.scalars())
+#: 点名的接入不存在、已停用时,提示的前半句:在设置里能做什么。后半句(去哪里换一个接入)看场合:运行时报错、
+#: 设置页写「或换一个接入」;画布校验、发布前检查按这一处在界面上能不能改另写(engine/provider_refs.py)
+NAMED_FIX = {"missing": "请在「设置 → 模型接入」中核对名称", "disabled": "请在「设置 → 模型接入」中启用它"}
+
+
+def named_provider(providers: list[Provider], ref: Any) -> Provider:
+ """按名称或 id 找显式点名的接入(providers 是 list_providers 的结果)。找不到、找到的已停用都抛
+ ProviderNotConfigured:点名了就只用它,不换别家。运行时取模型、设置和画布校验都按这一处认,说法一致。
+ 名称两边的空白不算;不是字符串的(接口写进来的数字一类)当作点了一个不存在的名字。"""
+ 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 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
+
+
+def serves(provider: Any, model: str) -> bool:
+ """这个接入提供不提供这个模型:模型列表里有,或者是它的默认模型(按模型名找接入的同一个认法)。"""
+ return any((m or {}).get("id") == model for m in (provider.models or [])) or provider.default_model == model
+
+
+def provider_for_model(providers: list[Provider], model: str) -> Provider | None:
+ """没点名接入时按模型名找提供它的接入(providers 是 list_providers 的结果):先认模型列表、再认默认模型,只看启用的。
+
+ 只有停用的接入提供它时抛 ProviderNotConfigured(disabled)——这时候不能默默换一家:拿着 A 家的模型名去调 B 家,
+ 必然 404,而且报错完全指不到真正的原因。认不出来的模型名返回 None:用户可能手填了一个目录里还没有的新模型,由
+ 调用方退回 fallback_provider。运行时取模型(resolve_provider)和画布校验、发布前检查(engine/provider_refs.py)
+ 按这一处认,说法一致。"""
+ enabled = [p for p in providers if p.enabled]
+ found = next((p for p in enabled if any((m or {}).get("id") == model for m in (p.models or []))), None) \
+ or next((p for p in enabled if p.default_model == model), None)
+ if found is not None:
+ return found
+ off = next((p for p in providers if not p.enabled and serves(p, model)), None)
+ if off is not None:
+ raise ProviderNotConfigured(f"模型「{model}」所属的模型接入「{off.name}」已停用",
+ "请在「设置 → 模型接入」中启用它,或为节点换一个模型", code="disabled")
+ return None
+
+
+def fallback_provider(providers: list[Provider]) -> Provider | None:
+ """没点名接入、也没写模型(或模型名认不出来)时用哪个:第一个启用的真实接入,没有就第一个启用的演示接入;一个启用
+ 的都没有返回 None(get_chat_model 换成内置演示模型)。"""
+ enabled = [p for p in providers if p.enabled]
+ real = [p for p in enabled if p.kind != "mock"]
+ return (real or enabled or [None])[0]
+
+
+def assistant_unavailable(e: BaseException) -> str:
+ """助手(画布上改图、表格导入与数据目录的 AI 起草)用的模型取不到时给人看的那句话。
+
+ 点名的接入不存在、已停用(按模型名找到的接入已停用也算)照实说是哪个接入、在设置里能做什么,或者给助手换一个
+ 模型;以前这几种要么悄悄换成演示模型,要么各处自己判停用。其余(缺 key、没有模型……)是接入本身没配完。"""
+ 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)}。请到「设置 → 模型接入」检查"
+
+
async def resolve_provider(
session: AsyncSession, ref: str | None, model: str | None = None
) -> Provider | None:
@@ -56,38 +121,23 @@ async def resolve_provider(
顺序很关键:显式指定的 ref 最优先;其次按模型名反查 —— 节点上通常只选了
模型(比如 mock-fast),没选 provider,这时必须找到真正提供该模型的那一个,
否则会拿着 A 家的模型名去调 B 家的接口。最后才回退到默认 provider。
+
+ 显式给了 ref 却不存在或已停用,抛 ProviderNotConfigured(named_provider)。以前不存在时返回 None,
+ get_chat_model 把它当成「一个接入都没配」,悄悄换成演示模型:名字写错一个字、接入改了名或删了,
+ 报告就由演示模型写成假文本,核查也由它来「核」,运行照样成功。停用的照常返回、照常调用,
+ 又和下面「按模型名找到的接入已停用就报错」对不上。
+ 返回 None 只剩一种情况:没给 ref(只有空白的也算没给),而一个启用的接入都没有。不是字符串的 ref(接口写进
+ 设置里的数字一类)按点名了一个不存在的接入报,不当成没给。
"""
providers = await list_providers(session)
- enabled = [p for p in providers if p.enabled]
- if ref:
- for p in providers:
- if p.name == ref or p.id == ref:
- return p
- return None
+ if ref is not None and (not isinstance(ref, str) or ref.strip()):
+ return named_provider(providers, ref)
- if model:
- for p in enabled:
- if any((m or {}).get("id") == model for m in (p.models or [])):
- return p
- for p in enabled:
- if p.default_model == model:
- return p
- # 模型认得出来,但它所属的 provider 被停用了。这时候不能默默换一家 ——
- # 拿着 A 家的模型名去调 B 家,必然 404,而且报错完全指不到真正的原因。
- for p in providers:
- if not p.enabled and (
- any((m or {}).get("id") == model for m in (p.models or []))
- or p.default_model == model
- ):
- raise ProviderNotConfigured(
- f"模型「{model}」所属的模型接入「{p.name}」已停用",
- "请在「设置 → 模型接入」中启用它,或为节点换一个模型", code="disabled",
- )
- # 认不出来的模型名就放行:用户可能手填了一个目录里还没有的新模型
-
- real = [p for p in enabled if p.kind != "mock"]
- return (real or enabled or [None])[0]
+ # 模型认得出来、所属的接入却停用了:provider_for_model 照实报错,不默默换一家。认不出来的模型名放行
+ if model and (found := provider_for_model(providers, model)) is not None:
+ return found
+ return fallback_provider(providers)
def _resolve_key(provider: Provider) -> str | None:
@@ -362,8 +412,11 @@ def bind_tools_safely(model: BaseChatModel, tools: list[Any], *, parallel: bool)
async def get_chat_model(session: AsyncSession, spec: ModelSpec) -> tuple[BaseChatModel, str]:
- """返回 (模型, 实际使用的模型 id)。找不到任何 provider 时回退到 mock,
- 保证一张图在没配 key 的机器上也能跑完。"""
+ """返回 (模型, 实际使用的模型 id)。
+
+ 只有没点名接入、而一个启用的接入都没有时才回退到演示模型,保证一张图在没配 key 的机器上也能执行完。
+ 点名的接入不存在或已停用、按模型名找到的接入已停用、接入缺 key 这些都抛 ProviderNotConfigured,
+ 由调用方照实报给人(节点失败、核查记缺口……),不换成演示模型。"""
provider = await resolve_provider(session, spec.provider, spec.model)
if provider is None:
return MockChatModel(model_name="mock-fast"), "mock-fast"
diff --git a/backend/tests/test_provider_ref_checks.py b/backend/tests/test_provider_ref_checks.py
new file mode 100644
index 00000000..639b4da2
--- /dev/null
+++ b/backend/tests/test_provider_ref_checks.py
@@ -0,0 +1,495 @@
+"""保存设置、校验画布、发布时就查出点名了不存在或已停用的模型接入(运行时它们会照实报错,见 test_provider_ref_runtime)。
+
+- PUT /api/settings:引用接入的几项(核查模型、证据裁判、门控模型、助手)写了不存在或已停用的接入 → 422,带 field、
+ code 和设置页上的叫法;一组出错整次不落库。留空照旧表示沿用上一级;存着的值没改(写好之后接入才被删、停用)不挡
+ 这次保存别的项——设置页标着「不存在」,运行时照实报错;
+- 画布校验:节点点名的接入不存在或已停用给警告,说清哪个节点、哪个接入;发布前检查、发布、自动修复的复核给错误,
+ 挡住发布。只查执行时真会去取模型的那几处(分支「让模型分类」、校验「让模型修复」且允许重试、检索「让模型重排」
+ 时才算;核查节点自己的开关关着不算);报告没写裁判配置时查全图默认里的那一份;
+- 提示按这一处在界面上能不能改:检查器里有接入下拉框的说「在节点中换一个接入」,别处说「在发布前检查中改用」;
+- 发布前检查给每一处配一条要人选的修复:改用一个启用的接入,或不指定接入(修完以后执行时落到哪里、只写了模型而
+ 提供它的接入已停用的那一处,见 test_provider_ref_landing)。
+
+接入名、节点名都是虚构的。
+"""
+from __future__ import annotations
+
+import uuid
+
+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.main import app
+
+LIVE, OFF, GONE = "核对用接入", "核对用停用接入", "核对用接人"
+SETTING_KEYS = ["verify", "judge", "copilot", "run"]
+
+
+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_([LIVE, OFF])))
+ await session.commit()
+
+
+@pytest.fixture(autouse=True)
+async def providers():
+ """一个启用、一个停用的接入(都是演示类,不联网);用完删掉,免得改变同一进程里别的测试「没点名」时的回退。"""
+ await _clean()
+ async with SessionLocal() as session:
+ live = Provider(name=LIVE, kind="mock", models=[{"id": "核对模型"}], default_model="核对模型", enabled=True,
+ extra={})
+ off = Provider(name=OFF, kind="mock", models=[{"id": "核对停用模型"}], default_model="核对停用模型",
+ enabled=False, extra={})
+ session.add_all([live, off])
+ await session.commit()
+ ids = {LIVE: live.id, OFF: off.id}
+ yield ids
+ await _clean()
+
+
+@pytest.fixture
+async def client():
+ 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
+
+
+# --------------------------------------------------------------------------
+# 保存设置
+# --------------------------------------------------------------------------
+
+#: (组, 键, 设置页上的叫法)
+FIELDS = [
+ ("verify", "provider", "核查模型 · 接入"),
+ ("judge", "provider", "裁判模型 · 接入"),
+ ("run", "tool_gate_provider", "门控模型 · 接入"),
+ ("copilot", "provider", "助手模型 · 接入"),
+]
+
+
+@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 await stored(group) is None
+
+
+@pytest.mark.parametrize("group,key,label", FIELDS)
+async def test_saving_a_disabled_provider_is_refused_too(client, group, key, label):
+ r = await client.put("/api/settings", json={"values": {group: {key: OFF}}})
+ 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 await stored(group) is None
+
+
+async def test_one_bad_group_keeps_the_whole_request_out_of_the_database(client):
+ r = await client.put("/api/settings", json={"values": {
+ "run": {"default_memory_scope": "门店", "tool_gate_provider": LIVE},
+ "judge": {"provider": LIVE, "model": None},
+ "verify": {"provider": GONE},
+ }})
+ assert r.status_code == 422 and r.json()["field"] == "verify.provider"
+ assert await stored("run") is None and await stored("judge") is None and await stored("verify") is None
+
+
+async def test_blank_means_inherit_and_an_enabled_provider_is_taken_by_name_or_id(client, providers):
+ r = await client.put("/api/settings", json={"values": {
+ "verify": {"provider": None, "model": "核对模型"},
+ "judge": {"provider": " "},
+ "run": {"tool_gate_provider": ""},
+ }})
+ assert r.status_code == 200, r.text
+ r = await client.put("/api/settings", json={"values": {
+ "verify": {"provider": LIVE}, "judge": {"provider": providers[LIVE]},
+ "run": {"tool_gate_provider": LIVE}, "copilot": {"provider": LIVE, "model": ""},
+ }})
+ assert r.status_code == 200, r.text
+ got = r.json()
+ assert got["verify"]["provider"] == LIVE and got["judge"]["provider"] == providers[LIVE]
+ assert got["run"]["tool_gate_provider"] == LIVE
+
+
+async def test_a_stored_provider_removed_later_does_not_block_saving_the_rest_of_its_group(client):
+ """写好之后接入才被删掉:存着的值没改,这次保存别的项照常(运行时照实报错,设置页标着「不存在」)。
+ 改成另一个不存在的名字仍然拦下。"""
+ async with SessionLocal() as session:
+ session.add(Setting(key="verify", value={"provider": GONE, "daily_max_usd": 2.0}))
+ await session.commit()
+ r = await client.put("/api/settings", json={"values": {"verify": {"provider": f" {GONE} ", "daily_max_usd": 3}}})
+ assert r.status_code == 200, r.text
+ assert (await stored("verify"))["daily_max_usd"] == 3
+ r = await client.put("/api/settings", json={"values": {"verify": {"provider": "另一个不存在的接入"}}})
+ assert r.status_code == 422 and r.json()["field"] == "verify.provider"
+
+
+async def test_format_problems_are_still_reported_first(client):
+ r = await client.put("/api/settings", json={"values": {"verify": {"provider": 3}}})
+ assert r.status_code == 422
+ assert "需要填写模型接入的名称或模型 ID" in r.json()["detail"]
+
+
+# --------------------------------------------------------------------------
+# 画布校验、发布前检查、发布
+# --------------------------------------------------------------------------
+
+
+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 chain(*nodes, defaults=None):
+ graph = {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
+ if defaults:
+ graph["defaults"] = defaults
+ return graph
+
+
+def writer(ref: str | None, **extra) -> dict:
+ return chain(node("start", "input"),
+ node("write", "llm", "写周报", **({"provider": ref} if ref else {}), prompt="写周报", **extra),
+ node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]))
+
+
+def provider_issues(result: dict) -> list[dict]:
+ return [i for i in result["issues"] if str(i.get("code") or "").startswith("provider.")]
+
+
+@pytest.mark.parametrize("ref,code,state", [(GONE, "provider.missing", "不存在"), (OFF, "provider.disabled", "已停用")])
+async def test_validation_warns_about_a_node_naming_a_broken_provider(client, ref, code, state):
+ r = await client.post("/api/workflows/validate", json={"graph": writer(ref)})
+ assert r.status_code == 200, r.text
+ [issue] = provider_issues(r.json())
+ assert issue["level"] == "warning" and issue["code"] == code
+ assert (issue["node_id"], issue["field"]) == ("write", "provider")
+ assert issue["message"].startswith(f"「写周报」(模型调用)指定的模型接入「{ref}」{state}。"), issue["message"]
+ assert r.json()["ok"] is True, "只是警告:探索运行仍可发起,执行到那里照实报错"
+
+
+async def test_validation_is_quiet_for_an_enabled_provider_or_none(client, providers):
+ for ref in (LIVE, providers[LIVE], None):
+ r = await client.post("/api/workflows/validate", json={"graph": writer(ref)})
+ assert provider_issues(r.json()) == [], ref
+
+
+async def test_every_place_a_graph_names_a_provider_is_checked(client):
+ graph = chain(
+ node("start", "input"),
+ node("team", "supervisor", "协作", provider=OFF, agents=[{"name": "分析师", "provider": GONE, "system": "分析"},
+ {"provider": LIVE, "system": "复核"}]),
+ node("write", "report", "写周报", instructions="写周报", claims="judge", judge={"provider": GONE}),
+ node("dual", "dual_check", "复核销售额", provider=GONE),
+ node("chal", "challenge", "审稿", report="write", provider=OFF),
+ node("ask", "llm", "提问", prompt="总结"), # 没写接入:取全图默认
+ node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]),
+ defaults={"provider": GONE},
+ )
+ r = await client.post("/api/workflows/validate", json={"graph": graph})
+ got = {(i["node_id"], i["field"]): i for i in provider_issues(r.json())}
+ assert set(got) == {("team", "provider"), ("team", "agents[0].provider"), ("write", "judge.provider"),
+ ("dual", "provider"), ("chal", "provider"), (None, "defaults.provider")}
+ assert got[("team", "agents[0].provider")]["message"].startswith(
+ 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[(None, "defaults.provider")]["message"].startswith(f"工作流默认设置指定的模型接入「{GONE}」不存在")
+
+
+async def test_only_places_that_actually_call_a_model_are_checked(client):
+ """分支按表达式判断、校验不让模型修复、检索不让模型重排时根本不取模型;报告不交给模型裁判时裁判的接入用不上;
+ 每个节点都写了自己的接入时,全图默认的接入没人用。"""
+ graph = chain(
+ node("start", "input"),
+ node("route", "branch", "分流", mode="expression", provider=GONE, cases=[{"key": "a", "condition": "true"}]),
+ node("check", "validate", "校验", repair_with_llm=False, provider=GONE, schema={"type": "object"}),
+ node("look", "retrieve", "检索", rerank="off", provider=GONE),
+ node("write", "report", "写周报", instructions="写周报", provider=LIVE, judge={"provider": GONE}),
+ node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]),
+ defaults={"provider": GONE},
+ )
+ r = await client.post("/api/workflows/validate", json={"graph": graph})
+ assert provider_issues(r.json()) == []
+ graph["nodes"][1]["data"]["config"]["mode"] = "llm"
+ graph["nodes"][3]["data"]["config"]["rerank"] = "model"
+ r = await client.post("/api/workflows/validate", json={"graph": graph})
+ assert {i["node_id"] for i in provider_issues(r.json())} == {"route", "look"}
+ # 分类的分支不写自己的接入:取全图默认,默认里的接入这才有人用
+ del graph["nodes"][1]["data"]["config"]["provider"]
+ r = await client.post("/api/workflows/validate", json={"graph": graph})
+ assert {(i["node_id"], i["field"]) for i in provider_issues(r.json())} == {("look", "provider"),
+ (None, "defaults.provider")}
+
+
+async def _workflow(graph: dict) -> str:
+ async with SessionLocal() as session:
+ wf = Workflow(name=f"门店周报-{uuid.uuid4().hex[:6]}", 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
+
+
+@pytest.mark.parametrize("level", ["published", "governed"])
+async def test_publish_check_and_publish_stop_at_a_broken_provider(client, level):
+ wf = await _workflow(writer(GONE))
+ r = await client.post(f"/api/workflows/{wf}/publish-check", json={"level": level})
+ assert r.status_code == 200, r.text
+ check = r.json()
+ [issue] = provider_issues(check)
+ assert issue["level"] == "error" and issue["node_id"] == "write"
+ assert issue["fix"] == "provider.missing:write:provider"
+ assert check["ok"] is False
+ r = await client.post(f"/api/workflows/{wf}/publish", json={"level": level})
+ body = r.json()
+ assert body["ok"] is False and [i["code"] for i in provider_issues(body)] == ["provider.missing"]
+ async with SessionLocal() as session:
+ row = await session.get(Workflow, wf)
+ assert row.published_version is None and row.status == "draft"
+
+
+async def test_publish_check_reads_the_unsaved_canvas_and_autofix_keeps_the_problem(client):
+ wf = await _workflow(writer(None))
+ r = await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "published", "graph": writer(OFF)})
+ assert r.json()["ok"] is False and [i["code"] for i in provider_issues(r.json())] == ["provider.disabled"]
+ r = await client.post(f"/api/workflows/{wf}/autofix", json={"level": "published", "graph": writer(OFF)})
+ assert r.status_code == 200, r.text
+ out = r.json()
+ assert out["ok"] is False
+ assert [(i["code"], i["level"]) for i in provider_issues({"issues": out["remaining"]})] == [
+ ("provider.disabled", "error")]
+ # 换成启用的接入:不再拦
+ r = await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "published", "graph": writer(LIVE)})
+ assert provider_issues(r.json()) == [] and r.json()["ok"] is True
+
+
+async def test_handing_only_a_provider_problem_to_the_assistant_says_it_cannot_help(client):
+ """修复之后门禁不再拦、只剩点名接入的问题:不说「已没有阻止发布的错误」,说清助手处理不了、该去哪里改。"""
+ from app.api.workflows import PROVIDER_LEFT
+
+ 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["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),
+ "assist": True})
+ assert r.json()["assist"]["summary"] == "修复后已没有阻止发布的错误,未再交给助手" and r.json()["ok"] is True
+
+
+async def test_publishing_a_clean_graph_is_unaffected(client):
+ wf = await _workflow(writer(LIVE))
+ r = await client.post(f"/api/workflows/{wf}/publish", json={"level": "published"})
+ assert r.json()["ok"] is True, r.text
+
+
+@pytest.mark.parametrize("group,key,label", [("run", "tool_gate_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 await stored(group) is None
+
+
+# --------------------------------------------------------------------------
+# 只查真会取模型的地方(续)、报告的裁判取自全图默认、提示指到能改的地方
+# --------------------------------------------------------------------------
+
+
+async def test_a_switched_off_verify_node_or_a_validate_without_retries_is_not_checked(client):
+ """核查节点自己的开关关着时执行器直接跳过、不取模型;结构校验不许重试(max_retries 为 0)时一次都不修。它们点名的
+ 接入写错了不提、不挡发布。开关打开、允许重试就照查。"""
+ graph = chain(
+ node("start", "input"),
+ node("write", "report", "写周报", instructions="写周报", provider=LIVE),
+ node("dual", "dual_check", "复核销售额", enabled=False, provider=GONE),
+ node("chal", "challenge", "审稿", report="write", enabled=False, provider=OFF),
+ node("check", "validate", "校验", max_retries=0, provider=GONE, schema={"type": "object"}),
+ node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]),
+ )
+ wf = await _workflow(graph)
+ r = await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "published", "graph": graph})
+ assert provider_issues(r.json()) == []
+ r = await client.post("/api/workflows/validate", json={"graph": graph})
+ assert provider_issues(r.json()) == []
+ graph["nodes"][2]["data"]["config"]["enabled"] = True
+ del graph["nodes"][3]["data"]["config"]["enabled"] # 没写开关:跟随设置,照查
+ graph["nodes"][4]["data"]["config"]["max_retries"] = 1
+ r = await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "published", "graph": graph})
+ assert {i["node_id"] for i in provider_issues(r.json())} == {"dual", "chal", "check"}
+
+
+async def test_a_report_judged_with_the_graph_default_judge_checks_that_provider_once(client):
+ """报告没写自己的裁判配置时执行用全图默认里的那一份(report_judge):它点名的接入记在全图默认上,几个报告共用
+ 时只记一条;写了自己的裁判配置的报告不受它影响;不交给模型裁判的报告用不上它。"""
+ graph = chain(
+ node("start", "input"),
+ node("a", "report", "写周报", instructions="写周报", provider=LIVE), # claims 取全图默认的 judge
+ node("b", "report", "写月报", instructions="写月报", provider=LIVE),
+ node("c", "report", "写年报", instructions="写年报", provider=LIVE, judge={"provider": LIVE}),
+ node("d", "report", "写日报", instructions="写日报", provider=LIVE, claims="off"),
+ node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.a.text }}"}]),
+ defaults={"claims": "judge", "judge": {"provider": GONE}},
+ )
+ r = await client.post("/api/workflows/validate", json={"graph": graph})
+ [issue] = provider_issues(r.json())
+ assert (issue["node_id"], issue["field"], issue["code"]) == (None, "defaults.judge.provider", "provider.missing")
+ assert issue["message"].startswith(f"工作流默认设置中的结论句裁判指定的模型接入「{GONE}」不存在。"), issue["message"]
+ wf = await _workflow(graph)
+ r = await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "published", "graph": graph})
+ check = r.json()
+ assert check["ok"] is False
+ assert [i["fix"] for i in provider_issues(check)] == ["provider.missing:graph:defaults.judge.provider"]
+
+
+async def test_the_hint_points_to_where_the_provider_can_actually_be_changed(client):
+ """检查器里有接入下拉框的(双盲复算、反方审稿、报告自己的结论句裁判)说「在节点中换一个接入」;节点自己的接入、
+ 协作成员、全图默认在界面上没有下拉框,说「在发布前检查中改用」——那里有这一处的修复。"""
+ graph = chain(
+ node("start", "input"),
+ node("ask", "llm", "提问", provider=GONE, prompt="总结"),
+ node("write", "report", "写周报", instructions="写周报", provider=LIVE, claims="judge", judge={"provider": OFF}),
+ node("dual", "dual_check", "复核销售额", provider=GONE),
+ node("team", "supervisor", "协作", provider=LIVE, agents=[{"name": "分析师", "provider": OFF, "system": "分析"}]),
+ node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]),
+ )
+ r = await client.post("/api/workflows/validate", json={"graph": graph})
+ got = {(i["node_id"], i["field"]): i["message"] for i in provider_issues(r.json())}
+ in_node, in_check = "或在节点中换一个接入", "或在「发布前检查」中改用其他接入"
+ assert got[("ask", "provider")].endswith(f"。请在「设置 → 模型接入」中核对名称,{in_check}"), got
+ assert got[("dual", "provider")].endswith(f"。请在「设置 → 模型接入」中核对名称,{in_node}"), got
+ assert got[("write", "judge.provider")].endswith(f"。请在「设置 → 模型接入」中启用它,{in_node}"), got
+ assert got[("team", "agents[0].provider")].endswith(f"。请在「设置 → 模型接入」中启用它,{in_check}"), got
+
+
+# --------------------------------------------------------------------------
+# 发布前检查的修复:改用一个启用的接入,或不指定接入
+# --------------------------------------------------------------------------
+
+
+async def _fix(client, wf: str, graph: dict, fix_id: str, value) -> dict:
+ r = await client.post(f"/api/workflows/{wf}/autofix", json={"level": "published", "graph": graph,
+ "apply": [fix_id], "choices": {fix_id: value}})
+ assert r.status_code == 200, r.text
+ return r.json()
+
+
+def config_of(graph: dict, node_id: str) -> dict:
+ return next(n for n in graph["nodes"] if n["id"] == node_id)["data"]["config"]
+
+
+async def test_each_broken_provider_gets_a_choice_to_switch_or_stop_naming_one(client):
+ """多数节点的检查器里没有接入下拉框:发布前检查里的这条修复是界面上唯一能改它的地方。候选只列启用的接入,不替人选;
+ 选的接入不提供这一处写着的模型时,模型一并去掉(拿 A 家的模型名去调 B 家必然失败),候选的说明里写清。"""
+ graph = writer(GONE, model="核对旧模型")
+ wf = await _workflow(graph)
+ r = await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "published", "graph": graph})
+ check = r.json()
+ [issue] = provider_issues(check)
+ [fix] = [f for f in check["fixes"] if f["id"] == issue["fix"]]
+ assert (fix["kind"], fix["node_id"], fix["field"]) == ("choice", "write", "provider")
+ assert fix["label"] == "选择「写周报」(模型调用)改用的模型接入"
+ assert "default" not in fix and not any(k.startswith("_") for k in fix)
+ values = [o["value"] for o in fix["options"]]
+ assert values[0] == "" and fix["options"][0]["label"] == "不指定接入"
+ assert LIVE in values and OFF not in values and GONE not in values
+ live = next(o for o in fix["options"] if o["value"] == LIVE)
+ assert "不提供模型「核对旧模型」,将改用它的默认模型「核对模型」" in live["hint"] and "演示用" in live["hint"]
+
+ out = await _fix(client, wf, graph, fix["id"], LIVE)
+ assert out["applied"] == [fix["id"]] and out["rejected"] == [] and out["ok"] is True, out["rejected"]
+ assert config_of(out["graph"], "write")["provider"] == LIVE and "model" not in config_of(out["graph"], "write")
+ assert [(c["node_id"], c["field"], c["before"], c["after"]) for c in out["changes"]] == [
+ ("write", "provider", GONE, LIVE), ("write", "model", "核对旧模型", None)]
+ assert provider_issues({"issues": out["remaining"]}) == []
+
+ # 不指定接入:哪一级都没写接入,按模型名找;「核对旧模型」没有哪个接入提供,执行时会落到第一个可用的接入去调它,
+ # 所以模型一并去掉,说明里写清改用哪里(详见 test_provider_ref_landing)
+ clear = fix["options"][0]
+ assert clear["hint"].startswith("去掉这一处指定的接入和模型「核对旧模型」,执行时改用第一个可用的模型接入「"), clear
+ out = await _fix(client, wf, graph, fix["id"], "")
+ assert out["applied"] == [fix["id"]] and out["ok"] is True, out["rejected"]
+ config = config_of(out["graph"], "write")
+ assert "provider" not in config and "model" not in config
+ assert [(c["field"], c["before"], c["after"]) for c in out["changes"]] == [("provider", GONE, None),
+ ("model", "核对旧模型", None)]
+
+
+async def test_a_team_node_and_its_member_are_fixed_one_place_at_a_time(client):
+ """同一个协作节点上自己的接入和成员的接入各有一条修复:只修成员那一处时,节点自己那一处还在不算没修好。"""
+ graph = chain(
+ node("start", "input"),
+ node("team", "supervisor", "协作", provider=GONE, goal="分析本周销售",
+ agents=[{"name": "分析师", "provider": GONE, "system": "分析"}]),
+ node("out", "output", fields=[{"name": "结论", "value": "{{ last_message }}"}]),
+ )
+ wf = await _workflow(graph)
+ r = await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "published", "graph": graph})
+ issues = provider_issues(r.json())
+ member = "provider.missing:team:agents[0].provider"
+ assert [(i["field"], i["fix"]) for i in issues] == [("provider", "provider.missing:team:provider"),
+ ("agents[0].provider", member)]
+ assert {f["id"] for f in r.json()["fixes"]} >= {"provider.missing:team:provider", member}
+ out = await _fix(client, wf, graph, member, LIVE)
+ assert out["applied"] == [member], out["rejected"]
+ config = config_of(out["graph"], "team")
+ assert config["agents"][0]["provider"] == LIVE and config["provider"] == GONE
+ assert [i["field"] for i in provider_issues({"issues": out["remaining"]})] == ["provider"]
+ assert out["ok"] is False
+
+
+async def test_the_graph_default_provider_has_its_own_fix(client):
+ graph = chain(node("start", "input"), node("ask", "llm", "提问", prompt="总结"),
+ node("out", "output", fields=[{"name": "结论", "value": "{{ last_message }}"}]),
+ defaults={"provider": OFF, "model": "核对停用模型"})
+ wf = await _workflow(graph)
+ r = await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "published", "graph": graph})
+ [issue] = provider_issues(r.json())
+ assert (issue["node_id"], issue["code"], issue["fix"]) == (
+ None, "provider.disabled", "provider.disabled:graph:defaults.provider")
+ out = await _fix(client, wf, graph, issue["fix"], LIVE)
+ assert out["applied"] == [issue["fix"]] and out["ok"] is True, out["rejected"]
+ assert out["graph"]["defaults"] == {"provider": LIVE}
+ assert [(c["node_id"], c["field"], c["before"], c["after"]) for c in out["changes"]] == [
+ (None, "defaults.provider", OFF, LIVE), (None, "defaults.model", "核对停用模型", None)]
+
+
+async def test_stop_naming_a_provider_is_not_offered_when_the_graph_default_is_broken_too(client):
+ """节点不再指定接入就改取全图默认:全图默认的接入也坏了,修完照样失败,「不指定接入」这一项不给;直接把这个值发给
+ 接口也不采用。换成启用的接入照常给。"""
+ graph = chain(node("start", "input"), node("ask", "llm", "提问", provider=GONE, prompt="总结"),
+ node("out", "output", fields=[{"name": "结论", "value": "{{ last_message }}"}]),
+ defaults={"provider": OFF})
+ wf = await _workflow(graph)
+ r = await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "published", "graph": graph})
+ [issue] = provider_issues(r.json()) # 唯一的节点写了自己的接入,全图默认的没人用,不提
+ [fix] = [f for f in r.json()["fixes"] if f["id"] == issue["fix"]]
+ values = [o["value"] for o in fix["options"]]
+ assert "" not in values and LIVE in values, values
+ out = await _fix(client, wf, graph, issue["fix"], "")
+ assert out["applied"] == [] and [x["fix_id"] for x in out["rejected"]] == [issue["fix"]]
+ assert config_of(out["graph"], "ask")["provider"] == GONE and out["changes"] == []
diff --git a/backend/tests/test_provider_ref_landing.py b/backend/tests/test_provider_ref_landing.py
new file mode 100644
index 00000000..494e3a26
--- /dev/null
+++ b/backend/tests/test_provider_ref_landing.py
@@ -0,0 +1,295 @@
+"""发布前修复点名的模型接入:修完以后执行时落到哪个接入,按执行器的取法算,落不稳的候选不给。
+
+上一轮的「不指定接入」只去掉接入、留下同一处写着的模型,复核又只查点名的接入,于是检查、发布都通过,正式运行在那一处
+照样失败:
+- 节点点名停用的丙、模型写丙家的模型:去掉接入后按模型名找,找到的还是停用的丙;
+- 协作节点点名启用的甲、成员点名停用的丙并写丙家的模型:成员去掉接入后跟随节点,拿甲去调丙家的模型;
+- 节点点名丙并写丙家的模型、全图默认点名乙:同样拿乙去调丙家的模型;
+- 双盲复算、反方审稿、结论句裁判同理:按模型名落到停用的接入,记缺口或未裁判。
+
+现在:
+- 门禁把「没有一级写接入、只写了模型,提供它的接入已停用」也记成 provider.disabled,落在写模型的那一处,原因和运行时
+ resolve_provider 报的同一句:只写模型的节点(多数节点的检查器里只能选模型)发布前查得出来,复核也拒得掉这类修复;
+- 修复的每个候选先在副本上改,按执行时的取法算出波及的每一处落到哪里(成员先看自己、再看节点、再看全图默认;节点看
+ 全图默认;都没写按模型名找;核查、裁判整组交给设置)。落到的接入不提供这一处的模型、按模型名落到停用的接入或认不出
+ 的模型,就把同一级写着的模型一并去掉再算;还落不稳的候选不给。候选说明写清执行时改用哪里、模型去没去掉;
+- 只写了模型的那一处也有修复:改用一个启用的接入(模型换成它的),或不指定模型。
+
+纯函数:接入表是虚构的几行,不读库。
+"""
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+
+from app.engine import autofix
+from app.engine.autofix import apply_fixes, check
+from app.engine.governance import publish_issues
+from app.engine.schema import GraphSpec
+
+
+def provider(name: str, *models: str, enabled: bool = True, kind: str = "openai_compatible") -> SimpleNamespace:
+ return SimpleNamespace(id=f"pv-{name}", name=name, kind=kind, enabled=enabled,
+ models=[{"id": m} for m in models], default_model=models[0] if models else None)
+
+
+#: 甲排在最前:没写接入也没写模型时落到它(第一个可用的接入)
+JIA = provider("甲接入", "甲一号", "甲二号")
+YI = provider("乙接入", "乙一号")
+BING = provider("丙接入", "丙一号", enabled=False)
+TABLE = [JIA, YI, BING]
+STOPPED = "所属的模型接入「丙接入」已停用"
+
+
+def node(nid, ntype, label=None, **config):
+ return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}}
+
+
+def flow(*middle, defaults=None) -> dict:
+ nodes = [node("start", "input"), *middle,
+ node("out", "output", fields=[{"name": "结论", "value": "{{ last_message }}"}])]
+ graph = {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
+ if defaults:
+ graph["defaults"] = defaults
+ return graph
+
+
+def provider_issues(issues) -> list[dict]:
+ return [i for i in map(autofix._as_dict, issues) if str(i.get("code") or "").startswith("provider.")]
+
+
+def gate(graph: dict, table=TABLE) -> list[dict]:
+ return provider_issues(publish_issues(GraphSpec.model_validate(graph), level="published", providers=table))
+
+
+def fix_of(graph: dict, fid: str, table=TABLE) -> dict | None:
+ found = check(GraphSpec.model_validate(graph), level="published", providers=table)
+ return next((f for f in found["fixes"] if f["id"] == fid), None)
+
+
+def hints(fix: dict) -> dict:
+ return {o["value"]: o.get("hint") for o in fix["options"]}
+
+
+def apply(graph: dict, choices: dict, table=TABLE) -> dict:
+ return apply_fixes(graph, list(choices), choices, level="published", providers=table)
+
+
+def config_of(graph: dict, nid: str) -> dict:
+ return next(n for n in graph["nodes"] if n["id"] == nid)["data"]["config"]
+
+
+def changes(out: dict) -> list[tuple]:
+ return [(c["node_id"], c["field"], c["before"], c["after"]) for c in out["changes"]]
+
+
+# --------------------------------------------------------------------------
+# 门禁:只写了模型、提供它的接入已停用
+# --------------------------------------------------------------------------
+
+
+def test_a_node_naming_only_a_model_of_a_disabled_provider_is_stopped_before_publishing():
+ """检查器里多数节点只能选模型:选的是停用接入的模型,运行时 resolve_provider 报「所属的模型接入已停用」,发布前就该看到。"""
+ [issue] = gate(flow(node("ask", "llm", "提问", model="丙一号", prompt="总结")))
+ assert (issue["level"], issue["code"], issue["node_id"], issue["field"]) == (
+ "error", "provider.disabled", "ask", "model")
+ assert issue["message"] == ("「提问」(模型调用)指定的模型「丙一号」所属的模型接入「丙接入」已停用。"
+ "请在「设置 → 模型接入」中启用它,或在节点中换一个模型")
+ # 启用的接入提供它;模型名认不出来(手填的新模型,运行时退回第一个可用的接入);同一处点名了启用的接入(以点名的为准,
+ # 不按模型名找):都不提
+ for config in ({"model": "甲二号"}, {"model": "自填的新模型"}, {"model": "丙一号", "provider": "甲接入"}):
+ assert gate(flow(node("ask", "llm", "提问", prompt="总结", **config))) == [], config
+
+
+def test_the_problem_is_recorded_once_where_the_model_is_written():
+ """全图默认里的模型几个节点共用时只记一条;协作成员、全图默认里的模型在检查器里改不了,提示指到发布前检查。
+ 节点点名了启用的接入时,跟随它的成员、取全图默认模型的节点以点名的为准,不提。"""
+ graph = flow(node("a", "llm", "提问", prompt="总结"), node("b", "agent", "查数", prompt="查数"),
+ node("team", "supervisor", "协作", provider="甲接入", goal="分析",
+ agents=[{"name": "分析师", "model": "丙一号", "system": "分析"}]),
+ node("team2", "supervisor", "协作二", goal="分析",
+ agents=[{"name": "研究员", "model": "丙一号", "system": "研究"}]),
+ defaults={"model": "丙一号"})
+ got = [(i["node_id"], i["field"], i["message"]) for i in gate(graph)]
+ tail = "请在「设置 → 模型接入」中启用它,或在「发布前检查」中改用其他接入"
+ assert got == [
+ ("team2", "agents[0].model", f"「协作二」(多 Agent 协作)的成员「研究员」指定的模型「丙一号」{STOPPED}。{tail}"),
+ (None, "defaults.model", f"工作流默认设置指定的模型「丙一号」{STOPPED}。{tail}"),
+ ]
+
+
+def test_verify_nodes_and_the_judge_naming_only_a_model_of_a_disabled_provider_are_checked_too():
+ """双盲复算、反方审稿、结论句裁判只写了模型时按模型名找接入(整组以节点为准,不交给设置)。"""
+ graph = flow(node("write", "report", "写周报", instructions="写周报", provider="甲接入", claims="judge",
+ judge={"model": "丙一号"}),
+ node("dual", "dual_check", "复核销售额", model="丙一号"),
+ node("chal", "challenge", "审稿", report="write", model="丙一号"))
+ got = {(i["node_id"], i["field"]): i["message"] for i in gate(graph)}
+ assert set(got) == {("write", "judge.model"), ("dual", "model"), ("chal", "model")}
+ assert got[("dual", "model")] == (f"「复核销售额」(双盲复算)的盲算模型指定的模型「丙一号」{STOPPED}。"
+ "请在「设置 → 模型接入」中启用它,或在节点中换一个模型")
+ assert got[("write", "judge.model")].startswith(f"「写周报」(报告撰写)的结论句裁判指定的模型「丙一号」{STOPPED}")
+
+
+# --------------------------------------------------------------------------
+# 「不指定接入」:按执行时的取法算出去掉以后落到哪里
+# --------------------------------------------------------------------------
+
+
+def test_stop_naming_a_disabled_provider_drops_the_model_only_it_provides_too():
+ """复核发现的原样:节点点名停用的丙、模型写丙家的。只去掉接入的话,按模型名找到的还是停用的丙。"""
+ graph = flow(node("ask", "llm", "提问", provider="丙接入", model="丙一号", prompt="总结"))
+ fid = "provider.disabled:ask:provider"
+ assert hints(fix_of(graph, fid))[""] == "去掉这一处指定的接入和模型「丙一号」,执行时改用第一个可用的模型接入「甲接入」"
+ out = apply(graph, {fid: ""})
+ assert out["applied"] == [fid] and out["ok"] is True, out["rejected"]
+ assert config_of(out["graph"], "ask") == {"prompt": "总结"}
+ assert changes(out) == [("ask", "provider", "丙接入", None), ("ask", "model", "丙一号", None)]
+ assert gate(out["graph"]) == []
+
+ # 写的模型有启用的接入提供:只去掉接入,按模型名落到提供它的那一家,模型留着
+ graph = flow(node("ask", "llm", "提问", provider="丙接入", model="乙一号", prompt="总结"))
+ assert hints(fix_of(graph, fid))[""] == "去掉这一处指定的接入,执行时改用提供模型「乙一号」的接入「乙接入」"
+ out = apply(graph, {fid: ""})
+ assert out["ok"] is True and config_of(out["graph"], "ask") == {"prompt": "总结", "model": "乙一号"}, out["rejected"]
+
+
+def test_a_member_that_stops_naming_a_provider_follows_its_team_node():
+ """成员去掉接入后跟随节点点名的甲:甲不提供成员写的模型,模型一并去掉(不然拿甲去调丙家的模型);甲提供的就留着。"""
+ graph = flow(node("team", "supervisor", "协作", provider="甲接入", goal="分析",
+ agents=[{"name": "分析师", "provider": "丙接入", "model": "丙一号", "system": "分析"},
+ {"name": "复核员", "provider": "丙接入", "model": "甲二号", "system": "复核"}]))
+ first, second = "provider.disabled:team:agents[0].provider", "provider.disabled:team:agents[1].provider"
+ assert hints(fix_of(graph, first))[""] == ("去掉这一处指定的接入和模型「丙一号」,"
+ "执行时改用「协作」(多 Agent 协作)指定的接入「甲接入」")
+ assert hints(fix_of(graph, second))[""] == "去掉这一处指定的接入,执行时改用「协作」(多 Agent 协作)指定的接入「甲接入」"
+ out = apply(graph, {first: "", second: ""})
+ assert out["applied"] == [first, second] and out["ok"] is True, out["rejected"]
+ assert config_of(out["graph"], "team")["agents"] == [{"name": "分析师", "system": "分析"},
+ {"name": "复核员", "model": "甲二号", "system": "复核"}]
+
+
+def test_stop_naming_a_provider_under_a_graph_default_drops_a_model_the_default_does_not_provide():
+ graph = flow(node("ask", "llm", "提问", provider="丙接入", model="丙一号", prompt="总结"), defaults={"provider": "乙接入"})
+ fid = "provider.disabled:ask:provider"
+ assert hints(fix_of(graph, fid))[""] == ("去掉这一处指定的接入和模型「丙一号」,"
+ "执行时改用工作流默认设置指定的接入「乙接入」")
+ out = apply(graph, {fid: ""})
+ assert out["ok"] is True and config_of(out["graph"], "ask") == {"prompt": "总结"}, out["rejected"]
+
+
+@pytest.mark.parametrize("ntype,extra", [("dual_check", {}), ("challenge", {"report": "write"})])
+def test_a_verify_node_that_stops_naming_a_provider_goes_to_the_settings_not_a_disabled_provider(ntype, extra):
+ """核查节点只写了模型时按模型名找接入:丙家的模型留着就又落到停用的丙。连模型一起去掉,整组交给设置里的核查模型。"""
+ graph = flow(node("write", "report", "写周报", instructions="写周报", provider="甲接入"),
+ node("v", ntype, "核查", provider="丙接入", model="丙一号", **extra))
+ fid = "provider.disabled:v:provider"
+ assert hints(fix_of(graph, fid))[""] == "去掉这一处指定的接入和模型「丙一号」,执行时改用设置中的核查模型"
+ out = apply(graph, {fid: ""})
+ assert out["applied"] == [fid] and gate(out["graph"]) == [], out["rejected"]
+ assert config_of(out["graph"], "v") == extra
+
+
+def test_a_judge_that_stops_naming_a_provider_goes_to_the_settings_not_a_disabled_provider():
+ graph = flow(node("write", "report", "写周报", instructions="写周报", provider="甲接入", claims="judge",
+ judge={"provider": "丙接入", "model": "丙一号", "max_claims": 5}))
+ fid = "provider.disabled:write:judge.provider"
+ assert hints(fix_of(graph, fid))[""] == "去掉这一处指定的接入和模型「丙一号」,执行时改用设置中的裁判模型"
+ out = apply(graph, {fid: ""})
+ assert out["applied"] == [fid] and gate(out["graph"]) == [], out["rejected"]
+ assert config_of(out["graph"], "write")["judge"] == {"max_claims": 5}
+
+
+# --------------------------------------------------------------------------
+# 换接入:跟着这一处的别处也要落得稳
+# --------------------------------------------------------------------------
+
+
+def test_a_switch_that_would_send_another_place_a_model_it_does_not_provide_is_not_offered():
+ """协作节点换接入,没写接入的成员跟着换:成员自己写的乙家模型甲不提供,「改用甲」不给;「不指定接入」各处分别
+ 落到哪里写清。"""
+ graph = flow(node("team", "supervisor", "协作", provider="丙接入", model="丙一号", goal="分析",
+ agents=[{"name": "分析师", "model": "乙一号", "system": "分析"}]))
+ got = hints(fix_of(graph, "provider.disabled:team:provider"))
+ assert set(got) == {"", "乙接入"}
+ assert got["乙接入"] == "不提供模型「丙一号」,将改用它的默认模型「乙一号」"
+ assert got[""] == ("去掉这一处指定的接入和模型「丙一号」,执行时「协作」(多 Agent 协作)改用第一个可用的模型接入「甲接入」;"
+ "「协作」(多 Agent 协作)的成员「分析师」改用提供模型「乙一号」的接入「乙接入」")
+
+ # 全图默认同理:自己写了乙家模型的节点跟着换到甲,甲不提供
+ graph = flow(node("a", "llm", "提问", prompt="总结"), node("b", "llm", "复述", model="乙一号", prompt="复述"),
+ defaults={"provider": "丙接入"})
+ fid = "provider.disabled:graph:defaults.provider"
+ got = hints(fix_of(graph, fid))
+ assert set(got) == {"", "乙接入"} and got["乙接入"] is None
+ assert got[""] == ("去掉这一处指定的接入,执行时「提问」(模型调用)改用第一个可用的模型接入「甲接入」;"
+ "「复述」(模型调用)改用提供模型「乙一号」的接入「乙接入」")
+ out = apply(graph, {fid: "乙接入"})
+ assert out["ok"] is True and out["graph"]["defaults"] == {"provider": "乙接入"}, out["rejected"]
+
+
+def test_switching_names_the_model_actually_used_when_a_higher_level_writes_one():
+ """成员换到甲、自己的丙家模型去掉以后,模型跟随节点写的甲二号,不是甲的默认模型:说明照实写。"""
+ graph = flow(node("team", "supervisor", "协作", model="甲二号", goal="分析",
+ agents=[{"name": "分析师", "provider": "丙接入", "model": "丙一号", "system": "分析"}]))
+ got = hints(fix_of(graph, "provider.disabled:team:agents[0].provider"))
+ assert got["甲接入"] == "不提供模型「丙一号」,将改用模型「甲二号」"
+
+
+def test_no_choice_lands_on_the_built_in_demo_model():
+ """一个启用的接入都没有:去掉接入就会悄悄换成内置演示模型,这样的修复不给(问题照样拦着发布)。"""
+ graph = flow(node("ask", "llm", "提问", provider="丙接入", model="丙一号", prompt="总结"))
+ found = check(GraphSpec.model_validate(graph), level="published", providers=[BING])
+ [issue] = provider_issues(found["issues"])
+ assert issue["fix"] is None and found["ok"] is False
+
+
+# --------------------------------------------------------------------------
+# 只写了模型的那一处:改用一个启用的接入,或不指定模型
+# --------------------------------------------------------------------------
+
+
+def test_a_place_naming_only_a_model_gets_a_choice_of_provider_or_no_model():
+ graph = flow(node("ask", "llm", "提问", model="丙一号", prompt="总结"))
+ fid = "provider.disabled:ask:model"
+ fix = fix_of(graph, fid)
+ assert (fix["kind"], fix["field"], fix["label"]) == ("choice", "model", "选择「提问」(模型调用)改用的模型接入")
+ assert [o["label"] for o in fix["options"]] == ["不指定模型", "甲接入", "乙接入"]
+ got = hints(fix)
+ assert got[""] == "去掉这一处指定的模型,执行时改用第一个可用的模型接入「甲接入」"
+ assert got["乙接入"] == "不提供模型「丙一号」,将改用它的默认模型「乙一号」"
+ out = apply(graph, {fid: "乙接入"})
+ assert out["ok"] is True and config_of(out["graph"], "ask") == {"prompt": "总结", "provider": "乙接入"}, out["rejected"]
+ assert changes(out) == [("ask", "provider", None, "乙接入"), ("ask", "model", "丙一号", None)]
+ out = apply(graph, {fid: ""})
+ assert out["ok"] is True and config_of(out["graph"], "ask") == {"prompt": "总结"}, out["rejected"]
+
+
+def test_a_graph_default_model_is_fixed_once_for_every_node_that_uses_it():
+ graph = flow(node("a", "llm", "提问", prompt="总结"), node("b", "agent", "查数", prompt="查数"),
+ defaults={"model": "丙一号"})
+ fid = "provider.disabled:graph:defaults.model"
+ out = apply(graph, {fid: "甲接入"})
+ assert out["ok"] is True and out["graph"]["defaults"] == {"provider": "甲接入"}, out["rejected"]
+ assert changes(out) == [(None, "defaults.provider", None, "甲接入"), (None, "defaults.model", "丙一号", None)]
+
+
+# --------------------------------------------------------------------------
+# 复核照样把关
+# --------------------------------------------------------------------------
+
+
+def test_the_review_refuses_a_fix_that_leaves_a_place_on_a_disabled_provider(monkeypatch):
+ """修复规则自己只去掉接入(上一轮的做法)时,门禁查出那一处按模型名落到停用的丙,整条丢弃、说清原因。"""
+ def only_the_provider(graph, fix, value):
+ config = autofix._node(graph, fix["node_id"])["data"]["config"]
+ return [(fix["node_id"], "provider", config.pop("provider"), None)]
+
+ monkeypatch.setitem(autofix._EDITORS, "provider.disabled", only_the_provider)
+ graph = flow(node("ask", "llm", "提问", provider="丙接入", model="丙一号", prompt="总结"))
+ out = apply(graph, {"provider.disabled:ask:provider": ""})
+ assert out["applied"] == [] and out["changes"] == [] and out["graph"] == graph
+ [rejected] = out["rejected"]
+ assert rejected["reason"] == (f"修改后出现了新的问题:「提问」(模型调用)指定的模型「丙一号」{STOPPED}。"
+ "请在「设置 → 模型接入」中启用它,或在节点中换一个模型")
diff --git a/backend/tests/test_provider_ref_runtime.py b/backend/tests/test_provider_ref_runtime.py
new file mode 100644
index 00000000..ccb51ee9
--- /dev/null
+++ b/backend/tests/test_provider_ref_runtime.py
@@ -0,0 +1,608 @@
+"""节点上、设置里点名的模型接入不存在或已停用时,每条用到模型的路径都照实报给人,不落成演示模型。
+
+以前点名的接入不存在时 get_chat_model 悄悄换成演示模型(mock-fast):报告由它写成假文本,双盲复算、反方审稿、审批
+质询由它来「核查」,正式运行照样出具。现在(providers/factory.named_provider):
+
+- 模型调用、Agent、报告撰写、多 Agent 协作、让模型分类的条件分支、让模型修复的结构校验:节点失败,报错带原因和去哪里改,
+ 一次模型调用都不发;让模型重排的知识检索:不重排,降级日志写明原因;
+- 双盲复算:该指标无法复算,原因「核查模型不可用(…)」,记缺口;反方审稿:这几句未能反查,原因同上;
+- 审批质询:回答 ok 为假,error 说明原因;结论句裁判(运行中、按需):按「裁判模型不可用」照实记未裁判和缺口;
+- 助手:报错给用户,查当前用哪个模型的接口照常返回、写明原因;门控模型:转人工审批、写明原因;
+- 定时任务发起的正式运行:运行失败,通知照常推送。
+
+每条路径都看一遍:产出、事件、llm.end 里不出现 mock-fast。没点名接入的节点(写作者)用一个显式建的演示接入,模型名是
+「写作演示模型」,所以事件里一出现 mock-fast 就是回退到了内置的演示模型。接入名、数据源、数字都是虚构的。
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+import sqlite3
+import uuid
+from datetime import datetime, timedelta, timezone
+
+import httpx
+import pytest
+from httpx import ASGITransport, AsyncClient
+from langchain_core.messages import AIMessage
+from sqlalchemy import delete, select
+
+from app.core import artifact_store
+from app.core.crypto import encrypt
+from app.data import introspect
+from app.db.base import SessionLocal
+from app.db.models import (
+ Approval, DataSource, Provider, Run, RunEvent, Schedule, ScheduleFire, Setting, Workflow, WorkflowVersion,
+)
+from app.engine import spend
+from app.engine.evidence import iter_units
+from app.engine.judge import SPEND_KEY as JUDGE_SPEND_KEY
+from app.engine.runner import run_manager
+from app.main import app
+from app.providers.mock_model import MockChatModel
+from tests.fixtures.dual_check_shop import DEFINITION, RIGHT, add_source, build
+from tests.fixtures.sources import drop_source
+
+#: 没点名接入的节点用的演示接入:正常的调用在事件里记「写作演示模型」
+WRITER_PROVIDER, WRITER_MODEL = "测试演示接入", "写作演示模型"
+#: 写错一个字的接入名;一个停用了的演示接入(停用前照样能调用:以前点名它会照常返回、照常调用)
+MISSING, DISABLED = "测试演示接人", "停用演示接入"
+REASONS = {MISSING: f"模型接入「{MISSING}」不存在", DISABLED: f"模型接入「{DISABLED}」已停用"}
+HINTS = {MISSING: "请在「设置 → 模型接入」中核对名称,或换一个接入",
+ DISABLED: "请在「设置 → 模型接入」中启用它,或换一个接入"}
+SETTING_KEYS = ["verify", "judge", "copilot", "run", spend.SPEND_KEY, JUDGE_SPEND_KEY]
+UTC = timezone.utc
+
+
+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_([WRITER_PROVIDER, DISABLED])))
+ await session.commit()
+
+
+@pytest.fixture(autouse=True)
+async def engine_up():
+ await _clean()
+ async with SessionLocal() as session:
+ session.add_all([
+ Provider(name=WRITER_PROVIDER, kind="mock", models=[{"id": WRITER_MODEL}], default_model=WRITER_MODEL,
+ enabled=True, extra={}),
+ Provider(name=DISABLED, kind="mock", models=[{"id": "停用演示模型"}], default_model="停用演示模型",
+ enabled=False, extra={}),
+ ])
+ await session.commit()
+ await run_manager.setup()
+ yield
+ await run_manager.shutdown()
+ # 建过的接入、写过的设置不留给同一进程里后面的测试:留着的启用接入会改变别处「没点名」时的回退
+ await _clean()
+
+
+@pytest.fixture
+async def client():
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
+ yield c
+
+
+def script(monkeypatch, writer: str = "本周情况平稳。") -> list[str]:
+ """演示模型的剧本:写作者回 writer;按结构交结果的调用交空对象、绑了工具的直接收尾(核查、裁判、质询的模型照理
+ 一次都不该被调用到)。返回每次调用的模型名。"""
+ called: list[str] = []
+
+ def decide(model, messages):
+ called.append(model.model_name)
+ if model.response_format:
+ return AIMessage(content="{}")
+ if model.tools:
+ return AIMessage(content="查完了。")
+ return AIMessage(content=writer)
+
+ monkeypatch.setattr(MockChatModel, "_decide", decide)
+ return called
+
+
+async def put_setting(key: str, value: dict) -> None:
+ """直接写库:PUT /api/settings 现在会拦下不存在、已停用的接入,这里模拟的是写好之后接入才被删掉、停用。"""
+ async with SessionLocal() as session:
+ row = await session.get(Setting, key)
+ if row:
+ row.value = value
+ else:
+ session.add(Setting(key=key, value=value))
+ await session.commit()
+
+
+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 chain(*nodes):
+ return {"nodes": list(nodes), "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
+
+
+async def wait(run_id: str, statuses=("succeeded", "failed")) -> Run:
+ for _ in range(600):
+ async with SessionLocal() as session:
+ row = await session.get(Run, run_id)
+ if row.status in statuses and (row.status == "interrupted" or row.manifest_seq is not None):
+ await run_manager.wait_idle(run_id)
+ return row
+ await asyncio.sleep(0.03)
+ raise AssertionError(f"运行 {run_id} 停在 {row.status}:{row.error}")
+
+
+async def start(spec: dict, *, run_class: str = "exploratory", statuses=("succeeded", "failed")) -> Run:
+ run = await run_manager.start(graph=spec, input_payload={}, run_class=run_class)
+ return await wait(run.id, statuses)
+
+
+async def events(run_id: str) -> list[RunEvent]:
+ async with SessionLocal() as session:
+ return list((await session.execute(
+ select(RunEvent).where(RunEvent.run_id == run_id).order_by(RunEvent.seq))).scalars())
+
+
+def output_of(evs: list[RunEvent], node_id: str) -> dict:
+ finished = [e for e in evs if e.type == "node.finished" and e.node_id == node_id]
+ assert finished, f"节点 {node_id} 没有执行完"
+ return artifact_store.load(finished[-1].data["artifact"])
+
+
+def logs(evs: list[RunEvent], code: str) -> list[dict]:
+ return [e.data for e in evs if e.type == "log" and e.data.get("code") == code]
+
+
+def no_mock(evs: list[RunEvent], *more) -> None:
+ """事件和给出的产出里都不出现内置演示模型的名字。"""
+ blob = json.dumps([e.data for e in evs] + list(more), ensure_ascii=False, default=str)
+ assert "mock-fast" not in blob
+
+
+# --------------------------------------------------------------------------
+# 模型调用、Agent、报告撰写节点:节点失败,说清原因和去哪里改
+# --------------------------------------------------------------------------
+
+NODES = {
+ "llm": lambda ref: node("ask", "llm", "提问", provider=ref, prompt="总结本周情况"),
+ "agent": lambda ref: node("ask", "agent", "查数作答", provider=ref, prompt="回答问题"),
+ "report": lambda ref: node("ask", "report", "写周报", provider=ref, instructions="写周报"),
+ # 下面两种的执行器不自己接这个错,由编排层按「执行出错:原因」报出,原因和提示一样都在
+ "supervisor": lambda ref: node("ask", "supervisor", "协作", provider=ref, goal="分析本周销售",
+ agents=[{"name": "分析师", "system": "你是销售分析师"}]),
+ "branch": lambda ref: node("ask", "branch", "分流", mode="llm", provider=ref,
+ cases=[{"key": "follow", "label": "需要跟进"}]),
+ # 输入不是 JSON:第一次校验不通过,要让模型修复时才取模型
+ "validate": lambda ref: node("ask", "validate", "校验", provider=ref, source="门店甲本周销售平稳",
+ schema={"type": "object", "required": ["区域"]}),
+}
+
+
+@pytest.mark.parametrize("ref", [MISSING, DISABLED])
+@pytest.mark.parametrize("kind", list(NODES))
+async def test_a_node_naming_a_broken_provider_fails_and_says_why(monkeypatch, kind, ref):
+ called = script(monkeypatch)
+ out = node("out", "output", fields=[{"name": "结果", "value": "{{ last_message }}"}])
+ spec = chain(node("start", "input"), NODES[kind](ref), out)
+ if kind == "branch":
+ spec["edges"][1]["sourceHandle"] = "follow"
+ spec["edges"].append({"source": "ask", "target": "out", "sourceHandle": "default"})
+ run = await start(spec)
+ assert run.status == "failed"
+ assert REASONS[ref] in run.error and HINTS[ref] in run.error, run.error
+ evs = await events(run.id)
+ [failed] = [e for e in evs if e.type == "node.failed"]
+ assert failed.node_id == "ask" and REASONS[ref] in failed.data["error"] and HINTS[ref] in failed.data["error"]
+ assert not [e for e in evs if e.type in ("llm.start", "llm.end")] and called == []
+ no_mock(evs, run.output, run.error)
+
+
+@pytest.mark.parametrize("ref", [MISSING, DISABLED])
+async def test_a_retrieve_reranked_by_a_broken_provider_keeps_its_hits_and_says_why(monkeypatch, ref):
+ """让模型重排的知识检索:接入取不到就不重排、照初筛的顺序给命中,降级日志写明原因(不悄悄让演示模型去排)。"""
+ from app.db.models import Document
+ from app.memory import kb
+
+ called = script(monkeypatch)
+ collection = f"k1_{uuid.uuid4().hex[:8]}"
+ async with SessionLocal() as session:
+ await kb.ingest_document(session, collection=collection, title="门店巡检规范",
+ content="门店巡检每周一次,巡检结果当天上报区域经理。")
+ try:
+ spec = chain(node("start", "input"),
+ node("look", "retrieve", "检索", query="门店巡检多久一次", collection=collection, rerank="model",
+ provider=ref),
+ node("out", "output", fields=[{"name": "结论", "value": "见检索结果"}]))
+ run = await start(spec)
+ assert run.status == "succeeded", run.error
+ evs = await events(run.id)
+ assert output_of(evs, "look")["count"] >= 1
+ said = [d["message"] for d in logs(evs, "retrieve_degraded")]
+ assert any(m.startswith("重排不可用:") and REASONS[ref] in m and HINTS[ref] in m for m in said), said
+ assert called == [] and not [e for e in evs if e.type in ("llm.start", "llm.end")]
+ no_mock(evs, run.output)
+ finally:
+ async with SessionLocal() as session:
+ for doc in list((await session.execute(select(Document).where(Document.collection == collection))).scalars()):
+ await kb.delete_document(session, doc.id)
+
+
+# --------------------------------------------------------------------------
+# 双盲复算:无法复算,记缺口
+# --------------------------------------------------------------------------
+
+GMV = {"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "expression": "cell(nodes.fetch, 0, 'gmv')"}
+
+
+@pytest.fixture
+async def shop(tmp_path):
+ name, source_id = await add_source(build(tmp_path / "shop.db"))
+ yield name
+ await drop_source(source_id)
+
+
+def dual_graph(source: str, **dual) -> dict:
+ return chain(
+ node("start", "input"),
+ node("fetch", "tool", tool=f"db_query__{source}", args={"sql": RIGHT}),
+ node("card", "metrics", caliber="销售口径", caliber_version="v1", metrics=[GMV]),
+ node("dual", "dual_check", "复核销售额", definitions={"gmv": DEFINITION}, **dual),
+ node("out", "output", fields=[{"name": "结论", "value": "本周销售额见口径卡"}],
+ contract={"metrics_from": "card", "narrative": "本周销售额见口径卡"}),
+ )
+
+
+@pytest.mark.parametrize("where", ["settings", "node"])
+@pytest.mark.parametrize("ref", [MISSING, DISABLED])
+async def test_dual_check_with_a_broken_verify_provider_records_the_metric_as_unchecked(monkeypatch, shop, ref, where):
+ called = script(monkeypatch)
+ if where == "settings":
+ await put_setting("verify", {"provider": ref})
+ run = await start(dual_graph(shop, **({"provider": ref} if where == "node" else {})), run_class="formal")
+ assert run.status == "succeeded", run.error
+ evs = await events(run.id)
+ out = output_of(evs, "dual")
+ assert out["status"] == "unchecked"
+ [metric] = out["metrics"]
+ assert metric["status"] == "unchecked"
+ assert metric["reason"].startswith("核查模型不可用") and REASONS[ref] in metric["reason"], metric["reason"]
+ assert out["issuance_gaps"] == [f"指标「销售额」无法复算:{metric['reason']}"]
+ assert any(REASONS[ref] in gap for gap in run.output["_issuance"]["gaps"])
+ [said] = logs(evs, "dual_check_unchecked")
+ assert REASONS[ref] in said["metrics"][0]["reason"]
+ assert called == [] and not [e for e in evs if e.type == "llm.end"]
+ no_mock(evs, run.output, out)
+
+
+# --------------------------------------------------------------------------
+# 反方审稿:未能反查;审批质询:回答失败、说明原因
+# --------------------------------------------------------------------------
+
+SALES = ("CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, amount INTEGER, refunded INTEGER);"
+ "INSERT INTO sales VALUES (1, '华东', 1200, 0), (2, '华东', 300, 1), (3, '华南', 900, 0), (4, '华北', 400, 0);")
+QUERY = "SELECT region AS 区域, SUM(amount) AS 销售额 FROM sales GROUP BY region ORDER BY 销售额 DESC"
+#: 一句有依据的结论、一句预埋的错误结论(华南其实低于华东)
+TEXT = "华东销售额 [[v:Q1.r0.销售额]],居各区域之首。[[see:Q1]]华南销售额超过华东。[[see:Q1]]"
+
+
+@pytest.fixture
+async def sales(tmp_path):
+ path = tmp_path / f"{uuid.uuid4().hex[:8]}.db"
+ db = sqlite3.connect(path)
+ db.executescript(SALES)
+ db.commit()
+ db.close()
+ async with SessionLocal() as session:
+ row = DataSource(name=f"sales_{uuid.uuid4().hex[:8]}", kind="sqlite", database=str(path), readonly=True,
+ enabled=True, options={})
+ session.add(row)
+ await session.flush()
+ row.schema_cache = await introspect.introspect(row)
+ await session.commit()
+ name, source_id = row.name, row.id
+ yield name
+ await drop_source(source_id)
+
+
+def report_graph(source: str, *between) -> dict:
+ return chain(
+ node("start", "input"),
+ node("fetch", "tool", tool=f"db_query__{source}", args={"sql": QUERY}),
+ node("write", "report", "写周报", instructions="写周报"),
+ *between,
+ node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}],
+ contract={"report_from": "write"}),
+ )
+
+
+@pytest.mark.parametrize("where", ["settings", "node"])
+@pytest.mark.parametrize("ref", [MISSING, DISABLED])
+async def test_challenge_with_a_broken_verify_provider_reviews_no_claim_and_says_why(monkeypatch, sales, ref, where):
+ called = script(monkeypatch, writer=TEXT)
+ if where == "settings":
+ await put_setting("verify", {"provider": ref})
+ chal = node("chal", "challenge", "审稿", report="write", **({"provider": ref} if where == "node" else {}))
+ run = await start(report_graph(sales, chal), run_class="formal")
+ assert run.status == "succeeded", run.error
+ evs = await events(run.id)
+ out = output_of(evs, "chal")
+ assert out["counts"]["inconclusive"] == out["unreviewed"] == 2
+ assert out["counts"]["refuted"] == out["counts"]["stands"] == 0
+ [said] = logs(evs, "challenge_unreviewed")
+ assert said["reason"].startswith("核查模型不可用") and REASONS[ref] in said["reason"], said["reason"]
+ record = artifact_store.load(out["challenge_artifact"])
+ assert all(REASONS[ref] in c["rationale"] for c in record["claims"])
+ # 只有写作者调用了模型
+ assert called == [WRITER_MODEL]
+ no_mock(evs, run.output, out, record)
+
+
+@pytest.mark.parametrize("ref", [MISSING, DISABLED])
+async def test_an_inquiry_with_a_broken_verify_provider_answers_with_the_reason(monkeypatch, sales, client, ref):
+ called = script(monkeypatch, writer=TEXT)
+ await put_setting("verify", {"provider": ref})
+ paused = await start(report_graph(sales, node("gate", "human", mode="approve", title="周报可以出具吗")),
+ statuses=("interrupted",))
+ async with SessionLocal() as session:
+ [approval] = list((await session.execute(select(Approval).where(
+ Approval.run_id == paused.id, Approval.status == "pending"))).scalars())
+ r = await client.post(f"/api/runs/{paused.id}/inquiries",
+ json={"report": "write", "unit": None, "question": "华南真的超过华东吗?",
+ "approval_id": approval.id})
+ assert r.status_code == 200, r.text
+ inquiry = r.json()["inquiry"]
+ answer = inquiry["answer"]
+ assert answer["ok"] is False and answer["text"] == ""
+ assert answer["error"].startswith("核查模型不可用") and REASONS[ref] in answer["error"], answer["error"]
+ assert inquiry["model"] is None and inquiry["usage"]["calls"] == 0
+ evs = await events(paused.id)
+ [event] = [e for e in evs if e.type == "approval.inquiry"]
+ assert event.data["ok"] is False and event.data["error"] == answer["error"]
+ assert called == [WRITER_MODEL]
+ no_mock(evs, r.json())
+
+
+# --------------------------------------------------------------------------
+# 结论句裁判:照「裁判模型不可用」记未裁判和缺口
+# --------------------------------------------------------------------------
+
+METRICS = [{"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "expression": "vars.kpi.gmv"}]
+JUDGED = "本周销售额 [[m:gmv]]。[[see:m:gmv]]增长主要来自新客首单。"
+
+
+def weekly(**report) -> dict:
+ return chain(
+ node("start", "input"),
+ node("fetch", "transform", mode="expression", expression="{'gmv': 45678.5}", assign_to="kpi"),
+ node("caliber", "metrics", caliber="周报口径", caliber_version="v2", metrics=METRICS),
+ node("write", "report", instructions="写周报", **report),
+ node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]),
+ )
+
+
+@pytest.mark.parametrize("where", ["node", "settings"])
+@pytest.mark.parametrize("ref", [MISSING, DISABLED])
+async def test_a_broken_judge_provider_leaves_the_claims_unjudged_and_says_why(monkeypatch, where, ref):
+ called = script(monkeypatch, writer=JUDGED)
+ if where == "settings":
+ await put_setting("judge", {"provider": ref})
+ report = {"claims": "judge", **({"judge": {"provider": ref}} if where == "node" else {})}
+ run = await start(weekly(**report), run_class="formal")
+ assert run.status == "succeeded", run.error
+ evs = await events(run.id)
+ [checked] = [e for e in evs if e.type == "report.checked"]
+ summary = checked.data["judge"]
+ assert summary["complete"] is False and summary["counts"]["unjudged"] == 2
+ assert summary["gaps"] and all("裁判模型不可用" in g and REASONS[ref] in g for g in summary["gaps"]), summary["gaps"]
+ doc = artifact_store.load(checked.data["doc_artifact"])
+ verdicts = [u["verdict"] for _, u in iter_units(doc) if u.get("verdict")]
+ assert len(verdicts) == 2 and all(v["status"] == "unjudged" and v["reason"] == "error" for v in verdicts)
+ assert not [e for e in evs if e.type == "llm.end" and e.data.get("purpose") == "judge"]
+ assert called == [WRITER_MODEL]
+ no_mock(evs, run.output, doc)
+
+
+@pytest.mark.parametrize("ref", [MISSING, DISABLED])
+async def test_judging_on_demand_with_a_broken_judge_provider_says_why(monkeypatch, client, ref):
+ """探索运行里按需裁判几句(POST /evidence/judge):设置里的裁判接入取不到,照「裁判模型不可用」说清,句子仍是未裁判,
+ 不调用模型、不记判定。"""
+ called = script(monkeypatch, writer=JUDGED)
+ run = await start(weekly(claims="require_citation"))
+ assert run.status == "succeeded", run.error
+ evs = await events(run.id)
+ [checked] = [e for e in evs if e.type == "report.checked"]
+ doc = artifact_store.load(checked.data["doc_artifact"])
+ units = [u["id"] for _, u in iter_units(doc)]
+ await put_setting("judge", {"provider": ref})
+ r = await client.post(f"/api/runs/{run.id}/evidence/judge", json={"units": units})
+ assert r.status_code == 200, r.text
+ body = r.json()
+ assert body["judged"] == [] and body["calls"] == 0 and body["event"] is None
+ assert body["gaps"] and all("裁判模型不可用" in g and REASONS[ref] in g for g in body["gaps"]), body["gaps"]
+ assert body["message"].startswith("结论句裁判未完成:") and REASONS[ref] in body["message"]
+ assert all(v["status"] == "unjudged" for v in body["verdicts"].values()) and body["verdicts"]
+ assert called == [WRITER_MODEL]
+ no_mock(await events(run.id), body)
+
+
+# --------------------------------------------------------------------------
+# 助手、门控模型
+# --------------------------------------------------------------------------
+
+
+#: 助手报给人的那一句:照实说是哪个接入、在设置里能做什么,或者给助手换一个模型(不再说「尚未配置完成」)
+SAID = {MISSING: f"助手使用的{REASONS[MISSING]}。请在「设置 → 模型接入」中核对名称,或为助手换一个模型",
+ DISABLED: f"助手使用的{REASONS[DISABLED]}。请在「设置 → 模型接入」中启用它,或为助手换一个模型"}
+
+
+@pytest.mark.parametrize("ref", [MISSING, DISABLED])
+async def test_the_assistant_with_a_broken_provider_tells_the_user(monkeypatch, client, ref):
+ called = script(monkeypatch)
+ await put_setting("copilot", {"provider": ref, "model": ""})
+ r = await client.post("/api/copilot/generate", json={"instruction": "做一张门店周报"})
+ assert r.status_code == 400
+ assert r.json()["detail"] == SAID[ref]
+ assert called == []
+ # 查当前用哪个模型:照常返回,不 500,写明为什么用不了
+ got = await client.get("/api/copilot/model")
+ assert got.status_code == 200, got.text
+ body = got.json()
+ assert (body["configured"], body["provider"]) == (True, ref)
+ assert body["effective_provider"] is None and body["effective_model"] is None
+ assert body["error"] == SAID[ref]
+ no_mock([], r.json(), body)
+
+
+async def test_the_assistant_model_lookup_survives_a_name_that_is_not_text(client):
+ """设置里存进了不是字符串的接入名(以前经设置接口就能写进来):照常返回、说清用不了,不 500。"""
+ await put_setting("copilot", {"provider": 123, "model": ""})
+ got = await client.get("/api/copilot/model")
+ assert got.status_code == 200, got.text
+ assert got.json()["effective_provider"] is None and "模型接入「123」不存在" in got.json()["error"]
+
+
+async def test_drafting_with_the_assistant_model_says_which_provider_is_broken(monkeypatch):
+ """表格导入、数据目录的 AI 起草也用助手的模型:说法和助手同一句(以前停用的接入说「尚未配置完成」)。"""
+ from app.data import catalog, recipe_ai
+
+ called = script(monkeypatch)
+ for ref in (MISSING, DISABLED):
+ await put_setting("copilot", {"provider": ref, "model": ""})
+ async with SessionLocal() as session:
+ assert (await recipe_ai.ai_availability(session)).reason == SAID[ref]
+ with pytest.raises(catalog.CatalogModelUnavailable) as e:
+ await catalog.resolve_draft_model(session)
+ assert str(e.value) == SAID[ref]
+ assert called == []
+
+
+async def test_the_assistant_model_lookup_survives_a_model_whose_provider_is_disabled(client):
+ """只存了模型名、提供它的接入已停用:以前这里直接 500。"""
+ await put_setting("copilot", {"provider": "", "model": "停用演示模型"})
+ got = await client.get("/api/copilot/model")
+ assert got.status_code == 200, got.text
+ body = got.json()
+ assert body["effective_provider"] is None and body["effective_model"] is None
+ assert f"所属的模型接入「{DISABLED}」已停用" in body["error"]
+
+
+async def test_the_assistant_model_lookup_reports_no_error_when_it_works(client):
+ await put_setting("copilot", {"provider": WRITER_PROVIDER, "model": ""})
+ body = (await client.get("/api/copilot/model")).json()
+ assert body["effective_provider"] == WRITER_PROVIDER and body["effective_model"] == WRITER_MODEL
+ assert body["error"] is None
+
+
+@pytest.mark.parametrize("ref", [MISSING, DISABLED])
+async def test_a_broken_gate_provider_hands_the_call_to_a_person_and_says_why(monkeypatch, ref):
+ from langchain_core.tools import StructuredTool
+
+ from app.tools.trust import ask_gate
+
+ called = script(monkeypatch)
+ await put_setting("run", {"tool_gate_provider": ref})
+
+ def lookup(name: str) -> str:
+ return name
+
+ tool = StructuredTool.from_function(lookup, name="crm_lookup", description="按客户名称查询客户档案")
+ verdict = await ask_gate(tool=tool, args={"name": "甲公司"}, node_title="查客户", task="查甲公司的档案")
+ assert verdict.allowed is False
+ assert verdict.reason.startswith("门控模型未给出判断") and REASONS[ref] in verdict.reason, verdict.reason
+ assert verdict.model == "" and called == []
+
+
+# --------------------------------------------------------------------------
+# 定时任务发起的正式运行:运行失败,通知照常
+# --------------------------------------------------------------------------
+
+#: 上海 2026-10-05(周一)09:00
+NINE = datetime(2026, 10, 5, 1, 0, tzinfo=UTC)
+HOOK = "https://hooks.example.com/agentlab/inbox-k1"
+
+
+@pytest.mark.parametrize("ref", [MISSING, DISABLED])
+async def test_a_scheduled_formal_run_naming_a_broken_provider_fails_and_is_still_notified(monkeypatch, ref):
+ from app.scheduler import loop, webhook
+
+ called = script(monkeypatch)
+ sent: list[dict] = []
+
+ def receive(request: httpx.Request) -> httpx.Response:
+ sent.append(json.loads(request.content))
+ return httpx.Response(200, json={})
+
+ monkeypatch.setattr(webhook, "_transport", httpx.MockTransport(receive))
+ monkeypatch.setattr(webhook, "RETRY_DELAYS", (0, 0, 0))
+ spec = chain(node("start", "input"), node("ask", "llm", "写日报", provider=ref, prompt="写今天的门店日报"),
+ node("out", "output", fields=[{"name": "日报", "value": "{{ last_message }}"}]))
+ async with SessionLocal() as session:
+ wf = Workflow(name=f"门店日报-{uuid.uuid4().hex[:6]}", graph=spec, status="published", published_version=1)
+ session.add(wf)
+ await session.flush()
+ session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=spec))
+ sched = Schedule(name="门店日报", workflow_id=wf.id, cron="0 9 * * *", timezone="Asia/Shanghai",
+ run_class="formal", input_template={}, enabled=True, misfire_grace_s=3600, next_run_at=NINE,
+ webhook_format="generic", webhook_url=encrypt(HOOK), webhook_events=["finished", "failed"])
+ session.add(sched)
+ await session.commit()
+ sid = sched.id
+ try:
+ await loop.fire_due(NINE + timedelta(seconds=5))
+ async with SessionLocal() as session:
+ fired = await session.execute(select(ScheduleFire).where(ScheduleFire.schedule_id == sid))
+ [fire] = list(fired.scalars())
+ assert fire.status == "started" and fire.run_id
+ run = await wait(fire.run_id, ("succeeded", "failed", "cancelled"))
+ assert run.status == "failed" and run.run_class == "formal"
+ assert REASONS[ref] in run.error and HINTS[ref] in run.error, run.error
+ assert await webhook.notify_due(datetime.now(UTC) + timedelta(seconds=webhook.SEAL_WAIT_S + 5)) == 1
+ [body] = sent
+ assert (body["event"], body["status"], body["run_id"]) == ("failed", "failed", run.id)
+ assert called == []
+ no_mock(await events(run.id), run.output, body)
+ finally:
+ async with SessionLocal() as session:
+ await session.execute(delete(Schedule).where(Schedule.id == sid))
+ await session.commit()
+
+
+# --------------------------------------------------------------------------
+# 发布前检查里修好的图,正式运行不再失败在那一处
+# --------------------------------------------------------------------------
+
+
+async def test_after_the_publish_fix_stops_naming_a_disabled_provider_the_formal_run_succeeds(monkeypatch, client):
+ """复核发现的原样:节点点名停用的接入、模型写它家的。以前发布前检查里选「不指定接入」只去掉接入,检查、保存、发布都
+ 通过,正式运行按模型名找到的还是那个停用的接入,照样失败。现在模型一并去掉,候选说明写清执行时改用哪个接入,正式
+ 运行就用它。"""
+ called = script(monkeypatch)
+ spec = chain(node("start", "input"),
+ node("ask", "llm", "写日报", provider=DISABLED, model="停用演示模型", prompt="写今天的门店日报"),
+ node("out", "output", fields=[{"name": "日报", "value": "{{ last_message }}"}]))
+ async with SessionLocal() as session:
+ wf = Workflow(name=f"门店日报-{uuid.uuid4().hex[:6]}", graph=spec, version=1)
+ session.add(wf)
+ await session.flush()
+ session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=spec))
+ await session.commit()
+ wid = wf.id
+ r = await client.post(f"/api/workflows/{wid}/publish-check", json={"level": "published"})
+ [issue] = [i for i in r.json()["issues"] if i.get("code") == "provider.disabled"]
+ [fix] = [f for f in r.json()["fixes"] if f["id"] == issue["fix"]]
+ [clear] = [o for o in fix["options"] if o["value"] == ""]
+ # 没点名接入、也没写模型时落到第一个可用的接入:本文件只建了一个启用的接入
+ assert clear["hint"] == f"去掉这一处指定的接入和模型「停用演示模型」,执行时改用第一个可用的模型接入「{WRITER_PROVIDER}」"
+ r = await client.post(f"/api/workflows/{wid}/autofix", json={"level": "published", "apply": [fix["id"]],
+ "choices": {fix["id"]: ""}})
+ fixed = r.json()
+ assert fixed["applied"] == [fix["id"]] and fixed["ok"] is True, fixed["rejected"]
+ assert (await client.patch(f"/api/workflows/{wid}", json={"graph": fixed["graph"]})).status_code == 200
+ r = await client.post(f"/api/workflows/{wid}/publish", json={"level": "published"})
+ assert r.json()["ok"] is True, r.text
+ r = await client.post("/api/runs", json={"workflow_id": wid, "run_class": "formal"})
+ assert r.status_code == 201, r.text
+ run = await wait(r.json()["id"])
+ assert run.status == "succeeded" and run.run_class == "formal", run.error
+ assert called == [WRITER_MODEL]
+ no_mock(await events(run.id), run.output)
diff --git a/backend/tests/test_provider_resolution.py b/backend/tests/test_provider_resolution.py
new file mode 100644
index 00000000..74e20826
--- /dev/null
+++ b/backend/tests/test_provider_resolution.py
@@ -0,0 +1,210 @@
+"""找模型接入(providers/factory.resolve_provider、get_chat_model):点名了就只用点名的那个。
+
+以前显式点名的接入不存在时返回 None,get_chat_model 把它当成「一个接入都没配」,悄悄换成演示模型(mock-fast):
+节点上、设置里把接入名写错一个字,或者接入改了名、删了,报告就由演示模型写成假文本,运行照样成功。点名一个已停用的
+接入又照常返回、照常调用,和「按模型名找到的接入已停用就报错」对不上。
+
+现在:
+- 点名的接入不存在 → ProviderNotConfigured(missing),说清是哪个名字、去「设置 → 模型接入」核对;
+- 点名的接入已停用 → ProviderNotConfigured(disabled),说法和按模型名找到停用接入时一致;
+- 只有没点名、一个启用的接入都没有时才回退到演示模型(没配 key 的机器上图也能执行完);没点名、模型名认不出来时
+ 照旧退回第一个启用的真实接入(用户可能手填了目录里还没有的新模型)。
+
+每个用例一个独立的内存库,接入名、模型名都是虚构的。
+"""
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from app.providers import factory
+from app.providers.factory import ModelSpec, ProviderNotConfigured, get_chat_model, resolve_provider
+from app.providers.mock_model import MockChatModel
+
+
+class _MemDB:
+ """独立的内存库:不碰测试会话共用的库,也不受别的测试建的接入影响(同 test_recipe_ai)。"""
+
+ async def __aenter__(self):
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+ from sqlalchemy.pool import StaticPool
+
+ import app.db.models # noqa: F401 - 建表前要先登记全部模型
+ from app.db.base import Base
+
+ self.engine = create_async_engine("sqlite+aiosqlite:///:memory:", poolclass=StaticPool)
+ async with self.engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+ self.session = async_sessionmaker(self.engine, expire_on_commit=False, class_=AsyncSession)()
+ return self.session
+
+ async def __aexit__(self, *exc):
+ await self.session.close()
+ await self.engine.dispose()
+
+
+@pytest.fixture
+async def session():
+ async with _MemDB() as s:
+ yield s
+
+
+@pytest.fixture(autouse=True)
+def no_env_keys(monkeypatch):
+ for k in ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY", "ANTHROPIC_BASE_URL"):
+ monkeypatch.delenv(k, raising=False)
+
+
+async def add(session, name: str, *, kind: str = "openai_compatible", models: tuple[str, ...] = ("m-1",),
+ enabled: bool = True, **extra: Any):
+ from app.core.crypto import encrypt
+ from app.db.models import Provider
+
+ row = Provider(name=name, kind=kind, base_url="http://127.0.0.1:9/v1" if kind == "openai_compatible" else None,
+ api_key=encrypt("k-test"), models=[{"id": m} for m in models],
+ default_model=models[0] if models else None, enabled=enabled, extra={}, **extra)
+ session.add(row)
+ await session.commit()
+ return row
+
+
+# --------------------------------------------------------------------------
+# 点名了就只用点名的那个
+# --------------------------------------------------------------------------
+
+
+async def test_a_named_provider_that_does_not_exist_is_an_error_not_the_mock(session):
+ await add(session, "甲接入")
+ with pytest.raises(ProviderNotConfigured) as e:
+ await resolve_provider(session, "甲接人") # 写错一个字
+ assert e.value.code == "missing"
+ assert e.value.reason == "模型接入「甲接人」不存在"
+ assert str(e.value) == "模型接入「甲接人」不存在。请在「设置 → 模型接入」中核对名称,或换一个接入"
+ with pytest.raises(ProviderNotConfigured) as e:
+ await get_chat_model(session, ModelSpec(provider="甲接人", model="m-1"))
+ assert e.value.code == "missing"
+
+
+async def test_a_named_provider_is_missing_even_when_no_provider_exists_at_all(session):
+ """一个接入都没有、却点了名:点名的那个仍然不存在,不能因为「什么都没配」就退回演示模型。"""
+ with pytest.raises(ProviderNotConfigured) as e:
+ await get_chat_model(session, ModelSpec(provider="已删除的接入"))
+ assert e.value.code == "missing" and "已删除的接入" in str(e.value)
+
+
+async def test_a_named_provider_that_is_disabled_is_an_error_worded_like_the_model_lookup(session):
+ off = await add(session, "停用接入", models=("m-off",), enabled=False)
+ await add(session, "备用接入", models=("m-on",))
+ for ref in ("停用接入", off.id):
+ with pytest.raises(ProviderNotConfigured) as e:
+ await resolve_provider(session, ref, "m-off")
+ assert e.value.code == "disabled"
+ assert str(e.value) == "模型接入「停用接入」已停用。请在「设置 → 模型接入」中启用它,或换一个接入"
+ # 和按模型名找到停用接入时同一种说法:「已停用」「启用它」
+ with pytest.raises(ProviderNotConfigured) as by_model:
+ await resolve_provider(session, None, "m-off")
+ assert by_model.value.code == "disabled"
+ for words in ("已停用", "请在「设置 → 模型接入」中启用它"):
+ assert words in str(by_model.value) and words in str(e.value)
+
+
+async def test_a_disabled_provider_named_like_a_model_is_not_swapped_for_the_one_serving_that_model(session):
+ """开发数据里真有一条停用的接入,名字恰好就是一个模型 id:点名时撞上它要照实报停用,不能悄悄换成提供这个模型的那一家;
+ 没点名、只写了模型名时照常按模型名找。"""
+ await add(session, "fake-v4.1-flash", models=("fake-v4.1-flash",), enabled=False)
+ serving = await add(session, "云端接入", models=("fake-v4.1-flash", "fake-v4-pro"))
+ 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 (await resolve_provider(session, None, "fake-v4.1-flash")).id == serving.id
+
+
+async def test_a_named_provider_is_found_by_name_or_id_and_used_as_is(session):
+ house = await add(session, "本地接入", kind="mock", models=("mock-fast",))
+ await add(session, "云端接入", models=("m-cloud",))
+ assert (await resolve_provider(session, "本地接入")).id == house.id
+ assert (await resolve_provider(session, house.id)).id == house.id
+ assert (await resolve_provider(session, " 本地接入 ")).id == house.id # 两边的空白不算
+ # 点名一个演示接入是明确的选择,照用
+ model, model_id = await get_chat_model(session, ModelSpec(provider="本地接入"))
+ assert isinstance(model, MockChatModel) and model_id == "mock-fast"
+
+
+async def test_a_blank_name_counts_as_not_named(session):
+ cloud = await add(session, "云端接入")
+ for ref in ("", " ", None):
+ assert (await resolve_provider(session, ref)).id == cloud.id
+
+
+# --------------------------------------------------------------------------
+# 没点名:保留原来的回退
+# --------------------------------------------------------------------------
+
+
+async def test_without_any_enabled_provider_and_no_name_the_mock_still_runs(session):
+ """没配 key 的机器上图也能执行完:没点名、一个启用的接入都没有,才回退到演示模型。停用的接入不算。"""
+ model, model_id = await get_chat_model(session, ModelSpec())
+ assert isinstance(model, MockChatModel) and model_id == "mock-fast"
+ await add(session, "停用接入", models=("m-off",), enabled=False)
+ model, model_id = await get_chat_model(session, ModelSpec(model="认不出的模型"))
+ assert isinstance(model, MockChatModel) and model_id == "mock-fast"
+
+
+async def test_without_a_name_an_unknown_model_goes_to_the_first_real_provider(session):
+ """手填了目录里还没有的新模型:照旧退回第一个启用的真实接入(演示接入排在后面)。"""
+ await add(session, "演示", kind="mock", models=("mock-fast",))
+ real = await add(session, "云端接入", models=("m-cloud",))
+ assert (await resolve_provider(session, None, "刚发布的新模型")).id == real.id
+ assert (await resolve_provider(session, None, None)).id == real.id
+
+
+async def test_without_a_name_the_model_picks_its_own_provider(session):
+ await add(session, "甲接入", models=("m-a",))
+ b = await add(session, "乙接入", models=("m-b",))
+ assert (await resolve_provider(session, None, "m-b")).id == b.id
+
+
+async def test_get_chat_model_builds_the_named_provider(session, monkeypatch):
+ """点名的接入找得到、启用着:照常建模型,用的就是它。"""
+ target = await add(session, "乙接入", models=("m-b",))
+ await add(session, "甲接入", models=("m-a",))
+ built: list[Any] = []
+
+ def spy(provider, spec):
+ built.append((provider.id, spec.model))
+ return MockChatModel(model_name=spec.model or "")
+
+ monkeypatch.setattr(factory, "build_chat_model", spy)
+ _, model_id = await get_chat_model(session, ModelSpec(provider="乙接入", model="m-b"))
+ assert built == [(target.id, "m-b")] and model_id == "m-b"
+
+
+async def test_a_name_that_is_not_text_counts_as_named_and_missing(session):
+ """接口写进设置里的数字一类:以前 resolve_provider 在这里抛 AttributeError(查助手模型的接口跟着 500),
+ 再往前是当成没给、悄悄回退。现在按点名了一个不存在的接入报。"""
+ await add(session, "云端接入")
+ for ref in (123, 0):
+ with pytest.raises(ProviderNotConfigured) as e:
+ await resolve_provider(session, ref) # type: ignore[arg-type]
+ assert e.value.code == "missing" and e.value.reason == f"模型接入「{ref}」不存在"
+
+
+async def test_the_assistant_is_told_which_provider_is_broken(session):
+ """助手(改图、表格导入、数据目录起草)用的模型取不到时的那句话:点名的接入不存在、已停用,按模型名找到的接入
+ 已停用,照实说是哪个、在设置里能做什么;接入本身没配完(缺 key)仍说「尚未配置完成」。"""
+ from app.providers.factory import assistant_unavailable
+
+ await add(session, "停用接入", models=("m-off",), enabled=False)
+ said = {}
+ for case, ref, model in (("missing", "甲接人", None), ("disabled", "停用接入", None), ("by_model", None, "m-off")):
+ with pytest.raises(ProviderNotConfigured) as e:
+ await resolve_provider(session, ref, model)
+ said[case] = assistant_unavailable(e.value)
+ assert said["missing"] == "助手使用的模型接入「甲接人」不存在。请在「设置 → 模型接入」中核对名称,或为助手换一个模型"
+ assert said["disabled"] == "助手使用的模型接入「停用接入」已停用。请在「设置 → 模型接入」中启用它,或为助手换一个模型"
+ assert said["by_model"] == ("助手使用的模型「m-off」所属的模型接入「停用接入」已停用。"
+ "请在「设置 → 模型接入」中启用它,或为助手换一个模型")
+ keyless = ProviderNotConfigured("模型接入「云端接入」没有配置 API Key", "请在接入中填写", code="missing_key")
+ assert assistant_unavailable(keyless) == (
+ "助手使用的模型尚未配置完成:模型接入「云端接入」没有配置 API Key。请到「设置 → 模型接入」检查")
diff --git a/backend/tests/test_verify_settings.py b/backend/tests/test_verify_settings.py
index b94fd3fb..e6a2c313 100644
--- a/backend/tests/test_verify_settings.py
+++ b/backend/tests/test_verify_settings.py
@@ -21,7 +21,7 @@ from app.api.settings import (
verify_settings,
)
from app.db.base import SessionLocal
-from app.db.models import Setting
+from app.db.models import Provider, Setting
from app.engine import spend
from app.engine.judge import JUDGE_DEFAULTS, MAX_TOKENS
from app.main import app
@@ -64,7 +64,20 @@ async def test_settings_expose_the_verify_group_with_defaults():
assert spend.SPEND_KEY not in got
-async def test_a_full_group_round_trips_and_null_stays_unlimited():
+@pytest.fixture
+async def house():
+ """设置里点名的接入要真的存在、启用着(保存时会查):建一个演示类的,用完删掉。"""
+ async with SessionLocal() as session:
+ session.add(Provider(name="house", kind="mock", models=[{"id": "verify-model"}], default_model="verify-model",
+ enabled=True, extra={}))
+ await session.commit()
+ yield "house"
+ async with SessionLocal() as session:
+ await session.execute(delete(Provider).where(Provider.name == "house"))
+ await session.commit()
+
+
+async def test_a_full_group_round_trips_and_null_stays_unlimited(house):
value = {
"provider": "house", "model": "verify-model",
"dual_check": {"enabled": False, "tolerance": 0, "max_queries": None, "timeout_s": None},
diff --git a/frontend/src/canvas/ProblemsPanel.tsx b/frontend/src/canvas/ProblemsPanel.tsx
index 7f543d05..28a24e15 100644
--- a/frontend/src/canvas/ProblemsPanel.tsx
+++ b/frontend/src/canvas/ProblemsPanel.tsx
@@ -13,7 +13,7 @@ import {
import { PublishPreflightPane } from './PublishDialog'
import { humanizeError } from '../lib/errors'
import { formatShortcut } from '../lib/keys'
-import { PUBLISH_FIX_TEXT, UPGRADE_TEXT } from '../lib/terms'
+import { PROVIDER_REF_TEXT, PUBLISH_FIX_TEXT, UPGRADE_TEXT } from '../lib/terms'
import { useCatalog } from '../store/catalog'
import { EDIT_LOCK_TEXT, upgradeUnsaved, useEditLock, useStudio, type UpgradeFlow } from '../store/studio'
import { Spinner, useRadioGroup } from '../components/ui'
@@ -232,7 +232,7 @@ function ProblemRow({ problem: p, active, onLocate, onDeleteEdge }: {
aria-label={err ? '错误' : '提示'} />
{p.message}
{where && (
-
+
{where}
)}
@@ -615,13 +615,21 @@ function UpgradeValue({ value, field, nameOf, className, ...data }: {
)
}
-const SUB_LABEL: Record = { condition: '的条件', key: '的标识', tools: '的工具', system: '的系统提示' }
+const SUB_LABEL: Record = {
+ condition: '的条件', key: '的标识', tools: '的工具', system: '的系统提示', provider: PROVIDER_REF_TEXT.memberField,
+ model: PROVIDER_REF_TEXT.memberModel,
+}
+/**
+ * 节点定义里没有、问题却会落上去的字段:点名的模型接入(多数节点的检查器里没有这一栏),以及只写了模型、提供它的接入
+ * 已停用时的模型(知识检索、结构校验的检查器里没有模型这一栏)。不写的话露出键名
+ */
+const FIELD_FALLBACK: Record = { provider: PROVIDER_REF_TEXT.field, model: PROVIDER_REF_TEXT.model }
function fieldLabel(field: FieldRef | null | undefined, type?: string, config?: Record): string {
if (!field) return ''
if (field.key === 'label') return '节点名称'
const def = type ? NODE_DEFS[type as keyof typeof NODE_DEFS] : undefined
- const base = def?.fields.find((f) => f.key === field.key)?.label ?? field.key
+ const base = def?.fields.find((f) => f.key === field.key)?.label ?? FIELD_FALLBACK[field.key] ?? field.key
if (field.index == null) return base
const sub = (field.sub && SUB_LABEL[field.sub]) || ''
// 成员有名字就叫名字:「第 2 个成员」还得数,「成员 writer」一眼就知道是谁
diff --git a/frontend/src/canvas/PublishDialog.tsx b/frontend/src/canvas/PublishDialog.tsx
index 10b5a867..2ba3c3f8 100644
--- a/frontend/src/canvas/PublishDialog.tsx
+++ b/frontend/src/canvas/PublishDialog.tsx
@@ -272,12 +272,13 @@ const unsavedApply = (a: ApplyState) => a.status === 'failed' || (a.status === '
const unsavedFix = (pf: PreflightState) => !!pf.apply && unsavedApply(pf.apply)
/**
- * 节点 id → 名字和类型。跟着 issues 换新(改名、改配置之后校验都会重跑),不订阅 nodes:
- * 问题面板开着时拖一下节点,这一整块不必跟着每帧重渲染
+ * 节点 id → 名字、类型和配置。跟着 issues 换新(改名、改配置之后校验都会重跑),不订阅 nodes:
+ * 问题面板开着时拖一下节点,这一整块不必跟着每帧重渲染。配置给修复预览认列表里的那一项(成员「分析师」)
*/
-function useNodeLookup(): (id: string) => { label: string; type: string } | undefined {
+function useNodeLookup(): (id: string) => { label: string; type: string; config?: Record } | undefined {
const tick = useStudio((s) => s.issues)
- const map = useMemo(() => new Map(useStudio.getState().nodes.map((n) => [n.id, { label: n.data.label, type: n.data.nodeType }])),
+ const map = useMemo(() => new Map(useStudio.getState().nodes.map((n) => [n.id,
+ { label: n.data.label, type: n.data.nodeType, config: n.data.config }])),
// eslint-disable-next-line react-hooks/exhaustive-deps
[tick])
return useCallback((id: string) => map.get(id), [map])
@@ -544,7 +545,8 @@ export function Preflight({ pf, gate, onLocate, stale }: {
)}
{pf.preview && (id ? nodeOf(id)?.type : undefined)} />}
+ typeOf={(id) => (id ? nodeOf(id)?.type : undefined)}
+ configOf={(id) => (id ? nodeOf(id)?.config : undefined)} />}
{/* 修复落到画布上却没存上,预览又已经收起了(换过等级):「重试保存」照样得在 */}
{pf.preview?.status !== 'ready' && unsavedFix(pf) && }
@@ -656,9 +658,11 @@ function FixControl({ fix, pf }: { fix: PublishFix; pf: PreflightState }) {
}
/** 修复预览:逐项「节点 · 字段:原值 → 新值」,没采用的写原因,Copilot 的问题原样摆出来 */
-function FixPreview({ pf, labelOf, nameOf, typeOf }: {
+function FixPreview({ pf, labelOf, nameOf, typeOf, configOf }: {
pf: PreflightState; labelOf: (id?: string | null) => string; nameOf: NodeNameOf
typeOf: (id?: string | null) => string | undefined
+ /** 列表里的某一项(agents[0]、fields[1])写那一项的名字,不写「第 1 项」 */
+ configOf?: (id?: string | null) => Record | undefined
}) {
const dirty = useStudio((s) => s.dirty)
const p = pf.preview!
@@ -703,7 +707,7 @@ function FixPreview({ pf, labelOf, nameOf, typeOf }: {
{result.changes.map((c, i) => {
const where = c.node_title || labelOf(c.node_id) || T.whole
- const field = fixFieldLabel(c.field, typeOf(c.node_id))
+ const field = fixFieldLabel(c.field, typeOf(c.node_id), configOf?.(c.node_id))
return (
-
diff --git a/frontend/src/canvas/issues.ts b/frontend/src/canvas/issues.ts
index 316d8123..62034366 100644
--- a/frontend/src/canvas/issues.ts
+++ b/frontend/src/canvas/issues.ts
@@ -11,7 +11,9 @@
* 那一栏,定位落到工具上(见 toolBindingOf)。
*/
import { ApiError } from '../api/client'
-import { APPROVAL_POLICY_LABEL, JUDGE_FIELD_LABEL, JUDGE_ON_UNSUPPORTED_LABEL, UPGRADE_TEXT, nodeTypeLabel } from '../lib/terms'
+import {
+ APPROVAL_POLICY_LABEL, JUDGE_FIELD_LABEL, JUDGE_ON_UNSUPPORTED_LABEL, PROVIDER_REF_TEXT, UPGRADE_TEXT, nodeTypeLabel,
+} from '../lib/terms'
import { NODE_DEFS } from './nodeDefs'
import type { FlowNode } from '../store/studio'
import type {
@@ -244,6 +246,12 @@ function normalizeIssue(raw: any): ValidationIssue | null {
}
}
+/**
+ * 点名的模型接入不存在或已停用,或者只写了模型、提供它的接入已停用(后端 engine/provider_refs.py)。一处一条问题、
+ * 一条修复:同一个协作节点上自己的接入和成员的接入是两处,合并、找修复都要连字段一起认
+ */
+export const isProviderRefCode = (code?: string | null): boolean => code === 'provider.missing' || code === 'provider.disabled'
+
function normalizeFix(raw: any): PublishFix | null {
if (!raw || typeof raw !== 'object' || typeof raw.id !== 'string' || !raw.id) return null
const kind = raw.kind === 'choice' || raw.kind === 'assist' ? raw.kind : raw.kind === 'auto' ? 'auto' : null
@@ -259,6 +267,7 @@ function normalizeFix(raw: any): PublishFix | null {
code: asText(raw.code),
kind,
node_id: raw.node_id ?? null,
+ ...(typeof raw.field === 'string' && raw.field ? { field: raw.field } : {}),
label: asText(raw.label) || raw.id,
preview: raw.preview && typeof raw.preview === 'object' ? raw.preview : null,
options,
@@ -318,7 +327,10 @@ export function fixFor(issue: ValidationIssue, fixes: PublishFix[]): PublishFix
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 = same.find((f) => (f.node_id ?? null) === (issue.node_id ?? null))
+ // 点名的接入一处一条修复:同一节点上的几处按字段认
+ 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 (exact) return exact
return issue.node_id == null ? same[0] : same.find((f) => f.node_id == null)
}
@@ -326,13 +338,16 @@ export function fixFor(issue: ValidationIssue, fixes: PublishFix[]): PublishFix
/**
* 同一处问题只列一行:validate 和门禁常对同一处各报一条(同 code、同节点,说法不同),
* 列两行就是两组一样的修复控件、「会被拦下 2 处」。合成的那一行取最重的级别、第一条 error 的说法,
- * 其余说法收进 others(悬停可见)。没有 code 的认不出是不是同一处,照原样各列一行
+ * 其余说法收进 others(悬停可见)。没有 code 的认不出是不是同一处,照原样各列一行。
+ * 点名的模型接入连字段一起认:协作节点自己的接入和成员的接入是两处问题、各有各的修复,不合成一行
*/
export function mergeIssues(issues: ValidationIssue[]): { issue: ValidationIssue; others: string[] }[] {
const out: { issue: ValidationIssue; others: string[] }[] = []
const at = new Map()
for (const issue of issues) {
- const key = issue.code ? `${issue.code}\u0000${issue.node_id ?? ''}` : null
+ const key = issue.code
+ ? `${issue.code}\u0000${issue.node_id ?? ''}${isProviderRefCode(issue.code) ? `\u0000${issue.field ?? ''}` : ''}`
+ : null
const i = key != null ? at.get(key) : undefined
if (i == null) {
if (key != null) at.set(key, out.length)
@@ -382,11 +397,16 @@ const CONTRACT_KEYS: Record = {
/** 不在节点定义的字段表里、由检查器自己画的几项(子工作流钉的版本、升版处置),以及图级的全图默认 */
const EXTRA_FIELDS: Record = {
workflow_version: '固定版本', upgrade_policy: '上游发布新版本时', defaults: '工作流默认设置',
+ // 多数节点的检查器里没有接入这一栏(只有双盲复算、反方审稿有),修复点名接入时照样要写得出叫法;修复连带去掉的
+ // 模型也一样(知识检索、结构校验的检查器里没有模型这一栏)
+ provider: PROVIDER_REF_TEXT.field, model: PROVIDER_REF_TEXT.model,
}
/** 点号后面那一段的叫法:契约里的键,全图默认里的审批策略,列表项里的名称、取值、表达式 */
const SUB_KEYS: Record = {
...CONTRACT_KEYS, approval: '审批策略', claims: '未附依据的结论句', on_uncited: '未附依据时',
name: '名称', value: '取值', expression: '表达式',
+ // 点名的接入:成员的、全图默认里的(连同全图默认里结论句裁判的那一份)
+ provider: PROVIDER_REF_TEXT.field, model: PROVIDER_REF_TEXT.model, judge: PROVIDER_REF_TEXT.judge,
}
/** 契约 claims 的 on_uncited:没挂依据的结论句怎么算 */
const ON_UNCITED_LABEL: Record = {
diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts
index 5c0e6215..38756d6b 100644
--- a/frontend/src/lib/terms.ts
+++ b/frontend/src/lib/terms.ts
@@ -919,6 +919,31 @@ export const PUBLISH_FIX_TEXT = {
whole: '整个工作流',
} as const
+/**
+ * 点名的模型接入不存在或已停用(后端 providers/factory.named_provider、engine/provider_refs.py)。以前点名的接入不存在时
+ * 运行悄悄换成演示模型、点名停用的照常调用;现在运行照实报错,设置页、画布校验、发布前检查都提前说出来
+ */
+export const PROVIDER_REF_TEXT = {
+ /** 节点配置里 provider 这一项的叫法:多数节点的检查器里没有这一栏,问题面板、修复预览里照样要写得出来 */
+ field: '模型接入',
+ /** 协作成员、修复预览里点号后面那一段:「成员「分析师」的模型接入」「工作流默认设置 · 模型接入」 */
+ memberField: '的模型接入',
+ /**
+ * 只写了模型、提供它的接入已停用时,问题落在写模型的那一处:成员的「成员「分析师」的模型」;节点定义里没有模型这一栏的
+ * (让模型重排的知识检索、让模型修复的结构校验)照样写得出「模型」
+ */
+ memberModel: '的模型',
+ model: '模型',
+ judge: '结论句裁判',
+ /** 设置页那一格下面:存着的接入不存在、已停用时先说状态,再说这一项因此会怎样(各组自己的后果) */
+ missing: (name: string) => `模型接入「${name}」不存在`,
+ disabled: (name: string) => `模型接入「${name}」已停用`,
+ gateBroken: '门控模型无法调用,所有工具调用将转为人工审批',
+ judgeBroken: '裁判调用将失败,所有结论句记为未裁判',
+ // 和核查一组「不存在」那句(VERIFY_SETTING_TEXT.section.providerGone)同一个后果
+ verifyBroken: '核查调用将失败',
+} as const
+
/**
* 一键升级为可追溯结构(问题面板的快速修复、记录页的横幅)。和发布前修复同一套规矩:
* 只出预览,人点「应用」才走现有的保存存成草稿;不发布
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx
index 61849480..fa0179de 100644
--- a/frontend/src/pages/SettingsPage.tsx
+++ b/frontend/src/pages/SettingsPage.tsx
@@ -5,7 +5,7 @@ import {
AlertTriangle, Check, Cpu, Download, KeyRound, Monitor, Moon, Plug, Plus, Settings as SettingsIcon, Sun, X,
} from 'lucide-react'
import clsx from 'clsx'
-import { api } from '../api/client'
+import { ApiError, api } from '../api/client'
import type { ProviderDraft } from '../api/client'
import { modelOptions, providerOfModel, useCatalog, useOnReconnect } from '../store/catalog'
import {
@@ -13,7 +13,7 @@ import {
promptDialog, SectionBar, Skeleton, Spinner, TabPanel, Tabs, toast, useRadioGroup, useTabRoute,
} from '../components/ui'
import { setThemePref, useTheme } from '../components/CommandPalette'
-import { humanizeError } from '../lib/errors'
+import { errorMessage } from '../lib/errors'
import { formatNumber, shortLabel } from '../lib/format'
import { checkHealth, forgetHealth, healthFromServer, setHealth, useHealth } from '../lib/health'
import type { HealthRecord } from '../lib/health'
@@ -22,9 +22,13 @@ import type { Provider } from '../types'
import { normalizeTheme } from '../lib/theme'
import { useLeaveGuard } from '../lib/leave'
import { localActor, setLocalActor } from '../lib/actor'
-import { AGENT_GUARD_TEXT, JUDGE_SETTING_TEXT, TOOL_GATE_TEXT, VERIFY_SETTING_TEXT, judgeByModelText, judgeUnlimitedText } from '../lib/terms'
+import {
+ AGENT_GUARD_TEXT, JUDGE_SETTING_TEXT, PROVIDER_REF_TEXT, TOOL_GATE_TEXT, VERIFY_SETTING_TEXT, judgeByModelText, judgeUnlimitedText,
+} from '../lib/terms'
import type { ThemePref } from '../lib/theme'
-import { VerifySection, verifyChanged, verifyDraftOf, verifyErrors, verifyLabels, verifyPayload, type VerifyDraft } from './VerifySettings'
+import {
+ ProviderOptions, VerifySection, verifyChanged, verifyDraftOf, verifyErrors, verifyLabels, verifyPayload, type VerifyDraft,
+} from './VerifySettings'
// 提到模块级:tab 名同时是 URL 的最后一段,两处各写一份迟早对不上。
// 数据源搬去了顶级的「数据」页(/data),/settings/datasources 跳过去
@@ -622,6 +626,8 @@ function PrefsTab() {
const [saving, setSaving] = useState(false)
const [savedFlash, setSavedFlash] = useState(0)
const [saveError, setSaveError] = useState(null)
+ // 服务端按项拒收(点名的模型接入不存在、已停用:422 带 field):整句落到那一格下面,焦点移过去
+ const [fieldError, setFieldError] = useState<{ field: string; message: string } | null>(null)
// 今天裁判花了多少:只读,放在每日上限旁边。老后端没有这个接口就不显示
const loadSpend = () => api.settings.judgeSpend().then(setSpend, () => setSpend(null))
@@ -709,10 +715,12 @@ function PrefsTab() {
const edit = (patch: Partial) => {
setSaveError(null)
+ setFieldError(null)
setDraft({ ...cur, ...patch })
}
const editVerify = (patch: Partial) => {
setSaveError(null)
+ setFieldError(null)
setVerifyDraft({ ...verifyCur, ...patch })
}
@@ -724,6 +732,7 @@ function PrefsTab() {
if (!values || !dirty || invalid) return
setSaving(true)
setSaveError(null)
+ setFieldError(null)
const actor = cur.actor.trim()
if (actor !== savedActor.current) {
// 写不进去(隐私模式)时署名只在这一次会话里有效;setLocalActor 会通知导航底部的首字
@@ -775,6 +784,15 @@ function PrefsTab() {
// 署名已经存上了:留在草稿里的只剩运行默认值,条上的计数跟着变少
setDraft({ ...cur, actor })
setSaveError(e)
+ const at = e instanceof ApiError && e.field ? PROVIDER_FIELD_IDS[e.field] : undefined
+ if (at) {
+ setFieldError({ field: (e as ApiError).field!, message: (e as ApiError).message })
+ requestAnimationFrame(() => {
+ const el = document.getElementById(at)
+ el?.scrollIntoView({ block: 'center' })
+ el?.focus()
+ })
+ }
} finally {
setSaving(false)
}
@@ -783,16 +801,29 @@ function PrefsTab() {
const scopeOptions = [...new Set(['default', ...scopes.map((s) => s.scope), cur.scope])]
const collectionNames = collections.map((c) => c.collection)
const collectionMissing = !collectionNames.includes(cur.collection)
+ /**
+ * 接入那一格下面的报错:服务端按这一项拒收的话照它的整句;否则存着(或选着)的接入不存在、已停用时说清这一项会怎样。
+ * 只在接入表取到了时判「不存在」:表还没取回来,什么都找不到
+ */
+ const providerError = (field: string, name: string, found: Provider | undefined, broken: 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}`
+ }
// 门控模型留空时后端的挑法(resolve_provider):第一个启用的真实接入,没有就第一个启用的
const enabledProviders = providers.filter((p) => p.enabled)
const defaultProvider = enabledProviders.find((p) => p.kind !== 'mock') ?? enabledProviders[0]
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 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 judgeModelName = cur.judgeModel.trim()
const judgeByModel = !cur.judgeProvider && !!judgeModelName
// 裁判模型没有单独配置(接入、模型都留空):后端依次用助手的模型、默认接入
@@ -895,22 +926,19 @@ function PrefsTab() {
{TOOL_GATE_TEXT.label}
{TOOL_GATE_TEXT.hint}
-
+
{(p) => (
)}
+ hint={gateProvider?.enabled && gateProvider.default_model ? `留空则使用 ${gateProvider.default_model}` : undefined}>
{(p) => (
<>
{JUDGE_SETTING_TEXT.differ}
)}
-
{judgeByModelHint}
@@ -986,14 +1013,12 @@ function PrefsTab() {
)}
+ hint={judgeProvider?.enabled && judgeProvider.default_model ? `留空则使用 ${judgeProvider.default_model}` : undefined}>
{(p) => (
<>
)}
- {values.verify && }
+ {values.verify && }
>
)}
@@ -1076,11 +1102,14 @@ function PrefsTab() {
{saveError != null && (
- 保存失败:{humanizeError(saveError).title}
+
+ {/* 连同原因:服务端的说明常是两三句,只留标题会把「请在……中启用它」这类下一步截掉 */}
+ 保存失败:{errorMessage(saveError)}
+
)}
+ onClick={() => { setDraft(null); setVerifyDraft(null); setSaveError(null); setFieldError(null) }}>放弃