From 0a690a599b916a56317ab2debeedf7f65dd35e6d Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 6 Oct 2026 16:31:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=91=E5=B8=83=E5=89=8D=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=82=B9=E5=90=8D=E7=9A=84=E6=A8=A1=E5=9E=8B=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=EF=BC=9A=E6=8C=89=E6=89=A7=E8=A1=8C=E6=97=B6=E7=9A=84=E5=8F=96?= =?UTF-8?q?=E6=B3=95=E7=AE=97=E4=BF=AE=E5=AE=8C=E8=90=BD=E5=88=B0=E5=93=AA?= =?UTF-8?q?=E9=87=8C=EF=BC=8C=E5=8F=AA=E5=86=99=E4=BA=86=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E8=80=8C=E6=8E=A5=E5=85=A5=E5=B7=B2=E5=81=9C=E7=94=A8=E7=9A=84?= =?UTF-8?q?=E4=B9=9F=E6=8B=A6=E4=B8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「不指定接入」以前只去掉接入、留下同一处写着的模型,复核又只查点名的接入:按模型名落回停用的接入,或者成员跟随节点、节点跟随全图默认,拿别家的接入去调这个模型。检查、发布都通过,正式运行在那一处照样失败 - 现在每个候选(不指定、改用某个启用的接入)先在副本上改,按执行器的取法(成员先看自己、再看节点、再看全图默认;节点看全图默认;都没写按模型名找;核查和裁判整组交给设置)算出改动波及的每一处用哪个接入、哪个模型。落到的接入不提供那一处的模型、按模型名落到停用接入或认不出的模型、落到内置演示模型的,连同一级写着的模型一并去掉再算,还落不稳的候选不给;候选说明写清执行时改用哪里、模型去没去掉、换上的接入实际用哪个模型 - 门禁把哪一级都没写接入、只写了模型而提供它的接入已停用的那一处也记成 provider.disabled(落在写模型的字段,原因和运行时同一句):复核拒得掉这类修复,检查器里只能选模型的节点发布前也查得出来;这一处配「不指定模型」或改用一个启用接入的修复 - 按模型名找接入、没写时退回哪个接入抽成 factory.provider_for_model、fallback_provider,运行时和门禁共用一份 - 问题面板、修复预览写得出「模型」「成员「x」的模型」;check-publish 的伪造门禁跟上新的候选,补只写模型的一段 - 测试:复核发现的几种形状逐一钉住(含发布前检查修好、保存、发布后正式运行成功的端到端一条);依赖旧做法的两条测试改成新的正确行为 --- backend/app/api/workflows.py | 9 +- backend/app/engine/autofix.py | 118 +++++++-- backend/app/engine/governance.py | 4 +- backend/app/engine/provider_refs.py | 199 ++++++++++---- backend/app/providers/factory.py | 59 +++-- backend/tests/test_provider_ref_checks.py | 24 +- backend/tests/test_provider_ref_landing.py | 295 +++++++++++++++++++++ backend/tests/test_provider_ref_runtime.py | 41 +++ frontend/src/canvas/ProblemsPanel.tsx | 8 +- frontend/src/canvas/issues.ts | 9 +- frontend/src/lib/terms.ts | 5 + scripts/check-publish.mjs | 143 ++++++++-- 12 files changed, 776 insertions(+), 138 deletions(-) create mode 100644 backend/tests/test_provider_ref_landing.py diff --git a/backend/app/api/workflows.py b/backend/app/api/workflows.py index f24f3c0e..348e8cd2 100644 --- a/backend/app/api/workflows.py +++ b/backend/app/api/workflows.py @@ -427,9 +427,9 @@ async def _sql_checkers(session: AsyncSession, spec: GraphSpec) -> dict[str, Any async def _providers(session: AsyncSession, spec: GraphSpec) -> list[Any]: - """模型接入表:节点点名的接入不存在或已停用时,发布、发布前检查、自动修复的复核给 error、挡住发布,修复从启用的 - 接入里列候选(engine/provider_refs.py、autofix 的 provider.*)。和 _sql_checkers 一样由接口这一层读好交给门禁, - 门禁本身不查库;三处传同一份。图里没点名任何接入时不读库。""" + """模型接入表:节点点名的接入不存在或已停用、只写了模型而提供它的接入已停用时,发布、发布前检查、自动修复的复核 + 给 error、挡住发布,修复从启用的接入里列候选(engine/provider_refs.py、autofix 的 provider.*)。和 _sql_checkers + 一样由接口这一层读好交给门禁,门禁本身不查库;三处传同一份。图里取模型的几处都没写接入和模型时不读库。""" return await load_providers(session, spec) @@ -522,7 +522,8 @@ class ValidateIn(BaseModel): async def validate(payload: ValidateIn, session: AsyncSession = Depends(get_session)) -> dict[str, Any]: """画布上的实时校验。前端每次改动都会调它来标红。 - 点名的模型接入不存在或已停用只给警告:探索运行仍可发起,执行到那个节点时照实报错;发布时才挡住。""" + 点名的模型接入不存在或已停用(只写了模型、提供它的接入已停用也算)只给警告:探索运行仍可发起,执行到那个节点时 + 照实报错;发布时才挡住。""" try: spec = GraphSpec.model_validate(payload.graph) except Exception as e: # noqa: BLE001 diff --git a/backend/app/engine/autofix.py b/backend/app/engine/autofix.py index 9a10376c..1054356e 100644 --- a/backend/app/engine/autofix.py +++ b/backend/app/engine/autofix.py @@ -462,7 +462,7 @@ def _plan_not_object(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) - return _assist(f"「{_title(node)}」的出具契约不是一个 JSON 对象", "contract") -#: 「不指定接入」那个候选的值:去掉这一处点名的接入 +#: 「不指定接入」(只写了模型的那一处是「不指定模型」)那个候选的值:去掉这一处写的接入(模型) PROVIDER_CLEAR = "" @@ -506,44 +506,109 @@ def _model_path(path: str) -> str: return path[: -len("provider")] + "model" -def _serves(provider: Any, model: str) -> bool: - models = [m.get("id") for m in getattr(provider, "models", None) or [] if isinstance(m, dict)] - return model in models or model == getattr(provider, "default_model", None) +def _landing_text(landed: list[tuple[Any, Any]]) -> str: + """修完以后执行时改用哪里(provider_refs.land 的说法):各处落到同一处只说一次,落到不同处逐处说。""" + groups: dict[str, list[str]] = {} + for use, landing in landed: + groups.setdefault(landing.where, []).append(use.who) + if len(groups) == 1: + return f"改用{next(iter(groups))}" + return ";".join(f"{'、'.join(whos)}改用{where}" for where, whos in groups.items()) def _plan_provider(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: - """点名的模型接入不存在或已停用(engine/provider_refs.py):改用一个启用的接入,或者不再指定。 + """执行时取不到模型的那一处(engine/provider_refs.py):改用一个启用的接入,或者不再指定。点名的接入不存在、已停用 + 的,候选是「不指定接入」和各个启用的接入;只写了模型、提供它的接入已停用的,是「不指定模型」和各个启用的接入 + (写在模型旁边的接入上)。 要人选、不给默认:换成哪个接入只有人知道(同一个模型可能有两家提供,价格、配额都不同)。多数节点的检查器里没有 - 接入下拉框(只能选模型),全图默认、协作成员也没有,这里是界面上唯一能改它的地方。同一处写着模型、选的接入 - 又不提供它时,模型一并去掉、改用那个接入的默认模型——拿 A 家的模型名去调 B 家必然失败(设置页换接入时同一个 - 做法),候选的说明里写清。一处一条(plan_fixes 的 id 带字段):协作节点自己的接入和成员的接入各修各的。 + 接入下拉框(只能选模型),全图默认、协作成员也没有,这里是界面上唯一能改它的地方。一处一条(plan_fixes 的 id + 带字段):协作节点自己的接入和成员的接入各修各的。 + + 每个候选先在副本上改,按执行时的取法(provider_refs.land)算出改动波及的每一处落到哪里:去掉以后成员跟随节点、 + 节点跟随全图默认,都没写按模型名找;协作节点、全图默认换了接入,没写自己接入的成员、节点跟着换。落到的接入不提供 + 那一处的模型、按模型名落到停用的接入或认不出的模型时,同一级写着的模型一并去掉再算——拿 A 家的模型名去调 B 家 + 必然失败(设置页换接入时同一个做法);还落不稳的候选不给,修完了运行照样失败的修复不能交给人。候选的说明写清执行 + 时改用哪里、模型去没去掉。复核(judge)另把一道关:门禁同样查只写了模型、提供它的接入已停用的那一处。 """ - from app.engine.provider_refs import provider_refs + 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(path)) + model = _read_path(root, model_path) model = model.strip() if isinstance(model, str) and model.strip() else None - options: list[dict[str, Any]] = [{"value": PROVIDER_CLEAR, "label": "不指定接入", - "hint": "去掉这一处指定的接入,按上一级的设置或模型名找接入"}] - drop: list[str] = [] + before = {use.key: use for use in uses(ctx.spec)} + + def trial(edits: list[tuple[str, Any]]) -> list[tuple[Any, Any]] | None: + """在副本上改,改动波及的每一处(取法变了的)执行时落到哪里;有一处落不稳返回 None。""" + spec = ctx.spec.model_copy(deep=True) + target = spec.defaults if ref.node_id is None else spec.node_map()[ref.node_id].config + for at, value in edits: + _put_path(target, at, value) + landed = [] + for use in uses(spec): + if before.get(use.key) == use: + continue + if not (landing := land(use, ctx.providers)).ok: + return None + landed.append((use, landing)) + return landed + + def settle(edits: list[tuple[str, Any]]) -> tuple[list[tuple[str, Any]], list[tuple[Any, Any]]] | None: + """先只改这一处;落不稳而同一级写着模型时,连模型一并去掉再试。返回(改法,波及的各处落到哪里),还落不稳为 None。""" + if (landed := trial(edits)) is not None: + return edits, landed + if model and all(at != model_path for at, _ in edits): + edits = [*edits, (model_path, None)] + if (landed := trial(edits)) is not None: + return edits, landed + return None + + options: list[dict[str, Any]] = [] + plans: dict[str, list[tuple[str, Any]]] = {} + if (got := settle([(model_path if ref.kind == "model" else provider_path, None)])) is not None: + edits, landed = got + what = "模型" if ref.kind == "model" else "接入" + (f"和模型「{model}」" if len(edits) > 1 else "") + options.append({"value": PROVIDER_CLEAR, "label": f"不指定{'模型' if ref.kind == 'model' else '接入'}", + "hint": f"去掉这一处指定的{what},执行时{_landing_text(landed)}"}) + plans[PROVIDER_CLEAR] = edits for p in ctx.providers: - if not getattr(p, "enabled", False) or not getattr(p, "name", None): + 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 model and not _serves(p, model): - drop.append(p.name) - default = getattr(p, "default_model", None) - notes.append(f"不提供模型「{model}」,将改用它的默认模型" + (f"「{default}」" if default else "")) + 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}) - return _choice(f"选择{ref.who}改用的模型接入", field, options, _path=path, _drop_model=drop) + plans[p.name] = edits + if not options: + # 一个都落不稳(比如一个启用的接入都没有):不给修复,问题照样拦着发布,说明里写了去设置里启用 + return None + return _choice(f"选择{ref.who}改用的模型接入", field, options, _edits=plans) _PLANNERS: dict[str, Callable[[_Ctx, dict[str, Any], GraphNode | None], dict[str, Any] | None]] = { @@ -759,8 +824,8 @@ def _edit_judge_budget(graph: dict[str, Any], fix: dict[str, Any], value: Any) - def _edit_provider(graph: dict[str, Any], fix: dict[str, Any], value: Any) -> list[tuple[str | None, str, Any, Any]]: - """点名的模型接入(provider.*):落在节点上改节点配置,落在全图默认上改 defaults(节点 id 记 None,字段写全路径 - defaults.x)。选「不指定接入」去掉这一项;选的接入不提供同一处写着的模型时,模型一并去掉。""" + """点名的模型接入(provider.*):照规划时为选中的候选算好的改法写(_plan_provider 的 _edits:写上或去掉接入、 + 连带去掉的模型)。落在节点上改节点配置,落在全图默认上改 defaults(节点 id 记 None,字段写全路径 defaults.x)。""" if fix["node_id"] is None: if not isinstance(graph.get("defaults"), dict): graph["defaults"] = {} @@ -770,12 +835,11 @@ def _edit_provider(graph: dict[str, Any], fix: dict[str, Any], value: Any) -> li if node is None: return [] root, prefix = _config(node), "" - path = str(fix.get("_path") or "") - after = value if isinstance(value, str) and value != PROVIDER_CLEAR else None - edits: list[tuple[str | None, str, Any, Any]] = [(fix["node_id"], prefix + path, _put_path(root, path, after), after)] - if after is not None and after in (fix.get("_drop_model") or ()): - if (model := _put_path(root, _model_path(path), None)) is not None: - edits.append((fix["node_id"], prefix + _model_path(path), model, None)) + edits: list[tuple[str | None, str, Any, Any]] = [] + for path, after in (fix.get("_edits") or {}).get(value) or (): + before = _put_path(root, path, after) + if before is not None or after is not None: + edits.append((fix["node_id"], prefix + path, before, copy.deepcopy(after))) return edits diff --git a/backend/app/engine/governance.py b/backend/app/engine/governance.py index 39858dc4..7b583de3 100644 --- a/backend/app/engine/governance.py +++ b/backend/app/engine/governance.py @@ -45,8 +45,8 @@ def lint_for_publish(spec: GraphSpec, *, level: str, checkers: Mapping[str, Any] 门禁本身不查库。给了的话,调用工具节点里写死的 SQL 对照目录查一遍(_lint_sql)。 providers 是模型接入表(provider_refs.load_providers),同样由调用方读好传进来。给了的话,节点点名的接入 - 不存在或已停用的每一处一条 error(provider.missing / provider.disabled),两档都挡:正式运行到那里一定失败, - 或者少一块核查。不给(一键升级、助手自查这类只看图本身的地方)就不查。 + 不存在或已停用、只写了模型而提供它的接入已停用的每一处一条 error(provider.missing / provider.disabled), + 两档都挡:正式运行到那里一定失败,或者少一块核查。不给(一键升级、助手自查这类只看图本身的地方)就不查。 """ result = ValidationResult() strict = level == "governed" diff --git a/backend/app/engine/provider_refs.py b/backend/app/engine/provider_refs.py index 7a8d2f4d..7014745a 100644 --- a/backend/app/engine/provider_refs.py +++ b/backend/app/engine/provider_refs.py @@ -4,19 +4,23 @@ 就该看得到:画布校验给警告(探索运行仍可发起,执行到那里报错),发布、发布前检查、自动修复的复核给错误、挡住 发布——正式运行到那里一定失败,或者少一块核查。 -查的是执行时真会去取模型的那几处,取法和执行器一致: +查的是执行时真会去取模型的那几处,取法和执行器一致(uses): - 模型调用、Agent、报告撰写、多 Agent 协作的接入;条件分支只在「让模型分类」时、结构校验只在「让模型修复」且允许 - 重试时(max_retries 为 0 一次都不修)、知识检索只在「让模型重排」时才取模型。这几类节点没写自己的接入时取全图 - 默认(NodeContext.cfg); -- 多 Agent 协作各成员的接入;报告撰写交给模型裁判结论句时,「结论句裁判」里的接入(节点没写裁判配置时取全图默认 - 里的那一份,同 nodes/report.report_judge); -- 双盲复算、反方审稿的接入(核查模型):只读节点自己的配置,不取全图默认。节点自己的开关关着时执行器直接跳过、 - 不取模型,不查;设置里的总开关不看——它随时会被打开,打开时这一处就会失败; -- 全图默认的接入:有节点会取到它时才查。 + 重试时(max_retries 为 0 一次都不修)、知识检索只在「让模型重排」时才取模型。这几类节点没写自己的接入、模型时 + 各自取全图默认(NodeContext.cfg); +- 多 Agent 协作各成员:自己没写的接入、模型各自跟随节点(再到全图默认,nodes/multi._member);报告撰写交给模型 + 裁判结论句时,「结论句裁判」里的接入、模型(节点没写裁判配置时取全图默认里的那一份,同 nodes/report.report_judge); +- 双盲复算、反方审稿的接入、模型(核查模型):只读节点自己的配置,不取全图默认。节点自己的开关关着时执行器直接 + 跳过、不取模型,不查;设置里的总开关不看——它随时会被打开,打开时这一处就会失败; +- 全图默认的接入、模型:有节点会取到它时才查。 +取到了接入的,查点名的那个接入(不存在、已停用);哪一级都没写接入、只写了模型的,按模型名找接入,只有停用的接入 +提供它时同样是问题(provider.disabled,落在写模型的那一处)——运行时 resolve_provider 报的就是这一句,而多数节点的 +检查器里只能选模型,这是最常见的写法。核查、裁判的接入、模型都没写时交给设置,不归这里查。 -说法分两种:检查器里有接入下拉框的几处(双盲复算、反方审稿、节点自己的结论句裁判)说「在节点中换一个接入」; -别处(节点自己的接入、协作成员、全图默认)界面上没有下拉框,说「在发布前检查中改用其他接入」——发布前检查给这类 -问题配了修复(engine/autofix.py 的 provider.*:改用一个启用的接入,或不再指定)。 +说法分两种:检查器里有这一项下拉框的几处(双盲复算、反方审稿、节点自己的结论句裁判的接入和模型,几类节点自己的模型) +说「在节点中换一个接入 / 模型」;别处(节点自己的接入、协作成员、全图默认)界面上没有下拉框,说「在发布前检查中改用 +其他接入」——发布前检查给这类问题配了修复(engine/autofix.py 的 provider.*:改用一个启用的接入,或不再指定),修复 +按 land 算出修完以后执行时落到哪里,落不稳的候选不给。 接入表要读库,门禁本身不查库(governance.lint_for_publish),所以由接口这一层读出接入表交进来,同数据目录 SQL 检查器的做法。设置里点名接入的几项由 api/settings.py 在保存时查。 @@ -30,12 +34,19 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.engine.labels import field_label, type_label from app.engine.schema import CLAIMS_JUDGE, GraphNode, GraphSpec, NodeType, ValidationIssue -from app.providers.factory import NAMED_FIX, ProviderNotConfigured, list_providers, named_provider +from app.providers.factory import ( + NAMED_FIX, ProviderNotConfigured, fallback_provider, list_providers, named_provider, provider_for_model, serves, +) -#: 这类问题的编号:provider.missing(点名的接入不存在)、provider.disabled(已停用) +#: 这类问题的编号:provider.missing(点名的接入不存在)、provider.disabled(已停用;只写了模型、提供它的接入停用了也是它) CODES = frozenset({"provider.missing", "provider.disabled"}) -#: 提示的后半句:这一处在检查器里有没有接入下拉框 -_WHERE = {True: "或在节点中换一个接入", False: "或在「发布前检查」中改用其他接入"} +#: 提示的后半句:这一处写的是接入还是模型、检查器里有没有这一项的下拉框 +_WHERE = {("provider", True): "或在节点中换一个接入", ("model", True): "或在节点中换一个模型", + ("provider", False): "或在「发布前检查」中改用其他接入", ("model", False): "或在「发布前检查」中改用其他接入"} +#: 检查器里有模型下拉框的几类节点(前端 nodeDefs 的 MODEL_FIELDS;条件分支只在「让模型分类」时显示,也只在那时才查) +_MODEL_PICKER = frozenset({NodeType.LLM, NodeType.AGENT, NodeType.SUPERVISOR, NodeType.BRANCH, NodeType.REPORT}) +#: 核查、裁判的接入和模型都没写时执行用设置里的哪一项(api/settings.verify_model_spec、judge.judge_model_spec) +_SETTINGS = {"verify": "设置中的核查模型", "judge": "设置中的裁判模型"} def _cfg(spec: GraphSpec, node: GraphNode, key: str, default: Any = None) -> Any: @@ -75,80 +86,166 @@ def _name(value: Any) -> str | None: @dataclass(frozen=True) class ProviderRef: - """图里点名接入的一处:落在哪个节点(全图默认为 None)、哪个字段,点的名字,句子里怎么称呼这一处, - 检查器里有没有这一处的接入下拉框。""" + """图里写着接入或模型的一处:落在哪个节点(全图默认为 None)、哪个字段,写的名字,句子里怎么称呼这一处,检查器里 + 有没有这一项的下拉框;kind 是写的接入(provider)还是模型(model)。""" node_id: str | None field: str name: str who: str editable: bool = False + kind: str = "provider" -def provider_refs(spec: GraphSpec) -> list[ProviderRef]: - """图里执行时会用到的接入点名,按画布上节点的顺序,全图默认排最后。""" - out: list[ProviderRef] = [] - falls_back = False - default_judge = False +def _place(node_id: str | None, field: str, value: Any, who: str, *, editable: bool = False, + kind: str = "provider") -> ProviderRef | None: + name = _name(value) + return ProviderRef(node_id, field, name, who, editable, kind) if name else None + + +@dataclass(frozen=True) +class Use: + """执行时取一次模型:key 是(节点 id,哪一次:node、agents[i]、judge、verify),who 是句子里怎么称呼它;接入、模型 + 各取自哪一处(哪一级都没写为 None)。settings 是核查、裁判的接入和模型都没写、交给设置的那一项(verify / judge)。""" + + key: tuple[str, str] + who: str + provider: ProviderRef | None + model: ProviderRef | None + settings: str | None = None + + +def _own_or_default(spec: GraphSpec, node: GraphNode, key: str, who: str) -> ProviderRef | None: + """节点的 provider / model 取自哪一处,同 NodeContext.cfg:节点上没写(None、空串)就取全图默认。""" + if node.config.get(key) in (None, ""): + return _place(None, f"defaults.{key}", spec.defaults.get(key), field_label("defaults"), kind=key) + return _place(node.id, key, node.config.get(key), who, kind=key, + editable=key == "model" and node.type in _MODEL_PICKER) + + +def uses(spec: GraphSpec) -> list[Use]: + """图里执行时取模型的每一处,按画布上节点的顺序;只算执行时真会取模型的(见模块说明)。""" + out: list[Use] = [] for node in spec.nodes: cfg = node.config who = f"「{node.title}」({type_label(node.type)})" if node.type in _VERIFY: - if cfg.get("enabled") is not False and (name := _name(cfg.get("provider"))): - out.append(ProviderRef(node.id, "provider", name, who + _VERIFY[node.type], editable=True)) + # 接入、模型哪一项写了就整组以节点为准,都没写交给设置(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): - if name := _name(cfg.get("provider")): - out.append(ProviderRef(node.id, "provider", name, who)) - else: - falls_back = True + 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 isinstance(member, dict) and (name := _name(member.get("provider"))): - label = _name(member.get("name")) or f"第 {i + 1} 位" - out.append(ProviderRef(node.id, f"agents[{i}].provider", name, f"{who}的成员「{label}」")) + 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") - if judge is not cfg.get("judge"): - # 节点没写裁判配置,执行时用全图默认里的那一份:问题记在全图默认上,几个报告共用时只记一条 - default_judge = True - elif isinstance(judge, dict) and (name := _name(judge.get("provider"))): - out.append(ProviderRef(node.id, "judge.provider", name, f"{who}的{field_label('judge')}", - editable=True)) - if falls_back and (name := _name(spec.defaults.get("provider"))): - out.append(ProviderRef(None, "defaults.provider", name, field_label("defaults"))) - judge = spec.defaults.get("judge") - if default_judge and isinstance(judge, dict) and (name := _name(judge.get("provider"))): - out.append(ProviderRef(None, "defaults.judge.provider", name, - f"{field_label('defaults')}中的{field_label('judge')}")) + # 节点没写裁判配置,执行时用全图默认里的那一份:问题记在全图默认上,几个报告共用时只记一条 + own = judge is cfg.get("judge") + nid, at, jwho = (node.id, "judge", f"{who}的{field_label('judge')}") if own else \ + (None, "defaults.judge", f"{field_label('defaults')}中的{field_label('judge')}") + judge = judge if isinstance(judge, dict) else {} + p = _place(nid, f"{at}.provider", judge.get("provider"), jwho, editable=own) + m = _place(nid, f"{at}.model", judge.get("model"), jwho, editable=own, kind="model") + out.append(Use((node.id, "judge"), jwho, p, m, None if p or m else "judge")) return out +def provider_refs(spec: GraphSpec) -> list[ProviderRef]: + """执行时要查的那几处,按画布上节点的顺序,全图默认排最后,同一处只记一次:取到了接入的是那个接入;哪一级都没写 + 接入、只写了模型的是那个模型(按模型名找接入)。""" + found: dict[tuple[str | None, str], ProviderRef] = {} + for use in uses(spec): + if (ref := use.provider or use.model) is not None: + found.setdefault((ref.node_id, ref.field), ref) + return sorted(found.values(), key=lambda r: r.node_id is None) + + def broken(refs: list[ProviderRef], providers: list[Any]) -> list[tuple[ProviderRef, ProviderNotConfigured]]: - """点名的接入不存在或已停用的那几处,带着 named_provider 报的错(code 是 missing / disabled)。""" + """执行时会报错的那几处,带着运行时报的那个错(code 是 missing / disabled):点名的接入按名字找(named_provider), + 只写了模型的按模型名找(provider_for_model,认不出的模型名运行时放行,不算)。""" out: list[tuple[ProviderRef, ProviderNotConfigured]] = [] for ref in refs: try: - named_provider(providers, ref.name) + if ref.kind == "model": + provider_for_model(providers, ref.name) + else: + named_provider(providers, ref.name) except ProviderNotConfigured as e: out.append((ref, e)) return out def issues(refs: list[ProviderRef], providers: list[Any], *, level: str) -> list[ValidationIssue]: - """点名的接入不存在或已停用的每一处一条问题。providers 是接入表(factory.list_providers),level 由调用方定: - 画布校验 warning,发布一类 error。原因和运行时报的同一句(named_provider),提示按这一处在界面上能不能改。""" + """执行时会报错的每一处一条问题。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.editable]}") + message=f"{ref.who}指定的{e.reason}。{NAMED_FIX[e.code]},{_WHERE[ref.kind, ref.editable]}") for ref, e in broken(refs, providers)] +@dataclass(frozen=True) +class Landing: + """执行时这一处落到哪里:where 是说给人听的(「工作流默认设置指定的接入「甲」」……),model 是用哪个模型(交给设置时 + 为 None),ok 是修复能不能把它落在这里。""" + + where: str + model: str | None + ok: bool + + +def land(use: Use, providers: list[Any]) -> Landing: + """按执行器的取法(get_chat_model → resolve_provider → build_chat_model)算这一处执行时用哪个接入、哪个模型。 + + ok 为真才算落得稳:点名的接入启用着、有模型可用,并且提供这一处写的模型——拿 A 家的模型名去调 B 家必然失败; + 只写了模型的,按模型名找到启用的接入。按模型名落到停用的接入(运行时报错)、模型名认不出来(落到第一个可用的 + 接入,它多半不提供)、一个启用的接入都没有(悄悄换成内置演示模型)都不算。交给设置的(核查、裁判)算落得稳: + 设置保存时查过点名的接入,之后接入出了问题设置页会标出来。""" + if use.settings: + return Landing(_SETTINGS[use.settings], None, True) + model = use.model.name if use.model is not None else None + if use.provider is not None: + try: + found = named_provider(providers, use.provider.name) + except ProviderNotConfigured as e: + return Landing(e.reason, model, False) + where = f"{use.provider.who}指定的接入「{found.name}」" + elif model: + try: + found = provider_for_model(providers, model) + except ProviderNotConfigured as e: + return Landing(e.reason, model, False) + if found is None: + return Landing(f"认不出的模型「{model}」", model, False) + where = f"提供模型「{model}」的接入「{found.name}」" + else: + found = fallback_provider(providers) + if found is None: + return Landing("内置演示模型", None, False) + where = f"第一个可用的模型接入「{found.name}」" + used = model or found.default_model + return Landing(where, used, bool(used) and (model is None or serves(found, model))) + + async def load_issues(session: AsyncSession, spec: GraphSpec, *, level: str) -> list[ValidationIssue]: - """读接入表、查一遍。图里没点名任何接入时不读库。""" + """读接入表、查一遍。图里执行时取模型的几处都没写接入和模型时不读库。""" refs = provider_refs(spec) if not refs: return [] @@ -156,6 +253,6 @@ async def load_issues(session: AsyncSession, spec: GraphSpec, *, level: str) -> async def load_providers(session: AsyncSession, spec: GraphSpec) -> list[Any] | None: - """发布、发布前检查、自动修复交给门禁的接入表(governance.lint_for_publish 的 providers)。图里没点名任何接入时 - 不读库,给空表:没有要查的,修复也用不上候选。""" + """发布、发布前检查、自动修复交给门禁的接入表(governance.lint_for_publish 的 providers)。图里执行时取模型的几处 + 都没写接入和模型时不读库,给空表:没有要查的,修复也用不上候选。""" return await list_providers(session) if provider_refs(spec) else [] diff --git a/backend/app/providers/factory.py b/backend/app/providers/factory.py index 3e70d426..f6cb0a66 100644 --- a/backend/app/providers/factory.py +++ b/backend/app/providers/factory.py @@ -68,6 +68,38 @@ def named_provider(providers: list[Provider], ref: Any) -> Provider: return found +def serves(provider: Any, model: str) -> bool: + """这个接入提供不提供这个模型:模型列表里有,或者是它的默认模型(按模型名找接入的同一个认法)。""" + return any((m or {}).get("id") == model for m in (provider.models or [])) or provider.default_model == model + + +def provider_for_model(providers: list[Provider], model: str) -> Provider | None: + """没点名接入时按模型名找提供它的接入(providers 是 list_providers 的结果):先认模型列表、再认默认模型,只看启用的。 + + 只有停用的接入提供它时抛 ProviderNotConfigured(disabled)——这时候不能默默换一家:拿着 A 家的模型名去调 B 家, + 必然 404,而且报错完全指不到真正的原因。认不出来的模型名返回 None:用户可能手填了一个目录里还没有的新模型,由 + 调用方退回 fallback_provider。运行时取模型(resolve_provider)和画布校验、发布前检查(engine/provider_refs.py) + 按这一处认,说法一致。""" + enabled = [p for p in providers if p.enabled] + found = next((p for p in enabled if any((m or {}).get("id") == model for m in (p.models or []))), None) \ + or next((p for p in enabled if p.default_model == model), None) + if found is not None: + return found + off = next((p for p in providers if not p.enabled and serves(p, model)), None) + if off is not None: + raise ProviderNotConfigured(f"模型「{model}」所属的模型接入「{off.name}」已停用", + "请在「设置 → 模型接入」中启用它,或为节点换一个模型", code="disabled") + return None + + +def fallback_provider(providers: list[Provider]) -> Provider | None: + """没点名接入、也没写模型(或模型名认不出来)时用哪个:第一个启用的真实接入,没有就第一个启用的演示接入;一个启用 + 的都没有返回 None(get_chat_model 换成内置演示模型)。""" + enabled = [p for p in providers if p.enabled] + real = [p for p in enabled if p.kind != "mock"] + return (real or enabled or [None])[0] + + def assistant_unavailable(e: BaseException) -> str: """助手(画布上改图、表格导入与数据目录的 AI 起草)用的模型取不到时给人看的那句话。 @@ -98,33 +130,14 @@ async def resolve_provider( 设置里的数字一类)按点名了一个不存在的接入报,不当成没给。 """ providers = await list_providers(session) - enabled = [p for p in providers if p.enabled] if ref is not None and (not isinstance(ref, str) or ref.strip()): return named_provider(providers, ref) - if model: - for p in enabled: - if any((m or {}).get("id") == model for m in (p.models or [])): - return p - for p in enabled: - if p.default_model == model: - return p - # 模型认得出来,但它所属的 provider 被停用了。这时候不能默默换一家 —— - # 拿着 A 家的模型名去调 B 家,必然 404,而且报错完全指不到真正的原因。 - for p in providers: - if not p.enabled and ( - any((m or {}).get("id") == model for m in (p.models or [])) - or p.default_model == model - ): - raise ProviderNotConfigured( - f"模型「{model}」所属的模型接入「{p.name}」已停用", - "请在「设置 → 模型接入」中启用它,或为节点换一个模型", code="disabled", - ) - # 认不出来的模型名就放行:用户可能手填了一个目录里还没有的新模型 - - real = [p for p in enabled if p.kind != "mock"] - return (real or enabled or [None])[0] + # 模型认得出来、所属的接入却停用了:provider_for_model 照实报错,不默默换一家。认不出来的模型名放行 + if model and (found := provider_for_model(providers, model)) is not None: + return found + return fallback_provider(providers) def _resolve_key(provider: Provider) -> str | None: diff --git a/backend/tests/test_provider_ref_checks.py b/backend/tests/test_provider_ref_checks.py index 9dc8292e..639b4da2 100644 --- a/backend/tests/test_provider_ref_checks.py +++ b/backend/tests/test_provider_ref_checks.py @@ -7,7 +7,8 @@ 挡住发布。只查执行时真会去取模型的那几处(分支「让模型分类」、校验「让模型修复」且允许重试、检索「让模型重排」 时才算;核查节点自己的开关关着不算);报告没写裁判配置时查全图默认里的那一份; - 提示按这一处在界面上能不能改:检查器里有接入下拉框的说「在节点中换一个接入」,别处说「在发布前检查中改用」; -- 发布前检查给每一处配一条要人选的修复:改用一个启用的接入,或不指定接入。 +- 发布前检查给每一处配一条要人选的修复:改用一个启用的接入,或不指定接入(修完以后执行时落到哪里、只写了模型而 + 提供它的接入已停用的那一处,见 test_provider_ref_landing)。 接入名、节点名都是虚构的。 """ @@ -426,11 +427,16 @@ async def test_each_broken_provider_gets_a_choice_to_switch_or_stop_naming_one(c ("write", "provider", GONE, LIVE), ("write", "model", "核对旧模型", None)] assert provider_issues({"issues": out["remaining"]}) == [] + # 不指定接入:哪一级都没写接入,按模型名找;「核对旧模型」没有哪个接入提供,执行时会落到第一个可用的接入去调它, + # 所以模型一并去掉,说明里写清改用哪里(详见 test_provider_ref_landing) + clear = fix["options"][0] + assert clear["hint"].startswith("去掉这一处指定的接入和模型「核对旧模型」,执行时改用第一个可用的模型接入「"), clear out = await _fix(client, wf, graph, fix["id"], "") assert out["applied"] == [fix["id"]] and out["ok"] is True, out["rejected"] config = config_of(out["graph"], "write") - assert "provider" not in config and config["model"] == "核对旧模型" - assert [(c["field"], c["before"], c["after"]) for c in out["changes"]] == [("provider", GONE, None)] + assert "provider" not in config and "model" not in config + assert [(c["field"], c["before"], c["after"]) for c in out["changes"]] == [("provider", GONE, None), + ("model", "核对旧模型", None)] async def test_a_team_node_and_its_member_are_fixed_one_place_at_a_time(client): @@ -472,16 +478,18 @@ async def test_the_graph_default_provider_has_its_own_fix(client): (None, "defaults.provider", OFF, LIVE), (None, "defaults.model", "核对停用模型", None)] -async def test_stop_naming_a_provider_is_refused_when_the_graph_default_is_broken_too(client): - """节点不再指定接入就改取全图默认:全图默认的接入也坏了,修完冒出新问题,这一项不采用并说清原因。""" +async def test_stop_naming_a_provider_is_not_offered_when_the_graph_default_is_broken_too(client): + """节点不再指定接入就改取全图默认:全图默认的接入也坏了,修完照样失败,「不指定接入」这一项不给;直接把这个值发给 + 接口也不采用。换成启用的接入照常给。""" graph = chain(node("start", "input"), node("ask", "llm", "提问", provider=GONE, prompt="总结"), node("out", "output", fields=[{"name": "结论", "value": "{{ last_message }}"}]), defaults={"provider": OFF}) wf = await _workflow(graph) r = await client.post(f"/api/workflows/{wf}/publish-check", json={"level": "published", "graph": graph}) [issue] = provider_issues(r.json()) # 唯一的节点写了自己的接入,全图默认的没人用,不提 + [fix] = [f for f in r.json()["fixes"] if f["id"] == issue["fix"]] + values = [o["value"] for o in fix["options"]] + assert "" not in values and LIVE in values, values out = await _fix(client, wf, graph, issue["fix"], "") - assert out["applied"] == [] - [(fid, reason)] = [(x["fix_id"], x["reason"]) for x in out["rejected"]] - assert fid == issue["fix"] and reason.startswith("修改后出现了新的问题:工作流默认设置指定的模型接入"), reason + assert out["applied"] == [] and [x["fix_id"] for x in out["rejected"]] == [issue["fix"]] assert config_of(out["graph"], "ask")["provider"] == GONE and out["changes"] == [] diff --git a/backend/tests/test_provider_ref_landing.py b/backend/tests/test_provider_ref_landing.py new file mode 100644 index 00000000..494e3a26 --- /dev/null +++ b/backend/tests/test_provider_ref_landing.py @@ -0,0 +1,295 @@ +"""发布前修复点名的模型接入:修完以后执行时落到哪个接入,按执行器的取法算,落不稳的候选不给。 + +上一轮的「不指定接入」只去掉接入、留下同一处写着的模型,复核又只查点名的接入,于是检查、发布都通过,正式运行在那一处 +照样失败: +- 节点点名停用的丙、模型写丙家的模型:去掉接入后按模型名找,找到的还是停用的丙; +- 协作节点点名启用的甲、成员点名停用的丙并写丙家的模型:成员去掉接入后跟随节点,拿甲去调丙家的模型; +- 节点点名丙并写丙家的模型、全图默认点名乙:同样拿乙去调丙家的模型; +- 双盲复算、反方审稿、结论句裁判同理:按模型名落到停用的接入,记缺口或未裁判。 + +现在: +- 门禁把「没有一级写接入、只写了模型,提供它的接入已停用」也记成 provider.disabled,落在写模型的那一处,原因和运行时 + resolve_provider 报的同一句:只写模型的节点(多数节点的检查器里只能选模型)发布前查得出来,复核也拒得掉这类修复; +- 修复的每个候选先在副本上改,按执行时的取法算出波及的每一处落到哪里(成员先看自己、再看节点、再看全图默认;节点看 + 全图默认;都没写按模型名找;核查、裁判整组交给设置)。落到的接入不提供这一处的模型、按模型名落到停用的接入或认不出 + 的模型,就把同一级写着的模型一并去掉再算;还落不稳的候选不给。候选说明写清执行时改用哪里、模型去没去掉; +- 只写了模型的那一处也有修复:改用一个启用的接入(模型换成它的),或不指定模型。 + +纯函数:接入表是虚构的几行,不读库。 +""" +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from app.engine import autofix +from app.engine.autofix import apply_fixes, check +from app.engine.governance import publish_issues +from app.engine.schema import GraphSpec + + +def provider(name: str, *models: str, enabled: bool = True, kind: str = "openai_compatible") -> SimpleNamespace: + return SimpleNamespace(id=f"pv-{name}", name=name, kind=kind, enabled=enabled, + models=[{"id": m} for m in models], default_model=models[0] if models else None) + + +#: 甲排在最前:没写接入也没写模型时落到它(第一个可用的接入) +JIA = provider("甲接入", "甲一号", "甲二号") +YI = provider("乙接入", "乙一号") +BING = provider("丙接入", "丙一号", enabled=False) +TABLE = [JIA, YI, BING] +STOPPED = "所属的模型接入「丙接入」已停用" + + +def node(nid, ntype, label=None, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}} + + +def flow(*middle, defaults=None) -> dict: + nodes = [node("start", "input"), *middle, + node("out", "output", fields=[{"name": "结论", "value": "{{ last_message }}"}])] + graph = {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} + if defaults: + graph["defaults"] = defaults + return graph + + +def provider_issues(issues) -> list[dict]: + return [i for i in map(autofix._as_dict, issues) if str(i.get("code") or "").startswith("provider.")] + + +def gate(graph: dict, table=TABLE) -> list[dict]: + return provider_issues(publish_issues(GraphSpec.model_validate(graph), level="published", providers=table)) + + +def fix_of(graph: dict, fid: str, table=TABLE) -> dict | None: + found = check(GraphSpec.model_validate(graph), level="published", providers=table) + return next((f for f in found["fixes"] if f["id"] == fid), None) + + +def hints(fix: dict) -> dict: + return {o["value"]: o.get("hint") for o in fix["options"]} + + +def apply(graph: dict, choices: dict, table=TABLE) -> dict: + return apply_fixes(graph, list(choices), choices, level="published", providers=table) + + +def config_of(graph: dict, nid: str) -> dict: + return next(n for n in graph["nodes"] if n["id"] == nid)["data"]["config"] + + +def changes(out: dict) -> list[tuple]: + return [(c["node_id"], c["field"], c["before"], c["after"]) for c in out["changes"]] + + +# -------------------------------------------------------------------------- +# 门禁:只写了模型、提供它的接入已停用 +# -------------------------------------------------------------------------- + + +def test_a_node_naming_only_a_model_of_a_disabled_provider_is_stopped_before_publishing(): + """检查器里多数节点只能选模型:选的是停用接入的模型,运行时 resolve_provider 报「所属的模型接入已停用」,发布前就该看到。""" + [issue] = gate(flow(node("ask", "llm", "提问", model="丙一号", prompt="总结"))) + assert (issue["level"], issue["code"], issue["node_id"], issue["field"]) == ( + "error", "provider.disabled", "ask", "model") + assert issue["message"] == ("「提问」(模型调用)指定的模型「丙一号」所属的模型接入「丙接入」已停用。" + "请在「设置 → 模型接入」中启用它,或在节点中换一个模型") + # 启用的接入提供它;模型名认不出来(手填的新模型,运行时退回第一个可用的接入);同一处点名了启用的接入(以点名的为准, + # 不按模型名找):都不提 + for config in ({"model": "甲二号"}, {"model": "自填的新模型"}, {"model": "丙一号", "provider": "甲接入"}): + assert gate(flow(node("ask", "llm", "提问", prompt="总结", **config))) == [], config + + +def test_the_problem_is_recorded_once_where_the_model_is_written(): + """全图默认里的模型几个节点共用时只记一条;协作成员、全图默认里的模型在检查器里改不了,提示指到发布前检查。 + 节点点名了启用的接入时,跟随它的成员、取全图默认模型的节点以点名的为准,不提。""" + graph = flow(node("a", "llm", "提问", prompt="总结"), node("b", "agent", "查数", prompt="查数"), + node("team", "supervisor", "协作", provider="甲接入", goal="分析", + agents=[{"name": "分析师", "model": "丙一号", "system": "分析"}]), + node("team2", "supervisor", "协作二", goal="分析", + agents=[{"name": "研究员", "model": "丙一号", "system": "研究"}]), + defaults={"model": "丙一号"}) + got = [(i["node_id"], i["field"], i["message"]) for i in gate(graph)] + tail = "请在「设置 → 模型接入」中启用它,或在「发布前检查」中改用其他接入" + assert got == [ + ("team2", "agents[0].model", f"「协作二」(多 Agent 协作)的成员「研究员」指定的模型「丙一号」{STOPPED}。{tail}"), + (None, "defaults.model", f"工作流默认设置指定的模型「丙一号」{STOPPED}。{tail}"), + ] + + +def test_verify_nodes_and_the_judge_naming_only_a_model_of_a_disabled_provider_are_checked_too(): + """双盲复算、反方审稿、结论句裁判只写了模型时按模型名找接入(整组以节点为准,不交给设置)。""" + graph = flow(node("write", "report", "写周报", instructions="写周报", provider="甲接入", claims="judge", + judge={"model": "丙一号"}), + node("dual", "dual_check", "复核销售额", model="丙一号"), + node("chal", "challenge", "审稿", report="write", model="丙一号")) + got = {(i["node_id"], i["field"]): i["message"] for i in gate(graph)} + assert set(got) == {("write", "judge.model"), ("dual", "model"), ("chal", "model")} + assert got[("dual", "model")] == (f"「复核销售额」(双盲复算)的盲算模型指定的模型「丙一号」{STOPPED}。" + "请在「设置 → 模型接入」中启用它,或在节点中换一个模型") + assert got[("write", "judge.model")].startswith(f"「写周报」(报告撰写)的结论句裁判指定的模型「丙一号」{STOPPED}") + + +# -------------------------------------------------------------------------- +# 「不指定接入」:按执行时的取法算出去掉以后落到哪里 +# -------------------------------------------------------------------------- + + +def test_stop_naming_a_disabled_provider_drops_the_model_only_it_provides_too(): + """复核发现的原样:节点点名停用的丙、模型写丙家的。只去掉接入的话,按模型名找到的还是停用的丙。""" + graph = flow(node("ask", "llm", "提问", provider="丙接入", model="丙一号", prompt="总结")) + fid = "provider.disabled:ask:provider" + assert hints(fix_of(graph, fid))[""] == "去掉这一处指定的接入和模型「丙一号」,执行时改用第一个可用的模型接入「甲接入」" + out = apply(graph, {fid: ""}) + assert out["applied"] == [fid] and out["ok"] is True, out["rejected"] + assert config_of(out["graph"], "ask") == {"prompt": "总结"} + assert changes(out) == [("ask", "provider", "丙接入", None), ("ask", "model", "丙一号", None)] + assert gate(out["graph"]) == [] + + # 写的模型有启用的接入提供:只去掉接入,按模型名落到提供它的那一家,模型留着 + graph = flow(node("ask", "llm", "提问", provider="丙接入", model="乙一号", prompt="总结")) + assert hints(fix_of(graph, fid))[""] == "去掉这一处指定的接入,执行时改用提供模型「乙一号」的接入「乙接入」" + out = apply(graph, {fid: ""}) + assert out["ok"] is True and config_of(out["graph"], "ask") == {"prompt": "总结", "model": "乙一号"}, out["rejected"] + + +def test_a_member_that_stops_naming_a_provider_follows_its_team_node(): + """成员去掉接入后跟随节点点名的甲:甲不提供成员写的模型,模型一并去掉(不然拿甲去调丙家的模型);甲提供的就留着。""" + graph = flow(node("team", "supervisor", "协作", provider="甲接入", goal="分析", + agents=[{"name": "分析师", "provider": "丙接入", "model": "丙一号", "system": "分析"}, + {"name": "复核员", "provider": "丙接入", "model": "甲二号", "system": "复核"}])) + first, second = "provider.disabled:team:agents[0].provider", "provider.disabled:team:agents[1].provider" + assert hints(fix_of(graph, first))[""] == ("去掉这一处指定的接入和模型「丙一号」," + "执行时改用「协作」(多 Agent 协作)指定的接入「甲接入」") + assert hints(fix_of(graph, second))[""] == "去掉这一处指定的接入,执行时改用「协作」(多 Agent 协作)指定的接入「甲接入」" + out = apply(graph, {first: "", second: ""}) + assert out["applied"] == [first, second] and out["ok"] is True, out["rejected"] + assert config_of(out["graph"], "team")["agents"] == [{"name": "分析师", "system": "分析"}, + {"name": "复核员", "model": "甲二号", "system": "复核"}] + + +def test_stop_naming_a_provider_under_a_graph_default_drops_a_model_the_default_does_not_provide(): + graph = flow(node("ask", "llm", "提问", provider="丙接入", model="丙一号", prompt="总结"), defaults={"provider": "乙接入"}) + fid = "provider.disabled:ask:provider" + assert hints(fix_of(graph, fid))[""] == ("去掉这一处指定的接入和模型「丙一号」," + "执行时改用工作流默认设置指定的接入「乙接入」") + out = apply(graph, {fid: ""}) + assert out["ok"] is True and config_of(out["graph"], "ask") == {"prompt": "总结"}, out["rejected"] + + +@pytest.mark.parametrize("ntype,extra", [("dual_check", {}), ("challenge", {"report": "write"})]) +def test_a_verify_node_that_stops_naming_a_provider_goes_to_the_settings_not_a_disabled_provider(ntype, extra): + """核查节点只写了模型时按模型名找接入:丙家的模型留着就又落到停用的丙。连模型一起去掉,整组交给设置里的核查模型。""" + graph = flow(node("write", "report", "写周报", instructions="写周报", provider="甲接入"), + node("v", ntype, "核查", provider="丙接入", model="丙一号", **extra)) + fid = "provider.disabled:v:provider" + assert hints(fix_of(graph, fid))[""] == "去掉这一处指定的接入和模型「丙一号」,执行时改用设置中的核查模型" + out = apply(graph, {fid: ""}) + assert out["applied"] == [fid] and gate(out["graph"]) == [], out["rejected"] + assert config_of(out["graph"], "v") == extra + + +def test_a_judge_that_stops_naming_a_provider_goes_to_the_settings_not_a_disabled_provider(): + graph = flow(node("write", "report", "写周报", instructions="写周报", provider="甲接入", claims="judge", + judge={"provider": "丙接入", "model": "丙一号", "max_claims": 5})) + fid = "provider.disabled:write:judge.provider" + assert hints(fix_of(graph, fid))[""] == "去掉这一处指定的接入和模型「丙一号」,执行时改用设置中的裁判模型" + out = apply(graph, {fid: ""}) + assert out["applied"] == [fid] and gate(out["graph"]) == [], out["rejected"] + assert config_of(out["graph"], "write")["judge"] == {"max_claims": 5} + + +# -------------------------------------------------------------------------- +# 换接入:跟着这一处的别处也要落得稳 +# -------------------------------------------------------------------------- + + +def test_a_switch_that_would_send_another_place_a_model_it_does_not_provide_is_not_offered(): + """协作节点换接入,没写接入的成员跟着换:成员自己写的乙家模型甲不提供,「改用甲」不给;「不指定接入」各处分别 + 落到哪里写清。""" + graph = flow(node("team", "supervisor", "协作", provider="丙接入", model="丙一号", goal="分析", + agents=[{"name": "分析师", "model": "乙一号", "system": "分析"}])) + got = hints(fix_of(graph, "provider.disabled:team:provider")) + assert set(got) == {"", "乙接入"} + assert got["乙接入"] == "不提供模型「丙一号」,将改用它的默认模型「乙一号」" + assert got[""] == ("去掉这一处指定的接入和模型「丙一号」,执行时「协作」(多 Agent 协作)改用第一个可用的模型接入「甲接入」;" + "「协作」(多 Agent 协作)的成员「分析师」改用提供模型「乙一号」的接入「乙接入」") + + # 全图默认同理:自己写了乙家模型的节点跟着换到甲,甲不提供 + graph = flow(node("a", "llm", "提问", prompt="总结"), node("b", "llm", "复述", model="乙一号", prompt="复述"), + defaults={"provider": "丙接入"}) + fid = "provider.disabled:graph:defaults.provider" + got = hints(fix_of(graph, fid)) + assert set(got) == {"", "乙接入"} and got["乙接入"] is None + assert got[""] == ("去掉这一处指定的接入,执行时「提问」(模型调用)改用第一个可用的模型接入「甲接入」;" + "「复述」(模型调用)改用提供模型「乙一号」的接入「乙接入」") + out = apply(graph, {fid: "乙接入"}) + assert out["ok"] is True and out["graph"]["defaults"] == {"provider": "乙接入"}, out["rejected"] + + +def test_switching_names_the_model_actually_used_when_a_higher_level_writes_one(): + """成员换到甲、自己的丙家模型去掉以后,模型跟随节点写的甲二号,不是甲的默认模型:说明照实写。""" + graph = flow(node("team", "supervisor", "协作", model="甲二号", goal="分析", + agents=[{"name": "分析师", "provider": "丙接入", "model": "丙一号", "system": "分析"}])) + got = hints(fix_of(graph, "provider.disabled:team:agents[0].provider")) + assert got["甲接入"] == "不提供模型「丙一号」,将改用模型「甲二号」" + + +def test_no_choice_lands_on_the_built_in_demo_model(): + """一个启用的接入都没有:去掉接入就会悄悄换成内置演示模型,这样的修复不给(问题照样拦着发布)。""" + graph = flow(node("ask", "llm", "提问", provider="丙接入", model="丙一号", prompt="总结")) + found = check(GraphSpec.model_validate(graph), level="published", providers=[BING]) + [issue] = provider_issues(found["issues"]) + assert issue["fix"] is None and found["ok"] is False + + +# -------------------------------------------------------------------------- +# 只写了模型的那一处:改用一个启用的接入,或不指定模型 +# -------------------------------------------------------------------------- + + +def test_a_place_naming_only_a_model_gets_a_choice_of_provider_or_no_model(): + graph = flow(node("ask", "llm", "提问", model="丙一号", prompt="总结")) + fid = "provider.disabled:ask:model" + fix = fix_of(graph, fid) + assert (fix["kind"], fix["field"], fix["label"]) == ("choice", "model", "选择「提问」(模型调用)改用的模型接入") + assert [o["label"] for o in fix["options"]] == ["不指定模型", "甲接入", "乙接入"] + got = hints(fix) + assert got[""] == "去掉这一处指定的模型,执行时改用第一个可用的模型接入「甲接入」" + assert got["乙接入"] == "不提供模型「丙一号」,将改用它的默认模型「乙一号」" + out = apply(graph, {fid: "乙接入"}) + assert out["ok"] is True and config_of(out["graph"], "ask") == {"prompt": "总结", "provider": "乙接入"}, out["rejected"] + assert changes(out) == [("ask", "provider", None, "乙接入"), ("ask", "model", "丙一号", None)] + out = apply(graph, {fid: ""}) + assert out["ok"] is True and config_of(out["graph"], "ask") == {"prompt": "总结"}, out["rejected"] + + +def test_a_graph_default_model_is_fixed_once_for_every_node_that_uses_it(): + graph = flow(node("a", "llm", "提问", prompt="总结"), node("b", "agent", "查数", prompt="查数"), + defaults={"model": "丙一号"}) + fid = "provider.disabled:graph:defaults.model" + out = apply(graph, {fid: "甲接入"}) + assert out["ok"] is True and out["graph"]["defaults"] == {"provider": "甲接入"}, out["rejected"] + assert changes(out) == [(None, "defaults.provider", None, "甲接入"), (None, "defaults.model", "丙一号", None)] + + +# -------------------------------------------------------------------------- +# 复核照样把关 +# -------------------------------------------------------------------------- + + +def test_the_review_refuses_a_fix_that_leaves_a_place_on_a_disabled_provider(monkeypatch): + """修复规则自己只去掉接入(上一轮的做法)时,门禁查出那一处按模型名落到停用的丙,整条丢弃、说清原因。""" + def only_the_provider(graph, fix, value): + config = autofix._node(graph, fix["node_id"])["data"]["config"] + return [(fix["node_id"], "provider", config.pop("provider"), None)] + + monkeypatch.setitem(autofix._EDITORS, "provider.disabled", only_the_provider) + graph = flow(node("ask", "llm", "提问", provider="丙接入", model="丙一号", prompt="总结")) + out = apply(graph, {"provider.disabled:ask:provider": ""}) + assert out["applied"] == [] and out["changes"] == [] and out["graph"] == graph + [rejected] = out["rejected"] + assert rejected["reason"] == (f"修改后出现了新的问题:「提问」(模型调用)指定的模型「丙一号」{STOPPED}。" + "请在「设置 → 模型接入」中启用它,或在节点中换一个模型") diff --git a/backend/tests/test_provider_ref_runtime.py b/backend/tests/test_provider_ref_runtime.py index f44f2a30..ccb51ee9 100644 --- a/backend/tests/test_provider_ref_runtime.py +++ b/backend/tests/test_provider_ref_runtime.py @@ -565,3 +565,44 @@ async def test_a_scheduled_formal_run_naming_a_broken_provider_fails_and_is_stil async with SessionLocal() as session: await session.execute(delete(Schedule).where(Schedule.id == sid)) await session.commit() + + +# -------------------------------------------------------------------------- +# 发布前检查里修好的图,正式运行不再失败在那一处 +# -------------------------------------------------------------------------- + + +async def test_after_the_publish_fix_stops_naming_a_disabled_provider_the_formal_run_succeeds(monkeypatch, client): + """复核发现的原样:节点点名停用的接入、模型写它家的。以前发布前检查里选「不指定接入」只去掉接入,检查、保存、发布都 + 通过,正式运行按模型名找到的还是那个停用的接入,照样失败。现在模型一并去掉,候选说明写清执行时改用哪个接入,正式 + 运行就用它。""" + called = script(monkeypatch) + spec = chain(node("start", "input"), + node("ask", "llm", "写日报", provider=DISABLED, model="停用演示模型", prompt="写今天的门店日报"), + node("out", "output", fields=[{"name": "日报", "value": "{{ last_message }}"}])) + async with SessionLocal() as session: + wf = Workflow(name=f"门店日报-{uuid.uuid4().hex[:6]}", graph=spec, version=1) + session.add(wf) + await session.flush() + session.add(WorkflowVersion(workflow_id=wf.id, version=1, graph=spec)) + await session.commit() + wid = wf.id + r = await client.post(f"/api/workflows/{wid}/publish-check", json={"level": "published"}) + [issue] = [i for i in r.json()["issues"] if i.get("code") == "provider.disabled"] + [fix] = [f for f in r.json()["fixes"] if f["id"] == issue["fix"]] + [clear] = [o for o in fix["options"] if o["value"] == ""] + # 没点名接入、也没写模型时落到第一个可用的接入:本文件只建了一个启用的接入 + assert clear["hint"] == f"去掉这一处指定的接入和模型「停用演示模型」,执行时改用第一个可用的模型接入「{WRITER_PROVIDER}」" + r = await client.post(f"/api/workflows/{wid}/autofix", json={"level": "published", "apply": [fix["id"]], + "choices": {fix["id"]: ""}}) + fixed = r.json() + assert fixed["applied"] == [fix["id"]] and fixed["ok"] is True, fixed["rejected"] + assert (await client.patch(f"/api/workflows/{wid}", json={"graph": fixed["graph"]})).status_code == 200 + r = await client.post(f"/api/workflows/{wid}/publish", json={"level": "published"}) + assert r.json()["ok"] is True, r.text + r = await client.post("/api/runs", json={"workflow_id": wid, "run_class": "formal"}) + assert r.status_code == 201, r.text + run = await wait(r.json()["id"]) + assert run.status == "succeeded" and run.run_class == "formal", run.error + assert called == [WRITER_MODEL] + no_mock(await events(run.id), run.output) diff --git a/frontend/src/canvas/ProblemsPanel.tsx b/frontend/src/canvas/ProblemsPanel.tsx index 3d965f6b..28a24e15 100644 --- a/frontend/src/canvas/ProblemsPanel.tsx +++ b/frontend/src/canvas/ProblemsPanel.tsx @@ -617,9 +617,13 @@ function UpgradeValue({ value, field, nameOf, className, ...data }: { const SUB_LABEL: Record = { condition: '的条件', key: '的标识', tools: '的工具', system: '的系统提示', provider: PROVIDER_REF_TEXT.memberField, + model: PROVIDER_REF_TEXT.memberModel, } -/** 节点定义里没有、问题却会落上去的字段:点名的模型接入(多数节点的检查器里没有这一栏)。不写的话露出键名 */ -const FIELD_FALLBACK: Record = { provider: PROVIDER_REF_TEXT.field } +/** + * 节点定义里没有、问题却会落上去的字段:点名的模型接入(多数节点的检查器里没有这一栏),以及只写了模型、提供它的接入 + * 已停用时的模型(知识检索、结构校验的检查器里没有模型这一栏)。不写的话露出键名 + */ +const FIELD_FALLBACK: Record = { provider: PROVIDER_REF_TEXT.field, model: PROVIDER_REF_TEXT.model } function fieldLabel(field: FieldRef | null | undefined, type?: string, config?: Record): string { if (!field) return '' diff --git a/frontend/src/canvas/issues.ts b/frontend/src/canvas/issues.ts index f8ed2b64..62034366 100644 --- a/frontend/src/canvas/issues.ts +++ b/frontend/src/canvas/issues.ts @@ -247,8 +247,8 @@ function normalizeIssue(raw: any): ValidationIssue | null { } /** - * 点名的模型接入不存在或已停用(后端 engine/provider_refs.py)。一处一条问题、一条修复:同一个协作节点上自己的接入和 - * 成员的接入是两处,合并、找修复都要连字段一起认 + * 点名的模型接入不存在或已停用,或者只写了模型、提供它的接入已停用(后端 engine/provider_refs.py)。一处一条问题、 + * 一条修复:同一个协作节点上自己的接入和成员的接入是两处,合并、找修复都要连字段一起认 */ export const isProviderRefCode = (code?: string | null): boolean => code === 'provider.missing' || code === 'provider.disabled' @@ -397,8 +397,9 @@ const CONTRACT_KEYS: Record = { /** 不在节点定义的字段表里、由检查器自己画的几项(子工作流钉的版本、升版处置),以及图级的全图默认 */ const EXTRA_FIELDS: Record = { workflow_version: '固定版本', upgrade_policy: '上游发布新版本时', defaults: '工作流默认设置', - // 多数节点的检查器里没有接入这一栏(只有双盲复算、反方审稿有),修复点名接入时照样要写得出叫法 - provider: PROVIDER_REF_TEXT.field, + // 多数节点的检查器里没有接入这一栏(只有双盲复算、反方审稿有),修复点名接入时照样要写得出叫法;修复连带去掉的 + // 模型也一样(知识检索、结构校验的检查器里没有模型这一栏) + provider: PROVIDER_REF_TEXT.field, model: PROVIDER_REF_TEXT.model, } /** 点号后面那一段的叫法:契约里的键,全图默认里的审批策略,列表项里的名称、取值、表达式 */ const SUB_KEYS: Record = { diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 7cd003a1..68e2761b 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -928,6 +928,11 @@ export const PROVIDER_REF_TEXT = { field: '模型接入', /** 协作成员、修复预览里点号后面那一段:「成员「分析师」的模型接入」「工作流默认设置 · 模型接入」 */ memberField: '的模型接入', + /** + * 只写了模型、提供它的接入已停用时,问题落在写模型的那一处:成员的「成员「分析师」的模型」;节点定义里没有模型这一栏的 + * (让模型重排的知识检索、让模型修复的结构校验)照样写得出「模型」 + */ + memberModel: '的模型', model: '模型', judge: '结论句裁判', /** 设置页那一格下面:存着的接入不存在、已停用时先说状态,再说这一项因此会怎样(各组自己的后果) */ diff --git a/scripts/check-publish.mjs b/scripts/check-publish.mjs index 5c66e4eb..af00b6b5 100644 --- a/scripts/check-publish.mjs +++ b/scripts/check-publish.mjs @@ -186,6 +186,19 @@ const PROV_OK = { ], edges: chain('in', 'ask', 'done'), } +/** + * 只写了模型、提供它的接入已停用(K1):让模型重排的检索写的 tiny-1 只有停用的 demo-off 提供,协作成员同样只写了 + * tiny-1。检索的检查器里没有模型这一栏、成员也没有:问题面板和修复预览照样要写得出「模型」 + */ +const PROV_MODEL = { + nodes: [ + node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }), + node('look', 'retrieve', 240, '检索', { query: '{{ input.week }}', rerank: 'model', model: 'tiny-1' }), + node('team', 'supervisor', 480, '协作', { goal: '复核', agents: [{ name: '分析师', model: 'tiny-1', tools: [] }] }), + node('done', 'output', 720, '成果', { fields: [{ name: 'report', value: '{{ last_message }}' }] }), + ], + edges: chain('in', 'look', 'team', 'done'), +} const PROVIDERS = [ { id: 'pv-main', name: 'demo-main', kind: 'openai_compatible', base_url: 'https://example.com/v1', default_model: 'big-model', models: [{ id: 'big-model' }], enabled: true, extra: {}, api_key_masked: '', has_key: true }, @@ -212,6 +225,7 @@ const FAKES = { 'pf-sql': wf('pf-sql', '__publish_check_sql__', SQLWF), 'pf-prov': wf('pf-prov', '__publish_check_provider__', PROV), 'pf-provok': wf('pf-provok', '__publish_check_provider_ok__', PROV_OK), + 'pf-provmodel': wf('pf-provmodel', '__publish_check_provider_model__', PROV_MODEL), } const clone = (x) => JSON.parse(JSON.stringify(x)) @@ -221,18 +235,24 @@ const metricOptions = (m) => m.data.config.metrics.map((x) => ({ value: x.id, la /** * 伪造的点名接入检查,形状照后端 engine/provider_refs.py 与 autofix._plan_provider:一处一条问题(字段 provider、 - * agents[i].provider),门禁里是 error、带一条要人选的修复(id 是 code:节点:字段);画布校验里是 warning、没有修复 + * agents[i].provider),门禁里是 error、带一条要人选的修复(id 是 code:节点:字段);画布校验里是 warning、没有修复。 + * 候选照后端按执行时的取法算去掉、换掉以后落到哪里(只覆盖这两张图用到的情形,图里没有全图默认):节点去掉接入落到 + * 第一个可用的接入;成员去掉接入跟随节点自己的接入,那个接入停用了就不给这一项;落到的接入不提供同一处写着的模型时 + * 模型一并去掉(_drops 记哪几个候选要去掉,应用时用,前端收不到) */ function providerLint(graph, providers, level) { const issues = [] const fixes = [] const refs = [] const kind = { llm: '模型调用', supervisor: '多 Agent 协作' } + const serves = (p, m) => (p.models ?? []).some((x) => x.id === m) || p.default_model === m + const enabled = providers.filter((x) => x.enabled) for (const n of graph.nodes) { const c = n.data.config ?? {} - if (c.provider) refs.push({ n, field: 'provider', name: c.provider, who: `${who(n)}(${kind[n.type]})` }) + const at = `${who(n)}(${kind[n.type]})` + if (c.provider) refs.push({ n, field: 'provider', name: c.provider, who: at, holder: c }) ;(c.agents ?? []).forEach((a, i) => { - if (a.provider) refs.push({ n, field: `agents[${i}].provider`, name: a.provider, who: `${who(n)}(${kind[n.type]})的成员「${a.name}」` }) + if (a.provider) refs.push({ n, field: `agents[${i}].provider`, name: a.provider, who: `${at}的成员「${a.name}」`, holder: a, team: at }) }) } for (const r of refs) { @@ -244,11 +264,50 @@ function providerLint(graph, providers, level) { 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) { - fixes.push({ id, code, node_id: r.n.id, field: r.field, kind: 'choice', label: `选择${r.who}改用的模型接入`, - options: [{ value: '', label: '不指定接入', hint: '去掉这一处指定的接入,按上一级的设置或模型名找接入' }, - ...providers.filter((x) => x.enabled).map((x) => ({ value: x.name, label: x.name, hint: null }))] }) + if (!gate) continue + const model = r.holder.model + const options = [] + const drops = [] + const up = r.team && r.n.data.config.provider ? providers.find((x) => x.name === r.n.data.config.provider) : enabled[0] + if (up?.enabled) { + const drop = !!model && !serves(up, model) + if (drop) drops.push('') + options.push({ value: '', label: '不指定接入', hint: `去掉这一处指定的接入${drop ? `和模型「${model}」` : ''},执行时改用` + + (r.team && r.n.data.config.provider ? `${r.team}指定的接入「${up.name}」` : `第一个可用的模型接入「${up.name}」`) }) } + for (const x of enabled) { + const drop = !!model && !serves(x, model) + if (drop) drops.push(x.name) + options.push({ value: x.name, label: x.name, hint: drop ? `不提供模型「${model}」,将改用它的默认模型「${x.default_model}」` : null }) + } + fixes.push({ id, code, node_id: r.n.id, field: r.field, kind: 'choice', label: `选择${r.who}改用的模型接入`, options, _drops: drops }) + } + // 哪一级都没写接入、只写了模型:按模型名找,只有停用的接入提供它时同样是问题,落在写模型的那一处。修复是「不指定 + // 模型」(落到第一个可用的接入)或改用一个启用的接入(写在模型旁边,模型去掉) + const models = [] + for (const n of graph.nodes) { + const c = n.data.config ?? {} + const at = `${who(n)}(${n.type === 'retrieve' ? '知识检索' : kind[n.type]})` + if (!c.provider && c.model && (n.type !== 'retrieve' || c.rerank === 'model')) { + models.push({ n, field: 'model', name: c.model, who: at, holder: c, editable: n.type !== 'retrieve' }) + } + ;(c.agents ?? []).forEach((a, i) => { + if (!c.provider && !a.provider && a.model) models.push({ n, field: `agents[${i}].model`, name: a.model, who: `${at}的成员「${a.name}」`, holder: a }) + }) + } + for (const r of models) { + const off = enabled.some((x) => serves(x, r.name)) ? null : providers.find((x) => !x.enabled && serves(x, r.name)) + if (!off) continue + const id = `provider.disabled:${r.n.id}:${r.field}` + const gate = level === 'error' + issues.push({ level, node_id: r.n.id, field: r.field, code: 'provider.disabled', fix: gate ? id : null, + message: `${r.who}指定的模型「${r.name}」所属的模型接入「${off.name}」已停用。请在「设置 → 模型接入」中启用它,` + + (r.editable ? '或在节点中换一个模型' : '或在「发布前检查」中改用其他接入') }) + if (!gate || !enabled.length) continue + fixes.push({ id, code: 'provider.disabled', node_id: r.n.id, field: r.field, kind: 'choice', label: `选择${r.who}改用的模型接入`, + options: [{ value: '', label: '不指定模型', hint: `去掉这一处指定的模型,执行时改用第一个可用的模型接入「${enabled[0].name}」` }, + ...enabled.map((x) => ({ value: x.name, label: x.name, hint: `不提供模型「${r.name}」,将改用它的默认模型「${x.default_model}」` }))], + _drops: ['', ...enabled.map((x) => x.name)] }) } return { issues, fixes } } @@ -411,14 +470,23 @@ function autofix(body, { unpublished = false, followsNever = false, providers = const to = `{{ nodes.${v}.text }}` change('fields[0].value', c.fields[0].value, to); c.fields[0].value = to } else if (fix.code.startsWith('provider.')) { - // 点名的接入:改用选的那个,选「不指定接入」就去掉这一项 + // 点名的接入:改用选的那个,选「不指定接入」就去掉这一项;落到的接入不提供同一处写着的模型时模型一并去掉。 + // 只写了模型的那一处:改用的接入写在模型旁边、模型去掉,「不指定模型」只去掉模型 const v = body.choices?.[id] if (v === undefined) { rejected.push({ fix_id: id, reason: `这一处需要你选择:${fix.label}` }); continue } - const m = /^agents\[(\d+)\]\.provider$/.exec(fix.field) + if (!fix.options.some((o) => o.value === v)) { rejected.push({ fix_id: id, reason: `所选的「${v}」不在候选项中` }); continue } + const m = /^agents\[(\d+)\]\.(provider|model)$/.exec(fix.field) const holder = m ? c.agents[Number(m[1])] : c - change(fix.field, holder.provider ?? null, v || null) - if (v) holder.provider = v - else delete holder.provider + const base = fix.field.replace(/(provider|model)$/, '') + if (fix.field.endsWith('provider') || v) { + change(`${base}provider`, holder.provider ?? null, v || null) + if (v) holder.provider = v + else delete holder.provider + } + if (fix._drops.includes(v) && holder.model) { + change(`${base}model`, holder.model, null) + delete holder.model + } } else if (fix.code === 'contract.required_missing') { const v = body.choices?.[id] if (!Array.isArray(v) || !v.length) { rejected.push({ fix_id: id, reason: `这一处需要你选择:${fix.label}` }); continue } @@ -550,8 +618,8 @@ async function open({ id = 'pf-hard', width = 1440, height = 900, backend = 'new // validate:'ok' | 'fail'(校验接口 500,后端断开时和保存一起失败)。检查中途改它,下一次校验就按新的回 const state = { checks: [], autofixes: [], patches: [], publishes: [], upgrades: [], version: FAKES[id].version, graph: clone(FAKES[id].graph), save, backend, upgrade, validate: 'ok', validates: 0, deleted: [] } - // 点名接入的那两张图:接入表伪造,校验、门禁、修复都按它现算 - const providers = id === 'pf-prov' || id === 'pf-provok' ? clone(PROVIDERS) : null + // 点名接入、只写了模型的那几张图:接入表伪造,校验、门禁、修复都按它现算 + const providers = ['pf-prov', 'pf-provok', 'pf-provmodel'].includes(id) ? clone(PROVIDERS) : null state.providers = providers await page.route(/\/api\//, (route) => { @@ -1624,9 +1692,14 @@ await section('点名的模型接入:一处一行、各有一组要人选的 check('……结论按三处算:3 处问题将被门禁拦截', summary.includes('3 处问题将被门禁拦截'), summary) const member = dialog(page).locator('[data-fix-choice="provider.missing:team:agents[0].provider"]') const memberText = await member.innerText().catch(() => '') - check('每一处一组选项:不指定接入、启用的接入;停用的不在候选里', await dialog(page).locator('[data-fix-choice^="provider."]').count() === 3 - && await member.locator('input[type="radio"]').count() === 2 && memberText.includes('不指定接入') && memberText.includes('demo-main') - && !memberText.includes('demo-off'), memberText) + const askText = await dialog(page).locator('[data-fix-choice="provider.missing:ask:provider"]').innerText().catch(() => '') + check('每一处一组选项:启用的接入在、停用的不在候选里', await dialog(page).locator('[data-fix-choice^="provider."]').count() === 3 + && memberText.includes('demo-main') && !memberText.includes('demo-off') && askText.includes('demo-main') && !askText.includes('demo-off'), + `${askText} | ${memberText}`) + check('……「不指定接入」写清执行时改用哪里、写着的模型一并去掉', askText.includes('不指定接入') + && askText.includes('去掉这一处指定的接入和模型「old-model」,执行时改用第一个可用的模型接入「demo-main」'), askText) + check('……成员去掉接入会跟随节点上停用的 demo-off:不给这一项,只剩启用的接入', + await member.locator('input[type="radio"]').count() === 1 && !memberText.includes('不指定接入'), memberText) check('……标题说清是哪一处', memberText.includes('选择「协作」(多 Agent 协作)的成员「分析师」改用的模型接入'), memberText) check('……不替人选', await member.locator('input:checked').count() === 0) await member.locator('label', { hasText: 'demo-main' }).click() @@ -1662,6 +1735,42 @@ await section('点名的模型接入:问题面板写「模型接入」,不 check('没有运行时报错', errors.length === 0, errors.join(' | ')) }) +await section('只写了模型、提供它的接入已停用:问题面板写「模型」,修复给「不指定模型」,预览不露键名', async () => { + const { page, state, errors } = await open({ id: 'pf-provmodel' }) + await page.locator('.react-flow__pane').click({ position: { x: 30, y: 30 } }) + await page.keyboard.press('Alt+KeyP') + await page.locator('#dock-problems').waitFor() + await waitFor(page, async () => (await page.locator('#dock-problems [data-problem-field]').count()) >= 2) + const fields = await page.locator('#dock-problems [data-problem-field]').allInnerTexts() + check('字段写「模型」「成员「分析师」的模型」(检索、成员的检查器里都没有模型这一栏)', + fields.some((t) => t.trim() === '模型') && fields.some((t) => t.trim() === '成员「分析师」的模型'), fields.join(' | ')) + const panel = await page.locator('#dock-problems').innerText() + check('……说清是哪个模型、哪个接入停用了,没有一处露出键名 model', panel.includes('「检索」(知识检索)指定的模型「tiny-1」所属的模型接入「demo-off」已停用') + && !/\bmodel\b/.test(panel), panel.slice(0, 160)) + await shoot(page, 'problems-provider-model', page.locator('section[aria-label="问题"]')) + + await openDialog(page) + const look = dialog(page).locator('[data-fix-choice="provider.disabled:look:model"]') + await look.waitFor() + const lookText = await look.innerText().catch(() => '') + check('修复:「不指定模型」写清执行时改用哪里,启用的接入写清换成它的哪个模型', lookText.includes('不指定模型') + && lookText.includes('去掉这一处指定的模型,执行时改用第一个可用的模型接入「demo-main」') + && lookText.includes('不提供模型「tiny-1」,将改用它的默认模型「big-model」') && !lookText.includes('demo-off'), lookText) + // 「不指定模型」的说明里也写着 demo-main:按选项文字开头认 + await look.locator('label').filter({ hasText: /^demo-main/ }).click() + await look.locator('[data-fix-action="choice"]').click() + await dialog(page).locator('[data-fix-preview="ready"]').waitFor() + const lines = await dialog(page).locator('[data-fix-change]').allInnerTexts() + check('预览两行:写上接入、去掉模型,都写叫法', lines.length === 2 && /「检索」\s*·\s*模型接入:/.test(lines[0]) && lines[0].includes('demo-main') + && /「检索」\s*·\s*模型:\s*tiny-1/.test(lines[1]) && !lines.some((t) => /provider|model\b/.test(t)), lines.join(' | ')) + await dialog(page).locator('[data-fix-apply]').click() + await waitFor(page, async () => state.patches.length === 1) + const saved = cfgOf(state.patches[0]?.graph, 'look') + check('应用:检索改用 demo-main、去掉停用接入的模型', saved?.provider === 'demo-main' && !('model' in (saved ?? {})), JSON.stringify(saved ?? {})) + await shoot(page, 'publish-provider-model', dialog(page)) + check('没有运行时报错', errors.length === 0, errors.join(' | ')) +}) + await section('点名的模型接入:在设置里删了接入,回到画布重新校验', async () => { const { page, state, errors } = await open({ id: 'pf-provok' }) const providerIssues = () => page.evaluate(() => window.__studio.getState().issues