From 7255ae98a574dfafca36760851ba40f2a21f2e78 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 19:43:44 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=8E=A5=E5=85=A5=E6=8A=A5?= =?UTF-8?q?=E9=94=99=E6=94=B6=E5=B0=BE=EF=BC=88=E7=AC=AC=201=20=E8=BD=AE?= =?UTF-8?q?=E5=A4=8D=E6=A0=B8=EF=BC=8C=E5=90=8E=E7=AB=AF=EF=BC=89=EF=BC=9A?= =?UTF-8?q?=E4=BA=A4=E7=BB=99=E8=AE=BE=E7=BD=AE=E7=9A=84=E6=A0=B8=E6=9F=A5?= =?UTF-8?q?=E3=80=81=E8=A3=81=E5=88=A4=E8=90=BD=E5=88=B0=E6=BC=94=E7=A4=BA?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E7=85=A7=E5=AE=9E=E8=AF=B4=EF=BC=9B=E7=BB=99?= =?UTF-8?q?=E4=B8=8D=E5=87=BA=E4=BF=AE=E5=A4=8D=E6=97=B6=E8=AF=B4=E5=8D=A1?= =?UTF-8?q?=E5=9C=A8=E5=93=AA=E9=87=8C=EF=BC=9B=E6=8E=A5=E5=85=A5=E5=90=8D?= =?UTF-8?q?=E6=81=B0=E5=A5=BD=E6=98=AF=E6=A8=A1=E5=9E=8B=E5=90=8D=E9=82=A3?= =?UTF-8?q?=E5=8F=A5=E6=8C=89=E8=BF=99=E4=B8=80=E5=A4=84=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E5=99=A8=E9=87=8C=E6=9C=89=E5=93=AA=E5=87=A0=E6=A0=8F=E5=86=99?= =?UTF-8?q?=EF=BC=9B=E4=BF=AE=E5=A4=8D=E8=A7=84=E5=88=92=E5=8F=AA=E9=87=8D?= =?UTF-8?q?=E7=AE=97=E8=A2=AB=E6=94=B9=E7=9A=84=E8=8A=82=E7=82=B9=EF=BC=9B?= =?UTF-8?q?=E5=8A=A9=E6=89=8B=E6=A0=8F=E9=82=A3=E4=B8=80=E6=A0=BC=E5=8F=A6?= =?UTF-8?q?=E7=BB=99=E4=B8=80=E4=BB=BD=E8=AF=B4=E6=B3=95=EF=BC=9B=E5=87=A0?= =?UTF-8?q?=E5=A4=84=E6=8E=AA=E8=BE=9E=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「不指定接入」交给设置(核查模型、裁判模型)时:启用的接入全是演示类,设置这一级不管写的是什么,执行时落到的都是 演示模型,说明照实带上「(演示用,不调用真实模型)」、候选带 demo 标记;一个启用的接入都没有时不算落得稳(同别处 「换成内置演示模型」不算)。以前 land() 在判演示之前就把交给设置的一处当成落稳返回了; - 检查器里没有下拉框、发布前检查又给不出修复时,提示末尾补「无法改用其他接入:」加第一处落不稳的地方(Landing.why, 比如「成员「研究员」指定的模型「小模型」所属的模型接入「旧接入」已停用」),不再只剩「核对名称」让人去设置里找一个 找不到的名字; - 点名的接入名恰好是某个启用接入的模型:画布、发布前检查按这一处在检查器里有哪几栏写(provider_refs._model_note)—— 模型调用一类在节点的「模型」中选择它,接入在发布前检查里改用这个接入或选择「不指定接入」(修复里有哪项说哪项); 协作成员、全图默认只说可在发布前检查里改用;核查节点、报告自己的结论句裁判两栏都在,照设置页写这两栏的叫法。 不再叫人去一个没有的「接入」栏留空。运行时报错不分节点类型,「填写」改为「选择它」; - 修复候选修完以后落到演示类接入的带 demo 标记,发布弹窗据此在选项名旁加醒目的标签; - 性能:改一个节点的配置只影响它自己取模型的那几处,修复规划(autofix._ProviderPlan)每试一个候选只重算这个节点 (provider_refs.node_uses),不再深拷贝、重算整张图;问题提示只问给不给得出(provider_fixable,找到第一个落得稳的 就停);接入问题的引用整张图只算一遍。60 个节点都点名同一个停用接入时,画布校验里这一部分约 0.35 秒降到约 0.002 秒, 发布前检查约 0.72 秒降到约 0.01 秒,一次应用 30 条修复约 2.7 秒降到约 0.09 秒; - 助手:GET /api/copilot/model 另给 panel_error(写在助手栏「助手使用的模型」那一格下面,去向写「上方」,不把人指回这个 下拉框本身);接入名恰好是模型名时并成一句,不再两次提「助手使用的模型」;PUT /api/settings 里助手那一项的 422 用 助手报错那一句(那里只有一个选模型的下拉框,没有「换一个接入」的地方); - 裁判设置的格式检查写「留空表示跟随助手的模型」,同设置页空选项(engine/judge.py 一行,同核查那条的改法);「交给助手」 后只剩接入问题时写「助手无法处理」,有修复时另起半句「也可在各条问题下方选择改用的模型接入」。 测试:test_provider_ref_wording 加演示落在设置上、demo 标记、给不出修复时卡在哪里(含一个启用接入都没有)、各处的 接入名恰好是模型名、助手栏那一格的说法、助手那一项的 422、裁判格式检查、规划开销随节点数线性增长(数试算时算了多少处 取模型的地方);其余几个文件跟上新说法。新加的核心用例都做过去掉实现、测试变红的验证。 --- backend/app/api/copilot.py | 8 +- backend/app/api/settings.py | 17 +- backend/app/api/workflows.py | 10 +- backend/app/engine/autofix.py | 228 ++++++++++++------- backend/app/engine/judge.py | 3 +- backend/app/engine/provider_refs.py | 183 ++++++++++----- backend/app/providers/factory.py | 55 +++-- backend/tests/test_provider_ref_checks.py | 12 +- backend/tests/test_provider_ref_landing.py | 5 +- backend/tests/test_provider_ref_wording.py | 251 ++++++++++++++++++--- backend/tests/test_provider_resolution.py | 2 +- 11 files changed, 557 insertions(+), 217 deletions(-) diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index 3a937ed1..8f75dd9f 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -525,9 +525,10 @@ async def get_copilot_model(session: AsyncSession = Depends(get_session)) -> dic """当前 Copilot 会用哪个模型,以及是显式配的还是兜底来的。 存的接入不存在、已停用(或者只存了模型名、提供它的接入已停用)时实际什么都用不了:effective_* 为空, - error 写明原因(和生成时报的同一句),能用时 error 为 None。""" + error 写明原因(和生成时报的同一句),能用时 error 为 None。panel_error 是同一件事写在助手栏「助手使用的模型」 + 那一格下面的说法:人就在这个下拉框旁边,去向写「上方」,不再把人指回这个下拉框本身。""" from app.db.models import Setting - from app.providers.factory import resolve_provider + from app.providers.factory import assistant_unavailable, resolve_provider row = await session.get(Setting, COPILOT_SETTING_KEY) saved = row.value if row else {} @@ -537,7 +538,7 @@ async def get_copilot_model(session: AsyncSession = Depends(get_session)) -> dic except ProviderNotConfigured as e: return {"configured": configured, "provider": saved.get("provider") or None, "model": saved.get("model") or None, "effective_provider": None, "effective_model": None, - "error": _unconfigured(e)} + "error": _unconfigured(e), "panel_error": assistant_unavailable(e, here=True)} return { "configured": configured, "provider": saved.get("provider") or None, @@ -545,6 +546,7 @@ async def get_copilot_model(session: AsyncSession = Depends(get_session)) -> dic "effective_provider": resolved.name if resolved else None, "effective_model": saved.get("model") or (resolved.default_model if resolved else None), "error": None, + "panel_error": None, } diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index cef84e46..9a4ee8ad 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -23,7 +23,7 @@ from app.engine.judge import sanitize_settings as judge_values, settings_problem from app.engine.spend import SPEND_KEY as VERIFY_SPEND, daily_spend as verify_daily_spend from app.providers import catalog from app.providers.factory import ( - ModelSpec, ProviderNotConfigured, _resolve_key, assistant_model_note, build_chat_model, + ModelSpec, ProviderNotConfigured, _resolve_key, assistant_unavailable, build_chat_model, list_providers as all_providers, model_note, named_provider, ) @@ -732,8 +732,8 @@ PROVIDER_INVALID = "settings_provider_invalid" def _provider_fields() -> dict[tuple[str, str], tuple[str, str | None]]: """设置里点名模型接入的项:(组, 键) → (这一项在界面上的叫法, 同一组模型那一项的叫法)。门控、裁判、核查的叫法和 - 设置页一致;助手的模型由「编排」页助手栏的「助手使用的模型」单独设定(PUT /api/copilot/model),只有这一个下拉框, - 这里只防经设置接口写进去的那一份。""" + 设置页一致;助手的模型由「编排」页助手栏的「助手使用的模型」单独设定(PUT /api/copilot/model),只有这一个下拉框 + (模型那一项为 None),这里只防经设置接口写进去的那一份。""" from app.api.copilot import COPILOT_SETTING_KEY # 在这里导入:copilot 模块很大,设置模块被引擎各处导入 from app.providers.factory import ASSISTANT_MODEL_FIELD @@ -747,7 +747,9 @@ def _provider_fields() -> dict[tuple[str, str], tuple[str, str | None]]: async def _provider_problem(session: AsyncSession, values: dict[str, Any]) -> tuple[str, str] | None: """这次写进来的设置里点名的模型接入有没有问题:(字段, 说明),没问题返回 None。说明和运行时报的原因、提示同一句 - (factory.named_provider),主语写这一项在设置页上的叫法;下拉框里选的,写「指定的」。 + (factory.named_provider),主语写这一项在设置页上的叫法;下拉框里选的,写「指定的」。助手那一项只有「编排」页 + 助手栏一个下拉框(只选模型,选了就清掉接入),没有「换一个接入」的地方:说法同助手取不到模型时那一句 + (factory.assistant_unavailable),哪里报都一样。 以前点名的接入不存在时运行悄悄换成演示模型,名字写错一个字就由演示模型写报告、做核查,现在运行时照实报错;保存时 先拦一道。留空照旧表示沿用上一级——说明里不再讲留空是什么意思:设置页那一格的空选项写着(「默认接入」「跟随助手 @@ -775,11 +777,12 @@ async def _provider_problem(session: AsyncSession, values: dict[str, Any]) -> tu try: named_provider(providers, name) except ProviderNotConfigured as e: + if model_label is None: + return f"{group}.{key}", assistant_unavailable(e) said = f"「{label}」指定的{e.reason}。{e.hint}" if e.model_owner: - note = model_note(e.name, e.model_owner, f"「{model_label}」", f"「{label}」") if model_label \ - else assistant_model_note(e.name, e.model_owner) - said += f"。{note}" + # 这几组的模型一栏是输入框:写「填写」 + said += f"。{model_note(e.name, e.model_owner, f'「{model_label}」', f'「{label}」', '填写')}" return f"{group}.{key}", said return None diff --git a/backend/app/api/workflows.py b/backend/app/api/workflows.py index 3f252bf5..69703cf3 100644 --- a/backend/app/api/workflows.py +++ b/backend/app/api/workflows.py @@ -23,11 +23,13 @@ _GONE = "工作流不存在,可能已被删除" _LEVELS = ("published", "governed") _BAD_LEVEL = "发布级别只能是「已发布」或「受管」" #: 剩下的挡住发布的只有模型接入的问题、人又要交给助手时的说明:换成哪个接入要人拿主意,助手处理不了。发布弹窗里 -#: 渲染成「助手:……」,说明里不再带冒号;可能是全图默认、也可能只写了模型而提供它的接入停用了,不说「节点」「点名」 -PROVIDER_LEFT = ("剩余的问题是所用的模型接入不存在或已停用,无法交给助手处理。" +#: 渲染成「助手:……」,说明里不再带冒号;可能是全图默认、也可能只写了模型而提供它的接入停用了,不说「节点」「点名」。 +#: 人刚点了「交给助手」,写「助手无法处理」,不写「无法交给助手处理」(读来像没交出去) +PROVIDER_LEFT = ("剩余的问题是所用的模型接入不存在或已停用,助手无法处理。" "请在「设置 → 模型接入」中核对名称或启用相应的接入") -#: 剩下的问题里有一条带着修复(provider_refs 按候选算得出来)才补这半句:一个候选都落不稳时那里什么都不给 -PROVIDER_LEFT_FIX = ",或在各条问题的修复中改用其他接入" +#: 剩下的问题里有一条带着修复(provider_refs 按候选算得出来)才补这半句:一个候选都落不稳时那里什么都不给。弹窗里那一组 +#: 选项在每条问题下方,标题是「选择……改用的模型接入」;另起半句,不和前面的「或」连用 +PROVIDER_LEFT_FIX = ";也可在各条问题下方选择改用的模型接入" class WorkflowIn(BaseModel): diff --git a/backend/app/engine/autofix.py b/backend/app/engine/autofix.py index 9ab207cb..2173e469 100644 --- a/backend/app/engine/autofix.py +++ b/backend/app/engine/autofix.py @@ -19,7 +19,7 @@ from __future__ import annotations import copy import re -from typing import Any, Callable +from typing import Any, Callable, Iterator from pydantic import BaseModel @@ -94,6 +94,16 @@ class _Ctx: self.level = level #: 模型接入表(provider_refs.load_providers):点名的接入出了问题时,候选从里面启用的那些里挑 self.providers = providers or [] + self._provider_refs: dict[tuple[str | None, str], Any] | None = None + + def provider_ref(self, node_id: str | None, field: str) -> Any: + """图里写着接入或模型的那一处(provider_refs.ProviderRef)。整张图只算一遍:每条接入问题都去扫一遍全图, + 几十处同时坏掉时就是几十遍。""" + if self._provider_refs is None: + from app.engine.provider_refs import provider_refs + + self._provider_refs = {(r.node_id, r.field): r for r in provider_refs(self.spec)} + return self._provider_refs.get((node_id, field)) def upstream(self, node_id: str, *types: NodeType) -> list[GraphNode]: """这个节点上游的某几类节点,按画布上的节点顺序。""" @@ -518,125 +528,169 @@ def _landing_text(landed: list[tuple[Any, Any]]) -> str: def _plan_provider(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: """执行时取不到模型的那一处(engine/provider_refs.py):改用一个启用的接入,或者不再指定(provider_choice)。""" - from app.engine.provider_refs import provider_refs - - 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) + ref = ctx.provider_ref(issue.get("node_id"), str(issue.get("field") or "")) return provider_choice(ctx.spec, ref, ctx.providers) if ref is not None else None -def provider_choice(spec: GraphSpec, ref: Any, providers: list[Any]) -> dict[str, Any] | None: - """图里一处点名的接入(provider_refs.ProviderRef)的修复:要人选的 choice(_edits 记每个候选怎么改),一个候选都 - 落不稳时为 None。provider_refs 拿它判断问题的提示指不指到发布前检查——给不出修复就别让人去那里找。 +class _ProviderPlan: + """图里一处点名的接入(provider_refs.ProviderRef)的修复候选,一个一个算:provider_choice 要全部,provider_fixable + (问题提示指不指到发布前检查)找到第一个落得稳的就停。 点名的接入不存在、已停用的,候选是「不指定接入」和各个启用的接入;只写了模型、提供它的接入已停用的,是「不指定 模型」和各个启用的接入(写在模型旁边的接入上)。 - 要人选、不给默认:换成哪个接入只有人知道(同一个模型可能有两家提供,价格、配额都不同)。多数节点的检查器里没有 - 接入下拉框(只能选模型),全图默认、协作成员也没有,这里是界面上唯一能改它的地方。一处一条(plan_fixes 的 id - 带字段):协作节点自己的接入和成员的接入各修各的。 - 每个候选先在副本上改,按执行时的取法(provider_refs.land)算出改动波及的每一处落到哪里:去掉以后成员跟随节点、 节点跟随全图默认,都没写按模型名找;协作节点、全图默认换了接入,没写自己接入的成员、节点跟着换。落到的接入不提供 那一处的模型、按模型名落到停用的接入或认不出的模型时,同一级写着的模型一并去掉再算——拿 A 家的模型名去调 B 家 - 必然失败(设置页换接入时同一个做法);还落不稳的候选不给,修完了运行照样失败的修复不能交给人。候选的说明写清执行 - 时改用哪里、模型去没去掉,落到演示类接入的照实说。复核(judge)另把一道关:门禁同样查只写了模型、提供它的接入 - 已停用的那一处。 + 必然失败(设置页换接入时同一个做法);还落不稳的候选不给,修完了运行照样失败的修复不能交给人。 只否决这次改动造成的失败:改动波及的一处要是点名的接入没变、而那个接入本来就不存在或已停用(比如节点点名的接入 写错了,这次改的是它跟随的全图默认模型),它落不稳不是这次改动的事,它自己那条问题照样在、照样拦着发布。拿它否决 的话,两处互相牵连时谁都给不出修复,规划器自己卡住。 - """ - from app.engine.provider_refs import land, uses - from app.providers.factory import ProviderNotConfigured, named_provider, serves - field = ref.field - node = spec.node_map().get(ref.node_id) if ref.node_id is not None else 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 = 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(spec)} + 改的是节点上的一处时只重算这个节点(provider_refs.node_uses:节点只读自己的配置和全图默认),不复制、不重算整张图; + 改的是全图默认时各节点都可能跟着变,整张图重算(这样的引用最多几处)。""" - def broken_before(use: Any) -> bool: + def __init__(self, spec: GraphSpec, ref: Any, providers: list[Any]) -> None: + from app.engine.provider_refs import node_uses, uses + + self.spec, self.ref, self.providers = spec, ref, providers + path = ref.field.removeprefix("defaults.") if ref.node_id is None else ref.field + if ref.kind == "model": + self.model_path, self.provider_path = path, path[: -len("model")] + "provider" + else: + self.provider_path, self.model_path = path, _model_path(path) + self.prefix = "defaults." if ref.node_id is None else "" + self.node = spec.node_map()[ref.node_id] if ref.node_id is not None else None + model = _read_path(spec.defaults if self.node is None else self.node.config, self.model_path) + self.model = model.strip() if isinstance(model, str) and model.strip() else None + self.before = {use.key: use for use in (uses(spec) if self.node is None else node_uses(spec, self.node))} + #: 一个候选都落不稳时说给人听的:第一个候选最后一次试算里第一处落不稳的地方(provider_refs.Landing.why) + self.stuck = "" + + def _broken_before(self, use: Any) -> bool: """这一处点名的接入改动前后没变,而它本来就不存在或已停用:落不稳不是这次改动造成的。""" - was = before.get(use.key) + from app.providers.factory import ProviderNotConfigured, named_provider + + was = self.before.get(use.key) if was is None or use.provider is None or use.provider != was.provider: return False try: - named_provider(providers, use.provider.name) + named_provider(self.providers, use.provider.name) except ProviderNotConfigured: return True return False - def trial(edits: list[tuple[str, Any]]) -> list[tuple[Any, Any]] | None: - """在副本上改,改动波及的每一处(取法变了的)执行时落到哪里;有一处因这次改动落不稳返回 None。""" - copied = spec.model_copy(deep=True) - target = copied.defaults if ref.node_id is None else copied.node_map()[ref.node_id].config - for at, value in edits: - _put_path(target, at, value) - landed = [] - for use in uses(copied): - if before.get(use.key) == use: - continue - if not (landing := land(use, providers)).ok: - if broken_before(use): - continue - return None - landed.append((use, landing)) - # 改完没有一处落到新地方:这一处没修好(比如它跟随的那一级也坏着、原因还一样) - return landed or None + def _trial(self, edits: list[tuple[str, Any]]) -> tuple[list[tuple[Any, Any]] | None, Any]: + """在副本上改,改动波及的每一处(取法变了的)执行时落到哪里:(各处落到哪里,None);有一处因这次改动落不稳 + 时是(None,那一处的 Landing)。改完没有一处落到新地方也算没修好(比如它跟随的那一级也坏着、原因还一样)。""" + from app.engine.provider_refs import land, node_uses, uses - def settle(edits: list[tuple[str, Any]]) -> tuple[list[tuple[str, Any]], list[tuple[Any, Any]]] | None: + if self.node is None: + defaults = copy.deepcopy(self.spec.defaults) + for at, value in edits: + _put_path(defaults, at, value) + # 浅拷贝:节点原样共用,只换全图默认(uses 只读不写) + after = uses(self.spec.model_copy(update={"defaults": defaults})) + else: + twin = self.node.model_copy(deep=True) + for at, value in edits: + _put_path(twin.config, at, value) + after = node_uses(self.spec, twin) + landed = [] + for use in after: + if self.before.get(use.key) == use: + continue + if not (landing := land(use, self.providers)).ok: + if self._broken_before(use): + continue + return None, landing + landed.append((use, landing)) + return landed or None, None + + def _settle(self, edits: list[tuple[str, Any]]) -> tuple[list[tuple[str, Any]], list[tuple[Any, Any]]] | None: """先只改这一处;落不稳而同一级写着模型时,连模型一并去掉再试。返回(改法,波及的各处落到哪里),还落不稳为 None。""" - if (landed := trial(edits)) is not None: + landed, failed = self._trial(edits) + if landed is None and self.model and all(at != self.model_path for at, _ in edits): + edits = [*edits, (self.model_path, None)] + landed, failed = self._trial(edits) + if landed is not None: return edits, landed - if model and all(at != model_path for at, _ in edits): - edits = [*edits, (model_path, None)] - if (landed := trial(edits)) is not None: - return edits, landed + if not self.stuck and failed is not None: + self.stuck = failed.why return None + def options(self) -> Iterator[tuple[dict[str, Any], list[tuple[str, Any]]]]: + """落得稳的候选(选项,改法),先「不指定」,再按接入表的顺序各个启用的接入。""" + from app.providers.factory import serves + + ref, model = self.ref, self.model + if (got := self._settle([(self.model_path if ref.kind == "model" else self.provider_path, None)])) is not None: + edits, landed = got + what = "模型" if ref.kind == "model" else "接入" + (f"和模型「{model}」" if len(edits) > 1 else "") + option = {"value": PROVIDER_CLEAR, "label": f"不指定{'模型' if ref.kind == 'model' else '接入'}", + "hint": f"去掉这一处指定的{what},执行时{_landing_text(landed)}"} + yield _demo_mark(option, landed), edits + seen = {PROVIDER_CLEAR} + for p in self.providers: + if not getattr(p, "enabled", False) or not getattr(p, "name", None) or p.name in seen: + continue + seen.add(p.name) + if (got := self._settle([(self.provider_path, p.name)])) is None: + continue + edits, landed = got + notes = [] + if len(edits) > 1: + # 这一处原来用的就是去掉的那个模型:换上的接入实际用哪个模型(多半是它的默认模型,上一级写了模型就是那个) + mine = {landing.model for use, landing in landed + if (was := self.before.get(use.key)) is not None and was.model is not None + and (was.model.node_id, was.model.field) == (ref.node_id, self.prefix + self.model_path)} + used = next(iter(mine)) if len(mine) == 1 else None + said = [f"不提供模型「{model}」" if not serves(p, model) else f"模型「{model}」一并去掉"] + if used: + said.append(f"将改用{'它的默认模型' if used == p.default_model else '模型'}「{used}」") + elif not serves(p, model): + said.append("这一处的模型一并去掉") + notes.append(",".join(said)) + if getattr(p, "kind", None) == "mock": + notes.append(DEMO_PROVIDER) + yield _demo_mark({"value": p.name, "label": p.name, "hint": ";".join(notes) or None}, landed), edits + + +def _demo_mark(option: dict[str, Any], landed: list[tuple[Any, Any]]) -> dict[str, Any]: + """修完以后有一处落到演示类接入(provider_refs.Landing.demo):候选带上 demo 标记,发布弹窗在选项名旁加一个 + 醒目的「演示模型」标签。说明里那半句演示说明照旧写,只是淡色小字、排在长句末尾,不够醒目——K1 要防的就是 + 悄悄换成演示模型。""" + return {**option, "demo": True} if any(landing.demo for _, landing in landed) else option + + +def provider_choice(spec: GraphSpec, ref: Any, providers: list[Any]) -> dict[str, Any] | None: + """图里一处点名的接入(provider_refs.ProviderRef)的修复:要人选的 choice(_edits 记每个候选怎么改),一个候选都 + 落不稳时为 None。候选怎么算见 _ProviderPlan。 + + 要人选、不给默认:换成哪个接入只有人知道(同一个模型可能有两家提供,价格、配额都不同)。多数节点的检查器里没有 + 接入下拉框(只能选模型),全图默认、协作成员也没有,这里是界面上唯一能改它的地方。一处一条(plan_fixes 的 id + 带字段):协作节点自己的接入和成员的接入各修各的。候选的说明写清执行时改用哪里、模型去没去掉,落到演示类接入的 + 照实说并带 demo 标记。复核(judge)另把一道关:门禁同样查只写了模型、提供它的接入已停用的那一处。""" options: list[dict[str, Any]] = [] plans: dict[str, list[tuple[str, Any]]] = {} - if (got := settle([(model_path if ref.kind == "model" else provider_path, None)])) is not None: - edits, landed = got - what = "模型" if ref.kind == "model" else "接入" + (f"和模型「{model}」" if len(edits) > 1 else "") - options.append({"value": PROVIDER_CLEAR, "label": f"不指定{'模型' if ref.kind == 'model' else '接入'}", - "hint": f"去掉这一处指定的{what},执行时{_landing_text(landed)}"}) - plans[PROVIDER_CLEAR] = edits - for p in 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(DEMO_PROVIDER) - options.append({"value": p.name, "label": p.name, "hint": ";".join(notes) or None}) - plans[p.name] = edits + for option, edits in _ProviderPlan(spec, ref, providers).options(): + options.append(option) + plans[option["value"]] = edits if not options: - # 一个都落不稳(比如一个启用的接入都没有):不给修复,问题照样拦着发布,提示里只说去设置里核对、启用 + # 一个都落不稳(比如一个启用的接入都没有):不给修复,问题照样拦着发布,提示里说去设置里核对、启用、卡在哪里 return None - return _choice(f"选择{ref.who}改用的模型接入", field, options, _edits=plans) + return _choice(f"选择{ref.who}改用的模型接入", ref.field, options, _edits=plans) + + +def provider_fixable(spec: GraphSpec, ref: Any, providers: list[Any]) -> tuple[bool, str]: + """这一处给不给得出修复:(给得出,卡在哪里)。provider_refs 拿它决定问题提示指不指到发布前检查——给不出修复就别让 + 人去那里找,改说第一处落不稳的地方(Landing.why)。找到第一个落得稳的候选就停:画布每改一下都要校验,不必为了 + 一句提示把每个候选都算一遍。""" + plan = _ProviderPlan(spec, ref, providers) + for _ in plan.options(): + return True, "" + return False, plan.stuck #: 候选本身是演示类接入(kind 为 mock)时的说明。「不指定」落到演示类接入时说明里另带 provider_refs.DEMO_LANDING diff --git a/backend/app/engine/judge.py b/backend/app/engine/judge.py index 25b03568..b5808583 100644 --- a/backend/app/engine/judge.py +++ b/backend/app/engine/judge.py @@ -843,7 +843,8 @@ def settings_problem(value: Any) -> str | None: label = SETTING_LABEL.get(key, key) if key in ("provider", "model"): if item is not None and not isinstance(item, str): - return f"「{label}」需要填写模型接入的名称或模型 ID,留空表示不指定,当前为「{item}」" + # 「跟随助手的模型」是设置页接入下拉框空选项的原话(核查一组同样照设置页写,api/settings.verify_problem) + return f"「{label}」需要填写模型接入的名称或模型 ID,留空表示跟随助手的模型,当前为「{item}」" elif not _limit_ok(key, item): return f"「{label}」需要填写{_limit_words(key)},留空表示不限,当前为「{item}」" return None diff --git a/backend/app/engine/provider_refs.py b/backend/app/engine/provider_refs.py index d06bb09b..4aa4f4ca 100644 --- a/backend/app/engine/provider_refs.py +++ b/backend/app/engine/provider_refs.py @@ -21,8 +21,9 @@ 说「在节点中换一个接入 / 模型」;别处(节点自己的接入、协作成员、全图默认)界面上没有下拉框,说「在发布前检查中改用 其他接入」——发布前检查给这类问题配了修复(engine/autofix.py 的 provider.*:改用一个启用的接入,或不再指定),修复 按 land 算出修完以后执行时落到哪里,落不稳的候选不给。一个候选都落不稳时修复为空,这时不指到发布前检查(那里什么 -都不给),只说去设置里核对、启用。点名的名字恰好是某个启用接入的模型时,末尾补一句这是哪个接入的模型 -(factory.model_note)。 +都不给),说去设置里核对、启用,再说卡在哪里(第一处落不稳的地方,比如成员只写了停用接入的模型)——不然人去设置里 +找那个写错的名字,找不到,也看不出真正挡住修复的是别处。点名的名字恰好是某个启用接入的模型时,末尾补一句这是哪个 +接入的模型、在这一处怎么用它(_model_note:按检查器里有哪几栏写,不叫人去一个没有的「接入」栏留空)。 节点没写模型、点名的接入(自己的或全图默认的)本身不存在或已停用时,校验那条「没有指定模型,将使用接入「X」的 默认模型」(schema.MODEL_UNSET)和这里的问题互相矛盾,由 quiet_unset_model 去掉前者。 @@ -37,10 +38,10 @@ from typing import Any, Callable from sqlalchemy.ext.asyncio import AsyncSession -from app.engine.labels import field_label, type_label +from app.engine.labels import field_label, judge_label, type_label from app.engine.schema import CLAIMS_JUDGE, MODEL_UNSET, GraphNode, GraphSpec, NodeType, ValidationIssue from app.providers.factory import ( - NAMED_FIX, ProviderNotConfigured, fallback_provider, list_providers, model_note, named_provider, + NAMED_FIX, ProviderNotConfigured, fallback_provider, list_providers, model_fact, model_note, named_provider, provider_for_model, serves, ) @@ -50,6 +51,10 @@ CODES = frozenset({"provider.missing", "provider.disabled"}) #: 才指到那里(IN_CHECK),否则不写后半句 _WHERE = {"provider": "或在节点中换一个接入", "model": "或在节点中换一个模型"} IN_CHECK = "或在「发布前检查」中改用其他接入" +#: 检查器里没有下拉框、发布前检查也给不出修复时,提示末尾说卡在哪里(Landing.why) +NO_SWITCH = "无法改用其他接入:" +#: 一个启用的接入都没有:没点名接入的执行时换成内置演示模型,点名的调不了 +NO_ENABLED = "没有启用的模型接入" #: 修完以后执行时落到演示类接入(kind 为 mock):说明里照实写。K1 要防的就是悄悄换成演示模型——落到它是人选的, #: 得让人知道选的是什么 DEMO_LANDING = "(演示用,不调用真实模型)" @@ -142,50 +147,56 @@ def _own_or_default(spec: GraphSpec, node: GraphNode, key: str, who: str) -> Pro editable=key == "model" and node.type in _MODEL_PICKER) +def node_uses(spec: GraphSpec, node: GraphNode) -> list[Use]: + """一个节点执行时取模型的每一处(uses 里属于它的那几项)。只读这个节点自己的配置和全图默认:改一个节点的配置 + 只影响它自己这几处,修复规划(autofix 的 provider.*)按这一点只重算被改的那个节点,不再每试一个候选就把整张图 + 重算一遍——几十个节点都点名同一个停用接入时,那样画布每改一下都要多算上万处。""" + out: list[Use] = [] + cfg = node.config + who = f"「{node.title}」({type_label(node.type)})" + if node.type in _VERIFY: + # 接入、模型哪一项写了就整组以节点为准,都没写交给设置(verify_model_spec) + if cfg.get("enabled") is not False: + vwho = who + _VERIFY[node.type] + p = _place(node.id, "provider", cfg.get("provider"), vwho, editable=True, + said=f"{who}的「{field_label('provider', node.type)}」") + m = _place(node.id, "model", cfg.get("model"), vwho, editable=True, kind="model", + said=f"{who}的「{field_label('model', node.type)}」") + out.append(Use((node.id, "verify"), vwho, p, m, None if p or m else "verify")) + return out + if node.type not in _TAKES_MODEL: + return out + provider, model = _own_or_default(spec, node, "provider", who), _own_or_default(spec, node, "model", who) + cond = _TAKES_MODEL[node.type] + if cond is None or cond(spec, node): + out.append(Use((node.id, "node"), who, provider, model)) + if node.type == NodeType.SUPERVISOR and isinstance(cfg.get("agents"), list): + # 成员自己写了(非空)就用自己的,否则跟随节点,同 nodes/multi._member 的 `cfg.get(...) or ctx.cfg(...)` + for i, member in enumerate(cfg["agents"]): + if not isinstance(member, dict): + continue + label = _name(member.get("name")) or f"第 {i + 1} 位" + mwho = f"{who}的成员「{label}」" + own_p, own_m = member.get("provider"), member.get("model") + out.append(Use((node.id, f"agents[{i}]"), mwho, + _place(node.id, f"agents[{i}].provider", own_p, mwho) if own_p else provider, + _place(node.id, f"agents[{i}].model", own_m, mwho, kind="model") if own_m else model)) + if node.type == NodeType.REPORT and _cfg(spec, node, "claims", "off") == CLAIMS_JUDGE: + judge = _cfg(spec, node, "judge") + # 节点没写裁判配置,执行时用全图默认里的那一份:问题记在全图默认上,几个报告共用时只记一条 + own = judge is cfg.get("judge") + nid, at, jwho = (node.id, "judge", f"{who}的{field_label('judge')}") if own else \ + (None, "defaults.judge", f"{field_label('defaults')}中的{field_label('judge')}") + judge = judge if isinstance(judge, dict) else {} + p = _place(nid, f"{at}.provider", judge.get("provider"), jwho, editable=own) + m = _place(nid, f"{at}.model", judge.get("model"), jwho, editable=own, kind="model") + out.append(Use((node.id, "judge"), jwho, p, m, None if p or m else "judge")) + return out + + def uses(spec: GraphSpec) -> list[Use]: """图里执行时取模型的每一处,按画布上节点的顺序;只算执行时真会取模型的(见模块说明)。""" - out: list[Use] = [] - for node in spec.nodes: - cfg = node.config - who = f"「{node.title}」({type_label(node.type)})" - if node.type in _VERIFY: - # 接入、模型哪一项写了就整组以节点为准,都没写交给设置(verify_model_spec) - if cfg.get("enabled") is not False: - vwho = who + _VERIFY[node.type] - p = _place(node.id, "provider", cfg.get("provider"), vwho, editable=True, - said=f"{who}的「{field_label('provider', node.type)}」") - m = _place(node.id, "model", cfg.get("model"), vwho, editable=True, kind="model", - said=f"{who}的「{field_label('model', node.type)}」") - out.append(Use((node.id, "verify"), vwho, p, m, None if p or m else "verify")) - continue - if node.type not in _TAKES_MODEL: - continue - provider, model = _own_or_default(spec, node, "provider", who), _own_or_default(spec, node, "model", who) - cond = _TAKES_MODEL[node.type] - if cond is None or cond(spec, node): - out.append(Use((node.id, "node"), who, provider, model)) - if node.type == NodeType.SUPERVISOR and isinstance(cfg.get("agents"), list): - # 成员自己写了(非空)就用自己的,否则跟随节点,同 nodes/multi._member 的 `cfg.get(...) or ctx.cfg(...)` - for i, member in enumerate(cfg["agents"]): - if not isinstance(member, dict): - continue - label = _name(member.get("name")) or f"第 {i + 1} 位" - mwho = f"{who}的成员「{label}」" - own_p, own_m = member.get("provider"), member.get("model") - out.append(Use((node.id, f"agents[{i}]"), mwho, - _place(node.id, f"agents[{i}].provider", own_p, mwho) if own_p else provider, - _place(node.id, f"agents[{i}].model", own_m, mwho, kind="model") if own_m else model)) - if node.type == NodeType.REPORT and _cfg(spec, node, "claims", "off") == CLAIMS_JUDGE: - judge = _cfg(spec, node, "judge") - # 节点没写裁判配置,执行时用全图默认里的那一份:问题记在全图默认上,几个报告共用时只记一条 - own = judge is cfg.get("judge") - nid, at, jwho = (node.id, "judge", f"{who}的{field_label('judge')}") if own else \ - (None, "defaults.judge", f"{field_label('defaults')}中的{field_label('judge')}") - judge = judge if isinstance(judge, dict) else {} - p = _place(nid, f"{at}.provider", judge.get("provider"), jwho, editable=own) - m = _place(nid, f"{at}.model", judge.get("model"), jwho, editable=own, kind="model") - out.append(Use((node.id, "judge"), jwho, p, m, None if p or m else "judge")) - return out + return [use for node in spec.nodes for use in node_uses(spec, node)] def provider_refs(spec: GraphSpec) -> list[ProviderRef]: @@ -215,22 +226,54 @@ def broken(refs: list[ProviderRef], providers: list[Any]) -> list[tuple[Provider def _tail(spec: GraphSpec, ref: ProviderRef, providers: list[Any]) -> str: """提示的后半句:检查器里有这一项的下拉框,指到节点;没有的,发布前检查给得出修复才指到那里——一个候选都落不稳 - 时(比如一个启用的接入都没有,或者改动波及的别处都落不稳)那里什么都不给,指过去只会让人白找。""" + 时(比如一个启用的接入都没有,或者改动波及的别处都落不稳)那里什么都不给,指过去只会让人白找,改说卡在哪里。 + 只问给不给得出、卡在哪里(autofix.provider_fixable,找到第一个落得稳的候选就停),不拼整条修复。""" if ref.editable: return f",{_WHERE[ref.kind]}" # 在这里导入:autofix 导入了门禁(governance),门禁又在 lint_for_publish 里导入本模块 - from app.engine.autofix import provider_choice + from app.engine.autofix import provider_fixable - return f",{IN_CHECK}" if provider_choice(spec, ref, providers) is not None else "" + fixable, stuck = provider_fixable(spec, ref, providers) + if fixable: + return f",{IN_CHECK}" + return f"。{NO_SWITCH}{stuck}" if stuck else "" + + +def _model_note(spec: GraphSpec, ref: ProviderRef, e: ProviderNotConfigured, providers: list[Any]) -> str: + """点名的名字恰好是某个启用接入的模型时补的那一句,按这一处在检查器里有哪几栏写(运行时报错、设置页的说法见 + factory.model_note)。多数节点的检查器里没有接入这一栏,叫人「接入留空」做不到: + - 核查节点、报告自己的结论句裁判:接入、模型两栏都在(模型是输入框),照设置页的写法; + - 检查器里有模型下拉框、没有接入一栏的节点(模型调用、Agent……):在节点的「模型」中选它,接入在发布前检查里 + 改用这个接入或不指定(修复里有哪一项说哪一项); + - 协作成员、全图默认这些两栏都没有的:发布前检查里能改用这个接入时说一句,否则只说这是哪个接入的模型。""" + name, owner = e.name, e.model_owner + if ref.editable: + node = spec.node_map()[ref.node_id] if ref.node_id is not None else None + labels = (judge_label("model"), judge_label("provider")) if ref.field.startswith("judge.") or node is None \ + else (field_label("model", node.type), field_label("provider", node.type)) + return model_note(name, owner, f"「{labels[0]}」", f"「{labels[1]}」", "填写") + from app.engine.autofix import PROVIDER_CLEAR, provider_choice + + fact = model_fact(name, owner) + choice = provider_choice(spec, ref, providers) + values = {o["value"] for o in choice["options"]} if choice else set() + node = spec.node_map().get(ref.node_id) if ref.node_id is not None else None + if node is not None and ref.field == "provider" and node.type in _MODEL_PICKER: + picks = ([f"改用「{owner}」"] if owner in values else []) + \ + (["选择「不指定接入」"] if PROVIDER_CLEAR in values else []) + if not picks: + return fact + return f"{fact}:如需使用该模型,请在节点的「{field_label('model')}」中选择它,并在「发布前检查」中{'或'.join(picks)}" + return f"{fact},可在「发布前检查」中改用「{owner}」" if owner in values else fact def issues(spec: GraphSpec, providers: list[Any], *, level: str) -> list[ValidationIssue]: """执行时会报错的每一处一条问题。providers 是接入表(factory.list_providers),level 由调用方定:画布校验 warning, 发布一类 error。原因和运行时报的同一句,提示按这一处在界面上能不能改(_tail);点名的名字恰好是某个启用接入的 - 模型时补一句(factory.model_note)。""" + 模型时补一句(_model_note)。""" out = [] for ref, e in broken(provider_refs(spec), providers): - note = f"。{model_note(e.name, e.model_owner)}" if e.model_owner else "" + note = f"。{_model_note(spec, ref, e, providers)}" if e.model_owner else "" out.append(ValidationIssue(level=level, node_id=ref.node_id, field=ref.field, # type: ignore[arg-type] code=f"provider.{e.code}", message=f"{ref.subject}指定的{e.reason}。{NAMED_FIX[e.code]}" @@ -261,11 +304,14 @@ def quiet_unset_model(spec: GraphSpec, found: list[Any], provider_issues: list[A @dataclass(frozen=True) class Landing: """执行时这一处落到哪里:where 是说给人听的(「工作流默认设置指定的接入「甲」」……),model 是用哪个模型(交给设置时 - 为 None),ok 是修复能不能把它落在这里。落到演示类接入时 where 末尾带着 DEMO_LANDING。""" + 为 None),ok 是修复能不能把它落在这里。落到演示类接入时 demo 为真、where 末尾带着 DEMO_LANDING。落不稳时 why 说 + 为什么(一句话,接在「无法改用其他接入:」后面),修复一个候选都给不出时拿第一处的 why 告诉人卡在哪里。""" where: str model: str | None ok: bool + demo: bool = False + why: str = "" def land(use: Use, providers: list[Any]) -> Landing: @@ -273,34 +319,47 @@ def land(use: Use, providers: list[Any]) -> Landing: ok 为真才算落得稳:点名的接入启用着、有模型可用,并且提供这一处写的模型——拿 A 家的模型名去调 B 家必然失败; 只写了模型的,按模型名找到启用的接入。按模型名落到停用的接入(运行时报错)、模型名认不出来(落到第一个可用的 - 接入,它多半不提供)、一个启用的接入都没有(悄悄换成内置演示模型)都不算。交给设置的(核查、裁判)算落得稳: - 设置保存时查过点名的接入,之后接入出了问题设置页会标出来。""" + 接入,它多半不提供)、一个启用的接入都没有(悄悄换成内置演示模型)都不算。 + + 交给设置的(核查、裁判)算落得稳:设置保存时查过点名的接入,之后接入出了问题设置页会标出来。启用的接入全是演示类 + 时,设置这一级不管写的是什么,执行时落到的都是演示模型(点名的启用接入、按模型名找到的、取值链最后的第一个可用的 + 接入,只能是演示类),照实带上 DEMO_LANDING;一个启用的接入都没有时不算落得稳,同上面「换成内置演示模型」。""" if use.settings: - return Landing(_SETTINGS[use.settings], None, True) + enabled = [p for p in providers if getattr(p, "enabled", False)] + if not enabled: + return Landing(_SETTINGS[use.settings], None, False, why=NO_ENABLED) + demo = all(getattr(p, "kind", None) == "mock" for p in enabled) + return Landing(_SETTINGS[use.settings] + (DEMO_LANDING if demo else ""), None, True, demo=demo) model = use.model.name if use.model is not None else None if use.provider is not None: try: found = named_provider(providers, use.provider.name) except ProviderNotConfigured as e: - return Landing(e.reason, model, False) + return Landing(e.reason, model, False, why=f"{use.provider.subject}指定的{e.reason}") where = f"{use.provider.subject}指定的接入「{found.name}」" - elif model: + elif use.model is not None and model: try: found = provider_for_model(providers, model) except ProviderNotConfigured as e: - return Landing(e.reason, model, False) + return Landing(e.reason, model, False, why=f"{use.model.subject}指定的{e.reason}") if found is None: - return Landing(f"认不出的模型「{model}」", model, False) + return Landing(f"认不出的模型「{model}」", model, False, + why=f"{use.model.subject}指定的模型「{model}」不属于任何启用的模型接入") where = f"提供模型「{model}」的接入「{found.name}」" else: found = fallback_provider(providers) if found is None: - return Landing("内置演示模型", None, False) + return Landing("内置演示模型", None, False, why=NO_ENABLED) where = f"第一个可用的模型接入「{found.name}」" - if getattr(found, "kind", None) == "mock": + demo = getattr(found, "kind", None) == "mock" + if demo: where += DEMO_LANDING used = model or found.default_model - return Landing(where, used, bool(used) and (model is None or serves(found, model))) + if not used: + return Landing(where, None, False, demo, why=f"模型接入「{found.name}」没有可用的模型") + if use.model is not None and model and not serves(found, model): + return Landing(where, model, False, demo, why=f"模型接入「{found.name}」不提供{use.model.subject}指定的模型「{model}」") + return Landing(where, used, True, demo) async def load_issues(session: AsyncSession, spec: GraphSpec, *, level: str) -> list[ValidationIssue]: diff --git a/backend/app/providers/factory.py b/backend/app/providers/factory.py index 2489adcf..b94ae6d6 100644 --- a/backend/app/providers/factory.py +++ b/backend/app/providers/factory.py @@ -54,17 +54,27 @@ async def list_providers(session: AsyncSession) -> list[Provider]: return list(rows.scalars()) -#: 点名的接入不存在、已停用时,提示的前半句:在设置里能做什么。后半句(去哪里换一个接入)看场合:运行时报错、 -#: 设置页写「或换一个接入」;画布校验、发布前检查按这一处在界面上能不能改另写(engine/provider_refs.py) -NAMED_FIX = {"missing": "请在「设置 → 模型接入」中核对名称", "disabled": "请在「设置 → 模型接入」中启用它"} +#: 点名的接入不存在、已停用时,在「设置 → 模型接入」里能做什么。NAMED_FIX 是整句的前半句;后半句(去哪里换一个 +#: 接入)看场合:运行时报错、设置页写「或换一个接入」;画布校验、发布前检查按这一处在界面上能不能改另写 +#:(engine/provider_refs.py);助手栏那一格下面先说「在上方重新选择」,再说去设置里做什么(assistant_unavailable) +NAMED_ACTION = {"missing": "核对名称", "disabled": "启用它"} +NAMED_FIX = {code: f"请在「设置 → 模型接入」中{act}" for code, act in NAMED_ACTION.items()} -def model_note(name: str, owner: str, model_field: str = "模型一栏", provider_field: str = "接入") -> str: +def model_fact(name: str, owner: str) -> str: + """点名的接入名恰好是某个启用接入的模型:先照实说这是哪个接入的模型,怎么用它各处另说(model_note……)。""" + return f"「{name}」是接入「{owner}」的模型" + + +def model_note(name: str, owner: str, model_field: str = "模型一栏", provider_field: str = "接入", + how: str = "选择它") -> str: """点名的接入名恰好是某个启用接入的模型时补的那一句:多半是把模型名填进了接入那一栏。开发数据里真有一条停用的 接入,名字就是一个模型 id,同名模型由另一条启用的接入提供——照「启用它」的提示去做,启用的是那条旧接入(它的 - 模型清单里根本没有这个模型),用户要的是这个模型。model_field、provider_field 是这一处模型、接入两栏的叫法: - 设置页写「「门控模型 · 模型」」「「门控模型 · 接入」」,运行时报错、画布不分节点类型,写「模型一栏」「接入」。""" - return f"「{name}」是接入「{owner}」的模型:如需使用该模型,请在{model_field}中填写,{provider_field}留空或选择「{owner}」" + 模型清单里根本没有这个模型),用户要的是这个模型。model_field、provider_field 是这一处模型、接入两栏的叫法, + how 是在模型那一栏里怎么做:设置页、核查节点的模型是输入框,写「填写」;运行时报错不分节点类型,多数节点的模型 + 是下拉框,写「模型一栏」「接入」「选择它」。画布、发布前检查按这一处在检查器里有哪几栏另写(provider_refs)。""" + return (f"{model_fact(name, owner)}:如需使用该模型,请在{model_field}中{how}," + f"{provider_field}留空或选择「{owner}」") def _model_owner(providers: list[Provider], name: str) -> Provider | None: @@ -129,28 +139,33 @@ def fallback_provider(providers: list[Provider]) -> Provider | None: #: 助手的模型在界面上只有一处能改:「编排」页助手栏的「助手使用的模型」(设置页里没有这一项)。选「跟随默认」或一个 #: 模型都会清掉存着的接入。画布、问数据页、表格导入、数据目录起草报的是同一句,所以写明是哪一页 ASSISTANT_MODEL_FIELD = "助手使用的模型" -ASSISTANT_REPICK = f"或在「编排」页助手栏的「{ASSISTANT_MODEL_FIELD}」中重新选择" +ASSISTANT_PICKER = f"「编排」页助手栏的「{ASSISTANT_MODEL_FIELD}」中" +ASSISTANT_REPICK = f"或在{ASSISTANT_PICKER}重新选择" -def assistant_model_note(name: str, owner: str) -> str: - """点名的接入名恰好是某个启用接入的模型(model_note)时,助手这边的说法:它的下拉框只选模型,选了就清掉接入。""" - return f"「{name}」是接入「{owner}」的模型,可在「{ASSISTANT_MODEL_FIELD}」中直接选择" - - -def assistant_unavailable(e: BaseException) -> str: +def assistant_unavailable(e: BaseException, *, here: bool = False) -> str: """助手(画布上改图、表格导入与数据目录的 AI 起草)用的模型取不到时给人看的那句话。 点名的接入不存在、已停用(按模型名找到的接入已停用也算)照实说是哪个接入、在设置里能做什么,或者到助手栏重新 - 选一个模型;以前这几种要么悄悄换成演示模型,要么各处自己判停用。点名的名字恰好是启用接入的模型时补一句可以直接 - 选它。其余(缺 key、没有模型……)是接入本身没配完。""" + 选一个模型;以前这几种要么悄悄换成演示模型,要么各处自己判停用。点名的名字恰好是启用接入的模型时并成一句:先说 + 在设置里能做什么,再说这是哪个接入的模型、也可以在助手栏直接选它(不再另起一句把「助手使用的模型」说两遍)。 + 其余(缺 key、没有模型……)是接入本身没配完。 + + here:说给助手栏「助手使用的模型」那一格下面(GET /api/copilot/model 的 panel_error)。人就在这个下拉框旁边, + 去向写「上方」,不再把人指回「编排」页助手栏。""" from app.core.errors import not_configured code = getattr(e, "code", "") if code in NAMED_FIX: - said = f"助手使用的{getattr(e, 'reason', '') or e}。{NAMED_FIX[code]},{ASSISTANT_REPICK}" - owner = getattr(e, "model_owner", "") - return f"{said}。{assistant_model_note(getattr(e, 'name', ''), owner)}" if owner else said - return f"助手使用的模型尚未配置完成:{not_configured(e, with_hint=False)}。请到「设置 → 模型接入」检查" + said = f"助手使用的{getattr(e, 'reason', '') or e}。" + if owner := getattr(e, "model_owner", ""): + where = "上方" if here else ASSISTANT_PICKER + return f"{said}{NAMED_FIX[code]};{model_fact(getattr(e, 'name', ''), owner)},也可在{where}直接选择它" + if here: + return f"{said}请在上方重新选择,或在「设置 → 模型接入」中{NAMED_ACTION[code]}" + return f"{said}{NAMED_FIX[code]},{ASSISTANT_REPICK}" + return (f"助手使用的模型尚未配置完成:{not_configured(e, with_hint=False)}。请到「设置 → 模型接入」检查" + + (",或在上方重新选择" if here else "")) async def resolve_provider( diff --git a/backend/tests/test_provider_ref_checks.py b/backend/tests/test_provider_ref_checks.py index 9934a5ef..a2537f9e 100644 --- a/backend/tests/test_provider_ref_checks.py +++ b/backend/tests/test_provider_ref_checks.py @@ -77,13 +77,21 @@ FIELDS = [ ] +def refused(group: str, label: str, reason: str, fix: str) -> str: + """保存时拒收的说法。助手那一项只有「编排」页助手栏一个下拉框(只选模型),没有「换一个接入」的地方:和助手取不到 + 模型时报的同一句(factory.assistant_unavailable,见 test_provider_ref_wording)。""" + if group == "copilot": + return f"助手使用的{reason}。{fix},或在「编排」页助手栏的「助手使用的模型」中重新选择" + return f"「{label}」指定的{reason}。{fix},或换一个接入" + + @pytest.mark.parametrize("group,key,label", FIELDS) async def test_saving_a_provider_that_does_not_exist_is_refused_with_the_field(client, group, key, label): r = await client.put("/api/settings", json={"values": {group: {key: GONE}}}) assert r.status_code == 422, r.text body = r.json() assert body["code"] == "settings_provider_invalid" and body["field"] == f"{group}.{key}" - assert body["detail"] == f"「{label}」指定的模型接入「{GONE}」不存在。请在「设置 → 模型接入」中核对名称,或换一个接入" + assert body["detail"] == refused(group, label, f"模型接入「{GONE}」不存在", "请在「设置 → 模型接入」中核对名称") assert await stored(group) is None @@ -93,7 +101,7 @@ async def test_saving_a_disabled_provider_is_refused_too(client, group, key, lab assert r.status_code == 422, r.text body = r.json() assert body["field"] == f"{group}.{key}" - assert body["detail"] == f"「{label}」指定的模型接入「{OFF}」已停用。请在「设置 → 模型接入」中启用它,或换一个接入" + assert body["detail"] == refused(group, label, f"模型接入「{OFF}」已停用", "请在「设置 → 模型接入」中启用它") assert await stored(group) is None diff --git a/backend/tests/test_provider_ref_landing.py b/backend/tests/test_provider_ref_landing.py index 6b35d6ed..58db6c83 100644 --- a/backend/tests/test_provider_ref_landing.py +++ b/backend/tests/test_provider_ref_landing.py @@ -107,7 +107,7 @@ 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": "分析"}]), @@ -117,7 +117,8 @@ def test_the_problem_is_recorded_once_where_the_model_is_written(): got = [(i["node_id"], i["field"], i["message"]) for i in gate(graph)] enable = "请在「设置 → 模型接入」中启用它" assert got == [ - ("team2", "agents[0].model", f"「协作二」(多 Agent 协作)的成员「研究员」指定的模型「丙一号」{STOPPED}。{enable}"), + ("team2", "agents[0].model", f"「协作二」(多 Agent 协作)的成员「研究员」指定的模型「丙一号」{STOPPED}。{enable}。" + f"无法改用其他接入:工作流默认设置指定的模型「丙一号」{STOPPED}"), (None, "defaults.model", f"工作流默认设置指定的模型「丙一号」{STOPPED}。{enable},或在「发布前检查」中改用其他接入"), ] diff --git a/backend/tests/test_provider_ref_wording.py b/backend/tests/test_provider_ref_wording.py index 7d76eceb..65f070ef 100644 --- a/backend/tests/test_provider_ref_wording.py +++ b/backend/tests/test_provider_ref_wording.py @@ -12,6 +12,15 @@ K1 让点名的接入不存在或已停用时照实报错、发布前挡住并 - 同一节点同时提示「没有指定模型,将使用默认模型」和「指定的模型接入不存在」,互相矛盾; - 助手的模型取不到时,提示指到真正能改它的地方(「编排」页助手栏的「助手使用的模型」)。 +第 1 轮复核又留下的几处: +- 「不指定接入」交给设置(核查、裁判)时,落到演示类接入也没说; +- 「接入名恰好是模型名」那句叫人「接入留空」,多数节点的检查器里没有接入这一栏,模型又是下拉框却写「填写」; +- 给不出修复时只剩「核对名称」,没说真正卡住修复的是哪一处; +- 「演示用,不调用真实模型」只是淡色小字:候选另带 demo 标记,界面上加醒目的标签; +- 画布校验为每处坏掉的接入把每个候选都在整张图上算一遍,几十处同时坏掉时开销随规模平方增长; +- 助手那一项的 422 说法和助手报错、助手栏对不上;助手栏下拉下面的原因把人指回这个下拉本身; +- 裁判的格式检查仍写「留空表示不指定」;「交给助手」后只剩接入问题时的说明措辞。 + 纯函数的用例用虚构的接入表,不读库;接口的用例建自己的几条接入、用完删掉。接入名、节点名、模型名都是虚构的。 """ from __future__ import annotations @@ -24,7 +33,7 @@ from sqlalchemy import delete from app.db.base import SessionLocal from app.db.models import Provider, Setting, Workflow, WorkflowVersion -from app.engine import autofix +from app.engine import autofix, provider_refs from app.engine.autofix import apply_fixes, check from app.engine.governance import publish_issues from app.engine.provider_refs import issues as provider_ref_issues @@ -48,6 +57,7 @@ DEMO = provider("演示接入", "演示一号", kind="mock") DEMO_NOTE = "(演示用,不调用真实模型)" MISSING_FIX, DISABLED_FIX = "请在「设置 → 模型接入」中核对名称", "请在「设置 → 模型接入」中启用它" IN_CHECK = ",或在「发布前检查」中改用其他接入" +NO_SWITCH = "。无法改用其他接入:" def node(nid, ntype, label=None, **config): @@ -114,14 +124,16 @@ def test_a_verify_node_names_the_field_that_points_at_the_provider(): def test_without_a_fix_the_hint_does_not_send_people_to_the_publish_check(): """协作节点点名的接入不存在,成员只写了停用的丙家的模型、跟随节点的接入:去掉或换掉节点的接入,成员都落不稳, - 修复为空。检查器里又没有这一栏,提示只剩去设置里核对——不说「或在「发布前检查」中改用其他接入」。画布校验的 - 警告是同一句。成员换成启用的接入提供的模型,修复就有了,提示照常指到发布前检查。""" + 修复为空。检查器里又没有这一栏,提示不说「或在「发布前检查」中改用其他接入」,说去设置里核对,再说卡在哪里—— + 人去设置里找「戊接入」找不到,真正挡住修复的是成员的模型所属的接入停用了。画布校验的警告是同一句。成员换成启用的 + 接入提供的模型,修复就有了,提示照常指到发布前检查。""" graph = flow(node("team", "supervisor", "协作", provider="戊接入", goal="分析", agents=[{"name": "研究员", "model": "丙一号", "system": "研究"}])) found = checked(graph) [issue] = provider_issues(found["issues"]) assert (issue["node_id"], issue["field"], issue["fix"]) == ("team", "provider", None) - assert issue["message"] == f"「协作」(多 Agent 协作)指定的模型接入「戊接入」不存在。{MISSING_FIX}" + assert issue["message"] == (f"「协作」(多 Agent 协作)指定的模型接入「戊接入」不存在。{MISSING_FIX}{NO_SWITCH}" + "「协作」(多 Agent 协作)的成员「研究员」指定的模型「丙一号」所属的模型接入「丙接入」已停用") [warn] = provider_ref_issues(GraphSpec.model_validate(graph), TABLE, level="warning") assert warn.message == issue["message"] @@ -131,6 +143,15 @@ def test_without_a_fix_the_hint_does_not_send_people_to_the_publish_check(): assert issue["message"] == f"「协作」(多 Agent 协作)指定的模型接入「戊接入」不存在。{MISSING_FIX}{IN_CHECK}" +def test_without_any_enabled_provider_the_hint_says_so(): + """一个启用的接入都没有:去掉接入以后执行时只能换成内置演示模型,不算修好,也没有别的接入可换。提示照实说卡在 + 这里,不只说去设置里启用这个接入。""" + graph = flow(node("ask", "llm", "提问", provider="丙接入", prompt="总结")) + [issue] = provider_issues(checked(graph, [BING])["issues"]) + assert issue["fix"] is None + assert issue["message"] == f"「提问」(模型调用)指定的模型接入「丙接入」已停用。{DISABLED_FIX}{NO_SWITCH}没有启用的模型接入" + + def test_an_issue_without_its_own_fix_does_not_borrow_another_places_fix(): """全图默认点名停用的丙,节点「提问」只写了丙家的模型(跟随全图默认的接入),节点「复述」自己点名丙:复述那一处有 修复(改用甲、乙),全图默认那一处没有(换掉或去掉,提问都落不稳)。全图默认那条不能认到复述的修复上去。""" @@ -142,10 +163,47 @@ def test_an_issue_without_its_own_fix_does_not_borrow_another_places_fix(): assert set(got) == {("b", "provider"), (None, "defaults.provider")} assert got[("b", "provider")]["fix"] == "provider.disabled:b:provider" assert got[(None, "defaults.provider")]["fix"] is None - assert got[(None, "defaults.provider")]["message"] == f"工作流默认设置指定的模型接入「丙接入」已停用。{DISABLED_FIX}" + # 卡在「提问」:它只写了丙家的模型,去掉全图默认的接入以后按模型名找,找到的还是停用的丙 + assert got[(None, "defaults.provider")]["message"] == ( + f"工作流默认设置指定的模型接入「丙接入」已停用。{DISABLED_FIX}{NO_SWITCH}" + "「提问」(模型调用)指定的模型「丙一号」所属的模型接入「丙接入」已停用") assert [f["id"] for f in found["fixes"]] == ["provider.disabled:b:provider"] +# -------------------------------------------------------------------------- +# 很多处同时坏掉:每试一个候选只重算被改的那个节点 +# -------------------------------------------------------------------------- + + +def test_checking_many_broken_places_does_not_redo_the_whole_graph_per_candidate(monkeypatch): + """几十个节点都点名同一个停用的接入(导入别处导出的工作流时常见):画布校验要给每一处说给不给得出修复,发布前检查 + 再给每一处列候选。改一个节点的配置只影响它自己取模型的那几处,每试一个候选只重算这个节点,开销随节点数线性增长; + 以前每试一个候选都把整张图重算一遍,随节点数平方增长(60 个节点时画布每改一下要多算约 0.35 秒)。数的是一共算了 + 多少处取模型的地方(provider_refs.Use),节点数翻 4 倍时不能超过约 4 倍。""" + made = {"n": 0} + + class Counted(provider_refs.Use): + def __init__(self, *args, **kwargs): + made["n"] += 1 + super().__init__(*args, **kwargs) + + monkeypatch.setattr(provider_refs, "Use", Counted) + table = [provider(f"接入{i}", f"模型{i}") for i in range(6)] + [BING] + + def cost(n: int) -> tuple[int, int]: + spec = GraphSpec.model_validate(flow(*[node(f"n{i}", "llm", f"节点{i}", provider="丙接入", prompt="总结") + for i in range(n)])) + made["n"] = 0 + assert len(provider_ref_issues(spec, table, level="warning")) == n + lint = made["n"] + made["n"] = 0 + assert len(check(spec, level="published", providers=table)["fixes"]) == n + return lint, made["n"] + + small, big = cost(8), cost(32) + assert big[0] <= 5 * small[0] and big[1] <= 5 * small[1], (small, big) + + # -------------------------------------------------------------------------- # 本来就因自己点名的接入坏着的那一处,不否决别处的修复 # -------------------------------------------------------------------------- @@ -212,11 +270,51 @@ def test_stop_naming_a_provider_that_lands_on_a_demo_provider_says_so(): f"去掉这一处指定的接入,执行时改用提供模型「演示一号」的接入「演示接入」{DEMO_NOTE}") +def test_stop_naming_a_provider_handed_to_settings_says_when_it_lands_on_a_demo_model(): + """核查节点、报告自己的结论句裁判去掉接入以后交给设置(核查模型、裁判模型)。启用的接入全是演示类时,设置这一级 + 不管写的是什么,执行时落到的都是演示模型(取值链最后是第一个可用的接入):说明里和模型调用那一处一样写明演示用、 + 不调用真实模型,候选也带 demo 标记。有启用的真实接入时不带;一个启用的接入都没有时,设置这一级只能换成内置演示 + 模型(或者调不了),不算修好、不给这一项。""" + graph = flow(node("ask", "llm", "提问", provider="丙接入", prompt="总结"), + node("write", "report", "写周报", instructions="写周报", claims="judge", judge={"provider": "丙接入"}), + node("dual", "dual_check", "复核销售额", provider="丙接入")) + demo_only = [BING, DEMO] + got = {fid: hints(fix_of(graph, fid, demo_only)) for fid in ( + "provider.disabled:ask:provider", "provider.disabled:write:judge.provider", "provider.disabled:dual:provider")} + assert got["provider.disabled:ask:provider"][""] == ( + f"去掉这一处指定的接入,执行时改用第一个可用的模型接入「演示接入」{DEMO_NOTE}") + assert got["provider.disabled:write:judge.provider"][""] == f"去掉这一处指定的接入,执行时改用设置中的裁判模型{DEMO_NOTE}" + assert got["provider.disabled:dual:provider"][""] == f"去掉这一处指定的接入,执行时改用设置中的核查模型{DEMO_NOTE}" + clear = next(o for o in fix_of(graph, "provider.disabled:dual:provider", demo_only)["options"] if o["value"] == "") + assert clear.get("demo") is True + + real = [JIA, BING, DEMO] + assert hints(fix_of(graph, "provider.disabled:dual:provider", real))[""] == "去掉这一处指定的接入,执行时改用设置中的核查模型" + clear = next(o for o in fix_of(graph, "provider.disabled:dual:provider", real)["options"] if o["value"] == "") + assert "demo" not in clear + + assert fix_of(graph, "provider.disabled:dual:provider", [BING]) is None + assert fix_of(graph, "provider.disabled:write:judge.provider", [BING]) is None + + +def test_candidates_that_land_on_a_demo_provider_carry_a_mark(): + """落到演示类接入的候选带 demo 标记(发布弹窗在选项名旁加醒目的标签),落到真实接入的不带。""" + table = [JIA, BING, DEMO] + graph = flow(node("ask", "llm", "提问", provider="丙接入", prompt="总结")) + fix = fix_of(graph, "provider.disabled:ask:provider", table) + marks = {o["value"]: o.get("demo", False) for o in fix["options"]} + assert marks == {"": False, "甲接入": False, "演示接入": True} + # 只剩演示接入可用:「不指定接入」也落到它 + fix = fix_of(graph, "provider.disabled:ask:provider", [BING, DEMO]) + assert {o["value"]: o.get("demo", False) for o in fix["options"]} == {"": True, "演示接入": True} + + # -------------------------------------------------------------------------- # 点名的接入名恰好是某个启用接入的模型 # -------------------------------------------------------------------------- -MODEL_NOTE = "「乙一号」是接入「乙接入」的模型:如需使用该模型,请在模型一栏中填写,接入留空或选择「乙接入」" +#: 运行时报错不分节点类型:多数节点的模型是下拉框,写「选择它」 +MODEL_NOTE = "「乙一号」是接入「乙接入」的模型:如需使用该模型,请在模型一栏中选择它,接入留空或选择「乙接入」" def test_a_provider_name_that_is_really_a_model_gets_a_note(): @@ -231,23 +329,77 @@ def test_a_provider_name_that_is_really_a_model_gets_a_note(): with pytest.raises(ProviderNotConfigured) as e: named_provider(table, " 甲二号 ") assert str(e.value) == (f"模型接入「甲二号」不存在。{MISSING_FIX},或换一个接入。" - "「甲二号」是接入「甲接入」的模型:如需使用该模型,请在模型一栏中填写,接入留空或选择「甲接入」") + "「甲二号」是接入「甲接入」的模型:如需使用该模型,请在模型一栏中选择它,接入留空或选择「甲接入」") for name in ("丙一号", "戊接入"): with pytest.raises(ProviderNotConfigured) as e: named_provider(table, name) assert "是接入" not in str(e.value), str(e.value) - # 画布、发布前检查:同一句补在提示后面 - graph = flow(node("ask", "llm", "提问", provider="乙一号", prompt="总结")) - [issue] = provider_issues(checked(graph, table)["issues"]) - assert issue["message"] == (f"「提问」(模型调用)指定的模型接入「乙一号」已停用。{DISABLED_FIX}{IN_CHECK}。{MODEL_NOTE}") - - # 助手:指到能直接选这个模型的地方 + # 助手:并成一句,指到能直接选这个模型的地方(不再另起一句把「助手使用的模型」说两遍) with pytest.raises(ProviderNotConfigured) as e: named_provider(table, "乙一号") assert assistant_unavailable(e.value) == ( - f"助手使用的模型接入「乙一号」已停用。{DISABLED_FIX},或在「编排」页助手栏的「助手使用的模型」中重新选择。" - "「乙一号」是接入「乙接入」的模型,可在「助手使用的模型」中直接选择") + f"助手使用的模型接入「乙一号」已停用。{DISABLED_FIX};" + "「乙一号」是接入「乙接入」的模型,也可在「编排」页助手栏的「助手使用的模型」中直接选择它") + + +def test_the_model_note_on_the_canvas_points_to_fields_that_exist_there(): + """画布、发布前检查里那句按这一处在检查器里有哪几栏写,不叫人去一个没有的「接入」栏留空: + - 模型调用这类节点只有模型下拉框:在节点的「模型」中选它,接入在发布前检查里改用乙接入或不指定(修复里有哪项说哪项); + - 协作成员、全图默认两栏都没有:只说发布前检查里可以改用乙接入; + - 核查节点、报告自己的结论句裁判两栏都在(模型是输入框):照设置页的写法,写这个节点上两栏的叫法; + - 给不出修复的:只说这是哪个接入的模型。""" + table = [JIA, YI, BING, TRAP] + fact = "「乙一号」是接入「乙接入」的模型" + stopped = f"指定的模型接入「乙一号」已停用。{DISABLED_FIX}" + graph = flow(node("ask", "llm", "提问", provider="乙一号", prompt="总结"), + node("team", "supervisor", "协作", provider="甲接入", goal="分析", + agents=[{"name": "分析师", "provider": "乙一号", "system": "分析"}]), + node("write", "report", "写周报", instructions="写周报", claims="judge", judge={"provider": "乙一号"}), + node("dual", "dual_check", "复核销售额", provider="乙一号"), + node("chal", "challenge", "审稿", report="write", provider="乙一号")) + got = {(i["node_id"], i["field"]): i["message"] for i in provider_issues(checked(graph, table)["issues"])} + assert got[("ask", "provider")] == ( + f"「提问」(模型调用){stopped}{IN_CHECK}。{fact}:如需使用该模型,请在节点的「模型」中选择它," + "并在「发布前检查」中改用「乙接入」或选择「不指定接入」") + assert got[("team", "agents[0].provider")] == ( + f"「协作」(多 Agent 协作)的成员「分析师」{stopped}{IN_CHECK}。{fact},可在「发布前检查」中改用「乙接入」") + assert got[("write", "judge.provider")] == ( + f"「写周报」(报告撰写)的结论句裁判{stopped},或在节点中换一个接入。" + f"{fact}:如需使用该模型,请在「裁判模型」中填写,「裁判模型 · 接入」留空或选择「乙接入」") + assert got[("dual", "provider")] == ( + f"「复核销售额」(双盲复算)的「盲算模型 · 接入」{stopped},或在节点中换一个接入。" + f"{fact}:如需使用该模型,请在「盲算模型」中填写,「盲算模型 · 接入」留空或选择「乙接入」") + assert got[("chal", "provider")].endswith( + f"{fact}:如需使用该模型,请在「反方模型」中填写,「反方模型 · 接入」留空或选择「乙接入」") + # 画布校验的警告是同一句 + warned = {(i.node_id, i.field): i.message + for i in provider_ref_issues(GraphSpec.model_validate(graph), table, level="warning")} + assert warned == got + + # 全图默认也点名「乙一号」:「提问」去掉自己的接入以后落到全图默认那个停用的接入,修复里没有「不指定接入」,只说改用 + defaulted = flow(node("ask", "llm", "提问", provider="乙一号", prompt="总结"), + node("plain", "llm", "复述", prompt="复述"), defaults={"provider": "乙一号"}) + got = {(i["node_id"], i["field"]): i["message"] for i in provider_issues(checked(defaulted, table)["issues"])} + assert got[("ask", "provider")].endswith( + f"{fact}:如需使用该模型,请在节点的「模型」中选择它,并在「发布前检查」中改用「乙接入」") + assert got[(None, "defaults.provider")] == f"工作流默认设置{stopped}{IN_CHECK}。{fact},可在「发布前检查」中改用「乙接入」" + + # 成员自己写着甲家的模型:协作节点改用乙接入时成员落不稳,修复里只有「不指定接入」和甲接入 + mixed = flow(node("team", "supervisor", "协作", provider="乙一号", goal="分析", + agents=[{"name": "研究员", "model": "甲二号", "system": "研究"}])) + [issue] = provider_issues(checked(mixed, table)["issues"]) + assert issue["message"].endswith( + f"{fact}:如需使用该模型,请在节点的「模型」中选择它,并在「发布前检查」中选择「不指定接入」") + + # 给不出修复(成员只写了停用的丙家的模型,换掉、去掉节点的接入都落不稳):只说这是哪个接入的模型 + stuck = flow(node("team", "supervisor", "协作", provider="乙一号", goal="分析", + agents=[{"name": "研究员", "model": "丙一号", "system": "研究"}])) + [issue] = provider_issues(checked(stuck, table)["issues"]) + assert issue["fix"] is None + assert issue["message"] == ( + f"「协作」(多 Agent 协作){stopped}{NO_SWITCH}" + f"「协作」(多 Agent 协作)的成员「研究员」指定的模型「丙一号」所属的模型接入「丙接入」已停用。{fact}") def test_the_assistant_hint_points_to_the_dropdown_that_can_change_it(): @@ -259,6 +411,24 @@ def test_the_assistant_hint_points_to_the_dropdown_that_can_change_it(): f"助手使用的模型接入「戊接入」不存在。{MISSING_FIX},或在「编排」页助手栏的「助手使用的模型」中重新选择") +def test_under_the_assistant_dropdown_the_hint_points_up_not_back_to_itself(): + """助手栏「助手使用的模型」那一格下面:人就在这个下拉框旁边,去向写「上方」,不再说「「编排」页助手栏的「助手使用的 + 模型」」。接入本身没配完(缺 key……)时也补一句可以在上方重新选。""" + table = [JIA, YI, BING, TRAP] + for name, said in ( + ("戊接入", "助手使用的模型接入「戊接入」不存在。请在上方重新选择,或在「设置 → 模型接入」中核对名称"), + ("丙接入", "助手使用的模型接入「丙接入」已停用。请在上方重新选择,或在「设置 → 模型接入」中启用它"), + ("乙一号", f"助手使用的模型接入「乙一号」已停用。{DISABLED_FIX};「乙一号」是接入「乙接入」的模型,也可在上方直接选择它"), + ): + with pytest.raises(ProviderNotConfigured) as e: + named_provider(table, name) + assert assistant_unavailable(e.value, here=True) == said + assert "助手栏" not in said + keyless = ProviderNotConfigured("模型接入「甲接入」缺少 API Key", "请在「设置 → 模型接入」中填写", code="missing_key") + assert assistant_unavailable(keyless, here=True).endswith("请到「设置 → 模型接入」检查,或在上方重新选择") + assert assistant_unavailable(keyless).endswith("请到「设置 → 模型接入」检查") + + # -------------------------------------------------------------------------- # 接口:保存设置、交给助手、画布校验(建自己的几条接入,用完删掉) # -------------------------------------------------------------------------- @@ -306,19 +476,18 @@ async def stored(key: str): return row.value if row else None -#: (组, 键, 接入这一项在界面上的叫法, 模型那一项的叫法;助手只有一个下拉框) +#: (组, 键, 接入这一项在界面上的叫法, 模型那一项的叫法) SETTING_FIELDS = [ ("verify", "provider", "核查模型 · 接入", "核查模型 · 模型"), ("judge", "provider", "裁判模型 · 接入", "裁判模型 · 模型"), ("run", "tool_gate_provider", "门控模型 · 接入", "门控模型 · 模型"), - ("copilot", "provider", "助手使用的模型", None), ] @pytest.mark.parametrize("group,key,label,model_label", SETTING_FIELDS) async def test_saving_a_broken_provider_is_worded_like_the_settings_page(client, group, key, label, model_label): """下拉框里选的接入写「指定的」,不写「填写的」;留空那半句和设置页空选项的叫法对不上,不再说(那一格就在下拉框 - 下面,空选项写着留空的意思)。助手那一项叫「助手使用的模型」,界面上没有「助手模型 · 接入」。""" + 下面,空选项写着留空的意思)。这几组的模型一栏是输入框,名字恰好是模型时写「填写」。""" r = await client.put("/api/settings", json={"values": {group: {key: GONE}}}) assert r.status_code == 422, r.text assert r.json()["detail"] == f"「{label}」指定的模型接入「{GONE}」不存在。{MISSING_FIX},或换一个接入" @@ -327,12 +496,27 @@ async def test_saving_a_broken_provider_is_worded_like_the_settings_page(client, # 名字恰好是启用接入的模型:补一句这是哪个接入的模型、在这一组里怎么用它 r = await client.put("/api/settings", json={"values": {group: {key: NAMED_LIKE_MODEL}}}) note = (f"「{NAMED_LIKE_MODEL}」是接入「{LIVE}」的模型:" - f"如需使用该模型,请在「{model_label}」中填写,「{label}」留空或选择「{LIVE}」") if model_label else \ - f"「{NAMED_LIKE_MODEL}」是接入「{LIVE}」的模型,可在「助手使用的模型」中直接选择" + f"如需使用该模型,请在「{model_label}」中填写,「{label}」留空或选择「{LIVE}」") assert r.json()["detail"] == f"「{label}」指定的模型接入「{NAMED_LIKE_MODEL}」已停用。{DISABLED_FIX},或换一个接入。{note}" assert await stored(group) is None +async def test_saving_a_broken_assistant_provider_is_worded_like_the_assistant(client): + """助手那一项只有「编排」页助手栏一个下拉框(只选模型),没有「换一个接入」的地方:422 的说法和助手取不到模型时 + 报的同一句,名字恰好是模型时并成一句。""" + picker = "「编排」页助手栏的「助手使用的模型」中" + r = await client.put("/api/settings", json={"values": {"copilot": {"provider": GONE}}}) + assert r.status_code == 422, r.text + assert r.json()["detail"] == f"助手使用的模型接入「{GONE}」不存在。{MISSING_FIX},或在{picker}重新选择" + r = await client.put("/api/settings", json={"values": {"copilot": {"provider": OFF}}}) + assert r.json()["detail"] == f"助手使用的模型接入「{OFF}」已停用。{DISABLED_FIX},或在{picker}重新选择" + r = await client.put("/api/settings", json={"values": {"copilot": {"provider": NAMED_LIKE_MODEL}}}) + assert r.json()["detail"] == (f"助手使用的模型接入「{NAMED_LIKE_MODEL}」已停用。{DISABLED_FIX};" + f"「{NAMED_LIKE_MODEL}」是接入「{LIVE}」的模型,也可在{picker}直接选择它") + assert "换一个接入" not in r.json()["detail"] + assert await stored("copilot") is None + + @pytest.mark.parametrize("group,key,label", [("run", "tool_gate_provider", "门控模型 · 接入"), ("copilot", "provider", "助手使用的模型")]) async def test_a_provider_name_that_is_not_text_is_worded_without_the_blank_half(client, group, key, label): @@ -347,6 +531,13 @@ async def test_the_verify_format_check_says_blank_follows_the_judge_model_like_t assert r.json()["detail"] == "「核查模型 · 接入」需要填写模型接入的名称或模型 ID,留空表示跟随证据裁判模型,当前为「3」" +async def test_the_judge_format_check_says_blank_follows_the_assistant_model_like_the_page(client): + """设置页裁判那一组接入下拉框的空选项是「跟随助手的模型」,不是「不指定」。""" + r = await client.put("/api/settings", json={"values": {"judge": {"provider": 3}}}) + assert r.status_code == 422 + assert r.json()["detail"] == "「裁判模型 · 接入」需要填写模型接入的名称或模型 ID,留空表示跟随助手的模型,当前为「3」" + + async def _workflow(graph: dict) -> str: async with SessionLocal() as session: wf = Workflow(name="措辞检查", graph=graph, version=1) @@ -358,14 +549,15 @@ async def _workflow(graph: dict) -> str: async def test_handing_only_provider_problems_to_the_assistant_is_one_sentence_per_step(client): - """发布弹窗里渲染成「助手:……」:说明自己不再带冒号,「核对」有宾语,不说「节点」(可能是全图默认)。有修复时指到 - 各条问题的修复;没有修复时不提。""" + """发布弹窗里渲染成「助手:……」:说明自己不再带冒号,「核对」有宾语,不说「节点」(可能是全图默认)。人刚点了 + 「交给助手」,说「助手无法处理」,不说「无法交给助手处理」(读来像没交出去)。有修复时补半句指到各条问题下方的 + 选项(那一组的标题是「选择……改用的模型接入」),不和前一个「或」连用;没有修复时不提。""" from app.api.workflows import PROVIDER_LEFT, PROVIDER_LEFT_FIX assert ":" not in PROVIDER_LEFT + PROVIDER_LEFT_FIX - assert PROVIDER_LEFT == ("剩余的问题是所用的模型接入不存在或已停用,无法交给助手处理。" + assert PROVIDER_LEFT == ("剩余的问题是所用的模型接入不存在或已停用,助手无法处理。" "请在「设置 → 模型接入」中核对名称或启用相应的接入") - assert PROVIDER_LEFT_FIX == ",或在各条问题的修复中改用其他接入" + assert PROVIDER_LEFT_FIX == ";也可在各条问题下方选择改用的模型接入" graph = flow(node("ask", "llm", "提问", provider=GONE, prompt="总结")) wf = await _workflow(graph) r = await client.post(f"/api/workflows/{wf}/autofix", json={"level": "published", "graph": graph, "assist": True}) @@ -416,13 +608,16 @@ async def test_no_model_says_which_provider_and_steps_aside_when_that_provider_i async def test_the_assistant_model_lookup_explains_a_provider_named_like_a_model(client): + """error 是生成时报的同一句(别处也这么说);panel_error 写在助手栏那一格下面,去向是「上方」的下拉框。""" async with SessionLocal() as session: session.add(Setting(key="copilot", value={"provider": NAMED_LIKE_MODEL, "model": ""})) await session.commit() got = (await client.get("/api/copilot/model")).json() - assert got["error"] == ( - f"助手使用的模型接入「{NAMED_LIKE_MODEL}」已停用。{DISABLED_FIX},或在「编排」页助手栏的「助手使用的模型」中重新选择。" - f"「{NAMED_LIKE_MODEL}」是接入「{LIVE}」的模型,可在「助手使用的模型」中直接选择") + note = f"「{NAMED_LIKE_MODEL}」是接入「{LIVE}」的模型" + assert got["error"] == (f"助手使用的模型接入「{NAMED_LIKE_MODEL}」已停用。{DISABLED_FIX};" + f"{note},也可在「编排」页助手栏的「助手使用的模型」中直接选择它") + assert got["panel_error"] == f"助手使用的模型接入「{NAMED_LIKE_MODEL}」已停用。{DISABLED_FIX};{note},也可在上方直接选择它" # 在助手栏重新选「跟随默认」:存着的接入一并清掉,不再报错 r = await client.put("/api/copilot/model", json={"model": None}) - assert r.json()["error"] is None and (await stored("copilot")) == {"provider": "", "model": ""} + assert r.json()["error"] is None and r.json()["panel_error"] is None + assert (await stored("copilot")) == {"provider": "", "model": ""} diff --git a/backend/tests/test_provider_resolution.py b/backend/tests/test_provider_resolution.py index 9f16699c..dce4a18f 100644 --- a/backend/tests/test_provider_resolution.py +++ b/backend/tests/test_provider_resolution.py @@ -119,7 +119,7 @@ async def test_a_disabled_provider_named_like_a_model_is_not_swapped_for_the_one assert e.value.code == "disabled" and "fake-v4.1-flash" in str(e.value) # 照「启用它」去做启用的是那条旧接入:另补一句这个名字是哪个接入的模型、怎么用它 assert e.value.model_owner == "云端接入" and str(e.value).endswith( - "「fake-v4.1-flash」是接入「云端接入」的模型:如需使用该模型,请在模型一栏中填写,接入留空或选择「云端接入」") + "「fake-v4.1-flash」是接入「云端接入」的模型:如需使用该模型,请在模型一栏中选择它,接入留空或选择「云端接入」") assert (await resolve_provider(session, None, "fake-v4.1-flash")).id == serving.id