mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 02:31:58 +00:00
合入期 5 前置修复 K1:点名的模型接入不存在或已停用时照实报错,不再悄悄换成演示模型——各条用到模型的路径照实失败或记为核查不可用;保存设置时 422、画布校验给警告、发布与发布前检查挡住并给修复候选(不指定模型、改用启用的接入),只写模型而提供它的接入已停用也查得出
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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:设置页整组回写时带着一份旧的,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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 []
|
||||
@@ -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"
|
||||
|
||||
@@ -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"] == []
|
||||
@@ -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}。"
|
||||
"请在「设置 → 模型接入」中启用它,或在节点中换一个模型")
|
||||
@@ -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)
|
||||
@@ -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。请到「设置 → 模型接入」检查")
|
||||
@@ -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},
|
||||
|
||||
@@ -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」一眼就知道是谁
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
* 一键升级为可追溯结构(问题面板的快速修复、记录页的横幅)。和发布前修复同一套规矩:
|
||||
* 只出预览,人点「应用」才走现有的保存存成草稿;不发布
|
||||
|
||||
@@ -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'])
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 的「它在做计算」)。只认这个标记,不认值——
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
// 窄屏上「发布」收在「更多操作」里
|
||||
|
||||
Reference in New Issue
Block a user