wf-patch.py 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. wf-patch.py — workflow.json 的安全批量字段设置器 (procedure skill 版).
  5. procedure skill 的主路径是**直接 Write/Edit workflow.json**; 本工具是批量修错的快捷方式:
  6. validate.py 报出几十条同类错误 (整批 anchor / 整批 effect/action) 时, 用 Edit
  7. 一处一处改太碎, 手写整段 JSON 又易踩转义坑. wf-patch 卡在中间:
  8. **你只负责语义决策 (path=value), 工具负责安全落盘 + 合法性校验**.
  9. - 安全 IO: 工具自己 json.load -> 改 -> json.dump(ensure_ascii=False), 你永远不手写 JSON.
  10. - 写入即校验 (fail-fast): 每条赋值立刻对照词表 / type_registry / anchor 格式校验,
  11. **任何一条非法 -> 报具体哪条错, 整批不写**. 词表与 validate.py 同源 (vocab.py).
  12. 用法:
  13. # 单条 / 多条 --set (path=value, 只在第一个 '=' 处切, value 可含 '=' 和空格)
  14. python procedure/tools/wf-patch.py --workflow outputs/case-N/workflow.json \
  15. --set 'p1.s1.inputs[0].anchor=← s0.主角图' \
  16. --set 'p1.s2.effect=主体生成' \
  17. --set 'p1.s2.action=生成/图像生成/文生图'
  18. # 或一次性喂一份 patch 清单 (适合 1.3 加 anchor / 2A 填字段这种几十处批量)
  19. python procedure/tools/wf-patch.py --workflow outputs/case-N/workflow.json --patch _scratch/anchors.json
  20. # anchors.json = [{"path": "p1.s1.inputs[0].anchor", "value": "← s0.x"}, ...]
  21. # 只校验不写
  22. python procedure/tools/wf-patch.py --workflow ... --set '...' --dry-run
  23. # 删字段 (取代手 Edit 删; 字段不存在则幂等跳过)
  24. python procedure/tools/wf-patch.py --workflow ... --unset 'p1.declarations.inputs[0].inferred'
  25. # 只校验不写
  26. python procedure/tools/wf-patch.py --workflow ... --set '...' --dry-run
  27. 路径语法 (proc/step 主用 id (p1/s1), 下标 procedures[N].steps[M] 也接受; inputs/outputs 用 [i]; 嵌套步 id 带点 s2.1 也支持):
  28. p1.s2.effect step 标量字段 (effect/substance/form/via/action/directive/kind/intent/group)
  29. p1.s1.inputs[0].anchor IO 字段 (anchor/type/value/id)
  30. p1.s2.focus step 的 focus 数组 (逗号分隔: focus=via,action,out-type-0)
  31. p1.purpose procedure 头部字段 (name/purpose/category/platform/author)
  32. p1.declarations.inputs[0].desc declarations 内任意字段 (通用下钻)
  33. source.url case-level 原帖信息 (platform/author/date/url/title/excerpt)
  34. p1.type_registry.场景图.extends 注册 case-specific 类型 (会自动建 type_registry 段)
  35. value 特殊取值:
  36. __null__ -> JSON null (用于 substance/form/url 可空)
  37. 仍用 Write / Edit 的只剩 (尽量别碰生 JSON):
  38. - workflow.json 骨架的首次创建 (Phase 1.2 从 template Write)
  39. - instruction (列表套列表, 手动 Edit; 透传 directive 用 --resolve-passthrough)
  40. 改字段/删字段/改 source 现在都走本工具, 不要再 Read→Edit 改 workflow.json (会反复重读、烧 token).
  41. 自动修引号 (load 时兜底):
  42. workflow.json 由模型直 Write, 偶尔把中文引号写成未转义的 ASCII " → JSON 崩.
  43. 本工具 load 失败时会自动把这类误引号修成「」再 parse; 修成功则继续 patch, 并把
  44. 修复随本次写回落盘 (--dry-run 不写). 修不回才按 exit 2 报错. 不用再手写 _scratch 修复脚本.
  45. 退出码:
  46. 0 全部校验通过并写入 (--dry-run 时为校验通过)
  47. 1 有校验失败 (整批未写) / 路径解析失败
  48. 2 CLI 参数错误 / 文件不存在 / JSON 损坏 (且自动修引号也救不回)
  49. """
  50. from __future__ import annotations
  51. import argparse
  52. import json
  53. import re
  54. import sys
  55. from pathlib import Path
  56. # 词表常量与 validate.py 同源 (同目录 vocab.py); 被当脚本跑时 sys.path[0] 即本目录.
  57. sys.path.insert(0, str(Path(__file__).resolve().parent))
  58. from vocab import (EFFECT_LEAF2PATH, ACTION_LEAF2PATH, TYPE_LEAF2PATH, # noqa: E402
  59. ACTION_CONTROL, EFFECT_LEAVES, ACTION_LEAVES, TYPE_LEAVES)
  60. # Windows 控制台 UTF-8
  61. for _s in (sys.stdout, sys.stderr):
  62. if hasattr(_s, 'reconfigure'):
  63. try:
  64. _s.reconfigure(encoding='utf-8', errors='replace')
  65. except Exception:
  66. pass
  67. KIND_VOCAB = {'step', 'block', 'nested'}
  68. # value/directive 里的「引用占位」文案 — 这些是 anchor 的活, value 应填数据本身.
  69. # 命中即视为「未真正回填」(--resolve-passthrough 会尝试填, lint 会报警).
  70. META_REF = re.compile(r'[((]?\s*同\s*s[\d]|见\s*s[\d]|←\s*s[\d]|同上')
  71. # ===========================================================================
  72. # 自动修引号: 模型直 Write workflow.json 时常把中文引号写成 ASCII " (未转义) → JSON 崩.
  73. # 仅在 json.loads 失败时兜底调用 (合法文件零开销). 判别: 串内一个 ASCII " 之后第一个
  74. # 非空白字符 ∈ {,:}]} 或 EOF → 真·字符串定界符 (保留); 否则是误写的内容引号 → 换直角
  75. # 引号「」(串内交替 开「/闭」). 逻辑独立内置于本文件 (不 import 任何外部模块);
  76. # scratch/repair_workflow_quotes.py 是同款独立实现, 二者无依赖关系. 改完必须能 parse 才用.
  77. # ===========================================================================
  78. _STRUCT_AFTER = set(',:}]')
  79. def repair_ascii_quotes(raw: str):
  80. """→ (修后文本, 改动的内容引号数). 纯走字符, 不依赖能否 parse."""
  81. out, i, n = [], 0, len(raw)
  82. in_str = esc = False
  83. open_q = True
  84. changes = 0
  85. while i < n:
  86. c = raw[i]
  87. if not in_str:
  88. out.append(c)
  89. if c == '"':
  90. in_str, esc, open_q = True, False, True
  91. i += 1
  92. continue
  93. if esc:
  94. out.append(c); esc = False; i += 1; continue
  95. if c == '\\':
  96. out.append(c); esc = True; i += 1; continue
  97. if c == '"':
  98. j = i + 1
  99. while j < n and raw[j] in ' \t\r\n':
  100. j += 1
  101. nxt = raw[j] if j < n else ''
  102. if nxt == '' or nxt in _STRUCT_AFTER:
  103. out.append(c); in_str = False # 真·结束符
  104. else:
  105. out.append('「' if open_q else '」') # 误写的内容引号
  106. open_q = not open_q
  107. changes += 1
  108. i += 1
  109. continue
  110. out.append(c); i += 1
  111. return ''.join(out), changes
  112. class PathError(Exception):
  113. """路径无法解析到 workflow.json 里的目标位置."""
  114. # ===========================================================================
  115. # 词表 (vocab.py 常量; 变量名沿用旧 *_PATHS = {leaf: 全路径} 约定, 校验逻辑零改动)
  116. # ===========================================================================
  117. EFFECT_PATHS = EFFECT_LEAF2PATH
  118. ACTION_PATHS = ACTION_LEAF2PATH
  119. TYPE_PATHS = TYPE_LEAF2PATH
  120. def _closest(name: str, leaves) -> str:
  121. """给个最接近的叶子名做提示 (子串/前缀朴素匹配, 仅供报错文案)."""
  122. cands = [lf for lf in leaves if name and (name in lf or lf in name)]
  123. return (' 最接近: ' + '/'.join(cands[:3])) if cands else ''
  124. def _leaf_menu(field: str) -> str:
  125. """校验失败时把该词表的**完整合法叶子**摆出来, 让模型直接照抄、别瞎猜反复试。
  126. 三张词表都很小 (effect 9 / action 30 / type 50), 整列得起。比"去读文件"更直接:
  127. 模型不用多花一次 read_file, 报错里就有菜单。每个失败字段类型只在末尾打一次。
  128. """
  129. if field == 'effect':
  130. return ('合法 effect 叶子 (照抄其一): ' + ' / '.join(sorted(EFFECT_LEAVES))
  131. + ' [词表树见 procedure/SKILL.md]')
  132. if field == 'action':
  133. byb: dict = {}
  134. for p in sorted(set(ACTION_PATHS.values())):
  135. byb.setdefault(p.split('/')[0], []).append(p)
  136. body = '; '.join(' | '.join(byb[b]) for b in sorted(byb))
  137. return ('合法 action 叶路径 (照抄其一, 全集): ' + body
  138. + ' [词表树见 procedure/SKILL.md]')
  139. if field in ('type', 'extends'):
  140. return ('合法 type 叶子 (extends 挂靠照抄其一): ' + ' / '.join(sorted(TYPE_LEAVES))
  141. + ' [词表树见 procedure/SKILL.md]')
  142. return ''
  143. # ===========================================================================
  144. # 字段校验 -> (ok, normalized_value, err_msg)
  145. # ===========================================================================
  146. def validate_field(field: str, value, proc: dict, pending_types: set[str] = None):
  147. # null 哨兵 (substance/form/url 可空)
  148. if value == '__null__':
  149. if field in ('substance', 'form', 'url'):
  150. return True, None, ''
  151. return False, value, f'__null__ 只对 substance/form/url 有意义, {field} 不可为 null'
  152. # focus 是数组: 逗号分隔 → list ('via,action,out-type-0'); 空串 → []
  153. if field == 'focus':
  154. items = [t.strip() for t in str(value).split(',') if t.strip()]
  155. return True, items, ''
  156. if field == 'effect':
  157. if value in EFFECT_LEAVES:
  158. return True, value, ''
  159. # 给了全路径 -> 归一到叶名 (schema 存叶名)
  160. for leaf, path in EFFECT_PATHS.items():
  161. if value == path:
  162. return True, leaf, ''
  163. # 容错: 路径里有某段是合法叶名 → 取最末的那个
  164. segs = [s.strip() for s in str(value).split('/') if s.strip()]
  165. for seg in reversed(segs):
  166. if seg in EFFECT_LEAVES:
  167. return True, seg, ''
  168. return False, value, f'effect={value!r} 不是 effect 词表叶子(存叶名).{_closest(segs[-1] if segs else str(value), EFFECT_LEAVES)}'
  169. if field == 'action':
  170. # action 存全路径; 给叶名自动展开, 给全叶路径原样接受
  171. if value in ACTION_PATHS: # 是叶名
  172. return True, ACTION_PATHS[value], ''
  173. if value in ACTION_PATHS.values(): # 是合法叶路径
  174. return True, value, ''
  175. # 容错: 路径里有某段是合法叶名(多写了段 / 前缀错, 如 生成/元素生成/文生图)→ 取最末的合法叶, 纠正到其全路径
  176. segs = [s.strip() for s in str(value).split('/') if s.strip()]
  177. for seg in reversed(segs):
  178. if seg in ACTION_PATHS:
  179. return True, ACTION_PATHS[seg], ''
  180. return False, value, (f'action={value!r} 不是合法动作叶子/叶路径 '
  181. f'(对到 action 词表的叶子, 如 元素生成、提取/化学提取/反推).{_closest(segs[-1] if segs else str(value), ACTION_LEAVES)}')
  182. if field == 'type':
  183. # type 是自由标签: Phase 1 随便起个描述性词即可, 不校验是否叶子/注册。
  184. # 「对到 type.json 标准叶子 / 注册 type_registry 挂靠」是 Phase 2 归类的活;
  185. # 最终是否合法由 lint-case (Check 1: 揪未注册的 case-specific type) + render schema 兜底。
  186. if isinstance(value, str) and value.strip():
  187. return True, value.strip(), ''
  188. return False, value, 'type 不能为空'
  189. if field == 'extends': # type_registry entry 的 extends 必须桥到 stdlib 叶子
  190. if value in TYPE_LEAVES:
  191. return True, value, ''
  192. return False, value, f'type_registry extends={value!r} 必须是 type 词表叶子.{_closest(value, TYPE_LEAVES)}'
  193. if field in ('substance', 'form'):
  194. # 自由提炼的元素点 — 不查词表、不校验 (README「第二阶段 · 2.1 实质 / 形式」).
  195. # 字符串原样存 (如 "人物、卧室场景"), 数组逐项 strip 后存. 旧的 taxonomy-lookup 校验已废弃.
  196. if isinstance(value, str):
  197. return True, value.strip(), ''
  198. if isinstance(value, list):
  199. return True, [str(p).strip() for p in value if str(p).strip()], ''
  200. return False, value, f'{field} 必须是字符串或数组'
  201. if field == 'anchor':
  202. if re.match(r'^\s*(←|→)', str(value)):
  203. return True, value, ''
  204. return False, value, f'anchor={value!r} 须以 ← (输入引用) 或 → (输出去向) 开头'
  205. if field == 'kind':
  206. if value in KIND_VOCAB:
  207. return True, value, ''
  208. return False, value, f'kind={value!r} 不在 {sorted(KIND_VOCAB)}'
  209. # 自由文本字段 (name/value/intent/via/purpose/category/platform/author/desc/group...)
  210. return True, value, ''
  211. # ===========================================================================
  212. # 路径解析 -> (parent_container, key, proc, field_name)
  213. # ===========================================================================
  214. _SEG = re.compile(r'^([^\[]+)(?:\[(\d+)\])?$')
  215. def _split_seg(seg: str):
  216. m = _SEG.match(seg)
  217. if not m:
  218. raise PathError(f'非法路径段 {seg!r}')
  219. return m.group(1), (int(m.group(2)) if m.group(2) is not None else None)
  220. def _descend(container, segs, create=False):
  221. """沿 segs 走进 container, 返回 (parent, last_key). create=True 时自动建中间节点.
  222. segs 每段可带 [i] 下标. last_key 是 dict 键 (str) 或列表下标 (int);
  223. 设置即 parent[last_key]=value, 删除即 del parent[last_key].
  224. 用于 source.* / declarations.* 等通用路径 (proc/step 的 id 寻址不走这里).
  225. """
  226. cur = container
  227. for i, seg in enumerate(segs):
  228. name, idx = _split_seg(seg)
  229. last = (i == len(segs) - 1)
  230. if idx is None:
  231. if last:
  232. if not isinstance(cur, dict):
  233. raise PathError(f'{name!r} 的父级不是对象')
  234. return cur, name
  235. if not isinstance(cur, dict):
  236. raise PathError(f'路径段 {name!r} 的父级不是对象')
  237. if name not in cur:
  238. if not create:
  239. raise PathError(f'路径段 {name!r} 不存在, 无法下钻')
  240. cur[name] = {}
  241. cur = cur[name]
  242. else:
  243. if not isinstance(cur, dict):
  244. raise PathError(f'路径段 {name!r} 的父级不是对象')
  245. if name not in cur:
  246. if not create:
  247. raise PathError(f'路径段 {name!r} 不存在, 无法下钻')
  248. cur[name] = []
  249. lst = cur[name]
  250. if not isinstance(lst, list):
  251. raise PathError(f'{name} 不是列表')
  252. if idx >= len(lst):
  253. if not create:
  254. raise PathError(f'{name}[{idx}] 越界或非列表')
  255. while idx >= len(lst):
  256. lst.append({})
  257. if last:
  258. return lst, idx
  259. cur = lst[idx]
  260. raise PathError('路径为空')
  261. # ── --create 自动建结构用的骨架 ─────────────────────────────────────────────
  262. _STEP_SCALARS = {'effect', 'substance', 'form', 'via', 'action', 'directive', 'kind', 'intent', 'group'}
  263. def _new_procedure(pid: str) -> dict:
  264. return {
  265. 'id': pid, 'name': '', 'purpose': '', 'category': '', 'platform': '', 'author': '',
  266. 'declarations': {'inputs': [], 'resources': [], 'returns': {}},
  267. 'steps': [],
  268. }
  269. def _new_step(sid: str) -> dict:
  270. return {'id': sid, 'kind': 'step', 'via': '', 'inputs': [], 'outputs': []}
  271. def _new_io(is_output: bool, sid: str, idx: int) -> dict:
  272. if is_output:
  273. return {'id': f'{sid}o{idx + 1}', 'type': '', 'value': '', 'anchor': ''}
  274. return {'type': '', 'value': '', 'anchor': ''}
  275. def _split_step_path(remainder: str):
  276. """create 模式下 step 不存在、无法前缀匹配时, 从路径切出 (sid, fsegs).
  277. 规则: 出现 inputs[i]/outputs[i] 段 → 其前为 sid; 否则末段须是已知 step 标量字段, 其前为 sid.
  278. sid 可含点 (嵌套步 s2.1)。切不出返回 (None, None) — 不创建, 避免误建。
  279. """
  280. segs = remainder.split('.')
  281. for j, s in enumerate(segs):
  282. nm, _ = _split_seg(s)
  283. if nm in ('inputs', 'outputs'):
  284. return ('.'.join(segs[:j]) or None), segs[j:]
  285. if segs[-1] in _STEP_SCALARS:
  286. return ('.'.join(segs[:-1]) or None), [segs[-1]]
  287. return None, None
  288. def locate(data: dict, path: str, create: bool = False):
  289. """把 path 解析到目标. 返回 (parent, key, proc, field_name).
  290. 设置即 parent[key] = value. proc 给校验提供 type_registry 上下文.
  291. proc / step 主用 id 寻址 (p1/s1), 也接受下标 procedures[N]/steps[M]; inputs/outputs 用 [i] 下标.
  292. step id 可能带点 (嵌套步 s2.1) — 用最长前缀匹配消歧 (s2.1 优先于 s2).
  293. create=True (构建模式): 缺失的 procedure / step / IO 元素 / 中间结构自动创建, 新建打印到 stderr.
  294. """
  295. if '.' not in path:
  296. raise PathError(f'路径太短 {path!r}, 至少 <proc>.<字段> 或 source.<字段>')
  297. proc_id, remainder = path.split('.', 1)
  298. # --- source.* 分支 (case-level 原帖信息, 无 proc 上下文) ---
  299. if proc_id == 'source':
  300. src = data.setdefault('source', {})
  301. parent, key = _descend(src, remainder.split('.'), create=create)
  302. return parent, key, None, (key if isinstance(key, str) else '')
  303. procs = data.setdefault('procedures', [])
  304. # 接受两种 proc 寻址: id (p1) 或下标别名 procedures[N] (映射到第 N 个工序; 弱模型爱用这种)
  305. m_idx = re.match(r'^procedures\[(\d+)\]$', proc_id)
  306. if m_idx:
  307. idx = int(m_idx.group(1))
  308. if idx < len(procs):
  309. proc = procs[idx]
  310. elif create:
  311. proc = _new_procedure(f'p{idx + 1}')
  312. procs.append(proc)
  313. print(f'[wf-patch] + 新建 procedure p{idx + 1} (来自 procedures[{idx}])', file=sys.stderr)
  314. else:
  315. raise PathError(f'procedures[{idx}] 越界 (现有 {len(procs)} 个工序)')
  316. else:
  317. proc = next((p for p in procs if p.get('id') == proc_id), None)
  318. if proc is None:
  319. if not create:
  320. ids = [p.get('id') for p in procs]
  321. raise PathError(f'找不到 procedure id={proc_id!r} (现有: {ids})')
  322. proc = _new_procedure(proc_id)
  323. procs.append(proc)
  324. print(f'[wf-patch] + 新建 procedure {proc_id}', file=sys.stderr)
  325. # --- type_registry 分支 (允许自动建段/条目) ---
  326. if remainder == 'type_registry' or remainder.startswith('type_registry.'):
  327. parts = remainder.split('.')
  328. if len(parts) == 3:
  329. reg = proc.setdefault('type_registry', {})
  330. entry = reg.setdefault(parts[1], {})
  331. return entry, parts[2], proc, parts[2]
  332. raise PathError('type_registry 路径形如 p1.type_registry.<类型名>.<extends|desc>')
  333. # --- step 分支: 支持 id 寻址 (p1.s1.effect) 和下标寻址 (procedures[0].steps[0].effect) ---
  334. matched, field_part = None, None
  335. m_step = re.match(r'^steps\[(\d+)\]\.(.+)$', remainder) # 下标寻址 steps[N].<字段>
  336. if m_step:
  337. sidx = int(m_step.group(1))
  338. steps = proc.setdefault('steps', [])
  339. if sidx < len(steps):
  340. matched = steps[sidx]
  341. elif create:
  342. matched = _new_step(f's{sidx + 1}')
  343. steps.append(matched)
  344. print(f'[wf-patch] + 新建 step (procedures.steps[{sidx}] → id s{sidx + 1})', file=sys.stderr)
  345. else:
  346. raise PathError(f'steps[{sidx}] 越界 (该工序现有 {len(steps)} 步)')
  347. field_part = m_step.group(2)
  348. else: # id 寻址: 最长前缀匹配现有 step id
  349. for s in (proc.get('steps') or []):
  350. sid = s.get('id')
  351. if not sid:
  352. continue
  353. if remainder == sid:
  354. raise PathError(f'step 路径要带字段, 形如 {proc_id}.{sid}.effect')
  355. if remainder.startswith(sid + '.') and (matched is None or len(sid) > len(matched['id'])):
  356. matched = s
  357. if matched is None and create:
  358. sid_new, _ = _split_step_path(remainder)
  359. if sid_new:
  360. matched = _new_step(sid_new)
  361. proc.setdefault('steps', []).append(matched)
  362. print(f'[wf-patch] + 新建 step {proc_id}.{sid_new}', file=sys.stderr)
  363. if matched is not None:
  364. field_part = remainder[len(matched['id']) + 1:] # 'sid.' 之后
  365. if matched is not None:
  366. sid = matched.get('id', '?')
  367. fsegs = field_part.split('.')
  368. name2, idx2 = _split_seg(fsegs[0])
  369. if name2 in ('inputs', 'outputs'):
  370. if idx2 is None:
  371. raise PathError(f'{name2} 要带下标, 形如 {name2}[0]')
  372. lst = matched.get(name2)
  373. if not isinstance(lst, list):
  374. if create:
  375. matched[name2] = lst = []
  376. else:
  377. raise PathError(f'{proc_id}.{sid}.{name2}[{idx2}] 越界 (该 step 有 0 个 {name2})')
  378. if idx2 >= len(lst):
  379. if not create:
  380. raise PathError(f'{proc_id}.{sid}.{name2}[{idx2}] 越界 (该 step 有 {len(lst)} 个 {name2})')
  381. while idx2 >= len(lst):
  382. lst.append(_new_io(name2 == 'outputs', sid, len(lst)))
  383. if len(fsegs) != 2:
  384. raise PathError(f'IO 路径形如 {proc_id}.{sid}.{name2}[{idx2}].anchor')
  385. return lst[idx2], fsegs[1], proc, fsegs[1]
  386. else:
  387. if len(fsegs) != 1:
  388. raise PathError(f'step 标量字段形如 {proc_id}.{sid}.{name2}')
  389. return matched, name2, proc, name2
  390. # --- proc 内其余路径: 头部字段 / declarations.* / return_row.* 等, 走通用下钻 ---
  391. parent, key = _descend(proc, remainder.split('.'), create=create)
  392. return parent, key, proc, (key if isinstance(key, str) else '')
  393. # ===========================================================================
  394. # 透传回填: anchor 为纯 ← sN.varname 的 IO, 从源 output 抄 value (逐字回填)
  395. # ===========================================================================
  396. def _is_fillable(value) -> bool:
  397. """该 value 算「还没真正回填」吗 — 空 / 占位符 / 引用文案."""
  398. if value in (None, '', '-'):
  399. return True
  400. return bool(META_REF.search(str(value)))
  401. def _passthrough_id(anchor):
  402. """anchor 为 `← <output-id>` (可带 [i] 容器索引) → 返回 output id; 否则 None.
  403. `← 工序输入` / `← s6 (链)` 等非纯 id 引用返回 None (无法确定唯一源 value).
  404. """
  405. m = re.match(r'^\s*←\s*([^\s\[((]+)', str(anchor or ''))
  406. if not m:
  407. return None
  408. return m.group(1) or None
  409. def resolve_passthrough(data: dict):
  410. """把 anchor 为纯透传 (← <output-id>)、value 仍空或占位的 input, 用源 output 的 value 逐字填上.
  411. 迭代到不动点 (处理链式透传). 返回 (filled_msgs, warn_msgs).
  412. """
  413. out_index = {} # output id -> output item (读 value)
  414. for p in data.get('procedures') or []:
  415. for s in p.get('steps') or []:
  416. for o in s.get('outputs') or []:
  417. if isinstance(o, dict) and o.get('id'):
  418. out_index[o['id']] = o
  419. def _src_value(rid):
  420. src = out_index.get(rid)
  421. if src is None or _is_fillable(src.get('value')):
  422. return None
  423. return src['value']
  424. filled: list[str] = []
  425. changed, rounds = True, 0
  426. while changed and rounds < 20:
  427. changed, rounds = False, rounds + 1
  428. for p in data.get('procedures') or []:
  429. for s in p.get('steps') or []:
  430. for idx, io in enumerate(s.get('inputs') or []):
  431. if not isinstance(io, dict) or not _is_fillable(io.get('value')):
  432. continue
  433. rid = _passthrough_id(io.get('anchor'))
  434. val = _src_value(rid) if rid else None
  435. if val is None:
  436. continue
  437. io['value'] = val
  438. filled.append(
  439. f"{p.get('id')}.{s.get('id')}.inputs[{idx}].value "
  440. f"← 复制自 {rid} ({len(str(val))} 字)"
  441. )
  442. changed = True
  443. # 仍填不动的透传 (源 id 找不到) → warn
  444. warns: list[str] = []
  445. for p in data.get('procedures') or []:
  446. for s in p.get('steps') or []:
  447. for idx, io in enumerate(s.get('inputs') or []):
  448. if not isinstance(io, dict) or not _is_fillable(io.get('value')):
  449. continue
  450. rid = _passthrough_id(io.get('anchor'))
  451. if rid and out_index.get(rid) is None:
  452. warns.append(
  453. f"{p.get('id')}.{s.get('id')}.inputs[{idx}] anchor 指向 "
  454. f"{rid} 但找不到该 output id (检查 anchor / output id)"
  455. )
  456. return filled, warns
  457. # ===========================================================================
  458. # 应用
  459. # ===========================================================================
  460. def load_patches(args) -> list[tuple[str, str]]:
  461. """汇总 --set、--patch 与 --set-file 成 [(path, value), ...]."""
  462. def _norm(v):
  463. if isinstance(v, str):
  464. # 将中文全角双角/单引号自动归一化为标准半角引号,更利于 AI 生图引擎和 Prompt 语法识别
  465. v = v.replace('“', '"').replace('”', '"').replace('‘', "'").replace('’', "'")
  466. return v
  467. def _dq(x: str) -> str:
  468. # 剥掉成对外层引号. cmd.exe 不剥单引号 → agent 写的 --set 'p.f=v' / p.f='v'
  469. # 会把单引号原样传进来, 导致 path 变成 'p1 这种 (找不到 procedure). 这里兜底剥掉.
  470. x = x.strip()
  471. if len(x) >= 2 and x[0] == x[-1] and x[0] in ('"', "'"):
  472. x = x[1:-1].strip()
  473. return x
  474. out: list[tuple[str, str]] = []
  475. for s in args.set or []:
  476. s = _dq(s) # 整体外层引号 (cmd 没剥的 'path=value')
  477. if '=' not in s:
  478. raise SystemExit(f'wf-patch: --set 缺 "=" : {s!r} (形如 path=value)')
  479. path, value = s.split('=', 1) # 只切第一个 '='
  480. out.append((_dq(path), _norm(_dq(value))))
  481. # 🟢 新增:从外部文件读取值注入
  482. for sf in getattr(args, 'set_file', None) or []:
  483. if '=' not in sf:
  484. raise SystemExit(f'wf-patch: --set-file 缺 "=" : {sf!r} (形如 path=file_path)')
  485. path, fpath_str = sf.split('=', 1)
  486. fpath = Path(fpath_str.strip())
  487. if not fpath.exists():
  488. raise SystemExit(f'wf-patch: --set-file 指定的文件不存在: {fpath_str}')
  489. try:
  490. value = fpath.read_text(encoding='utf-8')
  491. except Exception as e:
  492. raise SystemExit(f'wf-patch: 无法读取 --set-file 指定的文件 {fpath_str}: {e}')
  493. out.append((path.strip(), _norm(value)))
  494. if args.patch:
  495. if not args.patch.exists():
  496. raise SystemExit(
  497. f'wf-patch: --patch 文件不存在 {args.patch}\n'
  498. f' → --patch 的清单文件是**你要先写的输入**(扁平 `[{{"path":..,"value":..}}]` JSON)。\n'
  499. f' 先 write_file 写出它, 再 `--patch` 跑它;\n'
  500. f' 或字段不多时直接内联: `--set \'p1.s1.inputs[0].type=参考图\' --set ...`(不用文件)。')
  501. try:
  502. items = json.loads(args.patch.read_text(encoding='utf-8'))
  503. except json.JSONDecodeError as e:
  504. raise SystemExit(f'wf-patch: --patch 不是合法 JSON: {e}')
  505. for it in items:
  506. out.append((it['path'], _norm(it['value'])))
  507. return out
  508. # ===========================================================================
  509. # @quote 标记回填: value/directive 写 `@quote|起锚|止锚` 或 `@quote|关键词`,
  510. # 由 --resolve-quotes 顺标记从原文/配图 OCR 匹配真实内容, 批量替换 (空白无关匹配)
  511. # ===========================================================================
  512. def _q_source_text(path: Path) -> str:
  513. d = json.loads(path.read_text(encoding='utf-8'))
  514. if not isinstance(d, dict):
  515. return str(d)
  516. return '\n'.join(str(d.get(k, '')) for k in ('title', 'body_text') if d.get(k))
  517. def _q_norm(text: str):
  518. chars, idx = [], []
  519. for i, ch in enumerate(text):
  520. if ch.isspace():
  521. continue
  522. chars.append(ch)
  523. idx.append(i)
  524. return ''.join(chars), idx
  525. def _q_range_between(text: str, frm: str, to: str):
  526. ns, idx = _q_norm(text)
  527. nf, _ = _q_norm(frm)
  528. nt, _ = _q_norm(to)
  529. if not nf or not nt:
  530. return None
  531. p = ns.find(nf)
  532. if p < 0:
  533. return None
  534. q = ns.find(nt, p + len(nf))
  535. if q < 0:
  536. return None
  537. return text[idx[p]: idx[q + len(nt) - 1] + 1]
  538. def _q_braces(text: str, lo: int, hi: int):
  539. depth, op, i = 0, None, lo
  540. while i >= 0:
  541. if text[i] == '}':
  542. depth += 1
  543. elif text[i] == '{':
  544. if depth == 0:
  545. op = i
  546. break
  547. depth -= 1
  548. i -= 1
  549. if op is None:
  550. return None
  551. depth, j = 0, op
  552. while j < len(text):
  553. if text[j] == '{':
  554. depth += 1
  555. elif text[j] == '}':
  556. depth -= 1
  557. if depth == 0:
  558. return (op, j) if j >= hi else None
  559. j += 1
  560. return None
  561. def _q_query_block(text: str, query: str):
  562. ns, idx = _q_norm(text)
  563. nq, _ = _q_norm(query)
  564. if not nq:
  565. return None
  566. p = ns.find(nq)
  567. if p < 0:
  568. return None
  569. o_lo, o_hi = idx[p], idx[p + len(nq) - 1]
  570. br = _q_braces(text, o_lo, o_hi) # 命中落在 {...} 内 → 返回整块
  571. if br:
  572. return text[br[0]: br[1] + 1]
  573. lo = text.rfind('\n', 0, o_lo) + 1 # 否则返回所在行/段
  574. hi = text.find('\n', o_hi)
  575. return text[lo: (hi if hi >= 0 else len(text))].strip()
  576. def resolve_quotes(data: dict, corpora: list):
  577. """扫 value/directive 里的 `@quote|...` 标记, 顺标记从 corpora 匹配真实内容批量替换.
  578. 标记: `@quote|起锚|止锚` (范围, 推荐长段) 或 `@quote|关键词` (命中落 JSON 块返回整块, 否则所在行/段).
  579. corpora: [(label, text), ...] 依次尝试 (原文优先, 再 OCR). 返回 (filled, warns).
  580. """
  581. filled, warns = [], []
  582. def _resolve(v: str):
  583. parts = v[len('@quote|'):].split('|')
  584. for label, text in corpora:
  585. r = _q_range_between(text, parts[0], parts[1]) if len(parts) >= 2 else _q_query_block(text, parts[0])
  586. if r:
  587. return r, label
  588. return None, None
  589. for p in data.get('procedures') or []:
  590. pid = p.get('id')
  591. for s in p.get('steps') or []:
  592. sid = s.get('id')
  593. d = s.get('directive')
  594. if isinstance(d, str) and d.startswith('@quote|'):
  595. r, label = _resolve(d)
  596. if r:
  597. s['directive'] = r
  598. filled.append(f'{pid}.{sid}.directive ← [{label}] {len(r)} 字')
  599. else:
  600. warns.append(f'{pid}.{sid}.directive: @quote 未匹配 {d[:40]!r}')
  601. for kind in ('inputs', 'outputs'):
  602. for k, item in enumerate(s.get(kind) or []):
  603. if not isinstance(item, dict):
  604. continue
  605. v = item.get('value')
  606. if isinstance(v, str) and v.startswith('@quote|'):
  607. r, label = _resolve(v)
  608. if r:
  609. item['value'] = r
  610. filled.append(f'{pid}.{sid}.{kind}[{k}].value ← [{label}] {len(r)} 字')
  611. else:
  612. warns.append(f'{pid}.{sid}.{kind}[{k}].value: @quote 未匹配 {v[:40]!r}')
  613. return filled, warns
  614. def prune_patch_file(patch_path: Path, value_errors: list) -> int | None:
  615. """把 --patch 文件裁成只剩**未成功 (value 校验失败)** 的条目, 成功的删掉。
  616. 成功项已落盘到 workflow.json, 留在 patch 里只会重复应用; 裁掉后 patch 文件就是
  617. "剩余待修清单", 每条带 `_error` 失败原因 (再读时 path/value 之外的键被忽略, 无副作用)。
  618. 改完 value 重跑同一文件直到清空为 []。返回剩余条数; 文件读不动/不是数组返回 None。
  619. """
  620. try:
  621. items = json.loads(patch_path.read_text(encoding='utf-8'))
  622. except Exception:
  623. return None
  624. if not isinstance(items, list):
  625. return None
  626. err_by_path: dict = {}
  627. for p, _field, msg in value_errors:
  628. err_by_path.setdefault(p, msg) # 同 path 多错只留第一条
  629. remaining = []
  630. for it in items:
  631. if isinstance(it, dict) and it.get('path') in err_by_path:
  632. it = {k: v for k, v in it.items() if k != '_error'}
  633. it['_error'] = err_by_path[it['path']]
  634. remaining.append(it)
  635. patch_path.write_text(json.dumps(remaining, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  636. return len(remaining)
  637. def main() -> None:
  638. ap = argparse.ArgumentParser(
  639. prog='wf-patch.py',
  640. description='workflow.json 安全批量字段设置器 (写入即校验, 任何一条非法整批不写)',
  641. )
  642. ap.add_argument('--workflow', type=Path, required=True, help='目标 workflow.json')
  643. ap.add_argument('--set', action='append', metavar='PATH=VALUE',
  644. help='单条赋值, 可重复. 只在第一个 = 处切; value 可含 = 和空格 (记得整体加引号)')
  645. ap.add_argument('--patch', type=Path, default=None,
  646. help='批量赋值清单 .json: [{"path":..,"value":..}, ...]')
  647. ap.add_argument('--set-file', action='append', metavar='PATH=FILE_PATH', default=None,
  648. help='从外部文件读取内容注入指定字段. e.g. p1.s1.outputs[0].value=_scratch/prompt.txt')
  649. ap.add_argument('--unset', action='append', metavar='PATH', default=None,
  650. help='删字段, 可重复. e.g. p1.declarations.inputs[0].inferred (字段不存在则跳过). 取代手 Edit 删字段')
  651. ap.add_argument('--resolve-passthrough', action='store_true',
  652. help='把 anchor 为纯透传 (← sN.varname)、value 仍空/占位的 IO, 顺 anchor 从源 output 逐字抄 value. 可单独跑, 也可跟在 --set/--patch 后 (先赋值再解析). 迭代处理链式透传')
  653. ap.add_argument('--dry-run', action='store_true', help='只校验/预演, 不写')
  654. ap.add_argument('--create', action='store_true',
  655. help='(已默认开启, 保留兼容) 缺失的 procedure / step / IO 元素自动创建、文件不存在从空建')
  656. ap.add_argument('--no-create', action='store_true',
  657. help='关闭自动建: 路径不存在就报错 (严格防 typo; 仅在「纯填充已存在结构、想抓打错的路径」时用)')
  658. ap.add_argument('--resolve-quotes', action='store_true',
  659. help='把 value/directive 里的 @quote|起锚|止锚 (或 @quote|关键词) 标记, 顺标记从 --source 原文 / --ocr 配图文本匹配真实内容批量替换. 跟 anchor patch 一起跑')
  660. ap.add_argument('--source', type=Path, default=None, help='--resolve-quotes 的原文 case json (匹配语料)')
  661. ap.add_argument('--ocr', type=Path, default=None, help='--resolve-quotes 的配图 OCR 文本文件 (第二语料)')
  662. ap.add_argument('--prune', action='store_true',
  663. help='跟 --patch 配合: 应用后把 patch 文件裁成只剩**未成功**的条目(成功的已落盘→删掉, '
  664. '失败的标 _error 原因保留)。patch 文件即变"剩余待修清单", 改完重跑同一文件直到清空。')
  665. args = ap.parse_args()
  666. # 默认 upsert (缺路径自动建); 弱模型增量 patch 总需要它, gated 反而一直撞「越界/不存在」死循环。
  667. # --no-create 才关闭 (回到严格寻址、防 typo)。--create 保留为 no-op 兼容旧命令/文档。
  668. args.create = not args.no_create
  669. wf = args.workflow
  670. repaired = 0
  671. if args.create and not wf.exists():
  672. data = {}
  673. print(f'[wf-patch] + 新建 workflow 文件 {wf.name} (从空开始构建)', file=sys.stderr)
  674. elif not wf.exists():
  675. print(f'wf-patch: 文件不存在 {wf} (默认会从空新建; 你传了 --no-create 才不建)', file=sys.stderr)
  676. sys.exit(2)
  677. else:
  678. raw = wf.read_text(encoding='utf-8')
  679. try:
  680. data = json.loads(raw)
  681. except json.JSONDecodeError as e:
  682. # 兜底: 试着把误写成 ASCII 的中文引号修成「」再 parse (模型直 Write 常见崩因)
  683. fixed, repaired = repair_ascii_quotes(raw)
  684. try:
  685. data = json.loads(fixed)
  686. except json.JSONDecodeError:
  687. print(f'wf-patch: {wf} 不是合法 JSON, 无法处理: {e}\n'
  688. f' → 去 workflow.json 第 {e.lineno} 行附近修语法 '
  689. f'(最常见: 数组 ] 或 对象 }} 结束后、下一个 "key" 前缺逗号), 修好再重跑。'
  690. f'别盲目重试本命令 (JSON 没修, 每次都同样报错)。', file=sys.stderr)
  691. sys.exit(2)
  692. print(f'[wf-patch] ⚠️ 原文件 JSON 非法 ({e.msg} @ line {e.lineno}); 已自动把 '
  693. f'{repaired} 处误写的 ASCII 引号修成「」→ 解析成功, 修复将随本次写回落盘',
  694. file=sys.stderr)
  695. patches = load_patches(args)
  696. unsets = args.unset or []
  697. if not patches and not unsets and not args.resolve_passthrough and not args.resolve_quotes:
  698. if args.patch: # --patch 给了但解析为空 [] (多半是 --prune 收敛后): 算完成, 不是误用
  699. print(f'wf-patch: {args.patch.name} 为空 [], 没有待应用项 (patch 已全部完成)。')
  700. sys.exit(0)
  701. print('wf-patch: 没有 --set / --patch / --unset / --resolve-passthrough / --resolve-quotes, 啥也没干', file=sys.stderr)
  702. sys.exit(2)
  703. # 解析 + 校验; 任何一条失败 -> 整批不写
  704. pending_types = set()
  705. for path, _ in patches:
  706. m = re.match(r'^p\d+\.type_registry\.([^.]+)\.(extends|desc)$', path)
  707. if m:
  708. pending_types.add(m.group(1))
  709. plan = [] # set: (parent, key, normalized_value, path, display)
  710. del_plan = [] # unset: (parent, key, path)
  711. skipped = [] # unset 跳过 (字段本就不在)
  712. # 错误分两类, 决定原子 vs 部分应用:
  713. # fatal = 路径/结构错 (locate 失败: 路径对不上、缺 --create 等) → 整批不写, 保护结构
  714. # value = 字段值校验失败 (effect/action/type 等值非法) → **跳过这条、其余照写** (部分应用),
  715. # 末尾以 exit 1 提示回去补。这样一批里几个值错不再连累其余正确字段一起丢。
  716. fatal_errors = [] # (path, msg)
  717. value_errors = [] # (path, msg)
  718. for path, value in patches:
  719. try:
  720. parent, key, proc, field = locate(data, path, create=args.create)
  721. except PathError as e:
  722. fatal_errors.append((path, str(e)))
  723. continue
  724. ok, norm, msg = validate_field(field, value, proc, pending_types)
  725. if not ok:
  726. value_errors.append((path, field, msg))
  727. continue
  728. plan.append((parent, key, norm, path, norm if norm is not None else 'null'))
  729. for path in unsets:
  730. try:
  731. parent, key, _proc, _field = locate(data, path)
  732. except PathError as e:
  733. fatal_errors.append((path, str(e)))
  734. continue
  735. present = (isinstance(parent, dict) and key in parent) or \
  736. (isinstance(parent, list) and isinstance(key, int) and key < len(parent))
  737. (del_plan if present else skipped).append((parent, key, path) if present else path)
  738. if patches or unsets:
  739. n_fail = len(fatal_errors) + len(value_errors)
  740. print(f'[wf-patch] {wf.name} — set {len(plan)}/{len(patches)} 通过, '
  741. f'unset {len(del_plan)} 删/{len(skipped)} 跳过, {n_fail} 失败')
  742. for _p, _k, _n, path, disp in plan:
  743. print(f' ✓ set {path} = {disp}')
  744. for _p, _k, path in del_plan:
  745. print(f' ✓ unset {path}')
  746. for path in skipped:
  747. print(f' · skip {path} (字段本就不存在)')
  748. for path, _field, msg in value_errors:
  749. print(f' ✗ {path} — {msg}')
  750. for path, msg in fatal_errors:
  751. print(f' ✗✗ {path} — {msg} [路径/结构错]')
  752. # 结构错 = patch 清单跟文件对不上 → 整批不写 (原子, 别半建坏骨架)
  753. if fatal_errors:
  754. print(f'\n有 {len(fatal_errors)} 条路径/结构错误, 整批未写入 (先修: 路径写对 / 该建的加 --create).',
  755. file=sys.stderr)
  756. sys.exit(1)
  757. # 只有字段值非法 → 跳过这几条、其余照常写, 进度不丢; 末尾 exit 1 提示去补
  758. if value_errors:
  759. # 把出错字段类型的**完整合法清单**打出来 (每类一次), 模型直接照抄、别再瞎猜反复试
  760. shown = []
  761. for _p, f, _m in value_errors:
  762. key = 'type' if f == 'extends' else f
  763. if key in ('effect', 'action', 'type') and key not in shown:
  764. shown.append(key)
  765. for key in shown:
  766. print(f' ↳ {_leaf_menu(key)}', file=sys.stderr)
  767. print(f'\n{len(value_errors)} 处字段值非法**已跳过未写**, 其余 {len(plan)} 处照常应用; '
  768. f'照上面清单选合法叶子, 修正这几条后重跑补上 (进度不丢, 别整批重来).', file=sys.stderr)
  769. # 应用到内存 data (set 先 unset 后; resolve 要看到这些改动). 是否持久化由 dry-run 决定.
  770. for parent, key, norm, _, _ in plan:
  771. parent[key] = norm
  772. for parent, key, _path in sorted(del_plan, key=lambda d: -d[1] if isinstance(d[1], int) else 0):
  773. if isinstance(parent, list):
  774. parent.pop(key)
  775. else:
  776. del parent[key]
  777. # 透传回填
  778. filled, warns = [], []
  779. if args.resolve_passthrough:
  780. filled, warns = resolve_passthrough(data)
  781. print(f'[resolve-passthrough] 回填 {len(filled)} 处透传 value, {len(warns)} 处填不动')
  782. for m in filled:
  783. print(f' ✓ {m}')
  784. for w in warns:
  785. print(f' ⚠ {w}')
  786. # @quote 标记回填 (顺 @quote|起锚|止锚 从原文/OCR 匹配真实内容批量替换)
  787. quoted, qwarns = [], []
  788. if args.resolve_quotes:
  789. corpora = []
  790. if args.source and args.source.exists():
  791. corpora.append(('原文', _q_source_text(args.source)))
  792. if args.ocr and args.ocr.exists():
  793. corpora.append(('配图OCR', args.ocr.read_text(encoding='utf-8')))
  794. if not corpora:
  795. print('wf-patch: --resolve-quotes 需要 --source (或 --ocr) 指向匹配语料', file=sys.stderr)
  796. else:
  797. quoted, qwarns = resolve_quotes(data, corpora)
  798. print(f'[resolve-quotes] 回填 {len(quoted)} 处 @quote 标记, {len(qwarns)} 处未匹配')
  799. for m in quoted:
  800. print(f' ✓ {m}')
  801. for w in qwarns:
  802. print(f' ⚠ {w}')
  803. # 有字段值非法被跳过 → 退出码 1 (告诉 agent 还有几条要补), 但有效改动已照常落盘
  804. final_exit = 1 if value_errors else 0
  805. # --prune: 把 --patch 文件裁成只剩未成功项 (成功的已落盘→删, 失败的标 _error 留待修)。
  806. # dry-run 不动文件 (预演不该有副作用); fatal 早已 exit, 到这里只剩 value 错或全成功。
  807. if args.prune and args.patch and not args.dry_run:
  808. nrem = prune_patch_file(args.patch, value_errors)
  809. if nrem is not None:
  810. if nrem:
  811. print(f'\n[prune] 已把 {args.patch.name} 裁为剩余 {nrem} 条未成功项 '
  812. f'(成功的已删, 每条带 _error 原因); 修正 value 后重跑同一文件即可。', file=sys.stderr)
  813. else:
  814. print(f'\n[prune] {args.patch.name} 全部成功, 已清空为 []。', file=sys.stderr)
  815. n_changes = len(plan) + len(del_plan) + len(filled) + len(quoted)
  816. if args.dry_run:
  817. extra = f' (+ 自动修复 {repaired} 处引号, dry-run 同样不写)' if repaired else ''
  818. print(f'\n--dry-run: 预演 {n_changes} 处改动{extra}, 未写入.')
  819. sys.exit(final_exit)
  820. # repaired>0 时即便无字段改动也要落盘 (否则修好的引号没存下来, 文件还是坏的)
  821. if n_changes == 0 and not repaired:
  822. print('\n没有改动 (透传 value 都已填好 / 无可赋值), 未写文件.')
  823. sys.exit(final_exit)
  824. # 落盘 (安全序列化, 你从不手写 JSON)
  825. wf.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  826. tail = f' (含自动修复 {repaired} 处引号→「」)' if repaired else ''
  827. print(f'\n已写入 {n_changes} 处到 {wf.name}{tail}.')
  828. sys.exit(final_exit)
  829. if __name__ == '__main__':
  830. main()