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

    {TOOL_GATE_TEXT.hint}

    - + {(p) => ( )} + hint={gateProvider?.enabled && gateProvider.default_model ? `留空则使用 ${gateProvider.default_model}` : undefined}> {(p) => ( <> {JUDGE_SETTING_TEXT.differ}

    )}
    - {judgeByModelHint} @@ -986,14 +1013,12 @@ function PrefsTab() { )} + hint={judgeProvider?.enabled && judgeProvider.default_model ? `留空则使用 ${judgeProvider.default_model}` : undefined}> {(p) => ( <> )} - {values.verify && } + {values.verify && } )} @@ -1076,11 +1102,14 @@ function PrefsTab() { {saveError != null && ( - 保存失败:{humanizeError(saveError).title} + + {/* 连同原因:服务端的说明常是两三句,只留标题会把「请在……中启用它」这类下一步截掉 */} + 保存失败:{errorMessage(saveError)} + )} + onClick={() => { setDraft(null); setVerifyDraft(null); setSaveError(null); setFieldError(null) }}>放弃