verify-io.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """verify-io.py — Phase 1 收口的专用「IO 校验」步骤 (填完 value 后、进 Phase 2 前必跑)。
  4. 专门校验四件事, 报问题就回去补 (修完重跑直到通过):
  5. 1. **文本 IO 逐字**: 每个**文本类** input/output 的 value 是不是在原文(正文+配图 OCR)里有
  6. 连续对应 —— 媒体类(图/视频/音频)IO **不要求**对应, 直接跳过。
  7. 2. **提示词提取**: 每个**工具生成步**(via 是具体生成工具)是不是把原文给这步的整段 prompt
  8. 提取进了 directive 或某个文本输入 —— 弱模型常把生成步建成「媒体输入→媒体输出」, 把原文
  9. 那段 prompt(最该提取的内容, 如「半剖面视图…」「视觉层:…技术层:…」)整段漏掉。
  10. 3. **declarations 收口**: 每个工序的 declarations.returns 补了没(必), inputs/resources 提醒。
  11. 4. **anchor 闭合**: 透传输入(有 ← anchor)是不是真把 value/type 回填了 —— 弱模型常**漏跑
  12. `wf-patch --resolve-passthrough`** 或把 anchor 写成 JSON 路径(← p1.s1.outputs[0])而非
  13. 输出编号(← s1o1), 两种都会让透传 value 静默留空、一路漏到成品。
  14. 退出码: 0 通过 / 1 有阻塞项要修 / 2 CLI/文件错。
  15. 用法:
  16. python spec/tools/verify-io.py --workflow outputs/case-{N}/workflow.json \
  17. --source input/case-{N}.json --ocr outputs/case-{N}/_scratch/ocr.txt
  18. """
  19. import argparse
  20. import importlib.util
  21. import re
  22. import sys
  23. from pathlib import Path
  24. import json
  25. # 媒体类 value 应整体用 <…> 包成 <具体描述> (强模型参考一致): <极简写实背景图:深钢蓝-松绿-冷白渐层…>
  26. _MEDIA_BRACKET_OK = re.compile(r'^\s*<.+>\s*$', re.S)
  27. DSL_ROOT = Path(__file__).resolve().parent.parent.parent
  28. for _s in (sys.stdout, sys.stderr):
  29. if hasattr(_s, 'reconfigure'):
  30. try:
  31. _s.reconfigure(encoding='utf-8', errors='replace')
  32. except Exception:
  33. pass
  34. def _load_lint():
  35. """复用 lint-case.py 的 _norm / check_value_verbatim / _type_modality (文件名带连字符, 按路径载)."""
  36. p = Path(__file__).resolve().parent / 'lint-case.py'
  37. spec = importlib.util.spec_from_file_location('_lint_case_mod', p)
  38. mod = importlib.util.module_from_spec(spec)
  39. spec.loader.exec_module(mod)
  40. return mod
  41. # 装配/排版/人工类工具不要求 prompt (它们不是"按提示词生成"). 用较长的判别词, 避免 'na' 误伤 nanobanana
  42. _NON_PROMPT_VIA = ('human', 'ppt', 'adobe', 'illustrat', 'inkscape', 'photoshop',
  43. '剪辑', '剪映', '拼接', '排版', '拼版', '合成', '后期', '裁剪', '修图',
  44. '人工', 'excel', 'word')
  45. def _is_generation_via(via: str) -> bool:
  46. v = (via or '').strip().lower()
  47. if not v or v in ('-', 'n/a', 'na', '人工', '人'):
  48. return False
  49. return not any(k in v for k in _NON_PROMPT_VIA)
  50. # 原文 prompt 的**独特**分层/要素标记: 命中后原文紧跟这些 = 只提了一部分 (截断)。
  51. # 只放区分度高的 (视觉层/技术层/核心要素…); 不放 产品/人物/环境 这种常见词 (满篇都是, 会误判)。
  52. _LAYER_MARKERS = ('视觉层', '技术层', '氛围层', '核心要素', '视觉风格', '输出要求', '技术规格')
  53. def _has_prompt_type(t: str) -> bool:
  54. return '提示词' in (t or '') or 'prompt' in (t or '').lower()
  55. def check_prompt_extraction(data: dict, lint, source_norm: str) -> list:
  56. """提示词建模 + 完整性 (对齐强模型参考: 提示词是**数据**, 建成 type=提示词 的 IO value, directive 不装它):
  57. 1. 生成步必须有 type=提示词 的输入 (原文整段 prompt 当数据流入), 没有=漏建;
  58. 2. directive 里疑似装了提示词原文 = 放错地方, 该挪到 IO;
  59. 3. type=提示词 的 value 逐字但截断 (原文后面还有分层/要素) = 只提了一部分。
  60. (value 是否逐字由「文本 IO 逐字」那条统一管, 这里只管"建对地方"和"提全".)
  61. """
  62. issues = []
  63. for p in data.get('procedures', []):
  64. for s in p.get('steps', []) or []:
  65. if not isinstance(s, dict) or s.get('kind') == 'block':
  66. continue
  67. sid = f"{p.get('id')}.{s.get('id')}"
  68. via = (s.get('via') or '').strip()
  69. directive = (s.get('directive') or '').strip()
  70. # 2. directive 不该装提示词 (长且像在引原文) —— 不限生成步
  71. if directive:
  72. dn = lint._norm(directive)
  73. if len(dn) >= 12 and lint._longest_run(dn, source_norm) >= 12:
  74. issues.append(
  75. f"[{sid}] directive 里疑似装了提示词原文 —— directive 只放「给工具的指示」"
  76. f"(如严格反推/比例 2:3); **提示词原文是数据, 挪到 type=提示词 的输入/输出 value**。")
  77. # 1. 生成步必须有 type=提示词 的输入
  78. if _is_generation_via(via):
  79. has_prompt_in = any(_has_prompt_type(io.get('type', '')) for io in (s.get('inputs') or []))
  80. if not has_prompt_in:
  81. issues.append(
  82. f"[{sid}] via={via!r} 是生成步但**没有 type=提示词 的输入** —— 原文的整段 prompt 是数据, "
  83. f"建成 type=提示词 的输入(value 逐字完整; 通常由一个 human「写提示词」步 OUT=提示词、本步 anchor 引入), "
  84. f"别塞 directive、也别只写「XX需求」标签。原文确实没给详细 prompt 才可省。")
  85. # 3. type=提示词 的 value 逐字但截断 (原文紧跟还有分层/要素)
  86. for kind in ('inputs', 'outputs'):
  87. for io in (s.get(kind) or []):
  88. if not _has_prompt_type(io.get('type', '')):
  89. continue
  90. v = (io.get('value') or '').strip()
  91. if not v or v.startswith('<'):
  92. continue
  93. vn = lint._norm(v)
  94. if len(vn) < 8:
  95. continue
  96. pos = source_norm.find(vn)
  97. if pos >= 0:
  98. tail = source_norm[pos + len(vn): pos + len(vn) + 40]
  99. cont = [m for m in _LAYER_MARKERS if m in tail and m not in vn]
  100. if cont:
  101. issues.append(
  102. f"[{sid}].{kind[:3]} type=提示词 的 value 可能**只提了一部分** —— 原文后面紧跟 "
  103. f"{cont[:3]} 等, @quote 止锚要延到这段 prompt 真正结束(把所有分层/要素提全)。")
  104. return issues
  105. def check_media_brackets(data: dict, lint) -> list:
  106. """媒体类(图/视频/音频)IO 的 value 必须整体用 <…> 包成 <具体描述>, 别写裸标签."""
  107. issues = []
  108. for p in data.get('procedures', []):
  109. reg = p.get('type_registry') or {}
  110. for s in p.get('steps', []) or []:
  111. if not isinstance(s, dict) or s.get('kind') == 'block':
  112. continue
  113. for kind in ('inputs', 'outputs'):
  114. for io in (s.get(kind) or []):
  115. if io.get('inferred'):
  116. continue
  117. if lint._type_modality(io.get('type', ''), reg) != 'media':
  118. continue
  119. v = (io.get('value') or '').strip()
  120. if not v:
  121. continue
  122. if not _MEDIA_BRACKET_OK.match(v):
  123. issues.append(
  124. f"[{p.get('id')}.{s.get('id')}.{kind[:3]}] type={io.get('type', '')!r}(媒体) "
  125. f"value={v[:24]!r} 没用 <…> 包 —— 媒体类 value 写成 <具体描述>"
  126. f"(如 <一张冲锋衣登山者暴雨场景图,水珠滚落,冷色调>)。")
  127. return issues
  128. def check_declarations(data: dict) -> tuple:
  129. """returns 空 = 必补; inputs 空 = 提醒. 返回 (block_list, warn_list)."""
  130. block, warn = [], []
  131. for p in data.get('procedures', []):
  132. decl = p.get('declarations') or {}
  133. rets = decl.get('returns')
  134. if not rets or (isinstance(rets, dict) and not any(rets.values())):
  135. block.append(f"[{p.get('id')}] declarations.returns 空 — 补这个工序最终返回什么(type)")
  136. if not decl.get('inputs'):
  137. warn.append(f"[{p.get('id')}] declarations.inputs 空 — 确认外部输入(没有可不填)")
  138. return block, warn
  139. def main() -> None:
  140. ap = argparse.ArgumentParser(prog='verify-io.py', description='Phase 1 收口 IO 校验')
  141. ap.add_argument('--workflow', type=Path, required=True)
  142. ap.add_argument('--source', type=Path, required=True, help='原文 input/case-N.json (逐字比对用)')
  143. ap.add_argument('--ocr', type=Path, default=None, help='配图 OCR 文本 (可选, 并入比对语料)')
  144. args = ap.parse_args()
  145. if not args.workflow.exists():
  146. print(f'verify-io: workflow 不存在 {args.workflow}', file=sys.stderr)
  147. sys.exit(2)
  148. try:
  149. data = json.loads(args.workflow.read_text(encoding='utf-8'))
  150. except json.JSONDecodeError as e:
  151. print(f'verify-io: workflow 不是合法 JSON: {e}', file=sys.stderr)
  152. sys.exit(2)
  153. lint = _load_lint()
  154. _raw, source_norm = lint._load_source_corpus(args.source, args.ocr)
  155. if not source_norm:
  156. print(f'verify-io: 读不到原文 {args.source} (逐字校验需要它)', file=sys.stderr)
  157. sys.exit(2)
  158. case_id = data.get('case_id', '?')
  159. print(f'[verify-io] case-{case_id} ({args.workflow.name})')
  160. # 1. 文本 IO 逐字 (媒体跳过)
  161. io_hints = lint.check_value_verbatim(data, source_norm)
  162. if io_hints:
  163. print(f' · 文本 IO 逐字: {len(io_hints)} 处未对上原文 (媒体类已跳过)')
  164. for h in io_hints:
  165. print(f' ✗ {h}')
  166. print(' → 此时可重读原文; 用 @quote|起锚|止锚 + wf-patch --resolve-quotes 拽完整原文; '
  167. '原文确无则标 inferred:true+reason 或 value 写「原文未提供」。')
  168. else:
  169. print(' · 文本 IO 逐字: OK (文本类 value 都对上原文; 媒体类不要求)')
  170. # 2. 提示词提取
  171. prompt_issues = check_prompt_extraction(data, lint, source_norm)
  172. if prompt_issues:
  173. print(f' · 提示词提取: {len(prompt_issues)} 个生成步没提取到提示词')
  174. for h in prompt_issues:
  175. print(f' ✗ {h}')
  176. else:
  177. print(' · 提示词提取: OK (生成步都有提示词)')
  178. # 3. 媒体 value 的 <…> 包裹
  179. media_issues = check_media_brackets(data, lint)
  180. if media_issues:
  181. print(f' · 媒体 value <…>: {len(media_issues)} 处没包 (媒体类 value 要写 <具体描述>)')
  182. for h in media_issues:
  183. print(f' ✗ {h}')
  184. else:
  185. print(' · 媒体 value <…>: OK')
  186. # 4. declarations 收口
  187. decl_block, decl_warn = check_declarations(data)
  188. if decl_block or decl_warn:
  189. print(f' · declarations 收口: {len(decl_block)} 必补 / {len(decl_warn)} 待确认')
  190. for d in decl_block:
  191. print(f' ✗ {d}')
  192. for d in decl_warn:
  193. print(f' ⚠ {d}')
  194. print(' → 用 wf-patch 补 declarations.inputs/resources/returns, '
  195. '例: `--set p1.declarations.returns.type=产品概念图`。')
  196. else:
  197. print(' · declarations 收口: OK')
  198. # 5. anchor 闭合 (透传输入回填了没 + anchor 格式)
  199. anchor_issues = lint.check_anchor_closure(data)
  200. if anchor_issues:
  201. print(f' · anchor 闭合: {len(anchor_issues)} 处透传没回填 / anchor 写成 JSON 路径')
  202. for h in anchor_issues:
  203. print(f' ✗ {h}')
  204. print(' → 透传输入(有 ← anchor)的 value/type 要回填: 跑 '
  205. '`wf-patch.py --resolve-passthrough` 顺编号自动抄上游; anchor 用编号 ← s1o1, 别用 JSON 路径。')
  206. else:
  207. print(' · anchor 闭合: OK')
  208. # 6. 干骨架填满 (via 非空 + 输入有 value 或 anchor)
  209. skel_issues = lint.check_skeleton_filled(data)
  210. if skel_issues:
  211. print(f' · 干骨架填满: {len(skel_issues)} 处 Phase 1 骨架没填(via 空 / 输入既无 value 又无 anchor)')
  212. for h in skel_issues:
  213. print(f' ✗ {h}')
  214. print(' → Phase 2.0 要把骨架填满: 每步补 via, 每个输入要么填 value 要么连 anchor。')
  215. else:
  216. print(' · 干骨架填满: OK')
  217. n_block = (len(io_hints) + len(prompt_issues) + len(media_issues) + len(decl_block)
  218. + len(anchor_issues) + len(skel_issues))
  219. if n_block:
  220. print(f'\n有 {n_block} 个阻塞项要修 (文本IO {len(io_hints)} + 提示词 {len(prompt_issues)} '
  221. f'+ 媒体<…> {len(media_issues)} + returns {len(decl_block)} + anchor {len(anchor_issues)} '
  222. f'+ 骨架 {len(skel_issues)})。修完**重跑本脚本**确认通过, 才进 Phase 2。',
  223. file=sys.stderr)
  224. sys.exit(1)
  225. print('\n✓ IO 校验通过, 可进 Phase 2 归类标注。')
  226. sys.exit(0)
  227. if __name__ == '__main__':
  228. main()