合入期 5 前置修复 K1:点名的模型接入不存在或已停用时照实报错,不再悄悄换成演示模型——各条用到模型的路径照实失败或记为核查不可用;保存设置时 422、画布校验给警告、发布与发布前检查挡住并给修复候选(不指定模型、改用启用的接入),只写模型而提供它的接入已停用也查得出

This commit is contained in:
ThearchyHelios
2026-10-06 16:55:27 +08:00
25 changed files with 2865 additions and 149 deletions
+16 -4
View File
@@ -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):
+56 -2
View File
@@ -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:设置页整组回写时带着一份旧的,
+33 -11
View File
@@ -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")
+6 -10
View File
@@ -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 ""
+5 -13
View File
@@ -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}))
+214 -22
View File
@@ -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:
+16 -5
View File
@@ -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]
+258
View File
@@ -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 []
+84 -31
View File
@@ -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"
+495
View File
@@ -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"] == []
+295
View File
@@ -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}。"
"请在「设置 → 模型接入」中启用它,或在节点中换一个模型")
+608
View File
@@ -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)
+210
View File
@@ -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。请到「设置 → 模型接入」检查")
+15 -2
View File
@@ -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},
+12 -4
View File
@@ -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 ? '错误' : '提示'} />
<span className={clsx('min-w-0 flex-1', err ? 'text-fg' : 'text-dim')}>{p.message}</span>
{where && (
<span className="flex shrink-0 items-center gap-0.5 text-faint">
<span className="flex shrink-0 items-center gap-0.5 text-faint" data-problem-field="">
<CornerDownRight size={9} aria-hidden />{where}
</span>
)}
@@ -615,13 +615,21 @@ function UpgradeValue({ value, field, nameOf, className, ...data }: {
)
}
const SUB_LABEL: Record<string, string> = { condition: '的条件', key: '的标识', tools: '的工具', system: '的系统提示' }
const SUB_LABEL: Record<string, string> = {
condition: '的条件', key: '的标识', tools: '的工具', system: '的系统提示', provider: PROVIDER_REF_TEXT.memberField,
model: PROVIDER_REF_TEXT.memberModel,
}
/**
* 节点定义里没有、问题却会落上去的字段:点名的模型接入(多数节点的检查器里没有这一栏),以及只写了模型、提供它的接入
* 已停用时的模型(知识检索、结构校验的检查器里没有模型这一栏)。不写的话露出键名
*/
const FIELD_FALLBACK: Record<string, string> = { provider: PROVIDER_REF_TEXT.field, model: PROVIDER_REF_TEXT.model }
function fieldLabel(field: FieldRef | null | undefined, type?: string, config?: Record<string, any>): 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」一眼就知道是谁
+11 -7
View File
@@ -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<string, any> } | 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 && <FixPreview pf={pf} labelOf={labelOf} nameOf={nameOf}
typeOf={(id) => (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) && <SaveTrouble pf={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<string, any> | undefined
}) {
const dirty = useStudio((s) => s.dirty)
const p = pf.preview!
@@ -703,7 +707,7 @@ function FixPreview({ pf, labelOf, nameOf, typeOf }: {
<ul className="space-y-1" data-fix-changes="">
{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 (
<li key={i} className="leading-snug" data-fix-change={c.fix_id}>
<span className="[overflow-wrap:anywhere]">
+24 -4
View File
@@ -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<string, number>()
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<string, string> = {
/** 不在节点定义的字段表里、由检查器自己画的几项(子工作流钉的版本、升版处置),以及图级的全图默认 */
const EXTRA_FIELDS: Record<string, string> = {
workflow_version: '固定版本', upgrade_policy: '上游发布新版本时', defaults: '工作流默认设置',
// 多数节点的检查器里没有接入这一栏(只有双盲复算、反方审稿有),修复点名接入时照样要写得出叫法;修复连带去掉的
// 模型也一样(知识检索、结构校验的检查器里没有模型这一栏)
provider: PROVIDER_REF_TEXT.field, model: PROVIDER_REF_TEXT.model,
}
/** 点号后面那一段的叫法:契约里的键,全图默认里的审批策略,列表项里的名称、取值、表达式 */
const SUB_KEYS: Record<string, string> = {
...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<string, string> = {
+25
View File
@@ -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
/**
* 一键升级为可追溯结构(问题面板的快速修复、记录页的横幅)。和发布前修复同一套规矩:
* 只出预览,人点「应用」才走现有的保存存成草稿;不发布
+53 -19
View File
@@ -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<unknown>(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<typeof saved>) => {
setSaveError(null)
setFieldError(null)
setDraft({ ...cur, ...patch })
}
const editVerify = (patch: Partial<VerifyDraft>) => {
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() {
<div id="pref-gate-label" className="text-xs font-medium">{TOOL_GATE_TEXT.label}</div>
<p id="pref-gate-hint" className="mb-2 mt-0.5 text-2xs leading-relaxed text-faint">{TOOL_GATE_TEXT.hint}</p>
<div className="grid grid-cols-2 gap-3">
<Field label={TOOL_GATE_TEXT.provider} htmlFor="pref-gate-provider"
error={gateProviderMissing ? `模型接入「${cur.gateProvider}」不存在:门控模型无法调用,所有工具调用将转为人工审批` : undefined}>
<Field label={TOOL_GATE_TEXT.provider} htmlFor="pref-gate-provider" error={gateProviderError}>
{(p) => (
<select {...p} className="field" value={cur.gateProvider} onChange={(e) => pickGateProvider(e.target.value)}>
<option value="">
{TOOL_GATE_TEXT.providerDefault}{defaultProvider ? `(当前为 ${defaultProvider.name})` : ''}
</option>
{gateProviderMissing && <option value={cur.gateProvider}>{cur.gateProvider}(不存在)</option>}
{providers.map((pv) => (
<option key={pv.id} value={pv.name}>{pv.name}{pv.enabled ? '' : '(已停用)'}</option>
))}
<ProviderOptions providers={providers} current={cur.gateProvider} />
</select>
)}
</Field>
<Field label={TOOL_GATE_TEXT.model} htmlFor="pref-gate-model"
hint={gateProvider?.default_model ? `留空则使用 ${gateProvider.default_model}` : undefined}>
hint={gateProvider?.enabled && gateProvider.default_model ? `留空则使用 ${gateProvider.default_model}` : undefined}>
{(p) => (
<>
<input {...p} className="field mono text-xs" value={cur.gateModel} list="pref-gate-models"
@@ -976,8 +1004,7 @@ function PrefsTab() {
<p id="pref-judge-model-hint" className="mb-2 mt-0.5 text-2xs leading-relaxed text-faint">{JUDGE_SETTING_TEXT.differ}</p>
)}
<div className="grid grid-cols-2 gap-3">
<Field label={JUDGE_SETTING_TEXT.provider} htmlFor="pref-judge-provider"
error={judgeProviderMissing ? `模型接入「${cur.judgeProvider}」不存在:裁判调用将失败,所有结论句记为未裁判` : undefined}
<Field label={JUDGE_SETTING_TEXT.provider} htmlFor="pref-judge-provider" error={judgeProviderError}
hint={judgeByModelHint ? (
<span data-judge-by-model={judgeOwner ? (judgeOwner.enabled ? 'found' : 'disabled') : 'default'}
style={judgeOwner?.enabled ? undefined : { color: 'var(--st-waiting)' }}>{judgeByModelHint}</span>
@@ -986,14 +1013,12 @@ function PrefsTab() {
<select {...p} className="field" value={cur.judgeProvider} onChange={(e) => pickJudgeProvider(e.target.value)}>
<option value="">{judgeByModel ? JUDGE_SETTING_TEXT.providerFromModel : JUDGE_SETTING_TEXT.providerDefault}</option>
{judgeProviderMissing && <option value={cur.judgeProvider}>{cur.judgeProvider}(不存在)</option>}
{providers.map((pv) => (
<option key={pv.id} value={pv.name}>{pv.name}{pv.enabled ? '' : '(已停用)'}</option>
))}
<ProviderOptions providers={providers} current={cur.judgeProvider} />
</select>
)}
</Field>
<Field label={JUDGE_SETTING_TEXT.model} htmlFor="pref-judge-model"
hint={judgeProvider?.default_model ? `留空则使用 ${judgeProvider.default_model}` : undefined}>
hint={judgeProvider?.enabled && judgeProvider.default_model ? `留空则使用 ${judgeProvider.default_model}` : undefined}>
{(p) => (
<>
<input {...p} className="field mono text-xs" value={cur.judgeModel} list="pref-judge-models"
@@ -1048,7 +1073,8 @@ function PrefsTab() {
</section>
)}
{values.verify && <VerifySection cur={verifyCur} errors={verifyErrs} onEdit={editVerify} refresh={verifySaves} />}
{values.verify && <VerifySection cur={verifyCur} onEdit={editVerify} refresh={verifySaves}
errors={fieldError?.field === 'verify.provider' ? { ...verifyErrs, provider: fieldError.message } : verifyErrs} />}
</>
)}
@@ -1076,11 +1102,14 @@ function PrefsTab() {
</span>
</span>
{saveError != null && (
<span className="text-2xs text-[var(--err)]" role="alert">保存失败:{humanizeError(saveError).title}</span>
<span className="text-2xs text-[var(--err)]" role="alert" data-prefs-save-error="">
{/* 连同原因:服务端的说明常是两三句,只留标题会把「请在……中启用它」这类下一步截掉 */}
保存失败:{errorMessage(saveError)}
</span>
)}
<span className="flex-1" />
<button className="btn btn-sm btn-ghost" disabled={saving}
onClick={() => { setDraft(null); setVerifyDraft(null); setSaveError(null) }}>放弃</button>
onClick={() => { setDraft(null); setVerifyDraft(null); setSaveError(null); setFieldError(null) }}>放弃</button>
<button className="btn btn-sm btn-primary" disabled={saving || invalid}
title={guardInvalid ? '护栏设置中有无效值,请修正后再保存'
: judgeInvalid ? '证据裁判的上限有无效值,请修正后再保存'
@@ -1100,6 +1129,11 @@ function PrefsTab() {
)
}
/** 服务端按项拒收时(422 带 field)那一项在页面上是哪个控件:点名的模型接入的三格 */
const PROVIDER_FIELD_IDS: Record<string, string> = {
'run.tool_gate_provider': 'pref-gate-provider', 'judge.provider': 'pref-judge-provider', 'verify.provider': 'pref-verify-provider',
}
/** 裁判那一组在草稿里的键:它们改了才写 judge 一组 */
const JUDGE_DRAFT = new Set<string>(['judgeProvider', 'judgeModel', 'jClaims', 'jCost', 'jTimeout', 'jClick', 'jDaily'])
+20
View File
@@ -78,6 +78,15 @@ function matches(e: KeyboardEvent, combo: string): boolean {
/** 改图的快捷键。正式运行期间按了要说为什么没反应,不能安静地什么都不做 */
const EDIT_SHORTCUTS = new Set<StudioShortcutId>(['undo', 'redo', 'paste', 'duplicate', 'layout'])
/**
* 接入表的签名:只看校验用得上的(id、名称、启用与否)。画布校验会查节点点名的接入是不是不存在、已停用(后端
* engine/provider_refs.py),接入在设置里启用、停用、改名、删除之后,眼前的问题清单就过时了。记在模块里而不是
* 组件里:去设置页改完再回来,编排页是重新挂上的,要和离开前校验时的那份比
*/
let checkedProviders: string | null = null
const providerSig = (providers: { id: string; name: string; enabled: boolean }[]) =>
providers.map((p) => `${p.id}\u0000${p.name}\u0000${p.enabled ? 1 : 0}`).join('\u0001')
export function StudioPage() {
const { workflowId } = useParams()
const [params, setParams] = useSearchParams()
@@ -87,6 +96,8 @@ export function StudioPage() {
const workflows = useCatalog((s) => s.workflows)
const refresh = useCatalog((s) => s.refresh)
const catalogLoaded = useCatalog((s) => s.loaded)
// 选签名(字符串)而不是整张表:目录每刷新一次表都是新数组,内容没变就不该重新校验
const providers = useCatalog((s) => providerSig(s.providers))
const workflow = useStudio((s) => s.workflow)
const dirty = useStudio((s) => s.dirty)
// 只订阅数量,不订阅整个 nodes——后者每拖一下都会让整页重渲染
@@ -241,6 +252,15 @@ export function StudioPage() {
resolve(workflowId)
}, [workflowId, workflow, workflows, catalogLoaded, dirty, load, navigate, resolve, location.state])
// 接入表变了(在设置里启用、停用、改名、删除了接入):画布上的问题清单可能已经过时,重新校验一次。
// 第一次拿到接入表时只记下来——打开画布本身就会校验,后端校验时读的是库里的接入表,不靠这里
useEffect(() => {
if (!catalogLoaded) return
const changed = checkedProviders !== null && checkedProviders !== providers
checkedProviders = providers
if (changed && useStudio.getState().nodes.length) void useStudio.getState().validate()
}, [providers, catalogLoaded])
// 断开期间没取到的那张,连上之后再取一次。有未保存的改动就不动它
useOnReconnect(() => {
if (loadError && loadError.id === workflowId && !useStudio.getState().dirty) resolve(loadError.id)
+26 -6
View File
@@ -4,8 +4,8 @@ import { AlertTriangle } from 'lucide-react'
import { get } from '../api/http'
import { modelOptions, providerOfModel, useCatalog } from '../store/catalog'
import { Field, SectionBar } from '../components/ui'
import { VERIFY_SETTING_TEXT } from '../lib/terms'
import type { VerifySpend } from '../types'
import { PROVIDER_REF_TEXT, VERIFY_SETTING_TEXT } from '../lib/terms'
import type { Provider, VerifySpend } from '../types'
/*
* 设置页「偏好设置」里「核查」一节:双盲复算、反方审稿、审批质询共用的核查模型、各自的开关和上限、每日金额上限
@@ -163,6 +163,10 @@ export function VerifySection({ cur, errors, onEdit, refresh }: {
const defaultProvider = enabledProviders.find((p) => p.kind !== 'mock') ?? enabledProviders[0]
const provider = cur.provider ? providers.find((p) => p.name === cur.provider) : undefined
const providerMissing = !!cur.provider && providers.length > 0 && !provider
// 点名停用的接入以前照常调用,现在核查时照实报「已停用」、记缺口:和「不存在」一样在这一格下面说。服务端按这一项
// 拒收时(errors.provider,设置页从 422 的 field 落过来)照它的整句
const providerError = errors.provider ?? (providerMissing ? S.providerGone(cur.provider)
: provider && !provider.enabled ? `${PROVIDER_REF_TEXT.disabled(cur.provider)}:${PROVIDER_REF_TEXT.verifyBroken}` : undefined)
const modelName = cur.model.trim()
const byModel = !cur.provider && !!modelName
const notSet = !cur.provider && !modelName
@@ -222,8 +226,7 @@ export function VerifySection({ cur, errors, onEdit, refresh }: {
<p id="pref-verify-model-hint" className="mb-2 mt-0.5 text-2xs leading-relaxed text-faint">{S.differ}</p>
)}
<div className="grid grid-cols-2 gap-3 max-sm:grid-cols-1">
<Field label={T.provider} htmlFor={fieldId('provider')}
error={providerMissing ? S.providerGone(cur.provider) : undefined}
<Field label={T.provider} htmlFor={fieldId('provider')} error={providerError}
hint={byModelHint ? (
<span data-verify-by-model={owner ? (owner.enabled ? 'found' : 'disabled') : 'default'}
style={owner?.enabled ? undefined : { color: 'var(--st-waiting)' }}>{byModelHint}</span>
@@ -232,11 +235,12 @@ export function VerifySection({ cur, errors, onEdit, refresh }: {
<select {...p} className="field" value={cur.provider} onChange={(e) => pickProvider(e.target.value)}>
<option value="">{byModel ? S.providerFromModel : S.providerDefault}</option>
{providerMissing && <option value={cur.provider}>{S.providerGoneOption(cur.provider)}</option>}
{providers.map((pv) => <option key={pv.id} value={pv.name}>{pv.name}{pv.enabled ? '' : S.providerOff}</option>)}
<ProviderOptions providers={providers} current={cur.provider} />
</select>
)}
</Field>
<Field label={T.model} htmlFor={fieldId('model')} hint={provider?.default_model ? S.modelOf(provider.default_model) : undefined}>
<Field label={T.model} htmlFor={fieldId('model')}
hint={provider?.enabled && provider.default_model ? S.modelOf(provider.default_model) : undefined}>
{(p) => (
<>
<input {...p} className="field mono text-xs" value={cur.model} list="pref-verify-models" spellCheck={false}
@@ -291,6 +295,22 @@ export function VerifySection({ cur, errors, onEdit, refresh }: {
)
}
/**
* 接入下拉里的各家。停用的照列(带「已停用」),但选不了:选了运行时也调不了,保存时服务端同样会拒收。
* 已经选着的那一个(存着的值后来被停用了)照常显示,下面那一格写明它用不了
*/
export function ProviderOptions({ providers, current }: { providers: Provider[]; current: string }) {
return (
<>
{providers.map((pv) => (
<option key={pv.id} value={pv.name} disabled={!pv.enabled && pv.name !== current}>
{pv.name}{pv.enabled ? '' : S.providerOff}
</option>
))}
</>
)
}
/** 三项各一组:标题是服务端叫法表里那一组的叫法,下面一句说它做什么 */
function PartGroup({ part, children }: { part: Part; children: ReactNode }) {
return (
+2
View File
@@ -1384,6 +1384,8 @@ export interface PublishFix {
code: string
kind: 'auto' | 'choice' | 'assist'
node_id?: string | null
/** 这条修复落在哪个字段:点名的模型接入一处一条(同一节点上的几处按它区分) */
field?: string | null
label: string
preview?: { field?: string | null; before?: unknown; after?: unknown } | null
/** handoff:选了这一项就是「交给 Copilot」(G4 的「它在做计算」)。只认这个标记,不认值——
+2 -1
View File
@@ -1455,7 +1455,8 @@ await section('studio', '报告撰写节点:画布认得、检查器能配(R
check('……null 写「不限」(没写才是「(空)」)、金额带单位', previews[1] === '不限' && previews[2] === '0.05 美元', JSON.stringify(previews.slice(1, 3)))
check('……补预算按整份 judge 记时一键一行', previews[3] === '裁判模型:x;金额上限(美元):不限', previews[3])
check('……证据相矛盾时写选项文字', previews[4] === '不予出具', previews[4])
check('……别处的 model 不借用裁判的叫法', previews[5] === '工作流默认设置 · model', previews[5])
// 不借「裁判模型」,写通用的「模型」(以前露键名 model;点名接入的修复会清掉全图默认里的模型,预览里要写得出叫法)
check('……别处的 model 不借用裁判的叫法', previews[5] === '工作流默认设置 · 模型', previews[5])
if (SHOTS) {
await jf.scrollIntoViewIfNeeded()
for (const theme of ['dark', 'light']) {
+64
View File
@@ -589,6 +589,70 @@ await section('设置 · 门控模型', async () => {
await old.close()
})
await section('设置 · 点名的接入已停用或不存在', async () => {
// 点名停用的接入以前照常调用,现在运行时照实报错(K1):存着的值后来被停用了,那一格下面要说;别的停用接入列着但选不了;
// 服务端按项拒收(422 带 field)时整句落到那一格下面、焦点移过去,保存条上原因不截断。接入表、设置读写都是假的
const PROVIDERS = [
{ id: 'check-ref-p1', name: 'demo-main', kind: 'openai', base_url: 'https://example.com/v1', default_model: 'big-model',
models: [{ id: 'big-model' }], enabled: true, extra: {}, api_key_masked: '', has_key: true },
{ id: 'check-ref-p2', name: 'demo-off', kind: 'openai', base_url: 'https://example.com/v1', default_model: 'off-model',
models: [{ id: 'off-model' }], enabled: false, extra: {}, api_key_masked: '', has_key: true },
{ id: 'check-ref-p3', name: 'demo-spare', kind: 'openai', base_url: 'https://example.com/v1', default_model: 'spare-1',
models: [{ id: 'spare-1' }], enabled: false, extra: {}, api_key_masked: '', has_key: true },
]
let stored = await get('/settings')
stored = {
...stored,
run: { ...(stored.run ?? {}), tool_gate_provider: 'demo-off', tool_gate_model: null },
judge: { ...(stored.judge ?? {}), provider: 'demo-off', model: null },
verify: { ...(stored.verify ?? {}), provider: 'demo-off', model: null },
}
const REFUSED = '「门控模型 · 接入」填写的模型接入「demo-main」已停用。请在「设置 → 模型接入」中启用它,或换一个接入;留空表示使用默认接入的默认模型'
let refuse = true
const { page, sent, errors, close } = await open('/settings/prefs', {
handlers: [
[/^GET \/settings$/, (route) => json(stored)(route)],
[/^PUT \/settings$/, (route, { body }) => {
if (refuse) {
refuse = false
return route.fulfill({ status: 422, contentType: 'application/json',
body: JSON.stringify({ detail: REFUSED, code: 'settings_provider_invalid', field: 'run.tool_gate_provider' }) })
}
stored = { ...stored, ...body.values }
return json(stored)(route)
}],
[/^GET \/providers$/, json(PROVIDERS)],
],
})
const said = async (id) => page.locator(`#${id}-error`).innerText().catch(() => '')
check('门控模型:存着的接入已停用,那一格下面说清', (await said('pref-gate-provider')).includes('模型接入「demo-off」已停用:门控模型无法调用,所有工具调用将转为人工审批'),
await said('pref-gate-provider'))
check('证据裁判:同样说清', (await said('pref-judge-provider')).includes('模型接入「demo-off」已停用:裁判调用将失败'), await said('pref-judge-provider'))
check('核查模型:同样说清', (await said('pref-verify-provider')).includes('模型接入「demo-off」已停用:核查调用将失败'), await said('pref-verify-provider'))
check('……模型那一格不再写「留空则使用 off-model」', !(await page.locator('[data-gate-model]').innerText()).includes('off-model'))
const gate = page.locator('#pref-gate-provider')
const off = (name) => gate.locator(`option[value="${name}"]`).evaluate((o) => o.disabled)
check('下拉:选着的停用接入照常显示,别的停用接入列着但选不了', !(await off('demo-off')) && await off('demo-spare') && !(await off('demo-main')))
await shot(page, 'prefs-provider-disabled')
await gate.selectOption('demo-main')
check('换成启用的接入:那一格的说明消失,刚才那个停用接入也选不回去了', !(await said('pref-gate-provider')) && await off('demo-off'))
await page.getByRole('button', { name: /保存设置/ }).click()
await page.waitForTimeout(500)
check('服务端按项拒收:整句落到那一格下面', (await said('pref-gate-provider')) === REFUSED, await said('pref-gate-provider'))
check('……焦点移到那一格', await gate.evaluate((el) => el === document.activeElement))
const bar = await page.locator('[data-prefs-save-error]').innerText().catch(() => '')
check('……保存条写到原因,不截在第一句', bar.startsWith('保存失败:') && bar.includes('请在「设置 → 模型接入」中启用它'), bar)
await gate.selectOption('')
check('再改这一项:服务端那句说明收起', !(await said('pref-gate-provider')))
await page.getByRole('button', { name: /保存设置/ }).click()
await page.waitForTimeout(500)
const put = sent.filter((x) => x.key === 'PUT /settings').at(-1)?.body?.values?.run ?? {}
check('……再保存:照常写进去', put.tool_gate_provider === null && (await page.locator('[data-prefs-save-error]').count()) === 0,
`provider=${JSON.stringify(put.tool_gate_provider)}`)
check('没有运行时报错', errors.length === 0, errors[0] ?? '')
await close()
})
await section('设置 · 智能体护栏', async () => {
// 默认步数、token 预算、金额预算(后端 engine/guards.py)。预算留空 = 不限,存 null
let stored = await get('/settings')
+315 -8
View File
@@ -2,6 +2,8 @@
// 先预览再「应用并重新检查」、问题面板里的「发布前检查」、老后端(两个接口 404)照旧。
// 另有一键升级为可追溯结构(可点击证据五期):问题面板的快速修复、升级预览、应用走现有的保存、
// 从记录页横幅跳过来(?upgrade=1)。升级接口和校验给的那条建议同样在浏览器里伪造。
// 还有点名的模型接入不存在或已停用(K1):一处一行、各有一组要人选的修复,问题面板不露键名,
// 在设置里删了接入回到画布要重新校验。接入表、校验同样伪造。
//
// 不写库:工作流、保存、发布、publish-check、autofix 全在浏览器里用 page.route 伪造。
// 伪造的检查接口按请求里的图现算(下面的 lint),所以修完、存完、再查,结论会真的变;
@@ -162,6 +164,48 @@ const SQLWF = {
edges: chain('in', 'sum', 'done'),
}
/**
* 点名的模型接入(K1):「提问」点的接入不存在,协作节点自己的接入已停用、成员「分析师」点的接入不存在。
* 接入表在 open() 里伪造(state.providers),校验和门禁都按它现算
*/
const PROV = {
nodes: [
node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }),
node('ask', 'llm', 240, '提问', { prompt: '总结本周销售', provider: 'demo-old', model: 'old-model' }),
node('team', 'supervisor', 480, '协作', { goal: '复核', provider: 'demo-off', agents: [{ name: '分析师', provider: 'demo-old', tools: [] }] }),
node('done', 'output', 720, '成果', { fields: [{ name: 'report', value: '{{ last_message }}' }] }),
],
edges: chain('in', 'ask', 'team', 'done'),
}
/** 点名的接入现在好好的(启用着):在设置里删掉它之后回到画布,校验要重新查出来 */
const PROV_OK = {
nodes: [
node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }),
node('ask', 'llm', 240, '提问', { prompt: '总结本周销售', provider: 'demo-main' }),
node('done', 'output', 480, '成果', { fields: [{ name: 'report', value: '{{ nodes.ask.text }}' }] }),
],
edges: chain('in', 'ask', 'done'),
}
/**
* 只写了模型、提供它的接入已停用(K1):让模型重排的检索写的 tiny-1 只有停用的 demo-off 提供,协作成员同样只写了
* tiny-1。检索的检查器里没有模型这一栏、成员也没有:问题面板和修复预览照样要写得出「模型」
*/
const PROV_MODEL = {
nodes: [
node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }),
node('look', 'retrieve', 240, '检索', { query: '{{ input.week }}', rerank: 'model', model: 'tiny-1' }),
node('team', 'supervisor', 480, '协作', { goal: '复核', agents: [{ name: '分析师', model: 'tiny-1', tools: [] }] }),
node('done', 'output', 720, '成果', { fields: [{ name: 'report', value: '{{ last_message }}' }] }),
],
edges: chain('in', 'look', 'team', 'done'),
}
const PROVIDERS = [
{ id: 'pv-main', name: 'demo-main', kind: 'openai_compatible', base_url: 'https://example.com/v1', default_model: 'big-model',
models: [{ id: 'big-model' }], enabled: true, extra: {}, api_key_masked: '', has_key: true },
{ id: 'pv-off', name: 'demo-off', kind: 'openai_compatible', base_url: 'https://example.com/v1', default_model: 'tiny-1',
models: [{ id: 'tiny-1' }], enabled: false, extra: {}, api_key_masked: '', has_key: true },
]
const wf = (id, name, graph, extra = {}) => ({
id, name, description: '检查脚本伪造的工作流', graph, tags: [], version: 3, is_template: false,
status: 'draft', published_version: null, published_by: null, run_count: 0,
@@ -179,6 +223,9 @@ const FAKES = {
'pf-legacy': wf('pf-legacy', '__upgrade_check_legacy__', LEGACY, { status: 'published', published_version: 3 }),
'pf-ask': wf('pf-ask', '__upgrade_check_ask__', ASK),
'pf-sql': wf('pf-sql', '__publish_check_sql__', SQLWF),
'pf-prov': wf('pf-prov', '__publish_check_provider__', PROV),
'pf-provok': wf('pf-provok', '__publish_check_provider_ok__', PROV_OK),
'pf-provmodel': wf('pf-provmodel', '__publish_check_provider_model__', PROV_MODEL),
}
const clone = (x) => JSON.parse(JSON.stringify(x))
@@ -186,15 +233,99 @@ const who = (n) => `「${n.data.label}」`
/** 指标候选的写法照后端 autofix._metric_options:「名称(id)」,提示写来自哪个口径卡 */
const metricOptions = (m) => m.data.config.metrics.map((x) => ({ value: x.id, label: `${x.name}(${x.id})`, hint: `口径卡「${m.data.label}」` }))
/**
* 伪造的点名接入检查,形状照后端 engine/provider_refs.py 与 autofix._plan_provider:一处一条问题(字段 provider、
* agents[i].provider),门禁里是 error、带一条要人选的修复(id 是 code:节点:字段);画布校验里是 warning、没有修复。
* 候选照后端按执行时的取法算去掉、换掉以后落到哪里(只覆盖这两张图用到的情形,图里没有全图默认):节点去掉接入落到
* 第一个可用的接入;成员去掉接入跟随节点自己的接入,那个接入停用了就不给这一项;落到的接入不提供同一处写着的模型时
* 模型一并去掉(_drops 记哪几个候选要去掉,应用时用,前端收不到)
*/
function providerLint(graph, providers, level) {
const issues = []
const fixes = []
const refs = []
const kind = { llm: '模型调用', supervisor: '多 Agent 协作' }
const serves = (p, m) => (p.models ?? []).some((x) => x.id === m) || p.default_model === m
const enabled = providers.filter((x) => x.enabled)
for (const n of graph.nodes) {
const c = n.data.config ?? {}
const at = `${who(n)}(${kind[n.type]})`
if (c.provider) refs.push({ n, field: 'provider', name: c.provider, who: at, holder: c })
;(c.agents ?? []).forEach((a, i) => {
if (a.provider) refs.push({ n, field: `agents[${i}].provider`, name: a.provider, who: `${at}的成员「${a.name}」`, holder: a, team: at })
})
}
for (const r of refs) {
const p = providers.find((x) => x.name === r.name)
if (p?.enabled) continue
const code = p ? 'provider.disabled' : 'provider.missing'
const id = `${code}:${r.n.id}:${r.field}`
const gate = level === 'error'
issues.push({ level, node_id: r.n.id, field: r.field, code, fix: gate ? id : null,
message: `${r.who}指定的模型接入「${r.name}」${p ? '已停用' : '不存在'}。请在「设置 → 模型接入」中${p ? '启用它' : '核对名称'},`
+ '或在「发布前检查」中改用其他接入' })
if (!gate) continue
const model = r.holder.model
const options = []
const drops = []
const up = r.team && r.n.data.config.provider ? providers.find((x) => x.name === r.n.data.config.provider) : enabled[0]
if (up?.enabled) {
const drop = !!model && !serves(up, model)
if (drop) drops.push('')
options.push({ value: '', label: '不指定接入', hint: `去掉这一处指定的接入${drop ? `和模型「${model}」` : ''},执行时改用`
+ (r.team && r.n.data.config.provider ? `${r.team}指定的接入「${up.name}」` : `第一个可用的模型接入「${up.name}」`) })
}
for (const x of enabled) {
const drop = !!model && !serves(x, model)
if (drop) drops.push(x.name)
options.push({ value: x.name, label: x.name, hint: drop ? `不提供模型「${model}」,将改用它的默认模型「${x.default_model}」` : null })
}
fixes.push({ id, code, node_id: r.n.id, field: r.field, kind: 'choice', label: `选择${r.who}改用的模型接入`, options, _drops: drops })
}
// 哪一级都没写接入、只写了模型:按模型名找,只有停用的接入提供它时同样是问题,落在写模型的那一处。修复是「不指定
// 模型」(落到第一个可用的接入)或改用一个启用的接入(写在模型旁边,模型去掉)
const models = []
for (const n of graph.nodes) {
const c = n.data.config ?? {}
const at = `${who(n)}(${n.type === 'retrieve' ? '知识检索' : kind[n.type]})`
if (!c.provider && c.model && (n.type !== 'retrieve' || c.rerank === 'model')) {
models.push({ n, field: 'model', name: c.model, who: at, holder: c, editable: n.type !== 'retrieve' })
}
;(c.agents ?? []).forEach((a, i) => {
if (!c.provider && !a.provider && a.model) models.push({ n, field: `agents[${i}].model`, name: a.model, who: `${at}的成员「${a.name}」`, holder: a })
})
}
for (const r of models) {
const off = enabled.some((x) => serves(x, r.name)) ? null : providers.find((x) => !x.enabled && serves(x, r.name))
if (!off) continue
const id = `provider.disabled:${r.n.id}:${r.field}`
const gate = level === 'error'
issues.push({ level, node_id: r.n.id, field: r.field, code: 'provider.disabled', fix: gate ? id : null,
message: `${r.who}指定的模型「${r.name}」所属的模型接入「${off.name}」已停用。请在「设置 → 模型接入」中启用它,`
+ (r.editable ? '或在节点中换一个模型' : '或在「发布前检查」中改用其他接入') })
if (!gate || !enabled.length) continue
fixes.push({ id, code: 'provider.disabled', node_id: r.n.id, field: r.field, kind: 'choice', label: `选择${r.who}改用的模型接入`,
options: [{ value: '', label: '不指定模型', hint: `去掉这一处指定的模型,执行时改用第一个可用的模型接入「${enabled[0].name}」` },
...enabled.map((x) => ({ value: x.name, label: x.name, hint: `不提供模型「${r.name}」,将改用它的默认模型「${x.default_model}」` }))],
_drops: ['', ...enabled.map((x) => x.name)] })
}
return { issues, fixes }
}
/**
* 伪造的门禁:按请求里的图现算,形状照 PF-SPEC(issues 带 code 和 fix,fixes 列修法)。
* 已发布档只给提示,受管档有错。修法的 id 是 code:节点(图级的是 code:graph)。
* followsNever:当作这张图的工作流默认设置是「全部无需审批」(画布上不带 defaults,按工作流认)
*/
function lint(graph, level, { followsNever = false } = {}) {
function lint(graph, level, { followsNever = false, providers = null } = {}) {
const hard = level === 'governed'
const issues = []
const fixes = []
if (providers) {
const found = providerLint(graph, providers, 'error')
issues.push(...found.issues)
fixes.push(...found.fixes)
}
const add = (n, code, message, fix, isError = true) => {
const id = `${code}:${n.id}`
issues.push({ level: isError && hard ? 'error' : 'warning', node_id: n.id, code, message, fix: fix ? id : null, field: null })
@@ -294,9 +425,9 @@ function lint(graph, level, { followsNever = false } = {}) {
}
/** 伪造的 autofix:在副本上照选中的修法改,逐项记 changes;钉不上的(pf-lib 没发过版时)进 rejected */
function autofix(body, { unpublished = false, followsNever = false } = {}) {
function autofix(body, { unpublished = false, followsNever = false, providers = null } = {}) {
const graph = clone(body.graph)
const before = lint(graph, body.level, { followsNever })
const before = lint(graph, body.level, { followsNever, providers })
const changes = []
const rejected = []
const applied = []
@@ -338,6 +469,24 @@ function autofix(body, { unpublished = false, followsNever = false } = {}) {
const v = body.choices?.[id]
const to = `{{ nodes.${v}.text }}`
change('fields[0].value', c.fields[0].value, to); c.fields[0].value = to
} else if (fix.code.startsWith('provider.')) {
// 点名的接入:改用选的那个,选「不指定接入」就去掉这一项;落到的接入不提供同一处写着的模型时模型一并去掉。
// 只写了模型的那一处:改用的接入写在模型旁边、模型去掉,「不指定模型」只去掉模型
const v = body.choices?.[id]
if (v === undefined) { rejected.push({ fix_id: id, reason: `这一处需要你选择:${fix.label}` }); continue }
if (!fix.options.some((o) => o.value === v)) { rejected.push({ fix_id: id, reason: `所选的「${v}」不在候选项中` }); continue }
const m = /^agents\[(\d+)\]\.(provider|model)$/.exec(fix.field)
const holder = m ? c.agents[Number(m[1])] : c
const base = fix.field.replace(/(provider|model)$/, '')
if (fix.field.endsWith('provider') || v) {
change(`${base}provider`, holder.provider ?? null, v || null)
if (v) holder.provider = v
else delete holder.provider
}
if (fix._drops.includes(v) && holder.model) {
change(`${base}model`, holder.model, null)
delete holder.model
}
} else if (fix.code === 'contract.required_missing') {
const v = body.choices?.[id]
if (!Array.isArray(v) || !v.length) { rejected.push({ fix_id: id, reason: `这一处需要你选择:${fix.label}` }); continue }
@@ -356,7 +505,7 @@ function autofix(body, { unpublished = false, followsNever = false } = {}) {
assist = { ok: true, summary: '把复核团队的轮数收紧到 2 轮;换成固定编排要删节点,按规矩没动它',
questions: ['复核团队要换成哪一个固定编排的 Agent?它现在只有一个成员 checker,保留它的职责吗?'] }
}
const remaining = lint(graph, body.level, { followsNever })
const remaining = lint(graph, body.level, { followsNever, providers })
return { graph, changes, applied, rejected, handoff, remaining: remaining.issues, assist, ok: remaining.ok, ops: [] }
}
@@ -468,7 +617,10 @@ async function open({ id = 'pf-hard', width = 1440, height = 900, backend = 'new
const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
// validate:'ok' | 'fail'(校验接口 500,后端断开时和保存一起失败)。检查中途改它,下一次校验就按新的回
const state = { checks: [], autofixes: [], patches: [], publishes: [], upgrades: [], version: FAKES[id].version, graph: clone(FAKES[id].graph), save, backend, upgrade,
validate: 'ok' }
validate: 'ok', validates: 0, deleted: [] }
// 点名接入、只写了模型的那几张图:接入表伪造,校验、门禁、修复都按它现算
const providers = ['pf-prov', 'pf-provok', 'pf-provmodel'].includes(id) ? clone(PROVIDERS) : null
state.providers = providers
await page.route(/\/api\//, (route) => {
const req = route.request()
@@ -483,6 +635,23 @@ async function open({ id = 'pf-hard', width = 1440, height = 900, backend = 'new
? json(route, { detail: '校验服务暂时无法连接' }, 500)
: json(route, { ok: true, issues: advice ? upgradeAdvice(route.request().postDataJSON()?.graph ?? { nodes: [], edges: [] }) : [] }))
}
if (providers) {
await page.route(/\/api\/workflows\/validate$/, (route) => {
state.validates += 1
const graph = route.request().postDataJSON()?.graph ?? { nodes: [], edges: [] }
return json(route, { ok: true, issues: providerLint(graph, state.providers, 'warning').issues })
})
await page.route(/\/api\/providers(\?.*)?$/, (route) => (route.request().method() === 'GET'
? json(route, state.providers) : route.fallback()))
// 设置里删接入:只改伪造的接入表
await page.route(/\/api\/providers\/pv-[a-z]+$/, (route) => {
if (route.request().method() !== 'DELETE') return route.fallback()
const pid = new URL(route.request().url()).pathname.split('/').pop()
state.deleted.push(pid)
state.providers = state.providers.filter((x) => x.id !== pid)
return json(route, { ok: true })
})
}
await page.route(/\/api\/copilot\/upgrade-evidence$/, async (route) => {
const body = route.request().postDataJSON()
state.upgrades.push(body)
@@ -520,19 +689,19 @@ async function open({ id = 'pf-hard', width = 1440, height = 900, backend = 'new
const last = state.checks.at(-1)
if (!(last && last.key === key && Date.now() - last.at < 300)) state.checks.push({ ...body, key, at: Date.now() })
if (state.backend === 'old') return json(route, { detail: 'Not Found' }, 404)
return json(route, lint(body.graph ?? state.graph, body.level, { followsNever }))
return json(route, lint(body.graph ?? state.graph, body.level, { followsNever, providers: state.providers }))
}
if (method === 'POST' && sub === 'autofix') {
const body = req.postDataJSON()
state.autofixes.push(body)
if (state.backend !== 'new') return json(route, { detail: 'Not Found' }, 404)
if (fixDelay) await new Promise((r) => setTimeout(r, fixDelay))
return json(route, autofix(body, { unpublished, followsNever }))
return json(route, autofix(body, { unpublished, followsNever, providers: state.providers }))
}
if (method === 'POST' && sub === 'publish') {
const body = req.postDataJSON()
state.publishes.push(body)
const r = lint(state.graph, body.level, { followsNever })
const r = lint(state.graph, body.level, { followsNever, providers: state.providers })
// 真后端的 /publish 回包:问题带 code、不带 fix(fix 只有 publish-check 给);老后端连 code 也没有
const issues = state.backend === 'old' ? r.issues.map(({ code: _c, fix: _f, ...rest }) => rest)
: r.issues.map(({ fix: _f, ...rest }) => rest)
@@ -1513,6 +1682,144 @@ await section('升级:?upgrade=1 直达:打开问题面板、开始预览,
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
await section('点名的模型接入:一处一行、各有一组要人选的修复,预览写「模型接入」', async () => {
const { page, state, errors } = await open({ id: 'pf-prov' })
await openDialog(page)
await waitFor(page, async () => (await dialog(page).locator('[data-preflight-issue]').count()) > 0)
const rows = dialog(page).locator('[data-preflight-issue^="provider."]')
check('三处点名的接入各一行:协作节点自己的接入和成员的接入不合成一行', await rows.count() === 3, `${await rows.count()} 行`)
const summary = await dialog(page).locator('[data-preflight-summary]').innerText().catch(() => '')
check('……结论按三处算:3 处问题将被门禁拦截', summary.includes('3 处问题将被门禁拦截'), summary)
const member = dialog(page).locator('[data-fix-choice="provider.missing:team:agents[0].provider"]')
const memberText = await member.innerText().catch(() => '')
const askText = await dialog(page).locator('[data-fix-choice="provider.missing:ask:provider"]').innerText().catch(() => '')
check('每一处一组选项:启用的接入在、停用的不在候选里', await dialog(page).locator('[data-fix-choice^="provider."]').count() === 3
&& memberText.includes('demo-main') && !memberText.includes('demo-off') && askText.includes('demo-main') && !askText.includes('demo-off'),
`${askText} | ${memberText}`)
check('……「不指定接入」写清执行时改用哪里、写着的模型一并去掉', askText.includes('不指定接入')
&& askText.includes('去掉这一处指定的接入和模型「old-model」,执行时改用第一个可用的模型接入「demo-main」'), askText)
check('……成员去掉接入会跟随节点上停用的 demo-off:不给这一项,只剩启用的接入',
await member.locator('input[type="radio"]').count() === 1 && !memberText.includes('不指定接入'), memberText)
check('……标题说清是哪一处', memberText.includes('选择「协作」(多 Agent 协作)的成员「分析师」改用的模型接入'), memberText)
check('……不替人选', await member.locator('input:checked').count() === 0)
await member.locator('label', { hasText: 'demo-main' }).click()
await member.locator('[data-fix-action="choice"]').click()
await dialog(page).locator('[data-fix-preview="ready"]').waitFor()
check('预览请求只修这一处,带着选的接入', JSON.stringify(state.autofixes.at(-1)?.apply) === '["provider.missing:team:agents[0].provider"]'
&& state.autofixes.at(-1)?.choices?.['provider.missing:team:agents[0].provider'] === 'demo-main')
const line = await dialog(page).locator('[data-fix-change]').first().innerText().catch(() => '')
check('预览写「「协作」 · 团队成员 · 「分析师」 · 模型接入:demo-old → demo-main」,不露键名',
/「协作」\s*·\s*团队成员 · 「分析师」 · 模型接入:\s*demo-old\s*demo-main/.test(line) && !/provider/.test(line), line)
await dialog(page).locator('[data-fix-apply]').click()
await waitFor(page, async () => state.patches.length === 1 && state.checks.length >= 2)
const team = cfgOf(state.patches[0]?.graph, 'team')
check('应用:成员改用 demo-main,节点自己的接入不动', team?.agents?.[0]?.provider === 'demo-main' && team?.provider === 'demo-off',
JSON.stringify(team ?? {}).slice(0, 120))
await waitFor(page, async () => (await dialog(page).locator('[data-preflight-issue^="provider."]').count()) === 2)
check('……重查之后剩两处', await dialog(page).locator('[data-preflight-issue^="provider."]').count() === 2)
await shoot(page, 'publish-provider-fix', dialog(page))
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
await section('点名的模型接入:问题面板写「模型接入」,不露键名', async () => {
const { page, errors } = await open({ id: 'pf-prov' })
await page.locator('.react-flow__pane').click({ position: { x: 30, y: 30 } })
await page.keyboard.press('Alt+KeyP')
await page.locator('#dock-problems').waitFor()
await waitFor(page, async () => (await page.locator('#dock-problems [data-problem-field]').count()) >= 3)
const fields = await page.locator('#dock-problems [data-problem-field]').allInnerTexts()
check('字段写「模型接入」「成员「分析师」的模型接入」', fields.filter((t) => t.trim() === '模型接入').length === 2
&& fields.some((t) => t.trim() === '成员「分析师」的模型接入'), fields.join(' | '))
check('……没有一处露出键名 provider', !(await page.locator('#dock-problems').innerText()).includes('provider'))
await shoot(page, 'problems-provider', page.locator('section[aria-label="问题"]'))
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
await section('只写了模型、提供它的接入已停用:问题面板写「模型」,修复给「不指定模型」,预览不露键名', async () => {
const { page, state, errors } = await open({ id: 'pf-provmodel' })
await page.locator('.react-flow__pane').click({ position: { x: 30, y: 30 } })
await page.keyboard.press('Alt+KeyP')
await page.locator('#dock-problems').waitFor()
await waitFor(page, async () => (await page.locator('#dock-problems [data-problem-field]').count()) >= 2)
const fields = await page.locator('#dock-problems [data-problem-field]').allInnerTexts()
check('字段写「模型」「成员「分析师」的模型」(检索、成员的检查器里都没有模型这一栏)',
fields.some((t) => t.trim() === '模型') && fields.some((t) => t.trim() === '成员「分析师」的模型'), fields.join(' | '))
const panel = await page.locator('#dock-problems').innerText()
check('……说清是哪个模型、哪个接入停用了,没有一处露出键名 model', panel.includes('「检索」(知识检索)指定的模型「tiny-1」所属的模型接入「demo-off」已停用')
&& !/\bmodel\b/.test(panel), panel.slice(0, 160))
await shoot(page, 'problems-provider-model', page.locator('section[aria-label="问题"]'))
await openDialog(page)
const look = dialog(page).locator('[data-fix-choice="provider.disabled:look:model"]')
await look.waitFor()
const lookText = await look.innerText().catch(() => '')
check('修复:「不指定模型」写清执行时改用哪里,启用的接入写清换成它的哪个模型', lookText.includes('不指定模型')
&& lookText.includes('去掉这一处指定的模型,执行时改用第一个可用的模型接入「demo-main」')
&& lookText.includes('不提供模型「tiny-1」,将改用它的默认模型「big-model」') && !lookText.includes('demo-off'), lookText)
// 「不指定模型」的说明里也写着 demo-main:按选项文字开头认
await look.locator('label').filter({ hasText: /^demo-main/ }).click()
await look.locator('[data-fix-action="choice"]').click()
await dialog(page).locator('[data-fix-preview="ready"]').waitFor()
const lines = await dialog(page).locator('[data-fix-change]').allInnerTexts()
check('预览两行:写上接入、去掉模型,都写叫法', lines.length === 2 && /「检索」\s*·\s*模型接入:/.test(lines[0]) && lines[0].includes('demo-main')
&& /「检索」\s*·\s*模型:\s*tiny-1/.test(lines[1]) && !lines.some((t) => /provider|model\b/.test(t)), lines.join(' | '))
await dialog(page).locator('[data-fix-apply]').click()
await waitFor(page, async () => state.patches.length === 1)
const saved = cfgOf(state.patches[0]?.graph, 'look')
check('应用:检索改用 demo-main、去掉停用接入的模型', saved?.provider === 'demo-main' && !('model' in (saved ?? {})), JSON.stringify(saved ?? {}))
await shoot(page, 'publish-provider-model', dialog(page))
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
await section('点名的模型接入:在设置里删了接入,回到画布重新校验', async () => {
const { page, state, errors } = await open({ id: 'pf-provok' })
const providerIssues = () => page.evaluate(() => window.__studio.getState().issues
.filter((i) => String(i.code ?? '').startsWith('provider.')).map((i) => i.message))
await waitFor(page, async () => state.validates > 0)
check('起初点名的接入启用着:没有接入问题', (await providerIssues()).length === 0)
const before = state.validates
await page.locator('nav[aria-label="主导航"] a[href="/settings"]').click()
await page.locator('[data-provider="demo-main"]').waitFor()
await page.getByRole('button', { name: '删除模型接入 demo-main' }).click()
await page.getByRole('button', { name: '删除接入' }).click()
await waitFor(page, async () => state.deleted.length === 1)
await page.waitForTimeout(400)
await page.evaluate(() => history.back())
await waitFor(page, async () => (await providerIssues()).length > 0, 6000)
const said = await providerIssues()
check('回到画布:重新校验,查出「demo-main」不存在', state.validates > before && said.some((m) => m.includes('「demo-main」不存在')),
`${before} → ${state.validates} · ${said.join(' | ').slice(0, 100)}`)
// 接入表没再变:来回切页不重复校验
const settled = state.validates
await page.locator('nav[aria-label="主导航"] a[href="/settings"]').click()
await page.locator('[data-provider="demo-off"]').waitFor()
await page.evaluate(() => history.back())
await page.waitForFunction(() => window.location.pathname.startsWith('/studio/'))
await page.waitForTimeout(1200)
check('接入表没变:切回来不重复校验', state.validates === settled, `${settled} → ${state.validates}`)
check('没有运行时报错', errors.length === 0, errors.join(' | '))
})
await section('点名的模型接入:390px 下修复选项不伸出弹窗', async () => {
const { page } = await open({ id: 'pf-prov', width: 390, height: 820 })
await page.getByRole('button', { name: '更多操作' }).click()
await page.locator('[role="menu"][aria-label="更多操作"] [role^="menuitem"]', { hasText: '发布' }).click()
await dialog(page).waitFor()
await dialog(page).locator('[data-fix-choice^="provider."]').first().waitFor()
const m = await page.evaluate(() => {
const d = document.querySelector('[role="dialog"]')
const wide = [...d.querySelectorAll('*')].filter((el) => {
const b = el.getBoundingClientRect()
return b.width > 0 && (b.right > window.innerWidth + 0.5 || b.left < -0.5)
}).map((el) => el.tagName + (el.className ? `.${String(el.className).split(' ')[0]}` : ''))
const scrollers = [...d.querySelectorAll('*')].filter((el) => el.scrollWidth > el.clientWidth + 1
&& !['visible', 'hidden', 'clip'].includes(getComputedStyle(el).overflowX)).length
return { wide: wide.slice(0, 4), scrollers, own: d.scrollWidth - d.clientWidth }
})
check('390px:几组接入选项和长标题都在弹窗里,没有横向滚动条', !m.wide.length && m.scrollers === 0 && m.own <= 0, JSON.stringify(m))
await shoot(page, 'publish-provider-390', page)
})
await section('360px 下不横向滚动', async () => {
const { page } = await open({ width: 360, height: 780 })
// 窄屏上「发布」收在「更多操作」里