Merge pull request #8 from ThearchyHelios/feat/evidence-phase5

可点击证据第五期:老工作流一键升级为可追溯结构;受管问数据图的 cells 一键选;pytest 并行
This commit is contained in:
ThearchyHelios
2026-09-29 13:40:55 +08:00
committed by GitHub
22 changed files with 3870 additions and 72 deletions
+319 -4
View File
@@ -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 = ""
+67 -17
View File
@@ -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
+8
View File
@@ -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 下未回指的数字只降档不拦截",
+10 -1
View File
@@ -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):
+679
View File
@@ -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.<assign_to> 就是核对过的对象、不再是文字了)
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"(?<![\w.])input\s*\.\s*question(?![\w-])")
_SPAN = re.compile(r"\{\{(.*?)\}\}", re.S)
@dataclass(frozen=True)
class Match:
"""一处符合 R1–R4 的旧结构。blocked 不为空的是看着像、但说不准怎么改的,只进 notes。
exits 是这一步要改的出口;skipped 是认出来了、但这一步不碰的出口各自的原因(R3 里要经过别的节点才读到
agent 原话的出口),这一步照改其余的出口,skipped 进 notes。"""
rule: str
node_id: str
exits: tuple[str, ...] = ()
summary: str = ""
blocked: str | None = None
skipped: tuple[str, ...] = ()
@dataclass
class _Step:
label: str
changes: list[dict[str, Any]] = field(default_factory=list)
ops: list[dict[str, Any]] = field(default_factory=list)
notes: list[dict[str, Any]] = field(default_factory=list)
retyped: set[tuple[str, str, str]] = field(default_factory=set)
rewired: set[tuple[str, str]] = field(default_factory=set)
created: bool = False
# --------------------------------------------------------------------------
# 认旧结构
# --------------------------------------------------------------------------
def _queries(node: GraphNode) -> 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.<assign_to>、最后一条消息。
开了 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.<assign_to>(没开 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.<var> 整个拿来用的地方:(节点, 原样的 {{ … }})。取字段的(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}
+1 -1
View File
@@ -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"]
+5
View File
@@ -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
+80
View File
@@ -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 兜底不算修好
@@ -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
+4 -1
View File
@@ -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("运行没有按时结束")
+216
View File
@@ -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 == []
+590
View File
@@ -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
+8 -1
View File
@@ -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<ReviewResult>('/copilot/review', body),
/**
* 一键升级为可追溯结构:先按确定的规则改写(llm 换成报告撰写、问数据的图插入报告节点……),
* assist 时再交给 Copilot 改语义层。level 是复核门禁用的发布级别(受管的图,新写出的报告节点按受管的要求配)。
* 只给预览,不改库。老后端没有这个接口(404),调用方按「不支持」处理
*/
upgradeEvidence: (body: { graph: GraphSpec; assist?: boolean; level?: PublishLevel }, opts?: RequestOptions) =>
request<UpgradeResult>('/copilot/upgrade-evidence', { method: 'POST', body: JSON.stringify(body), ...opts }),
},
}
+415 -21
View File
@@ -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<HTMLDivElement>(null)
// 「可以升级为可追溯结构」:校验给的建议,不是问题。预览开着时建议可能已经没了(又校验过一次),预览照样留着
const upgradeAdvice = useStudio((s) => s.advice.find(isUpgradeAdvice))
const upgrading = useStudio((s) => !!s.upgrade)
const upgrade = upgradeAdvice || upgrading ? <UpgradeBlock advice={upgradeAdvice} /> : null
// F8 跳到的那一条要在视野里
useEffect(() => {
@@ -86,8 +100,9 @@ function LintPane({ problems, activeId, onLocate, onDeleteEdge }: {
// 不会有人替你再试,这时候承诺「会自动重新校验」就是在让人干等
const retryHint = offline ? '连上后端之后会自动重新校验' : '点「重试」再校验一次'
if (analysis === 'failed' && !problems.length) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-6 text-center text-2xs">
const failed = (
<div className={clsx('flex flex-col items-center justify-center gap-2 px-6 text-center text-2xs', upgrade ? 'py-3' : 'flex-1')}
data-analysis-failed="">
<span style={{ color: 'var(--warn)' }}>分析失败{analysisError ? `:${analysisError}` : ''}</span>
<span className="text-faint">问题清单暂时拿不到,画布照常能编辑。{retryHint}</span>
<button type="button" className="btn btn-sm" onClick={() => void analyzeNow()}>
@@ -95,15 +110,30 @@ function LintPane({ problems, activeId, onLocate, onDeleteEdge }: {
</button>
</div>
)
// 升级块照样摆着:后端断开时保存和校验一起失败,这时候最要紧的正是「没存上 → 重试保存」
if (!upgrade) return failed
return (
<div className="min-h-0 flex-1 overflow-y-auto py-1">
{upgrade}
{failed}
</div>
)
}
if (!problems.length) {
return (
<div className="flex flex-1 items-center justify-center gap-1.5 text-2xs text-faint">
const idle = (
<div className={clsx('flex items-center justify-center gap-1.5 text-2xs text-faint', upgrade ? 'py-3' : 'flex-1')}>
{analysis === 'pending'
? <><Spinner size={11} /> 正在校验…</>
: <><CheckCircle2 size={12} style={{ color: 'var(--ok)' }} /> 没有发现问题</>}
</div>
)
if (!upgrade) return idle
return (
<div className="min-h-0 flex-1 overflow-y-auto py-1">
{upgrade}
{idle}
</div>
)
}
const byNode = new Map(nodes.map((n) => [n.id, n]))
@@ -150,19 +180,22 @@ function LintPane({ problems, activeId, onLocate, onDeleteEdge }: {
</button>
</div>
)}
<div ref={list} className="min-h-0 flex-1 overflow-y-auto py-1" role="list" aria-label="问题">
{groups.map((g) => (
<div key={g.key} className="mb-1">
<div className="sticky top-0 z-[1] flex items-center gap-1.5 bg-panel px-3 py-1 text-2xs font-medium">
{g.title}
<span className="tnum ml-auto shrink-0 text-faint">{g.items.length}</span>
<div ref={list} className="min-h-0 flex-1 overflow-y-auto py-1">
{upgrade}
<div role="list" aria-label="问题">
{groups.map((g) => (
<div key={g.key} className="mb-1">
<div className="sticky top-0 z-[1] flex items-center gap-1.5 bg-panel px-3 py-1 text-2xs font-medium">
{g.title}
<span className="tnum ml-auto shrink-0 text-faint">{g.items.length}</span>
</div>
{g.items.map((p) => (
<ProblemRow key={p.id} problem={p} active={p.id === activeId}
onLocate={() => onLocate(p)} onDeleteEdge={onDeleteEdge} />
))}
</div>
{g.items.map((p) => (
<ProblemRow key={p.id} problem={p} active={p.id === activeId}
onLocate={() => onLocate(p)} onDeleteEdge={onDeleteEdge} />
))}
</div>
))}
))}
</div>
<div className="px-3 pb-1 pt-0.5 text-2xs text-faint">
{formatShortcut('F8')} 下一个 · {formatShortcut('Shift+F8')} 上一个
</div>
@@ -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<HTMLElement>(null)
const seq = upgrade?.seq
const status = upgrade?.status
useEffect(() => {
if (seq != null) ref.current?.scrollIntoView({ block: 'nearest' })
}, [seq, status])
return (
<section ref={ref} className="mx-2 mb-1.5 mt-0.5 rounded-md border px-2.5 py-1.5 text-2xs" aria-label={UPGRADE_TEXT.advice}
style={{ borderColor: 'color-mix(in srgb, var(--accent) 35%, var(--border))' }} data-upgrade="">
<div className="flex flex-wrap items-start gap-x-2 gap-y-1">
<Sparkles size={11} className="mt-px shrink-0" style={{ color: 'var(--accent)' }} aria-hidden />
<span className="min-w-0 flex-1 leading-snug">
<span className="text-fg" data-upgrade-advice="">{advice?.message || UPGRADE_TEXT.advice}</span>
{/* 后端的建议多半已经说了要改哪里、先预览;只有一句光秃秃的标题时才补这句说明 */}
{(!advice?.message || advice.message === UPGRADE_TEXT.advice) && (
<span className="block text-faint">{UPGRADE_TEXT.adviceHint}</span>
)}
</span>
{!lock && !upgrade && (
<button type="button" className="btn btn-xs shrink-0" data-upgrade-action="" onClick={() => void previewUpgrade()}>
<Wand2 size={10} aria-hidden /> {UPGRADE_TEXT.action}
</button>
)}
</div>
{lock && <div className="mt-1 text-faint" data-upgrade-locked="">{UPGRADE_TEXT.locked(EDIT_LOCK_TEXT[lock])}</div>}
{upgrade && <UpgradePreview flow={upgrade} locked={!!lock} />}
</section>
)
}
/** 预览里念节点和字段要用的几样:名字、升级前后的类型、升级前的配置 */
interface UpgradeNames {
nameOf: NodeNameOf
/** 升级后的类型(新插入的节点也有) */
typeOf: (id?: string | null) => string | undefined
/** 升级前的类型:换成报告撰写之后去掉的那几项(用户提示、角色设定)按原来的叫法念 */
wasTypeOf: (id?: string | null) => string | undefined
configOf: (id?: string | null) => Record<string, any> | 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<string, { label: string; type: string }>()
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<NodeNameOf>((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 (
<div className={clsx(box, 'flex items-center gap-2')} data-upgrade-preview="loading" aria-live="polite">
<Spinner size={11} />
<span className="min-w-0 flex-1 text-dim">{flow.assist ? T.assisting : T.previewing}</span>
<button type="button" className="btn btn-xs" onClick={discard}>{P.stop}</button>
</div>
)
}
if (flow.status === 'unsupported' || flow.status === 'error') {
return (
<div className={clsx(box, 'flex flex-wrap items-center gap-2')} style={{ borderColor: 'var(--warn)' }}
data-upgrade-preview={flow.status} role="alert">
<AlertTriangle size={11} className="shrink-0" style={{ color: 'var(--warn)' }} aria-hidden />
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]" style={{ color: 'var(--warn)' }}>
{flow.status === 'unsupported' ? T.unsupported : `${T.failed}:${reasonOf(flow.error)}`}
</span>
{flow.status === 'error' && !locked && (
<button type="button" className="btn btn-xs" onClick={() => void previewUpgrade({ assist: flow.assist })}>
<RotateCw size={10} aria-hidden /> {P.retry}
</button>
)}
<button type="button" className="btn btn-xs" onClick={discard}>{P.discard}</button>
</div>
)
}
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 (
<section className={clsx(box, 'space-y-1.5')} style={{ borderColor: 'var(--accent)' }}
data-upgrade-preview="ready" aria-label="升级预览">
<div className="font-medium">{changes.length ? T.title(changes.length) : T.noChange}</div>
{summary.length > 0 && <div className="text-faint" data-upgrade-summary="">{T.summary(summary)}</div>}
{changes.length > 0 && (
<div className="space-y-1.5" data-upgrade-changes="">
{upgradeGroups(changes).map((g) => (
<div key={g.key} data-upgrade-step={g.rule ?? ''}>
{g.label && (
<div className="flex items-start gap-1 text-dim [overflow-wrap:anywhere]" data-upgrade-step-label="">
{g.rule && <RuleTag rule={g.rule} />}
<span className="min-w-0">{g.label}</span>
</div>
)}
<ul className={clsx('space-y-0.5', g.label && 'mt-0.5 border-l pl-2.5')}>
{g.items.map((c, i) => (
<UpgradeChangeRow key={i} change={c} names={names} showRule={!g.label} showLabel={c.label !== g.label} />
))}
</ul>
</div>
))}
</div>
)}
{result.notes.length > 0 && (
<div className="rounded border px-2 py-1" style={{ borderColor: 'var(--st-waiting)' }} data-upgrade-notes="">
<div style={{ color: 'var(--st-waiting)' }}>{T.notes}</div>
<ul className="list-disc space-y-0.5 pl-4">
{result.notes.map((n, i) => (
<li key={i} className="[overflow-wrap:anywhere]" data-upgrade-note={n.rule ?? ''} data-upgrade-note-level={n.level ?? 'info'}
style={n.level === 'warning' ? { color: 'var(--warn)' } : undefined}>
{n.node_id && !n.text.includes(`「${nameOf(n.node_id) ?? n.node_id}」`) && (
<b className="font-semibold">「{nameOf(n.node_id) ?? n.node_id}」</b>
)}
<span data-upgrade-note-text="">{n.text}</span>
</li>
))}
</ul>
</div>
)}
{rejected.length > 0 && (
<div data-upgrade-rejected="">
<div className="text-faint">{T.rejected}</div>
<ul className="space-y-0.5">
{rejected.map((r, i) => (
<li key={i} className="flex items-start gap-1 [overflow-wrap:anywhere]" style={{ color: 'var(--warn)' }}>
<AlertTriangle size={10} className="mt-0.5 shrink-0" aria-hidden />
<span className="min-w-0">{upgradeStepText(r.fix_id, nameOf)}:{r.reason}</span>
</li>
))}
</ul>
</div>
)}
{result.assist && (result.assist.summary || !!result.assist.questions?.length || !!result.assist.warnings?.length) && (
<div className="space-y-1" data-upgrade-assist-said="">
{result.assist.summary && (
<div className="flex items-start gap-1 [overflow-wrap:anywhere]">
<Sparkles size={10} className="mt-0.5 shrink-0 text-faint" aria-hidden />
<span className="min-w-0"><span className="text-faint">{P.assistSaid}:</span>{result.assist.summary}</span>
</div>
)}
{!!result.assist.warnings?.length && (
<div data-upgrade-assist-warnings="">
<div className="text-faint">{T.assistWarnings}</div>
<ul className="space-y-0.5">
{result.assist.warnings.map((w, i) => (
<li key={i} className="flex items-start gap-1 [overflow-wrap:anywhere]" style={{ color: 'var(--warn)' }}>
<AlertTriangle size={10} className="mt-0.5 shrink-0" aria-hidden /><span className="min-w-0">{w}</span>
</li>
))}
</ul>
</div>
)}
{!!result.assist.questions?.length && (
<div className="rounded border px-2 py-1" style={{ borderColor: 'var(--st-waiting)' }} data-upgrade-questions="">
<div style={{ color: 'var(--st-waiting)' }}>{P.questions}</div>
<ul className="list-disc pl-4">
{result.assist.questions.map((q, i) => <li key={i} className="[overflow-wrap:anywhere]">{q}</li>)}
</ul>
</div>
)}
</div>
)}
{usable && (
<div style={{ color: left ? 'var(--warn)' : 'var(--ok)' }} data-upgrade-after-check="">
{left ? T.afterLeft(left) : T.afterOk}
</div>
)}
{a?.status === 'stale' && (
<div role="alert" className="flex flex-wrap items-center gap-2" style={{ color: 'var(--warn)' }} data-upgrade-stale="">
<span className="min-w-0 flex-1">{T.stale}</span>
{!locked && (
<button type="button" className="btn btn-xs" onClick={() => void previewUpgrade({ assist: flow.assist })}>
<RotateCw size={10} aria-hidden /> {T.again}
</button>
)}
</div>
)}
{a?.status === 'locked' && !a.unsaved && <div role="alert" style={{ color: 'var(--warn)' }}>{T.locked(a.why)}</div>}
{unsaved && (
<div role="alert" className="flex flex-wrap items-center gap-2" data-upgrade-save-failed={a?.status === 'failed' ? '' : 'locked'}
style={{ color: a?.status === 'failed' ? 'var(--err)' : 'var(--warn)' }}>
<span className="min-w-0 flex-1">
{a?.status === 'failed' ? `${P.saveFailed}:${reasonOf(a.error)}` : a?.status === 'locked' ? `${P.saveFailed}(${a.why})` : ''}。
{T.saveFailedHint}
</span>
{/* 放弃只收起这份预览:画布上的升级留着(要退回就撤销),之后按平常的保存来存 */}
<button type="button" className="btn btn-xs" onClick={discard} title={T.discardUnsavedHint} data-upgrade-discard="unsaved">
{P.discard}
</button>
<button type="button" className="btn btn-xs" onClick={() => void retrySave()}>
<RotateCw size={10} aria-hidden /> {P.retrySave}
</button>
</div>
)}
{!unsaved && (
<div className="flex flex-wrap items-center gap-2 pt-0.5">
<span className="min-w-0 flex-1 text-faint">
{saving ? P.saving : locked ? '' : usable ? `${T.applyNote}${dirty ? `。${P.dirtyNote}` : ''}` : ''}
</span>
{!flow.assist && !locked && (
<button type="button" className="btn btn-xs" data-upgrade-assist="" disabled={saving} title={T.assistHint}
onClick={() => void previewUpgrade({ assist: true })}>
<Sparkles size={10} aria-hidden /> {T.assist}
</button>
)}
<button type="button" className="btn btn-xs" onClick={discard} disabled={saving} data-upgrade-discard="">{P.discard}</button>
{usable && !locked && (
<button type="button" className="btn btn-xs btn-primary" data-upgrade-apply="" disabled={saving || a?.status === 'stale'}
onClick={() => void apply()}>
{saving ? <Spinner size={10} /> : <Check size={10} aria-hidden />} {T.apply}
</button>
)}
</div>
)}
</section>
)
}
/** 命中的改写规则:R1–R5 或 Copilot */
function RuleTag({ rule }: { rule: string }) {
return <span className="mono shrink-0 rounded border px-1 text-faint" title="命中的改写规则">{upgradeRuleText(rule)}</span>
}
/** 这个字段按哪种节点的叫法念:升级后的类型认得就用它,认不得(换类型时去掉的用户提示)用原来的 */
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 = <ArrowRight size={10} className="mx-1 inline align-[-1px] text-faint" aria-label="改成" />
let body: React.ReactNode
if (kind === 'type') {
body = (
<>
<b className="font-semibold">「{where}」</b> · {T.typeField}:
<span className="text-faint line-through" data-upgrade-before="">{upgradeTypeText(c.before)}</span>
{arrow}
<span className="font-medium" data-upgrade-after="">{upgradeTypeText(c.after)}</span>
</>
)
} else if (kind === 'node') {
body = <>{T.added}:<span className="font-medium" data-upgrade-after="">{upgradeNodeText(c.after)}</span></>
} 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}:<span className="text-faint line-through" data-upgrade-before="">{from}</span>
<span className="mx-1 text-faint">{T.rewired}</span><span className="font-medium" data-upgrade-after="">{to}</span></>
: to ? <>{T.addedEdge}:<span className="font-medium" data-upgrade-after="">{to}</span></>
: <>{T.removedEdge}:<span className="text-faint line-through" data-upgrade-before="">{from}</span></>
} else {
const field = fixFieldLabel(c.field, ownerType(c.field, c.node_id, names), names.configOf(c.node_id))
body = (
<>
<b className="font-semibold">「{where}」</b>{field && ` · ${field}`}:
<UpgradeValue value={c.before} field={c.field} nameOf={nameOf} className="text-faint line-through" data-upgrade-before="" />
{arrow}
<UpgradeValue value={c.after} field={c.field} nameOf={nameOf} className="font-medium" data-upgrade-after="" />
</>
)
}
return (
<li className="leading-snug" data-upgrade-change={kind} data-upgrade-rule={c.rule ?? undefined}>
<span className="[overflow-wrap:anywhere]">
{showRule && c.rule && <span className="mr-1"><RuleTag rule={c.rule} /></span>}
{body}
</span>
{showLabel && c.label && <span className="block text-faint">{c.label}</span>}
</li>
)
}
/** 预览里的一个值:整份契约一键一行,别的压成一行;节点引用写节点名。和发布前修复的写法相同 */
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 <span className={className} {...data}>{fixValueText(value, field, nameOf)}</span>
return (
<span className={clsx(className, 'block pl-3')} {...data}>
{lines.map((line, i) => <span key={i} className="block">{line}</span>)}
</span>
)
}
const SUB_LABEL: Record<string, string> = { condition: '的条件', key: '的标识', tools: '的工具', system: '的角色设定' }
function fieldLabel(field: FieldRef | null | undefined, type?: string, config?: Record<string, any>): string {
+207 -10
View File
@@ -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<string, number>,
): 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<string, string> = {
const EXTRA_FIELDS: Record<string, string> = {
workflow_version: '钉住版本', upgrade_policy: '上游发了新版本时', defaults: '全图默认',
}
/** 点号后面那一段的叫法:契约里的键,全图默认里的审批策略 */
/** 点号后面那一段的叫法:契约里的键,全图默认里的审批策略,列表项里的名称、取值、表达式 */
const SUB_KEYS: Record<string, string> = {
...CONTRACT_KEYS, approval: '审批策略', claims: '没挂依据的结论句', on_uncited: '没挂依据时',
name: '名称', value: '取值', expression: '表达式',
}
/** 契约 claims 的 on_uncited:没挂依据的结论句怎么算 */
const ON_UNCITED_LABEL: Record<string, string> = {
@@ -370,15 +379,24 @@ export type NodeNameOf = (id: string) => string | undefined
const isRecord = (v: unknown): v is Record<string, unknown> => !!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, any>): 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<ValidationIssue, 'code'>): 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<any>(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<any>(raw?.rejected).filter(isRecord)
.map((r) => ({ fix_id: asText(r.fix_id), reason: asText(r.reason) || '没说原因' })),
assist,
}
}
const isNodeLike = (v: unknown): v is Partial<GraphNode> =>
isRecord(v) && typeof v.type === 'string' && (typeof v.id === 'string' || isRecord(v.data))
const isEdgeLike = (v: unknown): v is Pick<GraphEdge, 'source' | 'target'> =>
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<GraphEdge, 'source' | 'target' | 'sourceHandle'>) => `${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)
}
+46
View File
@@ -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<string, string> = {
metric: '口径卡指标',
+20 -1
View File
@@ -88,7 +88,7 @@ const PHASE_TEXT: Record<string, string> = {
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<string | null>(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(() => {
+74 -3
View File
@@ -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 }: {
<div className="space-y-4 px-4 py-3">
<SummaryBar mode={mode} seal={seal} message={audit.data?.seal?.message}
reports={graph.reports ?? []} labelOf={labelOf} />
<UpgradeBanner run={run} mode={mode} />
{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<string, Promise<boolean>>()
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 (
<section className="flex flex-wrap items-center gap-x-3 gap-y-1.5 rounded-lg border px-3 py-2" data-upgrade-banner=""
aria-label={UPGRADE_TEXT.runBanner}
style={{ borderColor: 'color-mix(in srgb, var(--accent) 35%, var(--border))',
background: 'color-mix(in srgb, var(--accent) 5%, var(--bg-panel))' }}>
<Sparkles size={13} className="shrink-0" style={{ color: 'var(--accent)' }} aria-hidden />
<span className="min-w-0 flex-1 text-xs">
<span className="font-medium text-fg">{UPGRADE_TEXT.runBanner}</span>
<span className="mt-0.5 block text-2xs leading-relaxed text-faint">
{UPGRADE_TEXT.runHint}{run.run_class === 'formal' ? `。${UPGRADE_TEXT.runFormal}` : ''}
</span>
</span>
{lock ? (
<span className="text-2xs" style={{ color: 'var(--st-waiting)' }} data-upgrade-banner-locked="">
{UPGRADE_TEXT.runLocked(EDIT_LOCK_TEXT[lock])}
</span>
) : (
<Link className="btn btn-sm shrink-0" to={`/studio/${encodeURIComponent(wf.id)}?upgrade=1`} data-upgrade-link=""
title={UPGRADE_TEXT.runHint}>
<ArrowRight size={11} aria-hidden /> {UPGRADE_TEXT.runAction}
</Link>
)}
</section>
)
}
/** 详情栏宽到能放下右栏吗:按容器量,不按屏幕(左边还有运行列表) */
function useWide(el: HTMLElement | null, min: number): boolean {
const [wide, setWide] = useState(true)
+171 -7
View File
@@ -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<string, any> }>
@@ -210,6 +212,30 @@ export const EDIT_LOCK_TEXT: Record<Exclude<EditLock, null>, 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<void>
/**
* 应用预览:预览之后画布没改过才落(applyFixes,记一步撤销、全图默认照带),再走现有的保存存成草稿。
* 返回存上了没有
*/
applyUpgrade: () => Promise<boolean>
/** 升级已经落到画布上、没存上(存失败、或者要存时画布锁着):再存一次 */
retryUpgradeSave: () => Promise<boolean>
discardUpgrade: () => void
onNodesChange: (changes: NodeChange[]) => void
onEdgesChange: (changes: EdgeChange[]) => void
onConnect: (conn: Connection) => void
@@ -879,7 +926,7 @@ async function runAnalysis(
): Promise<void> {
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<boolean> {
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<StudioState>((set, get) => ({
selectedId: null,
dirty: false,
issues: [],
advice: [],
upgrade: null,
variables: [],
varIssues: [],
run: null,
@@ -1110,8 +1200,11 @@ export const useStudio = create<StudioState>((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<StudioState>((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<StudioState>((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<StudioState>((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<StudioState>((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
}
// 已经有一份结论时不翻回「校验中」:连续打字会让工具栏的标签一直闪。
+37 -1
View File
@@ -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 是终态清扫时收的:运行被取消或服务重启时还没跑完的节点 */
+539 -3
View File
@@ -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/<id>)
*/
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 })
// 窄屏上「发布」收在「更多操作」里
+109 -1
View File
@@ -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] ?? '')
})