mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 01:51:57 +00:00
可点击证据第五期:老工作流一键升级为可追溯结构;受管问数据图的 cells 一键选;pytest 并行
一、一键升级(只给预览,不自动保存)
- engine/upgrade.py:确定性改写,纯函数、重复执行结果不变
- R1 契约的叙述指向模型调用节点 → 换成报告撰写节点(system 和 prompt 合成写作要求,模型配置照搬),契约改成 report_from
- R2 没有契约、但模型写的文字进了出口,而且上游有取数 → 同样换成报告撰写
- R3 input → 智能体 → 出口(问数据的典型图)→ 中间插入报告撰写节点,出口字段接报告的正文;
只改直接相连的出口,经过别的节点读它的出口原样保留并写明原因
- R4 配了 output_schema 的智能体 → 打开 cite_fields
- R5 代码节点的产出被口径卡读 → 不改,只建议标成取数,等人确认
- 一个叙述里混了几个写作者的,不猜,整步不做并写明原因
- POST /api/copilot/upgrade-evidence:返回改动、说明和改完的检查结果;assist 时再交给 Copilot 把纯算术的代码节点
改写成口径卡表达式,不是纯算术的保留并给警告
- 升级和发布前修复走同一道「不许降低要求」的检查,只多两个由升级点名的例外:模型调用换成报告撰写、R3 改接的那条连线;
改完不能更糟
- 校验对符合 R1–R4 的旧结构给一条建议「可以升级为可追溯结构」,不算提示、不挡发布
- Copilot 自查:报告撰写节点上游没有任何能引用数字的来源时打回
二、界面
- 问题面板的快速修复「升级为可追溯结构(预览改动)」:逐项写「节点 · 字段:原值 → 新值」,节点类型的变化写成
「模型调用 → 报告撰写」,新插的节点和改接的连线另外写明;「应用」后走现有的保存,全图默认不丢
- 画布改过、存失败、画布被锁都有明确状态;保存失败后手动存成功会清掉「没存上」的提示,
画布和预览对不上时「重试保存」不再盲存;校验接口失败时升级块照样在
- 记录页里没有逐段证据的运行给横幅「这次的报告没有逐段证据 → 升级这张图」,跳到编排页并打开升级预览
- 修掉一处旧缺陷:应用修复时只换配置、不换节点类型,模型调用换成报告撰写后存下去的还是模型调用
三、受管门禁:cells 一键选
- 图里没有口径卡、契约又没写 cells: true 的受管图,以前能过门禁、正式运行却每次失败;现在门禁拦下,
发布弹窗给两个选项:写 cells: true,或交给 Copilot 改成口径卡指标(人亲手选的不算替人打开,Copilot 自己打开照样拦)
- 「修完要更好」的复核不再把改完才冒出来、只能由人拍板的这一处算作改坏:Copilot 补上契约、把 cells 留给人问,
这一步会被采用,不再被判「冒出新的问题」作废
四、测试
- 开发依赖加上 pytest-xdist(锁文件只多了 pytest-xdist 和 execnet):全量后端测试 -n auto 约 16 秒,原来串行约 2 分钟
- 修掉并行下暴露的一处测试竞态:运行先落终态、再另一个事务封存,测试改成等封存完成再核对
This commit is contained in:
+319
-4
@@ -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 = ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 下未回指的数字只降档不拦截",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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("运行没有按时结束")
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
@@ -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 }),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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: '口径卡指标',
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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] ?? '')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user