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 ef0a3a17..9a4ee8ad 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -23,7 +23,8 @@ 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, build_chat_model, list_providers as all_providers, named_provider, + ModelSpec, ProviderNotConfigured, _resolve_key, assistant_unavailable, build_chat_model, + list_providers as all_providers, model_note, named_provider, ) router = APIRouter(prefix="/api", tags=["settings"]) @@ -500,7 +501,8 @@ def verify_problem(value: Any) -> str | None: label = VERIFY_SETTING_LABEL[key] if key in ("provider", "model"): if item is not None and not isinstance(item, str): - return f"「{label}」需要填写模型接入的名称或模型 ID,留空表示沿用证据裁判模型,当前为「{item}」" + # 「跟随证据裁判模型」是设置页接入下拉框空选项的原话 + return f"「{label}」需要填写模型接入的名称或模型 ID,留空表示跟随证据裁判模型,当前为「{item}」" elif key == "daily_max_usd": if not _positive(item, whole=False): return f"「{label}」需要填写大于 0 的金额(美元),留空表示不限,当前为「{item}」" @@ -728,36 +730,42 @@ class SettingsIn(BaseModel): PROVIDER_INVALID = "settings_provider_invalid" -def _provider_fields() -> dict[tuple[str, str], tuple[str, str]]: - """设置里点名模型接入的项:(组, 键) → (设置页上的叫法, 留空时用什么)。门控、裁判、核查的叫法和设置页一致;助手的 - 模型由助手面板单独设定(PUT /api/copilot/model),这里只防经设置接口写进去的那一份。""" +def _provider_fields() -> dict[tuple[str, str], tuple[str, str | None]]: + """设置里点名模型接入的项:(组, 键) → (这一项在界面上的叫法, 同一组模型那一项的叫法)。门控、裁判、核查的叫法和 + 设置页一致;助手的模型由「编排」页助手栏的「助手使用的模型」单独设定(PUT /api/copilot/model),只有这一个下拉框 + (模型那一项为 None),这里只防经设置接口写进去的那一份。""" from app.api.copilot import COPILOT_SETTING_KEY # 在这里导入:copilot 模块很大,设置模块被引擎各处导入 + from app.providers.factory import ASSISTANT_MODEL_FIELD return { - ("run", "tool_gate_provider"): ("门控模型 · 接入", "留空表示使用默认接入的默认模型"), - (JUDGE_GROUP, "provider"): (JUDGE_LABEL["provider"], "留空表示不指定"), - (VERIFY_GROUP, "provider"): (VERIFY_SETTING_LABEL["provider"], "留空表示沿用证据裁判模型"), - (COPILOT_SETTING_KEY, "provider"): ("助手模型 · 接入", "留空表示不指定"), + ("run", "tool_gate_provider"): ("门控模型 · 接入", "门控模型 · 模型"), + (JUDGE_GROUP, "provider"): (JUDGE_LABEL["provider"], JUDGE_LABEL["model"]), + (VERIFY_GROUP, "provider"): (VERIFY_SETTING_LABEL["provider"], VERIFY_SETTING_LABEL["model"]), + (COPILOT_SETTING_KEY, "provider"): (ASSISTANT_MODEL_FIELD, 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),哪里报都一样。 以前点名的接入不存在时运行悄悄换成演示模型,名字写错一个字就由演示模型写报告、做核查,现在运行时照实报错;保存时 - 先拦一道。留空照旧表示沿用上一级。只查这次改了的:存着的值没改(写好之后接入才被删、停用)不挡这次保存别的项—— - 设置页在那一项下标着「不存在」「已停用」,运行时照实报错,同设置页「只校验改过的」的做法。 + 先拦一道。留空照旧表示沿用上一级——说明里不再讲留空是什么意思:设置页那一格的空选项写着(「默认接入」「跟随助手 + 的模型」「跟随证据裁判模型」……),这里另写一遍只会对不上。只查这次改了的:存着的值没改(写好之后接入才被删、 + 停用)不挡这次保存别的项——设置页在那一项下标着「不存在」「已停用」,运行时照实报错,同设置页「只校验改过的」。 + 点名的名字恰好是某个启用接入的模型时,补一句在这一组的哪一栏用它(factory.model_note)。 名字不是字符串(接口写进来的数字一类):核查、裁判两组自己的格式检查在前面先报;门控、助手两组没有自己的检查, 在这里报,免得存下去之后读它的地方(查助手当前用哪个模型)出错。""" providers = None - for (group, key), (label, blank) in _provider_fields().items(): + for (group, key), (label, model_label) 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}」" + return f"{group}.{key}", f"「{label}」需要填写模型接入的名称,当前为「{raw}」" if (name := _model_text(raw)) is None: continue row = await session.get(Setting, group) @@ -769,7 +777,13 @@ async def _provider_problem(session: AsyncSession, values: dict[str, Any]) -> tu try: named_provider(providers, name) except ProviderNotConfigured as e: - return f"{group}.{key}", f"「{label}」填写的{e.reason}。{e.hint};{blank}" + if model_label is None: + return f"{group}.{key}", assistant_unavailable(e) + said = f"「{label}」指定的{e.reason}。{e.hint}" + if e.model_owner: + # 这几组的模型一栏是输入框:写「填写」 + 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 348e8cd2..69703cf3 100644 --- a/backend/app/api/workflows.py +++ b/backend/app/api/workflows.py @@ -14,7 +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.provider_refs import CODES as PROVIDER_CODES, load_issues, load_providers, quiet_unset_model from app.engine.schema import GraphSpec, NodeType, validate_graph router = APIRouter(prefix="/api/workflows", tags=["workflows"]) @@ -22,9 +22,14 @@ router = APIRouter(prefix="/api/workflows", tags=["workflows"]) _GONE = "工作流不存在,可能已被删除" _LEVELS = ("published", "governed") _BAD_LEVEL = "发布级别只能是「已发布」或「受管」" -#: 剩下的挡住发布的只有点名接入的问题、人又要交给助手时的说明:换成哪个接入要人拿主意,每一处都有自己的修复 -PROVIDER_LEFT = ("剩下的问题是指定的模型接入不存在或已停用,需要你决定改用哪个接入:请在每一处的修复中选择," - "或在「设置 → 模型接入」中核对") +#: 剩下的挡住发布的只有模型接入的问题、人又要交给助手时的说明:换成哪个接入要人拿主意,助手处理不了。发布弹窗里 +#: 渲染成「助手:……」,说明里不再带冒号;可能是全图默认、也可能只写了模型而提供它的接入停用了,不说「节点」「点名」。 +#: 人刚点了「交给助手」,写「助手无法处理」,不写「无法交给助手处理」(读来像没交出去) +PROVIDER_LEFT = ("剩余的问题是所用的模型接入不存在或已停用,助手无法处理。" + "请在「设置 → 模型接入」中核对名称或启用相应的接入") +#: 剩下的问题里有一条带着修复(provider_refs 按候选算得出来)才补这半句:一个候选都落不稳时那里什么都不给。弹窗里那一组 +#: 选项在每条问题下方,标题是「选择……改用的模型接入」;另起半句,不和前面的「或」连用 +PROVIDER_LEFT_FIX = ";也可在各条问题下方选择改用的模型接入" class WorkflowIn(BaseModel): @@ -490,9 +495,12 @@ async def autofix( if out["ok"]: 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"): + left = [i for i in out["remaining"] if i["level"] == "error"] + if all(i.get("code") in PROVIDER_CODES for i in left): # 剩下的只有点名接入的问题:换成哪个接入要人拿主意(助手的自查也不看接入表),不交给助手 - out["assist"] = {"ok": False, "summary": PROVIDER_LEFT, "questions": []} + fixable = any(i.get("fix") for i in left) + out["assist"] = {"ok": False, "summary": PROVIDER_LEFT + (PROVIDER_LEFT_FIX if fixable else ""), + "questions": []} return out from app.api.copilot import assist_publish_fix @@ -523,14 +531,16 @@ async def validate(payload: ValidateIn, session: AsyncSession = Depends(get_sess """画布上的实时校验。前端每次改动都会调它来标红。 点名的模型接入不存在或已停用(只写了模型、提供它的接入已停用也算)只给警告:探索运行仍可发起,执行到那个节点时 - 照实报错;发布时才挡住。""" + 照实报错;发布时才挡住。这时同一节点上「没有指定模型,将使用接入「X」的默认模型」和它矛盾,不再列 + (quiet_unset_model,发布一类在 governance._not_repeated 里同样处理)。""" 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).model_dump() - result["issues"] += [i.model_dump() for i in await load_issues(session, spec, level="warning")] - return result + result = validate_graph(spec) + found = await load_issues(session, spec, level="warning") + result.issues = [*quiet_unset_model(spec, result.issues, found), *found] + return result.model_dump() @router.post("/variables") diff --git a/backend/app/engine/autofix.py b/backend/app/engine/autofix.py index 1054356e..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]: """这个节点上游的某几类节点,按画布上的节点顺序。""" @@ -517,98 +527,174 @@ 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):改用一个启用的接入,或者不再指定。点名的接入不存在、已停用 - 的,候选是「不指定接入」和各个启用的接入;只写了模型、提供它的接入已停用的,是「不指定模型」和各个启用的接入 - (写在模型旁边的接入上)。 + """执行时取不到模型的那一处(engine/provider_refs.py):改用一个启用的接入,或者不再指定(provider_choice)。""" + 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 - 要人选、不给默认:换成哪个接入只有人知道(同一个模型可能有两家提供,价格、配额都不同)。多数节点的检查器里没有 - 接入下拉框(只能选模型),全图默认、协作成员也没有,这里是界面上唯一能改它的地方。一处一条(plan_fixes 的 id - 带字段):协作节点自己的接入和成员的接入各修各的。 + +class _ProviderPlan: + """图里一处点名的接入(provider_refs.ProviderRef)的修复候选,一个一个算:provider_choice 要全部,provider_fixable + (问题提示指不指到发布前检查)找到第一个落得稳的就停。 + + 点名的接入不存在、已停用的,候选是「不指定接入」和各个启用的接入;只写了模型、提供它的接入已停用的,是「不指定 + 模型」和各个启用的接入(写在模型旁边的接入上)。 每个候选先在副本上改,按执行时的取法(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) + 改的是节点上的一处时只重算这个节点(provider_refs.node_uses:节点只读自己的配置和全图默认),不复制、不重算整张图; + 改的是全图默认时各节点都可能跟着变,整张图重算(这样的引用最多几处)。""" + + 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: + """这一处点名的接入改动前后没变,而它本来就不存在或已停用:落不稳不是这次改动造成的。""" + 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(self.providers, use.provider.name) + except ProviderNotConfigured: + return True + return False + + 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 + + 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 uses(spec): - if before.get(use.key) == use: + for use in after: + if self.before.get(use.key) == use: continue - if not (landing := land(use, ctx.providers)).ok: - return None + if not (landing := land(use, self.providers)).ok: + if self._broken_before(use): + continue + return None, landing landed.append((use, landing)) - return landed + return landed or None, None - def settle(edits: list[tuple[str, Any]]) -> tuple[list[tuple[str, Any]], list[tuple[Any, Any]]] | 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 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 + 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 +DEMO_PROVIDER = "演示用的模型接入,不调用真实模型" _PLANNERS: dict[str, Callable[[_Ctx, dict[str, Any], GraphNode | None], dict[str, Any] | None]] = { @@ -698,9 +784,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 是同一个退路。 + + 点名的模型接入(_PER_FIELD)一处一条修复,只认(编号、节点、字段)都对得上的那一条,没有就是没有:这一处一个 + 候选都落不稳时修复为空,退到同一节点、同一编号别处的修复上,接口里的 fix 就指到了另一处的修复。 """ 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} @@ -713,8 +801,10 @@ 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 = 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 code in _PER_FIELD: + out.append({**issue, "fix": exact.get((code, issue.get("node_id"), issue.get("field")))}) + continue + fid = 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}) diff --git a/backend/app/engine/governance.py b/backend/app/engine/governance.py index 7b583de3..79dab048 100644 --- a/backend/app/engine/governance.py +++ b/backend/app/engine/governance.py @@ -111,9 +111,9 @@ 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 + from app.engine.provider_refs import issues as provider_issues - for issue in provider_issues(provider_refs(spec), providers, level="error"): + for issue in provider_issues(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: @@ -164,7 +164,11 @@ def _not_repeated(issues: list[Any], gate: list[Any], spec: GraphSpec) -> list[A G5 落在 agent 上,validate 那条也落在 agent 上;G4 落在喂数的沙箱代码上,validate 那条落在口径卡读它的 那条指标上,按口径卡的输入对回去。没被门禁覆盖的(比如不给口径卡供数的 agent)照旧提示。 + 节点点名的接入不存在或已停用(门禁的 provider.*)时,validate 那条「没有指定模型,将使用接入「X」的默认模型」 + 和它矛盾,同样不列(provider_refs.quiet_unset_model)。 """ + from app.engine.provider_refs import quiet_unset_model + cited = {i.node_id for i in gate if i.code == "governed.caliber_agent_cite_fields"} compute = {i.node_id for i in gate if i.code == "governed.caliber_compute_input"} covered = {("evidence.cite_fields_off", nid, "output_schema") for nid in cited} @@ -172,7 +176,8 @@ def _not_repeated(issues: list[Any], gate: list[Any], spec: GraphSpec) -> list[A if card.type == NodeType.METRICS and compute: covered |= {("evidence.caliber_compute_input", card.id, f"metrics[{i}].expression") for i, producer in _caliber_inputs(card, spec) if producer.id in compute} - return [i for i in issues if i.code not in _REPEATS or (i.code, i.node_id, i.field) not in covered] + kept = [i for i in issues if i.code not in _REPEATS or (i.code, i.node_id, i.field) not in covered] + return quiet_unset_model(spec, kept, gate) def _lint_contract( 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 7014745a..4aa4f4ca 100644 --- a/backend/app/engine/provider_refs.py +++ b/backend/app/engine/provider_refs.py @@ -20,7 +20,13 @@ 说法分两种:检查器里有这一项下拉框的几处(双盲复算、反方审稿、节点自己的结论句裁判的接入和模型,几类节点自己的模型) 说「在节点中换一个接入 / 模型」;别处(节点自己的接入、协作成员、全图默认)界面上没有下拉框,说「在发布前检查中改用 其他接入」——发布前检查给这类问题配了修复(engine/autofix.py 的 provider.*:改用一个启用的接入,或不再指定),修复 -按 land 算出修完以后执行时落到哪里,落不稳的候选不给。 +按 land 算出修完以后执行时落到哪里,落不稳的候选不给。一个候选都落不稳时修复为空,这时不指到发布前检查(那里什么 +都不给),说去设置里核对、启用,再说卡在哪里(第一处落不稳的地方,比如成员只写了停用接入的模型)——不然人去设置里 +找那个写错的名字,找不到,也看不出真正挡住修复的是别处。点名的名字恰好是某个启用接入的模型时,末尾补一句这是哪个 +接入的模型、在这一处怎么用它(_model_note:按检查器里有哪几栏写,不叫人去一个没有的「接入」栏留空)。 + +节点没写模型、点名的接入(自己的或全图默认的)本身不存在或已停用时,校验那条「没有指定模型,将使用接入「X」的 +默认模型」(schema.MODEL_UNSET)和这里的问题互相矛盾,由 quiet_unset_model 去掉前者。 接入表要读库,门禁本身不查库(governance.lint_for_publish),所以由接口这一层读出接入表交进来,同数据目录 SQL 检查器的做法。设置里点名接入的几项由 api/settings.py 在保存时查。 @@ -32,17 +38,26 @@ 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.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, named_provider, provider_for_model, serves, + NAMED_FIX, ProviderNotConfigured, fallback_provider, list_providers, model_fact, model_note, 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): "或在「发布前检查」中改用其他接入"} +#: 提示的后半句:这一处写的是接入还是模型、检查器里有没有这一项的下拉框。检查器里没有下拉框的,发布前检查给了修复 +#: 才指到那里(IN_CHECK),否则不写后半句 +_WHERE = {"provider": "或在节点中换一个接入", "model": "或在节点中换一个模型"} +IN_CHECK = "或在「发布前检查」中改用其他接入" +#: 检查器里没有下拉框、发布前检查也给不出修复时,提示末尾说卡在哪里(Landing.why) +NO_SWITCH = "无法改用其他接入:" +#: 一个启用的接入都没有:没点名接入的执行时换成内置演示模型,点名的调不了 +NO_ENABLED = "没有启用的模型接入" +#: 修完以后执行时落到演示类接入(kind 为 mock):说明里照实写。K1 要防的就是悄悄换成演示模型——落到它是人选的, +#: 得让人知道选的是什么 +DEMO_LANDING = "(演示用,不调用真实模型)" #: 检查器里有模型下拉框的几类节点(前端 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) @@ -76,7 +91,8 @@ _TAKES_MODEL: dict[NodeType, Callable[[GraphSpec, GraphNode], bool] | None] = { NodeType.VALIDATE: _repairs, NodeType.RETRIEVE: lambda spec, node: _cfg(spec, node, "rerank", "off") == "model", } -#: 核查节点:接入只读节点自己的配置(verify_model_spec),图级默认的接入是写作模型的 +#: 核查节点:接入只读节点自己的配置(verify_model_spec),图级默认的接入是写作模型的。值是修复标题里的称呼 +#:(「选择……的盲算模型改用的模型接入」);问题里说「……的「盲算模型 · 接入」指定的」,写检查器上那一栏的叫法 _VERIFY = {NodeType.DUAL_CHECK: "的盲算模型", NodeType.CHALLENGE: "的反方模型"} @@ -87,7 +103,10 @@ def _name(value: Any) -> str | None: @dataclass(frozen=True) class ProviderRef: """图里写着接入或模型的一处:落在哪个节点(全图默认为 None)、哪个字段,写的名字,句子里怎么称呼这一处,检查器里 - 有没有这一项的下拉框;kind 是写的接入(provider)还是模型(model)。""" + 有没有这一项的下拉框;kind 是写的接入(provider)还是模型(model)。 + + who 是修复标题里的称呼(「选择{who}改用的模型接入」);said 是「{said}指定的模型接入「X」」里的主语,默认同 who。 + 核查节点两者不同:指定接入的是「盲算模型 · 接入」这一栏,不是盲算模型本身。""" node_id: str | None field: str @@ -95,12 +114,17 @@ class ProviderRef: who: str editable: bool = False kind: str = "provider" + said: str = "" + + @property + def subject(self) -> str: + return self.said or self.who def _place(node_id: str | None, field: str, value: Any, who: str, *, editable: bool = False, - kind: str = "provider") -> ProviderRef | None: + kind: str = "provider", said: str = "") -> ProviderRef | None: name = _name(value) - return ProviderRef(node_id, field, name, who, editable, kind) if name else None + return ProviderRef(node_id, field, name, who, editable, kind, said) if name else None @dataclass(frozen=True) @@ -123,48 +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) - 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 + return [use for node in spec.nodes for use in node_uses(spec, node)] def provider_refs(spec: GraphSpec) -> list[ProviderRef]: @@ -192,23 +224,94 @@ def broken(refs: list[ProviderRef], providers: list[Any]) -> list[tuple[Provider return out -def issues(refs: list[ProviderRef], providers: list[Any], *, level: str) -> list[ValidationIssue]: +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_fixable + + 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。原因和运行时报的同一句,提示按这一处在界面上能不能改。""" - 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)] + 发布一类 error。原因和运行时报的同一句,提示按这一处在界面上能不能改(_tail);点名的名字恰好是某个启用接入的 + 模型时补一句(_model_note)。""" + out = [] + for ref, e in broken(provider_refs(spec), providers): + 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]}" + f"{_tail(spec, ref, providers)}{note}")) + return out + + +def quiet_unset_model(spec: GraphSpec, found: list[Any], provider_issues: list[Any]) -> list[Any]: + """去掉被接入问题盖住的「没有指定模型,将使用接入「X」的默认模型」(schema.MODEL_UNSET)。 + + 节点没写模型、取到的接入(自己点名的,没写就是全图默认点名的,同 NodeContext.cfg)本身不存在或已停用时,那条说 + 会用 X 的默认模型照常执行,接入那一条说执行到这里会失败,读者无从判断哪条是真的。接入那一条带着原因和改法,留它。 + found 是校验的问题(ValidationIssue),provider_issues 是 issues() 的结果;画布校验和发布一类各调一次。""" + hit = {(i.node_id, i.field) for i in provider_issues if i.code in CODES} + if not hit: + return found + nodes = spec.node_map() + + def covered(issue: Any) -> bool: + if issue.code != MODEL_UNSET or (node := nodes.get(issue.node_id)) is None: + return False + own = node.config.get("provider") not in (None, "") + return ((node.id, "provider") if own else (None, "defaults.provider")) in hit + + return [i for i in found if not covered(i)] @dataclass(frozen=True) class Landing: """执行时这一处落到哪里:where 是说给人听的(「工作流默认设置指定的接入「甲」」……),model 是用哪个模型(交给设置时 - 为 None),ok 是修复能不能把它落在这里。""" + 为 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: @@ -216,40 +319,54 @@ 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) - where = f"{use.provider.who}指定的接入「{found.name}」" - elif model: + return Landing(e.reason, model, False, why=f"{use.provider.subject}指定的{e.reason}") + where = f"{use.provider.subject}指定的接入「{found.name}」" + 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}」" + 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]: """读接入表、查一遍。图里执行时取模型的几处都没写接入和模型时不读库。""" - refs = provider_refs(spec) - if not refs: + if not provider_refs(spec): return [] - return issues(refs, await list_providers(session), level=level) + return issues(spec, await list_providers(session), level=level) async def load_providers(session: AsyncSession, spec: GraphSpec) -> list[Any] | None: diff --git a/backend/app/engine/schema.py b/backend/app/engine/schema.py index 6a607845..7a9434b9 100644 --- a/backend/app/engine/schema.py +++ b/backend/app/engine/schema.py @@ -606,6 +606,19 @@ JUDGE_KEYS = ("provider", "model", "max_claims", "max_cost_usd", "timeout_s", "r #: 证据相矛盾时正式运行怎么判档:degrade 降档(默认)/ withhold 不予出具。探索运行只标注 JUDGE_ON_UNSUPPORTED = ("degrade", "withhold") +#: 「没有指定模型」那条提示的编号。节点(或全图默认)点名了接入、而那个接入本身不存在或已停用时,它和接入那条问题 +#: 互相矛盾(一条说照常执行、一条说会失败),接口层读了接入表以后按编号去掉它(provider_refs.quiet_unset_model) +MODEL_UNSET = "model.unset" + + +def _named_provider_of(spec: GraphSpec, node: GraphNode) -> str | None: + """节点执行时点名的接入名:节点上没写(None、空串)就取全图默认,同 NodeContext.cfg;只有空白、不是字符串的不算 + 点了名(同 provider_refs._name)。""" + value = node.config.get("provider") + if value in (None, ""): + value = spec.defaults.get("provider") + return value.strip() or None if isinstance(value, str) else None + def claims_problem(value: Any) -> str | None: """claims 的取值有什么问题,没问题返回 None。校验和报告撰写节点执行时报的是同一句话。""" @@ -929,11 +942,15 @@ def validate_graph(spec: GraphSpec) -> ValidationResult: if node.type in (NodeType.LLM, NodeType.AGENT, NodeType.SUPERVISOR, NodeType.REPORT): if not (cfg.get("model") or spec.defaults.get("model")): + # 点名了接入就用那个接入的默认模型,照实说是哪个:检查器、问题面板里只写「默认模型」,人会以为是第一个 + # 可用的接入。那个接入本身不存在或已停用时,接口层去掉这一条,只留接入的问题(MODEL_UNSET) + named = _named_provider_of(spec, node) result.add( - "没有指定模型,将使用默认模型", + f"没有指定模型,将使用接入「{named}」的默认模型" if named else "没有指定模型,将使用默认模型", level="warning", node_id=node.id, field="model", + code=MODEL_UNSET, ) # 表达式:用运行时同一个 parse_expression 先解析一遍。以前写错了只有跑到那一步 diff --git a/backend/app/providers/factory.py b/backend/app/providers/factory.py index f6cb0a66..b94ae6d6 100644 --- a/backend/app/providers/factory.py +++ b/backend/app/providers/factory.py @@ -34,13 +34,19 @@ class ModelSpec(BaseModel): class ProviderNotConfigured(RuntimeError): """模型接入没配好。reason 说缺了什么,hint 说去哪里补;str(e) 是两句连起来的整句, 运行失败原因直接用它。设置页的测试弹窗本来就在接入的编辑框里,只取 reason、另给自己的 hint。 - code 给调用方按类别分支:missing(点名的接入不存在)/ 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) + name、model_owner 只有 named_provider 填:点名的名字(去掉两边空白)恰好是某个启用接入的模型时,model_owner 是 + 那个接入的名称。str(e) 末尾补上 model_note 那一句;画布、设置页、助手各自另拼,说清在哪一栏改(同 hint)。""" + + def __init__(self, reason: str, hint: str = "", code: str = "", *, name: str = "", model_owner: str = "") -> None: + note = model_note(name, model_owner) if model_owner else "" + super().__init__("。".join(part for part in (reason, hint, note) if part)) self.reason = reason self.hint = hint self.code = code + self.name = name + self.model_owner = model_owner async def list_providers(session: AsyncSession) -> list[Provider]: @@ -48,24 +54,54 @@ 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_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 是这一处模型、接入两栏的叫法, + 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: + """提供这个模型的启用接入,认法同 provider_for_model(先认模型列表、再认默认模型)。只看启用的:停用接入的模型 + 补了这一句,照着做也一样调不了。""" + enabled = [p for p in providers if p.enabled] + return next((p for p in enabled if any((m or {}).get("id") == name for m in (p.models or []))), None) \ + or next((p for p in enabled if p.default_model == name), None) def named_provider(providers: list[Provider], ref: Any) -> Provider: """按名称或 id 找显式点名的接入(providers 是 list_providers 的结果)。找不到、找到的已停用都抛 ProviderNotConfigured:点名了就只用它,不换别家。运行时取模型、设置和画布校验都按这一处认,说法一致。 - 名称两边的空白不算;不是字符串的(接口写进来的数字一类)当作点了一个不存在的名字。""" + 名称两边的空白不算;不是字符串的(接口写进来的数字一类)当作点了一个不存在的名字。点名的名字恰好是某个启用接入 + 的模型时,异常带上 model_owner,报错补一句这是哪个接入的模型、怎么用它(model_note)。""" 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 not None and found.enabled: + return found + owner = _model_owner(providers, key) + trap = {"name": key, "model_owner": owner.name} if owner is not None else {} 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 + raise ProviderNotConfigured(f"模型接入「{key}」不存在", f"{NAMED_FIX['missing']},或换一个接入", code="missing", + **trap) + raise ProviderNotConfigured(f"模型接入「{found.name}」已停用", f"{NAMED_FIX['disabled']},或换一个接入", + code="disabled", **trap) def serves(provider: Any, model: str) -> bool: @@ -100,17 +136,36 @@ def fallback_provider(providers: list[Provider]) -> Provider | None: return (real or enabled or [None])[0] -def assistant_unavailable(e: BaseException) -> str: +#: 助手的模型在界面上只有一处能改:「编排」页助手栏的「助手使用的模型」(设置页里没有这一项)。选「跟随默认」或一个 +#: 模型都会清掉存着的接入。画布、问数据页、表格导入、数据目录起草报的是同一句,所以写明是哪一页 +ASSISTANT_MODEL_FIELD = "助手使用的模型" +ASSISTANT_PICKER = f"「编排」页助手栏的「{ASSISTANT_MODEL_FIELD}」中" +ASSISTANT_REPICK = f"或在{ASSISTANT_PICKER}重新选择" + + +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: - return f"助手使用的{getattr(e, 'reason', '') or e}。{NAMED_FIX[code]},或为助手换一个模型" - 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 639b4da2..a2537f9e 100644 --- a/backend/tests/test_provider_ref_checks.py +++ b/backend/tests/test_provider_ref_checks.py @@ -6,7 +6,8 @@ - 画布校验:节点点名的接入不存在或已停用给警告,说清哪个节点、哪个接入;发布前检查、发布、自动修复的复核给错误, 挡住发布。只查执行时真会去取模型的那几处(分支「让模型分类」、校验「让模型修复」且允许重试、检索「让模型重排」 时才算;核查节点自己的开关关着不算);报告没写裁判配置时查全图默认里的那一份; -- 提示按这一处在界面上能不能改:检查器里有接入下拉框的说「在节点中换一个接入」,别处说「在发布前检查中改用」; +- 提示按这一处在界面上能不能改:检查器里有接入下拉框的说「在节点中换一个接入」,别处在发布前检查给得出修复时 + 说「在发布前检查中改用」(给不出时不说,见 test_provider_ref_wording); - 发布前检查给每一处配一条要人选的修复:改用一个启用的接入,或不指定接入(修完以后执行时落到哪里、只写了模型而 提供它的接入已停用的那一处,见 test_provider_ref_landing)。 @@ -72,18 +73,25 @@ FIELDS = [ ("verify", "provider", "核查模型 · 接入"), ("judge", "provider", "裁判模型 · 接入"), ("run", "tool_gate_provider", "门控模型 · 接入"), - ("copilot", "provider", "助手模型 · 接入"), + ("copilot", "provider", "助手使用的模型"), ] +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"].startswith(f"「{label}」填写的模型接入「{GONE}」不存在。"), body["detail"] - assert "请在「设置 → 模型接入」中核对名称,或换一个接入" in body["detail"] and "留空表示" in body["detail"] + assert body["detail"] == refused(group, label, f"模型接入「{GONE}」不存在", "请在「设置 → 模型接入」中核对名称") assert await stored(group) is None @@ -93,8 +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"].startswith(f"「{label}」填写的模型接入「{OFF}」已停用。"), body["detail"] - assert "请在「设置 → 模型接入」中启用它,或换一个接入" in body["detail"] + assert body["detail"] == refused(group, label, f"模型接入「{OFF}」已停用", "请在「设置 → 模型接入」中启用它") assert await stored(group) is None @@ -207,8 +214,9 @@ async def test_every_place_a_graph_names_a_provider_is_checked(client): 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[("dual", "provider")]["message"].startswith( + f"「复核销售额」(双盲复算)的「盲算模型 · 接入」指定的模型接入「{GONE}」") + assert got[("chal", "provider")]["message"].startswith(f"「审稿」(反方审稿)的「反方模型 · 接入」指定的模型接入「{OFF}」已停用") assert got[(None, "defaults.provider")]["message"].startswith(f"工作流默认设置指定的模型接入「{GONE}」不存在") @@ -281,14 +289,15 @@ async def test_publish_check_reads_the_unsaved_canvas_and_autofix_keeps_the_prob async def test_handing_only_a_provider_problem_to_the_assistant_says_it_cannot_help(client): - """修复之后门禁不再拦、只剩点名接入的问题:不说「已没有阻止发布的错误」,说清助手处理不了、该去哪里改。""" - from app.api.workflows import PROVIDER_LEFT + """修复之后门禁不再拦、只剩点名接入的问题:不说「已没有阻止发布的错误」,说清助手处理不了、该去哪里改(这一处 + 有修复,连同各条问题的修复一起说;没有修复时的说法见 test_provider_ref_wording)。""" + from app.api.workflows import PROVIDER_LEFT, PROVIDER_LEFT_FIX 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["assist"] == {"ok": False, "summary": PROVIDER_LEFT + PROVIDER_LEFT_FIX, "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), @@ -303,15 +312,14 @@ async def test_publishing_a_clean_graph_is_unaffected(client): @pytest.mark.parametrize("group,key,label", [("run", "tool_gate_provider", "门控模型 · 接入"), - ("copilot", "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 body["detail"] == f"「{label}」需要填写模型接入的名称,当前为「123」", body["detail"] 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 494e3a26..58db6c83 100644 --- a/backend/tests/test_provider_ref_landing.py +++ b/backend/tests/test_provider_ref_landing.py @@ -103,8 +103,11 @@ def test_a_node_naming_only_a_model_of_a_disabled_provider_is_stopped_before_pub 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": "分析"}]), @@ -112,10 +115,11 @@ def test_the_problem_is_recorded_once_where_the_model_is_written(): agents=[{"name": "研究员", "model": "丙一号", "system": "研究"}]), defaults={"model": "丙一号"}) got = [(i["node_id"], i["field"], i["message"]) for i in gate(graph)] - tail = "请在「设置 → 模型接入」中启用它,或在「发布前检查」中改用其他接入" + enable = "请在「设置 → 模型接入」中启用它" assert got == [ - ("team2", "agents[0].model", f"「协作二」(多 Agent 协作)的成员「研究员」指定的模型「丙一号」{STOPPED}。{tail}"), - (None, "defaults.model", f"工作流默认设置指定的模型「丙一号」{STOPPED}。{tail}"), + ("team2", "agents[0].model", f"「协作二」(多 Agent 协作)的成员「研究员」指定的模型「丙一号」{STOPPED}。{enable}。" + f"无法改用其他接入:工作流默认设置指定的模型「丙一号」{STOPPED}"), + (None, "defaults.model", f"工作流默认设置指定的模型「丙一号」{STOPPED}。{enable},或在「发布前检查」中改用其他接入"), ] @@ -127,7 +131,7 @@ def test_verify_nodes_and_the_judge_naming_only_a_model_of_a_disabled_provider_a 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[("dual", "model")] == (f"「复核销售额」(双盲复算)的「盲算模型」指定的模型「丙一号」{STOPPED}。" "请在「设置 → 模型接入」中启用它,或在节点中换一个模型") assert got[("write", "judge.model")].startswith(f"「写周报」(报告撰写)的结论句裁判指定的模型「丙一号」{STOPPED}") diff --git a/backend/tests/test_provider_ref_runtime.py b/backend/tests/test_provider_ref_runtime.py index ccb51ee9..f895bb82 100644 --- a/backend/tests/test_provider_ref_runtime.py +++ b/backend/tests/test_provider_ref_runtime.py @@ -430,9 +430,11 @@ async def test_judging_on_demand_with_a_broken_judge_provider_says_why(monkeypat # -------------------------------------------------------------------------- -#: 助手报给人的那一句:照实说是哪个接入、在设置里能做什么,或者给助手换一个模型(不再说「尚未配置完成」) -SAID = {MISSING: f"助手使用的{REASONS[MISSING]}。请在「设置 → 模型接入」中核对名称,或为助手换一个模型", - DISABLED: f"助手使用的{REASONS[DISABLED]}。请在「设置 → 模型接入」中启用它,或为助手换一个模型"} +#: 助手报给人的那一句:照实说是哪个接入、在设置里能做什么,或者到「编排」页助手栏重新选一个模型(设置页里没有助手的 +#: 接入这一项,能改的只有那个下拉框;不再说「尚未配置完成」) +REPICK = "或在「编排」页助手栏的「助手使用的模型」中重新选择" +SAID = {MISSING: f"助手使用的{REASONS[MISSING]}。请在「设置 → 模型接入」中核对名称,{REPICK}", + DISABLED: f"助手使用的{REASONS[DISABLED]}。请在「设置 → 模型接入」中启用它,{REPICK}"} @pytest.mark.parametrize("ref", [MISSING, DISABLED]) @@ -591,8 +593,9 @@ async def test_after_the_publish_fix_stops_naming_a_disabled_provider_the_formal [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}」" + # 没点名接入、也没写模型时落到第一个可用的接入:本文件只建了一个启用的接入,是演示类的,说明里照实写 + 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() diff --git a/backend/tests/test_provider_ref_wording.py b/backend/tests/test_provider_ref_wording.py new file mode 100644 index 00000000..65f070ef --- /dev/null +++ b/backend/tests/test_provider_ref_wording.py @@ -0,0 +1,623 @@ +"""点名的模型接入(K1)收尾:说法与修复候选。 + +K1 让点名的接入不存在或已停用时照实报错、发布前挡住并给修复。复核留下的几处: +- 核查节点那句「……的盲算模型指定的模型接入」:指定接入的是「盲算模型 · 接入」这一栏,不是模型本身; +- 修复规划:没有候选能落稳时修复为空,问题却仍说「或在「发布前检查」中改用其他接入」;按(编号、节点、字段)认不出修复 + 时退到同节点、同编号别处的修复,接口里的 fix 指错;改动波及、但本来就因自己点名的接入坏着的那一处也算落不稳,两处 + 互相牵连时谁都给不出修复; +- 「不指定接入」落到演示类接入时,说明不提「演示用、不调用真实模型」——K1 要防的就是悄悄换成演示模型; +- 点名的接入名恰好是某个启用接入的模型:提示只说去启用那条旧接入,用户多半想要的是这个模型; +- 保存设置 422 的说法:下拉框写成「填写的」,留空那半句和设置页的叫法对不上,助手那一项叫法界面上没有; +- 「交给助手」后只剩接入问题时的说明:一句两个冒号、「核对」没有宾语; +- 同一节点同时提示「没有指定模型,将使用默认模型」和「指定的模型接入不存在」,互相矛盾; +- 助手的模型取不到时,提示指到真正能改它的地方(「编排」页助手栏的「助手使用的模型」)。 + +第 1 轮复核又留下的几处: +- 「不指定接入」交给设置(核查、裁判)时,落到演示类接入也没说; +- 「接入名恰好是模型名」那句叫人「接入留空」,多数节点的检查器里没有接入这一栏,模型又是下拉框却写「填写」; +- 给不出修复时只剩「核对名称」,没说真正卡住修复的是哪一处; +- 「演示用,不调用真实模型」只是淡色小字:候选另带 demo 标记,界面上加醒目的标签; +- 画布校验为每处坏掉的接入把每个候选都在整张图上算一遍,几十处同时坏掉时开销随规模平方增长; +- 助手那一项的 422 说法和助手报错、助手栏对不上;助手栏下拉下面的原因把人指回这个下拉本身; +- 裁判的格式检查仍写「留空表示不指定」;「交给助手」后只剩接入问题时的说明措辞。 + +纯函数的用例用虚构的接入表,不读库;接口的用例建自己的几条接入、用完删掉。接入名、节点名、模型名都是虚构的。 +""" +from __future__ import annotations + +from types import SimpleNamespace + +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.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 +from app.engine.schema import GraphSpec +from app.main import app +from app.providers.factory import ProviderNotConfigured, assistant_unavailable, named_provider + + +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) +#: 停用的接入恰好叫「乙一号」——启用的乙接入提供同名的模型(开发数据里的原样:模型名被建成了一条接入) +TRAP = provider("乙一号", "旧一号", enabled=False) +TABLE = [JIA, YI, BING] +DEMO = provider("演示接入", "演示一号", kind="mock") +DEMO_NOTE = "(演示用,不调用真实模型)" +MISSING_FIX, DISABLED_FIX = "请在「设置 → 模型接入」中核对名称", "请在「设置 → 模型接入」中启用它" +IN_CHECK = ",或在「发布前检查」中改用其他接入" +NO_SWITCH = "。无法改用其他接入:" + + +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) -> dict[tuple, dict]: + found = provider_issues(publish_issues(GraphSpec.model_validate(graph), level="published", providers=table)) + return {(i["node_id"], i["field"]): i for i in found} + + +def checked(graph: dict, table=TABLE) -> dict: + return check(GraphSpec.model_validate(graph), level="published", providers=table) + + +def fix_of(graph: dict, fid: str, table=TABLE) -> dict | None: + return next((f for f in checked(graph, table)["fixes"] if f["id"] == fid), None) + + +def hints(fix: dict) -> dict: + return {o["value"]: o.get("hint") for o in fix["options"]} + + +# -------------------------------------------------------------------------- +# 核查节点:指定接入的是「盲算模型 · 接入」这一栏 +# -------------------------------------------------------------------------- + + +def test_a_verify_node_names_the_field_that_points_at_the_provider(): + """双盲复算、反方审稿:问题里写检查器上那一栏的叫法(「盲算模型 · 接入」「反方模型」),不说「盲算模型指定的接入」。 + 修复的标题仍按角色说(「选择……的盲算模型改用的模型接入」),读得通。""" + graph = flow(node("write", "report", "写周报", instructions="写周报", provider="甲接入"), + node("dual", "dual_check", "复核销售额", provider="戊接入"), + node("chal", "challenge", "审稿", report="write", provider="丙接入"), + node("chal2", "challenge", "复审", report="write", model="丙一号")) + got = gate(graph) + assert got[("dual", "provider")]["message"] == ( + f"「复核销售额」(双盲复算)的「盲算模型 · 接入」指定的模型接入「戊接入」不存在。{MISSING_FIX},或在节点中换一个接入") + assert got[("chal", "provider")]["message"] == ( + f"「审稿」(反方审稿)的「反方模型 · 接入」指定的模型接入「丙接入」已停用。{DISABLED_FIX},或在节点中换一个接入") + assert got[("chal2", "model")]["message"] == ( + "「复审」(反方审稿)的「反方模型」指定的模型「丙一号」所属的模型接入「丙接入」已停用。" + f"{DISABLED_FIX},或在节点中换一个模型") + assert fix_of(graph, "provider.missing:dual:provider")["label"] == "选择「复核销售额」(双盲复算)的盲算模型改用的模型接入" + + +# -------------------------------------------------------------------------- +# 没有修复时:提示不指到发布前检查,也不借别处的修复 +# -------------------------------------------------------------------------- + + +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}{NO_SWITCH}" + "「协作」(多 Agent 协作)的成员「研究员」指定的模型「丙一号」所属的模型接入「丙接入」已停用") + [warn] = provider_ref_issues(GraphSpec.model_validate(graph), TABLE, level="warning") + assert warn.message == issue["message"] + + graph["nodes"][1]["data"]["config"]["agents"][0]["model"] = "甲二号" + [issue] = provider_issues(checked(graph)["issues"]) + assert issue["fix"] == "provider.missing:team:provider" + 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(): + """全图默认点名停用的丙,节点「提问」只写了丙家的模型(跟随全图默认的接入),节点「复述」自己点名丙:复述那一处有 + 修复(改用甲、乙),全图默认那一处没有(换掉或去掉,提问都落不稳)。全图默认那条不能认到复述的修复上去。""" + graph = flow(node("a", "llm", "提问", model="丙一号", prompt="总结"), + node("b", "llm", "复述", provider="丙接入", model="乙一号", prompt="复述"), + defaults={"provider": "丙接入"}) + found = checked(graph) + got = {(i["node_id"], i["field"]): i for i in provider_issues(found["issues"])} + 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}{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) + + +# -------------------------------------------------------------------------- +# 本来就因自己点名的接入坏着的那一处,不否决别处的修复 +# -------------------------------------------------------------------------- + + +def test_a_place_broken_by_its_own_provider_does_not_veto_a_fix_elsewhere(): + """「提问」点名不存在的戊接入、模型取全图默认的丙一号(只有停用的丙提供),「复述」跟随全图默认。去掉全图默认的模型 + 以后,提问照样因为戊接入不存在而失败——这不是这次改动造成的,不拿它否决全图默认的修复;否则两条都没有修复, + 规划器自己卡住。先修全图默认,提问的修复随之出现(不指定接入、改用甲、改用乙)。""" + graph = flow(node("a", "llm", "提问", provider="戊接入", prompt="总结"), + node("b", "llm", "复述", prompt="复述"), + defaults={"model": "丙一号"}) + found = checked(graph) + got = {(i["node_id"], i["field"]): i for i in provider_issues(found["issues"])} + assert set(got) == {("a", "provider"), (None, "defaults.model")} + fid = "provider.disabled:graph:defaults.model" + assert got[(None, "defaults.model")]["fix"] == fid + assert got[("a", "provider")]["fix"] is None + assert hints(fix_of(graph, fid))[""] == "去掉这一处指定的模型,执行时改用第一个可用的模型接入「甲接入」" + + out = apply_fixes(graph, [fid], {fid: ""}, level="published", providers=TABLE) + assert out["applied"] == [fid] and out["rejected"] == [], out["rejected"] + assert "model" not in (out["graph"].get("defaults") or {}) + [left] = provider_issues(out["remaining"]) + assert (left["node_id"], left["field"], left["fix"]) == ("a", "provider", "provider.missing:a:provider") + [fix] = [f for f in out["fixes"] if f["id"] == left["fix"]] + assert [o["value"] for o in fix["options"]] == ["", "甲接入", "乙接入"] + + +def test_the_place_being_fixed_is_never_waved_through(): + """被修的那一处自己换了接入引用:去掉节点的戊接入以后落到全图默认的戊接入,原因一字不差也不算修好。""" + graph = flow(node("ask", "llm", "提问", provider="戊接入", prompt="总结"), defaults={"provider": "戊接入"}) + found = checked(graph) + fid = "provider.missing:ask:provider" + [fix] = [f for f in found["fixes"] if f["id"] == fid] + assert "" not in hints(fix) + out = apply_fixes(graph, [fid], {fid: ""}, level="published", providers=TABLE) + assert out["applied"] == [], out + + +# -------------------------------------------------------------------------- +# 落到演示类接入时照实说 +# -------------------------------------------------------------------------- + + +def test_stop_naming_a_provider_that_lands_on_a_demo_provider_says_so(): + """启用的只有演示类接入:「不指定接入」落到它,说明里和「改用演示接入」那一项一样写明演示用、不调用真实模型。""" + table = [BING, DEMO] + lone = flow(node("ask", "llm", "提问", provider="丙接入", model="丙一号", prompt="总结")) + got = hints(fix_of(lone, "provider.disabled:ask:provider", table)) + assert got[""] == f"去掉这一处指定的接入和模型「丙一号」,执行时改用第一个可用的模型接入「演示接入」{DEMO_NOTE}" + assert got["演示接入"] == "不提供模型「丙一号」,将改用它的默认模型「演示一号」;演示用的模型接入,不调用真实模型" + # 有启用的真实接入:去掉以后落到真实接入,不带这句 + assert hints(fix_of(lone, "provider.disabled:ask:provider", [JIA, BING, DEMO]))[""] == ( + "去掉这一处指定的接入和模型「丙一号」,执行时改用第一个可用的模型接入「甲接入」") + + # 跟随节点点名的演示接入、按模型名落到演示接入,也一样 + graph = flow(node("team", "supervisor", "协作", provider="演示接入", goal="分析", + agents=[{"name": "分析师", "provider": "丙接入", "system": "分析"}]), + node("ask", "llm", "提问", provider="丙接入", model="演示一号", prompt="总结")) + assert hints(fix_of(graph, "provider.disabled:team:agents[0].provider", table))[""] == ( + f"去掉这一处指定的接入,执行时改用「协作」(多 Agent 协作)指定的接入「演示接入」{DEMO_NOTE}") + assert hints(fix_of(graph, "provider.disabled:ask:provider", table))[""] == ( + 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 = "「乙一号」是接入「乙接入」的模型:如需使用该模型,请在模型一栏中选择它,接入留空或选择「乙接入」" + + +def test_a_provider_name_that_is_really_a_model_gets_a_note(): + """停用的接入叫「乙一号」,启用的乙接入提供模型「乙一号」:照提示启用的会是那条旧接入,用户多半想要的是这个模型。 + 报错照旧说停用了、去哪里启用,另补一句这是哪个接入的模型、怎么用它。不存在的名字同理;只有停用的接入提供的 + 模型名、谁的模型都不是的名字不补。""" + table = [JIA, YI, BING, TRAP] + with pytest.raises(ProviderNotConfigured) as e: + named_provider(table, "乙一号") + assert e.value.code == "disabled" and e.value.reason == "模型接入「乙一号」已停用" + assert str(e.value) == f"模型接入「乙一号」已停用。{DISABLED_FIX},或换一个接入。{MODEL_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) + + # 助手:并成一句,指到能直接选这个模型的地方(不再另起一句把「助手使用的模型」说两遍) + with pytest.raises(ProviderNotConfigured) as e: + named_provider(table, "乙一号") + assert assistant_unavailable(e.value) == ( + 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(): + """设置页里没有助手的接入这一项:能改的是「编排」页助手栏的「助手使用的模型」(选「跟随默认」或一个模型会清掉存着的 + 接入)。表格导入、数据目录的 AI 起草也用助手的模型,同一句。""" + with pytest.raises(ProviderNotConfigured) as e: + named_provider(TABLE, "戊接入") + assert assistant_unavailable(e.value) == ( + 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("请到「设置 → 模型接入」检查") + + +# -------------------------------------------------------------------------- +# 接口:保存设置、交给助手、画布校验(建自己的几条接入,用完删掉) +# -------------------------------------------------------------------------- + +LIVE, OFF, GONE = "措辞用接入", "措辞用停用接入", "措辞用接人" +#: 停用的接入,名字恰好是 LIVE 提供的模型 +NAMED_LIKE_MODEL = "措辞模型" +SETTING_KEYS = ["verify", "judge", "copilot", "run"] +NAMES = [LIVE, OFF, NAMED_LIKE_MODEL] + + +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_(NAMES))) + await session.commit() + + +@pytest.fixture +async def rows(): + await _clean() + async with SessionLocal() as session: + session.add_all([ + Provider(name=LIVE, kind="mock", models=[{"id": NAMED_LIKE_MODEL}], default_model=NAMED_LIKE_MODEL, + enabled=True, extra={}), + Provider(name=OFF, kind="mock", models=[{"id": "措辞停用模型"}], default_model="措辞停用模型", + enabled=False, extra={}), + Provider(name=NAMED_LIKE_MODEL, kind="mock", models=[{"id": "措辞旧模型"}], default_model="措辞旧模型", + enabled=False, extra={}), + ]) + await session.commit() + yield + await _clean() + + +@pytest.fixture +async def client(rows): + 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 + + +#: (组, 键, 接入这一项在界面上的叫法, 模型那一项的叫法) +SETTING_FIELDS = [ + ("verify", "provider", "核查模型 · 接入", "核查模型 · 模型"), + ("judge", "provider", "裁判模型 · 接入", "裁判模型 · 模型"), + ("run", "tool_gate_provider", "门控模型 · 接入", "门控模型 · 模型"), +] + + +@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},或换一个接入" + r = await client.put("/api/settings", json={"values": {group: {key: OFF}}}) + assert r.json()["detail"] == f"「{label}」指定的模型接入「{OFF}」已停用。{DISABLED_FIX},或换一个接入" + # 名字恰好是启用接入的模型:补一句这是哪个接入的模型、在这一组里怎么用它 + r = await client.put("/api/settings", json={"values": {group: {key: NAMED_LIKE_MODEL}}}) + note = (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): + r = await client.put("/api/settings", json={"values": {group: {key: 123}}}) + assert r.status_code == 422 + assert r.json()["detail"] == f"「{label}」需要填写模型接入的名称,当前为「123」" + + +async def test_the_verify_format_check_says_blank_follows_the_judge_model_like_the_page(client): + r = await client.put("/api/settings", json={"values": {"verify": {"provider": 3}}}) + assert r.status_code == 422 + 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) + session.add(wf) + await session.flush() + session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=graph)) + await session.commit() + return wf.id + + +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_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}) + assert r.json()["assist"] == {"ok": False, "summary": PROVIDER_LEFT + PROVIDER_LEFT_FIX, "questions": []} + + # 没有一条有修复(成员只写了停用接入的模型,换掉、去掉节点的接入都落不稳):不指到修复 + graph = flow(node("team", "supervisor", "协作", provider=GONE, goal="分析", + agents=[{"name": "研究员", "model": "措辞停用模型", "system": "研究"}])) + r = await client.post(f"/api/workflows/{wf}/autofix", json={"level": "published", "graph": graph, "assist": True}) + out = r.json() + assert [i["fix"] for i in provider_issues(out["remaining"])] == [None] + assert out["assist"] == {"ok": False, "summary": PROVIDER_LEFT, "questions": []} + + +def unset_model(result: dict) -> list[dict]: + return [i for i in result["issues"] if i.get("code") == "model.unset"] + + +async def test_no_model_says_which_provider_and_steps_aside_when_that_provider_is_broken(client): + """节点点名了接入、没写模型:说用这个接入的默认模型。这个接入本身不存在或已停用时只留接入那一条:一条说会照常 + 执行、一条说执行到这里会失败,读者无从判断。全图默认的接入同理。没点名接入的照旧说「将使用默认模型」。""" + graph = flow(node("a", "llm", "提问", provider=LIVE, prompt="总结"), + node("b", "llm", "复述", provider=GONE, prompt="复述"), + node("c", "agent", "查数", prompt="查数"), + node("d", "report", "写周报", instructions="写周报", provider=""), + defaults={"provider": OFF}) + r = await client.post("/api/workflows/validate", json={"graph": graph}) + got = {i["node_id"]: i for i in unset_model(r.json())} + assert set(got) == {"a"}, got + assert (got["a"]["level"], got["a"]["field"], got["a"]["message"]) == ( + "warning", "model", f"没有指定模型,将使用接入「{LIVE}」的默认模型") + assert {(i["node_id"], i["field"]) for i in provider_issues(r.json()["issues"])} == { + ("b", "provider"), (None, "defaults.provider")} + + # 发布前检查同样只留接入那一条 + wf = await _workflow(graph) + r = await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "published", "graph": graph}) + assert {i["node_id"] for i in unset_model(r.json())} == {"a"} + + # 全图默认的接入换成启用的:跟随它的节点说用它的默认模型;什么都没点名的照旧 + graph["defaults"] = {"provider": LIVE} + r = await client.post("/api/workflows/validate", json={"graph": graph}) + said = {i["node_id"]: i["message"] for i in unset_model(r.json())} + assert said["c"] == said["a"] == f"没有指定模型,将使用接入「{LIVE}」的默认模型" + graph["defaults"] = {} + r = await client.post("/api/workflows/validate", json={"graph": graph}) + assert {i["node_id"]: i["message"] for i in unset_model(r.json())}["c"] == "没有指定模型,将使用默认模型" + + +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() + 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 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 74e20826..dce4a18f 100644 --- a/backend/tests/test_provider_resolution.py +++ b/backend/tests/test_provider_resolution.py @@ -117,6 +117,9 @@ async def test_a_disabled_provider_named_like_a_model_is_not_swapped_for_the_one 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 e.value.model_owner == "云端接入" and str(e.value).endswith( + "「fake-v4.1-flash」是接入「云端接入」的模型:如需使用该模型,请在模型一栏中选择它,接入留空或选择「云端接入」") assert (await resolve_provider(session, None, "fake-v4.1-flash")).id == serving.id @@ -201,10 +204,11 @@ async def test_the_assistant_is_told_which_provider_is_broken(session): with pytest.raises(ProviderNotConfigured) as e: await resolve_provider(session, ref, model) said[case] = assistant_unavailable(e.value) - assert said["missing"] == "助手使用的模型接入「甲接人」不存在。请在「设置 → 模型接入」中核对名称,或为助手换一个模型" - assert said["disabled"] == "助手使用的模型接入「停用接入」已停用。请在「设置 → 模型接入」中启用它,或为助手换一个模型" + repick = "或在「编排」页助手栏的「助手使用的模型」中重新选择" + assert said["missing"] == f"助手使用的模型接入「甲接人」不存在。请在「设置 → 模型接入」中核对名称,{repick}" + assert said["disabled"] == f"助手使用的模型接入「停用接入」已停用。请在「设置 → 模型接入」中启用它,{repick}" assert said["by_model"] == ("助手使用的模型「m-off」所属的模型接入「停用接入」已停用。" - "请在「设置 → 模型接入」中启用它,或为助手换一个模型") + f"请在「设置 → 模型接入」中启用它,{repick}") keyless = ProviderNotConfigured("模型接入「云端接入」没有配置 API Key", "请在接入中填写", code="missing_key") assert assistant_unavailable(keyless) == ( "助手使用的模型尚未配置完成:模型接入「云端接入」没有配置 API Key。请到「设置 → 模型接入」检查") diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 8a2ee0e1..e6104446 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -91,6 +91,21 @@ export interface ProviderTest { [key: string]: any } +/** + * 助手当前用哪个模型(GET / PUT /api/copilot/model)。provider、model 是存着的设定;存的接入不存在、已停用(或只存了 + * 模型名、提供它的接入已停用)时实际什么都用不了:effective_* 为空,error 是和生成时报的同一句。老后端不给 error + */ +export interface CopilotModel { + configured: boolean + provider: string | null + model: string | null + effective_provider: string | null + effective_model: string | null + error?: string | null + /** 同一件事写在助手栏「助手使用的模型」那一格下面的说法:去向写「上方」,不把人指回这个下拉框本身。老后端不给 */ + panel_error?: string | null +} + /** 还没保存的模型接入配置。带 id 表示在编辑已有的那个,api_key 留空沿用已存的 */ export interface ProviderDraft { id?: string @@ -718,12 +733,9 @@ export const api = { layout: (graph: GraphSpec) => post('/copilot/layout', { graph }), fromRun: (runId: string, name?: string) => post('/copilot/from-run', { run_id: runId, name }), - getModel: () => get<{ - configured: boolean; provider: string | null; model: string | null - effective_provider: string | null; effective_model: string | null - }>('/copilot/model'), + getModel: () => get('/copilot/model'), setModel: (body: { provider?: string | null; model?: string | null }) => - put('/copilot/model', body), + put('/copilot/model', body), /** 跑完之后复核一次。干净的运行后端直接返回 verdict='ok',不调模型 */ review: (body: { run_id: string; question: string }) => post('/copilot/review', body), diff --git a/frontend/src/canvas/Inspector.tsx b/frontend/src/canvas/Inspector.tsx index 9d0bc736..ca9ace96 100644 --- a/frontend/src/canvas/Inspector.tsx +++ b/frontend/src/canvas/Inspector.tsx @@ -7,15 +7,15 @@ import { NODE_DEFS, syntaxOf, type FieldDef, type FieldSyntax, type NodeDef } fr import { fieldOfIssue, isCaseKeyIssue, unboundToolOf, withToolBound, type FieldRef } from './issues' import { hintOf } from './shortcuts' import { isActivePhase } from '../run/trace' -import { EDIT_LOCK_TEXT, useEditLock, useStudio } from '../store/studio' +import { EDIT_LOCK_TEXT, toGraph, useEditLock, useStudio } from '../store/studio' import { datasourceTools, modelOptions, providerOfModel, useCatalog, useDatasources } from '../store/catalog' import { api } from '../api/client' import { IconButton, JsonInput, Modal, isComposing, useRadioGroup } from '../components/ui' import { formatShortcut } from '../lib/keys' -import { JUDGE_FIELD_LABEL, JUDGE_ON_UNSUPPORTED_HINT, JUDGE_ON_UNSUPPORTED_LABEL, JUDGE_SETTING_TEXT, MERGE_TEXT, CATALOG_UI_TEXT, NODE_TYPE_LABEL, SUBGRAPH_UPGRADE_HELP, judgeByModelText, upgradeNewerText } from '../lib/terms' +import { JUDGE_FIELD_LABEL, JUDGE_ON_UNSUPPORTED_HINT, JUDGE_ON_UNSUPPORTED_LABEL, JUDGE_SETTING_TEXT, MERGE_TEXT, CATALOG_UI_TEXT, NODE_TYPE_LABEL, PROVIDER_REF_TEXT, SUBGRAPH_UPGRADE_HELP, judgeByModelText, upgradeNewerText } from '../lib/terms' import { TemplateText } from './TemplateText' import { MERGE_ALIAS, isQueryNode, mergeAlias } from './mergeInputs' -import type { NodeType, ValidationIssue, WorkflowVersion } from '../types' +import type { NodeType, Provider, ValidationIssue, WorkflowVersion } from '../types' /** 检查器里一条落到字段上的问题 */ type FieldIssue = ValidationIssue & { at: FieldRef | null } @@ -544,11 +544,38 @@ function SyntaxBadge({ syntax }: { syntax: FieldSyntax }) { ) } +/** + * 模型一栏留空时执行用什么,同后端 NodeContext.cfg:节点没写的取工作流默认设置里的。工作流默认设置写了模型,用的就是那个 + * 模型(后端这时也不报「没有指定模型」);否则节点点名了接入(没写就是全图默认点名的)时是那个接入的默认模型。点名的接入 + * 不存在、已停用时空选项说状态、下面不写说明:检查器顶上另有接入那条问题,这里再说「使用……」就和它矛盾。全图默认不在 + * 节点里,从 toGraph 带回去的那一份读。接入表还没取回来时不判状态。help 为 undefined 时用节点定义里那句 + */ +function modelDefault(config: Record, providers: Provider[]): { blank: string; help?: string | null } { + const defaults = toGraph([], []).defaults ?? {} + let value = config.provider + if (value == null || value === '') value = defaults.provider + const named = typeof value === 'string' && value.trim() ? value.trim() : null + const fallback = typeof defaults.model === 'string' && defaults.model.trim() ? defaults.model.trim() : null + // 认法同后端 named_provider:名称或 id,名称两边的空白不算 + const found = named ? providers.find((p) => p.id === named || p.name === named || p.name.trim() === named) : undefined + const state = !named || !providers.length || found?.enabled ? 'ok' : found ? 'disabled' : 'missing' + const help = state !== 'ok' ? null : fallback ? PROVIDER_REF_TEXT.modelHelpDefaults(fallback) + : named ? PROVIDER_REF_TEXT.modelHelp(named) : undefined + return { blank: PROVIDER_REF_TEXT.modelBlank(named, state, fallback), help } +} + function Field({ field, nodeId, value, config, issues, onChange }: { field: FieldDef; nodeId: string; value: any; config: Record issues: FieldIssue[]; onChange: (v: any) => void }) { const id = `f-${nodeId}-${field.key}` + // 节点定义里的模型一栏(MODEL_FIELDS):说明跟着全图默认的模型、点名的接入走(modelDefault),节点定义里那句只适用于 + // 两样都没写的时候。自己画的模型栏(核查节点的反方模型、盲算模型)只读节点自己的配置、留空跟随设置,不取全图默认, + // 说明在它自己里面——这里再套一句「留空则使用接入「X」的默认模型」就和它相反 + const modelField = field.type === 'model' && !field.render + const providers = useCatalog((s) => (modelField ? s.providers : NO_ROWS)) + const model = modelField ? modelDefault(config, providers) : null + const help = model?.help === undefined ? field.help : model.help ?? undefined const syntax = syntaxOf(field) const composite = field.type === 'cases' || field.type === 'agents' || field.type === 'ioFields' || field.type === 'metricsList' || field.type === 'caliberFrom' || field.type === 'judge' @@ -590,8 +617,8 @@ function Field({ field, nodeId, value, config, issues, onChange }: { - {field.help && field.type !== 'switch' && ( -
{field.help}
+ {help && field.type !== 'switch' && ( +
{help}
)} {off &&
{off}
} {!!own.length && ( @@ -705,7 +732,7 @@ function FieldInput({ field, id, nodeId, syntax, value, config, invalid, describ const groups = [...new Set(options.map((o) => o.group))] return ( set({ enabled: e.target.checked })} /> - 启用停用后,该接入的模型不会出现在节点的模型下拉列表中 - +
+ + {/* 停用的后果另起一行写在复选框下面,不放进 label:放在里面时它和「启用」是并排的两个 flex 项,长说明会把「启用」 + 挤成上下两个字,读屏也把整句连着当成复选框的名字念 */} +

+ {PROVIDER_REF_TEXT.enabledHint} +

+
{blocked &&

还缺:{missing.join('、')}

} @@ -803,13 +811,18 @@ function PrefsTab() { const collectionMissing = !collectionNames.includes(cur.collection) /** * 接入那一格下面的报错:服务端按这一项拒收的话照它的整句;否则存着(或选着)的接入不存在、已停用时说清这一项会怎样。 - * 只在接入表取到了时判「不存在」:表还没取回来,什么都找不到 + * 只在接入表取到了时判「不存在」:表还没取回来,什么都找不到。labels 是这一组接入、模型两栏的叫法:名字恰好是某个 + * 启用接入的模型时(多半是把模型名填进了接入一栏)补一句怎么用它,同服务端保存时那句,免得照「启用」去启用那条旧接入 */ - const providerError = (field: string, name: string, found: Provider | undefined, broken: string): string | undefined => { + const providerError = (field: string, name: string, found: Provider | undefined, broken: string, + labels: { provider: string; model: 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}` + if (found?.enabled) return undefined + const state = found ? PROVIDER_REF_TEXT.disabled(name) : PROVIDER_REF_TEXT.missing(name) + const owner = providerOfModel(providers, name) + const note = owner?.enabled ? `。${PROVIDER_REF_TEXT.modelNote(name, owner.provider.name, labels.model, labels.provider)}` : '' + return `${state}:${broken}${note}` } // 门控模型留空时后端的挑法(resolve_provider):第一个启用的真实接入,没有就第一个启用的 const enabledProviders = providers.filter((p) => p.enabled) @@ -817,13 +830,15 @@ function PrefsTab() { 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 gateProviderError = providerError('run.tool_gate_provider', cur.gateProvider, gateProvider, PROVIDER_REF_TEXT.gateBroken, + TOOL_GATE_TEXT) 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 judgeProviderError = providerError('judge.provider', cur.judgeProvider, judgeProvider, PROVIDER_REF_TEXT.judgeBroken, + JUDGE_SETTING_TEXT) const judgeModelName = cur.judgeModel.trim() const judgeByModel = !cur.judgeProvider && !!judgeModelName // 裁判模型没有单独配置(接入、模型都留空):后端依次用助手的模型、默认接入 diff --git a/frontend/src/pages/VerifySettings.tsx b/frontend/src/pages/VerifySettings.tsx index 52cbbfe8..aef875f1 100644 --- a/frontend/src/pages/VerifySettings.tsx +++ b/frontend/src/pages/VerifySettings.tsx @@ -164,9 +164,13 @@ export function VerifySection({ cur, errors, onEdit, refresh }: { const provider = cur.provider ? providers.find((p) => p.name === cur.provider) : undefined const providerMissing = !!cur.provider && providers.length > 0 && !provider // 点名停用的接入以前照常调用,现在核查时照实报「已停用」、记缺口:和「不存在」一样在这一格下面说。服务端按这一项 - // 拒收时(errors.provider,设置页从 422 的 field 落过来)照它的整句 - const providerError = errors.provider ?? (providerMissing ? S.providerGone(cur.provider) - : provider && !provider.enabled ? `${PROVIDER_REF_TEXT.disabled(cur.provider)}:${PROVIDER_REF_TEXT.verifyBroken}` : undefined) + // 拒收时(errors.provider,设置页从 422 的 field 落过来)照它的整句。名字恰好是某个启用接入的模型时补一句怎么用它 + //(同设置页门控、裁判两组,和服务端保存时那句一致) + const providerTrap = provider?.enabled === false || providerMissing ? providerOfModel(providers, cur.provider) : undefined + const providerNote = providerTrap?.enabled ? `。${PROVIDER_REF_TEXT.modelNote(cur.provider, providerTrap.provider.name, T.model, T.provider)}` : '' + const providerError = errors.provider ?? (providerMissing ? `${S.providerGone(cur.provider)}${providerNote}` + : provider && !provider.enabled + ? `${PROVIDER_REF_TEXT.disabled(cur.provider)}:${PROVIDER_REF_TEXT.verifyBroken}${providerNote}` : undefined) const modelName = cur.model.trim() const byModel = !cur.provider && !!modelName const notSet = !cur.provider && !modelName diff --git a/frontend/src/run/Composer.tsx b/frontend/src/run/Composer.tsx index 12ea24ec..569da602 100644 --- a/frontend/src/run/Composer.tsx +++ b/frontend/src/run/Composer.tsx @@ -1,14 +1,18 @@ import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from 'react' import { AlertCircle, ArrowUp, CornerDownRight, Database, Settings2, Sparkles, Square, Wrench } from 'lucide-react' import clsx from 'clsx' -import { api } from '../api/client' +import { api, type CopilotModel } from '../api/client' import { useCatalog, useDatasources, modelOptions } from '../store/catalog' import { EDIT_LOCK_TEXT, useEditLock, useStudio } from '../store/studio' import { isComposing, Spinner, TechDetails, useRadioGroup } from '../components/ui' import { formatShortcut } from '../lib/keys' +import { ASSISTANT_MODEL_TEXT as MT } from '../lib/terms' import type { DataSource } from '../types' import { COPILOT_PHASE_TEXT } from './decode' +/** 「助手使用的模型」下拉里代表「存着的设定」的那一项的值(模型 id 不会是它) */ +const SAVED = '\u0000saved' + /** * Copilot 的输入。 * @@ -39,17 +43,26 @@ export function Composer({ hero, onFocusChange }: { const lock = useEditLock() const [text, setText] = useState('') const [opts, setOpts] = useState(false) - const [model, setModel] = useState('') - const [effective, setEffective] = useState('') + // 存着的助手模型设定(GET /api/copilot/model):接入、模型、实际会用的模型、用不了时的原因 + const [saved, setSaved] = useState(null) + // 重新选择时保存失败:下拉已退回选之前的样子,下面说明没改成 + const [saveFailed, setSaveFailed] = useState(false) + // 只认最后一次选择的回包:连着选两次时,先发的那次晚回来也不能盖掉后一次 + const picks = useRef(0) const [useBase, setUseBase] = useState(true) const ref = useRef(null) + // 挂上时取一次;每次展开「助手使用的模型」再取一次:接入可能刚在设置里启用、停用或改了名。取的途中人已经重新选过 + // 的话不用这一份(它是选之前的样子)。重新取回来的就是服务端现在存着的,上一次「保存失败」的说明不再留着 useEffect(() => { - void api.copilot.getModel().then((m) => { - setModel(m.model ?? '') - setEffective(m.effective_model ?? '') - }).catch(() => undefined) - }, []) + if (!opts && saved) return + let alive = true + setSaveFailed(false) + const seq = picks.current + void api.copilot.getModel().then((m) => { if (alive && seq === picks.current) setSaved(m) }).catch(() => undefined) + return () => { alive = false } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [opts]) // 工具栏的 Copilot 按钮把焦点甩过来 useEffect(() => { @@ -63,18 +76,38 @@ export function Composer({ hero, onFocusChange }: { if (hero) ref.current?.focus() }, [hero]) + const options = modelOptions(providers) + const groups = [...new Set(options.map((o) => o.group))] + // 存着的设定下拉里列不出来:点名了接入(下拉只选模型,选了会清掉接入),或者模型不在可选列表中。照实列成一项并选中 + // 它——否则下拉停在「跟随默认」,看着一切正常,接入失效了也看不出来,再选「跟随默认」不算改动、清不掉存着的接入 + const custom = !!saved && (!!saved.provider || (!!saved.model && !options.some((o) => o.value === saved.model))) + const model = custom ? SAVED : saved?.model ?? '' + // 下拉下面写的原因:服务端给的这一格专用的说法(去向写「上方」),老服务端只给整句 + const broken = saved?.panel_error || saved?.error || null + const pickModel = (value: string) => { - setModel(value) - void api.copilot.setModel({ model: value || null }) - .then((m) => setEffective(m.effective_model ?? '')) - .catch(() => undefined) + if (value === SAVED) return + const before = saved + const seq = ++picks.current + setSaveFailed(false) + // 先按选的显示;存的是 provider 置空、只写模型(跟随默认时两项都空),回来的是服务端认下的样子。跟随默认时实际用 + // 哪个模型由服务端说了算,回来之前不写「当前」 + setSaved({ configured: !!value, provider: null, model: value || null, effective_provider: null, + effective_model: value || null, error: null, panel_error: null }) + void api.copilot.setModel({ model: value || null }).then((m) => { if (seq === picks.current) setSaved(m) }).catch(() => { + if (seq !== picks.current) return + // 没存上:服务端存着的还是原来那一组,退回选之前的样子(原因、齿轮上的红点照旧),并说明保存失败 + setSaved(before) + setSaveFailed(true) + }) } const send = (override?: string) => { const q = (override ?? text).trim() if (!q || copilot.active) return - // 被正式运行拦下时(store 已经弹过提示)什么都没开始:这句话留在框里,结束后直接再发 - if (!runCopilot(q, useBase && nodes.length > 0, model || undefined)) return + // 被正式运行拦下时(store 已经弹过提示)什么都没开始:这句话留在框里,结束后直接再发。 + // 存着的设定列不出来时不带模型:服务端按存着的整组取(带了模型就会绕过存着的接入,和这里说的对不上) + if (!runCopilot(q, useBase && nodes.length > 0, model && model !== SAVED ? model : undefined)) return setText('') // 从头生成是一次性的:新图一落到画布上,下一句多半是在它上面改。停在「从头生成」 // 的话,下一句又把刚生成的图整张换掉(撤销找得回,但人得先发现) @@ -82,8 +115,6 @@ export function Composer({ hero, onFocusChange }: { } const mode = useRadioGroup(MODES, useBase ? 'base' : 'fresh', (m) => setUseBase(m === 'base')) - const options = modelOptions(providers) - const groups = [...new Set(options.map((o) => o.group))] const examples = hero ? exampleFor(nodes.length, sources) : [] return ( @@ -136,14 +167,17 @@ export function Composer({ hero, onFocusChange }: { leading={ <> {/* 两种方式并排摆出来,选中的那个就是这一句会怎么做。以前是一个 10px 的字, 点一下就翻成另一个词,看不出它是开关,更看不出另一头是什么。从头生成 @@ -169,9 +203,13 @@ export function Composer({ hero, onFocusChange }: { {opts && (
- - pickModel(e.target.value)} + aria-invalid={broken ? true : undefined} aria-describedby={broken ? 'copilot-model-error' : undefined}> + {/* 「当前」只在选着跟随默认、用得了的时候写:选了别的模型时 effective_model 是那个模型,不是跟随默认会用的; + 存着的设定列成单独一项时,这一项说的是选它以后会用哪个 */} + + {custom && } {groups.map((g) => ( {options.filter((o) => o.group === g).map((o) => ( @@ -180,10 +218,15 @@ export function Composer({ hero, onFocusChange }: { ))} -
- 仅影响助手,不改变节点使用的模型。助手需要严格按格式输出, - 指令遵循能力较弱的模型可能无法生成工作流。 -
+ {saveFailed && ( +
{MT.saveFailed}
+ )} + {broken && ( + + )} +
{MT.note}
)} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 2bde6047..e1b191ac 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1389,8 +1389,9 @@ export interface PublishFix { label: string preview?: { field?: string | null; before?: unknown; after?: unknown } | null /** handoff:选了这一项就是「交给 Copilot」(G4 的「它在做计算」)。只认这个标记,不认值—— - * 别的选项拿节点 id 当值,节点 id 恰好叫 copilot 的报告撰写节点照样是普通候选 */ - options?: { value: unknown; label: string; hint?: string | null; handoff?: boolean }[] + * 别的选项拿节点 id 当值,节点 id 恰好叫 copilot 的报告撰写节点照样是普通候选。 + * demo:点名的模型接入的候选,修完以后执行时落到演示类接入(不调用真实模型),选项名旁加醒目的标签 */ + options?: { value: unknown; label: string; hint?: string | null; handoff?: boolean; demo?: boolean }[] multiple?: boolean /** 建议值:界面上标「建议」,仍然要人选 */ default?: unknown diff --git a/scripts/check-manage.mjs b/scripts/check-manage.mjs index 54bdceed..f2d14dd5 100644 --- a/scripts/check-manage.mjs +++ b/scripts/check-manage.mjs @@ -607,7 +607,8 @@ await section('设置 · 点名的接入已停用或不存在', async () => { judge: { ...(stored.judge ?? {}), provider: 'demo-off', model: null }, verify: { ...(stored.verify ?? {}), provider: 'demo-off', model: null }, } - const REFUSED = '「门控模型 · 接入」填写的模型接入「demo-main」已停用。请在「设置 → 模型接入」中启用它,或换一个接入;留空表示使用默认接入的默认模型' + // 服务端的说法(api/settings._provider_problem):下拉框里选的写「指定的」,不另讲留空是什么意思(空选项写着) + const REFUSED = '「门控模型 · 接入」指定的模型接入「demo-main」已停用。请在「设置 → 模型接入」中启用它,或换一个接入' let refuse = true const { page, sent, errors, close } = await open('/settings/prefs', { handlers: [ @@ -653,6 +654,83 @@ await section('设置 · 点名的接入已停用或不存在', async () => { await close() }) +await section('设置 · 停用接入的后果写全;接入名恰好是模型名时说清怎么用这个模型', async () => { + // 停用一个接入以后,点名它(或只写了它的模型)的节点和设置都调不了它(K1):卡片上「已停用」的悬停说明、编辑框里 + // 「启用」下面的说明都要写到这一层,不只是「下拉里看不到」;调不了以后怎样(报错、转人工审批、记未裁判、记缺口)各组 + // 自己说,这里不笼统写「运行时报错」。停用的接入恰好叫 big-model,而 big-model 是启用的 demo-main 提供的模型:设置页 + // 三格下面补一句这是哪个接入的模型、在哪一栏用它,和服务端保存时 422 的那句一致。接入表、设置都是假的 + const PROVIDERS = [ + { id: 'check-trap-p1', name: 'demo-main', kind: 'openai', base_url: 'https://example.com/v1', default_model: 'big-model', + models: [{ id: 'big-model' }], enabled: true, extra: {}, api_key_masked: '', has_key: true }, + { id: 'check-trap-p2', name: 'big-model', kind: 'openai', base_url: 'https://example.com/v1', default_model: 'old-model', + models: [{ id: 'old-model' }], enabled: false, extra: {}, api_key_masked: '', has_key: true }, + ] + const EFFECT = '该接入的模型不再出现在下拉列表中,指定它或其模型的节点和设置也无法调用' + // 编辑框里「启用」的版式:「启用」两个字只占一行(以前长说明和它并排放在 label 里,1440 宽时把它挤成上下两个字); + // 说明不在 label 里,复选框的读屏名只是「启用」,说明由 aria-describedby 接上 + const enabledLayout = (page) => page.evaluate(() => { + const hint = document.querySelector('[data-provider-enabled-hint]') + const box = [...document.querySelectorAll('[role="dialog"] input[type="checkbox"]')] + .find((el) => el.closest('label')?.textContent?.trim().startsWith('启用')) + const label = box?.closest('label') + const word = label && [...label.childNodes].find((n) => n.nodeType === 3 && n.textContent.includes('启用')) + const range = document.createRange() + if (word) range.selectNodeContents(word) + const lines = word ? new Set([...range.getClientRects()].filter((r) => r.width > 0).map((r) => Math.round(r.top))).size : 0 + const d = document.querySelector('[role="dialog"]') + // 弹窗自己不出横向滚动条,「启用」一行和说明都在弹窗里 + const fits = !!d && d.scrollWidth - d.clientWidth <= 0 + && [label, hint].every((el) => el && el.getBoundingClientRect().right <= d.getBoundingClientRect().right + 0.5) + return { lines, inLabel: !!label && !!hint && label.contains(hint), described: !!hint?.id && box?.getAttribute('aria-describedby') === hint.id, fits } + }) + const cards = await open('/settings/providers', { handlers: [[/^GET \/providers$/, json(PROVIDERS)]], viewport: { width: 1440, height: 900 } }) + const chip = cards.page.locator('[data-provider="big-model"] .chip', { hasText: '已停用' }) + check('卡片上「已停用」:悬停写明点名它的节点和设置也无法调用', await chip.getAttribute('title').catch(() => '') === `已停用:${EFFECT}`, + await chip.getAttribute('title').catch(() => '')) + await cards.page.locator('[data-provider="big-model"]').getByRole('button', { name: '编辑' }).click() + const enabledHint = await cards.page.locator('[data-provider-enabled-hint]').innerText().catch(() => '') + check('编辑框「启用」下面:不只说下拉里看不到,也说点名它的节点和设置无法调用', enabledHint === `停用后,${EFFECT}`, enabledHint) + const wide = await enabledLayout(cards.page) + check('1440 宽:「启用」只占一行,说明另起一行、不在 label 里,由复选框的 aria-describedby 接上', + wide.lines === 1 && !wide.inLabel && wide.described && wide.fits, JSON.stringify(wide)) + check('……复选框的读屏名只是「启用」', await cards.page.getByRole('checkbox', { name: '启用', exact: true }).count() === 1) + await shot(cards.page, 'provider-enabled-hint') + check('没有运行时报错', cards.errors.length === 0, cards.errors[0] ?? '') + await cards.close() + + const phone = await open('/settings/providers', { handlers: [[/^GET \/providers$/, json(PROVIDERS)]], viewport: { width: 390, height: 844 } }) + await phone.page.locator('[data-provider="big-model"]').getByRole('button', { name: '编辑' }).click() + await phone.page.locator('[data-provider-enabled-hint]').waitFor() + const narrow = await enabledLayout(phone.page) + check('390 宽:「启用」同样只占一行,说明在弹窗里折行,弹窗不出横向滚动条', narrow.lines === 1 && narrow.fits, JSON.stringify(narrow)) + await shot(phone.page, 'provider-enabled-hint-390') + check('没有运行时报错', phone.errors.length === 0, phone.errors[0] ?? '') + await phone.close() + + let stored = await get('/settings') + stored = { + ...stored, + run: { ...(stored.run ?? {}), tool_gate_provider: 'big-model', tool_gate_model: null }, + judge: { ...(stored.judge ?? {}), provider: 'big-model', model: null }, + verify: { ...(stored.verify ?? {}), provider: 'big-model', model: null }, + } + const { page, errors, close } = await open('/settings/prefs', { + handlers: [[/^GET \/settings$/, (route) => json(stored)(route)], [/^GET \/providers$/, json(PROVIDERS)]], + }) + const said = async (id) => page.locator(`#${id}-error`).innerText().catch(() => '') + const note = (model, provider) => `「big-model」是接入「demo-main」的模型:如需使用该模型,请在「${model}」中填写,「${provider}」留空或选择「demo-main」` + check('门控模型:先说停用了、会怎样,再补一句这是 demo-main 的模型、在哪一栏用它', + await said('pref-gate-provider') === `模型接入「big-model」已停用:门控模型无法调用,所有工具调用将转为人工审批。${note('门控模型 · 模型', '门控模型 · 接入')}`, + await said('pref-gate-provider')) + check('证据裁判:同样补这一句,写裁判那两栏的叫法', (await said('pref-judge-provider')).endsWith(`。${note('裁判模型 · 模型', '裁判模型 · 接入')}`), + await said('pref-judge-provider')) + check('核查模型:同样补这一句,写核查那两栏的叫法', (await said('pref-verify-provider')).endsWith(`。${note('核查模型 · 模型', '核查模型 · 接入')}`), + await said('pref-verify-provider')) + await shot(page, 'prefs-provider-named-like-model') + check('没有运行时报错', errors.length === 0, errors[0] ?? '') + await close() +}) + await section('设置 · 智能体护栏', async () => { // 默认步数、token 预算、金额预算(后端 engine/guards.py)。预算留空 = 不限,存 null let stored = await get('/settings') diff --git a/scripts/check-publish.mjs b/scripts/check-publish.mjs index af00b6b5..babb588c 100644 --- a/scripts/check-publish.mjs +++ b/scripts/check-publish.mjs @@ -199,12 +199,43 @@ const PROV_MODEL = { ], edges: chain('in', 'look', 'team', 'done'), } +/** + * 一处给不出修复(K1b):协作节点点名的接入不存在,成员「研究员」跟随节点的接入、自己只写了停用的 demo-off 提供的模型—— + * 节点换成哪家、去掉接入,研究员都落不稳,这一处没有修复,提示也不指到发布前检查;成员「分析师」自己点名的接入不存在, + * 改用 demo-main 落得稳,有修复。两条同编号、同节点:前一条不能借后一条的修复 + */ +const PROV_NOFIX = { + nodes: [ + node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }), + node('team', 'supervisor', 240, '协作', { goal: '复核', provider: 'demo-old', + agents: [{ name: '分析师', provider: 'demo-old', tools: [] }, { name: '研究员', model: 'tiny-1', tools: [] }] }), + node('done', 'output', 480, '成果', { fields: [{ name: 'report', value: '{{ last_message }}' }] }), + ], + edges: chain('in', 'team', 'done'), +} const PROVIDERS = [ { id: 'pv-main', name: 'demo-main', kind: 'openai_compatible', base_url: 'https://example.com/v1', default_model: 'big-model', models: [{ id: 'big-model' }], enabled: true, extra: {}, api_key_masked: '', has_key: true }, { id: 'pv-off', name: 'demo-off', kind: 'openai_compatible', base_url: 'https://example.com/v1', default_model: 'tiny-1', models: [{ id: 'tiny-1' }], enabled: false, extra: {}, api_key_masked: '', has_key: true }, ] +/** + * 启用的只有演示类接入(K1b):「提问」点名停用的 demo-off,「不指定接入」落到演示接入,「改用 demo-mock」本身就是演示接入。 + * 两个候选都带 demo 标记(后端 autofix._demo_mark),选项名旁要有醒目的「演示模型」标签,不能只靠说明末尾的淡色小字 + */ +const PROV_DEMO = { + nodes: [ + node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }), + node('ask', 'llm', 240, '提问', { prompt: '总结本周销售', provider: 'demo-off' }), + node('done', 'output', 480, '成果', { fields: [{ name: 'report', value: '{{ nodes.ask.text }}' }] }), + ], + edges: chain('in', 'ask', 'done'), +} +const DEMO_PROVIDERS = [ + PROVIDERS[1], + { id: 'pv-mock', name: 'demo-mock', kind: 'mock', base_url: '', default_model: 'mock-1', + models: [{ id: 'mock-1' }], enabled: true, extra: {}, api_key_masked: '', has_key: false }, +] const wf = (id, name, graph, extra = {}) => ({ id, name, description: '检查脚本伪造的工作流', graph, tags: [], version: 3, is_template: false, @@ -226,6 +257,8 @@ const FAKES = { 'pf-prov': wf('pf-prov', '__publish_check_provider__', PROV), 'pf-provok': wf('pf-provok', '__publish_check_provider_ok__', PROV_OK), 'pf-provmodel': wf('pf-provmodel', '__publish_check_provider_model__', PROV_MODEL), + 'pf-provnofix': wf('pf-provnofix', '__publish_check_provider_nofix__', PROV_NOFIX), + 'pf-provdemo': wf('pf-provdemo', '__publish_check_provider_demo__', PROV_DEMO), } const clone = (x) => JSON.parse(JSON.stringify(x)) @@ -234,11 +267,13 @@ const who = (n) => `「${n.data.label}」` const metricOptions = (m) => m.data.config.metrics.map((x) => ({ value: x.id, label: `${x.name}(${x.id})`, hint: `口径卡「${m.data.label}」` })) /** - * 伪造的点名接入检查,形状照后端 engine/provider_refs.py 与 autofix._plan_provider:一处一条问题(字段 provider、 + * 伪造的点名接入检查,形状照后端 engine/provider_refs.py 与 autofix.provider_choice:一处一条问题(字段 provider、 * agents[i].provider),门禁里是 error、带一条要人选的修复(id 是 code:节点:字段);画布校验里是 warning、没有修复。 - * 候选照后端按执行时的取法算去掉、换掉以后落到哪里(只覆盖这两张图用到的情形,图里没有全图默认):节点去掉接入落到 + * 候选照后端按执行时的取法算去掉、换掉以后落到哪里(只覆盖这几张图用到的情形,图里没有全图默认):节点去掉接入落到 * 第一个可用的接入;成员去掉接入跟随节点自己的接入,那个接入停用了就不给这一项;落到的接入不提供同一处写着的模型时 - * 模型一并去掉(_drops 记哪几个候选要去掉,应用时用,前端收不到) + * 模型一并去掉(_drops 记哪几个候选要去掉,应用时用,前端收不到)。协作节点换掉、去掉接入时,跟着它的成员自己写着 + * 没有启用接入提供的模型,一个候选都落不稳:没有修复,提示不写「或在「发布前检查」中改用其他接入」,改说卡在哪里 + * (后端 provider_refs._tail:「无法改用其他接入:」加第一处落不稳的地方)。落到演示类接入的候选说明末尾照实说、带 demo 标记 */ function providerLint(graph, providers, level) { const issues = [] @@ -247,6 +282,7 @@ function providerLint(graph, providers, level) { const kind = { llm: '模型调用', supervisor: '多 Agent 协作' } const serves = (p, m) => (p.models ?? []).some((x) => x.id === m) || p.default_model === m const enabled = providers.filter((x) => x.enabled) + const demo = (p) => (p?.kind === 'mock' ? { demo: true } : {}) for (const n of graph.nodes) { const c = n.data.config ?? {} const at = `${who(n)}(${kind[n.type]})` @@ -261,26 +297,35 @@ function providerLint(graph, providers, level) { const code = p ? 'provider.disabled' : 'provider.missing' const id = `${code}:${r.n.id}:${r.field}` const gate = level === 'error' - issues.push({ level, node_id: r.n.id, field: r.field, code, fix: gate ? id : null, - message: `${r.who}指定的模型接入「${r.name}」${p ? '已停用' : '不存在'}。请在「设置 → 模型接入」中${p ? '启用它' : '核对名称'},` - + '或在「发布前检查」中改用其他接入' }) - if (!gate) continue const model = r.holder.model const options = [] const drops = [] const up = r.team && r.n.data.config.provider ? providers.find((x) => x.name === r.n.data.config.provider) : enabled[0] - if (up?.enabled) { + const stuckBy = r.team ? null : (r.n.data.config.agents ?? []).find((a) => !a.provider && a.model && !enabled.some((x) => serves(x, a.model))) + const stuck = !!stuckBy + if (up?.enabled && !stuck) { const drop = !!model && !serves(up, model) if (drop) drops.push('') options.push({ value: '', label: '不指定接入', hint: `去掉这一处指定的接入${drop ? `和模型「${model}」` : ''},执行时改用` - + (r.team && r.n.data.config.provider ? `${r.team}指定的接入「${up.name}」` : `第一个可用的模型接入「${up.name}」`) }) + + (r.team && r.n.data.config.provider ? `${r.team}指定的接入「${up.name}」` : `第一个可用的模型接入「${up.name}」`) + + (up.kind === 'mock' ? '(演示用,不调用真实模型)' : ''), ...demo(up) }) } - for (const x of enabled) { + for (const x of stuck ? [] : enabled) { const drop = !!model && !serves(x, model) if (drop) drops.push(x.name) - options.push({ value: x.name, label: x.name, hint: drop ? `不提供模型「${model}」,将改用它的默认模型「${x.default_model}」` : null }) + const notes = [drop ? `不提供模型「${model}」,将改用它的默认模型「${x.default_model}」` : '', + x.kind === 'mock' ? '演示用的模型接入,不调用真实模型' : ''].filter(Boolean) + options.push({ value: x.name, label: x.name, hint: notes.join(';') || null, ...demo(x) }) + } + const fixable = options.length > 0 + const off = stuckBy && providers.find((x) => !x.enabled && serves(x, stuckBy.model)) + issues.push({ level, node_id: r.n.id, field: r.field, code, fix: gate && fixable ? id : null, + message: `${r.who}指定的模型接入「${r.name}」${p ? '已停用' : '不存在'}。请在「设置 → 模型接入」中${p ? '启用它' : '核对名称'}` + + (fixable ? ',或在「发布前检查」中改用其他接入' + : off ? `。无法改用其他接入:${r.who}的成员「${stuckBy.name}」指定的模型「${stuckBy.model}」所属的模型接入「${off.name}」已停用` : '') }) + if (gate && fixable) { + fixes.push({ id, code, node_id: r.n.id, field: r.field, kind: 'choice', label: `选择${r.who}改用的模型接入`, options, _drops: drops }) } - fixes.push({ id, code, node_id: r.n.id, field: r.field, kind: 'choice', label: `选择${r.who}改用的模型接入`, options, _drops: drops }) } // 哪一级都没写接入、只写了模型:按模型名找,只有停用的接入提供它时同样是问题,落在写模型的那一处。修复是「不指定 // 模型」(落到第一个可用的接入)或改用一个启用的接入(写在模型旁边,模型去掉) @@ -619,7 +664,8 @@ async function open({ id = 'pf-hard', width = 1440, height = 900, backend = 'new const state = { checks: [], autofixes: [], patches: [], publishes: [], upgrades: [], version: FAKES[id].version, graph: clone(FAKES[id].graph), save, backend, upgrade, validate: 'ok', validates: 0, deleted: [] } // 点名接入、只写了模型的那几张图:接入表伪造,校验、门禁、修复都按它现算 - const providers = ['pf-prov', 'pf-provok', 'pf-provmodel'].includes(id) ? clone(PROVIDERS) : null + const providers = id === 'pf-provdemo' ? clone(DEMO_PROVIDERS) + : ['pf-prov', 'pf-provok', 'pf-provmodel', 'pf-provnofix'].includes(id) ? clone(PROVIDERS) : null state.providers = providers await page.route(/\/api\//, (route) => { @@ -1721,6 +1767,72 @@ await section('点名的模型接入:一处一行、各有一组要人选的 check('没有运行时报错', errors.length === 0, errors.join(' | ')) }) +await section('点名的模型接入:给不出修复的那一处不借同节点别处的修复,提示也不指到发布前检查', async () => { + const { page, errors } = await open({ id: 'pf-provnofix' }) + await openDialog(page) + await waitFor(page, async () => (await dialog(page).locator('[data-preflight-issue^="provider."]').count()) === 2) + const rows = dialog(page).locator('[data-preflight-issue^="provider."]') + const own = rows.nth(0) + const member = rows.nth(1) + const ownText = await own.innerText().catch(() => '') + check('协作节点自己的接入:一行,没有修复(选项、预览按钮都没有)', await own.locator('[data-fix-choice]').count() === 0 + && await own.getAttribute('data-fix-kind') === null, ownText.replace(/\s+/g, ' ').slice(0, 100)) + check('……提示不指到发布前检查:说去设置里核对,再说卡在哪里(成员只写了停用接入的模型)', + ownText.includes('指定的模型接入「demo-old」不存在。请在「设置 → 模型接入」中核对名称。无法改用其他接入:' + + '「协作」(多 Agent 协作)的成员「研究员」指定的模型「tiny-1」所属的模型接入「demo-off」已停用') + && !ownText.includes('发布前检查'), ownText.replace(/\s+/g, ' ').slice(0, 160)) + check('成员「分析师」那一处:自己的修复画在自己这一行', await member.locator('[data-fix-choice="provider.missing:team:agents[0].provider"]').count() === 1 + && (await member.innerText()).includes('或在「发布前检查」中改用其他接入')) + check('整个弹窗里只有这一组接入的修复', await dialog(page).locator('[data-fix-choice^="provider."]').count() === 1) + await shoot(page, 'publish-provider-nofix', dialog(page)) + check('没有运行时报错', errors.length === 0, errors.join(' | ')) +}) + +await section('点名的模型接入:落到演示类接入的候选在选项名旁有醒目的「演示模型」标签', async () => { + const { page, errors } = await open({ id: 'pf-provdemo' }) + await openDialog(page) + const group = dialog(page).locator('[data-fix-choice="provider.disabled:ask:provider"]') + await waitFor(page, async () => (await group.count()) === 1) + const tags = group.locator('[data-fix-demo]') + check('两个候选都落到演示接入,各有一个「演示模型」标签', await tags.count() === 2 + && (await tags.allInnerTexts()).every((t) => t.trim() === '演示模型'), String(await tags.count())) + const clear = group.locator('label', { hasText: '不指定接入' }) + check('……「不指定接入」的说明里照旧写明演示用、不调用真实模型', (await clear.innerText()).includes('(演示用,不调用真实模型)')) + // 醒目:标签用提醒色,不是说明那种淡色小字;标签就在选项名旁(同一行) + const look = await group.evaluate((el) => { + const tag = el.querySelector('[data-fix-demo]') + const hint = tag.closest('label').querySelector('.text-faint') + const probe = document.createElement('span') + probe.style.color = 'var(--warn)' + el.appendChild(probe) + const warn = getComputedStyle(probe).color + probe.remove() + const name = tag.parentElement.firstChild + const range = document.createRange() + range.selectNodeContents(name) + return { tag: getComputedStyle(tag).color, hint: hint ? getComputedStyle(hint).color : '', warn, + sameLine: Math.abs(range.getBoundingClientRect().top - tag.getBoundingClientRect().top) < 6 } + }) + check('……标签用提醒色(不是说明的淡色),和选项名在同一行', look.tag === look.warn && look.tag !== look.hint && look.sameLine, JSON.stringify(look)) + await shoot(page, 'publish-provider-demo', dialog(page)) + // 390 宽:「发布」收在「更多操作」里。标签和说明在弹窗里折行,不伸出弹窗、不出横向滚动条 + const narrow = await open({ id: 'pf-provdemo', width: 390, height: 844 }) + await narrow.page.getByRole('button', { name: '更多操作' }).click() + await narrow.page.locator('[role="menu"][aria-label="更多操作"] [role^="menuitem"]', { hasText: '发布' }).click() + await dialog(narrow.page).waitFor() + await waitFor(narrow.page, async () => (await dialog(narrow.page).locator('[data-fix-demo]').count()) === 2) + const fits = await narrow.page.evaluate(() => { + const d = document.querySelector('[role="dialog"]') + const box = d.getBoundingClientRect() + const tags = [...d.querySelectorAll('[data-fix-demo]')].map((t) => t.getBoundingClientRect()) + return { own: d.scrollWidth - d.clientWidth, + inside: tags.every((r) => r.right <= Math.min(box.right, window.innerWidth) + 0.5 && r.left >= box.left - 0.5) } + }) + check('390 宽:标签在弹窗里,弹窗不出横向滚动条', fits.own <= 0 && fits.inside, JSON.stringify(fits)) + await shoot(narrow.page, 'publish-provider-demo-390', dialog(narrow.page)) + check('没有运行时报错', errors.length === 0 && narrow.errors.length === 0, [...errors, ...narrow.errors].join(' | ')) +}) + await section('点名的模型接入:问题面板写「模型接入」,不露键名', async () => { const { page, errors } = await open({ id: 'pf-prov' }) await page.locator('.react-flow__pane').click({ position: { x: 30, y: 30 } }) diff --git a/scripts/check-studio.mjs b/scripts/check-studio.mjs index ec295233..7a741992 100644 --- a/scripts/check-studio.mjs +++ b/scripts/check-studio.mjs @@ -2607,6 +2607,263 @@ await section('合并查询:节点库认得、卡片摘要、检查器从上 await ctx.close() }) +// ================================================================ 点名的模型接入(K1b) +// 接入表、画布校验、助手的模型设定都是伪造的(一家启用、一家停用,名字都是通用名)。校验按伪造的接入表现算,形状照 +// 后端:点名的接入不存在、已停用给接入那一条(provider.*);点名的接入好好的、没写模型给「将使用接入「X」的默认模型」; +// 接入那一条在时不再给「没有指定模型」(provider_refs.quiet_unset_model);工作流默认设置写了模型时不报「没有指定模型」 +const MODEL_PROVIDERS = [ + { id: 'pv-main', name: 'demo-main', kind: 'openai_compatible', base_url: 'https://example.com/v1', default_model: 'big-model', + models: [{ id: 'big-model' }, { id: 'small-model' }], enabled: true, extra: {}, api_key_masked: '', has_key: true }, + { id: 'pv-off', name: 'demo-off', kind: 'openai_compatible', base_url: 'https://example.com/v1', default_model: 'tiny-1', + models: [{ id: 'tiny-1' }], enabled: false, extra: {}, api_key_masked: '', has_key: true }, +] +const MODEL_GRAPH = { + nodes: [ + node('start', 'input', 0, 300, '输入', { fields: [{ name: 'q', required: true }] }), + node('named', 'llm', 300, 60, '点名接入', { prompt: '{{ input.q }}', provider: 'demo-main' }), + node('gone', 'llm', 300, 220, '接入已删', { prompt: '{{ input.q }}', provider: 'demo-gone' }), + node('off', 'agent', 300, 380, '接入已停用', { prompt: '{{ input.q }}', provider: 'demo-off' }), + node('plain', 'llm', 300, 540, '没点名', { prompt: '{{ input.q }}' }), + // 反方审稿的模型一栏自己画(只读节点自己的配置、留空跟随设置),不取工作流默认设置 + node('chal', 'challenge', 300, 700, '审稿', {}), + node('done', 'output', 640, 300, '成果', { fields: [{ name: 'a', value: '{{ last_message }}' }] }), + ], + edges: [edge('start', 'named'), edge('start', 'gone'), edge('start', 'off'), edge('start', 'plain'), + edge('named', 'done'), edge('gone', 'done'), edge('off', 'done'), edge('plain', 'done')], +} +/** 伪造的画布校验:只管模型这一栏和点名的接入 */ +const modelLint = (graph) => { + const issues = [] + for (const n of graph.nodes ?? []) { + if (!['llm', 'agent'].includes(n.type)) continue + const c = n.data?.config ?? {} + const named = (c.provider || graph.defaults?.provider || '').trim() + const p = MODEL_PROVIDERS.find((x) => x.name === named) + const own = c.provider ? n.id : null + if (named && !p?.enabled) { + issues.push({ level: 'warning', node_id: own, field: own ? 'provider' : 'defaults.provider', code: p ? 'provider.disabled' : 'provider.missing', + message: `「${n.data.label}」指定的模型接入「${named}」${p ? '已停用' : '不存在'}。请在「设置 → 模型接入」中${p ? '启用它' : '核对名称'}` }) + continue + } + if (!c.model && !graph.defaults?.model) { + issues.push({ level: 'warning', node_id: n.id, field: 'model', code: 'model.unset', + message: named ? `没有指定模型,将使用接入「${named}」的默认模型` : '没有指定模型,将使用默认模型' }) + } + } + return { ok: true, issues } +} +// 助手的模型取不到:error 是生成时报的整句(别处也这么说),panel_error 写在助手栏那一格下面,去向是「上方」的下拉框 +const ASSISTANT_BROKEN = '助手使用的模型接入「demo-gone」不存在。请在「设置 → 模型接入」中核对名称,或在「编排」页助手栏的「助手使用的模型」中重新选择' +const ASSISTANT_PANEL = '助手使用的模型接入「demo-gone」不存在。请在上方重新选择,或在「设置 → 模型接入」中核对名称' + +await section('点名的模型接入:检查器模型一栏说清留空用哪个接入;助手栏照实显示存着的设定、能重新选「跟随默认」', async () => { + FAKES['st-model'] = wf('st-model', { name: '__studio_check_model__', graph: MODEL_GRAPH, version: 1 }) + FAKES['st-modeldef'] = wf('st-modeldef', { name: '__studio_check_model_defaults__', graph: { ...MODEL_GRAPH, defaults: { provider: 'demo-main' } }, version: 1 }) + FAKES['st-modeldm'] = wf('st-modeldm', { name: '__studio_check_model_default_model__', + graph: { ...MODEL_GRAPH, defaults: { provider: 'demo-main', model: 'small-model' } }, version: 1 }) + // 助手的模型设定:存着一个已删掉的接入和一个下拉里列得出的模型(big-model),GET 照后端说明用不了;PUT 记下请求,照请求 + // 存(选了模型就是那个模型、跟随默认就清掉存着的接入),fail 为真时答 500、什么都不改。模型列得出也不能直接选中它: + // 那样看着一切正常,发出去还会带上模型、绕过存着的接入 + const BROKEN = { configured: true, provider: 'demo-gone', model: 'big-model', effective_provider: null, effective_model: null, + error: ASSISTANT_BROKEN, panel_error: ASSISTANT_PANEL } + const copilot = { saved: BROKEN, puts: [], fail: false } + const before = async (page) => { + const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }) + await page.route(/\/api\/providers(\?.*)?$/, (route) => (route.request().method() === 'GET' ? json(route, MODEL_PROVIDERS) : route.fallback())) + await page.route(/\/api\/workflows\/validate$/, (route) => json(route, modelLint(route.request().postDataJSON()?.graph ?? {}))) + await page.route(/\/api\/copilot\/model$/, (route) => { + if (route.request().method() === 'PUT') { + const body = route.request().postDataJSON() + copilot.puts.push(body) + if (copilot.fail) return json(route, { detail: '数据库已锁定,请稍后重试' }, 500) + copilot.saved = body.model + ? { configured: true, provider: null, model: body.model, effective_provider: 'demo-main', effective_model: body.model, error: null, panel_error: null } + : { configured: false, provider: null, model: null, effective_provider: 'demo-main', effective_model: 'big-model', error: null, panel_error: null } + } + return json(route, copilot.saved) + }) + } + try { + const { ctx, page, state, errors } = await open({ path: '/studio/st-model', before, stream: [{ op: 'done', explanation: '好' }] }) + const look = async (p, id) => { + const f = p.locator('[data-inspector-sheet] [data-field="model"]') + await S(p, (x) => window.__studio.getState().select(x), id) + await f.waitFor({ timeout: 4000 }) + await p.waitForTimeout(150) + const select = f.locator('select option[value=""]') + return { + blank: await select.count() ? (await select.innerText()).trim() : null, + help: await f.locator('[data-field-help]').count() ? (await f.locator('[data-field-help]').innerText()).trim() : null, + text: await f.innerText(), + sheet: await p.locator('[data-inspector-sheet]').innerText(), + } + } + await waitAnalysis(page) + const named = await look(page, 'named') + check('点名了启用的接入、没写模型:空选项写「使用接入「demo-main」的默认模型」', named.blank === '默认(使用接入「demo-main」的默认模型)', named.blank) + check('……下面的说明同样写这个接入', named.help === '留空则使用接入「demo-main」的默认模型', named.help) + check('……校验那条也照实写接入', named.text.includes('没有指定模型,将使用接入「demo-main」的默认模型'), named.text.slice(0, 120)) + const gone = await look(page, 'gone') + check('点名的接入不存在:空选项说状态,不说「使用」', gone.blank === '默认(模型接入「demo-gone」不存在)', gone.blank) + check('……模型一栏下面不再写说明,也没有「没有指定模型」那条', gone.help === null && !gone.text.includes('没有指定模型'), gone.text.slice(0, 120)) + check('……接入那条问题在检查器顶上', gone.sheet.includes('指定的模型接入「demo-gone」不存在'), gone.sheet.slice(0, 160)) + const off = await look(page, 'off') + check('点名的接入已停用:空选项写「已停用」,没有说明', off.blank === '默认(模型接入「demo-off」已停用)' && off.help === null, `${off.blank} · ${off.help}`) + const plain = await look(page, 'plain') + check('没点名接入:照旧写第一个可用的模型接入', plain.blank === '默认(使用第一个可用的模型接入)' && plain.help === '留空则使用默认模型接入', + `${plain.blank} · ${plain.help}`) + if (SHOTS) { + await look(page, 'gone') + for (const theme of ['dark', 'light']) { + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await page.waitForTimeout(200) + await page.locator('[data-inspector-sheet]').screenshot({ path: `${SHOTS}/studio-model-inspector-${theme}.png` }) + } + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')) + } + + // 助手栏:存着的接入失效了,齿轮上有红点;展开后下拉照实停在存着的那一项,下面写原因 + await S(page, () => window.__studio.getState().select(null)) + const gear = page.getByRole('button', { name: '助手使用的模型' }) + await gear.waitFor({ timeout: 4000 }) + await page.waitForFunction(() => !!document.querySelector('[data-copilot-model-broken]'), null, { timeout: 4000 }).catch(() => {}) + check('存着的接入用不了:齿轮上有标记,悬停写明', await page.locator('[data-copilot-model-broken]').count() === 1 + && (await gear.getAttribute('title')) === '助手使用的模型当前无法调用,点击查看原因', await gear.getAttribute('title')) + await gear.click() + const select = page.locator('#copilot-model') + await select.waitFor({ timeout: 3000 }) + const pickedText = () => select.evaluate((el) => el.options[el.selectedIndex]?.textContent ?? '') + const followText = async () => (await select.locator('option[value=""]').innerText()).trim() + const picked = await pickedText() + check('下拉停在存着的设定上(接入连同模型),不显示成「跟随默认」,也不只选中同名的模型', picked === 'big-model(模型接入「demo-gone」)' + && await select.inputValue() !== '' && await select.inputValue() !== 'big-model', picked) + check('……「跟随默认」不带「当前」:选它以后用哪个要服务端说了算', await followText() === '跟随默认') + const said = await page.locator('[data-copilot-model-error]').innerText().catch(() => '') + check('下拉下面写原因,去向是「上方」的下拉框,不把人指回这个下拉框本身', said === ASSISTANT_PANEL + && await select.getAttribute('aria-invalid') === 'true', said) + if (SHOTS) { + for (const theme of ['dark', 'light']) { + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await page.waitForTimeout(200) + await page.locator('#copilot-model').locator('xpath=ancestor::div[contains(@class, "rounded-lg")][1]') + .screenshot({ path: `${SHOTS}/studio-copilot-model-${theme}.png` }) + } + await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark')) + } + // 存着的设定列不出来时发一句话:不带模型,服务端按存着的整组取(带了模型会绕过存着的接入) + const box = page.getByLabel('描述要生成或修改的工作流') + await box.fill('把回答改短一些') + await box.press('Enter') + await until(() => state.bodies.length > 0, 3000) + check('……发出的请求不带模型', state.bodies.length === 1 && !('model' in state.bodies[0] && state.bodies[0].model), JSON.stringify(state.bodies[0] ?? {}).slice(0, 100)) + await page.waitForFunction(() => !window.__studio.getState().copilot.active, null, { timeout: 4000 }).catch(() => {}) + + // 重新选「跟随默认」时保存失败:服务端存着的没变,下拉退回存着的那一项,原因和齿轮上的红点照旧,并说明没改成 + copilot.fail = true + await select.selectOption('') + await until(() => copilot.puts.length > 0, 3000) + await page.locator('[data-copilot-model-save-failed]').waitFor({ timeout: 3000 }).catch(() => {}) + check('保存失败:说明没改成', (await page.locator('[data-copilot-model-save-failed]').innerText().catch(() => '')) === '保存失败,助手使用的模型未更改') + check('……下拉退回存着的那一项,原因和齿轮上的红点照旧', await pickedText() === 'big-model(模型接入「demo-gone」)' + && await page.locator('[data-copilot-model-error]').innerText().catch(() => '') === ASSISTANT_PANEL + && await page.locator('[data-copilot-model-broken]').count() === 1, await pickedText()) + // 收起再展开:重新取回的就是服务端现在存着的,上一次「保存失败」的说明不再留着,原因照旧 + await gear.click() + await gear.click() + await select.waitFor({ timeout: 3000 }) + await page.waitForTimeout(300) + check('……收起再展开:「保存失败」的说明不再留着,原因照旧', await page.locator('[data-copilot-model-save-failed]').count() === 0 + && await page.locator('[data-copilot-model-error]').innerText().catch(() => '') === ASSISTANT_PANEL) + copilot.fail = false + + await select.selectOption('') + await until(() => copilot.puts.length > 1, 3000) + check('重新选「跟随默认」算一次改动:存成不指定接入和模型', copilot.puts.length === 2 && copilot.puts[1].model === null + && !copilot.puts[1].provider, JSON.stringify(copilot.puts)) + await page.waitForFunction(() => !document.querySelector('[data-copilot-model-error]'), null, { timeout: 3000 }).catch(() => {}) + check('……原因、保存失败的说明、齿轮上的标记一起消失,「跟随默认」写出当前用的模型', await page.locator('[data-copilot-model-error]').count() === 0 + && await page.locator('[data-copilot-model-save-failed]').count() === 0 + && await page.locator('[data-copilot-model-broken]').count() === 0 + && await followText() === '跟随默认(当前:big-model)', await followText()) + // 选了一个模型:服务端回来的「当前」就是这个模型,不能写到「跟随默认」上(跟随默认用的是 demo-main 的 big-model) + await select.selectOption('small-model') + await until(() => copilot.puts.length > 2, 3000) + await page.waitForTimeout(200) + check('选了具体模型:「跟随默认」不写「当前」', copilot.puts[2]?.model === 'small-model' && await followText() === '跟随默认' + && await select.inputValue() === 'small-model', await followText()) + check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | ')) + await ctx.close() + + // 全图默认点名的接入:没写自己接入的节点跟着它。反方审稿的模型一栏不取全图默认,不套这句 + const second = await open({ path: '/studio/st-modeldef', before }) + const defPlain = await look(second.page, 'plain') + check('全图默认点名了接入:没写自己接入的节点写「使用接入「demo-main」的默认模型」', defPlain.blank === '默认(使用接入「demo-main」的默认模型)', defPlain.blank) + const chal = await look(second.page, 'chal') + check('反方审稿的「反方模型」:不多出「留空则使用接入……」(和它自己「跟随设置」的说明相反)', chal.help === null + && !chal.text.includes('留空则使用接入') && chal.text.includes('核查模型'), chal.text.slice(0, 140)) + check('没有运行时报错', second.errors.length === 0, second.errors.slice(0, 2).join(' | ')) + await second.ctx.close() + + // 工作流默认设置写了模型:节点没写模型时用的就是它,不说「使用接入的默认模型」 + const third = await open({ path: '/studio/st-modeldm', before }) + const dmPlain = await look(third.page, 'plain') + check('全图默认写了模型:空选项和说明都写这个模型', dmPlain.blank === '默认(使用工作流默认设置的模型「small-model」)' + && dmPlain.help === '留空则使用工作流默认设置的模型「small-model」', `${dmPlain.blank} · ${dmPlain.help}`) + const dmNamed = await look(third.page, 'named') + check('……节点自己点名了接入也一样(模型取全图默认的)', dmNamed.blank === '默认(使用工作流默认设置的模型「small-model」)' + && dmNamed.help === '留空则使用工作流默认设置的模型「small-model」' && !dmNamed.text.includes('没有指定模型'), `${dmNamed.blank} · ${dmNamed.help}`) + const dmGone = await look(third.page, 'gone') + check('……点名的接入不存在时仍先说状态,不说「使用」', dmGone.blank === '默认(模型接入「demo-gone」不存在)' && dmGone.help === null, + `${dmGone.blank} · ${dmGone.help}`) + const dmChal = await look(third.page, 'chal') + check('……反方审稿的模型一栏同样不套这句', dmChal.help === null && !dmChal.text.includes('工作流默认设置'), dmChal.text.slice(0, 140)) + if (SHOTS) { + await look(third.page, 'plain') + for (const theme of ['dark', 'light']) { + await third.page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await third.page.waitForTimeout(200) + await third.page.locator('[data-inspector-sheet]').screenshot({ path: `${SHOTS}/studio-model-defaults-${theme}.png` }) + } + } + check('没有运行时报错', third.errors.length === 0, third.errors.slice(0, 2).join(' | ')) + await third.ctx.close() + + // 390 宽:助手栏浮在画布上,展开「助手使用的模型」、原因那一长句照样折行,不把页面撑出横向滚动 + copilot.saved = BROKEN + const narrow = await open({ path: '/studio/st-model', before, width: 390, height: 844 }) + await blur(narrow.page) + await narrow.page.keyboard.press('Alt+KeyA') + const narrowGear = narrow.page.getByRole('button', { name: '助手使用的模型' }) + await narrowGear.waitFor({ timeout: 4000 }) + // 编排页在 390 宽时工具栏本身就比视口宽(与这一块无关):比的是展开前后,展开这一块不能再多撑出横向溢出 + const overflow = () => narrow.page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + const base = await overflow() + await narrowGear.click() + await narrow.page.locator('[data-copilot-model-error]').waitFor({ timeout: 3000 }) + const fit = await narrow.page.evaluate(() => { + const err = document.querySelector('[data-copilot-model-error]').getBoundingClientRect() + const sel = document.getElementById('copilot-model').getBoundingClientRect() + return { over: document.documentElement.scrollWidth - document.documentElement.clientWidth, + right: Math.round(Math.max(err.right, sel.right)), vw: window.innerWidth } + }) + check('390 宽:原因折行显示,下拉和原因都在视口里,展开这一块不多撑出横向溢出', fit.over <= base && fit.right <= fit.vw, + JSON.stringify({ ...fit, base })) + if (SHOTS) { + for (const theme of ['dark', 'light']) { + await narrow.page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme) + await narrow.page.waitForTimeout(200) + await narrow.page.screenshot({ path: `${SHOTS}/studio-copilot-model-390-${theme}.png` }) + } + } + check('没有运行时报错', narrow.errors.length === 0, narrow.errors.slice(0, 2).join(' | ')) + await narrow.ctx.close() + } finally { + delete FAKES['st-model'] + delete FAKES['st-modeldef'] + delete FAKES['st-modeldm'] + } +}) + check('整个检查没有弹出原生 confirm / prompt', dialogs === 0, `${dialogs} 次`) check('检查没有写库(兜底拦下的写请求)', writes.length === 0, writes.slice(0, 4).join('、'))