From 050bea4b72952811fe8b68e02e3b84a17da34498 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Tue, 29 Sep 2026 13:27:27 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=AF=E7=82=B9=E5=87=BB=E8=AF=81=E6=8D=AE?= =?UTF-8?q?=E7=AC=AC=E4=BA=94=E6=9C=9F=EF=BC=9A=E8=80=81=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E4=B8=80=E9=94=AE=E5=8D=87=E7=BA=A7=E4=B8=BA=E5=8F=AF?= =?UTF-8?q?=E8=BF=BD=E6=BA=AF=E7=BB=93=E6=9E=84=EF=BC=9B=E5=8F=97=E7=AE=A1?= =?UTF-8?q?=E9=97=AE=E6=95=B0=E6=8D=AE=E5=9B=BE=E7=9A=84=20cells=20?= =?UTF-8?q?=E4=B8=80=E9=94=AE=E9=80=89=EF=BC=9Bpytest=20=E5=B9=B6=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、一键升级(只给预览,不自动保存) - engine/upgrade.py:确定性改写,纯函数、重复执行结果不变 - R1 契约的叙述指向模型调用节点 → 换成报告撰写节点(system 和 prompt 合成写作要求,模型配置照搬),契约改成 report_from - R2 没有契约、但模型写的文字进了出口,而且上游有取数 → 同样换成报告撰写 - R3 input → 智能体 → 出口(问数据的典型图)→ 中间插入报告撰写节点,出口字段接报告的正文; 只改直接相连的出口,经过别的节点读它的出口原样保留并写明原因 - R4 配了 output_schema 的智能体 → 打开 cite_fields - R5 代码节点的产出被口径卡读 → 不改,只建议标成取数,等人确认 - 一个叙述里混了几个写作者的,不猜,整步不做并写明原因 - POST /api/copilot/upgrade-evidence:返回改动、说明和改完的检查结果;assist 时再交给 Copilot 把纯算术的代码节点 改写成口径卡表达式,不是纯算术的保留并给警告 - 升级和发布前修复走同一道「不许降低要求」的检查,只多两个由升级点名的例外:模型调用换成报告撰写、R3 改接的那条连线; 改完不能更糟 - 校验对符合 R1–R4 的旧结构给一条建议「可以升级为可追溯结构」,不算提示、不挡发布 - Copilot 自查:报告撰写节点上游没有任何能引用数字的来源时打回 二、界面 - 问题面板的快速修复「升级为可追溯结构(预览改动)」:逐项写「节点 · 字段:原值 → 新值」,节点类型的变化写成 「模型调用 → 报告撰写」,新插的节点和改接的连线另外写明;「应用」后走现有的保存,全图默认不丢 - 画布改过、存失败、画布被锁都有明确状态;保存失败后手动存成功会清掉「没存上」的提示, 画布和预览对不上时「重试保存」不再盲存;校验接口失败时升级块照样在 - 记录页里没有逐段证据的运行给横幅「这次的报告没有逐段证据 → 升级这张图」,跳到编排页并打开升级预览 - 修掉一处旧缺陷:应用修复时只换配置、不换节点类型,模型调用换成报告撰写后存下去的还是模型调用 三、受管门禁:cells 一键选 - 图里没有口径卡、契约又没写 cells: true 的受管图,以前能过门禁、正式运行却每次失败;现在门禁拦下, 发布弹窗给两个选项:写 cells: true,或交给 Copilot 改成口径卡指标(人亲手选的不算替人打开,Copilot 自己打开照样拦) - 「修完要更好」的复核不再把改完才冒出来、只能由人拍板的这一处算作改坏:Copilot 补上契约、把 cells 留给人问, 这一步会被采用,不再被判「冒出新的问题」作废 四、测试 - 开发依赖加上 pytest-xdist(锁文件只多了 pytest-xdist 和 execnet):全量后端测试 -n auto 约 16 秒,原来串行约 2 分钟 - 修掉并行下暴露的一处测试竞态:运行先落终态、再另一个事务封存,测试改成等封存完成再核对 --- backend/app/api/copilot.py | 323 ++++++++- backend/app/engine/autofix.py | 84 ++- backend/app/engine/governance.py | 8 + backend/app/engine/schema.py | 11 +- backend/app/engine/upgrade.py | 679 ++++++++++++++++++ backend/pyproject.toml | 2 +- backend/requirements.lock | 5 + backend/tests/test_contract_cells.py | 80 +++ backend/tests/test_copilot_prompt_evidence.py | 265 +++++++ backend/tests/test_guardrails.py | 5 +- backend/tests/test_upgrade_assist.py | 216 ++++++ backend/tests/test_upgrade_evidence.py | 590 +++++++++++++++ frontend/src/api/client.ts | 9 +- frontend/src/canvas/ProblemsPanel.tsx | 436 ++++++++++- frontend/src/canvas/issues.ts | 217 +++++- frontend/src/lib/terms.ts | 46 ++ frontend/src/pages/StudioPage.tsx | 21 +- frontend/src/pages/runs/EvidencePane.tsx | 77 +- frontend/src/store/studio.ts | 178 ++++- frontend/src/types.ts | 38 +- scripts/check-publish.mjs | 542 +++++++++++++- scripts/check-runs.mjs | 110 ++- 22 files changed, 3870 insertions(+), 72 deletions(-) create mode 100644 backend/app/engine/upgrade.py create mode 100644 backend/tests/test_copilot_prompt_evidence.py create mode 100644 backend/tests/test_upgrade_assist.py create mode 100644 backend/tests/test_upgrade_evidence.py diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index e0698fea..569778f2 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -3,9 +3,10 @@ from __future__ import annotations import asyncio import copy import json +import math import re import time -from typing import Any +from typing import Any, Literal from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field @@ -738,7 +739,7 @@ async def generate( return GenerateOut( graph=out, explanation=str(raw.get("explanation", "")), - issues=[i.model_dump() for i in report.issues] + issues=[i.model_dump() for i in report.issues if i.level != "info"] + _issue_dicts(scope_issues(out["nodes"], scope), "datasource_out_of_scope") + dropped_tool_warnings(changes, payload.instruction), layout=layout, @@ -1033,6 +1034,62 @@ def _changed(spec: GraphSpec, baseline: list[dict[str, Any]] | None) -> set[str] return {n.id for n in spec.nodes if n.id not in before or before[n.id] != n.config} +_NUMERIC_TYPES = ("number", "integer", "float") + + +def _numeric_input(field: Any) -> bool: + """入口的这个字段看得出是数:写了数值类型,或者默认值是数(画布上填的默认值是字符串,能读成数的也算)。""" + if not isinstance(field, dict) or not str(field.get("name") or "").strip(): + return False + if str(field.get("type") or "").strip().lower() in _NUMERIC_TYPES: + return True + default = field.get("default") + if isinstance(default, bool): + return False + if isinstance(default, (int, float)): + return math.isfinite(default) + if isinstance(default, str) and default.strip(): + try: + return math.isfinite(float(default.strip().replace(",", ""))) + except ValueError: + return False + return False + + +def _evidence_source(node: Any) -> bool: + """报告撰写能从这个节点拿到可引用的证据:口径卡、调用工具、配了工具的 agent、知识检索,以及声明了数值字段的 + 入口(运行输入进证据目录,报告用 [[i:字段]] 引用)。子工作流、协作团队里面是什么看不见,一律算有——这条会把图 + 打回去让模型改,宁可漏判不可误判。 + + 入口只声明了文字字段的(问题、主题)不算:报告能照引的只有问题本身,要写的数照样没有出处。画布上的入口字段 + 不写类型,没写默认值的字段看不出是不是数,也不算——提示里说明了怎么声明(写数值默认值)。""" + if node.type == NodeType.AGENT: + return bool(node.config.get("tools")) + if node.type == NodeType.INPUT: + fields = node.config.get("fields") + return isinstance(fields, list) and any(_numeric_input(f) for f in fields) + return node.type in (NodeType.METRICS, NodeType.TOOL, NodeType.RETRIEVE, NodeType.SUBGRAPH, NodeType.SUPERVISOR) + + +def _unsourced_reports(spec: GraphSpec, changed: set[str]) -> list[dict[str, Any]]: + """这一轮新加或改过的报告撰写节点,上游一个证据来源都没有:运行时只给一条警告,报告里的数全被判成没有出处。""" + from app.engine.schema import _ancestors + + nodes = spec.node_map() + out = [] + for node in spec.nodes: + if node.id not in changed or node.type != NodeType.REPORT: + continue + if any(_evidence_source(nodes[a]) for a in _ancestors(spec, node.id) if a in nodes): + continue + out.append({"level": "error", "node_id": node.id, "edge_id": None, "field": None, "code": "report_no_source", + "message": f"「{node.title}」(报告撰写)的上游没有能引用数字的证据来源:没有口径卡、查库的 Agent 或调用工具、" + "知识检索,入口也没有看得出是数的字段,报告里写的数都会被判成没有出处。在它前面接取数的节点" + "(要算比率、增幅再加口径卡),连线连到它;要写的数本来就是运行输入的话,在入口字段上写明数值默认值," + "报告用 [[i:字段名]] 引用"}) + return out + + def authored_issues(spec: GraphSpec, sources: list[Any], baseline: list[dict[str, Any]] | None = None, ) -> list[dict[str, Any]]: """助手搭图时要打回、运行时却不挡的写法,按 error 交回模型改: @@ -1040,9 +1097,12 @@ def authored_issues(spec: GraphSpec, sources: list[Any], baseline: list[dict[str - sql_unknown_column:调用工具节点的 SQL 里有数据源结构(schema_cache)里查不到的列——凭空 猜的列名跑起来只会「no such column」,还白跑前面的步骤 - parse_model_text:整形节点按 JSON 解析 agent / llm 写的文字(validate 里只是 warning:旧图照跑) + - report_no_source:报告撰写节点的上游没有任何证据来源(运行时只给一条警告,报告里的数全没有出处)。 + metrics_from 指向不是口径卡的节点,validate 本来就报 error,自查照样交回去 """ changed = _changed(spec, baseline) out: list[dict[str, Any]] = [] + out += _unsourced_reports(spec, changed) for issue in text_parse_issues(spec): if issue.node_id in changed: # 模型的文字放在模板引号里的那种,改法是 | json,不是换结构化输出 @@ -1591,7 +1651,8 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g spec, layout = _layout_keeping( GraphSpec.model_validate({"nodes": list(nodes.values()), "edges": edges}), pinned, ) - issues = [i.model_dump() for i in validate_graph(spec).issues] + # 建议(info,比如「可以升级为可追溯结构」)不进这一轮的问题清单:图刚按规则搭好,画布自己的校验会列出来 + issues = [i.model_dump() for i in validate_graph(spec).issues if i.level != "info"] issues += _issue_dicts(scope_issues(list(nodes.values()), scope), "datasource_out_of_scope") # 自查没改好的猜列名留在画布上:运行时不挡(结构缓存可能旧了),但要看得见 @@ -1633,8 +1694,12 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g _LEVEL_WORD = {"published": "已发布", "governed": "受管"} -_ASSIST_RULES = """\ +#: 发布前修复和一键升级共用的硬性约定。拆开写是为了升级的兜底换掉「只修下面列出的问题」那一条, +#: 其余的(不许降低要求、要人拿主意的不替人选、只许用哪几种操作)一字不差 +_RULES_HEAD = """\ 硬性约定(违反任何一条,这次修改整个作废): +""" +_NO_LOWERING = """\ - 不许降低要求:不删节点、不删连线,add_node 不许用图里已有的节点 id(那等于把旧节点删了重建),也不换节点类型; 不删掉或清空出具契约,不删 required 里的指标,不把 strict 改成 false,不往 allow_numbers 里加数,不替人打开 cells; 审批策略只能往严里改(每次调用都审批 > 仅危险工具需要审批 > 全部自动放行),不放宽、不删掉,更不改成 never; @@ -1645,14 +1710,24 @@ _ASSIST_RULES = """\ judge.on_unsupported 是 withhold 的不改回 degrade、不删掉;update_node 会把 judge 整个换掉,补预算时把原来的 judge(包括从全图默认继承来的)整份带上,只加 max_cost_usd;契约里的 claims 也一样; 不关 agent 的 cite_fields,不删它的 output_schema +""" +_ASK_HUMAN = """\ - 要人拿主意的,不替人选、不编:required 该包括哪些指标、几张口径卡该用哪张、钉哪个版本、协作团队拆成哪几个 固定步骤、沙箱代码节点是在取数还是在计算(不许自己把 evidence_role 设成 source)……每一处输出一行 {"op":"question","node_id":"节点 id","text":"要问的话,把候选列出来"},那一处不改 +""" +_ONLY_LISTED = """\ - 只修下面列出的问题,别的不要动 +""" +_OPS_ONLY = """\ - 可用操作只有 update_node(config 只写要改的字段,没写的原样保留)、add_node、add_edge 和 question; 不要输出 remove_node、remove_edge、reply +""" +_PLAN_DONE = """\ - 第一行 plan,最后一行 done,done 的 explanation 用一两句话说清改了什么、为什么""" +_ASSIST_RULES = _RULES_HEAD + _NO_LOWERING + _ASK_HUMAN + _ONLY_LISTED + _OPS_ONLY + _PLAN_DONE + def _publish_fix_request(graph: dict[str, Any], errors: list[dict[str, Any]], level: str) -> str: return ( @@ -1777,6 +1852,246 @@ def _proposed_graph(base: dict[str, Any], nodes: dict[str, dict[str, Any]], return out +# -------------------------------------------------------------------------- +# 一键升级为可追溯结构:先按确定的规则改写(engine/upgrade.py),assist 时再请 Copilot 把纯算术的沙箱代码 +# 改写成口径卡表达式。和发布前修复一样只给预览:不改库、不保存,人确认后前端走现有的保存 +# -------------------------------------------------------------------------- + + +class UpgradeIn(BaseModel): + graph: dict[str, Any] + #: 确定性改写之后,再请 Copilot 做语义层面的改写(纯算术的沙箱代码改成口径卡表达式),同样只是预览 + assist: bool = False + #: 复核门禁用的发布级别。受管级别下新写出来的报告撰写节点另写明门禁 G3 要的三项 + level: Literal["published", "governed"] = "published" + provider: str | None = None + model: str | None = None + + +_UPGRADE_TASK = """\ +逐个判断上面这些沙箱代码节点: +- 代码只是对上游已有的数做纯算术(加减乘除、round、min / max、比较、按条件取值),没有查库、调接口、读写文件, + 也不用随机数、当前时间:把口径卡里读它的那几个指标的 expression 改成直接用上游的值算——上游是查库的调用工具 + 节点就写 cell(nodes.查库节点id, 行, '列名'),是开了 cite_fields 的 agent 就写 vars.<它的 assign_to>.字段。 + 改完口径卡不再读这个代码节点;代码节点本身留在图上(不删节点、不删连线,要不要删由人决定) +- 不是纯算术的(在取数、调接口、解析文件、用了随机数或时间……)保留原样,输出一行 question 说明为什么没改, + 并请人确认它是不是在取数 +""" + + +def _upgrade_assist_request(graph: dict[str, Any], spec: GraphSpec, feeders: list[Any]) -> str: + from app.engine.upgrade import metric_feeders + + lines = [] + for code, cards in feeders: + # 和 compute_feeders 同一条追法:指标读的是整形、校验节点的,追到头是这段代码也要列出来 + reads = [f"口径卡「{card.title}」({card.id})的指标 {card.config['metrics'][i].get('id')}:" + f"{card.config['metrics'][i].get('expression')}" + for card in cards for i, producer in metric_feeders(spec, card) if producer.id == code.id] + lines.append(f"- 「{code.title}」({code.id})被这些指标读到:" + ";".join(dict.fromkeys(reads))) + return ( + "这是一张已经按确定的规则升级成可追溯结构的工作流:\n" + f"{json.dumps(_slim(graph), ensure_ascii=False, indent=2)}\n\n" + "口径卡读的是下面这些沙箱代码节点的产出(evidence_role 不是 source):沙箱里算出来的数核对不了出处。\n" + + "\n".join(lines) + f"\n\n{_UPGRADE_TASK}\n" + + _RULES_HEAD + _NO_LOWERING + _ASK_HUMAN + + "- 只改上面这些口径卡指标的 expression,别的不要动\n" + _OPS_ONLY + _PLAN_DONE + ) + + +def _kept_code(spec: GraphSpec) -> list[str]: + """assist 之后仍然喂着口径卡的计算角色沙箱代码:不是纯算术、或者 Copilot 拿不准的,保留原样并警告。""" + from app.engine.upgrade import compute_feeders + + return [f"「{code.title}」(沙箱代码)没有改写成口径卡表达式,保留原样:口径卡" + + "、".join(f"「{c.title}」" for c in cards) + + "读到的数核对不了出处。它在取数的话,把 evidence_role 标成 source;在做计算的话,把计算写进口径卡" + for code, cards in compute_feeders(spec)] + + +async def assist_upgrade( + session: AsyncSession, graph: dict[str, Any], *, level: str, + provider: str | None = None, model: str | None = None, +) -> dict[str, Any]: + """升级的语义层:请 Copilot 把喂口径卡的纯算术沙箱代码改写成口径卡表达式,不是纯算术的保留并警告。 + + 模型、工具目录、操作流协议都和画布上改图是同一套;改完按搭图时同一套自查循环修正(只把这一轮冒出来的 + error 交回去,图上原来就有的不算),最后复核:没有降低要求的改动(autofix.forbidden_changes,这里没有例外: + Copilot 不换类型、不删连线)、没有比确定性改写的结果更糟(autofix.worse)才采纳,否则整个作废并写明原因。 + + 返回 {ok, accepted, reason, summary, questions, warnings, graph, ops}。不落库。 + """ + from app.engine.autofix import _sig, forbidden_changes, worse + from app.engine.upgrade import compute_feeders + + base = copy.deepcopy(graph) + spec = GraphSpec.model_validate(base) + out: dict[str, Any] = {"ok": True, "accepted": False, "reason": None, "summary": "", "questions": [], + "warnings": [], "graph": graph, "ops": []} + feeders = compute_feeders(spec) + if not feeders: + out["summary"] = "口径卡的输入都不来自做计算的沙箱代码,没有要交给 Copilot 改写的" + return out + sources = await _sources(session, None) + before_nodes = {str(n["id"]): n for n in copy.deepcopy(base.get("nodes") or []) if isinstance(n, dict)} + before_edges = [e for e in copy.deepcopy(base.get("edges") or []) if isinstance(e, dict)] + baseline = list(copy.deepcopy(before_nodes).values()) + defaults = base.get("defaults") + base_errors = _blocking_issues(before_nodes, before_edges, sources=sources, baseline=baseline, level=level, + defaults=defaults) or [] + known = {_sig(e) for e in base_errors} + out["warnings"] = _kept_code(spec) + try: + spec_ = await copilot_model_spec( + session, GenerateIn(instruction="升级为可追溯结构", provider=provider, model=model)) + chat, _ = await get_chat_model(session, spec_) + except ProviderNotConfigured as e: + out.update(ok=False, summary=_unconfigured(e)) + return out + + system = ( + "你是一个 agent 工作流编排专家。现在要做的是:把一张工作流里喂给口径卡的纯算术沙箱代码改写成口径卡表达式," + "以操作流的方式输出修改。\n\n" + f"{NODE_REFERENCE}\n\n可用工具:\n{_tool_catalog(sources)}{_datasource_section(sources)}\n\n" + f"{_STREAM_PROTOCOL}\n\n另外可以输出 question 操作:{{\"op\":\"question\",\"node_id\":\"…\",\"text\":\"…\"}}" + ) + nodes = copy.deepcopy(before_nodes) + edges = copy.deepcopy(before_edges) + tried: list[dict[str, Any]] = [] + effective: list[dict[str, Any]] = [] + plan = "" + + async def collect(request: str, *, first: bool) -> None: + nonlocal plan + async for op in _iter_ops(chat, [("system", system), ("human", request)]): + kind = op.get("op") + if kind in ("thinking", "heartbeat"): + continue + if kind in ("plan", "done"): + # 修补轮的开场和收尾不是新方案,不覆盖第一轮的说明 + if first and kind == "plan": + plan = str(op.get("summary") or "") + elif first: + out["summary"] = str(op.get("explanation") or "") + elif kind in ("question", "reply"): + text = str(op.get("text") or "").strip() + where = before_nodes.get(str(op.get("node_id") or "")) + title = str((where.get("data") or {}).get("label") or where.get("id")) if where else "" + if text: + out["questions"].append(f"「{title}」:{text}" if title and title not in text else text) + else: + tried.append(op) + if _apply_op(nodes, edges, op): + effective.append(op) + + try: + await collect(_upgrade_assist_request(base, spec, feeders), first=True) + # 自查:和搭图同一套规则,只把这一轮冒出来的 error 交回去改(原来就有的不归这一轮管) + for _ in range(_SELF_CHECK_ROUNDS): + now = _blocking_issues(nodes, edges, sources=sources, baseline=baseline, level=level, defaults=defaults) + fresh = [e for e in now or [] if _sig(e) not in known] + if not effective or not fresh: + break + await collect(_repair_request(nodes, edges, fresh), first=False) + except Exception as e: # noqa: BLE001 - 改不成就照实说,确定性改写的结果照样交给人 + out.update(ok=False, summary=f"Copilot 这一轮没跑完:{explain_error(e)[0]}") + return out + out["summary"] = out["summary"] or plan + if not effective: + out["summary"] = out["summary"] or ("Copilot 认为这些代码不是纯算术,都保留原样" if out["questions"] + else "Copilot 这一轮没有给出修改") + return out + + proposed = _proposed_graph(base, nodes, edges) + reasons = forbidden_changes(base, proposed, tried) + after = _blocking_issues(_nodes_by_id(proposed), proposed.get("edges") or [], sources=sources, + baseline=baseline, level=level, defaults=proposed.get("defaults")) + if reasons: + out["reason"] = "Copilot 的修改降低了要求,已作废:" + ";".join(reasons) + elif after is None: + out["reason"] = "Copilot 改完的图结构读不懂,已作废" + elif why := worse(base_errors, after): + out["reason"] = f"Copilot 的修改让图变糟了,已作废:{why}" + else: + out.update(accepted=True, graph=proposed, ops=effective, warnings=_kept_code(GraphSpec.model_validate(proposed))) + out["ok"] = out["reason"] is None + return out + + +def _placed(base: dict[str, Any], graph: dict[str, Any]) -> dict[str, Any]: + """升级新插进来的节点找空处摆,原有节点一个都不挪(坐标常带着业务上的分区)。""" + known = {n.get("id") for n in base.get("nodes") or [] if isinstance(n, dict)} + fresh = {n["id"] for n in graph.get("nodes") or [] if isinstance(n, dict) and n.get("id") not in known} + if not fresh: + return graph + out = copy.deepcopy(graph) + try: + laid, _ = _layout_keeping(GraphSpec.model_validate(out), _pinned_positions(base)) + except Exception: # noqa: BLE001 - 摆不好就留在规则给的位置上 + return out + where = {n.id: n.position.model_dump() for n in laid.nodes} + for n in out["nodes"]: + if n.get("id") in fresh: + n["position"] = where.get(n["id"], n.get("position")) + return out + + +def _node_changes(changes: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Copilot 那一段的逐项变更和确定性改写同一个形状:新加的节点记成 field 为 node、after 是整个节点。""" + out = [] + for c in changes: + if c.get("field") is None and isinstance(c.get("after"), dict): + c = {**c, "field": "node", "after": {"id": c["node_id"], "label": c["node_title"], **c["after"]}} + out.append({**c, "rule": "assist"}) + return out + + +@router.post("/upgrade-evidence") +async def upgrade_evidence(payload: UpgradeIn, session: AsyncSession = Depends(get_session)) -> dict[str, Any]: + """一键升级为可追溯结构(证据五期):返回升级后的整张图和逐项变更——只是预览,不改库、不保存。 + + 返回 {graph, changes, ops, notes, issues, applied, rejected, assist, ok}:changes / notes / rejected 的形状见 + engine/upgrade.upgrade_for_evidence;issues 是升级后的图按 level 重跑 validate 和门禁的结果(和发布同一套口径); + assist 只在请求带 assist: true 时有:{ok, summary, questions, warnings},warnings 是保留没改的沙箱代码。 + """ + from app.engine.autofix import diff_changes + from app.engine.governance import publish_issues + from app.engine.upgrade import upgrade_for_evidence + + try: + GraphSpec.model_validate(payload.graph) + except Exception as e: # noqa: BLE001 + return {"graph": payload.graph, "changes": [], "ops": [], "notes": [], "applied": [], "rejected": [], + "assist": None, "ok": False, + "issues": [{"level": "error", "node_id": None, "edge_id": None, "field": None, "code": None, + "fix": None, "message": f"工作流的结构读不懂:{graph_error(e)}"}]} + out = upgrade_for_evidence(payload.graph, level=payload.level) + out["graph"] = _placed(payload.graph, out["graph"]) + out["assist"] = None + if payload.assist: + helped = await assist_upgrade(session, out["graph"], level=payload.level, provider=payload.provider, + model=payload.model) + out["assist"] = {"ok": helped["ok"], "summary": helped["summary"], "questions": helped["questions"], + "warnings": helped["warnings"]} + if helped["accepted"]: + from app.engine.upgrade import compute_feeders + + out["changes"] += _node_changes( + diff_changes(out["graph"], helped["graph"], fix_id="assist", label="Copilot 的修改")) + out["ops"] += helped["ops"] + out["applied"].append("assist") + out["graph"] = helped["graph"] + # R5 的建议是改写之前给的:口径卡已经不读的那几个代码节点,不再建议人去标 source + still = {code.id for code, _ in compute_feeders(GraphSpec.model_validate(out["graph"]))} + out["notes"] = [n for n in out["notes"] if n["rule"] != "R5" or n["node_id"] in still] + elif helped["reason"]: + out["rejected"].append({"fix_id": "assist", "reason": helped["reason"]}) + issues = publish_issues(GraphSpec.model_validate(out["graph"]), level=payload.level) + out["issues"] = [i.model_dump() for i in issues] + out["ok"] = not any(i.level == "error" for i in issues) + return out + + class FromRunIn(BaseModel): run_id: str name: str = "" diff --git a/backend/app/engine/autofix.py b/backend/app/engine/autofix.py index 761dd695..c61d9471 100644 --- a/backend/app/engine/autofix.py +++ b/backend/app/engine/autofix.py @@ -48,7 +48,7 @@ _ORDER = ( "governed.no_contract", "contract.metrics_from_missing", "contract.metrics_from_invalid", "contract.report_from_missing", "contract.report_from_invalid", "governed.report_from_required", "governed.exit_text_source", "report.metrics_from_invalid", - "contract.required_missing", "contract.strict_off", "contract.claims_ignored", "report.claims_invalid", + "contract.required_missing", "contract.cells_undeclared", "contract.strict_off", "contract.claims_ignored", "report.claims_invalid", "governed.report_policy", "governed.judge_budget", "governed.caliber_agent_cite_fields", "governed.caliber_compute_input", "governed.agent_approval_never", "governed.default_approval_never", "governed.subgraph_unpinned", "governed.supervisor", "contract.not_object", @@ -282,6 +282,21 @@ def _plan_judge_budget(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) options, _judge=base) +def _plan_cells_undeclared(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: + """没有口径卡的受管图没写 cells:报告里的数要么直接引用查询单元格(写 cells: true),要么登记成口径卡 + 指标(交给 Copilot 加卡、改引用)。受管级别打开 cells 要作者显式决定,所以只给选项,不替人选。""" + if node is None: + return None + options = [ + {"value": True, "label": "写 cells: true(保留单元格引用)", + "hint": "报告里的数直接点得开查询结果里的那一格,每一格都对照快照核对"}, + {"value": HANDOFF, "label": "交给 Copilot:加一张口径卡,把数登记成指标", + "hint": "Copilot 改的同样只是预览,要人拿主意的会原样问你", "handoff": True}, + ] + return _choice(f"「{node.title}」的报告只能引用查询单元格:写 cells: true,还是改成口径卡指标?", + "contract.cells", options) + + def _plan_compute_input(ctx: _Ctx, issue: dict[str, Any], node: GraphNode | None) -> dict[str, Any] | None: """G4:沙箱代码到底是在取数还是在算,只有作者知道。取数的标成 source;在算的交给 Copilot 把计算 挪进口径卡——两样都不替人选,也不给建议。""" @@ -458,6 +473,7 @@ _PLANNERS: dict[str, Callable[[_Ctx, dict[str, Any], GraphNode | None], dict[str "governed.report_policy": _plan_report_policy, "report.claims_invalid": _plan_claims, "governed.judge_budget": _plan_judge_budget, + "contract.cells_undeclared": _plan_cells_undeclared, "governed.caliber_compute_input": _plan_compute_input, "governed.caliber_agent_cite_fields": _plan_cite_fields, "governed.caliber_agent_schema": _plan_agent_schema, @@ -752,22 +768,48 @@ def _sig(issue: dict[str, Any]) -> tuple[Any, ...]: return head if issue.get("code") else (*head, issue.get("message")) -def judge(before: list[Any], after: list[Any], *, target: tuple[str, str | None] | None = None) -> str | None: - """改完是不是更好了:不能冒出新的 error,error 不能变多;要修的那一处必须没了。 +#: 只有人能拍板、修复和 Copilot 都不许代做的 error(受管级别打开 cells)。改动让它冒出来不算更糟:它有自己的 +#: choice 修复,发布弹窗里一键选。拿它判作废的话,Copilot 能做的最好的一步(补上契约、把 cells 留给人问) +#: 反而会被拒掉——用户就撞过这个死局。原来就有的照常计数 +HUMAN_CHOICE = frozenset({"contract.cells_undeclared"}) - target 是 (问题编号, 节点 id):修的是一条警告(strict)时,error 不增加、那条警告消失就算更好; - 修的是 error,或者不指定 target(Copilot 兜底),error 必须变少。返回 None 表示更好,否则是原因。 + +def _errors(before: list[Any], after: list[Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """改动前后的 error;改完新冒出来的 HUMAN_CHOICE 不算进改完的那份。""" + b_err = [i for i in map(_as_dict, before) if i.get("level") == "error"] + seen = {_sig(i) for i in b_err} + a_err = [i for i in map(_as_dict, after) if i.get("level") == "error" + and not (i.get("code") in HUMAN_CHOICE and _sig(i) not in seen)] + return b_err, a_err + + +def worse(before: list[Any], after: list[Any]) -> str | None: + """改完是不是更糟了:冒出新的 error,或者 error 变多了。返回原因,没有就是 None。 + + judge 在这之上还要求「修的那一处没了、错误变少」;一键升级(engine/upgrade.py)只要求不更糟—— + 升级换的是结构,本来就不一定消掉哪条 error。 """ - b = [_as_dict(i) for i in before] - a = [_as_dict(i) for i in after] - b_err = [i for i in b if i.get("level") == "error"] - a_err = [i for i in a if i.get("level") == "error"] + b_err, a_err = _errors(before, after) seen = {_sig(i) for i in b_err} new = [i for i in a_err if _sig(i) not in seen] if new: return f"改完会冒出新的问题:{new[0].get('message')}" if len(a_err) > len(b_err): return f"改完错误反而变多了({len(b_err)} → {len(a_err)})" + return None + + +def judge(before: list[Any], after: list[Any], *, target: tuple[str, str | None] | None = None) -> str | None: + """改完是不是更好了:不能冒出新的 error,error 不能变多;要修的那一处必须没了。 + + target 是 (问题编号, 节点 id):修的是一条警告(strict)时,error 不增加、那条警告消失就算更好; + 修的是 error,或者不指定 target(Copilot 兜底),error 必须变少。返回 None 表示更好,否则是原因。 + """ + if why := worse(before, after): + return why + b = [_as_dict(i) for i in before] + a = [_as_dict(i) for i in after] + b_err, a_err = _errors(before, after) if target is not None: code, node_id = target @@ -849,9 +891,10 @@ def _numbers(value: Any) -> list[str]: return [str(v).strip() for v in _as_list(value) if str(v).strip()] -def _contract_loosened(name: str, prior: Any, contract: Any) -> list[str]: +def _contract_loosened(name: str, prior: Any, contract: Any, *, cells_chosen: bool = False) -> list[str]: """出具契约里替作者放宽口子的改动:allow_numbers 多出来的数(白名单里的数不用引用也能过), - cells 从没开变成开(受管级别要作者显式声明,不由模型替人打开)。原来没有契约也一样查。""" + cells 从没开变成开(受管级别要作者显式声明,不由模型替人打开;cells_chosen 是人在 choice 里亲手选的, + 不算)。原来没有契约也一样查。""" if not isinstance(contract, dict): return [] prior = prior if isinstance(prior, dict) else {} @@ -860,7 +903,7 @@ def _contract_loosened(name: str, prior: Any, contract: Any) -> list[str]: extra = [n for n in dict.fromkeys(_numbers(contract.get("allow_numbers"))) if n not in had] if extra: out.append(f"往「{name}」出具契约的 allow_numbers 里加了 {'、'.join(extra)}(不带出处也能放行的数)") - if contract.get("cells") is True and prior.get("cells") is not True: + if contract.get("cells") is True and prior.get("cells") is not True and not cells_chosen: out.append(f"替你打开了「{name}」出具契约的 cells(允许报告直接引用查询单元格),这要你自己决定") was, now = _claims_rank(prior.get("claims")), _claims_rank(contract.get("claims")) if was is not None and now is not None: @@ -895,13 +938,15 @@ def _claims_rank(value: Any) -> tuple[int, int] | None: def forbidden_changes(before: dict[str, Any], after: dict[str, Any], ops: list[dict[str, Any]] | None = None, *, - chosen: set[tuple[str, str]] | frozenset[tuple[str, str]] = frozenset()) -> list[str]: + chosen: set[tuple[str, str]] | frozenset[tuple[str, str]] = frozenset(), + retyped: set[tuple[str, str, str]] | frozenset[tuple[str, str, str]] = frozenset(), + rewired: set[tuple[str, str]] | frozenset[tuple[str, str]] = frozenset()) -> list[str]: """降低要求的改动,一条一句。空列表表示没有。确定性修复和 Copilot 兜底过的是同一道检查。 禁止: - 删节点、删连线;用 add_node 覆盖已有的节点、把节点换成别的类型(等于删了重建) - 删掉或清空出具契约、删掉 required 里的指标、把 strict 改成 false; - 往 allow_numbers 里加数、替人打开 cells + 往 allow_numbers 里加数、替人打开 cells(chosen 里有 (出口 id, "contract.cells") 的是人在 choice 里亲手选的,不算) - 审批策略往宽里改(每次调用都审批 > 仅危险工具需要审批 > 全部自动放行)、删掉写明的审批策略, 把审批写成(或新加一个)「全部自动放行」;全图默认同理 - 取消子工作流钉住的版本 @@ -911,6 +956,10 @@ def forbidden_changes(before: dict[str, Any], after: dict[str, Any], (节点 id, 字段) 是人在 choice 里亲手选的,不算 - 改发布级别——级别不在图里,修复接口也从不写工作流的 status,所以只可能以一个不认识的 操作出现,按不认识的操作拒掉 + + 一键升级(engine/upgrade.py)走同一道检查,只多两个由升级自己点名的例外:retyped 里的 + (节点 id, 原类型, 新类型) 允许换类型(模型调用换成报告撰写,这是升级的核心);rewired 里的 + (source, target) 允许删掉那条连线(中间插了报告撰写节点、改接的那一条)。发布前修复不传,照旧全拦。 """ old, new = _nodes_of(before), _nodes_of(after) reasons: list[str] = [] @@ -932,14 +981,15 @@ def forbidden_changes(before: dict[str, Any], after: dict[str, Any], kept = {_edge_key(e) for e in after.get("edges") or [] if isinstance(e, dict)} reasons += [f"删了连线 {s} → {t}" for s, t, _ in dict.fromkeys(_edge_key(e) for e in before.get("edges") or [] if isinstance(e, dict)) - if (s, t, _) not in kept] + if (s, t, _) not in kept and (s, t) not in rewired] old_default = _approval((before.get("defaults") or {}).get("approval")) new_default = _approval((after.get("defaults") or {}).get("approval")) for nid, node in new.items(): was = old.get(nid) cfg, prev = _cfg(node), _cfg(was) if was else {} name = _name(node) - if was is not None and was.get("type") != node.get("type"): + if was is not None and was.get("type") != node.get("type") \ + and (nid, str(was.get("type")), str(node.get("type"))) not in retyped: from app.engine.schema import type_label reasons.append(f"把「{_name(was)}」从{_type_word(was.get('type'), type_label)}换成了" @@ -952,7 +1002,7 @@ def forbidden_changes(before: dict[str, Any], after: dict[str, Any], fallback=new_default): reasons.append(why) contract, prior = cfg.get("contract"), prev.get("contract") - reasons += _contract_loosened(name, prior, contract) + reasons += _contract_loosened(name, prior, contract, cells_chosen=(nid, "contract.cells") in chosen) if node.get("type") == "code" and cfg.get("evidence_role") == "source" \ and prev.get("evidence_role") != "source" and (nid, "evidence_role") not in chosen: reasons.append((f"替你把「{name}」标成了取数(evidence_role: source)" if was diff --git a/backend/app/engine/governance.py b/backend/app/engine/governance.py index db7f0d00..fb172a22 100644 --- a/backend/app/engine/governance.py +++ b/backend/app/engine/governance.py @@ -189,6 +189,14 @@ def _lint_contract( "受管模板的出具契约必须声明 required(必需指标),否则「不予出具」这一档永远触发不了", code="contract.required_missing", node_id=node_id, hard=True, field="contract.required", ) + # 没有口径卡的图,报告里的数只能直接引用查询单元格;受管正式运行不认没声明的单元格引用, + # 报告撰写节点(numbers strict、on_violation fail)每次都会失败。写不写要作者定:修复给选项 + if strict and cells_only and contract.get("cells") is not True: + flag( + "这张图没有口径卡,报告里的数只能直接引用查询单元格:受管出具要在契约里写 cells: true," + "否则正式运行里一个数都引用不了,报告撰写节点每次都会失败(也可以加一张口径卡,把数登记成指标)", + code="contract.cells_undeclared", node_id=node_id, hard=True, field="contract.cells", + ) if strict and not contract.get("strict"): flag( "受管模板建议把出具契约设为 strict:非 strict 下未回指的数字只降档不拦截", diff --git a/backend/app/engine/schema.py b/backend/app/engine/schema.py index 4adc80b6..b6be8b01 100644 --- a/backend/app/engine/schema.py +++ b/backend/app/engine/schema.py @@ -148,7 +148,9 @@ class GraphSpec(BaseModel): class ValidationIssue(BaseModel): - level: Literal["error", "warning"] = "error" + #: info 是建议、不是问题(evidence.upgrade_available「可以升级为可追溯结构」):不挡运行、不挡发布, + #: 自查、门禁、修复复核都只数 error + level: Literal["error", "warning", "info"] = "error" node_id: str | None = None edge_id: str | None = None message: str @@ -683,6 +685,13 @@ def validate_graph(spec: GraphSpec) -> ValidationResult: for issue in [*text_parse_issues(spec), *evidence_issues(spec)]: result.add(issue.message, level=issue.level, node_id=issue.node_id, field=issue.field, code=issue.code) + # 旧结构(报告是模型调用写的、问数据的答案是 agent 的原话…)能一键升级成可追溯的:只给一条建议, + # 画布上的快速修复据此打开升级预览。放在函数里导入:upgrade 反过来要用这里的 GraphSpec + from app.engine.upgrade import HINT_CODE, upgrade_hint + + if hint := upgrade_hint(spec): + result.add(hint, level="info", code=HINT_CODE) + # 孤儿节点 for node in spec.nodes: if node.type in (NodeType.INPUT, NodeType.OUTPUT): diff --git a/backend/app/engine/upgrade.py b/backend/app/engine/upgrade.py new file mode 100644 index 00000000..630d9b22 --- /dev/null +++ b/backend/app/engine/upgrade.py @@ -0,0 +1,679 @@ +"""一键升级为可追溯结构(证据五期)。 + +旧图里给人看的报告是模型调用(llm)写的自由文本,问数据的答案是 agent 的原话:数字是模型转写的, +出处只能在出口按数值回头猜。这里按确定的规则把它们改写成可追溯的结构,只给预览,人确认了才保存: + +- R1 出具契约的 narrative 指向一个模型调用节点:把它换成报告撰写(system 加 prompt 合成 instructions, + 模型配置照搬),契约改成 report_from +- R2 没有契约,模型调用写的文字进了出口,上游又有取数(查库、口径卡):同样换成报告撰写 +- R3 input → agent → output(问数据页的典型图):在 agent 和出口之间插一个报告撰写,出口改取它的正文 +- R4 配了 output_schema 的 agent:打开 cite_fields(不开的话 Schema 不生效) +- R5 沙箱代码的产出喂给口径卡:不改,只在 notes 里建议标成 source——它在取数还是在算只有作者知道 + +纯函数、幂等:同一张图升级两次,第二次什么都不改。每一步都在副本上做,过一遍和发布前修复同一套禁止 +规则(autofix.forbidden_changes,只多「模型调用换成报告撰写」「R3 改接的那条连线」两个例外),再重跑 +validate 和门禁:冒出新 error 的那一步丢弃,写明原因。和 autofix 一样直接改原始的图字典,坐标、连线 id、 +视口这些前端的东西原样留着。 + +受管出具的契约要不要写 cells(报告直接引用查询单元格)、required 列哪些指标,都是作者的决定:R3 不替人 +加契约,只在 notes 里把两条路说清楚。 +""" +from __future__ import annotations + +import copy +import re +from dataclasses import dataclass, field, replace +from typing import Any, Callable + +from app.engine.autofix import forbidden_changes, worse +from app.engine.governance import ( + REPORT_POLICY, + REPORT_POLICY_ALSO, + Ref, + _card_feeders, + _exit_refs, + _Graph, + _refs, + publish_issues, +) +from app.engine.schema import GraphNode, GraphSpec, NodeType, _ancestors, _feeds_caliber, innermost_loops + +#: 会改图的规则,按这个顺序应用:先换写字的节点,再插报告,最后开 cite_fields(R3 要先把出口从 agent 的 +#: 原话改接到报告上,R4 打开以后 vars. 就是核对过的对象、不再是文字了) +RULES = ("R1", "R2", "R3", "R4") +#: validate 给的那条建议(info)的编号和开头 +HINT_CODE = "evidence.upgrade_available" +HINT = "可以升级为可追溯结构" +LEVELS = ("published", "governed") + +#: 模型调用有、报告撰写用不上的配置。system、prompt 合进 instructions;其余的换成报告撰写后不再生效 +_LLM_ONLY = ("prompt", "system", "skills", "use_history", "output_schema") +#: 新的报告撰写节点照搬过去的模型配置(R3 从 agent 带过去的只有 provider、model:温度、上限是查数用的) +_MODEL_KEYS = ("provider", "model") +#: 报告的指令里照搬了这几类节点的文字,就要提醒:报告撰写自己会收集它们的证据 +_EVIDENCE_TYPES = (NodeType.METRICS, NodeType.TOOL, NodeType.AGENT, NodeType.RETRIEVE, NodeType.CODE, + NodeType.SUBGRAPH) +#: 写整段文字的节点:出具契约的 narrative 拼了其中几个的文字,就说不准该换哪一个 +_WRITER_TYPES = (NodeType.LLM, NodeType.AGENT) +_QUESTION = re.compile(r"(? bool: + """这个节点查库:报告撰写能把它查到的结果编成 Q1、Q2… 引用。只认数据源的查询工具,别的工具的 + 返回进不了目录。""" + if node.type == NodeType.TOOL: + return str(node.config.get("tool") or "").startswith("db_query__") + if node.type == NodeType.AGENT: + tools = node.config.get("tools") + return isinstance(tools, list) and any(str(t).startswith("db_query__") for t in tools) + return False + + +def _fetches(node: GraphNode) -> bool: + """上游的取数:查库、口径卡——报告撰写的目录里就有能当值引用的数。知识库检索不算:文档里的话只能当引文, + 知识库问答的答案换成报告撰写,里面转述的数全会被判成没有出处。""" + return node.type == NodeType.METRICS or _queries(node) + + +def _cited(node: GraphNode) -> bool: + return bool(node.config.get("output_schema")) and node.config.get("cite_fields") is True + + +def _text_ref(node: GraphNode, ref: Ref) -> bool: + """顺着这个引用拿到的是不是它写的整段文字:nodes.X、nodes.X.text、vars.、最后一条消息。 + 开了 cite_fields 的 agent,assign_to 拿到的是核对过的字段,那是数据不是原话。""" + root, _, path = ref + if root in ("last_message", "messages"): + return True + if root == "nodes": + return path in ((), ("text",)) + if root == "vars": + return not path and not (node.type == NodeType.AGENT and _cited(node)) + return False + + +def _writers(graph: _Graph, consumer: str, refs: list[Ref], types: tuple[NodeType, ...]) -> list[GraphNode]: + """这些引用读到了哪些节点写的整段文字,按画布上的节点顺序。""" + found: set[str] = set() + for ref in refs: + found |= {src.id for src in graph.sources(consumer, ref) if src.type in types and _text_ref(src, ref)} + return [n for n in graph.spec.nodes if n.id in found] + + +def _exit_text_refs(exit_node: GraphNode) -> list[Ref]: + return [ref for _, _, refs in _exit_refs(exit_node) for ref in refs] + + +def _contract(node: GraphNode) -> dict[str, Any]: + contract = node.config.get("contract") + return contract if isinstance(contract, dict) else {} + + +def _names(nodes: list[GraphNode]) -> str: + return "、".join(f"「{n.title}」" for n in nodes) + + +def _llm_blocked(spec: GraphSpec, node: GraphNode, loops: dict[str, str]) -> str | None: + """这个模型调用节点能不能原样换成报告撰写;不能的说清为什么。""" + who = f"「{node.title}」(模型调用)" + if node.config.get("output_schema"): + return f"{who}配了 output_schema,交的是结构化数据、不是给人看的文字,没有换成报告撰写" + if node.id in loops: + return f"{who}在循环体里,换成报告撰写就是每一轮写一份报告:循环怎么改要你来定,升级没有动它" + if _feeds_caliber(node, spec): + return (f"{who}写的数还喂给了口径卡:换成报告撰写,口径卡就读不到它了。先把取数改由 Agent(配 output_schema、" + "开 cite_fields)或「调用工具」节点来做,再升级") + return None + + +def _add(found: dict[tuple[str, str], Match], match: Match) -> None: + """同一个节点从几个出口认出来的合成一处:出口、跳过的原因并起来;任何一个出口说不准,整处都不改。 + 结果不能看出口在画布上谁排前面。""" + key = (match.rule, match.node_id) + was = found.get(key) + if was is None: + found[key] = match + return + found[key] = Match(was.rule, was.node_id, tuple(dict.fromkeys([*was.exits, *match.exits])), + was.summary or match.summary, was.blocked or match.blocked, + tuple(dict.fromkeys([*was.skipped, *match.skipped]))) + + +def upgrade_candidates(spec: GraphSpec) -> list[Match]: + """图上符合 R1–R4 的旧结构,按规则、再按节点在画布上的顺序。只看结构,不改图、不跑门禁: + validate 每改一下画布就调一次,要便宜;真改的时候 upgrade_for_evidence 再逐步复核。""" + graph = _Graph(spec) + nodes = spec.node_map() + loops = innermost_loops(spec) + order = {n.id: i for i, n in enumerate(spec.nodes)} + found: dict[tuple[str, str], Match] = {} + outputs = [n for n in spec.nodes if n.type == NodeType.OUTPUT] + + for exit_node in outputs: + contract = _contract(exit_node) + narrative = contract.get("narrative") + if contract.get("report_from") not in (None, "") or not isinstance(narrative, str) or not narrative.strip(): + continue + writers = _writers(graph, exit_node.id, _refs(narrative, template=True), _WRITER_TYPES) + llms = [w for w in writers if w.type == NodeType.LLM] + if len(writers) > 1: + # 叙述拼了几个节点写的文字:换哪一个都会让其余的文字悄悄离开契约的核对。拼进来的每个模型调用都不换, + # 哪怕它另有一个只读它的出口——不然结果要看两个出口在画布上谁排前面 + why = (f"「{exit_node.title}」的出具契约 narrative 拼了{_names(writers)}几个节点写的文字,该换哪一个说不准," + "没有升级:让一个节点写完整篇(其余的内容交给它当素材),再升级") + for llm in llms: + _add(found, Match("R1", llm.id, blocked=why)) + elif llms: + llm = llms[0] + _add(found, Match("R1", llm.id, (exit_node.id,), + f"「{llm.title}」(模型调用)写的叙述换成报告撰写,出具契约改为核对它的文档", + _llm_blocked(spec, llm, loops))) + + r1 = {m.node_id for m in found.values()} + for exit_node in outputs: + if _contract(exit_node): + continue # 有契约的归 R1(叙述模式)或者已经是引用模式 + for llm in _writers(graph, exit_node.id, _exit_text_refs(exit_node), (NodeType.LLM,)): + if llm.id in r1 or not any(_fetches(nodes[a]) for a in _ancestors(spec, llm.id) if a in nodes): + continue + _add(found, Match("R2", llm.id, (exit_node.id,), + f"「{llm.title}」(模型调用)写的文字直接进了出口,换成报告撰写", + _llm_blocked(spec, llm, loops))) + + for exit_node in outputs: + contract = _contract(exit_node) + if contract.get("report_from") not in (None, ""): + continue + refs = _exit_text_refs(exit_node) + narrated: list[GraphNode] = [] + if isinstance(contract.get("narrative"), str): + story = _refs(contract["narrative"], template=True) + refs += story + narrated = _writers(graph, exit_node.id, story, _WRITER_TYPES) + for agent in _writers(graph, exit_node.id, refs, (NodeType.AGENT,)): + # 喂口径卡的 agent 是取数的,不是作答的:它的原话漏进出口由门禁 G2 管,不在这里插报告 + if not _queries(agent) or _feeds_caliber(agent, spec): + continue + # 说不准的只跳过这个出口:同一个 agent 直连的出口照样插报告撰写,结果不看出口在画布上的顺序 + if agent.id in loops: + _add(found, Match("R3", agent.id, blocked=( + f"「{agent.title}」在循环体里,升级不替你改循环结构,没有插入报告撰写"))) + elif not any(e.source == agent.id and e.target == exit_node.id for e in spec.edges): + _add(found, Match("R3", agent.id, skipped=( + f"「{agent.title}」的原话要经过别的节点才到「{exit_node.title}」,升级不替你改接中间的连线:在它后面" + f"接一个报告撰写,「{exit_node.title}」改取报告的正文",))) + elif agent.id in {n.id for n in narrated} and len(narrated) > 1: + _add(found, Match("R3", agent.id, skipped=( + f"「{exit_node.title}」的出具契约 narrative 拼了{_names(narrated)}几个节点写的文字,改成核对报告撰写的" + "文档,其余的文字就悄悄离开了核对:这个出口升级没有动。让一个节点写完整篇,再升级",))) + else: + _add(found, Match("R3", agent.id, (exit_node.id,))) + + for key, m in found.items(): + if m.rule != "R3": + continue + agent = nodes[m.node_id] + if m.exits: + found[key] = replace(m, summary=f"在「{agent.title}」和{_names([nodes[x] for x in m.exits])}之间插入报告撰写," + "答案里的数逐个点得开出处") + elif not m.blocked: + found[key] = replace(m, blocked=";".join(m.skipped), skipped=()) + + for n in spec.nodes: + if n.type == NodeType.AGENT and n.config.get("output_schema") and n.config.get("cite_fields") is not True: + _add(found, Match("R4", n.id, (), f"打开「{n.title}」的 cite_fields,output_schema 才生效")) + + return sorted(found.values(), key=lambda m: (RULES.index(m.rule), order.get(m.node_id, len(order)))) + + +def upgrade_hint(spec: GraphSpec) -> str | None: + """validate 的那条建议的文案;没有能升级的旧结构返回 None。 + + 升级是整张图一起改的,建议挂在图上、不挂在哪个节点上:挂上去就混进了那个节点自己的问题清单。 + 要改哪几个节点,预览里逐项列着。""" + ready = [m for m in upgrade_candidates(spec) if not m.blocked] + if not ready: + return None + parts = [m.summary for m in ready[:3]] + ([f"另有 {len(ready) - 3} 处"] if len(ready) > 3 else []) + return f"{HINT}:{';'.join(parts)}。先预览改动,确认了才保存" + + +# -------------------------------------------------------------------------- +# 改图(原地改副本) +# -------------------------------------------------------------------------- + + +def _raw(graph: dict[str, Any], node_id: str) -> dict[str, Any]: + return next(n for n in graph.get("nodes") or [] if isinstance(n, dict) and n.get("id") == node_id) + + +def _config(node: dict[str, Any]) -> dict[str, Any]: + data = node.setdefault("data", {}) + if not isinstance(data.get("config"), dict): + data["config"] = {} + return data["config"] + + +def _note(text: str, rule: str, node_id: str | None, level: str = "info") -> dict[str, Any]: + return {"text": text, "rule": rule, "node_id": node_id, "level": level} + + +def _change(node_id: str | None, title: str, field_name: str, before: Any, after: Any) -> dict[str, Any]: + return {"node_id": node_id, "node_title": title, "field": field_name, "before": copy.deepcopy(before), + "after": copy.deepcopy(after)} + + +def _config_changes(node_id: str, title: str, before: dict[str, Any], after: dict[str, Any]) -> list[dict[str, Any]]: + return [_change(node_id, title, key, before.get(key), after.get(key)) + for key in dict.fromkeys([*after, *before]) if before.get(key) != after.get(key)] + + +def _policy(level: str, config: dict[str, Any], defaults: dict[str, Any]) -> dict[str, Any]: + """受管级别下新写出来的报告撰写节点,照门禁 G3 写明最严的三项:已经(按节点、再按全图默认)是这个 + 值、或者更严的(claims: judge)不动。已发布级别不替探索运行拧紧,照缺省走。""" + if level != "governed": + return {} + out = {} + for key, want, _ in REPORT_POLICY: + value = config.get(key) + effective = defaults.get(key) if value in (None, "") else value + if effective not in (want, *REPORT_POLICY_ALSO.get(key, ())): + out[key] = want + return out + + +def _report_from(contract: dict[str, Any], node_id: str) -> dict[str, Any]: + """narrative 换成 report_from,放在 narrative 原来的位置,其余的键原样留着。原来写着的空 report_from(等于没声明, + upgrade_candidates 也这么认)丢掉:它排在 narrative 后面的话,逐键照抄会把新值盖成空的,契约的核对就悄悄关了。""" + out: dict[str, Any] = {} + for key, value in contract.items(): + if key == "narrative": + out["report_from"] = node_id + elif key != "report_from": + out[key] = copy.deepcopy(value) + out.setdefault("report_from", node_id) + return out + + +def _span_refs(text: str) -> list[tuple[str, list[Ref]]]: + return [(m.group(0), _refs(m.group(1), template=False)) for m in _SPAN.finditer(text)] + + +def _echoed_evidence(graph: _Graph, consumer: str, text: str) -> list[str]: + """指令里照搬了哪几段上游取数、口径卡的文字(原样的 {{ … }})。""" + out = [] + for span, refs in _span_refs(text): + if any(src.type in _EVIDENCE_TYPES for ref in refs for src in graph.sources(consumer, ref)): + out.append(span.strip()) + return list(dict.fromkeys(out)) + + +def _retype(graph: dict[str, Any], spec: GraphSpec, match: Match, level: str) -> _Step: + """R1 / R2:模型调用换成报告撰写。R1 另把契约的 narrative 换成 report_from。""" + node = spec.node_map()[match.node_id] + raw = _raw(graph, node.id) + before = copy.deepcopy(_config(raw)) + text = "\n\n".join(p for p in (str(before.get("system") or "").strip(), str(before.get("prompt") or "").strip()) if p) + after: dict[str, Any] = {"instructions": text} if text else {} + after.update({k: copy.deepcopy(v) for k, v in before.items() if k not in _LLM_ONLY}) + after.update(_policy(level, after, spec.defaults)) + raw["type"] = NodeType.REPORT.value + raw["data"]["config"] = after + title = node.title + step = _Step(label=(f"把「{title}」换成报告撰写,出具契约改为核对它的文档" if match.rule == "R1" + else f"把「{title}」换成报告撰写"), + retyped={(node.id, NodeType.LLM.value, NodeType.REPORT.value)}, created=True) + step.changes = [_change(node.id, title, "type", NodeType.LLM.value, NodeType.REPORT.value), + *_config_changes(node.id, title, before, after)] + step.ops = [{"op": "replace_node", "id": node.id, + "node": {"id": node.id, "type": NodeType.REPORT.value, "label": raw["data"].get("label") or "", + "config": copy.deepcopy(after)}}] + dropped = [k for k in _LLM_ONLY if k not in ("prompt", "system") and before.get(k) not in (None, "", [], {})] + if before.get("skills"): + skills = before["skills"] if isinstance(before["skills"], list) else [before["skills"]] + step.notes.append(_note(f"「{title}」挂的技能 {'、'.join(f'「{s}」' for s in skills)} 不会再加载:报告撰写不挂技能," + "其中的写作要求要保留的话,写进 instructions", match.rule, node.id, "warning")) + if other := [k for k in dropped if k != "skills"]: + step.notes.append(_note(f"「{title}」的 {'、'.join(other)} 报告撰写用不上,已去掉", match.rule, node.id)) + if echoed := _echoed_evidence(_Graph(spec), node.id, text): + step.notes.append(_note( + f"「{title}」的 instructions 里照搬了 {'、'.join(echoed)}:报告撰写会自己收集上游的口径卡指标和查询结果,数字由" + "系统渲染;直接塞进指令的这段文字里的数,模型容易照抄成不带出处的裸数字(会被打回重写)。确认用不上就删掉," + "升级没有替你删", match.rule, node.id)) + if match.rule == "R1": + for exit_id in match.exits: + exit_raw = _raw(graph, exit_id) + cfg = _config(exit_raw) + was = copy.deepcopy(cfg.get("contract")) + cfg["contract"] = _report_from(was or {}, node.id) + step.changes.append(_change(exit_id, spec.node_map()[exit_id].title, "contract", was, cfg["contract"])) + step.ops.append({"op": "update_node", "id": exit_id, "config": {"contract": copy.deepcopy(cfg["contract"])}}) + return step + + +def _fresh_id(graph: dict[str, Any], base: str) -> str: + taken = {str(n.get("id")) for n in graph.get("nodes") or [] if isinstance(n, dict)} + if base not in taken: + return base + return next(f"{base}_{i}" for i in range(2, len(taken) + 3) if f"{base}_{i}" not in taken) + + +def _answer_instructions(agent: GraphNode) -> str: + """插进来的报告撰写写什么。问数据的图里问题在 input.question;别的图照搬 agent 的要求。agent 的原话附在 + 后面当思路(和 Copilot 手册里问数据的写法一致),明说里面的数不能照抄。""" + prompt = str(agent.config.get("prompt") or "").strip() + if _QUESTION.search(prompt): + head = "回答:{{ input.question }}" + elif prompt: + head = f"按下面的要求写给读的人看的回答(查数已经由上游「{agent.title}」做完,这里只写):\n{prompt}" + else: + head = "根据证据目录里的查询结果,写一份简洁的回答" + return (f"{head}\n\n「{agent.title}」查数时的分析过程附在下面,只作思路参考:里面的数字不能照抄,一律用引用标记" + f"从证据目录里取。\n{{{{ nodes.{agent.id}.text }}}}") + + +def _agent_text_head(agent: GraphNode) -> re.Pattern[str]: + """模板里「这个 agent 写的整段文字」的写法:nodes.X、nodes.X.text、vars.(没开 cite_fields 时)。""" + nid = re.escape(agent.id) + heads = [rf"nodes\s*(?:\.\s*{nid}|\[\s*['\"]{nid}['\"]\s*\])(?:\s*(?:\.\s*text|\[\s*['\"]text['\"]\s*\]))?"] + var = str(agent.config.get("assign_to") or "").strip() + if var and not _cited(agent): + heads.append(rf"vars\s*(?:\.\s*{re.escape(var)}|\[\s*['\"]{re.escape(var)}['\"]\s*\])") + return re.compile("|".join(f"(?:{h})" for h in heads)) + + +def _swap_text(value: str, agent: GraphNode, report_id: str) -> str: + head_re = _agent_text_head(agent) + + def repl(m: re.Match[str]) -> str: + head, bar, rest = m.group(1).partition("|") + if not head_re.fullmatch(head.strip()): + return m.group(0) + return f"{{{{ nodes.{report_id}.text{(' |' + rest.rstrip()) if bar else ''} }}}}" + + return _SPAN.sub(repl, value) + + +def _insert(graph: dict[str, Any], spec: GraphSpec, match: Match, level: str) -> _Step: + """R3:agent 和出口之间插一个报告撰写。agent → 出口的连线改接成 agent → 报告 → 出口,出口字段里 agent 的 + 原话改取报告的正文;没配字段的出口交的是最后一条消息,插了报告以后就是报告写的,不用改。""" + nodes = spec.node_map() + agent = nodes[match.node_id] + raw_agent = _raw(graph, agent.id) + rid = _fresh_id(graph, "report") + config: dict[str, Any] = {"instructions": _answer_instructions(agent)} + config.update({k: copy.deepcopy(agent.config[k]) for k in _MODEL_KEYS if agent.config.get(k) not in (None, "")}) + config.update(_policy(level, config, spec.defaults)) + here = raw_agent.get("position") or {} + there = _raw(graph, match.exits[0]).get("position") or {} + position = {"x": round((float(here.get("x") or 0) + float(there.get("x") or 0)) / 2, 1), + "y": round((float(here.get("y") or 0) + float(there.get("y") or 0)) / 2 + 120, 1)} + new = {"id": rid, "type": NodeType.REPORT.value, "position": position, + "data": {"label": "报告撰写", "config": config}} + graph["nodes"].insert(graph["nodes"].index(raw_agent) + 1, new) + exits = [nodes[x] for x in match.exits] + step = _Step(label=f"在「{agent.title}」和{_names(exits)}之间插入报告撰写,出口改取报告的正文", + rewired={(agent.id, x.id) for x in exits}, created=True) + step.changes.append(_change(rid, "报告撰写", "node", None, + {"id": rid, "type": NodeType.REPORT.value, "label": "报告撰写", "config": config})) + step.ops.append({"op": "add_node", "node": {"id": rid, "type": NodeType.REPORT.value, "label": "报告撰写", + "config": copy.deepcopy(config)}}) + + def link(source: str, target: str) -> dict[str, Any]: + return {"id": f"{source}:->{target}", "source": source, "target": target} + + converted: list[dict[str, Any]] = [] # 这一步从叙述模式改成 report_from 的契约 + graph["edges"] = [e for e in graph.get("edges") or [] + if not (isinstance(e, dict) and e.get("source") == agent.id and e.get("target") in match.exits)] + graph["edges"].append(link(agent.id, rid)) + step.changes.append(_change(None, "", "edge", None, {"source": agent.id, "target": rid})) + step.ops.append({"op": "add_edge", "edge": {"source": agent.id, "target": rid}}) + for x in exits: + graph["edges"].append(link(rid, x.id)) + step.changes.append(_change(None, "", "edge", {"source": agent.id, "target": x.id}, + {"source": rid, "target": x.id})) + step.ops += [{"op": "remove_edge", "source": agent.id, "target": x.id}, + {"op": "add_edge", "edge": {"source": rid, "target": x.id}}] + cfg = _config(_raw(graph, x.id)) + updates: dict[str, Any] = {} + fields = cfg.get("fields") + if isinstance(fields, list): + swapped = [{**f, "value": _swap_text(f["value"], agent, rid)} + if isinstance(f, dict) and isinstance(f.get("value"), str) else f for f in fields] + if swapped != fields: + step.changes.append(_change(x.id, x.title, "fields", fields, swapped)) + updates["fields"] = swapped + cfg["fields"] = swapped + exact = f"{{{{ nodes.{rid}.text }}}}" + mixed = [str(f.get("name")) for f, old in zip(swapped, fields) + if isinstance(f, dict) and f != old and f.get("value", "").strip() != exact] + if mixed: + step.notes.append(_note( + f"「{x.title}」的成果字段{'、'.join(f'「{n}」' for n in mixed)}在报告正文前后还拼了别的内容:这样没法" + f"逐段对应证据。要每个数都点得开,字段里只放 {exact}", "R3", x.id)) + contract = cfg.get("contract") + narrative = contract.get("narrative") if isinstance(contract, dict) else None + if isinstance(narrative, str) and agent.id in { + n.id for n in _writers(_Graph(spec), x.id, _refs(narrative, template=True), (NodeType.AGENT,))}: + was = copy.deepcopy(contract) + cfg["contract"] = _report_from(contract, rid) + converted.append(cfg["contract"]) + step.changes.append(_change(x.id, x.title, "contract", was, cfg["contract"])) + updates["contract"] = cfg["contract"] + if updates: + step.ops.append({"op": "update_node", "id": x.id, "config": copy.deepcopy(updates)}) + if note := _cells_note(spec, exits, converted, level): + step.notes.append(_note(note[0], "R3", rid, note[1])) + return step + + +def _cells_note(spec: GraphSpec, exits: list[GraphNode], converted: list[dict[str, Any]], + level: str) -> tuple[str, str] | None: + """插进来的报告撰写会直接引用查询单元格,受管级别的正式运行只在出口契约写了 cells: true 时才认 + (governance.cells_allowed)。已经写了的不提;按受管级别升级的,或者把原有契约改成了 report_from、图上又没有 + 口径卡可走的,说成警告:不写的话报告里的数一个都引用不了,报告节点每次正式运行都会失败。""" + if converted and all(c.get("cells") is True for c in converted): + return None + where = _names(exits) + choose = (f"二选一:在{where}的出具契约里写 cells: true;或者改走口径卡,把要写的数登记成指标,报告只引用 " + "[[m:指标id]]。选哪条要你拿主意,升级没有替你写") + cards = any(n.type == NodeType.METRICS for n in spec.nodes) + if level == "governed" or (converted and not cards): + lead = "按受管级别" if level == "governed" else "现在(已发布级别)照常;以后按受管级别发布的话" + return (f"报告会直接引用查询单元格([[v:Q1.r0.列]]),{lead},正式运行只在出口的出具契约写了 cells: true 时才认单元格" + "引用:不写的话报告里的数一个都引用不了,报告节点(numbers: strict、on_violation: fail)每次正式运行都会失败。" + + choose, "warning") + return ("报告会直接引用查询单元格([[v:Q1.r0.列]])。探索运行和已发布级别照常;要按受管级别正式出具,得在出口的出具契约里写 " + "cells: true,或者改走口径卡:把要写的数登记成指标,报告只引用 [[m:指标id]]。选哪条要你拿主意,升级没有替你加契约", + "info") + + +def _whole_var_readers(spec: GraphSpec, var: str) -> list[tuple[GraphNode, str]]: + """模板里把 vars. 整个拿来用的地方:(节点, 原样的 {{ … }})。取字段的(vars.kpi.gmv)不算。""" + head_re = re.compile(rf"vars\s*(?:\.\s*{re.escape(var)}|\[\s*['\"]{re.escape(var)}['\"]\s*\])") + + def strings(value: Any) -> list[str]: + if isinstance(value, str): + return [value] + if isinstance(value, dict): + return [s for v in value.values() for s in strings(v)] + if isinstance(value, list): + return [s for v in value for s in strings(v)] + return [] + + out: dict[tuple[str, str], tuple[GraphNode, str]] = {} + for n in spec.nodes: + for text in strings(n.config): + for m in _SPAN.finditer(text): + if head_re.fullmatch(m.group(1).partition("|")[0].strip()): + out.setdefault((n.id, m.group(0).strip()), (n, m.group(0).strip())) + return list(out.values()) + + +def _cite(graph: dict[str, Any], spec: GraphSpec, match: Match, level: str) -> _Step: + """R4:打开 cite_fields。打开以后 assign_to 拿到的是按 Schema 核对过的对象,不再是文字:把它整个当文字 + 用的地方说出来,不替人改。""" + agent = spec.node_map()[match.node_id] + cfg = _config(_raw(graph, agent.id)) + before = cfg.get("cite_fields") + cfg["cite_fields"] = True + step = _Step(label=f"打开「{agent.title}」的 cite_fields:每个字段都核对到查询结果里的那一格") + step.changes = [_change(agent.id, agent.title, "cite_fields", before, True)] + step.ops = [{"op": "update_node", "id": agent.id, "config": {"cite_fields": True}}] + var = str(agent.config.get("assign_to") or "").strip() + for reader, span in _whole_var_readers(spec, var) if var else []: + step.notes.append(_note( + f"「{agent.title}」开了 cite_fields 以后,vars.{var} 拿到的是核对过的对象,不再是文字:「{reader.title}」里的 " + f"{span} 会显示成对象。要原话就改成 {{{{ nodes.{agent.id}.text }}}}", "R4", reader.id, "warning")) + return step + + +_APPLY: dict[str, Callable[[dict[str, Any], GraphSpec, Match, str], _Step]] = { + "R1": _retype, "R2": _retype, "R3": _insert, "R4": _cite} + + +def compute_feeders(spec: GraphSpec) -> list[tuple[GraphNode, list[GraphNode]]]: + """喂口径卡、角色又不是取数(evidence_role 不是 source)的沙箱代码:(代码节点, 读它的口径卡)。 + R5 只建议;assist 时交给 Copilot 看是不是纯算术。""" + graph = _Graph(spec) + found: dict[str, tuple[GraphNode, list[GraphNode]]] = {} + for card in spec.nodes: + if card.type != NodeType.METRICS: + continue + for feeder in _card_feeders(graph, card): + if feeder.type == NodeType.CODE and feeder.config.get("evidence_role") != "source": + found.setdefault(feeder.id, (feeder, []))[1].append(card) + return list(found.values()) + + +def metric_feeders(spec: GraphSpec, card: GraphNode) -> list[tuple[int, GraphNode]]: + """口径卡每个指标追到头读的是谁:(指标下标, 来源节点)。和 compute_feeders 同一条追法(穿过整形、校验节点, + governance._card_feeders),只是逐个指标追:code → 整形 → 口径卡的,指标记在这段代码名下。""" + graph = _Graph(spec) + metrics = card.config.get("metrics") + out: list[tuple[int, GraphNode]] = [] + for i, metric in enumerate(metrics if isinstance(metrics, list) else []): + one = card.model_copy(update={"data": card.data.model_copy(update={"config": {**card.config, + "metrics": [metric]}})}) + out += [(i, feeder) for feeder in _card_feeders(graph, one)] + return out + + +def _r5_notes(spec: GraphSpec) -> list[dict[str, Any]]: + return [_note( + f"「{code.title}」(沙箱代码)的产出喂给了口径卡{_names(cards)}:它如果是在取数(查库、调接口、读文件),把 " + "evidence_role 标成 source,口径卡读它时记得下出处;如果是在做计算,把计算挪进口径卡的表达式(交给 Copilot " + "可以请它把纯算术的代码改写成口径卡表达式)。它在取数还是在算只有你知道,升级没有替你改", "R5", code.id) + for code, cards in compute_feeders(spec)] + + +# -------------------------------------------------------------------------- +# 入口 +# -------------------------------------------------------------------------- + + +def forbidden(before: dict[str, Any], after: dict[str, Any], *, + retyped: set[tuple[str, str, str]] | frozenset[tuple[str, str, str]] = frozenset(), + rewired: set[tuple[str, str]] | frozenset[tuple[str, str]] = frozenset()) -> list[str]: + """升级的禁止规则:和发布前修复同一套(删节点、删连线、删契约、放宽审批、关 strict、改级别…一律拦), + 只放过这一步自己点名的换类型(模型调用 → 报告撰写)和改接的连线。""" + return forbidden_changes(before, after, retyped=frozenset(retyped), rewired=frozenset(rewired)) + + +def upgrade_for_evidence(graph: dict[str, Any] | GraphSpec, *, level: str = "published") -> dict[str, Any]: + """按 R1–R4 改写,R5 只给建议。纯函数,不动传进来的图;同一张图升级两次,第二次什么都不改。 + + level 是复核门禁用的发布级别:受管级别下新写出来的报告撰写节点另写明门禁 G3 要的三项。 + + 返回 {graph, changes, ops, notes, applied, rejected}: + - changes:[{fix_id, rule, label, node_id, node_title, field, before, after}];field 为 type 是节点换了类型, + node 是新插入的节点(after 是节点),edge 是连线(before / after 是 {source, target},改接的两边都有) + - ops:形状同 Copilot 的操作流,另有 replace_node {id, node}(换类型,node 是整个新节点) + - notes:[{text, rule, node_id, level}],不改、只建议的,以及看着像旧结构、但没法确定怎么改的 + - applied:采用的 "R1:<节点 id>"…;rejected:[{fix_id, reason}],碰了禁止规则或者会冒出新 error 的 + """ + if level not in LEVELS: + raise ValueError(f"level 只能是 {' / '.join(LEVELS)},写的是 {level!r}") + raw = graph.model_dump(mode="json") if isinstance(graph, GraphSpec) else graph + current = copy.deepcopy(raw) + spec = GraphSpec.model_validate(current) + issues = publish_issues(spec, level=level) + changes: list[dict[str, Any]] = [] + ops: list[dict[str, Any]] = [] + notes: list[dict[str, Any]] = [] + applied: list[str] = [] + rejected: list[dict[str, Any]] = [] + created: str | None = None # 第一处写出报告撰写节点的规则 + for rule in RULES: + tried: set[str] = set() + while True: + # 每一步之后重新认:前一步可能改了后一步要看的结构(插了报告的出口不再读 agent 的原话) + match = next((m for m in upgrade_candidates(spec) if m.rule == rule and m.node_id not in tried), None) + if match is None: + break + tried.add(match.node_id) + if match.blocked: + # 一个说不准的叙述会挡下拼进去的每个模型调用,原因只说一遍 + if not any(n["text"] == match.blocked for n in notes): + notes.append(_note(match.blocked, rule, match.node_id, "warning")) + continue + notes += [_note(why, rule, match.node_id, "warning") for why in match.skipped] + fix_id = f"{rule}:{match.node_id}" + trial = copy.deepcopy(current) + step = _APPLY[rule](trial, spec, match, level) + try: + trial_spec = GraphSpec.model_validate(trial) + except Exception: # noqa: BLE001 - 规则把图改坏了:丢弃,不交给人一张存不回去的图 + rejected.append({"fix_id": fix_id, "reason": "改完的图结构读不懂,这一步没有采用"}) + continue + trial_issues = publish_issues(trial_spec, level=level) + why = ";".join(forbidden(current, trial, retyped=step.retyped, rewired=step.rewired)) \ + or worse(issues, trial_issues) + if why: + rejected.append({"fix_id": fix_id, "reason": why}) + notes.append(_note(f"{step.label}:没有采用,{why}", rule, match.node_id, "warning")) + continue + current, spec, issues = trial, trial_spec, trial_issues + applied.append(fix_id) + changes += [{"fix_id": fix_id, "rule": rule, "label": step.label, **c} for c in step.changes] + ops += step.ops + notes += step.notes + created = created or (rule if step.created else None) + notes += _r5_notes(spec) + if created and level != "governed": + notes.append(_note( + "报告撰写重写之后仍有没出处的数字时:探索运行照常产出、把问题标在报告里;正式运行里 on_violation 缺省是 fail,节点" + "会失败。想先标注着看,把 on_violation 设成 flag", created, None)) + return {"graph": current, "changes": changes, "ops": ops, "notes": notes, "applied": applied, "rejected": rejected} diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 70c6fecc..6d078465 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -38,7 +38,7 @@ dependencies = [ # environment.yml 里已经带上了;单独装用 `pip install 'agentlab-backend[microvm]'`。 microvm = ["microsandbox>=0.6"] # 测试:conda 环境默认不装,跑测试前 pip install 'agentlab-backend[dev]' -dev = ["pytest>=8.0", "pytest-asyncio>=0.24"] +dev = ["pytest>=8.0", "pytest-asyncio>=0.24", "pytest-xdist>=3.6"] # 文档解析。PDF 和 Word 的解析库加起来几十兆,只用纯文本的人不该为此 # 付出安装时间——和 microvm、db 一样做成可选。HTML 用已在依赖里的 bs4。 docs = ["pypdf>=5.0", "python-docx>=1.1", "python-pptx>=1.0", "openpyxl>=3.1"] diff --git a/backend/requirements.lock b/backend/requirements.lock index 4e373b5a..04354cd9 100644 --- a/backend/requirements.lock +++ b/backend/requirements.lock @@ -56,6 +56,8 @@ docstring-parser==0.18.0 # via anthropic et-xmlfile==2.0.0 # via openpyxl +execnet==2.1.2 + # via pytest-xdist fastapi==0.141.1 # via agentlab-backend (pyproject.toml) greenlet==3.5.5 @@ -210,8 +212,11 @@ pytest==9.1.1 # via # agentlab-backend (pyproject.toml) # pytest-asyncio + # pytest-xdist pytest-asyncio==1.4.0 # via agentlab-backend (pyproject.toml) +pytest-xdist==3.8.0 + # via agentlab-backend (pyproject.toml) python-docx==1.2.0 # via agentlab-backend (pyproject.toml) python-dotenv==1.2.3 diff --git a/backend/tests/test_contract_cells.py b/backend/tests/test_contract_cells.py index cd70a762..3efc6d86 100644 --- a/backend/tests/test_contract_cells.py +++ b/backend/tests/test_contract_cells.py @@ -397,3 +397,83 @@ def test_a_graph_with_a_caliber_card_still_needs_required_even_without_metrics_f validate, gate = contract_codes(graph) assert "contract.required_missing" in gate, gate # 不写 metrics_from 绕不过必需指标 assert validate == [], validate + + +# -------------------------------------------------------------------------- +# 没有口径卡、又没写 cells: true:受管门禁要拦下,发布弹窗里一键选 +# +# 放开 metrics_from / required 之后留下的口子:这种契约以前被 metrics_from 挡着,现在能过门禁, +# 可受管正式运行里单元格引用一律不认,报告撰写节点(numbers strict、on_violation fail)每次都失败。 +# 写不写 cells 要作者定(模型不许替人打开),所以修复是 choice:写 cells: true,或交给 Copilot 改成口径卡 +# -------------------------------------------------------------------------- + + +def _plan(graph: dict, level: str = "governed") -> dict[str, dict]: + from app.engine.autofix import plan_fixes + from app.engine.governance import publish_issues + from app.engine.schema import GraphSpec + + spec = GraphSpec.model_validate(graph) + return {f["id"]: f for f in plan_fixes(spec, publish_issues(spec, level=level), level=level)} + + +def test_a_governed_graph_without_cards_must_declare_cells(): + graph = cells_only() + graph["nodes"][-1]["data"]["config"]["contract"].pop("cells") + _, gate = contract_codes(graph) + assert gate == ["contract.cells_undeclared"], gate + _, gate = contract_codes(cells_only(cells=False)) + assert gate == ["contract.cells_undeclared"], gate + _, gate = contract_codes(graph, level="published") # 已发布级别照样允许直接引用单元格 + assert "contract.cells_undeclared" not in gate + + +def test_a_graph_with_a_caliber_card_does_not_need_cells(): + _, gate = contract_codes(weekly()) # 有口径卡:数可以走 [[m:]],cells 是可选的 + assert "contract.cells_undeclared" not in gate, gate + + +def test_the_fix_is_a_choice_and_picking_cells_is_not_loosening(): + from app.engine.autofix import HANDOFF, apply_fixes + + graph = cells_only() + graph["nodes"][-1]["data"]["config"]["contract"].pop("cells") + fid = "contract.cells_undeclared:out" + fix = _plan(graph)[fid] + assert fix["kind"] == "choice" and fix["field"] == "contract.cells" + assert [o["value"] for o in fix["options"]] == [True, HANDOFF] + assert [o.get("handoff") is True for o in fix["options"]] == [False, True] + assert apply_fixes(graph, [fid], level="governed")["applied"] == [] # 不替人选 + out = apply_fixes(graph, [fid], {fid: True}, level="governed") + assert out["applied"] == [fid] and out["rejected"] == [], out["rejected"] + assert out["graph"]["nodes"][-1]["data"]["config"]["contract"]["cells"] is True and out["ok"] is True + out = apply_fixes(graph, [fid], {fid: HANDOFF}, level="governed") + assert out["handoff"] == [fid] and out["applied"] == [] + + +def test_turning_cells_on_unasked_is_still_loosening(): + from app.engine.autofix import forbidden_changes + + before = cells_only() + before["nodes"][-1]["data"]["config"]["contract"].pop("cells") + after = cells_only() + [why] = forbidden_changes(before, after) + assert "cells" in why + assert forbidden_changes(before, after, chosen={("out", "contract.cells")}) == [] + + +def test_only_a_newly_raised_human_choice_error_is_let_through(): + from app.engine.autofix import judge, worse + + no_contract = {"level": "error", "code": "governed.no_contract", "node_id": "out", "message": "没有契约"} + cells = {"level": "error", "code": "contract.cells_undeclared", "node_id": "out", "field": "contract.cells", + "message": "要写 cells"} + other = {"level": "error", "code": "contract.strict_off", "node_id": "out", "message": "strict 关了"} + assert judge([no_contract], [cells]) is None # 补上契约、cells 留给人:算更好 + assert worse([], [cells]) is None + assert "冒出新的问题" in (judge([no_contract], [other]) or "") # 别的新 error 照样作废 + assert judge([no_contract], [cells, other]) is not None + # 原来就有的 cells 照常计数:修它的那一步,改完还在就是没修好 + assert "还在" in (judge([cells], [cells], target=("contract.cells_undeclared", "out")) or "") + assert judge([cells], [], target=("contract.cells_undeclared", "out")) is None + assert judge([cells], [cells]) == "改完错误没有变少" # 原来就有、改完还在:Copilot 兜底不算修好 diff --git a/backend/tests/test_copilot_prompt_evidence.py b/backend/tests/test_copilot_prompt_evidence.py new file mode 100644 index 00000000..b4c3d9ae --- /dev/null +++ b/backend/tests/test_copilot_prompt_evidence.py @@ -0,0 +1,265 @@ +"""Copilot 搭图自查拦得下配错的报告撰写节点(证据五期)。 + +- metrics_from 指向的不是口径卡:validate 本来就报 error,自查交回去改 +- 报告撰写的上游没有任何证据来源(没有口径卡、查库的 agent / 调用工具、知识检索):运行时只会给一条警告、 + 报告里每个数都被判成没有出处——搭图时就打回去,按 error 交给模型改 +- 配对了的报告撰写节点不多花一次自查调用 +- 旧结构「可以升级为可追溯结构」的建议不混进这一轮的问题清单 +""" +from __future__ import annotations + +import json + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.api.copilot import authored_issues +from app.engine.schema import GraphSpec +from app.main import app + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +def add(nid, ntype, label, **config): + return {"op": "add_node", "node": {"id": nid, "type": ntype, "label": label, "config": config}} + + +def link(a, b): + return {"op": "add_edge", "edge": {"source": a, "target": b}} + + +class Scripted: + """第一轮吐 first;收到自查请求(human 里有「上线前自查」)时吐 fix。""" + + def __init__(self, first: list[dict], fix: list[dict] | None = None) -> None: + self.first = [json.dumps(o, ensure_ascii=False) for o in first] + self.fix = [json.dumps(o, ensure_ascii=False) for o in fix or []] + self.calls: list[str] = [] + + async def astream(self, messages): + from langchain_core.messages import AIMessageChunk + + human = next(text for role, text in messages if role == "human") + self.calls.append(human) + for line in (self.fix if "上线前自查" in human else self.first): + yield AIMessageChunk(content=line + "\n") + + +async def generate(client, monkeypatch, model: Scripted) -> list[dict]: + import app.api.copilot as copilot + + async def _model(*_a, **_k): + return model, "mock-fast" + + monkeypatch.setattr(copilot, "get_chat_model", _model) + events: list[dict] = [] + async with client.stream("POST", "/api/copilot/generate-stream", json={"instruction": "写一份订单周报"}) as r: + async for line in r.aiter_lines(): + if line.startswith("data: "): + events.append(json.loads(line[6:])) + return events + + +def wrap(*ops) -> list[dict]: + return [{"op": "plan", "summary": "周报"}, *ops, {"op": "done", "explanation": "周报", "run": False}] + + +FETCH = add("fetch", "tool", "查库", tool="db_query__shop", args={"sql": "SELECT COUNT(*) AS n FROM orders"}) +CARD = add("card", "metrics", "口径卡", metrics=[{"id": "n", "name": "订单数", "expression": "cell(nodes.fetch, 0, 'n')"}]) +OUT = add("out", "output", "成果", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]) + + +def issues_of(events, status): + return [i for e in events if e["op"] == "check" and e.get("status") == status for i in e["issues"]] + + +async def test_metrics_from_pointing_at_a_non_card_is_sent_back(client, monkeypatch): + first = wrap(add("start", "input", "输入"), FETCH, link("start", "fetch"), CARD, link("fetch", "card"), + add("write", "report", "报告撰写", instructions="写周报", metrics_from=["fetch"]), + link("card", "write"), OUT, link("write", "out")) + fix = [{"op": "update_node", "id": "write", "config": {"metrics_from": ["card"]}}, {"op": "done"}] + model = Scripted(first, fix) + events = await generate(client, monkeypatch, model) + sent = issues_of(events, "repairing") + assert any(i["code"] == "report.metrics_from_invalid" and i["node_id"] == "write" for i in sent), sent + assert "不是「口径卡」" in model.calls[1] + assert [e["status"] for e in events if e["op"] == "check"] == ["repairing", "passed"] + + +async def test_a_report_with_nothing_to_cite_upstream_is_sent_back(client, monkeypatch): + first = wrap(add("start", "input", "输入"), add("write", "report", "报告撰写", instructions="写周报"), + link("start", "write"), OUT, link("write", "out")) + fix = [FETCH, link("start", "fetch"), link("fetch", "write"), {"op": "done"}] + model = Scripted(first, fix) + events = await generate(client, monkeypatch, model) + [sent] = [i for i in issues_of(events, "repairing") if i["code"] == "report_no_source"] + assert sent["level"] == "error" and sent["node_id"] == "write" + assert "没有能引用数字的证据来源" in model.calls[1] + assert [e["status"] for e in events if e["op"] == "check"] == ["repairing", "passed"] + final = events[-1] + assert final["op"] == "final" and not [i for i in final["issues"] if i.get("code") == "report_no_source"] + + +async def test_a_well_wired_report_costs_no_extra_call(client, monkeypatch): + first = wrap(add("start", "input", "输入"), + add("ask", "agent", "查数", tools=["db_query__shop"], prompt="查本周订单数"), + link("start", "ask"), add("write", "report", "报告撰写", instructions="写周报"), + link("ask", "write"), OUT, link("write", "out")) + model = Scripted(first) + events = await generate(client, monkeypatch, model) + assert len(model.calls) == 1 and [e["status"] for e in events if e["op"] == "check"] == ["passed"] + + +async def test_the_upgrade_hint_is_not_part_of_the_copilot_issue_list(client, monkeypatch): + # 只问清单的问数据图就是 agent → output:validate 会建议升级,但这一轮的问题清单里不该有它 + first = wrap(add("start", "input", "输入"), + add("ask", "agent", "查数", tools=["db_query__shop"], prompt="列出订单表里的店铺"), + link("start", "ask"), + add("out", "output", "成果", fields=[{"name": "answer", "value": "{{ nodes.ask.text }}"}]), + link("ask", "out")) + events = await generate(client, monkeypatch, Scripted(first)) + final = events[-1] + assert final["op"] == "final" + assert not [i for i in final["issues"] if i.get("code") == "evidence.upgrade_available" or i["level"] == "info"] + + +def spec_of(nodes, edges): + return GraphSpec.model_validate({"nodes": nodes, "edges": edges}) + + +def n(nid, ntype, **config): + return {"id": nid, "type": ntype, "data": {"label": nid, "config": config}} + + +@pytest.mark.parametrize("source", [ + n("fetch", "tool", tool="db_query__shop"), + n("fetch", "agent", tools=["db_query__shop"]), + n("fetch", "retrieve", collection="docs"), + n("fetch", "metrics", metrics=[{"id": "a", "expression": "1"}]), + n("fetch", "subgraph", workflow_id="w1"), + n("fetch", "supervisor", goal="查清楚", agents=[{"name": "analyst", "tools": ["db_query__shop"]}]), + # 运行输入也是能引用的来源([[i:字段]]):声明了数值字段的入口算 + n("fetch", "input", fields=[{"name": "target", "default": 100}]), + n("fetch", "input", fields=[{"name": "week"}, {"name": "rate", "default": "0.35"}]), + n("fetch", "input", fields=[{"name": "budget", "type": "number"}]), +]) +def test_any_evidence_source_upstream_is_enough(source): + spec = spec_of([n("start", "input"), source, n("write", "report"), n("out", "output")], + [{"source": "start", "target": "fetch"}, {"source": "fetch", "target": "write"}, + {"source": "write", "target": "out"}]) + assert not [i for i in authored_issues(spec, []) if i["code"] == "report_no_source"] + + +@pytest.mark.parametrize("fields", [[], [{"name": "question", "required": True}], [{"name": "topic", "default": "本周"}]]) +def test_an_input_without_numeric_fields_is_not_a_source(fields): + # 只声明了文字字段的入口:报告只能照引问题本身,要写的数照样没有出处 + spec = spec_of([n("start", "input", fields=fields), n("write", "report"), n("out", "output")], + [{"source": "start", "target": "write"}, {"source": "write", "target": "out"}]) + [issue] = [i for i in authored_issues(spec, []) if i["code"] == "report_no_source"] + assert issue["node_id"] == "write" and issue["level"] == "error" + assert "运行输入" in issue["message"] and "[[i:" in issue["message"] + + +def test_a_numeric_input_upstream_is_enough_for_the_copilot_self_check(): + spec = spec_of([n("start", "input", fields=[{"name": "question"}, {"name": "gmv", "default": 1200.5}]), + n("write", "report"), n("out", "output")], + [{"source": "start", "target": "write"}, {"source": "write", "target": "out"}]) + assert not [i for i in authored_issues(spec, []) if i["code"] == "report_no_source"] + + +def test_a_tool_less_agent_or_a_downstream_source_does_not_count(): + spec = spec_of([n("start", "input"), n("chat", "agent", prompt="随便聊"), n("write", "report"), + n("late", "tool", tool="db_query__shop"), n("out", "output")], + [{"source": "start", "target": "chat"}, {"source": "chat", "target": "write"}, + {"source": "write", "target": "late"}, {"source": "late", "target": "out"}]) + [issue] = [i for i in authored_issues(spec, []) if i["code"] == "report_no_source"] + assert issue["node_id"] == "write" and issue["level"] == "error" + + +def test_reports_this_round_did_not_touch_are_left_alone(): + nodes = [n("start", "input"), n("write", "report", instructions="写"), n("out", "output")] + spec = spec_of(nodes, [{"source": "start", "target": "write"}, {"source": "write", "target": "out"}]) + assert not [i for i in authored_issues(spec, [], baseline=nodes) if i["code"] == "report_no_source"] + + +# -------------------------------------------------------------------------- +# 问数据的图发成受管:Copilot 补契约(用户报过的那一幕) +# -------------------------------------------------------------------------- + + +def ask_report_graph() -> dict: + """input → agent(查库)→ 报告撰写(受管三项都写了、正文引单元格)→ 出口,出口还没有契约。""" + def g(nid, ntype, label, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label, "config": config}} + + return { + "nodes": [ + g("start", "input", "问题"), + g("ask", "agent", "查数", tools=["db_query__shop"], prompt="查活跃用户数和用户总数", approval="dangerous"), + g("report", "report", "写答案", instructions="回答:{{ input.question }}", numbers="strict", + on_violation="fail", claims="require_citation"), + g("out", "output", "输出答案", fields=[{"name": "answer", "value": "{{ nodes.report.text }}"}]), + ], + "edges": [{"id": f"e{i}", "source": a, "target": b} + for i, (a, b) in enumerate([("start", "ask"), ("ask", "report"), ("report", "out")])], + } + + +class Assist: + def __init__(self, ops: list[dict]) -> None: + self.lines = [json.dumps(o, ensure_ascii=False) for o in ops] + + async def astream(self, messages): + from langchain_core.messages import AIMessageChunk + + for line in self.lines: + yield AIMessageChunk(content=line + "\n") + + +async def governed_assist(client, monkeypatch, ops: list[dict]) -> dict: + import app.api.copilot as copilot + + async def _model(*_a, **_k): + return Assist(ops), "mock-fast" + + monkeypatch.setattr(copilot, "get_chat_model", _model) + wf = (await client.post("/api/workflows", json={"name": "问数据-受管", "graph": ask_report_graph()})).json()["id"] + r = await client.post(f"/api/workflows/{wf}/autofix", json={"level": "governed", "apply": [], "assist": True}) + assert r.status_code == 200, r.text + return r.json() + + +CELLS_QUESTION = {"op": "question", "node_id": "out", + "text": "报告正文直接引用了查询单元格:契约里写 cells: true,还是改成口径卡指标?"} + + +async def test_the_copilots_contract_for_a_cells_only_graph_is_adopted(client, monkeypatch): + contract = {"report_from": "report", "strict": True} + out = await governed_assist(client, monkeypatch, [ + {"op": "plan", "summary": "补出具契约"}, + {"op": "update_node", "id": "out", "config": {"contract": contract}}, CELLS_QUESTION, + {"op": "done", "explanation": "给出口加上契约,cells 要你拿主意"}]) + assert out["assist"]["ok"] is True and "assist" in out["applied"], out["rejected"] + assert not [r for r in out["rejected"] if "metrics_from" in r["reason"]] + assert next(n for n in out["graph"]["nodes"] if n["id"] == "out")["data"]["config"]["contract"] == contract + assert out["assist"]["questions"] == ["「输出答案」:" + CELLS_QUESTION["text"]] + # 没有契约那一处没了;cells 要人拍板(Copilot 不许替人打开),改完冒出来的这一处不算 Copilot 改坏的, + # 发布弹窗里给一键选项:写 cells: true,或交给 Copilot 改成口径卡 + assert out["ok"] is False + assert [i["code"] for i in out["remaining"] if i["level"] == "error"] == ["contract.cells_undeclared"] + [fix] = [f for f in out["fixes"] if f["code"] == "contract.cells_undeclared"] + assert fix["kind"] == "choice" and [o["value"] for o in fix["options"]] == [True, "copilot"] + + +async def test_the_copilot_still_may_not_switch_cells_on_by_itself(client, monkeypatch): + out = await governed_assist(client, monkeypatch, [ + {"op": "plan", "summary": "补出具契约"}, + {"op": "update_node", "id": "out", "config": {"contract": {"report_from": "report", "strict": True, + "cells": True}}}, + {"op": "done", "explanation": "补上契约并打开 cells"}]) + [rejected] = [r for r in out["rejected"] if r["fix_id"] == "assist"] + assert "cells" in rejected["reason"] and out["ok"] is False diff --git a/backend/tests/test_guardrails.py b/backend/tests/test_guardrails.py index 310b0ca6..829be00b 100644 --- a/backend/tests/test_guardrails.py +++ b/backend/tests/test_guardrails.py @@ -60,7 +60,10 @@ async def _run(graph, *, timeout=20.0) -> Run: while asyncio.get_event_loop().time() < deadline: async with SessionLocal() as session: row = await session.get(Run, run.id) - if row and row.status in ("succeeded", "failed", "cancelled", "interrupted"): + # 终态先落库、清单随后另一个事务封上(runner._finalize):只等状态的话,负载一高就会 + # 读到「跑完了但还没封」的那一瞬。中断态不封存 + if row and (row.status == "interrupted" + or row.status in ("succeeded", "failed", "cancelled") and row.manifest_seq is not None): return row await asyncio.sleep(0.05) raise AssertionError("运行没有按时结束") diff --git a/backend/tests/test_upgrade_assist.py b/backend/tests/test_upgrade_assist.py new file mode 100644 index 00000000..7e37b342 --- /dev/null +++ b/backend/tests/test_upgrade_assist.py @@ -0,0 +1,216 @@ +"""一键升级的语义层(assist: true):请 Copilot 把喂口径卡的纯算术沙箱代码改写成口径卡表达式。 + +模型一律用剧本,不调真接口。守这几件事: +- 纯算术的改写被采纳:口径卡的表达式改成直接读上游,代码节点留在图上,只是预览、不改库 +- 删节点、放宽要求的改写整个作废,确定性改写的结果照样交出去 +- 不是纯算术的保留原样并给警告;模型要人拿主意的原样放进 questions +- 改出新 error 的按自查循环交回去改,改不好就作废 +- 没有要改写的代码节点时不调模型 +""" +from __future__ import annotations + +import json + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import Workflow, WorkflowVersion +from app.main import app + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +def node(nid, ntype, label=None, x=0, **config): + return {"id": nid, "type": ntype, "position": {"x": x, "y": 80}, "data": {"label": label or nid, "config": config}} + + +def graph(*, summary_llm: bool = False) -> dict: + """查库 → 沙箱代码算比率 → 口径卡读代码的产出 → 报告撰写(或者模型调用写总结)→ 出口。""" + writer = (node("write", "llm", "写总结", x=1120, prompt="写一句总结") if summary_llm + else node("write", "report", "报告撰写", x=1120, instructions="写一句话")) + return { + "nodes": [ + node("start", "input", "输入", x=0), + node("fetch", "tool", "查库", x=280, tool="db_query__shop", + args={"sql": "SELECT SUM(amount) AS a, COUNT(*) AS b FROM orders"}), + node("calc", "code", "算比率", x=560, language="python", assign_to="calc", + code="import json\nprint(json.dumps({'ratio': 3 / 4}))"), + node("card", "metrics", "口径卡", x=840, metrics=[ + {"id": "ratio", "name": "客单价", "expression": "vars.calc.ratio"}]), + writer, + node("out", "output", "成果", x=1400, fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]), + ], + "edges": [{"id": f"e{i}", "source": a, "target": b} for i, (a, b) in enumerate( + [("start", "fetch"), ("fetch", "calc"), ("calc", "card"), ("card", "write"), ("write", "out")])], + } + + +class Scripted: + """第一轮吐 first;收到自查请求(human 里有「上线前自查」)时吐 fix。记下每次的 human。""" + + def __init__(self, first: list[dict], fix: list[dict] | None = None) -> None: + self.first = [json.dumps(o, ensure_ascii=False) for o in first] + self.fix = [json.dumps(o, ensure_ascii=False) for o in fix or []] + self.calls: list[str] = [] + + async def astream(self, messages): + from langchain_core.messages import AIMessageChunk + + human = next(text for role, text in messages if role == "human") + self.calls.append(human) + for line in (self.fix if "上线前自查" in human else self.first): + yield AIMessageChunk(content=line + "\n") + + +def script(monkeypatch, first: list[dict], fix: list[dict] | None = None) -> Scripted: + import app.api.copilot as copilot + + model = Scripted(first, fix) + + async def _model(*_a, **_k): + return model, "mock-fast" + + monkeypatch.setattr(copilot, "get_chat_model", _model) + return model + + +def wrap(*ops) -> list[dict]: + return [{"op": "plan", "summary": "把纯算术的代码挪进口径卡"}, *ops, + {"op": "done", "explanation": "客单价改由口径卡直接算"}] + + +REWRITE = {"op": "update_node", "id": "card", "config": {"metrics": [ + {"id": "ratio", "name": "客单价", "expression": "cell(nodes.fetch, 0, 'a') / cell(nodes.fetch, 0, 'b')"}]}} + + +def by_id(g: dict) -> dict: + return {n["id"]: n for n in g["nodes"]} + + +async def upgrade(client, g: dict, **extra) -> dict: + r = await client.post("/api/copilot/upgrade-evidence", json={"graph": g, "assist": True, **extra}) + assert r.status_code == 200, r.text + return r.json() + + +async def rows() -> list: + async with SessionLocal() as session: + return [sorted(repr({c.name: getattr(r, c.name) for c in m.__table__.columns}) + for r in (await session.execute(select(m))).scalars()) + for m in (Workflow, WorkflowVersion)] + + +async def test_a_pure_arithmetic_rewrite_is_adopted_as_a_preview(client, monkeypatch): + model = script(monkeypatch, wrap(REWRITE)) + await client.post("/api/workflows", json={"name": "升级-兜底", "graph": graph()}) + before = await rows() + body = await upgrade(client, graph()) + assert body["assist"]["ok"] is True and body["assist"]["summary"] == "客单价改由口径卡直接算" + assert body["assist"]["warnings"] == [] and "assist" in body["applied"] + g = body["graph"] + assert by_id(g)["card"]["data"]["config"]["metrics"][0]["expression"] == REWRITE["config"]["metrics"][0]["expression"] + assert by_id(g)["calc"]["data"]["config"] == graph()["nodes"][2]["data"]["config"] # 代码节点留着、不替人标 source + assert [c for c in body["changes"] if c["rule"] == "assist"] == [ + {"fix_id": "assist", "rule": "assist", "node_id": "card", "node_title": "口径卡", "field": "metrics", + "before": graph()["nodes"][3]["data"]["config"]["metrics"], "after": REWRITE["config"]["metrics"], + "label": "Copilot 的修改"}] + assert body["ops"][-1] == REWRITE + assert not [n for n in body["notes"] if n["rule"] == "R5"] # 口径卡不再读它,建议随之作废 + assert await rows() == before # 只是预览 + human = model.calls[0] + assert "纯算术" in human and "「算比率」(calc)" in human and "vars.calc.ratio" in human + assert "不许降低要求" in human and "不替人选" in human and "只改上面这些口径卡指标的 expression" in human + assert "只修下面列出的问题" not in human + + +async def test_the_prompt_names_the_metrics_that_read_the_code_through_a_reshaper(client, monkeypatch): + # 代码 → 整形 → 口径卡:口径卡的指标读的是整形节点,追到头才是这段代码,要改写的表达式照样得列给模型 + g = graph() + g["nodes"].insert(3, node("shape", "transform", "整形", x=700, template="{{ vars.calc }}", assign_to="shaped")) + g["nodes"][4]["data"]["config"]["metrics"] = [ + {"id": "ratio", "name": "客单价", "expression": "vars.shaped.ratio"}, + {"id": "orders", "name": "订单数", "expression": "cell(nodes.fetch, 0, 'b')"}] + g["edges"] = [{"id": f"e{i}", "source": a, "target": b} for i, (a, b) in enumerate( + [("start", "fetch"), ("fetch", "calc"), ("calc", "shape"), ("shape", "card"), ("card", "write"), + ("write", "out")])] + model = script(monkeypatch, wrap({"op": "question", "node_id": "calc", "text": "拿不准"})) + await upgrade(client, g) + [line] = [ln for ln in model.calls[0].splitlines() if ln.startswith("- 「算比率」(calc)")] + assert "ratio" in line and "vars.shaped.ratio" in line + assert "orders" not in line # 不读这段代码的指标不列 + + +async def test_deleting_the_code_node_is_refused_and_the_deterministic_result_stays(client, monkeypatch): + script(monkeypatch, wrap(REWRITE, {"op": "remove_node", "id": "calc"})) + body = await upgrade(client, graph(summary_llm=True)) + assert body["assist"]["ok"] is False and "assist" not in body["applied"] + [rejected] = [r for r in body["rejected"] if r["fix_id"] == "assist"] + assert "降低" in rejected["reason"] and "算比率" in rejected["reason"] + g = body["graph"] + assert "calc" in by_id(g) and by_id(g)["write"]["type"] == "report" # R2 照样生效 + assert by_id(g)["card"]["data"]["config"]["metrics"][0]["expression"] == "vars.calc.ratio" + assert body["applied"] == ["R2:write"] + assert any("算比率" in w for w in body["assist"]["warnings"]) + + +async def test_non_arithmetic_code_is_kept_with_a_warning(client, monkeypatch): + script(monkeypatch, wrap({"op": "question", "node_id": "calc", "text": "它读了一个文件,像是在取数,请确认"})) + body = await upgrade(client, graph()) + assert body["assist"]["ok"] is True and body["applied"] == [] + assert body["assist"]["questions"] == ["「算比率」:它读了一个文件,像是在取数,请确认"] + [warning] = body["assist"]["warnings"] + assert "算比率" in warning and "evidence_role" in warning and "口径卡" in warning + assert body["graph"] == graph() + assert [n["node_id"] for n in body["notes"] if n["rule"] == "R5"] == ["calc"] + + +async def test_nothing_to_rewrite_means_no_model_call(client, monkeypatch): + g = graph() + g["nodes"][2]["data"]["config"]["evidence_role"] = "source" + model = script(monkeypatch, wrap(REWRITE)) + body = await upgrade(client, g) + assert model.calls == [] and body["assist"]["ok"] is True and body["assist"]["warnings"] == [] + assert "没有要交给 Copilot 改写的" in body["assist"]["summary"] + + +async def test_a_new_error_is_sent_back_once_and_fixed(client, monkeypatch): + broken = {"op": "update_node", "id": "card", "config": {"metrics": [ + {"id": "ratio", "name": "客单价", "expression": "cell(nodes.fetch, 0, 'a') /"}]}} + model = script(monkeypatch, wrap(broken), [REWRITE, {"op": "done"}]) + body = await upgrade(client, graph()) + assert len(model.calls) == 2 and "上线前自查" in model.calls[1] and "表达式" in model.calls[1] + assert body["assist"]["ok"] is True and "assist" in body["applied"] + assert body["assist"]["summary"] == "客单价改由口径卡直接算" # 修补轮的 done 不盖掉说明 + expr = by_id(body["graph"])["card"]["data"]["config"]["metrics"][0]["expression"] + assert expr == REWRITE["config"]["metrics"][0]["expression"] + + +async def test_a_rewrite_that_stays_broken_is_refused(client, monkeypatch): + broken = {"op": "update_node", "id": "card", "config": {"metrics": [ + {"id": "ratio", "name": "客单价", "expression": "cell(nodes.fetch, 0, 'a') /"}]}} + model = script(monkeypatch, wrap(broken), [broken, {"op": "done"}]) + body = await upgrade(client, graph()) + assert len(model.calls) == 3 # 最多交回去改两轮 + assert body["assist"]["ok"] is False and "assist" not in body["applied"] + [rejected] = [r for r in body["rejected"] if r["fix_id"] == "assist"] + assert "变糟" in rejected["reason"] + assert by_id(body["graph"])["card"]["data"]["config"]["metrics"][0]["expression"] == "vars.calc.ratio" + + +async def test_marking_the_code_as_a_source_is_not_the_copilots_call(client, monkeypatch): + script(monkeypatch, wrap({"op": "update_node", "id": "calc", "config": {"evidence_role": "source"}})) + body = await upgrade(client, graph()) + [rejected] = [r for r in body["rejected"] if r["fix_id"] == "assist"] + assert "source" in rejected["reason"] and body["graph"] == graph() + + +async def test_without_assist_there_is_no_model_call(client, monkeypatch): + model = script(monkeypatch, wrap(REWRITE)) + r = await client.post("/api/copilot/upgrade-evidence", json={"graph": graph()}) + assert r.json()["assist"] is None and model.calls == [] diff --git a/backend/tests/test_upgrade_evidence.py b/backend/tests/test_upgrade_evidence.py new file mode 100644 index 00000000..bbb3e402 --- /dev/null +++ b/backend/tests/test_upgrade_evidence.py @@ -0,0 +1,590 @@ +"""一键升级为可追溯结构(证据五期)。 + +确定性改写 upgrade_for_evidence 是纯函数、幂等: +- R1 契约的 narrative 指向模型调用节点 → 换成报告撰写,契约改成 report_from +- R2 没有契约、模型调用的文字进了出口、上游有取数 → 同样换成报告撰写 +- R3 input → agent → output(问数据的典型图)→ 中间插一个报告撰写,出口改取它的正文 +- R4 配了 output_schema 的 agent → 打开 cite_fields +- R5 沙箱代码的产出喂口径卡 → 不改,只在 notes 里建议标成 source + +禁止规则和发布前修复同一套,只多两个例外:模型调用换成报告撰写、R3 改接的那条连线。 +升级接口只给预览,不改库。 +""" +from __future__ import annotations + +import copy + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import Artifact, Run, RunEvent, Setting, Workflow, WorkflowVersion +from app.engine.governance import publish_issues +from app.engine.schema import GraphSpec, validate_graph +from app.engine.upgrade import forbidden, upgrade_candidates, upgrade_for_evidence +from app.main import app + +UPGRADE = "evidence.upgrade_available" + + +@pytest.fixture +async def client(): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +def node(nid, ntype, label=None, x=0, **config): + return {"id": nid, "type": ntype, "position": {"x": x, "y": 80}, "data": {"label": label or nid, "config": config}} + + +def edge(i, a, b, handle=None): + out = {"id": f"e{i}", "source": a, "target": b} + if handle: + out["sourceHandle"] = handle + return out + + +def edges_of(*pairs): + return [edge(i, a, b) for i, (a, b) in enumerate(pairs)] + + +def narrative_graph() -> dict: + """旧的周报:口径卡算数、模型调用写叙述、契约按数值回指叙述(R1)。""" + return { + "nodes": [ + node("start", "input", "周期", x=0, fields=[{"name": "week", "default": "2026-W37"}]), + node("fetch", "code", "取数", x=280, language="python", evidence_role="source", assign_to="agg", + code="import json\nprint(json.dumps({'orders': 12, 'amount': 345.5}))"), + node("caliber", "metrics", "周报口径卡", x=560, caliber="周报口径", caliber_version="v1", metrics=[ + {"id": "orders", "name": "订单数", "unit": "单", "expression": "vars.agg.orders"}, + {"id": "amount", "name": "销售额", "unit": "元", "expression": "vars.agg.amount"}]), + node("narrate", "llm", "叙述", x=840, system="你是周报撰写人。", provider="mock", model="mock-writer", + temperature=0.2, prompt="{{ nodes.caliber.text }}\n\n请为 {{ input.week }} 写周报正文。", + assign_to="report", skills=["结构化分析"]), + node("done", "output", "出具", x=1120, fields=[ + {"name": "周报", "value": "{{ vars.report }}"}, + {"name": "指标清单", "value": "{{ nodes.caliber.text }}"}], + contract={"metrics_from": ["caliber"], "narrative": "{{ vars.report }}", "required": ["amount"], + "expected": ["orders"], "allow_numbers": ["37"], "strict": False}), + ], + "edges": edges_of(("start", "fetch"), ("fetch", "caliber"), ("caliber", "narrate"), ("narrate", "done")), + "viewport": {"x": 1, "y": 2, "zoom": 0.9}, + } + + +def summary_graph() -> dict: + """没有契约:调用工具查库,模型调用写总结,出口直接取它的文字(R2)。""" + return { + "nodes": [ + node("start", "input", "输入"), + node("fetch", "tool", "查库", tool="db_query__shop", args={"sql": "SELECT COUNT(*) AS n FROM orders"}), + node("sum", "llm", "总结", prompt="根据 {{ nodes.fetch }} 写一句总结"), + node("out", "output", "成果", fields=[{"name": "总结", "value": "{{ nodes.sum.text }}"}]), + ], + "edges": edges_of(("start", "fetch"), ("fetch", "sum"), ("sum", "out")), + } + + +def ask_graph(**agent) -> dict: + """问数据的典型图:input → agent(绑查库工具)→ output(R3)。""" + cfg = {"tools": ["db_query__shop"], "prompt": "查库回答:{{ input.question }}", "assign_to": "answer", **agent} + return { + "nodes": [ + node("start", "input", "问题", x=0), + node("ask", "agent", "查数作答", x=280, **cfg), + node("out", "output", "答案", x=560, fields=[{"name": "answer", "value": "{{ vars.answer }}"}]), + ], + "edges": edges_of(("start", "ask"), ("ask", "out")), + } + + +def schema_graph() -> dict: + """agent 配了 output_schema 却没开 cite_fields(R4),另有一个节点把它的 assign_to 当文字用。""" + return { + "nodes": [ + node("start", "input", "输入"), + node("fetch", "agent", "取数", tools=["db_query__shop"], prompt="查本周销售额", assign_to="kpi", + output_schema={"type": "object", "properties": {"gmv": {"type": "number"}}}), + node("card", "metrics", "口径卡", metrics=[{"id": "gmv", "name": "销售额", "expression": "vars.kpi.gmv"}]), + node("write", "report", "报告撰写", instructions="写一句话"), + node("peek", "output", "调试", fields=[{"name": "原样", "value": "{{ vars.kpi }}"}]), + node("out", "output", "成果", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]), + ], + "edges": edges_of(("start", "fetch"), ("fetch", "card"), ("card", "write"), ("write", "out"), + ("fetch", "peek")), + } + + +def compute_graph() -> dict: + """沙箱代码在算数、口径卡读它(R5:只建议,不改)。""" + return { + "nodes": [ + node("start", "input", "输入"), + node("fetch", "tool", "查库", tool="db_query__shop", args={"sql": "SELECT 1 AS a, 2 AS b"}), + node("calc", "code", "计算", language="python", assign_to="calc", + code="import json\nprint(json.dumps({'ratio': 1 / 2}))"), + node("card", "metrics", "口径卡", metrics=[{"id": "ratio", "name": "比率", "expression": "vars.calc.ratio"}]), + node("write", "report", "报告撰写", instructions="写一句话"), + node("out", "output", "成果", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]), + ], + "edges": edges_of(("start", "fetch"), ("fetch", "calc"), ("calc", "card"), ("card", "write"), + ("write", "out")), + } + + +def nodes_by_id(graph: dict) -> dict: + return {n["id"]: n for n in graph["nodes"]} + + +def cfg(graph: dict, nid: str) -> dict: + return nodes_by_id(graph)[nid]["data"]["config"] + + +def pairs(graph: dict) -> set: + return {(e["source"], e["target"]) for e in graph["edges"]} + + +def errors(graph: dict, level: str = "published") -> list: + return [i for i in publish_issues(GraphSpec.model_validate(graph), level=level) if i.level == "error"] + + +def infos(graph: dict) -> list: + return [i for i in validate_graph(GraphSpec.model_validate(graph)).issues if i.code == UPGRADE] + + +# -------------------------------------------------------------------------- +# R1–R5 +# -------------------------------------------------------------------------- + + +def test_r1_turns_the_narrating_llm_into_a_report_and_the_contract_into_report_from(): + before = narrative_graph() + out = upgrade_for_evidence(before) + g = out["graph"] + write = nodes_by_id(g)["narrate"] + assert write["type"] == "report" and write["data"]["label"] == "叙述" + c = write["data"]["config"] + # system 和 prompt 合成 instructions,模型配置照搬,assign_to 留着(出口还在读 vars.report) + assert c["instructions"] == "你是周报撰写人。\n\n{{ nodes.caliber.text }}\n\n请为 {{ input.week }} 写周报正文。" + assert (c["provider"], c["model"], c["temperature"], c["assign_to"]) == ("mock", "mock-writer", 0.2, "report") + assert not {"prompt", "system", "skills"} & set(c) + contract = cfg(g, "done")["contract"] + assert contract == {"metrics_from": ["caliber"], "report_from": "narrate", "required": ["amount"], + "expected": ["orders"], "allow_numbers": ["37"], "strict": False} + assert out["applied"] == ["R1:narrate"] and out["rejected"] == [] + type_change = next(ch for ch in out["changes"] if ch["field"] == "type") + assert (type_change["node_id"], type_change["before"], type_change["after"], type_change["rule"]) == ( + "narrate", "llm", "report", "R1") + assert any(ch["node_id"] == "done" and ch["field"] == "contract" for ch in out["changes"]) + assert g["viewport"] == before["viewport"] and [e["id"] for e in g["edges"]] == [e["id"] for e in before["edges"]] + assert errors(g) == [] and infos(g) == [] + texts = " ".join(n["text"] for n in out["notes"]) + assert "结构化分析" in texts # 技能不再挂载要说出来 + assert "{{ nodes.caliber.text }}" in texts # 指令里照搬了口径卡的文字,提醒可以删 + + +def test_r2_replaces_an_llm_whose_text_reaches_an_exit_without_a_contract(): + out = upgrade_for_evidence(summary_graph()) + g = out["graph"] + assert nodes_by_id(g)["sum"]["type"] == "report" + assert cfg(g, "sum")["instructions"] == "根据 {{ nodes.fetch }} 写一句总结" + assert out["applied"] == ["R2:sum"] and errors(g) == [] and infos(g) == [] + assert "contract" not in cfg(g, "out") # R2 不替人加契约 + + +def test_r2_leaves_an_llm_with_nothing_fetched_upstream_alone(): + graph = summary_graph() + graph["nodes"] = [n for n in graph["nodes"] if n["id"] != "fetch"] + graph["edges"] = edges_of(("start", "sum"), ("sum", "out")) + out = upgrade_for_evidence(graph) + assert out["changes"] == [] and out["graph"] == graph and infos(graph) == [] + + +def test_r2_does_not_count_knowledge_retrieval_as_fetching_data(): + # 知识库问答的答案换成报告撰写,文档里的数只能当引文、不能当值引用,全会被判成裸数字:不算取数 + graph = summary_graph() + graph["nodes"][1] = node("fetch", "retrieve", "检索", query="{{ input.question }}", collection="docs") + out = upgrade_for_evidence(graph) + assert out["changes"] == [] and infos(graph) == [] + + +def test_r3_inserts_a_report_between_the_agent_and_the_exit(): + before = ask_graph() + out = upgrade_for_evidence(before) + g = out["graph"] + [rid] = [n["id"] for n in g["nodes"] if n["type"] == "report"] + assert pairs(g) == {("start", "ask"), ("ask", rid), (rid, "out")} + assert cfg(g, "out")["fields"] == [{"name": "answer", "value": f"{{{{ nodes.{rid}.text }}}}"}] + instructions = cfg(g, rid)["instructions"] + assert "{{ input.question }}" in instructions and "{{ nodes.ask.text }}" in instructions + assert cfg(g, "ask") == cfg(before, "ask") # agent 本身不动 + assert out["applied"] == [f"R3:ask"] and errors(g) == [] and infos(g) == [] + fields = {ch["field"] for ch in out["changes"]} + assert {"node", "edge", "fields"} <= fields + rewire = next(ch for ch in out["changes"] if ch["field"] == "edge" and ch["before"]) + assert rewire["before"] == {"source": "ask", "target": "out"} and rewire["after"] == {"source": rid, "target": "out"} + assert "contract" not in cfg(g, "out") # 契约(和 cells)要人拿主意,不替人加 + assert any("cells" in n["text"] for n in out["notes"]) + + +def test_r3_skips_agents_that_only_read_table_structures(): + graph = ask_graph(tools=["db_schema__shop"]) + assert upgrade_for_evidence(graph)["changes"] == [] and infos(graph) == [] + + +def test_r4_turns_on_cite_fields_and_warns_where_the_variable_was_read_as_text(): + out = upgrade_for_evidence(schema_graph()) + g = out["graph"] + assert cfg(g, "fetch")["cite_fields"] is True + assert out["applied"] == ["R4:fetch"] and errors(g) == [] and infos(g) == [] + [note] = [n for n in out["notes"] if n["rule"] == "R4"] + assert note["node_id"] == "peek" and "{{ vars.kpi }}" in note["text"] + + +def test_r5_only_suggests_marking_the_code_node_as_a_source(): + before = compute_graph() + out = upgrade_for_evidence(before) + assert out["graph"] == before and out["changes"] == [] + [note] = [n for n in out["notes"] if n["rule"] == "R5"] + assert note["node_id"] == "calc" and "source" in note["text"] + assert infos(before) == [] # 只有 R5 的不算「旧结构」 + + +@pytest.mark.parametrize("make", [narrative_graph, summary_graph, ask_graph, schema_graph, compute_graph]) +def test_upgrading_twice_changes_nothing_more(make): + first = upgrade_for_evidence(make()) + second = upgrade_for_evidence(first["graph"]) + assert second["graph"] == first["graph"] and second["changes"] == [] and second["applied"] == [] + + +@pytest.mark.parametrize("make", [narrative_graph, summary_graph, ask_graph, schema_graph]) +def test_upgrade_is_pure(make): + graph = make() + kept = copy.deepcopy(graph) + upgrade_for_evidence(graph) + upgrade_for_evidence(GraphSpec.model_validate(graph)) + assert graph == kept + + +def test_graph_spec_input_gives_the_same_result(): + assert upgrade_for_evidence(GraphSpec.model_validate(ask_graph()))["applied"] == ["R3:ask"] + + +def test_governed_upgrades_write_the_strict_report_settings(): + out = upgrade_for_evidence(ask_graph(), level="governed") + [rid] = [n["id"] for n in out["graph"]["nodes"] if n["type"] == "report"] + c = cfg(out["graph"], rid) + assert (c["numbers"], c["on_violation"], c["claims"]) == ("strict", "fail", "require_citation") + assert out["rejected"] == [] + # published 下不替探索运行拧紧:修不掉的数字违规照常标注 + loose = cfg(upgrade_for_evidence(ask_graph())["graph"], rid) + assert not {"numbers", "on_violation", "claims"} & set(loose) + + +def test_a_rewrite_that_would_add_an_error_is_dropped_with_a_reason(): + graph = narrative_graph() + # 叙述节点没连到出口:换成报告撰写以后 report_from 指向不在上游的节点,会冒出新的 error + graph["edges"] = edges_of(("start", "fetch"), ("fetch", "caliber"), ("caliber", "narrate"), ("caliber", "done")) + out = upgrade_for_evidence(graph) + assert out["graph"] == graph and out["changes"] == [] and out["applied"] == [] + [rejected] = out["rejected"] + assert rejected["fix_id"] == "R1:narrate" and "新的问题" in rejected["reason"] + + +def test_a_narrative_that_mixes_two_writers_is_not_guessed(): + graph = narrative_graph() + graph["nodes"].insert(4, node("extra", "llm", "补充", prompt="补一句", assign_to="more")) + graph["edges"].append(edge(9, "caliber", "extra")) + graph["edges"].append(edge(10, "extra", "done")) + cfg(graph, "done")["contract"]["narrative"] = "{{ vars.report }}{{ vars.more }}" + out = upgrade_for_evidence(graph) + assert not any(a.startswith("R1:") for a in out["applied"]) + assert any(n["rule"] == "R1" and "补充" in n["text"] for n in out["notes"]) + assert infos(graph) == [] # 说不准怎么改的不建议升级 + + +def two_exit_narrative_graph(*, extra_first: bool, mixed_exit_first: bool) -> dict: + """两个出口都读「叙述」:「出具」的 narrative 还拼了「补充」的文字(说不准),「出具2」只读「叙述」。""" + graph = narrative_graph() + extra = node("extra", "llm", "补充", prompt="补一句", assign_to="more") + graph["nodes"].insert(3 if extra_first else 4, extra) + graph["edges"] += [edge(9, "caliber", "extra"), edge(10, "extra", "done"), edge(11, "narrate", "done2")] + cfg(graph, "done")["contract"]["narrative"] = "{{ vars.report }}{{ vars.more }}" + other = node("done2", "output", "出具2", fields=[{"name": "周报", "value": "{{ vars.report }}"}], + contract={"metrics_from": ["caliber"], "narrative": "{{ vars.report }}", "required": ["amount"], + "strict": False}) + done = next(i for i, n in enumerate(graph["nodes"]) if n["id"] == "done") + graph["nodes"].insert(done + 1 if mixed_exit_first else done, other) + return graph + + +@pytest.mark.parametrize("extra_first", [False, True]) +@pytest.mark.parametrize("mixed_exit_first", [False, True]) +def test_an_ambiguous_narrative_blocks_the_retype_whatever_the_node_order(extra_first, mixed_exit_first): + # 同一个模型调用从两个出口认出来:一个出口说不准就整个不换——不看哪个出口在画布上排前面 + graph = two_exit_narrative_graph(extra_first=extra_first, mixed_exit_first=mixed_exit_first) + out = upgrade_for_evidence(graph) + assert out["graph"] == graph and out["changes"] == [] and out["applied"] == [] + blocked = [n for n in out["notes"] if n["rule"] == "R1" and n["level"] == "warning"] + assert len(blocked) == 1 and "补充" in blocked[0]["text"] and "叙述" in blocked[0]["text"] + assert infos(graph) == [] + + +def test_a_narrative_that_mixes_an_llm_and_an_agent_is_not_guessed(): + graph = narrative_graph() + graph["nodes"].insert(4, node("ask", "agent", "查数", tools=["db_query__shop"], prompt="查一下", assign_to="ans")) + graph["edges"] += [edge(9, "caliber", "ask"), edge(10, "ask", "done")] + cfg(graph, "done")["contract"]["narrative"] = "{{ vars.report }}\n{{ nodes.ask.text }}" + out = upgrade_for_evidence(graph) + assert out["changes"] == [] and out["graph"] == graph + assert any(n["rule"] == "R1" and n["level"] == "warning" and "查数" in n["text"] for n in out["notes"]) + assert any(n["rule"] == "R3" and n["level"] == "warning" and "出具" in n["text"] for n in out["notes"]) + assert infos(graph) == [] + + +def indirect_ask_graph(*, indirect_first: bool) -> dict: + """问数据的 agent 直连「答案」,另一个出口「转述」经过整形节点才读到它的原话。""" + graph = ask_graph() + graph["nodes"].insert(2, node("shape", "transform", "整形", template="{{ vars.answer }}", assign_to="shaped")) + other = node("out2", "output", "转述", fields=[{"name": "原话", "value": "{{ nodes.ask.text }}"}]) + graph["nodes"].insert(3 if indirect_first else 4, other) + graph["edges"] += [edge(7, "ask", "shape"), edge(8, "shape", "out2")] + return graph + + +@pytest.mark.parametrize("indirect_first", [False, True]) +def test_r3_rewires_the_direct_exit_and_explains_the_indirect_one_whatever_the_node_order(indirect_first): + graph = indirect_ask_graph(indirect_first=indirect_first) + out = upgrade_for_evidence(graph) + g = out["graph"] + assert out["applied"] == ["R3:ask"] and out["rejected"] == [] + [rid] = [n["id"] for n in g["nodes"] if n["type"] == "report"] + assert pairs(g) == {("start", "ask"), ("ask", rid), (rid, "out"), ("ask", "shape"), ("shape", "out2")} + assert cfg(g, "out")["fields"] == [{"name": "answer", "value": f"{{{{ nodes.{rid}.text }}}}"}] + assert cfg(g, "out2") == cfg(graph, "out2") # 经过别的节点的出口不替人改 + [skipped] = [n for n in out["notes"] if n["rule"] == "R3" and n["level"] == "warning"] + assert "转述" in skipped["text"] and "经过别的节点" in skipped["text"] + assert infos(graph) and infos(g) == [] + again = upgrade_for_evidence(g) + assert again["changes"] == [] and again["graph"] == g + + +def test_r3_with_only_indirect_exits_is_not_guessed(): + graph = indirect_ask_graph(indirect_first=True) + graph["nodes"] = [n for n in graph["nodes"] if n["id"] != "out"] + graph["edges"] = [e for e in graph["edges"] if e["target"] != "out"] + out = upgrade_for_evidence(graph) + assert out["changes"] == [] and out["graph"] == graph + [blocked] = [n for n in out["notes"] if n["rule"] == "R3"] + assert blocked["level"] == "warning" and "转述" in blocked["text"] + assert infos(graph) == [] + + +@pytest.mark.parametrize("unset", ["", None]) +@pytest.mark.parametrize("after_narrative", [True, False]) +def test_an_unset_report_from_does_not_swallow_the_new_one(unset, after_narrative): + graph = narrative_graph() + contract = cfg(graph, "done")["contract"] + rest = {k: v for k, v in contract.items() if k != "narrative"} + cfg(graph, "done")["contract"] = ({"narrative": contract["narrative"], "report_from": unset, **rest} + if after_narrative else + {"report_from": unset, "narrative": contract["narrative"], **rest}) + out = upgrade_for_evidence(graph) + assert out["applied"] == ["R1:narrate"] + assert cfg(out["graph"], "done")["contract"] == {"report_from": "narrate", **rest} + + +def test_an_unset_report_from_is_dropped_when_r3_converts_the_narrative(): + graph = ask_graph() + cfg(graph, "out")["contract"] = {"narrative": "{{ vars.answer }}", "report_from": "", "strict": True} + out = upgrade_for_evidence(graph) + [rid] = [n["id"] for n in out["graph"]["nodes"] if n["type"] == "report"] + assert cfg(out["graph"], "out")["contract"] == {"report_from": rid, "strict": True} + + +def cells_notes(out: dict) -> list: + return [n for n in out["notes"] if n["rule"] == "R3" and "cells: true" in n["text"]] + + +def test_r3_warns_that_governed_formal_runs_fail_without_cells(): + out = upgrade_for_evidence(ask_graph(), level="governed") + [note] = cells_notes(out) + assert note["level"] == "warning" and "每次正式运行都会失败" in note["text"] and "口径卡" in note["text"] + + +def test_r3_warns_when_it_turns_a_contract_on_a_graph_without_cards_into_report_from(): + graph = ask_graph() + cfg(graph, "out")["contract"] = {"narrative": "{{ vars.answer }}", "strict": True} + out = upgrade_for_evidence(graph) + [note] = cells_notes(out) + assert note["level"] == "warning" and "每次正式运行都会失败" in note["text"] + assert "cells" not in cfg(out["graph"], "out")["contract"] # 要人拿主意,不替人打开 + + +def test_r3_only_informs_about_cells_at_published_level_without_a_contract(): + [note] = cells_notes(upgrade_for_evidence(ask_graph())) + assert note["level"] == "info" + + +def test_r3_says_nothing_about_cells_when_the_contract_already_declares_them(): + graph = ask_graph() + cfg(graph, "out")["contract"] = {"narrative": "{{ vars.answer }}", "strict": True, "cells": True} + out = upgrade_for_evidence(graph, level="governed") + assert out["applied"] == ["R3:ask"] and cells_notes(out) == [] + + +def test_candidates_list_what_the_upgrade_would_touch(): + assert [(m.rule, m.node_id) for m in upgrade_candidates(GraphSpec.model_validate(narrative_graph()))] == [ + ("R1", "narrate")] + assert [(m.rule, m.node_id) for m in upgrade_candidates(GraphSpec.model_validate(ask_graph()))] == [ + ("R3", "ask")] + + +# -------------------------------------------------------------------------- +# 禁止规则 +# -------------------------------------------------------------------------- + + +def test_deleting_a_node_is_refused(): + before = narrative_graph() + after = copy.deepcopy(before) + after["nodes"] = [n for n in after["nodes"] if n["id"] != "fetch"] + assert any("删了节点" in r for r in forbidden(before, after)) + + +def test_llm_to_report_is_allowed_only_for_the_node_the_upgrade_names(): + before = narrative_graph() + after = copy.deepcopy(before) + nodes_by_id(after)["narrate"]["type"] = "report" + assert forbidden(before, after, retyped={("narrate", "llm", "report")}) == [] + assert any("换成了" in r for r in forbidden(before, after)) + # 别的换类型(比如把沙箱代码换成口径卡)不在例外里 + nodes_by_id(after)["fetch"]["type"] = "metrics" + assert any("取数" in r and "换成了" in r for r in forbidden(before, after, retyped={("narrate", "llm", "report")})) + + +def test_only_the_rewired_edge_may_go(): + before = ask_graph() + after = copy.deepcopy(before) + after["edges"] = [e for e in after["edges"] if (e["source"], e["target"]) != ("ask", "out")] + assert forbidden(before, after, rewired={("ask", "out")}) == [] + assert any("删了连线" in r for r in forbidden(before, after)) + after["edges"] = [] + assert any("start → ask" in r for r in forbidden(before, after, rewired={("ask", "out")})) + + +@pytest.mark.parametrize("damage, word", [ + (lambda g: cfg(g, "done").pop("contract"), "契约"), + (lambda g: cfg(g, "done")["contract"].update(required=[]), "required"), + (lambda g: cfg(g, "done")["contract"].update(strict=True), None), +]) +def test_contract_rules_are_the_publish_fix_rules(damage, word): + before = narrative_graph() + after = copy.deepcopy(before) + damage(after) + reasons = forbidden(before, after) + if word: + assert any(word in r for r in reasons) + else: + assert reasons == [] # 收紧不算 + + +def test_strict_off_and_looser_approval_are_refused(): + before = ask_graph(approval="dangerous") + before["nodes"].append(node("fin", "output", "出具", contract={"report_from": "w", "strict": True})) + after = copy.deepcopy(before) + cfg(after, "ask")["approval"] = "never" + cfg(after, "fin")["contract"]["strict"] = False + reasons = forbidden(before, after) + assert any("全部自动放行" in r for r in reasons) and any("strict" in r for r in reasons) + + +# -------------------------------------------------------------------------- +# validate 的建议 +# -------------------------------------------------------------------------- + + +def test_old_structures_get_one_info_and_nothing_else_changes(): + spec = GraphSpec.model_validate(narrative_graph()) + result = validate_graph(spec) + [hint] = [i for i in result.issues if i.code == UPGRADE] + assert hint.level == "info" and hint.message.startswith("可以升级为可追溯结构") and hint.node_id is None + assert "「叙述」" in hint.message # 说得出要改哪里 + assert result.ok is (not any(i.level == "error" for i in result.issues)) + assert len([i for i in validate_graph(GraphSpec.model_validate(ask_graph())).issues if i.code == UPGRADE]) == 1 + + +def test_already_traceable_graphs_get_no_info(): + for graph in (upgrade_for_evidence(narrative_graph())["graph"], compute_graph()): + assert infos(graph) == [] + + +def test_the_info_is_not_a_blocker_for_runs_or_publishing(): + graph = ask_graph() + assert errors(graph) == [] and validate_graph(GraphSpec.model_validate(graph)).ok is True + + +# -------------------------------------------------------------------------- +# 升级接口:只给预览,不改库 +# -------------------------------------------------------------------------- + + +async def _snapshot() -> dict: + async with SessionLocal() as session: + out = {} + for model in (Workflow, WorkflowVersion, Run, RunEvent, Artifact, Setting): + rows = (await session.execute(select(model))).scalars().all() + out[model.__tablename__] = sorted( + (repr({c.name: getattr(r, c.name) for c in model.__table__.columns}) for r in rows)) + return out + + +async def test_the_endpoint_previews_without_touching_the_database(client): + created = await client.post("/api/workflows", json={"name": "升级-只读", "graph": narrative_graph()}) + assert created.status_code == 201, created.text + before = await _snapshot() + r = await client.post("/api/copilot/upgrade-evidence", json={"graph": narrative_graph()}) + assert r.status_code == 200, r.text + body = r.json() + assert set(body) >= {"graph", "changes", "ops", "notes", "issues", "rejected", "assist"} + assert body["assist"] is None + assert nodes_by_id(body["graph"])["narrate"]["type"] == "report" + assert any(op["op"] == "replace_node" and op["id"] == "narrate" for op in body["ops"]) + assert not [i for i in body["issues"] if i["level"] == "error"] + assert not [i for i in body["issues"] if i.get("code") == UPGRADE] + assert await _snapshot() == before # 一行都没改 + stored = (await client.get(f"/api/workflows/{created.json()['id']}")).json()["graph"] + assert nodes_by_id(stored)["narrate"]["type"] == "llm" + + +async def test_the_endpoint_places_the_inserted_report_without_moving_the_others(client): + from app.engine.layout import NODE_W + + graph = ask_graph() + # 一个不相干的节点正好摆在 agent 和出口之间偏下的空处:新插进来的报告撰写不能压在它身上 + graph["nodes"].append(node("memo", "memory", "记一笔", x=420, action="write", content="{{ input.question }}")) + graph["nodes"][-1]["position"]["y"] = 200 + graph["edges"].append(edge(9, "start", "memo")) + body = (await client.post("/api/copilot/upgrade-evidence", json={"graph": graph})).json() + placed = nodes_by_id(body["graph"]) + for nid, n in nodes_by_id(graph).items(): + assert placed[nid]["position"] == n["position"] + [rid] = [nid for nid in placed if nid not in nodes_by_id(graph)] + mine = placed[rid]["position"] + for nid, n in placed.items(): + if nid != rid: + p = n["position"] + assert abs(p["x"] - mine["x"]) >= NODE_W or abs(p["y"] - mine["y"]) >= 60, (nid, p, mine) + + +async def test_the_endpoint_explains_an_unreadable_graph(client): + r = await client.post("/api/copilot/upgrade-evidence", json={"graph": {"nodes": [{"id": "bad id!", "type": "llm"}]}}) + assert r.status_code == 200 + body = r.json() + assert body["changes"] == [] and body["issues"][0]["level"] == "error" and "读不懂" in body["issues"][0]["message"] + + +async def test_the_endpoint_rejects_an_unknown_level(client): + r = await client.post("/api/copilot/upgrade-evidence", json={"graph": ask_graph(), "level": "everything"}) + assert r.status_code == 422 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index dfb870b3..6d794d4a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,7 +1,7 @@ import type { Approval, AutofixResult, Conversation, ConversationDetail, ConversationTurn, CustomTool, DataSource, EvidenceGraph, EvidenceAudit, EvidenceJudgeResult, EvidenceSegmentDetail, GraphSpec, KbDocument, MemoryItem, Provider, PublishCheck, PublishLevel, ReviewResult, Run, - RunEvent, RunStatus, Skill, ToolChange, ToolInfo, ToolTrust, ValidationIssue, VarIssue, Variable, Workflow, + RunEvent, RunStatus, Skill, ToolChange, ToolInfo, ToolTrust, UpgradeResult, ValidationIssue, VarIssue, Variable, Workflow, WorkflowVersion, } from '../types' import { localActor } from '../lib/actor' @@ -786,6 +786,13 @@ export const api = { /** 跑完之后复核一次。干净的运行后端直接返回 verdict='ok',不调模型 */ review: (body: { run_id: string; question: string }) => post('/copilot/review', body), + /** + * 一键升级为可追溯结构:先按确定的规则改写(llm 换成报告撰写、问数据的图插入报告节点……), + * assist 时再交给 Copilot 改语义层。level 是复核门禁用的发布级别(受管的图,新写出的报告节点按受管的要求配)。 + * 只给预览,不改库。老后端没有这个接口(404),调用方按「不支持」处理 + */ + upgradeEvidence: (body: { graph: GraphSpec; assist?: boolean; level?: PublishLevel }, opts?: RequestOptions) => + request('/copilot/upgrade-evidence', { method: 'POST', body: JSON.stringify(body), ...opts }), }, } diff --git a/frontend/src/canvas/ProblemsPanel.tsx b/frontend/src/canvas/ProblemsPanel.tsx index d59267d2..ddfc16df 100644 --- a/frontend/src/canvas/ProblemsPanel.tsx +++ b/frontend/src/canvas/ProblemsPanel.tsx @@ -1,16 +1,23 @@ -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { - AlertTriangle, CheckCircle2, CornerDownRight, ListChecks, Plus, RotateCw, ShieldCheck, Trash2, Unlink, Workflow, XCircle, + AlertTriangle, ArrowRight, Check, CheckCircle2, CornerDownRight, ListChecks, Plus, RotateCw, ShieldCheck, Sparkles, + Trash2, Unlink, Wand2, Workflow, XCircle, } from 'lucide-react' import clsx from 'clsx' import { NODE_DEFS } from './nodeDefs' -import { problemsOf, unboundToolOf, withToolBound, type FieldRef, type Problem } from './issues' +import { + fixFieldLabel, fixValueLines, fixValueText, isUpgradeAdvice, problemsOf, unboundToolOf, upgradeChangeKind, + upgradeChangesFromDiff, upgradeEdgeText, upgradeGroups, upgradeNodeText, upgradeRuleText, upgradeStepText, upgradeSummary, + upgradeTypeText, withToolBound, type FieldRef, type NodeNameOf, type Problem, +} from './issues' import { PublishPreflightPane } from './PublishDialog' +import { humanizeError } from '../lib/errors' import { formatShortcut } from '../lib/keys' -import { PUBLISH_FIX_TEXT } from '../lib/terms' +import { PUBLISH_FIX_TEXT, UPGRADE_TEXT } from '../lib/terms' import { useCatalog } from '../store/catalog' -import { useEditLock, useStudio } from '../store/studio' +import { EDIT_LOCK_TEXT, upgradeUnsaved, useEditLock, useStudio, type UpgradeFlow } from '../store/studio' import { Spinner, useRadioGroup } from '../components/ui' +import type { UpgradeChange, ValidationIssue } from '../types' /** * 问题面板:图级、节点、边上的 error 和 warning,一条一行,点一下定位。 @@ -31,6 +38,9 @@ export function ProblemsPane({ problems, activeId, onLocate, onDeleteEdge }: { const [mode, setMode] = useState<'lint' | 'publish'>('lint') const radio = useRadioGroup(MODES, mode, setMode) const hasWorkflow = useStudio((s) => !!s.workflow) + // 又开了一次升级预览(记录页的横幅跳过来、点了快速修复):它画在「校验」这一页,停在发布前检查上就看不见 + const upgradeSeq = useStudio((s) => s.upgrade?.seq) + useEffect(() => { if (upgradeSeq != null) setMode('lint') }, [upgradeSeq]) const errors = problems.filter((p) => p.level === 'error').length return ( <> @@ -74,6 +84,10 @@ function LintPane({ problems, activeId, onLocate, onDeleteEdge }: { const analyzeNow = useStudio((s) => s.analyzeNow) const offline = useCatalog((s) => s.backend === 'down') const list = useRef(null) + // 「可以升级为可追溯结构」:校验给的建议,不是问题。预览开着时建议可能已经没了(又校验过一次),预览照样留着 + const upgradeAdvice = useStudio((s) => s.advice.find(isUpgradeAdvice)) + const upgrading = useStudio((s) => !!s.upgrade) + const upgrade = upgradeAdvice || upgrading ? : null // F8 跳到的那一条要在视野里 useEffect(() => { @@ -86,8 +100,9 @@ function LintPane({ problems, activeId, onLocate, onDeleteEdge }: { // 不会有人替你再试,这时候承诺「会自动重新校验」就是在让人干等 const retryHint = offline ? '连上后端之后会自动重新校验' : '点「重试」再校验一次' if (analysis === 'failed' && !problems.length) { - return ( -
+ const failed = ( +
分析失败{analysisError ? `:${analysisError}` : ''} 问题清单暂时拿不到,画布照常能编辑。{retryHint}
) + // 升级块照样摆着:后端断开时保存和校验一起失败,这时候最要紧的正是「没存上 → 重试保存」 + if (!upgrade) return failed + return ( +
+ {upgrade} + {failed} +
+ ) } if (!problems.length) { - return ( -
+ const idle = ( +
{analysis === 'pending' ? <> 正在校验… : <> 没有发现问题}
) + if (!upgrade) return idle + return ( +
+ {upgrade} + {idle} +
+ ) } const byNode = new Map(nodes.map((n) => [n.id, n])) @@ -150,19 +180,22 @@ function LintPane({ problems, activeId, onLocate, onDeleteEdge }: {
)} -
- {groups.map((g) => ( -
-
- {g.title} - {g.items.length} +
+ {upgrade} +
+ {groups.map((g) => ( +
+
+ {g.title} + {g.items.length} +
+ {g.items.map((p) => ( + onLocate(p)} onDeleteEdge={onDeleteEdge} /> + ))}
- {g.items.map((p) => ( - onLocate(p)} onDeleteEdge={onDeleteEdge} /> - ))} -
- ))} + ))} +
{formatShortcut('F8')} 下一个 · {formatShortcut('Shift+F8')} 上一个
@@ -221,6 +254,367 @@ function ProblemRow({ problem: p, active, onLocate, onDeleteEdge }: { ) } +// ------------------------------------------------------------------------- +// 一键升级为可追溯结构:校验给了建议(evidence.upgrade_available)才有入口。老后端不给这条建议, +// 入口也就不出现。和发布前修复同一套规矩:先出预览,人点「应用」才走现有的保存存成草稿 +// ------------------------------------------------------------------------- + +const reasonOf = (e: unknown) => { + const h = humanizeError(e) + return h.reason ? `${h.title}:${h.reason}` : h.title +} + +/** 建议那一行加上预览。画布锁着(正式运行在跑、助手在改)不给入口,说清为什么 */ +function UpgradeBlock({ advice }: { advice?: ValidationIssue }) { + const upgrade = useStudio((s) => s.upgrade) + const previewUpgrade = useStudio((s) => s.previewUpgrade) + const lock = useEditLock() + // 预览出来、换了状态(算完、出错)时滚到眼前:连同上面那句建议一起,别只露出半截预览 + const ref = useRef(null) + const seq = upgrade?.seq + const status = upgrade?.status + useEffect(() => { + if (seq != null) ref.current?.scrollIntoView({ block: 'nearest' }) + }, [seq, status]) + return ( +
+
+ + + {advice?.message || UPGRADE_TEXT.advice} + {/* 后端的建议多半已经说了要改哪里、先预览;只有一句光秃秃的标题时才补这句说明 */} + {(!advice?.message || advice.message === UPGRADE_TEXT.advice) && ( + {UPGRADE_TEXT.adviceHint} + )} + + {!lock && !upgrade && ( + + )} +
+ {lock &&
{UPGRADE_TEXT.locked(EDIT_LOCK_TEXT[lock])}
} + {upgrade && } +
+ ) +} + +/** 预览里念节点和字段要用的几样:名字、升级前后的类型、升级前的配置 */ +interface UpgradeNames { + nameOf: NodeNameOf + /** 升级后的类型(新插入的节点也有) */ + typeOf: (id?: string | null) => string | undefined + /** 升级前的类型:换成报告撰写之后去掉的那几项(用户提示、角色设定)按原来的叫法念 */ + wasTypeOf: (id?: string | null) => string | undefined + configOf: (id?: string | null) => Record | undefined +} + +/** + * 节点 id → 名字和类型:先认画布上的,再认升级后的图里的(新插入的报告节点只在那里)。跟着 issues + * 和预览换新,不订阅 nodes:拖一下节点这一块不必跟着重渲染 + */ +function useUpgradeNames(flow: UpgradeFlow): UpgradeNames { + const tick = useStudio((s) => s.issues) + const after = flow.status === 'ready' ? flow.result.graph : null + const map = useMemo(() => { + const m = new Map() + for (const n of after?.nodes ?? []) m.set(n.id, { label: n.data?.label || n.id, type: n.type }) + // 画布上的名字优先,类型取升级后的:改的是「写报告」这个节点,字段按报告撰写的叫法念 + for (const n of useStudio.getState().nodes) { + m.set(n.id, { label: n.data.label || n.id, type: m.get(n.id)?.type ?? n.data.nodeType }) + } + return m + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tick, after]) + const nameOf = useCallback((id) => map.get(id)?.label, [map]) + const typeOf = useCallback((id?: string | null) => (id ? map.get(id)?.type : undefined), [map]) + // 列表项(成果字段第几项)按改之前的配置认名字:改的正是它 + const base = flow.status === 'ready' ? flow.base : null + const before = useCallback((id?: string | null) => (id ? base?.nodes.find((n) => n.id === id) : undefined), [base]) + const configOf = useCallback((id?: string | null) => before(id)?.data?.config, [before]) + const wasTypeOf = useCallback((id?: string | null) => before(id)?.type, [before]) + return { nameOf, typeOf, wasTypeOf, configOf } +} + +/** 升级预览:逐项「节点 · 字段:原值 → 新值」,节点类型的变化写成「模型调用 → 报告撰写」,说明原样列出 */ +function UpgradePreview({ flow, locked }: { flow: UpgradeFlow; locked: boolean }) { + const dirty = useStudio((s) => s.dirty) + const previewUpgrade = useStudio((s) => s.previewUpgrade) + const applyUpgrade = useStudio((s) => s.applyUpgrade) + const retryUpgradeSave = useStudio((s) => s.retryUpgradeSave) + const discard = useStudio((s) => s.discardUpgrade) + const names = useUpgradeNames(flow) + const { nameOf } = names + const T = UPGRADE_TEXT + const P = PUBLISH_FIX_TEXT + const box = 'mt-1.5 rounded-md border px-2.5 py-2' + const apply = async () => { + if (await applyUpgrade()) void useCatalog.getState().refresh() + } + const retrySave = async () => { + if (await retryUpgradeSave()) void useCatalog.getState().refresh() + } + + if (flow.status === 'loading') { + return ( +
+ + {flow.assist ? T.assisting : T.previewing} + +
+ ) + } + if (flow.status === 'unsupported' || flow.status === 'error') { + return ( +
+ + + {flow.status === 'unsupported' ? T.unsupported : `${T.failed}:${reasonOf(flow.error)}`} + + {flow.status === 'error' && !locked && ( + + )} + +
+ ) + } + + const { result, base } = flow + // 后端没给逐项改动时,按前后两张图自己列:照样逐项写,不替它编原因 + const changes = result.changes.length ? result.changes : result.graph ? upgradeChangesFromDiff(base, result.graph) : [] + const summary = result.graph ? upgradeSummary(base, result.graph) : [] + const left = result.issues.filter((i) => i.level === 'error').length + const usable = !!result.graph && changes.length > 0 + // 没采用的那几步,后端往往也在说明里写了一句「……:没有采用,原因」:说明里已经有这个原因的不再列一遍 + const rejected = result.rejected.filter((r) => !result.notes.some((n) => n.text.includes(r.reason))) + const a = flow.apply + const saving = a?.status === 'saving' + const unsaved = upgradeUnsaved(a) + return ( +
+
{changes.length ? T.title(changes.length) : T.noChange}
+ {summary.length > 0 &&
{T.summary(summary)}
} + {changes.length > 0 && ( +
+ {upgradeGroups(changes).map((g) => ( +
+ {g.label && ( +
+ {g.rule && } + {g.label} +
+ )} +
    + {g.items.map((c, i) => ( + + ))} +
+
+ ))} +
+ )} + {result.notes.length > 0 && ( +
+
{T.notes}
+
    + {result.notes.map((n, i) => ( +
  • + {n.node_id && !n.text.includes(`「${nameOf(n.node_id) ?? n.node_id}」`) && ( + 「{nameOf(n.node_id) ?? n.node_id}」 + )} + {n.text} +
  • + ))} +
+
+ )} + {rejected.length > 0 && ( +
+
{T.rejected}
+
    + {rejected.map((r, i) => ( +
  • + + {upgradeStepText(r.fix_id, nameOf)}:{r.reason} +
  • + ))} +
+
+ )} + {result.assist && (result.assist.summary || !!result.assist.questions?.length || !!result.assist.warnings?.length) && ( +
+ {result.assist.summary && ( +
+ + {P.assistSaid}:{result.assist.summary} +
+ )} + {!!result.assist.warnings?.length && ( +
+
{T.assistWarnings}
+
    + {result.assist.warnings.map((w, i) => ( +
  • + {w} +
  • + ))} +
+
+ )} + {!!result.assist.questions?.length && ( +
+
{P.questions}
+
    + {result.assist.questions.map((q, i) =>
  • {q}
  • )} +
+
+ )} +
+ )} + {usable && ( +
+ {left ? T.afterLeft(left) : T.afterOk} +
+ )} + + {a?.status === 'stale' && ( +
+ {T.stale} + {!locked && ( + + )} +
+ )} + {a?.status === 'locked' && !a.unsaved &&
{T.locked(a.why)}
} + {unsaved && ( +
+ + {a?.status === 'failed' ? `${P.saveFailed}:${reasonOf(a.error)}` : a?.status === 'locked' ? `${P.saveFailed}(${a.why})` : ''}。 + {T.saveFailedHint} + + {/* 放弃只收起这份预览:画布上的升级留着(要退回就撤销),之后按平常的保存来存 */} + + +
+ )} + {!unsaved && ( +
+ + {saving ? P.saving : locked ? '' : usable ? `${T.applyNote}${dirty ? `。${P.dirtyNote}` : ''}` : ''} + + {!flow.assist && !locked && ( + + )} + + {usable && !locked && ( + + )} +
+ )} +
+ ) +} + +/** 命中的改写规则:R1–R5 或 Copilot */ +function RuleTag({ rule }: { rule: string }) { + return {upgradeRuleText(rule)} +} + +/** 这个字段按哪种节点的叫法念:升级后的类型认得就用它,认不得(换类型时去掉的用户提示)用原来的 */ +function ownerType(field: string | null | undefined, id: string | null | undefined, names: UpgradeNames): string | undefined { + const now = names.typeOf(id) + const key = (field ?? '').split('.')[0].replace(/\[\d+\]$/, '') + const has = (type?: string) => !!type && !!NODE_DEFS[type as keyof typeof NODE_DEFS]?.fields.some((f) => f.key === key) + if (has(now)) return now + const was = names.wasTypeOf(id) + return has(was) ? was : now +} + +/** 一项改动。改接的连线、新插入的节点、换了的类型各有写法,其余和发布前修复的预览一样 */ +function UpgradeChangeRow({ change: c, names, showRule, showLabel }: { + change: UpgradeChange; names: UpgradeNames; showRule: boolean; showLabel: boolean +}) { + const { nameOf } = names + const kind = upgradeChangeKind(c) + const T = UPGRADE_TEXT + const where = c.node_title || (c.node_id ? nameOf(c.node_id) ?? c.node_id : '') || PUBLISH_FIX_TEXT.whole + const arrow = + let body: React.ReactNode + if (kind === 'type') { + body = ( + <> + 「{where}」 · {T.typeField}: + {upgradeTypeText(c.before)} + {arrow} + {upgradeTypeText(c.after)} + + ) + } else if (kind === 'node') { + body = <>{T.added}:{upgradeNodeText(c.after)} + } else if (kind === 'edge') { + // 连线本身带一个箭头,改接不再用箭头图标,写「改接为」,免得两种箭头混在一行里 + const from = c.before != null ? upgradeEdgeText(c.before, nameOf) : '' + const to = c.after != null ? upgradeEdgeText(c.after, nameOf) : '' + body = from && to + ? <>{T.edge}:{from} + {T.rewired}{to} + : to ? <>{T.addedEdge}:{to} + : <>{T.removedEdge}:{from} + } else { + const field = fixFieldLabel(c.field, ownerType(c.field, c.node_id, names), names.configOf(c.node_id)) + body = ( + <> + 「{where}」{field && ` · ${field}`}: + + {arrow} + + + ) + } + return ( +
  • + + {showRule && c.rule && } + {body} + + {showLabel && c.label && {c.label}} +
  • + ) +} + +/** 预览里的一个值:整份契约一键一行,别的压成一行;节点引用写节点名。和发布前修复的写法相同 */ +function UpgradeValue({ value, field, nameOf, className, ...data }: { + value: unknown; field?: string | null; nameOf: NodeNameOf; className?: string + 'data-upgrade-before'?: string; 'data-upgrade-after'?: string +}) { + const lines = fixValueLines(value, field, nameOf) + if (!lines) return {fixValueText(value, field, nameOf)} + return ( + + {lines.map((line, i) => {line})} + + ) +} + const SUB_LABEL: Record = { condition: '的条件', key: '的标识', tools: '的工具', system: '的角色设定' } function fieldLabel(field: FieldRef | null | undefined, type?: string, config?: Record): string { diff --git a/frontend/src/canvas/issues.ts b/frontend/src/canvas/issues.ts index f1ba3cbc..9e5b510a 100644 --- a/frontend/src/canvas/issues.ts +++ b/frontend/src/canvas/issues.ts @@ -11,10 +11,13 @@ * 那一栏,定位落到工具上(见 toolBindingOf)。 */ import { ApiError } from '../api/client' -import { APPROVAL_POLICY_LABEL, JUDGE_FIELD_LABEL, JUDGE_ON_UNSUPPORTED_LABEL } from '../lib/terms' +import { APPROVAL_POLICY_LABEL, JUDGE_FIELD_LABEL, JUDGE_ON_UNSUPPORTED_LABEL, nodeTypeLabel } from '../lib/terms' import { NODE_DEFS } from './nodeDefs' import type { FlowNode } from '../store/studio' -import type { AutofixResult, GraphSpec, PublishCheck, PublishFix, PublishLevel, ValidationIssue } from '../types' +import type { + AutofixResult, GraphEdge, GraphNode, GraphSpec, PublishCheck, PublishFix, PublishLevel, UpgradeChange, UpgradeNote, UpgradeResult, + ValidationIssue, +} from '../types' export interface FieldRef { /** 顶层配置键:prompt、cases、fields… 'label' 表示节点名称 */ @@ -171,9 +174,12 @@ export function problemsOf( issues: ValidationIssue[], nodes: FlowNode[], rank?: Record, ): Problem[] { const byId = new Map(nodes.map((n, i) => [n.id, { n, i }])) - const list: Problem[] = issues.map((issue, i) => { + // info 是建议(可以升级为可追溯结构),不是问题:不进清单、不占 F8 的跳转。编号按原下标, + // 过滤前后同一条问题的 id 不变 + const list: Problem[] = issues.flatMap((issue, i) => { + if (issue.level === 'info') return [] const hit = issue.node_id ? byId.get(issue.node_id) : undefined - return { + return [{ id: `${issue.node_id ?? issue.edge_id ?? 'graph'}:${i}`, level: issue.level, message: issue.message, @@ -181,7 +187,7 @@ export function problemsOf( nodeId: issue.node_id ?? undefined, edgeId: issue.edge_id ?? undefined, field: hit ? fieldOfIssue(issue, hit.n) : null, - } + }] }) const order = (p: Problem) => { if (p.scope !== 'node') return -1 @@ -203,6 +209,8 @@ const asText = (v: unknown): string => (typeof v === 'string' ? v : v == null ? function normalizeIssue(raw: any): ValidationIssue | null { if (!raw || typeof raw !== 'object' || !raw.message) return null + // 建议(info,比如「可以升级为可追溯结构」)不会让发布被拦,也不是要修的提示:不算进「另有 N 条提示」 + if (raw.level === 'info') return null return { level: raw.level === 'error' ? 'error' : 'warning', message: asText(raw.message), @@ -353,9 +361,10 @@ const CONTRACT_KEYS: Record = { const EXTRA_FIELDS: Record = { workflow_version: '钉住版本', upgrade_policy: '上游发了新版本时', defaults: '全图默认', } -/** 点号后面那一段的叫法:契约里的键,全图默认里的审批策略 */ +/** 点号后面那一段的叫法:契约里的键,全图默认里的审批策略,列表项里的名称、取值、表达式 */ const SUB_KEYS: Record = { ...CONTRACT_KEYS, approval: '审批策略', claims: '没挂依据的结论句', on_uncited: '没挂依据时', + name: '名称', value: '取值', expression: '表达式', } /** 契约 claims 的 on_uncited:没挂依据的结论句怎么算 */ const ON_UNCITED_LABEL: Record = { @@ -370,15 +379,24 @@ export type NodeNameOf = (id: string) => string | undefined const isRecord = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v) -/** 修复改的是哪个字段:顶层键用节点定义里的叫法,契约里的键另有一张表,结论句裁判(judge.*)另一张,其余原样 */ -export function fixFieldLabel(field: string | null | undefined, nodeType?: string): string { +/** + * 修复改的是哪个字段:顶层键用节点定义里的叫法,契约里的键另有一张表,结论句裁判(judge.*)另一张,其余原样。 + * 列表里的某一项(fields[0].value)写成「成果字段 · 「answer」 · 取值」:给了节点配置就认那一项的名字, + * 没给写「第 1 项」 + */ +export function fixFieldLabel(field: string | null | undefined, nodeType?: string, config?: Record): string { if (!field) return '' const [head, ...rest] = field.split('.') if (head === 'label') return '节点名称' + const at = /^([^[]+)\[(\d+)\]$/.exec(head) + const key = at ? at[1] : head const def = nodeType ? NODE_DEFS[nodeType as keyof typeof NODE_DEFS] : undefined - const base = def?.fields.find((f) => f.key === head)?.label ?? EXTRA_FIELDS[head] ?? head + const base = def?.fields.find((f) => f.key === key)?.label ?? EXTRA_FIELDS[key] ?? key + const item = at ? config?.[key]?.[Number(at[2])] : undefined + const name = item && typeof item === 'object' ? item.name ?? item.id ?? item.key : undefined + const which = at ? [typeof name === 'string' && name ? `「${name}」` : `第 ${Number(at[2]) + 1} 项`] : [] const sub = head === 'judge' ? JUDGE_FIELD_LABEL : SUB_KEYS - return [base, ...rest.map((k) => sub[k] ?? k)].join(' · ') + return [base, ...which, ...rest.map((k) => sub[k] ?? k)].join(' · ') } /** judge 里写 null 表示不限的三项上限:预览里 null 写「不限」,不写「(空)」——没写和不限是两回事 */ @@ -445,6 +463,10 @@ export function fixValueLines(value: unknown, field?: string | null, nameOf?: No return (x < 0 ? order.length : x) - (y < 0 ? order.length : y) }).map((k) => `${JUDGE_FIELD_LABEL[k] ?? k}:${fixValueText(value[k], `judge.${k}`, nameOf)}`) } + // 成果 / 输入字段整列(一键升级把成果字段改取报告的正文):一项一行,「answer:{{ nodes.write.text }}」 + if (field === 'fields' && Array.isArray(value) && value.length && value.every((f) => isRecord(f) && 'name' in f)) { + return value.map((f) => `${asText(f.name) || '(没名字)'}:${fixValueText(f.value)}`) + } if (field !== 'contract' || !isRecord(value) || !Object.keys(value).length) return null const order = Object.keys(CONTRACT_KEYS) const keys = Object.keys(value).sort((a, b) => { @@ -453,3 +475,178 @@ export function fixValueLines(value: unknown, field?: string | null, nameOf?: No }) return keys.map((k) => `${CONTRACT_KEYS[k] ?? k}:${fixValueText(value[k], `contract.${k}`, nameOf)}`) } + +// ------------------------------------------------------------------------- +// 一键升级为可追溯结构(POST /copilot/upgrade-evidence) +// +// 和发布前修复同一套收法:回来的东西收成确定的形状,缺字段就当没有。改动逐项给人看, +// 另认三种自动修复里没有的:节点类型的变化、新插入的节点、改接的连线。 +// ------------------------------------------------------------------------- + +/** validate 给的那条建议:旧结构(符合 R1–R4 任一条),可以升级 */ +export const UPGRADE_ADVICE = 'evidence.upgrade_available' +export const isUpgradeAdvice = (issue: Pick): boolean => issue.code === UPGRADE_ADVICE + +function normalizeNote(raw: any): UpgradeNote | null { + if (typeof raw === 'string') return raw.trim() ? { text: raw } : null + if (!isRecord(raw)) return null + const text = asText(raw.text ?? raw.message ?? raw.note) + if (!text) return null + return { + text, node_id: typeof raw.node_id === 'string' ? raw.node_id : null, + rule: raw.rule ? asText(raw.rule) : null, level: raw.level === 'warning' ? 'warning' : 'info', + } +} + +export function normalizeUpgrade(raw: any): UpgradeResult { + const graph = raw?.graph && typeof raw.graph === 'object' && Array.isArray(raw.graph.nodes) ? raw.graph as GraphSpec : null + const assist = isRecord(raw?.assist) + ? { + ok: !!raw.assist.ok, summary: asText(raw.assist.summary), + questions: asArray(raw.assist.questions).map(asText).filter(Boolean), + warnings: asArray(raw.assist.warnings).map(asText).filter(Boolean), + } + : null + return { + graph, + changes: asArray(raw?.changes).filter(isRecord).map((c) => ({ + fix_id: asText(c.fix_id ?? c.rule), rule: c.rule ? asText(c.rule) : null, + node_id: typeof c.node_id === 'string' ? c.node_id : null, node_title: c.node_title ? asText(c.node_title) : null, + field: typeof c.field === 'string' ? c.field : null, before: c.before, after: c.after, label: c.label ? asText(c.label) : null, + })), + notes: asArray(raw?.notes).map(normalizeNote).filter((n): n is UpgradeNote => !!n), + issues: asArray(raw?.issues).map(normalizeIssue).filter((i): i is ValidationIssue => !!i), + rejected: asArray(raw?.rejected).filter(isRecord) + .map((r) => ({ fix_id: asText(r.fix_id), reason: asText(r.reason) || '没说原因' })), + assist, + } +} + +const isNodeLike = (v: unknown): v is Partial => + isRecord(v) && typeof v.type === 'string' && (typeof v.id === 'string' || isRecord(v.data)) +const isEdgeLike = (v: unknown): v is Pick => + isRecord(v) && typeof v.source === 'string' && typeof v.target === 'string' + +/** 一项改动是哪一种:换了节点类型、新插入的节点、改接的连线,其余是改了某个配置 */ +export type UpgradeChangeKind = 'type' | 'node' | 'edge' | 'value' + +export function upgradeChangeKind(c: UpgradeChange): UpgradeChangeKind { + const f = c.field ?? '' + if (f === 'type' || f === 'node_type') return 'type' + if (f === 'node' || (!f && c.before == null && isNodeLike(c.after))) return 'node' + if (f === 'edge' || f === 'edges' || isEdgeLike(c.after) || isEdgeLike(c.before)) return 'edge' + return 'value' +} + +/** 节点类型写界面上的叫法:「模型调用」「报告撰写」;认不出的照写原码 */ +export const upgradeTypeText = (type: unknown): string => (typeof type === 'string' && type ? nodeTypeLabel(type) : '(空)') + +/** 新插入的节点:「「写报告」(报告撰写)」。后端的节点可能是图里的形状(data.label),也可能是操作流的(label) */ +export function upgradeNodeText(node: unknown): string { + if (!isNodeLike(node)) return fixValueText(node) + const label = asText((node as { label?: unknown }).label) || asText(node.data?.label) || asText(node.id) + return `「${label}」(${upgradeTypeText(node.type)})` +} + +/** 改写规则在界面上的叫法:R1–R5 照写,Copilot 那一段写 Copilot */ +export const upgradeRuleText = (rule?: string | null): string => (rule === 'assist' ? 'Copilot' : rule ?? '') + +/** + * 逐项改动按「哪一步」分组:同一步(同一个 fix_id,比如把「写周报」换成报告撰写)的几项改动放在一起, + * 这一步的说明只写一次。后端没给 fix_id 的(前端按两张图自己列的)各自一组、没有说明 + */ +export function upgradeGroups(changes: UpgradeChange[]): { key: string; rule: string | null; label: string | null; items: UpgradeChange[] }[] { + const out: { key: string; rule: string | null; label: string | null; items: UpgradeChange[] }[] = [] + changes.forEach((c, i) => { + const key = c.fix_id || `#${i}` + const last = out[out.length - 1] + if (last && c.fix_id && last.key === key) { + last.items.push(c) + return + } + out.push({ key, rule: c.rule ?? null, label: c.label ?? null, items: [c] }) + }) + return out +} + +/** + * 没采用的那一步是谁:「R1 · 「写周报」」「Copilot」。后端的 fix_id 是「规则:节点 id」, + * 认不出的照写 + */ +export function upgradeStepText(fixId: string, nameOf?: NodeNameOf): string { + if (!fixId || fixId === 'assist') return 'Copilot' + const m = /^(R\d+):(.+)$/.exec(fixId) + return m ? `${m[1]} · 「${nameOf?.(m[2]) ?? m[2]}」` : fixId +} + +/** 一条连线:「「取数」→「写报告」」。两头的节点名先在画布上找,再在升级后的图里找 */ +export function upgradeEdgeText(edge: unknown, nameOf?: NodeNameOf): string { + if (!isEdgeLike(edge)) return fixValueText(edge) + const name = (id: string) => `「${nameOf?.(id) ?? id}」` + return `${name(edge.source)} → ${name(edge.target)}` +} + +const edgeKey = (e: Pick) => `${e.source}\u0000${e.target}\u0000${e.sourceHandle ?? ''}` + +/** + * 后端没给逐项改动(或者老一点的形状只给了图)时,按前后两张图自己列:新插入的节点、换了类型的节点、 + * 改了的名字和每个顶层配置、加上和去掉的连线。和后端给的一样逐项写,不替它编原因 + */ +export function upgradeChangesFromDiff(before: GraphSpec, after: GraphSpec): UpgradeChange[] { + const out: UpgradeChange[] = [] + const old = new Map((before.nodes ?? []).map((n) => [n.id, n])) + for (const n of after.nodes ?? []) { + const o = old.get(n.id) + const title = n.data?.label ?? o?.data?.label ?? n.id + if (!o) { + out.push({ fix_id: '', node_id: n.id, node_title: title, field: 'node', before: null, after: n }) + continue + } + if (o.type !== n.type) out.push({ fix_id: '', node_id: n.id, node_title: title, field: 'type', before: o.type, after: n.type }) + if ((o.data?.label ?? '') !== (n.data?.label ?? '')) { + out.push({ fix_id: '', node_id: n.id, node_title: title, field: 'label', before: o.data?.label, after: n.data?.label }) + } + const a = o.data?.config ?? {} + const b = n.data?.config ?? {} + for (const key of [...new Set([...Object.keys(a), ...Object.keys(b)])]) { + if (JSON.stringify(a[key]) !== JSON.stringify(b[key])) { + out.push({ fix_id: '', node_id: n.id, node_title: title, field: key, before: a[key], after: b[key] }) + } + } + } + const was = new Set((before.edges ?? []).map(edgeKey)) + const now = new Set((after.edges ?? []).map(edgeKey)) + for (const e of before.edges ?? []) { + if (!now.has(edgeKey(e))) out.push({ fix_id: '', node_id: null, field: 'edge', before: { source: e.source, target: e.target }, after: null }) + } + for (const e of after.edges ?? []) { + if (!was.has(edgeKey(e))) out.push({ fix_id: '', node_id: null, field: 'edge', before: null, after: { source: e.source, target: e.target } }) + } + return out +} + +/** 升级前后差在哪,合成一句:「新增 1 个节点、1 个节点换了类型、改了 2 个节点的配置、连线变动 2 处」 */ +export function upgradeSummary(before: GraphSpec, after: GraphSpec): string[] { + const old = new Map((before.nodes ?? []).map((n) => [n.id, n])) + let added = 0 + let typed = 0 + let changed = 0 + for (const n of after.nodes ?? []) { + const o = old.get(n.id) + if (!o) { added += 1; continue } + if (o.type !== n.type) typed += 1 + else if ((o.data?.label ?? '') !== (n.data?.label ?? '') + || JSON.stringify(o.data?.config ?? {}) !== JSON.stringify(n.data?.config ?? {})) changed += 1 + } + const removed = (before.nodes ?? []).filter((n) => !(after.nodes ?? []).some((x) => x.id === n.id)).length + const was = new Set((before.edges ?? []).map(edgeKey)) + const now = new Set((after.edges ?? []).map(edgeKey)) + const edges = [...now].filter((k) => !was.has(k)).length + [...was].filter((k) => !now.has(k)).length + return [ + added && `新增 ${added} 个节点`, + typed && `${typed} 个节点换了类型`, + changed && `改了 ${changed} 个节点的配置`, + removed && `删掉 ${removed} 个节点`, + edges && `连线变动 ${edges} 处`, + ].filter((x): x is string => !!x) +} diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 784e296d..ec9fed84 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -636,6 +636,52 @@ export const PUBLISH_FIX_TEXT = { whole: '整张工作流', } as const +/** + * 一键升级为可追溯结构(问题面板的快速修复、记录页的横幅)。和发布前修复同一套规矩: + * 只出预览,人点「应用」才走现有的保存存成草稿;不发布 + */ +export const UPGRADE_TEXT = { + advice: '可以升级为可追溯结构', + adviceHint: '报告会改由「报告撰写」节点来写,每个数字、表名都能点开看出处。先看预览,确认了才改', + action: '升级为可追溯结构(预览改动)', + previewing: '正在算升级要改哪些地方…', + assisting: 'Copilot 正在改语义层,可能要几十秒…', + title: (n: number) => `升级预览 · ${n} 处改动`, + noChange: '这张工作流没有需要升级的地方', + summary: (parts: string[]) => `合计:${parts.join('、')}`, + added: '新增节点', + edge: '连线', + rewired: '改接为', + addedEdge: '新连线', + removedEdge: '去掉的连线', + typeField: '节点类型', + notes: '需要你确认的', + assist: '再交给 Copilot 改语义层', + assistHint: '把只做加减乘除的代码节点改写成口径卡的表达式;不是纯算术的保留原样并给出警告。它改的同样只是预览;会调用模型,可能要几十秒', + assistWarnings: '保留没改的', + rejected: '没有采用', + afterOk: '升级后校验和门禁都没有新的错误', + afterLeft: (n: number) => `升级后还有 ${n} 处错误,应用之后在问题面板里接着改`, + apply: '应用并保存', + applyNote: '应用后存成新的草稿版本;不会发布,正式运行要重新发布之后才用上', + saveFailedHint: '升级已经放到画布上(可以撤销),还没存进草稿', + discardUnsavedHint: '只收起这份预览:已经放到画布上的升级留着,要退回就撤销;之后按平常的保存来存', + stale: '预览之后画布又改过了,这份预览对不上了:按现在的画布重新预览一次', + again: '重新预览', + applied: (version?: number) => `已升级为可追溯结构${version != null ? `,存为草稿 v${version}` : ''}`, + appliedDraft: '已升级为可追溯结构:画布还没存成工作流,保存之后才算数', + saveNote: '升级为可追溯结构', + undoLabel: '升级为可追溯结构', + unsupported: '这个后端还不支持一键升级:打开助手描述一遍,或者照提示手动改', + failed: '升级预览没算出来', + locked: (why: string) => `${why}。现在不能升级`, + runBanner: '这次的报告没有逐段证据', + runAction: '升级这张图', + runHint: '打开编排页,先看升级要改哪些地方;确认了才改,改的是草稿', + runFormal: '正式运行跑的是已发布的版本:升级之后要重新发布,下一次正式运行才用得上', + runLocked: (why: string) => `${why}。现在不能升级这张图`, +} as const + /** 证据的种类怎么叫:aria-label、面板标题、出处那一句 */ export const EVIDENCE_KIND_LABEL: Record = { metric: '口径卡指标', diff --git a/frontend/src/pages/StudioPage.tsx b/frontend/src/pages/StudioPage.tsx index 4d0137cd..89b1f7d3 100644 --- a/frontend/src/pages/StudioPage.tsx +++ b/frontend/src/pages/StudioPage.tsx @@ -88,7 +88,7 @@ const PHASE_TEXT: Record = { export function StudioPage() { const { workflowId } = useParams() - const [params] = useSearchParams() + const [params, setParams] = useSearchParams() const location = useLocation() const navigate = useNavigate() // 逐个取:整个 catalog 订阅下来,连接心跳、待审批轮询每跳一次整页(连同检查器里每个字段)都要重渲染 @@ -276,6 +276,25 @@ export function StudioPage() { })() }, [workflow, workflowId, runParam, focusParam, attachRun, focusNode]) + // ?upgrade=1:记录页「这次的报告没有逐段证据 → 升级这张图」跳过来的。图打开之后打开问题面板、 + // 要一份升级预览(只读,要人点应用才存)。读完就摘掉:留着的话刷新、后退回来都会再要一次, + // 应用过之后再要,回来的是「没有需要升级的地方」。画布锁着(正式运行在跑、助手在改)不要,说清为什么 + const upgradeParam = params.get('upgrade') + const upgraded = useRef(null) + useEffect(() => { + if (!upgradeParam || !workflow || workflow.id !== workflowId) return + // 同一次跳转只要一次(开发模式下 effect 会被连跑两遍,摘参数的那次替换还没落地) + if (upgraded.current === location.key) return + upgraded.current = location.key + setParams((prev) => { + const next = new URLSearchParams(prev) + next.delete('upgrade') + return next + }, { replace: true }) + setDock('problems') + void useStudio.getState().previewUpgrade() + }, [upgradeParam, workflow, workflowId, setParams, location.key]) + // 开始跑图或生成时,属性面板让开——不然进展发生在一块被盖住的地方。 // (选中节点即滑出属性面板,取消选中即收起,不再需要手动切 tab) useEffect(() => { diff --git a/frontend/src/pages/runs/EvidencePane.tsx b/frontend/src/pages/runs/EvidencePane.tsx index 78cc956b..4938477d 100644 --- a/frontend/src/pages/runs/EvidencePane.tsx +++ b/frontend/src/pages/runs/EvidencePane.tsx @@ -1,21 +1,25 @@ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, } from 'react' -import { ChevronRight, Download, FileText } from 'lucide-react' +import { Link } from 'react-router-dom' +import { ArrowRight, ChevronRight, Download, FileText, Sparkles } from 'lucide-react' import clsx from 'clsx' import { ApiError, api } from '../../api/client' +import { isUpgradeAdvice } from '../../canvas/issues' import { ErrorState, Skeleton, Spinner, StatusBadge, rovingTarget, toast, useRadioGroup } from '../../components/ui' import { EVIDENCE_STATE, PROBLEM_GROUPS, auditCsv, auditFromApi, auditRows, groupRows, guessRows, guessesOf, issuanceMarks, segmentState, type AuditFilter, type AuditGroup, type AuditRow, } from '../../lib/evidence' import { formatNumber } from '../../lib/format' -import { EVIDENCE_AUDIT_TEXT as T } from '../../lib/terms' +import { EVIDENCE_AUDIT_TEXT as T, UPGRADE_TEXT } from '../../lib/terms' import { EvidenceDoc, type EvidenceDocHandle } from '../../run/EvidenceDoc' import { EvidenceGuessView } from '../../run/EvidenceGuess' import { Markdown, inlineStyles } from '../../run/Markdown' import { useEvidence } from '../../store/evidence' -import type { EvidenceAudit, EvidenceDocData, EvidenceGraph, Run } from '../../types' +import { useCatalog } from '../../store/catalog' +import { EDIT_LOCK_TEXT, editLockOf, useStudio } from '../../store/studio' +import type { EvidenceAudit, EvidenceDocData, EvidenceGraph, GraphSpec, Run } from '../../types' /** * 记录页的「证据」页签(方案 6.4):左边报告,右边常驻的证据面板,报告下面是整张审计表。 @@ -129,6 +133,7 @@ export function EvidencePane({ run, output, labelOf, refreshKey }: {
    + {mode === 'cited' && reports.map((r) => { const doc = docOf(r.node_id) @@ -183,6 +188,72 @@ export function EvidencePane({ run, output, labelOf, refreshKey }: { ) } +/** 已经收尾的运行:还在跑、停在审批上的,报告可能还没写,说「这次的报告没有逐段证据」太早 */ +const SETTLED = new Set(['succeeded', 'failed', 'cancelled']) + +/** + * 工作流现在的图能不能一键升级:拿它去校验,看有没有 evidence.upgrade_available 这条建议。老后端不给 + * 这条建议,横幅也就不出现(它也没有升级接口)。按工作流 id 和版本记住答案:同一张图的几条运行不重复问; + * 问失败了不记,下次打开再问 + */ +const upgradable = new Map>() +function useUpgradable(workflowId: string | null, version: number | undefined, graph: GraphSpec | undefined, enabled: boolean) { + const [yes, setYes] = useState(false) + useEffect(() => { + setYes(false) + if (!enabled || !workflowId || !graph?.nodes?.length) return + let alive = true + const key = `${workflowId}@${version ?? ''}` + let ask = upgradable.get(key) + if (!ask) { + ask = api.workflows.validate(graph).then((r) => (r.issues ?? []).some(isUpgradeAdvice)) + ask.catch(() => { if (upgradable.get(key) === ask) upgradable.delete(key) }) + upgradable.set(key, ask) + } + ask.then((v) => { if (alive) setYes(v) }, () => undefined) + return () => { alive = false } + // graph 跟着版本走:同一个版本的图不会变 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workflowId, version, enabled]) + return yes +} + +/** + * 没有逐段证据的运行(none、旧版出具、没有契约的旧运行):工作流现在的图可以升级的,给一条横幅, + * 跳到编排页打开升级预览。画布正锁着这张工作流(正式运行在跑、助手在改)时不给入口,说清为什么 + */ +function UpgradeBanner({ run, mode }: { run: Run; mode?: string }) { + const wf = useCatalog((s) => (run.workflow_id ? s.workflows.find((w) => w.id === run.workflow_id) : undefined)) + const legacy = mode === 'none' || !!mode?.startsWith('legacy') + const yes = useUpgradable(wf?.id ?? null, wf?.version, wf?.graph, legacy && SETTLED.has(run.status)) + const lock = useStudio((s) => (wf && s.workflow?.id === wf.id ? editLockOf(s) : null)) + if (!yes || !wf) return null + return ( +
    + + + {UPGRADE_TEXT.runBanner} + + {UPGRADE_TEXT.runHint}{run.run_class === 'formal' ? `。${UPGRADE_TEXT.runFormal}` : ''} + + + {lock ? ( + + {UPGRADE_TEXT.runLocked(EDIT_LOCK_TEXT[lock])} + + ) : ( + + {UPGRADE_TEXT.runAction} + + )} +
    + ) +} + /** 详情栏宽到能放下右栏吗:按容器量,不按屏幕(左边还有运行列表) */ function useWide(el: HTMLElement | null, min: number): boolean { const [wide, setWide] = useState(true) diff --git a/frontend/src/store/studio.ts b/frontend/src/store/studio.ts index cf62dce9..37a1279f 100644 --- a/frontend/src/store/studio.ts +++ b/frontend/src/store/studio.ts @@ -5,10 +5,12 @@ import { } from '@xyflow/react' import { api, streamCopilot, streamRun } from '../api/client' import { copilotReceipt, mergeNodeConfig, toolChangesOf } from '../canvas/copilotMerge' +import { contentSig, isMissingEndpoint, normalizeUpgrade } from '../canvas/issues' import { NODE_DEFS, sourceHandles } from '../canvas/nodeDefs' import { toast } from '../components/ui' import { humanizeError } from '../lib/errors' import { formatShortcut } from '../lib/keys' +import { UPGRADE_TEXT } from '../lib/terms' import type { CopilotOp } from '../run/decode' import { endingOf, reduceTeam, settleTeam } from '../run/decode' import { activeEdgesOf, applyDerived } from '../run/derive' @@ -18,7 +20,7 @@ import { } from '../run/trace' import type { ConversationTurn, CopilotCheckEntry, CopilotCheckIssue, CopilotCheckOp, GraphEdge, GraphSpec, NodeRuntime, NodeType, Run, - RunEvent, ToolChange, ValidationIssue, VarIssue, Variable, Workflow, + RunEvent, ToolChange, UpgradeResult, ValidationIssue, VarIssue, Variable, Workflow, } from '../types' export type FlowNode = Node<{ nodeType: NodeType; label: string; config: Record }> @@ -210,6 +212,30 @@ export const EDIT_LOCK_TEXT: Record, string> = { /** 组件里订阅锁:只在锁的原因变了时重渲染 */ export const useEditLock = (): EditLock => useStudio(editLockOf) +/** + * 一键升级为可追溯结构:预览摆着之后「应用」的那一段。saving 存草稿中;stale 预览之后画布又改过; + * locked 画布锁着没落下去(unsaved:已经落到画布上、要存时锁着);failed 落到画布上了、没存上 + */ +export type UpgradeApply = + | { status: 'saving' | 'stale' } + | { status: 'locked'; why: string; unsaved?: boolean } + | { status: 'failed'; error: unknown } + +/** 升级已经落到画布上、没存上:存失败,或者要存时画布锁着。面板给「重试保存」「放弃」,平常的保存存上了就清掉 */ +export function upgradeUnsaved(a: UpgradeApply | undefined): boolean { + return a?.status === 'failed' || (a?.status === 'locked' && !!a.unsaved) +} + +/** + * 升级预览的状态。seq 是第几次请求:面板据此认出「又开了一次预览」(切回校验页、滚到眼前)。 + * base 是发请求那一刻的图(后端没给逐项改动时按它和升级后的图自己列),sig 是它的内容签名 + */ +export type UpgradeFlow = + | { status: 'loading'; assist: boolean; seq: number } + | { status: 'ready'; assist: boolean; seq: number; result: UpgradeResult; sig: string; base: GraphSpec; apply?: UpgradeApply } + | { status: 'error'; assist: boolean; seq: number; error: unknown } + | { status: 'unsupported'; assist: boolean; seq: number } + /** 两张图差在哪。只看节点的名字和配置,挪位置不算改动 */ export interface GraphDiff { added: string[] @@ -480,7 +506,15 @@ interface StudioState { edges: Edge[] selectedId: string | null dirty: boolean + /** 校验出来的 error / warning。info 级的建议不在这里,在 advice */ issues: ValidationIssue[] + /** + * 校验给的建议(info 级,比如 evidence.upgrade_available「可以升级为可追溯结构」)。它们不是问题: + * 不点亮节点卡、不算进工具栏的计数,只在问题面板里给一个快速修复 + */ + advice: ValidationIssue[] + /** 一键升级的预览和应用。换图时清掉 */ + upgrade: UpgradeFlow | null /** 这张图里有哪些变量:谁产出、谁引用。结构不变就不重新请求 */ variables: Variable[] /** 变量层面的提示(含"产出了没人用"这类 info,不进 issues) */ @@ -580,6 +614,19 @@ interface StudioState { * 改的只是几个配置项,镜头一跳人就找不到刚才在看的节点了。锁着时不落、返回 false */ applyFixes: (graph: GraphSpec, label: string) => boolean + /** + * 一键升级:拿画布上此刻的图(连同全图默认)去要一份预览,只读。画布锁着时不要、说清为什么。 + * assist 时后端再交给 Copilot 改语义层 + */ + previewUpgrade: (opts?: { assist?: boolean }) => Promise + /** + * 应用预览:预览之后画布没改过才落(applyFixes,记一步撤销、全图默认照带),再走现有的保存存成草稿。 + * 返回存上了没有 + */ + applyUpgrade: () => Promise + /** 升级已经落到画布上、没存上(存失败、或者要存时画布锁着):再存一次 */ + retryUpgradeSave: () => Promise + discardUpgrade: () => void onNodesChange: (changes: NodeChange[]) => void onEdgesChange: (changes: EdgeChange[]) => void onConnect: (conn: Connection) => void @@ -879,7 +926,7 @@ async function runAnalysis( ): Promise { const { nodes, edges } = get() if (!nodes.length) { - set({ issues: [], variables: [], varIssues: [], analysis: 'ok', analysisError: null }) + set({ issues: [], advice: [], variables: [], varIssues: [], analysis: 'ok', analysisError: null }) return } const epoch = ++analyzeEpoch @@ -895,7 +942,12 @@ async function runAnalysis( // 图在请求飞行期间又改了,这份结果已经不对应眼前的图——丢掉, // 别用旧答案覆盖新答案(校验结果闪回是最难查的那种 UI bug) if (epoch !== analyzeEpoch) return - set({ issues: validation.issues ?? [], analysis: 'ok', analysisError: null }) + // 建议(info)另放:节点卡、检查器、运行按钮只认 error / warning,混进来会被当成提示点亮 + const all = validation.issues ?? [] + set({ + issues: all.filter((i) => i.level !== 'info'), advice: all.filter((i) => i.level === 'info'), + analysis: 'ok', analysisError: null, + }) if (vars) { lastVarSignature = signature set({ variables: vars.variables ?? [], varIssues: vars.issues ?? [] }) @@ -954,6 +1006,42 @@ function afterJump(set: SetFn, get: () => StudioState, nodes: FlowNode[], edges: void get().validate() } +/** 在途的升级预览请求:换图、放弃、再开一次时掐掉 */ +let upgradeCtl: AbortController | null = null +let upgradeSeq = 0 + +/** + * 升级已经落到画布上,存草稿。和页面自己的保存同一套规矩:画布锁着(助手在改、正式运行在跑)不存; + * 恢复旧版本留下的「回滚到 vN」不能被这一句盖掉,两句接着写 + */ +async function saveUpgrade(set: SetFn, get: () => StudioState): Promise { + const u = get().upgrade + if (u?.status !== 'ready') return false + const lock = editLockOf(get()) + if (lock) { + set({ upgrade: { ...u, apply: { status: 'locked', why: EDIT_LOCK_TEXT[lock], unsaved: true } } }) + return false + } + // 还没存成工作流的画布(问数据页「在画布里打开」的草稿)没有地方存:落到画布上就算完,照实说还没保存 + if (!get().workflow) { + set({ upgrade: null }) + toast.info(UPGRADE_TEXT.appliedDraft, { key: 'studio:upgrade' }) + return false + } + set({ upgrade: { ...u, apply: { status: 'saving' } } }) + const pending = get().pendingNote.trim() + try { + await get().save(pending ? `${pending};${UPGRADE_TEXT.saveNote}` : UPGRADE_TEXT.saveNote) + } catch (error) { + const cur = get().upgrade + if (cur?.status === 'ready' && cur.seq === u.seq) set({ upgrade: { ...cur, apply: { status: 'failed', error } } }) + return false + } + if (get().upgrade?.seq === u.seq) set({ upgrade: null }) + toast.ok(UPGRADE_TEXT.applied(get().workflow?.version), { key: 'studio:upgrade' }) + return true +} + /** 上次保存(或打开)时的图。撤销回这个样子时「未保存」要消失 */ let savedSig = '' @@ -1065,6 +1153,8 @@ export const useStudio = create((set, get) => ({ selectedId: null, dirty: false, issues: [], + advice: [], + upgrade: null, variables: [], varIssues: [], run: null, @@ -1110,8 +1200,11 @@ export const useStudio = create((set, get) => ({ graphExtras = extrasOf(workflow?.graph) savedSig = graphSig(nodes, edges) lastVarSignature = '' + // 上一张图的升级预览:套到这张图上就错了 + upgradeCtl?.abort() + upgradeCtl = null set({ - workflow, nodes, edges, selectedId: null, dirty: false, issues: [], + workflow, nodes, edges, selectedId: null, dirty: false, issues: [], advice: [], upgrade: null, // 变量表也清掉:不清的话切图后抽屉会先显示上一张图的变量 variables: [], varIssues: [], analysis: workflow ? 'pending' : 'idle', analysisError: null, ...runReset(), run: null, streaming: false, unsubscribe: null, @@ -1161,10 +1254,13 @@ export const useStudio = create((set, get) => ({ } const { nodes: cur, selectedId } = get() const byId = new Map(cur.map((n) => [n.id, n])) - // 已有的节点只换名字和配置:位置、量好的尺寸、选中都留着,React Flow 不用重新量一遍 + // 已有的节点只换类型、名字和配置:位置、量好的尺寸、选中都留着,React Flow 不用重新量一遍。 + // 类型也得跟着换——一键升级会把模型调用换成报告撰写,只换配置的话卡片还是模型调用,存下去的也是 const nodes = next.nodes.map((n) => { const old = byId.get(n.id) - return old ? { ...old, data: { ...old.data, label: n.data.label, config: n.data.config } } : n + return old + ? { ...old, data: { ...old.data, nodeType: n.data.nodeType, label: n.data.label, config: n.data.config } } + : n }) glideSeq++ commit(set, get, label) @@ -1177,6 +1273,69 @@ export const useStudio = create((set, get) => ({ return true }, + previewUpgrade: async ({ assist = false } = {}) => { + const lock = editLockOf(get()) + if (lock) { + toast.warn(EDIT_LOCK_TEXT[lock], { key: 'studio:readonly' }) + return + } + upgradeCtl?.abort() + const ctl = new AbortController() + upgradeCtl = ctl + const seq = ++upgradeSeq + const { nodes, edges, workflow } = get() + const graph = toGraph(nodes, edges) + // 受管的图升级完还要按受管重新发布:新写出来的报告节点直接按受管门禁的要求配 + const level = workflow?.status === 'governed' ? 'governed' : 'published' + set({ upgrade: { status: 'loading', assist, seq } }) + try { + const raw = await api.copilot.upgradeEvidence({ graph, level, ...(assist ? { assist: true } : {}) }, { signal: ctl.signal }) + if (ctl.signal.aborted || get().upgrade?.seq !== seq) return + set({ upgrade: { status: 'ready', assist, seq, result: normalizeUpgrade(raw), sig: contentSig(graph), base: graph } }) + } catch (e) { + if (ctl.signal.aborted || get().upgrade?.seq !== seq || (e as { name?: string } | null)?.name === 'AbortError') return + set({ upgrade: isMissingEndpoint(e) ? { status: 'unsupported', assist, seq } : { status: 'error', assist, seq, error: e } }) + } + }, + + applyUpgrade: async () => { + const u = get().upgrade + if (u?.status !== 'ready' || !u.result.graph || u.apply?.status === 'saving') return false + const { nodes, edges } = get() + // 预览是按发请求那一刻的画布算的:之后又改过的话,套上去会把那几下盖掉 + if (contentSig(toGraph(nodes, edges)) !== u.sig) { + set({ upgrade: { ...u, apply: { status: 'stale' } } }) + return false + } + if (!get().applyFixes(u.result.graph, UPGRADE_TEXT.undoLabel)) { + const lock = editLockOf(get()) + set({ upgrade: { ...u, apply: { status: 'locked', why: EDIT_LOCK_TEXT[lock ?? 'formal'] } } }) + return false + } + return saveUpgrade(set, get) + }, + + retryUpgradeSave: async () => { + const u = get().upgrade + if (u?.status !== 'ready' || !upgradeUnsaved(u.apply) || !u.result.graph) return false + // 画布上得还是升级完的样子才替它存:撤销过升级、或者之后又改过,再存下去的就不是这次升级了, + // 版本说明却还写着「升级为可追溯结构」。对不上就说这份预览过时了,要存按平常的保存来 + const { nodes, edges } = get() + // 升级后的图按 applyFixes 落画布的同一套换算一遍再比(没写名字的节点会补上默认名) + const applied = toFlow(u.result.graph) + if (contentSig(toGraph(nodes, edges)) !== contentSig(toGraph(applied.nodes, applied.edges))) { + set({ upgrade: { ...u, apply: { status: 'stale' } } }) + return false + } + return saveUpgrade(set, get) + }, + + discardUpgrade: () => { + upgradeCtl?.abort() + upgradeCtl = null + set({ upgrade: null }) + }, + onNodesChange: (changes) => { if (locked(get)) { // 锁定期间只放行量尺寸和选中:前者是 React Flow 自己的,后者不改图 @@ -1440,6 +1599,11 @@ export const useStudio = create((set, get) => ({ savedSig = JSON.stringify(graph) // 请求在途时又改了的话,那部分还没存:不能把「未保存」清掉 set({ workflow: updated, dirty: graphSig(get().nodes, get().edges) !== savedSig, pendingNote: '' }) + // 升级落到画布上之后没存上(存失败、要存时锁着),这回平常的保存(⌘S、工具栏)存上了:画布上是什么就存了什么, + // 升级要么跟着存进去了、要么已经撤销掉了,「草稿没保存上」都不再是真的。留着的话再点「重试保存」 + // 会多存一版,版本说明还写着「升级为可追溯结构」 + const u = get().upgrade + if (u?.status === 'ready' && upgradeUnsaved(u.apply)) set({ upgrade: null }) }, /** @@ -1456,7 +1620,7 @@ export const useStudio = create((set, get) => ({ validate: async () => { const { nodes, analysis } = get() if (!nodes.length) { - set({ issues: [], variables: [], varIssues: [], analysis: 'ok', analysisError: null }) + set({ issues: [], advice: [], variables: [], varIssues: [], analysis: 'ok', analysisError: null }) return } // 已经有一份结论时不翻回「校验中」:连续打字会让工具栏的标签一直闪。 diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 19be2469..ee7d19d3 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -298,7 +298,11 @@ export interface KbDocument { } export interface ValidationIssue { - level: 'error' | 'warning' + /** + * info 是建议,不是问题(比如 evidence.upgrade_available「可以升级为可追溯结构」):store 在校验回来时 + * 就把它分到 advice 里,issues 里只有 error / warning——节点卡、检查器、运行按钮都只认这两档 + */ + level: 'error' | 'warning' | 'info' node_id?: string | null edge_id?: string | null message: string @@ -366,6 +370,38 @@ export interface AutofixResult { ok: boolean } +/** + * 一键升级预览里的一项改动。形状同自动修复的 AutofixChange,另认这几种: + * field 为 type 是节点类型的变化(llm → report);field 为 node 是新插入的节点(after 是节点); + * field 为 edge 是连线的改接(before / after 是 {source, target})。rule 是命中的改写规则(R1–R4;Copilot 那一段是 assist)。 + * 同一步(同一个 fix_id)的几项改动共用一句 label + */ +export interface UpgradeChange extends AutofixChange { + rule?: string | null +} + +/** 升级给的说明:不改、只建议的(R5:代码节点要不要标成 source),原样给人看 */ +export interface UpgradeNote { + text: string + node_id?: string | null + rule?: string | null + level?: 'info' | 'warning' +} + +/** POST /copilot/upgrade-evidence:只给预览,不自动保存 */ +export interface UpgradeResult { + /** 升级后的整张图;人确认后走现有的保存 */ + graph: GraphSpec | null + changes: UpgradeChange[] + notes: UpgradeNote[] + /** 升级后的图重跑 validate 和门禁剩下的问题 */ + issues: ValidationIssue[] + /** 没有采用的改动(Copilot 的改写冒出新问题、碰了禁止规则),写明原因 */ + rejected: { fix_id: string; reason: string }[] + /** assist 时 Copilot 的那一段:非纯算术、保留没改的代码节点在 warnings 里 */ + assist: { ok: boolean; summary?: string; questions?: string[]; warnings?: string[] } | null +} + /** 单个节点在一次运行中的实时状态,驱动画布上的高亮。 */ export interface NodeRuntime { /** cancelled / suspended 是终态清扫时收的:运行被取消或服务重启时还没跑完的节点 */ diff --git a/scripts/check-publish.mjs b/scripts/check-publish.mjs index 40df988b..8a3f135c 100644 --- a/scripts/check-publish.mjs +++ b/scripts/check-publish.mjs @@ -1,5 +1,7 @@ // 发布前检查与自动修复的检查:发布弹窗打开就列问题、逐条修(auto / choice / assist)、 // 先预览再「应用并重新检查」、问题面板里的「发布前检查」、老后端(两个接口 404)照旧。 +// 另有一键升级为可追溯结构(可点击证据五期):问题面板的快速修复、升级预览、应用走现有的保存、 +// 从记录页横幅跳过来(?upgrade=1)。升级接口和校验给的那条建议同样在浏览器里伪造。 // // 不写库:工作流、保存、发布、publish-check、autofix 全在浏览器里用 page.route 伪造。 // 伪造的检查接口按请求里的图现算(下面的 lint),所以修完、存完、再查,结论会真的变; @@ -117,6 +119,35 @@ const HANDOFF = { edges: chain('in', 'calc', 'caliber', 'copilot', 'done'), } +/** + * 一键升级的旧结构(R1、R4、R5 都有):出具契约的 narrative 指着一个模型调用节点;取数的 agent 配了 + * output_schema 没开 cite_fields;口径卡还吃一个代码节点的产出。带全图默认,升级、保存都得把它带着 + */ +const LEGACY = { + nodes: [ + node('in', 'input', 0, '入口', { fields: [{ name: 'week', required: true }] }), + node('fetch', 'agent', 240, '取数', { prompt: '查 orders 本周的 gmv', tools: ['db_query__shop'], assign_to: 'kpi', + output_schema: { type: 'object', properties: { gmv: { type: 'number' } } } }), + node('calc', 'code', 240, '计算', { code: 'print(1)', assign_to: 'calc' }), + node('caliber', 'metrics', 480, '周报口径', { caliber: '周报口径', caliber_version: 'v1', + metrics: [{ id: 'gmv', name: '销售额', expression: 'vars.kpi.gmv' }, { id: 'ratio', name: '比例', expression: 'vars.calc.ratio' }] }), + node('story', 'llm', 720, '写周报', { system: '你是分析师', prompt: '按口径写本周周报' }), + node('done', 'output', 960, '成果', { fields: [{ name: 'report', value: '{{ nodes.story.text }}' }], + contract: { metrics_from: ['caliber'], narrative: '{{ nodes.story.text }}', required: ['gmv'], strict: true } }), + ], + edges: [...chain('in', 'fetch', 'caliber', 'story', 'done'), ...chain('in', 'calc', 'caliber')], + defaults: { approval: 'always', model: 'demo-model' }, +} +/** 问数据页的典型图(R3):输入 → agent → 成果,升级要在 agent 和成果之间插一个报告撰写节点 */ +const ASK = { + nodes: [ + node('in', 'input', 0, '问题', { fields: [{ name: 'question', required: true }] }), + node('fetch', 'agent', 240, '取数', { prompt: '{{ input.question }}', tools: ['db_query__shop'] }), + node('done', 'output', 480, '成果', { fields: [{ name: 'answer', value: '{{ nodes.fetch.text }}' }] }), + ], + edges: chain('in', 'fetch', 'done'), +} + const wf = (id, name, graph, extra = {}) => ({ id, name, description: '检查脚本伪造的工作流', graph, tags: [], version: 3, is_template: false, status: 'draft', published_version: null, published_by: null, run_count: 0, @@ -131,6 +162,8 @@ const FAKES = { 'pf-handoff': wf('pf-handoff', '__publish_check_handoff__', HANDOFF, { status: 'governed', published_version: 2 }), // 带全图默认的图:画布只管节点和连线,保存、检查、修复都得把 defaults 原样带着 'pf-defs': wf('pf-defs', '__publish_check_defaults__', { ...EASY, defaults: { approval: 'always', model: 'demo-model' } }), + 'pf-legacy': wf('pf-legacy', '__upgrade_check_legacy__', LEGACY, { status: 'published', published_version: 3 }), + 'pf-ask': wf('pf-ask', '__upgrade_check_ask__', ASK), } const clone = (x) => JSON.parse(JSON.stringify(x)) @@ -293,6 +326,82 @@ function autofix(body, { unpublished = false, followsNever = false } = {}) { return { graph, changes, applied, rejected, handoff, remaining: remaining.issues, assist, ok: remaining.ok, ops: [] } } +/** + * 伪造的校验建议:图里还有模型调用节点,或者 agent 的文字直接进了成果(问数据的图),就给一条 info + * 「可以升级为可追溯结构」。升级完(节点换成了报告撰写、插了报告节点)就没有了 + */ +function upgradeAdvice(graph) { + const byId = new Map(graph.nodes.map((n) => [n.id, n])) + const old = graph.nodes.some((n) => n.type === 'llm') + || graph.edges.some((e) => byId.get(e.source)?.type === 'agent' && byId.get(e.target)?.type === 'output') + return old ? [{ level: 'info', code: 'evidence.upgrade_available', node_id: null, field: null, message: '可以升级为可追溯结构' }] : [] +} + +/** R5、R3 的说明原文:原样显示,检查逐字比对 */ +const R5_NOTE = '「计算」的产出被口径卡引用:它要是只在取数、不做计算,可以标成 source(取数);标不标由你确认,这次没有改它' +const R3_NOTE = '报告会直接引用查询单元格。要按受管级别正式出具,得在出具契约里写 cells: true,或者改走口径卡:选哪条要你拿主意,升级没有替你加契约' + +/** + * 伪造的升级接口,形状照 E5-back 的 engine/upgrade.py:一步(fix_id = 规则:节点)的几项改动共用一句 label; + * 换类型是 field type,新插入的节点是 field node(after 是 {id, type, label, config}),连线是 field edge + * (改接的两头都有),成果字段整列改。bare:只给图、不给 changes(前端按前后两张图自己列)。 + * assist:Copilot 的一段总结和保留没改的代码节点 + */ +function upgradeFake(body, { bare = false } = {}) { + const graph = clone(body.graph) + const changes = [] + const notes = [] + const at = (id) => graph.nodes.find((n) => n.id === id) + const step = (rule, id, label, list) => changes.push(...list.map((c) => ({ fix_id: `${rule}:${id}`, rule, label, ...c }))) + const story = at('story') + if (story?.type === 'llm') { + const c = story.data.config + const instructions = `${c.system}\n\n${c.prompt}` + story.type = 'report' + story.data.config = { instructions } + const done = at('done') + const before = clone(done.data.config.contract) + done.data.config.contract = { metrics_from: ['caliber'], report_from: 'story', required: ['gmv'], strict: true } + step('R1', 'story', '把「写周报」换成报告撰写,出具契约改为核对它的文档', [ + { node_id: 'story', node_title: '写周报', field: 'type', before: 'llm', after: 'report' }, + { node_id: 'story', node_title: '写周报', field: 'instructions', before: null, after: instructions }, + { node_id: 'story', node_title: '写周报', field: 'system', before: c.system, after: null }, + { node_id: 'story', node_title: '写周报', field: 'prompt', before: c.prompt, after: null }, + { node_id: 'done', node_title: '成果', field: 'contract', before, after: done.data.config.contract }, + ]) + } + const fetch = at('fetch') + const done = at('done') + if (!at('report') && fetch && done && graph.edges.some((e) => e.source === 'fetch' && e.target === 'done')) { + const config = { instructions: '{{ input.question }}' } + graph.nodes.push({ id: 'report', type: 'report', position: { x: 480, y: 240 }, data: { label: '报告撰写', config } }) + done.position = { x: 720, y: 120 } + graph.edges = graph.edges.filter((e) => !(e.source === 'fetch' && e.target === 'done')) + graph.edges.push({ id: 'fetch:->report', source: 'fetch', target: 'report' }, { id: 'report:->done', source: 'report', target: 'done' }) + const was = clone(done.data.config.fields) + done.data.config.fields = was.map((f) => ({ ...f, value: '{{ nodes.report.text }}' })) + step('R3', 'fetch', '在「取数」和「成果」之间插入报告撰写,出口改取报告的正文', [ + { node_id: 'report', node_title: '报告撰写', field: 'node', before: null, after: { id: 'report', type: 'report', label: '报告撰写', config } }, + { node_id: null, node_title: '', field: 'edge', before: null, after: { source: 'fetch', target: 'report' } }, + { node_id: null, node_title: '', field: 'edge', before: { source: 'fetch', target: 'done' }, after: { source: 'report', target: 'done' } }, + { node_id: 'done', node_title: '成果', field: 'fields', before: was, after: done.data.config.fields }, + ]) + notes.push({ rule: 'R3', node_id: 'report', level: 'info', text: R3_NOTE }) + } + if (fetch?.data.config.output_schema && !fetch.data.config.cite_fields) { + fetch.data.config.cite_fields = true + step('R4', 'fetch', '打开「取数」的按出处核对字段', [ + { node_id: 'fetch', node_title: '取数', field: 'cite_fields', before: null, after: true }]) + } + if (at('calc')) notes.push({ rule: 'R5', node_id: 'calc', level: 'info', text: R5_NOTE }) + const assist = body.assist + ? { ok: true, summary: '「计算」调用了日期函数,不是纯算术:按规矩保留原样', questions: [], + warnings: ['「计算」不是纯算术(用了 datetime),保留原样,没有改成口径卡表达式'] } + : null + return { graph, changes: bare ? [] : changes, ops: [], notes, issues: [], applied: [...new Set(changes.map((c) => c.fix_id))], + rejected: [], assist, ok: true } +} + // ---------------------------------------------------------------- 浏览器 const browser = await chromium.launch({ executablePath: CHROME }) @@ -302,17 +411,28 @@ const writes = [] * backend:'new'(两个接口都有)| 'old'(两个接口 404)| 'nofix'(有检查、没有 autofix) * save:'ok' | 'fail'(保存接口 500)| 'slow'(1.5 秒后才回,看保存进行中的样子) */ -async function open({ id = 'pf-hard', width = 1440, height = 900, backend = 'new', save = 'ok', unpublished = false, fixDelay = 0 } = {}) { +/** + * upgrade(一键升级那几段):'new' 有接口 | 'old' 接口 404 | 'bare' 只回图不回 changes; + * advice:校验给不给那条建议(老后端不给);path:打开的地址(默认 /studio/) + */ +async function open({ id = 'pf-hard', width = 1440, height = 900, backend = 'new', save = 'ok', unpublished = false, fixDelay = 0, + upgrade = 'new', advice = true, path = null, upgradeDelay = 0 } = {}) { const followsNever = id === 'pf-dup' const ctx = await browser.newContext({ viewport: { width, height } }) opened.add(ctx) await ctx.addInitScript(() => { try { localStorage.setItem('agentlab_actor', '检查脚本') } catch { /* noop */ } }) + // 升级那几段:问题面板拉高一些,预览一屏看得全(截图也看得清) + if (id === 'pf-legacy' || id === 'pf-ask') { + await ctx.addInitScript(() => { try { localStorage.setItem('agentlab.studio.dockHeight', '460') } catch { /* noop */ } }) + } const page = await ctx.newPage() page.setDefaultTimeout(8000) const errors = [] page.on('pageerror', (e) => errors.push(e.message)) const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }) - const state = { checks: [], autofixes: [], patches: [], publishes: [], version: FAKES[id].version, graph: clone(FAKES[id].graph), save, backend } + // 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' } await page.route(/\/api\//, (route) => { const req = route.request() @@ -321,6 +441,19 @@ async function open({ id = 'pf-hard', width = 1440, height = 900, backend = 'new return json(route, { detail: '检查脚本不写库' }, 409) }) await page.route(/\/api\/workflows\/(validate|variables)$/, (route) => route.continue()) + // 一键升级的两张图:校验按图现算那条建议(真后端怎么说不影响这几段) + if (id === 'pf-legacy' || id === 'pf-ask') { + await page.route(/\/api\/workflows\/validate$/, (route) => state.validate === 'fail' + ? json(route, { detail: '校验服务暂时连不上' }, 500) + : json(route, { ok: true, issues: advice ? upgradeAdvice(route.request().postDataJSON()?.graph ?? { nodes: [], edges: [] }) : [] })) + } + await page.route(/\/api\/copilot\/upgrade-evidence$/, async (route) => { + const body = route.request().postDataJSON() + state.upgrades.push(body) + if (state.upgrade === 'old') return json(route, { detail: 'Not Found' }, 404) + if (upgradeDelay) await new Promise((r) => setTimeout(r, upgradeDelay)) + return json(route, upgradeFake(body, { bare: state.upgrade === 'bare' })) + }) await page.route(/\/api\/workflows(\?.*)?$/, async (route) => { if (route.request().method() !== 'GET') return route.fallback() const real = await route.fetch().then((r) => r.json()).catch(() => []) @@ -377,7 +510,8 @@ async function open({ id = 'pf-hard', width = 1440, height = 900, backend = 'new return json(route, { id: 'pf-conv', title: '', kind: 'canvas', archived: false, turn_count: 0, last_question: '', turns: [] }) }) - await page.goto(`${WEB}/studio/${id}`, { waitUntil: 'networkidle' }) + // 带参数进来(?upgrade=1)时不等网络静下来:参数没摘掉的话页面会一直重复请求,要让下面的检查说出是哪一条 + await page.goto(`${WEB}${path ?? `/studio/${id}`}`, { waitUntil: path ? 'load' : 'networkidle' }) await page.waitForFunction((wid) => window.__studio?.getState().workflow?.id === wid, id, { timeout: 15000 }) await page.waitForTimeout(400) return { ctx, page, errors, state } @@ -877,6 +1011,408 @@ await section('choice 里选「交给 Copilot」:请求带 assist、等待时 check('没有页面错误', !errors.length && !errors2.length, [...errors, ...errors2].join(' | ')) }) +// ================================================================ 一键升级为可追溯结构(五期) + +const dock = (page) => page.locator('#dock-problems') +async function openProblems(page) { + await page.locator('.react-flow__pane').click({ position: { x: 30, y: 30 } }) + await page.keyboard.press('Alt+KeyP') + await dock(page).waitFor() +} +const upgradeBox = (page) => dock(page).locator('[data-upgrade]') +const preview = (page) => dock(page).locator('[data-upgrade-preview]') +const flat = (t) => String(t ?? '').replace(/\s+/g, ' ').trim() + +await section('升级:建议不算问题,问题面板给快速修复;预览逐项写改动,类型变化写清楚,说明原样', async () => { + const { page, state, errors } = await open({ id: 'pf-legacy' }) + await waitFor(page, async () => page.evaluate(() => window.__studio.getState().advice.length > 0)) + const chip = flat(await page.locator('button.chip').first().innerText().catch(() => '')) + const st = await page.evaluate(() => { const s = window.__studio.getState(); return { issues: s.issues.length, advice: s.advice.map((a) => a.code) } }) + check('校验给的建议进 advice,不进 issues(节点卡、检查器、运行按钮只认 error / warning)', st.issues === 0 + && JSON.stringify(st.advice) === '["evidence.upgrade_available"]', JSON.stringify(st)) + check('工具栏照样说「可运行」:建议不算提示', chip.includes('可运行'), chip.slice(0, 120)) + const kept = await page.evaluate(async () => { + // 按页面自己加载时的地址 import:改过的模块地址带 ?t=,直接写 /src/… 会拿到另一份实例 + const url = performance.getEntriesByType('resource').map((e) => e.name) + .find((n) => { try { return new URL(n).pathname === '/src/canvas/issues.ts' } catch { return false } }) + const m = await import(url ?? '/src/canvas/issues.ts') + const info = { level: 'info', code: 'evidence.upgrade_available', message: '可以升级为可追溯结构', node_id: null } + const warn = { level: 'warning', code: null, message: '一条提示', node_id: null } + return { problems: m.problemsOf([info, warn], []).map((p) => p.level), check: m.normalizeCheck({ issues: [info, warn], fixes: [] }, 'published').issues.length } + }).catch((e) => ({ error: String(e) })) + check('建议不进问题清单,也不算进发布前检查的「另有 N 条提示」', JSON.stringify(kept) === JSON.stringify({ problems: ['warning'], check: 1 }), JSON.stringify(kept)) + await openProblems(page) + check('问题面板:没有问题行,但有「可以升级为可追溯结构」', await dock(page).locator('[data-problem]').count() === 0 + && (await upgradeBox(page).locator('[data-upgrade-advice]').innerText().catch(() => '')).includes('可以升级为可追溯结构')) + const action = upgradeBox(page).locator('[data-upgrade-action]') + check('快速修复写「升级为可追溯结构(预览改动)」', flat(await action.innerText().catch(() => '')) === '升级为可追溯结构(预览改动)', + await action.innerText().catch(() => '')) + check('……「没有发现问题」照样说', (await dock(page).innerText()).includes('没有发现问题')) + check('还没点之前不请求升级接口', state.upgrades.length === 0) + await action.click() + await page.locator('#dock-problems [data-upgrade-preview="ready"]').waitFor() + const req = state.upgrades[0] + check('请求带着画布上的整张图(连同全图默认),不带 assist', req?.graph?.nodes?.length === LEGACY.nodes.length + && req?.graph?.edges?.length === LEGACY.edges.length && req?.graph?.defaults?.approval === 'always' && !('assist' in (req ?? {})), + JSON.stringify({ n: req?.graph?.nodes?.length, d: req?.graph?.defaults, a: req?.assist })) + const typeRow = flat(await preview(page).locator('[data-upgrade-change="type"]').innerText().catch(() => '')) + check('节点类型的变化写清楚:「写周报」· 节点类型:模型调用 → 报告撰写', /^「写周报」 · 节点类型:\s*模型调用\s*报告撰写$/.test(typeRow) + && flat(await preview(page).locator('[data-upgrade-change="type"] [data-upgrade-before]').innerText()) === '模型调用' + && flat(await preview(page).locator('[data-upgrade-change="type"] [data-upgrade-after]').innerText()) === '报告撰写', typeRow) + const r1 = flat(await preview(page).locator('[data-upgrade-step="R1"] [data-upgrade-step-label]').innerText().catch(() => '')) + check('同一步的几项改动放在一组,规则(R1)和这一步的说明只写一次', r1 === 'R1 把「写周报」换成报告撰写,出具契约改为核对它的文档' + && await preview(page).locator('[data-upgrade-step="R1"] [data-upgrade-change]').count() === 5 + && !(await preview(page).locator('[data-upgrade-step="R1"] ul').innerText()).includes('出具契约改为核对它的文档'), r1) + const rows = (await preview(page).locator('[data-upgrade-change]').allInnerTexts()).map(flat) + const contract = rows.find((r) => r.includes('出具契约')) ?? '' + check('契约改动按键一行写、节点引用写节点名(报告来自:「写周报」)', contract.includes('「成果」 · 出具契约') && contract.includes('报告来自:「写周报」') + && contract.includes('叙述:{{ nodes.story.text }}'), contract) + const cite = rows.find((r) => r.includes('取数')) ?? '' + check('R4 用字段的界面叫法:「取数」· 按出处核对字段:(空)→ 是', /「取数」 · 按出处核对字段:\s*(空)\s*是/.test(cite), cite) + const instructions = rows.find((r) => r.includes('写作要求')) ?? '' + check('换了类型的节点,新字段按新类型的叫法念(写作要求)', instructions.includes('「写周报」 · 写作要求') && instructions.includes('按口径写本周周报'), instructions) + const prompt = rows.find((r) => r.includes('按口径写本周周报') && !r.includes('写作要求')) ?? '' + check('……换掉类型时去掉的,按原来的叫法念(用户提示:… → (空))', /「写周报」 · 用户提示:\s*按口径写本周周报\s*(空)/.test(prompt), prompt) + check('一共 6 处改动,标题写着', flat(await preview(page).locator('.font-medium').first().innerText()) === '升级预览 · 6 处改动') + check('合计:1 个节点换了类型、改了 2 个节点的配置', flat(await preview(page).locator('[data-upgrade-summary]').innerText().catch(() => '')) + === '合计:1 个节点换了类型、改了 2 个节点的配置') + const note = await preview(page).locator('[data-upgrade-note="R5"] [data-upgrade-note-text]').innerText().catch(() => '') + check('说明原样显示(R5:代码节点要不要标 source,等人确认)', note === R5_NOTE && await preview(page).locator('[data-upgrade-note]').count() === 1, note) + check('升级后没有新的错误:照实说', flat(await preview(page).locator('[data-upgrade-after-check]').innerText().catch(() => '')) === '升级后校验和门禁都没有新的错误') + check('预览不改画布、不保存', state.patches.length === 0 + && await page.evaluate(() => window.__studio.getState().nodes.find((n) => n.id === 'story').data.nodeType) === 'llm') + check('预览开着时快速修复按钮收起(不重复开)', await upgradeBox(page).locator('[data-upgrade-action]').count() === 0) + await shoot(page, 'upgrade-preview', page.locator('section[aria-label="问题"]')) + await preview(page).locator('[data-upgrade-discard]').click() + check('放弃:预览收起,快速修复回来,画布没动', await preview(page).count() === 0 && await upgradeBox(page).locator('[data-upgrade-action]').count() === 1 + && state.patches.length === 0) + // 问题面板停在「发布前检查」时又开了一次升级预览(记录页的横幅跳过来):切回「校验」,预览看得见 + await dock(page).locator('[data-problems-mode="publish"]').click() + await dock(page).locator('[data-preflight="panel"]').waitFor() + await page.evaluate(() => window.__studio.getState().previewUpgrade()) + await page.locator('#dock-problems [data-upgrade-preview="ready"]').waitFor({ timeout: 4000 }).catch(() => {}) + check('停在发布前检查时开了升级预览:切回「校验」,预览摆着', await dock(page).locator('[data-problems-mode="lint"]').getAttribute('aria-checked') === 'true' + && await page.locator('#dock-problems [data-upgrade-preview="ready"]').count() === 1) + check('没有运行时报错', errors.length === 0, errors.join(' | ')) +}) + +await section('升级:应用走现有的保存,全图默认不丢,节点类型真的换了', async () => { + const { page, state, errors } = await open({ id: 'pf-legacy' }) + await page.evaluate(() => window.__studio.setState({ pendingNote: '回滚到 v2' })) + await openProblems(page) + await upgradeBox(page).locator('[data-upgrade-action]').click() + await page.locator('[data-upgrade-preview="ready"]').waitFor() + await preview(page).locator('[data-upgrade-apply]').click() + await waitFor(page, async () => state.patches.length === 1 && await preview(page).count() === 0) + const saved = state.patches[0]?.graph + const story = saved?.nodes?.find((n) => n.id === 'story') + check('保存的载荷:「写周报」换成报告撰写,写作要求是合成的', story?.type === 'report' && story?.data?.config?.instructions === '你是分析师\n\n按口径写本周周报', + JSON.stringify(story)) + check('……契约改成 report_from,required / strict 原样', JSON.stringify(cfgOf(saved, 'done')?.contract) + === JSON.stringify({ metrics_from: ['caliber'], report_from: 'story', required: ['gmv'], strict: true })) + check('……全图默认原样带着(画布只管节点和连线,保存照样不能丢)', JSON.stringify(saved?.defaults) === JSON.stringify(LEGACY.defaults), + JSON.stringify(saved?.defaults ?? null)) + check('……节点和连线一个没少', saved?.nodes?.length === LEGACY.nodes.length && saved?.edges?.length === LEGACY.edges.length) + check('版本说明:「回滚到 v2」保留,后面接着写升级', state.patches[0]?.note === '回滚到 v2;升级为可追溯结构', state.patches[0]?.note) + const st = await page.evaluate(() => { + const s = window.__studio.getState() + return { type: s.nodes.find((n) => n.id === 'story').data.nodeType, dirty: s.dirty, label: s.past.at(-1)?.label, v: s.workflow.version, + card: document.querySelector('.react-flow__node[data-id="story"] [data-type]')?.getAttribute('data-type') ?? '' } + }) + check('画布上的节点真的换成报告撰写(卡片也跟着换)', st.type === 'report' && st.card === 'report', JSON.stringify(st)) + check('存完不再「未保存」,撤销栈里这一步叫「升级为可追溯结构」', !st.dirty && st.label === '升级为可追溯结构' && st.v === 4, JSON.stringify(st)) + await waitFor(page, async () => (await upgradeBox(page).count()) === 0, 4000) + check('重新校验之后建议没了,入口跟着收起', await upgradeBox(page).count() === 0) + check('只存了一次,没有发布', state.patches.length === 1 && state.publishes.length === 0) + const toasts = (await page.locator('[data-toast], [role=status]').allInnerTexts()).join(' ') + check('提示存成了哪一版', toasts.includes('已升级为可追溯结构,存为草稿 v4'), toasts.slice(0, 160)) + check('没有运行时报错', errors.length === 0, errors.join(' | ')) +}) + +await section('升级:问数据的图插入报告节点、改接连线;后端只给图时照样逐项列', async () => { + const { page, state } = await open({ id: 'pf-ask' }) + await openProblems(page) + await upgradeBox(page).locator('[data-upgrade-action]').waitFor() + await upgradeBox(page).locator('[data-upgrade-action]').click() + await page.locator('[data-upgrade-preview="ready"]').waitFor() + const added = flat(await preview(page).locator('[data-upgrade-change="node"]').innerText().catch(() => '')) + check('新插入的节点:新增节点:「报告撰写」(报告撰写)', added === '新增节点:「报告撰写」(报告撰写)', added) + const edges = (await preview(page).locator('[data-upgrade-change="edge"]').allInnerTexts()).map(flat) + check('新连线:「取数」 → 「报告撰写」(节点名取自升级后的图)', edges.includes('新连线:「取数」 → 「报告撰写」'), edges.join(' | ')) + check('改接的连线:连线:「取数」 → 「成果」 改接为 「报告撰写」 → 「成果」', edges.some((e) => /^连线:「取数」 → 「成果」\s*改接为\s*「报告撰写」 → 「成果」$/.test(e)), + edges.join(' | ')) + const field = flat(await preview(page).locator('[data-upgrade-change="value"]').innerText().catch(() => '')) + check('成果字段整列改:一项一行(answer:… → answer:{{ nodes.report.text }})', /^「成果」 · 成果字段:\s*answer:\{\{ nodes\.fetch\.text \}\}\s*answer:\{\{ nodes\.report\.text \}\}$/.test(field), field) + const note = await preview(page).locator('[data-upgrade-note="R3"] [data-upgrade-note-text]').innerText().catch(() => '') + check('R3 的说明(契约要不要写 cells 由人定)原样显示', note === R3_NOTE, note) + check('合计:新增 1 个节点、改了 1 个节点的配置、连线变动 3 处', flat(await preview(page).locator('[data-upgrade-summary]').innerText().catch(() => '')) + === '合计:新增 1 个节点、改了 1 个节点的配置、连线变动 3 处') + await shoot(page, 'upgrade-preview-ask', page.locator('section[aria-label="问题"]')) + await preview(page).locator('[data-upgrade-apply]').click() + await waitFor(page, async () => state.patches.length === 1) + const saved = state.patches[0]?.graph + check('应用:新节点和改接的连线都存进去(成果改取报告的正文,没替人加契约)', saved?.nodes?.some((n) => n.id === 'report' && n.type === 'report') + && saved?.edges?.some((e) => e.source === 'report' && e.target === 'done') && !saved?.edges?.some((e) => e.source === 'fetch' && e.target === 'done') + && cfgOf(saved, 'done')?.fields?.[0]?.value === '{{ nodes.report.text }}' && !cfgOf(saved, 'done')?.contract, JSON.stringify(saved?.edges)) + check('……画布上也有了这个节点', await page.evaluate(() => window.__studio.getState().nodes.some((n) => n.id === 'report' && n.data.nodeType === 'report'))) + + const bare = await open({ id: 'pf-ask', upgrade: 'bare' }) + await openProblems(bare.page) + await upgradeBox(bare.page).locator('[data-upgrade-action]').click() + await bare.page.locator('[data-upgrade-preview="ready"]').waitFor() + const kinds = await preview(bare.page).locator('[data-upgrade-change]').evaluateAll((els) => els.map((e) => e.getAttribute('data-upgrade-change'))) + check('后端只给图、不给逐项改动:按前后两张图自己列(新节点、改的配置、连线)', kinds.includes('node') && kinds.includes('value') + && kinds.filter((k) => k === 'edge').length === 3, kinds.join(',')) + check('……照样能应用', await preview(bare.page).locator('[data-upgrade-apply]').count() === 1) +}) + +await section('升级:画布锁着不给入口;预览摆着时锁上,应用落不下去', async () => { + const { page, state } = await open({ id: 'pf-legacy' }) + await openProblems(page) + await upgradeBox(page).locator('[data-upgrade-action]').waitFor() + await page.evaluate(() => { + const s = window.__studio.getState() + window.__studio.setState({ runPhase: 'running', trace: { ...s.trace, runClass: 'formal' } }) + }) + await page.waitForTimeout(200) + check('正式运行进行中:没有快速修复按钮', await upgradeBox(page).locator('[data-upgrade-action]').count() === 0) + check('……写明为什么现在不能升级', flat(await upgradeBox(page).locator('[data-upgrade-locked]').innerText().catch(() => '')).includes('正式运行进行中')) + await page.evaluate(() => window.__studio.getState().previewUpgrade()) + await page.waitForTimeout(200) + check('锁着时直接调也不请求升级接口', state.upgrades.length === 0) + await page.evaluate(() => window.__studio.setState({ runPhase: 'idle' })) + await upgradeBox(page).locator('[data-upgrade-action]').click() + await page.locator('[data-upgrade-preview="ready"]').waitFor() + await page.evaluate(() => window.__studio.setState({ copilot: { ...window.__studio.getState().copilot, active: true } })) + await page.waitForTimeout(200) + check('助手在改时:预览还在,但没有「应用」和「交给 Copilot」', await preview(page).count() === 1 + && await preview(page).locator('[data-upgrade-apply], [data-upgrade-assist]').count() === 0 + && flat(await upgradeBox(page).locator('[data-upgrade-locked]').innerText().catch(() => '')).includes('助手正在改')) + const ok = await page.evaluate(() => window.__studio.getState().applyUpgrade()) + check('锁着时直接调应用也落不下去、不保存', ok === false && state.patches.length === 0 + && await page.evaluate(() => window.__studio.getState().nodes.find((n) => n.id === 'story').data.nodeType) === 'llm') +}) + +await section('升级:老后端(校验不给这条建议)不给入口;升级接口 404 照实说', async () => { + const { page, state } = await open({ id: 'pf-legacy', advice: false }) + await openProblems(page) + await page.waitForTimeout(600) + check('没有这条建议:问题面板里没有升级入口', await upgradeBox(page).count() === 0 && (await dock(page).innerText()).includes('没有发现问题')) + check('……也不去请求升级接口', state.upgrades.length === 0) + + const old = await open({ id: 'pf-legacy', upgrade: 'old' }) + await openProblems(old.page) + await upgradeBox(old.page).locator('[data-upgrade-action]').click() + await old.page.locator('#dock-problems [data-upgrade-preview]:not([data-upgrade-preview="loading"])').waitFor() + check('升级接口不存在(404):照实说这个后端还不支持', await preview(old.page).getAttribute('data-upgrade-preview') === 'unsupported' + && flat(await preview(old.page).innerText()).includes('这个后端还不支持一键升级'), await preview(old.page).innerText()) + check('……没有应用按钮、不保存', await preview(old.page).locator('[data-upgrade-apply]').count() === 0 && old.state.patches.length === 0) +}) + +await section('升级:再交给 Copilot 改语义层:请求带 assist,保留没改的写出来', async () => { + const { page, state } = await open({ id: 'pf-legacy', upgradeDelay: 800 }) + await openProblems(page) + await upgradeBox(page).locator('[data-upgrade-action]').click() + await page.locator('[data-upgrade-preview="ready"]').waitFor() + const btn = preview(page).locator('[data-upgrade-assist]') + check('预览里有「再交给 Copilot 改语义层」,悬停说清它改什么、要调模型', flat(await btn.innerText()) === '再交给 Copilot 改语义层' + && (await btn.getAttribute('title') ?? '').includes('纯算术')) + await btn.click() + const loading = page.locator('[data-upgrade-preview="loading"]') + await loading.waitFor() + check('等待时说 Copilot 在改', flat(await loading.innerText()).includes('Copilot 正在改语义层')) + check('请求带复核用的发布级别:已发布的图按「已发布」', state.upgrades[0]?.level === 'published', JSON.stringify(state.upgrades[0]?.level)) + check('第二次请求带 assist: true、带的还是画布上的原图', state.upgrades.length === 2 && state.upgrades[1]?.assist === true + && state.upgrades[1]?.graph?.nodes?.find((n) => n.id === 'story')?.type === 'llm', JSON.stringify(state.upgrades.map((u) => u.assist ?? null))) + await page.locator('[data-upgrade-preview="ready"]').waitFor() + const said = flat(await preview(page).locator('[data-upgrade-assist-said]').innerText().catch(() => '')) + check('Copilot 的总结和保留没改的代码节点写出来', said.includes('不是纯算术') && said.includes('保留没改的') + && said.includes('没有改成口径卡表达式'), said) + check('已经是 Copilot 的结果:不再给「再交给 Copilot」', await preview(page).locator('[data-upgrade-assist]').count() === 0) + await preview(page).locator('[data-upgrade-assist-said]').scrollIntoViewIfNeeded().catch(() => {}) + await shoot(page, 'upgrade-preview-assist', page.locator('section[aria-label="问题"]')) + // 受管的图:新写出来的报告节点要直接按受管门禁的要求配,请求带 governed + await page.evaluate(() => { const s = window.__studio.getState(); window.__studio.setState({ workflow: { ...s.workflow, status: 'governed' } }) }) + await preview(page).locator('[data-upgrade-discard]').click() + await upgradeBox(page).locator('[data-upgrade-action]').click() + await waitFor(page, async () => state.upgrades.length === 3) + check('受管的图按「受管」复核', state.upgrades[2]?.level === 'governed', JSON.stringify(state.upgrades[2]?.level)) +}) + +await section('升级:预览之后画布又改过就不套用;存失败给重试保存', async () => { + const { page, state } = await open({ id: 'pf-legacy' }) + await openProblems(page) + await upgradeBox(page).locator('[data-upgrade-action]').click() + await page.locator('[data-upgrade-preview="ready"]').waitFor() + await page.evaluate(() => window.__studio.getState().updateNode('caliber', { label: '周报口径2' })) + await preview(page).locator('[data-upgrade-apply]').click() + await preview(page).locator('[data-upgrade-stale]').waitFor({ timeout: 3000 }).catch(() => {}) + check('画布改过:说预览对不上了,不套用、不保存', await preview(page).locator('[data-upgrade-stale]').count() === 1 && state.patches.length === 0 + && await page.evaluate(() => window.__studio.getState().nodes.find((n) => n.id === 'story').data.nodeType) === 'llm') + await preview(page).locator('[data-upgrade-stale]').getByRole('button', { name: '重新预览' }).click({ timeout: 2000 }).catch(() => {}) + await waitFor(page, async () => state.upgrades.length === 2 && await page.locator('#dock-problems [data-upgrade-preview="ready"]').count() === 1) + check('「重新预览」按现在的画布再要一份(带着刚改的名字)', state.upgrades.length === 2 + && state.upgrades[1]?.graph?.nodes?.find((n) => n.id === 'caliber')?.data?.label === '周报口径2' + && await preview(page).locator('[data-upgrade-stale]').count() === 0) + + const bad = await open({ id: 'pf-legacy', save: 'fail' }) + await openProblems(bad.page) + await upgradeBox(bad.page).locator('[data-upgrade-action]').click() + await bad.page.locator('[data-upgrade-preview="ready"]').waitFor() + await preview(bad.page).locator('[data-upgrade-apply]').click() + await preview(bad.page).locator('[data-upgrade-save-failed]').waitFor().catch(() => {}) + const why = flat(await preview(bad.page).locator('[data-upgrade-save-failed]').innerText({ timeout: 1000 }).catch(() => '')) + check('存失败:说清没存上、升级已经在画布上,给「重试保存」', why.includes('草稿没保存上') && why.includes('数据库锁住了') + && why.includes('已经放到画布上') && await bad.page.evaluate(() => window.__studio.getState().dirty), why) + bad.state.save = 'ok' + await preview(bad.page).getByRole('button', { name: '重试保存' }).click({ timeout: 2000 }).catch(() => {}) + await waitFor(bad.page, async () => bad.state.patches.length === 2 && await preview(bad.page).count() === 0) + check('重试保存:存上,预览收起', bad.state.patches.length === 2 && bad.state.patches[1]?.graph?.nodes?.find((n) => n.id === 'story')?.type === 'report' + && !(await bad.page.evaluate(() => window.__studio.getState().dirty))) +}) + +await section('升级:没存上之后——平常的保存存上了就收起;画布和升级结果对不上时不替它存;也能放弃', async () => { + // 三种收尾共同的起点:升级落到画布上、存失败。之后保存接口恢复 + const failedApply = async () => { + const r = await open({ id: 'pf-legacy', save: 'fail' }) + await openProblems(r.page) + await upgradeBox(r.page).locator('[data-upgrade-action]').click() + await r.page.locator('[data-upgrade-preview="ready"]').waitFor() + await preview(r.page).locator('[data-upgrade-apply]').click() + await preview(r.page).locator('[data-upgrade-save-failed]').waitFor().catch(() => {}) + r.state.save = 'ok' + return r + } + const upOf = (page) => page.evaluate(() => { + const u = window.__studio.getState().upgrade + return u ? `${u.status}:${u.apply?.status ?? ''}` : null + }) + const storyType = (page) => page.evaluate(() => window.__studio.getState().nodes.find((n) => n.id === 'story').data.nodeType) + + // 一、平常的保存(⌘S、工具栏的「保存」都走 studio.save) + const a = await failedApply() + check('起点:存失败,面板写着「草稿没保存上」', await preview(a.page).locator('[data-upgrade-save-failed]').count() === 1 && a.state.patches.length === 1, + `${await upOf(a.page)} patches=${a.state.patches.length}`) + const saved = await a.page.evaluate(async () => { + window.__p5up = window.__studio.getState().upgrade + try { await window.__studio.getState().save(); return 'ok' } catch (e) { return String(e) } + }) + await waitFor(a.page, async () => await dock(a.page).locator('[data-upgrade-save-failed]').count() === 0, 3000) + check('平常的保存存上了:「草稿没保存上」收起,升级的状态清掉', saved === 'ok' && a.state.patches.length === 2 + && await dock(a.page).locator('[data-upgrade-save-failed]').count() === 0 && await upOf(a.page) === null, + `${saved} ${await upOf(a.page)} patches=${a.state.patches.length}`) + check('……存的是画布上升级完的图,版本说明没冒充「升级为可追溯结构」', a.state.patches[1]?.graph?.nodes?.find((n) => n.id === 'story')?.type === 'report' + && a.state.patches[1]?.note === undefined, JSON.stringify(a.state.patches[1]?.note ?? null)) + const variants = await a.page.evaluate(async () => { + const st = window.__studio + const base = window.__p5up + st.setState({ upgrade: { ...base, apply: { status: 'locked', why: '助手正在改这张工作流', unsaved: true } } }) + await st.getState().save() + const locked = st.getState().upgrade + st.setState({ upgrade: { ...base, apply: undefined } }) + await st.getState().save() + const open = st.getState().upgrade + st.getState().discardUpgrade() + return { locked: locked ? `${locked.status}:${locked.apply?.status}` : null, open: open ? `${open.status}:${open.apply?.status ?? ''}` : null } + }) + check('锁着没存上的,平常的保存存上了同样清掉;预览还没应用的,平常的保存不替人收起', variants.locked === null && variants.open === 'ready:', + JSON.stringify(variants)) + + // 二、撤销了升级再点「重试保存」:存下去的是没升级的图,版本说明却写着升级,不能存 + const b = await failedApply() + await b.page.evaluate(() => window.__studio.getState().undo()) + check('撤销了升级:画布回到升级前', await storyType(b.page) === 'llm') + await preview(b.page).getByRole('button', { name: '重试保存' }).click({ timeout: 2000 }).catch(() => {}) + await b.page.waitForTimeout(500) + check('画布内容和升级结果对不上:点「重试保存」不发保存请求', b.state.patches.length === 1, `patches=${b.state.patches.length}`) + check('……改说这份预览对不上了(给「重新预览」),不再说「草稿没保存上」', await preview(b.page).locator('[data-upgrade-stale]').count() === 1 + && await preview(b.page).locator('[data-upgrade-save-failed]').count() === 0, String(await upOf(b.page))) + // 重做回升级完的样子、再改一处:画布上有升级,但也有升级之外的改动,同样不替它存 + const direct = await b.page.evaluate(async () => { + const st = window.__studio + st.getState().redo() + const redone = st.getState().nodes.find((n) => n.id === 'story').data.nodeType + st.setState({ upgrade: { ...st.getState().upgrade, apply: { status: 'failed', error: new Error('x') } } }) + st.getState().updateNode('caliber', { label: '周报口径2' }) + return { redone, ok: await st.getState().retryUpgradeSave(), apply: st.getState().upgrade?.apply?.status ?? null } + }) + await b.page.waitForTimeout(300) + check('……升级之后又改过画布的,直接调重试保存也不存', direct.redone === 'report' && direct.ok === false && direct.apply === 'stale' + && b.state.patches.length === 1, `${JSON.stringify(direct)} patches=${b.state.patches.length}`) + + // 三、没存上时也能放弃:只收起预览,画布上的升级留着、不再存 + const c = await failedApply() + const drop = preview(c.page).locator('[data-upgrade-save-failed] [data-upgrade-discard]') + check('没存上时有「放弃」,和「重试保存」并排', await drop.count() === 1 && flat(await drop.innerText().catch(() => '')) === '放弃' + && await preview(c.page).locator('[data-upgrade-save-failed]').getByRole('button', { name: '重试保存' }).count() === 1) + await c.page.evaluate(() => { + const u = window.__studio.getState().upgrade + window.__studio.setState({ upgrade: { ...u, apply: { status: 'locked', why: '助手正在改这张工作流', unsaved: true } } }) + }) + check('……锁着没存上时同样有「放弃」', await preview(c.page).locator('[data-upgrade-save-failed="locked"] [data-upgrade-discard]').count() === 1) + await drop.click({ timeout: 2000 }).catch(() => {}) + await c.page.waitForTimeout(300) + check('点「放弃」:状态清掉、预览收起;画布上的升级留着,没有再存', await upOf(c.page) === null && await preview(c.page).count() === 0 + && c.state.patches.length === 1 && await storyType(c.page) === 'report' && await c.page.evaluate(() => window.__studio.getState().dirty), + `${await upOf(c.page)} patches=${c.state.patches.length}`) + check('没有运行时报错', [...a.errors, ...b.errors, ...c.errors].length === 0, [...a.errors, ...b.errors, ...c.errors].join(' | ')) +}) + +await section('升级:校验接口失败时升级块照样在(后端断开时保存和校验一起失败,「重试保存」不能跟着消失)', async () => { + const { page, state, errors } = await open({ id: 'pf-legacy', save: 'fail' }) + await openProblems(page) + await upgradeBox(page).locator('[data-upgrade-action]').click() + await page.locator('[data-upgrade-preview="ready"]').waitFor() + state.validate = 'fail' + await page.evaluate(() => window.__studio.getState().analyzeNow()) + await waitFor(page, async () => page.evaluate(() => window.__studio.getState().analysis === 'failed'), 3000) + check('起点:校验接口失败,面板说分析失败、给「重试」', (await dock(page).innerText()).includes('分析失败') + && await dock(page).getByRole('button', { name: '重试', exact: true }).count() === 1) + check('升级预览开着时校验接口失败:升级块和预览照样在', await upgradeBox(page).count() === 1 + && await page.locator('#dock-problems [data-upgrade-preview="ready"]').count() === 1 + && await preview(page).locator('[data-upgrade-apply]').count() === 1) + const layout = await page.evaluate(() => { + const box = document.querySelector('#dock-problems [data-upgrade]') + const fail = document.querySelector('#dock-problems [data-analysis-failed]') + const scroller = box?.parentElement + return { above: !!(box && fail && box.compareDocumentPosition(fail) & Node.DOCUMENT_POSITION_FOLLOWING), + sameScroller: !!scroller && scroller === fail?.parentElement && getComputedStyle(scroller).overflowY === 'auto' } + }) + check('……升级块在「分析失败」上面,两块在同一个可滚动的容器里', layout.above && layout.sameScroller, JSON.stringify(layout)) + + await preview(page).locator('[data-upgrade-apply]').click() + await preview(page).locator('[data-upgrade-save-failed]').waitFor({ timeout: 3000 }).catch(() => {}) + await waitFor(page, async () => page.evaluate(() => window.__studio.getState().analysis === 'failed'), 3000) + check('保存和校验一起失败:「草稿没保存上」「重试保存」「放弃」照样在', await page.evaluate(() => window.__studio.getState().analysis) === 'failed' + && await preview(page).locator('[data-upgrade-save-failed]').count() === 1 + && await preview(page).locator('[data-upgrade-save-failed]').getByRole('button', { name: '重试保存' }).count() === 1 + && await preview(page).locator('[data-upgrade-save-failed] [data-upgrade-discard]').count() === 1 + && (await dock(page).innerText()).includes('分析失败')) + // 滚到底:「没存上」那一行和下面的「分析失败 · 重试」一屏看全 + await dock(page).locator('[data-analysis-failed]').scrollIntoViewIfNeeded().catch(() => {}) + await shoot(page, 'p5fix-offline', page.locator('section[aria-label="问题"]')) + // 后端回来了:重试保存照常存上 + state.save = 'ok' + state.validate = 'ok' + await preview(page).getByRole('button', { name: '重试保存' }).click({ timeout: 2000 }).catch(() => {}) + await waitFor(page, async () => state.patches.length === 2 && await preview(page).count() === 0) + check('后端回来之后「重试保存」存上,预览收起', state.patches.length === 2 && await preview(page).count() === 0 + && state.patches[1]?.note === '升级为可追溯结构', `patches=${state.patches.length} ${JSON.stringify(state.patches[1]?.note ?? null)}`) + check('没有运行时报错', errors.length === 0, errors.join(' | ')) +}) + +await section('升级:?upgrade=1 直达:打开问题面板、开始预览,地址摘掉参数', async () => { + const { page, state, errors } = await open({ id: 'pf-legacy', path: '/studio/pf-legacy?upgrade=1' }) + await page.locator('[data-upgrade-preview="ready"]').waitFor({ timeout: 8000 }).catch(() => {}) + await page.waitForTimeout(800) + check('一打开就是问题面板,升级预览摆着', await dock(page).count() === 1 && await page.locator('#dock-problems [data-upgrade-preview="ready"]').count() === 1) + check('只请求了一次升级接口', state.upgrades.length === 1, String(state.upgrades.length)) + check('地址里的 upgrade 摘掉了(刷新、后退不会再要一次)', !new URL(page.url()).searchParams.has('upgrade') && new URL(page.url()).pathname === '/studio/pf-legacy', + page.url()) + check('没有自动保存', state.patches.length === 0) + check('没有运行时报错', errors.length === 0, errors.join(' | ')) +}) + await section('360px 下不横向滚动', async () => { const { page } = await open({ width: 360, height: 780 }) // 窄屏上「发布」收在「更多操作」里 diff --git a/scripts/check-runs.mjs b/scripts/check-runs.mjs index 0d36e730..9c4739cc 100644 --- a/scripts/check-runs.mjs +++ b/scripts/check-runs.mjs @@ -1994,10 +1994,118 @@ await section('证据页签:没有报告的运行照实说明(none / 旧运 fakes.delete(`GET /api/artifacts/${fxe.doc_artifact}`) }) +// ------------------------------------------------------------------ 没有逐段证据 → 升级这张图(可点击证据五期) + +await section('证据页签:没有逐段证据的运行给升级横幅,跳到编排页打开升级预览', async () => { + const UP = 'fake0evupgrade000000000000000' + const UP_OLD = 'fake0evupgradeold00000000000' + const UP_LEGACY = 'fake0evupgradelegacy00000000' + const UP_NOWF = 'fake0evupgradenowf0000000000' + const node = (id, type, x, label, config = {}) => ({ id, type, position: { x, y: 120 }, data: { label, config } }) + const wfOf = (id, name, graph) => ({ id, name, description: '检查脚本伪造的工作流', graph, tags: [], version: 5, is_template: false, + status: 'published', published_version: 5, published_by: null, run_count: 1, + created_at: '2026-09-20T02:00:00Z', updated_at: '2026-09-26T02:00:00Z' }) + // 旧结构:模型调用写的文字进了成果(可以升级);另一张已经是报告撰写(校验不给建议) + const legacyGraph = { nodes: [node('in', 'input', 0, '入口'), node('fetch', 'agent', 240, '取数', { tools: ['db_query__shop'] }), + node('story', 'llm', 480, '写周报', { prompt: '写周报' }), node('done', 'output', 720, '成果', { fields: [{ name: 'report', value: '{{ nodes.story.text }}' }] })], + edges: [{ id: 'e1', source: 'in', target: 'fetch' }, { id: 'e2', source: 'fetch', target: 'story' }, { id: 'e3', source: 'story', target: 'done' }] } + const cleanGraph = { ...legacyGraph, nodes: legacyGraph.nodes.map((n) => (n.id === 'story' ? { ...n, type: 'report', data: { label: '写周报', config: { instructions: '写周报' } } } : n)) } + const WF = wfOf('fake-wf-upgrade', '升级检查', legacyGraph) + const WF_CLEAN = wfOf('fake-wf-upgraded', '升级检查(已升级)', cleanGraph) + const real = await getJson('/workflows').catch(() => []) + fakes.set('GET /api/workflows', () => ({ status: 200, json: [...real, WF, WF_CLEAN] })) + fakes.set(`GET /api/workflows/${WF.id}`, () => ({ status: 200, json: WF })) + fakes.set('GET /api/conversations', () => ({ status: 200, json: [{ id: 'fake-conv-upgrade', title: '', kind: 'canvas', archived: false, turn_count: 0 }] })) + const validated = [] + fakes.set('POST /api/workflows/validate', (url, r) => { + const graph = r.postDataJSON()?.graph ?? { nodes: [] } + validated.push(graph.nodes.map((n) => n.id).join(',')) + const old = graph.nodes.some((n) => n.type === 'llm') + return { status: 200, json: { ok: true, issues: old ? [{ level: 'info', code: 'evidence.upgrade_available', node_id: null, field: null, message: '可以升级为可追溯结构' }] : [] } } + }) + fakes.set('POST /api/workflows/variables', () => ({ status: 200, json: { variables: [], issues: [] } })) + const upgrades = [] + fakes.set('POST /api/copilot/upgrade-evidence', (url, r) => { + const body = r.postDataJSON() + upgrades.push(body) + const graph = JSON.parse(JSON.stringify(body.graph)) + const story = graph.nodes.find((n) => n.id === 'story') + if (story) { story.type = 'report'; story.data.config = { instructions: '写周报' } } + return { status: 200, json: { graph, ops: [], notes: [], issues: [], applied: ['R2:story'], rejected: [], assist: null, ok: true, + changes: [{ fix_id: 'R2:story', rule: 'R2', label: '把「写周报」换成报告撰写', node_id: 'story', node_title: '写周报', field: 'type', before: 'llm', after: 'report' }] } } + }) + const none = { schema: 'agentlab.evidence/1', mode: 'none', note: '', seal: { sealed: true, ok: true }, reports: [], evidence: [], edges: [] } + const noneAudit = { schema: 'agentlab.evidence.audit/1', mode: 'none', seal: { sealed: true, ok: true }, reports: [], groups: [], counts: {}, total: 0 } + const wire = (id, wf, graph) => { + evFakes(id, { output: { report: '本周销售额 45678' }, report: false, graph, audit: { ...noneAudit, mode: graph.mode } }) + fakes.set(`GET /api/runs/${id}`, () => ({ status: 200, json: { ...evRun(id, { report: '本周销售额 45678' }), workflow_id: wf.id, workflow_name: wf.name } })) + } + wire(UP, WF, none) + wire(UP_OLD, WF_CLEAN, none) + wire(UP_LEGACY, WF, { ...none, mode: 'legacy_text', legacy: { note: '猜测', fields: [] } }) + evFakes(UP_NOWF, { output: { report: '本周销售额 45678' }, report: false, graph: none, audit: noneAudit }) + const banner = () => page.locator('[data-view-pane=evidence] [data-upgrade-banner]') + + await openEvidence(UP) + await banner().waitFor({ timeout: 5000 }).catch(() => {}) + const text = await banner().innerText().catch(() => '') + check('没有逐段证据(none)、图能升级:横幅「这次的报告没有逐段证据 → 升级这张图」', text.includes('这次的报告没有逐段证据') + && (await banner().locator('[data-upgrade-link]').innerText().catch(() => '')).trim() === '升级这张图', text) + check('……拿工作流现在的图去校验、按那条建议认', validated.includes('in,fetch,story,done'), validated.join(' | ')) + check('……正式运行:说清升级之后要重新发布', text.includes('要重新发布'), text) + const href = await banner().locator('[data-upgrade-link]').getAttribute('href').catch(() => '') + check('跳转地址:编排页,带 upgrade=1', href === `/studio/${WF.id}?upgrade=1`, href) + 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.screenshot({ path: `${SHOTS}/runs-upgrade-banner-${theme}.png` }) + } + await page.evaluate(() => document.documentElement.removeAttribute('data-theme')) + } + // 画布正锁着这张工作流(正式运行在跑):不给入口,说清为什么 + await page.evaluate((wf) => { + const s = window.__studio.getState() + window.__studio.setState({ workflow: wf, runPhase: 'running', trace: { ...s.trace, runClass: 'formal' } }) + }, WF) + await page.waitForTimeout(200) + check('画布锁着这张工作流:横幅不给跳转,说清为什么', await banner().locator('[data-upgrade-link]').count() === 0 + && (await banner().locator('[data-upgrade-banner-locked]').innerText().catch(() => '')).includes('正式运行进行中')) + + await openEvidence(UP_LEGACY) + await banner().waitFor({ timeout: 5000 }).catch(() => {}) + check('旧运行猜测(legacy_*)也给横幅', await banner().count() === 1) + + const before = validated.length + await openEvidence(UP_OLD) + await page.waitForTimeout(500) + check('图已经是可追溯结构(校验不给建议,老后端同样不给):没有横幅', await banner().count() === 0 && validated.length === before + 1) + + await openEvidence(UP_NOWF) + await page.waitForTimeout(400) + check('没有工作流的运行(未保存的图):没有横幅,也不去校验', await banner().count() === 0 && validated.length === before + 1) + + await openEvidence(UP) + await banner().locator('[data-upgrade-link]').click() + await page.waitForURL((u) => u.pathname === `/studio/${WF.id}`, { timeout: 8000 }).catch(() => {}) + await page.locator('#dock-problems [data-upgrade-preview="ready"]').waitFor({ timeout: 8000 }).catch(() => {}) + check('跳到编排页:打开问题面板、升级预览摆着', await page.locator('#dock-problems [data-upgrade-preview="ready"]').count() === 1 + && (await page.locator('#dock-problems [data-upgrade-change="type"]').innerText().catch(() => '')).includes('报告撰写')) + check('……请求的是这张工作流的图,地址摘掉了 upgrade', upgrades.length === 1 && upgrades[0]?.graph?.nodes?.length === 4 + && new URL(page.url()).search === '', `${upgrades.length} 次 · ${page.url()}`) + check('……只出预览,没保存', !writes.some((w) => w.startsWith('PATCH '))) + + for (const k of [...fakes.keys()]) { + if ([UP, UP_OLD, UP_LEGACY, UP_NOWF].some((id) => k.includes(id)) || k.includes(WF.id)) fakes.delete(k) + } + for (const k of ['GET /api/workflows', 'GET /api/conversations', 'POST /api/workflows/validate', 'POST /api/workflows/variables', + 'POST /api/copilot/upgrade-evidence']) fakes.delete(k) +}) + await section('收尾', async () => { // 提取模板之后会跳进画布:画布一打开就拿图去做校验、变量分析,这两个是带图的只读 POST // (同样被探针拦下,不落库)。赶上它们发出来之前就离开了画布的话就没有 - check('只有伪造过的写请求', writes.every((w) => /\/continue$|\/cancel$|\/copilot\/from-run$|DELETE \/api\/runs\/fake0|^POST \/api\/runs$|^POST \/api\/workflows\/(validate|variables)$/.test(w)), + check('只有伪造过的写请求', writes.every((w) => /\/continue$|\/cancel$|\/copilot\/from-run$|DELETE \/api\/runs\/fake0|^POST \/api\/runs$|^POST \/api\/workflows\/(validate|variables)$|^POST \/api\/copilot\/upgrade-evidence$/.test(w)), writes.join(', ')) check('没有未捕获的运行时错误', errors.length === 0, errors[0] ?? '') })