Files
AgentLab/backend/app/engine/upgrade.py
T
ThearchyHelios 050bea4b72 可点击证据第五期:老工作流一键升级为可追溯结构;受管问数据图的 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 分钟
- 修掉并行下暴露的一处测试竞态:运行先落终态、再另一个事务封存,测试改成等封存完成再核对
2026-09-29 13:27:27 +08:00

680 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""一键升级为可追溯结构(证据五期)。
旧图里给人看的报告是模型调用(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}