| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """verify-io.py — Phase 1 收口的专用「IO 校验」步骤 (填完 value 后、进 Phase 2 前必跑)。
- 专门校验四件事, 报问题就回去补 (修完重跑直到通过):
- 1. **文本 IO 逐字**: 每个**文本类** input/output 的 value 是不是在原文(正文+配图 OCR)里有
- 连续对应 —— 媒体类(图/视频/音频)IO **不要求**对应, 直接跳过。
- 2. **提示词提取**: 每个**工具生成步**(via 是具体生成工具)是不是把原文给这步的整段 prompt
- 提取进了 directive 或某个文本输入 —— 弱模型常把生成步建成「媒体输入→媒体输出」, 把原文
- 那段 prompt(最该提取的内容, 如「半剖面视图…」「视觉层:…技术层:…」)整段漏掉。
- 3. **declarations 收口**: 每个工序的 declarations.returns 补了没(必), inputs/resources 提醒。
- 4. **anchor 闭合**: 透传输入(有 ← anchor)是不是真把 value/type 回填了 —— 弱模型常**漏跑
- `wf-patch --resolve-passthrough`** 或把 anchor 写成 JSON 路径(← p1.s1.outputs[0])而非
- 输出编号(← s1o1), 两种都会让透传 value 静默留空、一路漏到成品。
- 退出码: 0 通过 / 1 有阻塞项要修 / 2 CLI/文件错。
- 用法:
- python spec/tools/verify-io.py --workflow outputs/case-{N}/workflow.json \
- --source input/case-{N}.json --ocr outputs/case-{N}/_scratch/ocr.txt
- """
- import argparse
- import importlib.util
- import re
- import sys
- from pathlib import Path
- import json
- # 媒体类 value 应整体用 <…> 包成 <具体描述> (强模型参考一致): <极简写实背景图:深钢蓝-松绿-冷白渐层…>
- _MEDIA_BRACKET_OK = re.compile(r'^\s*<.+>\s*$', re.S)
- DSL_ROOT = Path(__file__).resolve().parent.parent.parent
- for _s in (sys.stdout, sys.stderr):
- if hasattr(_s, 'reconfigure'):
- try:
- _s.reconfigure(encoding='utf-8', errors='replace')
- except Exception:
- pass
- def _load_lint():
- """复用 lint-case.py 的 _norm / check_value_verbatim / _type_modality (文件名带连字符, 按路径载)."""
- p = Path(__file__).resolve().parent / 'lint-case.py'
- spec = importlib.util.spec_from_file_location('_lint_case_mod', p)
- mod = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(mod)
- return mod
- # 装配/排版/人工类工具不要求 prompt (它们不是"按提示词生成"). 用较长的判别词, 避免 'na' 误伤 nanobanana
- _NON_PROMPT_VIA = ('human', 'ppt', 'adobe', 'illustrat', 'inkscape', 'photoshop',
- '剪辑', '剪映', '拼接', '排版', '拼版', '合成', '后期', '裁剪', '修图',
- '人工', 'excel', 'word')
- def _is_generation_via(via: str) -> bool:
- v = (via or '').strip().lower()
- if not v or v in ('-', 'n/a', 'na', '人工', '人'):
- return False
- return not any(k in v for k in _NON_PROMPT_VIA)
- # 原文 prompt 的**独特**分层/要素标记: 命中后原文紧跟这些 = 只提了一部分 (截断)。
- # 只放区分度高的 (视觉层/技术层/核心要素…); 不放 产品/人物/环境 这种常见词 (满篇都是, 会误判)。
- _LAYER_MARKERS = ('视觉层', '技术层', '氛围层', '核心要素', '视觉风格', '输出要求', '技术规格')
- def _has_prompt_type(t: str) -> bool:
- return '提示词' in (t or '') or 'prompt' in (t or '').lower()
- def check_prompt_extraction(data: dict, lint, source_norm: str) -> list:
- """提示词建模 + 完整性 (对齐强模型参考: 提示词是**数据**, 建成 type=提示词 的 IO value, directive 不装它):
- 1. 生成步必须有 type=提示词 的输入 (原文整段 prompt 当数据流入), 没有=漏建;
- 2. directive 里疑似装了提示词原文 = 放错地方, 该挪到 IO;
- 3. type=提示词 的 value 逐字但截断 (原文后面还有分层/要素) = 只提了一部分。
- (value 是否逐字由「文本 IO 逐字」那条统一管, 这里只管"建对地方"和"提全".)
- """
- issues = []
- for p in data.get('procedures', []):
- for s in p.get('steps', []) or []:
- if not isinstance(s, dict) or s.get('kind') == 'block':
- continue
- sid = f"{p.get('id')}.{s.get('id')}"
- via = (s.get('via') or '').strip()
- directive = (s.get('directive') or '').strip()
- # 2. directive 不该装提示词 (长且像在引原文) —— 不限生成步
- if directive:
- dn = lint._norm(directive)
- if len(dn) >= 12 and lint._longest_run(dn, source_norm) >= 12:
- issues.append(
- f"[{sid}] directive 里疑似装了提示词原文 —— directive 只放「给工具的指示」"
- f"(如严格反推/比例 2:3); **提示词原文是数据, 挪到 type=提示词 的输入/输出 value**。")
- # 1. 生成步必须有 type=提示词 的输入
- if _is_generation_via(via):
- has_prompt_in = any(_has_prompt_type(io.get('type', '')) for io in (s.get('inputs') or []))
- if not has_prompt_in:
- issues.append(
- f"[{sid}] via={via!r} 是生成步但**没有 type=提示词 的输入** —— 原文的整段 prompt 是数据, "
- f"建成 type=提示词 的输入(value 逐字完整; 通常由一个 human「写提示词」步 OUT=提示词、本步 anchor 引入), "
- f"别塞 directive、也别只写「XX需求」标签。原文确实没给详细 prompt 才可省。")
- # 3. type=提示词 的 value 逐字但截断 (原文紧跟还有分层/要素)
- for kind in ('inputs', 'outputs'):
- for io in (s.get(kind) or []):
- if not _has_prompt_type(io.get('type', '')):
- continue
- v = (io.get('value') or '').strip()
- if not v or v.startswith('<'):
- continue
- vn = lint._norm(v)
- if len(vn) < 8:
- continue
- pos = source_norm.find(vn)
- if pos >= 0:
- tail = source_norm[pos + len(vn): pos + len(vn) + 40]
- cont = [m for m in _LAYER_MARKERS if m in tail and m not in vn]
- if cont:
- issues.append(
- f"[{sid}].{kind[:3]} type=提示词 的 value 可能**只提了一部分** —— 原文后面紧跟 "
- f"{cont[:3]} 等, @quote 止锚要延到这段 prompt 真正结束(把所有分层/要素提全)。")
- return issues
- def check_media_brackets(data: dict, lint) -> list:
- """媒体类(图/视频/音频)IO 的 value 必须整体用 <…> 包成 <具体描述>, 别写裸标签."""
- issues = []
- for p in data.get('procedures', []):
- reg = p.get('type_registry') or {}
- for s in p.get('steps', []) or []:
- if not isinstance(s, dict) or s.get('kind') == 'block':
- continue
- for kind in ('inputs', 'outputs'):
- for io in (s.get(kind) or []):
- if io.get('inferred'):
- continue
- if lint._type_modality(io.get('type', ''), reg) != 'media':
- continue
- v = (io.get('value') or '').strip()
- if not v:
- continue
- if not _MEDIA_BRACKET_OK.match(v):
- issues.append(
- f"[{p.get('id')}.{s.get('id')}.{kind[:3]}] type={io.get('type', '')!r}(媒体) "
- f"value={v[:24]!r} 没用 <…> 包 —— 媒体类 value 写成 <具体描述>"
- f"(如 <一张冲锋衣登山者暴雨场景图,水珠滚落,冷色调>)。")
- return issues
- def check_declarations(data: dict) -> tuple:
- """returns 空 = 必补; inputs 空 = 提醒. 返回 (block_list, warn_list)."""
- block, warn = [], []
- for p in data.get('procedures', []):
- decl = p.get('declarations') or {}
- rets = decl.get('returns')
- if not rets or (isinstance(rets, dict) and not any(rets.values())):
- block.append(f"[{p.get('id')}] declarations.returns 空 — 补这个工序最终返回什么(type)")
- if not decl.get('inputs'):
- warn.append(f"[{p.get('id')}] declarations.inputs 空 — 确认外部输入(没有可不填)")
- return block, warn
- def main() -> None:
- ap = argparse.ArgumentParser(prog='verify-io.py', description='Phase 1 收口 IO 校验')
- ap.add_argument('--workflow', type=Path, required=True)
- ap.add_argument('--source', type=Path, required=True, help='原文 input/case-N.json (逐字比对用)')
- ap.add_argument('--ocr', type=Path, default=None, help='配图 OCR 文本 (可选, 并入比对语料)')
- args = ap.parse_args()
- if not args.workflow.exists():
- print(f'verify-io: workflow 不存在 {args.workflow}', file=sys.stderr)
- sys.exit(2)
- try:
- data = json.loads(args.workflow.read_text(encoding='utf-8'))
- except json.JSONDecodeError as e:
- print(f'verify-io: workflow 不是合法 JSON: {e}', file=sys.stderr)
- sys.exit(2)
- lint = _load_lint()
- _raw, source_norm = lint._load_source_corpus(args.source, args.ocr)
- if not source_norm:
- print(f'verify-io: 读不到原文 {args.source} (逐字校验需要它)', file=sys.stderr)
- sys.exit(2)
- case_id = data.get('case_id', '?')
- print(f'[verify-io] case-{case_id} ({args.workflow.name})')
- # 1. 文本 IO 逐字 (媒体跳过)
- io_hints = lint.check_value_verbatim(data, source_norm)
- if io_hints:
- print(f' · 文本 IO 逐字: {len(io_hints)} 处未对上原文 (媒体类已跳过)')
- for h in io_hints:
- print(f' ✗ {h}')
- print(' → 此时可重读原文; 用 @quote|起锚|止锚 + wf-patch --resolve-quotes 拽完整原文; '
- '原文确无则标 inferred:true+reason 或 value 写「原文未提供」。')
- else:
- print(' · 文本 IO 逐字: OK (文本类 value 都对上原文; 媒体类不要求)')
- # 2. 提示词提取
- prompt_issues = check_prompt_extraction(data, lint, source_norm)
- if prompt_issues:
- print(f' · 提示词提取: {len(prompt_issues)} 个生成步没提取到提示词')
- for h in prompt_issues:
- print(f' ✗ {h}')
- else:
- print(' · 提示词提取: OK (生成步都有提示词)')
- # 3. 媒体 value 的 <…> 包裹
- media_issues = check_media_brackets(data, lint)
- if media_issues:
- print(f' · 媒体 value <…>: {len(media_issues)} 处没包 (媒体类 value 要写 <具体描述>)')
- for h in media_issues:
- print(f' ✗ {h}')
- else:
- print(' · 媒体 value <…>: OK')
- # 4. declarations 收口
- decl_block, decl_warn = check_declarations(data)
- if decl_block or decl_warn:
- print(f' · declarations 收口: {len(decl_block)} 必补 / {len(decl_warn)} 待确认')
- for d in decl_block:
- print(f' ✗ {d}')
- for d in decl_warn:
- print(f' ⚠ {d}')
- print(' → 用 wf-patch 补 declarations.inputs/resources/returns, '
- '例: `--set p1.declarations.returns.type=产品概念图`。')
- else:
- print(' · declarations 收口: OK')
- # 5. anchor 闭合 (透传输入回填了没 + anchor 格式)
- anchor_issues = lint.check_anchor_closure(data)
- if anchor_issues:
- print(f' · anchor 闭合: {len(anchor_issues)} 处透传没回填 / anchor 写成 JSON 路径')
- for h in anchor_issues:
- print(f' ✗ {h}')
- print(' → 透传输入(有 ← anchor)的 value/type 要回填: 跑 '
- '`wf-patch.py --resolve-passthrough` 顺编号自动抄上游; anchor 用编号 ← s1o1, 别用 JSON 路径。')
- else:
- print(' · anchor 闭合: OK')
- # 6. 干骨架填满 (via 非空 + 输入有 value 或 anchor)
- skel_issues = lint.check_skeleton_filled(data)
- if skel_issues:
- print(f' · 干骨架填满: {len(skel_issues)} 处 Phase 1 骨架没填(via 空 / 输入既无 value 又无 anchor)')
- for h in skel_issues:
- print(f' ✗ {h}')
- print(' → Phase 2.0 要把骨架填满: 每步补 via, 每个输入要么填 value 要么连 anchor。')
- else:
- print(' · 干骨架填满: OK')
- n_block = (len(io_hints) + len(prompt_issues) + len(media_issues) + len(decl_block)
- + len(anchor_issues) + len(skel_issues))
- if n_block:
- print(f'\n有 {n_block} 个阻塞项要修 (文本IO {len(io_hints)} + 提示词 {len(prompt_issues)} '
- f'+ 媒体<…> {len(media_issues)} + returns {len(decl_block)} + anchor {len(anchor_issues)} '
- f'+ 骨架 {len(skel_issues)})。修完**重跑本脚本**确认通过, 才进 Phase 2。',
- file=sys.stderr)
- sys.exit(1)
- print('\n✓ IO 校验通过, 可进 Phase 2 归类标注。')
- sys.exit(0)
- if __name__ == '__main__':
- main()
|