diff --git a/README.md b/README.md index 14120d4..338fe5b 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,21 @@ python scripts/orchestrator.py \ CI 也会跑这一套 dry-run,防止入口契约、QR registry、导入路由或 self-report no-write 行为漂移。 +### Step -0.5:旁白改写和 AI 味检查 + +短视频脚本先过一遍“活人感”检查,再进入分镜。规则从 `md2wechat` 的 +`khazix-writer` 改写方法论吸收而来,但已经改成 md2video 的口播场景: +少写报告腔,多写现场动作、具体工具名、运行证据和真人复盘。 + +```bash +python scripts/lint_narration_style.py \ + --input examples/ai_workflow_video_script.md +``` + +硬规则会拦住“综上所述”“这意味着”“在当今……”这类套话、空泛工具名、 +超长段落和不利于 TTS 的标点。L2 只给风格提醒,例如口语化不足、缺少具体锚点、 +缺少疑问句转向。`scripts/preflight.py` 已自动接入这个检查。 + ### Step 0:分镜拆解 **规则驱动,无需改代码。** 将文章输入 `storyboard_ai.py`,自动输出: diff --git a/SKILL.md b/SKILL.md index de35597..e9b9d8a 100644 --- a/SKILL.md +++ b/SKILL.md @@ -161,6 +161,8 @@ python scripts/preflight.py --input examples/example_article.md --skip-command-c ``` article.md ↓ +lint_narration_style → 旁白活人感 / 反套话检查 + ↓ storyboard_ai → shots.json + segments_hint.json + prompts.json ↓ segment_tts → segments.json(精确时长) @@ -180,6 +182,7 @@ harness → compliance_report.json | 组件 | 核心原则 | |------|---------| +| `lint_narration_style` | 吸收 md2wechat/khazix-writer 的改写方法论,拦截报告腔、套话、空泛工具名和 TTS 不友好的标点 | | `segment_tts` | 按语义切分,独立生成,ffprobe 精确测时长 | | `timeline_mapper` | Single Source of Truth,程序化对齐,L1 硬阻塞校验,Clip 模型支持 fade/transition | | `concat_engine` | **双路径策略**:无特效→`-c copy` 快速路径;有特效→filter_complex (xfade) + Python numpy 音频混合。`acrossfade` 无 `offset` 参数已被废弃,音频用 `adelay`+`amix` 或 Python 逐段叠加 | diff --git a/examples/ai_workflow_video_script.md b/examples/ai_workflow_video_script.md index f5c4d7e..5b2d1ed 100644 --- a/examples/ai_workflow_video_script.md +++ b/examples/ai_workflow_video_script.md @@ -1,13 +1,17 @@ -# 三分钟建立可复用的 AI 工作流 +# 我现在做视频脚本,会先看一眼证据 -很多人用 AI 写东西,最大的问题不是不会提问,而是每次都从零开始。今天这条短视频,只讲一个方法,把一次性的聊天,变成可以复用的工作流。 +事情是这样的。前两天我又让 Codex 帮我整理一段交付,结果发现问题不在模型,而在我每次都临场发挥。 -先看一个对比。临时提问通常要花 10 分钟,还经常漏掉检查步骤;流程化之后,准备时间可以压到 2 分钟,复用率从 0 提升到 80%。差别不在模型,而在你有没有把输入、规则、验证和交付固定下来。 +说真的,临场提问很爽,但复盘的时候很痛苦。十分钟过去了,我还在补背景、补约束、补检查项。我当时就在想,那我到底漏了什么?下一次换个任务,又从头再来。 -第一,先写清楚输入是什么;第二,写清楚输出长什么样;第三,把失败时必须检查的步骤列出来;第四,把验证命令留在文档里。只要这四件事稳定,AI 就不再只是聊天窗口,而是一个可审计的执行伙伴。 +后来我给自己定了一个笨办法。第一,输入是什么。第二,结果长什么样。第三,失败了先查哪几处。第四,最后用什么证据算完成。 -2026年6月8日,我给自己的工作流加了一条新规:每次交付前必须留下运行证明。比如日志、manifest、检查报告和最终文件路径。没有证据,就不能说完成。 +很笨,但有效。 -我给团队留下一句金句:好提示词不是让 AI 显得聪明,而是让结果可以被复核、被复用、被改进。 +你想想看,这四行一固定,事情就变了。Codex 不再只是在聊天框里接话,它会沿着流程做事,留下日志、清单、检查报告和最终文件路径。 -如果你也想把零散经验变成稳定流程,先收藏这条视频。评论区告诉我你的工作流卡在哪一步,我会继续拆给你看。 +2026 年 6 月 8 日,我又加了一条规矩。没有运行证明,就别说交付完成。听着有点较真,但这玩意真的救命。 + +我现在越来越觉得,好提示词不是把模型哄得更聪明,而是让结果经得起回头看。能复核,能复用,也能被下一次改掉。 + +如果你也经常把一堆经验散落在聊天记录里,先别急着写更长的 prompt。先写那四行。你会马上知道,自己到底卡在哪一步。 diff --git a/rules/narration_style_rules.json b/rules/narration_style_rules.json new file mode 100644 index 0000000..a46f8d5 --- /dev/null +++ b/rules/narration_style_rules.json @@ -0,0 +1,141 @@ +{ + "_source": "Adapted from md2wechat references/khazix-writer, derived from KKKKhazix/Khazix-Skills under the MIT License.", + "_description": "Narration style rules for md2video. L1 rules block pipeline readiness; L2 rules warn about script voice and rhythm.", + "version": "1.0.0", + "l1_banned_words": [ + "说白了", + "意味着什么", + "这意味着", + "本质上", + "换句话说", + "不可否认", + "综上所述", + "总的来说", + "值得注意的是", + "不难发现", + "让我们来看看", + "接下来让我们", + "在当今", + "随着技术" + ], + "l1_banned_words_replacements": { + "说白了": "坦率的讲 / 其实就是", + "意味着什么": "那结果会怎样", + "这意味着": "所以呢", + "本质上": "说到底 / 其实", + "换句话说": "你想想看 / 也就是说", + "不可否认": "直接正面陈述", + "综上所述": "用具体回扣句替代", + "总的来说": "用具体回扣句替代", + "值得注意的是": "删掉,直接说", + "不难发现": "删掉,直接说", + "让我们来看看": "删掉,直接进入画面", + "接下来让我们": "删掉,直接进入画面", + "在当今": "换成具体时间、场景或动作", + "随着技术": "换成具体工具或事件" + }, + "l1_banned_punctuation": [ + { + "char": "——", + "reason": "破折号会让 TTS 节奏僵硬,改用逗号或句号", + "replacement": "," + }, + { + "char": "“", + "reason": "中文双引号改用「」或直接不加", + "replacement": "「" + }, + { + "char": "”", + "reason": "中文双引号改用「」或直接不加", + "replacement": "」" + } + ], + "l2_banned_punctuation": [ + { + "char": ":", + "reason": "中文冒号偏报告腔,口播脚本优先用逗号", + "replacement": "," + } + ], + "l1_banned_structures": [ + "首先.*其次.*最后", + "让我们来看看", + "接下来让我们", + "在当今.*时代", + "随着.*不断.*发展", + "随着.*快速.*发展", + "本文将" + ], + "l1_banned_vague_tools": [ + "AI工具", + "某个模型", + "相关技术", + "某AI", + "一些工具", + "相关工具" + ], + "l1_max_paragraph_chars": 220, + "l2_no_go_openings": [ + "在当今.*时代", + "随着.*不断.*发展", + "随着.*快速.*发展", + "众所周知", + "不可否认的是" + ], + "l2_min_colloquial_expressions": 2, + "l2_colloquial_expressions": [ + "坦率的讲", + "说真的", + "我是真的觉得", + "反正我觉得", + "怎么说呢", + "其实吧", + "你想想看", + "我跟你说", + "回到.*这块", + "这块需要注意", + "顺着上面的", + "我有时候觉得", + "我一直觉得", + "我自己的感受是", + "我自己也还在摸索", + "说实话", + "我也不知道", + "我当时就", + "想想就觉得", + "太离谱了", + "这玩意", + "不是哥们", + "我寻思", + "真的就是", + "你敢信", + "一时间", + "听着有点", + "别急着" + ], + "l2_min_concrete_anchors": 2, + "l2_concrete_anchor_patterns": [ + "\\d{4}\\s*年", + "\\d+\\s*分钟", + "Codex", + "ChatGPT", + "Claude", + "Seedance", + "即梦", + "日志", + "清单", + "检查报告", + "文件路径", + "我" + ], + "l2_min_question_marks": 1, + "l2_sentence_variance_threshold": 6, + "l2_max_consecutive_similar_length": 4, + "l3_manual_checks": [ + "开头是否从具体动作或具体现场切入,而不是价值观口号", + "每段是否有画面、动作、工具名或证据,不只是在讲抽象道理", + "是否有一句不完美但真实的判断,让口播像真人复盘", + "结尾是否给一个马上能做的小动作,而不是泛泛号召" + ] +} diff --git a/scripts/generate_ai_workflow_video.py b/scripts/generate_ai_workflow_video.py index ac5496e..9bab16e 100644 --- a/scripts/generate_ai_workflow_video.py +++ b/scripts/generate_ai_workflow_video.py @@ -87,6 +87,14 @@ def save_json(path: Path, data: Any) -> None: json.dump(data, f, ensure_ascii=False, indent=2) +def reset_generated_subdirs(run_dir: Path) -> None: + """Remove reproducible per-run media dirs before rebuilding a demo video.""" + for name in ("scenes", "dreamina_downloads", "narration_segments"): + target = run_dir / name + if target.exists(): + shutil.rmtree(target) + + def parse_json_maybe(text: str) -> dict[str, Any]: text = text.strip() if not text: @@ -366,16 +374,17 @@ def font(size: int) -> ImageFont.ImageFont: def wrap_text(draw: ImageDraw.ImageDraw, text: str, face: ImageFont.ImageFont, max_width: int) -> list[str]: lines: list[str] = [] current = "" - for char in text: - candidate = current + char + tokens = re.findall(r"[A-Za-z0-9_+.-]+|\s+|.", text) + for token in tokens: + candidate = current + token bbox = draw.textbbox((0, 0), candidate, font=face) if current and bbox[2] - bbox[0] > max_width: - lines.append(current) - current = char + lines.append(current.rstrip()) + current = token.lstrip() else: current = candidate if current: - lines.append(current) + lines.append(current.rstrip()) return lines @@ -448,6 +457,20 @@ def draw_footer(draw: ImageDraw.ImageDraw, segment_type: str) -> None: draw.text((96, HEIGHT - 132), label, fill=(28, 48, 58), font=face) +def narrative_title(text: str) -> str: + if "Codex" in text or "模型" in text: + return "不是模型的问题" + if "笨办法" in text or "四行" in text or "第一" in text: + return "四行笨办法" + if "运行证明" in text or "证据" in text: + return "没有证据,就别说完成" + if "提示词" in text: + return "回头看结果" + if "prompt" in text: + return "先写那四行" + return "从临场发挥到稳定流程" + + def render_hook(segment: dict[str, Any], output_path: Path) -> None: img, draw = base_image((10, 25, 33)) accent = (77, 191, 172) @@ -457,10 +480,10 @@ def render_hook(segment: dict[str, Any], output_path: Path) -> None: draw.line([(80, y), (WIDTH - 80, y - 80)], fill=color, width=6) draw.rounded_rectangle([80, 220, WIDTH - 80, 1180], radius=36, fill=(17, 43, 52), outline=accent, width=3) draw.text((116, 280), "md2video", fill=accent, font=font(42)) - title = "三分钟建立可复用的 AI 工作流" + title = segment["text"] lines = wrap_text(draw, title, font(86), WIDTH - 220) draw_centered_lines(draw, lines, 470, font(86), (245, 249, 250), 26) - subtitle_lines = wrap_text(draw, "把一次性的聊天,变成可复核、可复用、可改进的流程。", font(48), WIDTH - 260) + subtitle_lines = wrap_text(draw, "先把临场发挥,改成能回头看的流程。", font(48), WIDTH - 260) draw_centered_lines(draw, subtitle_lines, 820, font(48), (194, 222, 224), 20) for idx, label in enumerate(["输入", "规则", "验证", "交付"]): x = 150 + idx * 205 @@ -473,7 +496,8 @@ def render_hook(segment: dict[str, Any], output_path: Path) -> None: def render_narrative(segment: dict[str, Any], output_path: Path) -> None: img, draw = base_image((244, 247, 246)) draw.rounded_rectangle([72, 120, WIDTH - 72, 340], radius=28, fill=(20, 57, 69)) - draw.text((112, 170), "从零散提问到稳定流程", fill=(255, 255, 255), font=font(58)) + title = narrative_title(segment["text"]) + draw.text((112, 170), title, fill=(255, 255, 255), font=font(58)) body = segment["text"] lines = wrap_text(draw, body, font(48), WIDTH - 180) y = 460 @@ -523,7 +547,7 @@ def render_data(segment: dict[str, Any], output_path: Path) -> None: def render_list(segment: dict[str, Any], output_path: Path) -> None: img, draw = base_image((238, 244, 243)) draw.text((80, 130), "四个固定动作", fill=(28, 48, 58), font=font(70)) - items = ["写清楚输入", "写清楚输出", "列出失败检查", "留下验证命令"] + items = ["输入是什么", "结果长什么样", "失败先查哪里", "证据怎么算完成"] for idx, item in enumerate(items): y = 350 + idx * 275 draw.rounded_rectangle([95, y, WIDTH - 95, y + 190], radius=28, fill=(255, 255, 255), outline=(198, 214, 216), width=2) @@ -536,7 +560,8 @@ def render_list(segment: dict[str, Any], output_path: Path) -> None: def render_date(segment: dict[str, Any], output_path: Path) -> None: img, draw = base_image((247, 248, 244)) - draw.text((90, 120), "2026 年 6 月 8 日", fill=(28, 48, 58), font=font(68)) + title = "2026 年 6 月 8 日" if "2026" in segment["text"] else "留下运行证明" + draw.text((90, 120), title, fill=(28, 48, 58), font=font(68)) draw.rounded_rectangle([100, 270, WIDTH - 100, 1220], radius=30, fill=(255, 255, 255), outline=(205, 216, 220), width=2) weekdays = ["一", "二", "三", "四", "五", "六", "日"] cell_w = 120 @@ -558,7 +583,7 @@ def render_date(segment: dict[str, Any], output_path: Path) -> None: fill = (34, 53, 60) draw.text((x + 42 if day < 10 else x + 28, y), str(day), fill=fill, font=font(44)) day += 1 - proof = "每次交付前必须留下运行证明:日志、清单、检查报告和最终文件路径。" + proof = segment["text"] lines = wrap_text(draw, proof, font(46), WIDTH - 190) draw_centered_lines(draw, lines, 1350, font(46), (28, 48, 58), 18) draw_footer(draw, "date") @@ -568,7 +593,7 @@ def render_date(segment: dict[str, Any], output_path: Path) -> None: def render_quote(segment: dict[str, Any], output_path: Path) -> None: img, draw = base_image((19, 33, 39)) draw.rounded_rectangle([90, 280, WIDTH - 90, 1360], radius=36, fill=(247, 246, 239)) - quote = "好提示词不是让 AI 显得聪明,而是让结果可以被复核、被复用、被改进。" + quote = segment["text"] draw.text((145, 360), '"', fill=(42, 139, 126), font=font(120)) lines = wrap_text(draw, quote, font(60), WIDTH - 260) draw_centered_lines(draw, lines, 570, font(60), (28, 48, 58), 24) @@ -821,6 +846,7 @@ def main() -> int: raise FileNotFoundError(input_path) run_dir.mkdir(parents=True, exist_ok=True) + reset_generated_subdirs(run_dir) env_report = discover_env_files() credit_report = check_dreamina_credit(args.dreamina_lock_wait) if not args.force_local else {"ok": False, "reason": "force_local"} diff --git a/scripts/lint_narration_style.py b/scripts/lint_narration_style.py new file mode 100644 index 0000000..a477514 --- /dev/null +++ b/scripts/lint_narration_style.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Lint md2video narration scripts for report-like AI phrasing.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any + + +REPO_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_RULES = REPO_ROOT / "rules" / "narration_style_rules.json" + + +def load_rules(rules_path: Path = DEFAULT_RULES) -> dict[str, Any]: + with rules_path.open("r", encoding="utf-8") as f: + return json.load(f) + + +def strip_markdown_noise(markdown: str) -> str: + lines: list[str] = [] + in_code = False + for line in markdown.splitlines(): + stripped = line.strip() + if stripped.startswith("```"): + in_code = not in_code + continue + if in_code: + continue + if stripped.startswith("|") and stripped.endswith("|"): + continue + if re.match(r"^\|[-:\s|]+\|$", stripped): + continue + lines.append(line) + return "\n".join(lines) + + +def line_for_index(text: str, index: int) -> int: + return text[:index].count("\n") + 1 + + +def scan_literal_list(text: str, items: list[str], key: str, replacements: dict[str, str] | None = None) -> list[dict[str, Any]]: + hits: list[dict[str, Any]] = [] + for item in items: + start = 0 + while True: + idx = text.find(item, start) + if idx < 0: + break + hit = {key: item, "line": line_for_index(text, idx)} + if replacements and item in replacements: + hit["replacement"] = replacements[item] + hits.append(hit) + start = idx + len(item) + return hits + + +def scan_regex_list(text: str, patterns: list[str]) -> list[dict[str, Any]]: + hits: list[dict[str, Any]] = [] + for pattern in patterns: + regex = re.compile(pattern) + for match in regex.finditer(text): + line = line_for_index(text, match.start()) + excerpt = text.splitlines()[line - 1].strip()[:100] if text.splitlines() else "" + hits.append({"pattern": pattern, "line": line, "excerpt": excerpt}) + return hits + + +def scan_punctuation(text: str, rules: list[dict[str, str]]) -> list[dict[str, Any]]: + hits: list[dict[str, Any]] = [] + for rule in rules: + char = rule["char"] + start = 0 + while True: + idx = text.find(char, start) + if idx < 0: + break + hits.append({ + "char": char, + "line": line_for_index(text, idx), + "reason": rule.get("reason", ""), + "replacement": rule.get("replacement", ""), + }) + start = idx + len(char) + return hits + + +def scan_long_paragraphs(text: str, max_chars: int) -> list[dict[str, Any]]: + hits: list[dict[str, Any]] = [] + current_line = 1 + for para in re.split(r"\n\s*\n", text): + plain = re.sub(r"[#*>`\[\]!|()_-]", "", para).strip() + if len(plain) > max_chars: + hits.append({ + "start_line": current_line, + "char_count": len(plain), + "max_chars": max_chars, + "excerpt": plain[:80], + }) + current_line += para.count("\n") + 2 + return hits + + +def scan_colloquial(text: str, rules: dict[str, Any]) -> dict[str, Any]: + found = [] + for pattern in rules.get("l2_colloquial_expressions", []): + if re.search(pattern, text): + found.append(pattern) + minimum = int(rules.get("l2_min_colloquial_expressions", 0)) + return {"found": found, "count": len(found), "minimum": minimum, "passed": len(found) >= minimum} + + +def scan_concrete_anchors(text: str, rules: dict[str, Any]) -> dict[str, Any]: + found = [] + for pattern in rules.get("l2_concrete_anchor_patterns", []): + if re.search(pattern, text): + found.append(pattern) + minimum = int(rules.get("l2_min_concrete_anchors", 0)) + return {"found": found, "count": len(found), "minimum": minimum, "passed": len(found) >= minimum} + + +def scan_sentence_rhythm(text: str, rules: dict[str, Any]) -> list[dict[str, Any]]: + warnings: list[dict[str, Any]] = [] + parts = [p.strip() for p in re.split(r"[。!??!\n]+", text) if p.strip()] + threshold = int(rules.get("l2_sentence_variance_threshold", 6)) + max_similar = int(rules.get("l2_max_consecutive_similar_length", 3)) + consecutive = 0 + prev_len = 0 + for part in parts: + clean = re.sub(r"[#*>`\[\]!|()_-]", "", part).strip() + if not clean: + continue + if re.match(r"^(第一|第二|第三|第四|第五|第六|第七|第八|第九|第十)[,,、]", clean): + continue + length = len(clean) + if prev_len and abs(length - prev_len) < threshold: + consecutive += 1 + if consecutive >= max_similar: + warnings.append({ + "type": "sentence_rhythm", + "detail": f"连续 {consecutive + 1} 句长度接近,口播节奏可能偏平", + }) + break + else: + consecutive = 0 + prev_len = length + return warnings + + +def lint_narration_style(markdown: str, rules_path: Path = DEFAULT_RULES, strict: bool = False) -> dict[str, Any]: + rules = load_rules(rules_path) + prose = strip_markdown_noise(markdown) + replacements = rules.get("l1_banned_words_replacements", {}) + + l1 = { + "banned_words": scan_literal_list(prose, rules.get("l1_banned_words", []), "word", replacements), + "banned_punctuation": scan_punctuation(prose, rules.get("l1_banned_punctuation", [])), + "banned_structures": scan_regex_list(prose, rules.get("l1_banned_structures", [])), + "vague_tools": scan_literal_list(prose, rules.get("l1_banned_vague_tools", []), "phrase"), + "long_paragraphs": scan_long_paragraphs(prose, int(rules.get("l1_max_paragraph_chars", 9999))), + } + l1_total = sum(len(v) for v in l1.values()) + + l2_punctuation = scan_punctuation(prose, rules.get("l2_banned_punctuation", [])) + colloquial = scan_colloquial(prose, rules) + anchors = scan_concrete_anchors(prose, rules) + questions = prose.count("?") + prose.count("?") + min_questions = int(rules.get("l2_min_question_marks", 0)) + l2_warnings = [ + *scan_regex_list(prose.split("\n\n", 1)[0] if prose.strip() else "", rules.get("l2_no_go_openings", [])), + *scan_sentence_rhythm(prose, rules), + ] + l2_warnings.extend({"type": "punctuation", "detail": f"第{h['line']}行 {h['reason']}"} for h in l2_punctuation) + if not colloquial["passed"]: + l2_warnings.append({"type": "colloquial", "detail": f"口语化表达 {colloquial['count']}/{colloquial['minimum']}"}) + if not anchors["passed"]: + l2_warnings.append({"type": "concrete_anchor", "detail": f"具体锚点 {anchors['count']}/{anchors['minimum']}"}) + if questions < min_questions: + l2_warnings.append({"type": "question", "detail": f"疑问句 {questions}/{min_questions},少了口播转向"}) + + l1_passed = l1_total == 0 + l2_passed = len(l2_warnings) == 0 + return { + "passed": l1_passed and (l2_passed if strict else True), + "strict": strict, + "l1": {**l1, "passed": l1_passed, "total_hits": l1_total}, + "l2": { + "passed": l2_passed, + "warnings": l2_warnings, + "colloquial": colloquial, + "concrete_anchors": anchors, + "question_count": questions, + "minimum_questions": min_questions, + }, + "manual_checks": rules.get("l3_manual_checks", []), + "rules_version": rules.get("version", ""), + } + + +def format_report(report: dict[str, Any]) -> str: + lines = ["md2video Narration Style Lint", ""] + lines.append(f"L1 hard rules: {'PASS' if report['l1']['passed'] else 'FAIL'}") + for key, label in [ + ("banned_words", "banned word"), + ("banned_punctuation", "banned punctuation"), + ("banned_structures", "banned structure"), + ("vague_tools", "vague tool"), + ("long_paragraphs", "long paragraph"), + ]: + for hit in report["l1"][key]: + lines.append(f"- {label}: {hit}") + if report["l1"]["passed"]: + lines.append("- no hard-rule hits") + + lines.append("") + lines.append(f"L2 voice warnings: {'PASS' if report['l2']['passed'] else 'WARN'}") + for warning in report["l2"]["warnings"]: + lines.append(f"- {warning.get('type', 'warning')}: {warning.get('detail', warning)}") + lines.append( + f"- colloquial expressions: {report['l2']['colloquial']['count']}/{report['l2']['colloquial']['minimum']}" + ) + lines.append( + f"- concrete anchors: {report['l2']['concrete_anchors']['count']}/{report['l2']['concrete_anchors']['minimum']}" + ) + lines.append(f"- questions: {report['l2']['question_count']}/{report['l2']['minimum_questions']}") + + lines.append("") + lines.append("Manual review prompts:") + for item in report["manual_checks"]: + lines.append(f"- {item}") + lines.append("") + lines.append(f"Result: {'PASS' if report['passed'] else 'FAIL'}") + return "\n".join(lines) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Lint narration scripts for human voice and anti-AI phrasing") + parser.add_argument("--input", required=True, help="Markdown narration script") + parser.add_argument("--rules", default=str(DEFAULT_RULES), help="Rules JSON path") + parser.add_argument("--strict", action="store_true", help="Treat L2 warnings as failures") + parser.add_argument("--json", action="store_true", help="Print JSON report") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + input_path = Path(args.input) + if not input_path.exists(): + print(f"Input file not found: {input_path}", file=sys.stderr) + return 2 + report = lint_narration_style(input_path.read_text(encoding="utf-8"), Path(args.rules), strict=args.strict) + if args.json: + print(json.dumps(report, ensure_ascii=False, indent=2)) + else: + print(format_report(report)) + return 0 if report["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/preflight.py b/scripts/preflight.py index dd86795..f1f006f 100755 --- a/scripts/preflight.py +++ b/scripts/preflight.py @@ -17,6 +17,7 @@ "harness/video-rules.json", "rules/segment_types.json", "rules/storyboard_rules.json", + "rules/narration_style_rules.json", "cta_resources.json", ] @@ -202,6 +203,54 @@ def check_input_text(input_path: Optional[Path]) -> dict: ) +def check_narration_style(input_path: Optional[Path]) -> dict: + if not input_path: + return result( + "narration_style", + "Narration anti-AI style lint", + "L1", + True, + "Skipped: no input file supplied", + True, + skipped=True, + ) + if not input_path.exists(): + return result( + "narration_style", + "Narration anti-AI style lint", + "L1", + False, + f"Input file does not exist: {input_path}", + True, + findings=["missing_input"], + ) + + sys.path.insert(0, str(REPO_ROOT)) + lint = importlib.import_module("scripts.lint_narration_style") + report = lint.lint_narration_style(input_path.read_text(encoding="utf-8")) + l1_hits = report["l1"]["total_hits"] + l2_warnings = len(report["l2"]["warnings"]) + if l1_hits: + return result( + "narration_style", + "Narration anti-AI style lint", + "L1", + False, + f"Narration style has {l1_hits} hard-rule hit(s)", + True, + report=report, + ) + return result( + "narration_style", + "Narration anti-AI style lint", + "L1", + True, + f"Narration hard style passed; {l2_warnings} L2 warning(s)", + True, + report=report, + ) + + def check_output_dir(output_dir: Path, allow_dirty_output: bool = False) -> dict: known_artifacts = [ "segments.json", @@ -278,6 +327,7 @@ def run_preflight( check_animation_routing(), check_cta_resources(), check_input_text(input_path), + check_narration_style(input_path), check_output_dir(output_dir, allow_dirty_output=allow_dirty_output), ]) diff --git a/scripts/smoke_imports.py b/scripts/smoke_imports.py index 3c97a8c..c6d0060 100644 --- a/scripts/smoke_imports.py +++ b/scripts/smoke_imports.py @@ -22,6 +22,7 @@ "harness.harness", "harness.memory_loader", "harness.self_report", + "scripts.lint_narration_style", "scripts.preflight", "scripts.orchestrator", ] diff --git a/tests/test_pipeline_governance.py b/tests/test_pipeline_governance.py index d3894ad..c589a00 100644 --- a/tests/test_pipeline_governance.py +++ b/tests/test_pipeline_governance.py @@ -101,6 +101,22 @@ def test_self_report_no_write_preserves_governance_files(self): self.assertFalse((project / "output" / "self_report.json").exists()) self.assertEqual(data["friction_summary"]["total"], 1) + def test_narration_style_lint_blocks_report_voice(self): + lint = load_module("lint_narration_style", REPO_ROOT / "scripts" / "lint_narration_style.py") + + bad_script = "在当今AI快速发展的时代,首先让我们来看看AI工具。这意味着什么?" + bad_report = lint.lint_narration_style(bad_script) + self.assertFalse(bad_report["passed"]) + self.assertGreater(bad_report["l1"]["total_hits"], 0) + + good_script = ( + "事情是这样的。昨天我让 Codex 帮我收尾,十分钟后发现自己漏了检查报告。\n\n" + "说真的,我当时就在想,我到底漏了什么?\n\n" + "后来我只留四行,输入、输出、失败检查、运行证据。这玩意很笨,但有效。" + ) + good_report = lint.lint_narration_style(good_script, strict=True) + self.assertTrue(good_report["passed"]) + if __name__ == "__main__": unittest.main()