verify-io.py 12 KB

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