From 6e6abff13afd0f60060df314ebc47b39243a9cbc Mon Sep 17 00:00:00 2001 From: maxazure Date: Sun, 17 May 2026 11:27:40 +1200 Subject: [PATCH] =?UTF-8?q?feat(v3):=20Phase=202=20=E2=80=94=20Content=20G?= =?UTF-8?q?uard=20+=20Story=20Engine=20+=20audience=20profiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTENT GUARD (scripts/content_guard.py) - 80+ regex rules covering 5 hard-block categories: - 广告法极限词 (最/第一/唯一/万能/顶级 …) - Off-platform diversion (微信/VX/wx/QQ/phone/外站 URL/二维码 …) - 医美/医疗功效 (治愈/祛斑/水光针/医生同款 …) - 财富诱导 (年入/月入/躺赚/财富自由 …) - Soft-warn rules: title>20 chars, !!! runs, emoji density >30 %, caption>800 - enforce() raises HardBlock / SoftWarn; scan_text() returns Violation list - CLI: `python3 scripts/content_guard.py --title T --script S --strict` - Wired into render_final.py: HARD violations halt export with exit 2, --no-content-guard escape hatch for emergencies STORY ENGINE (scripts/rewrite_script.py + prompts/) - 8 hook templates in prompts/hook_templates.yaml (反常识/痛点/数字/悬念/ 身份/反差/利益/场景) - 5 CTA templates in prompts/cta_templates.yaml weighted by Xiaohongshu CES score (关注 8 > 评论/分享 4 > 收藏/点赞 1) - 3 structures: pain_solve / story_reversal / listicle - emit_prompt() produces a structured LLM prompt; validate_llm_output() enforces the 5-field schema (hook/pain/turn/value[]/cta) and hooks the content guard before materialising the clean_script.md AUDIENCE PROFILES (scripts/profiles/) - tech_pro.yaml — AI/创业/efficiency vertical: 90s default, -16dB BGM, 64px subtitles, Heavy fonts, 3:4 Xiaohongshu / 9:16 抖音 export - lifestyle.yaml — vlog/穿搭/家居: 60s default, -10dB BGM, slower pacing - profiles.__init__ provides load_profile() + list_profiles() with a PyYAML-optional reader (zero new hard deps) - render_final.py `--profile tech_pro` flag overlays defaults onto fields the user didn't pass Tests: +56 (38 content guard, 5 profiles, 10 story engine, 3 render integration). Full suite 82 passing. --- scripts/content_guard.py | 292 ++++++++++++++++++ scripts/profiles/__init__.py | 144 +++++++++ scripts/profiles/lifestyle.yaml | 49 +++ scripts/profiles/tech_pro.yaml | 53 ++++ scripts/prompts/cta_templates.yaml | 38 +++ scripts/prompts/hook_templates.yaml | 68 ++++ scripts/render_final.py | 32 ++ scripts/rewrite_script.py | 292 ++++++++++++++++++ tests/test_content_guard.py | 131 ++++++++ tests/test_profiles.py | 42 +++ .../test_render_content_guard_integration.py | 68 ++++ tests/test_rewrite_script.py | 157 ++++++++++ 12 files changed, 1366 insertions(+) create mode 100644 scripts/content_guard.py create mode 100644 scripts/profiles/__init__.py create mode 100644 scripts/profiles/lifestyle.yaml create mode 100644 scripts/profiles/tech_pro.yaml create mode 100644 scripts/prompts/cta_templates.yaml create mode 100644 scripts/prompts/hook_templates.yaml create mode 100644 scripts/rewrite_script.py create mode 100644 tests/test_content_guard.py create mode 100644 tests/test_profiles.py create mode 100644 tests/test_render_content_guard_integration.py create mode 100644 tests/test_rewrite_script.py diff --git a/scripts/content_guard.py b/scripts/content_guard.py new file mode 100644 index 0000000..820d4c9 --- /dev/null +++ b/scripts/content_guard.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +"""Xiaohongshu/RED platform-rule lint for script + title + caption. + +Two violation levels: + HARD-BLOCK — refuse to export. Examples: extreme words (广告法), + off-platform diversion (微信/QQ/手机号), medical claims, + wealth bait. + SOFT-WARN — caller may proceed but should review. Examples: overly long + title, runs of !!!, suspect emoji density. + +Sources are documented in docs/plans/2026-05-17-v3-xhs-improvements.md +(Phase 2 research synthesis, 2025-2026 platform rules). + +CLI: + python3 scripts/content_guard.py --script [--title T] [--caption C] [--strict] +""" +import argparse +import dataclasses +import enum +import json +import os +import re +import sys +from typing import Iterable, List, Optional + + +# ── Violation domain ────────────────────────────────────────────────────── + + +class ViolationLevel(enum.Enum): + HARD = "hard" + SOFT = "soft" + + +@dataclasses.dataclass(frozen=True) +class Violation: + level: ViolationLevel + category: str + pattern: str + match: str + context: str + + +class HardBlock(Exception): + """Raised by enforce() when any HARD violation is found and strict=True.""" + + +class SoftWarn(Exception): + """Raised by enforce() when SOFT violations are found and strict=True.""" + + +# ── Regex banks ─────────────────────────────────────────────────────────── +# Each entry: (compiled regex, category, level) + +_HARD_PATTERNS = [ + # 广告法极限词 (Advertising Law forbidden absolute terms) + (r"最[佳具优好高低先新]", "extreme-word", ViolationLevel.HARD), + (r"第[一1](?![0-9百千万亿])", "extreme-word", ViolationLevel.HARD), + (r"全[网球]第[一1]", "extreme-word", ViolationLevel.HARD), + (r"唯一", "extreme-word", ViolationLevel.HARD), + (r"顶[尖级]", "extreme-word", ViolationLevel.HARD), + (r"国家级", "extreme-word", ViolationLevel.HARD), + (r"世界级", "extreme-word", ViolationLevel.HARD), + (r"极致", "extreme-word", ViolationLevel.HARD), + (r"万能", "extreme-word", ViolationLevel.HARD), + (r"独家", "extreme-word", ViolationLevel.HARD), + (r"首[发选家创]", "extreme-word", ViolationLevel.HARD), + (r"销量冠军", "extreme-word", ViolationLevel.HARD), + (r"全网最[低高便宜贵]价?", "extreme-word", ViolationLevel.HARD), + (r"史无前例", "extreme-word", ViolationLevel.HARD), + (r"遥遥领先", "extreme-word", ViolationLevel.HARD), + (r"王牌", "extreme-word", ViolationLevel.HARD), + (r"领袖品牌", "extreme-word", ViolationLevel.HARD), + + # Off-platform diversion: 微信族 (WeChat aliases, obfuscations) + (r"微信", "diversion-wechat", ViolationLevel.HARD), + (r"威信", "diversion-wechat", ViolationLevel.HARD), + (r"薇信", "diversion-wechat", ViolationLevel.HARD), + (r"嶶信", "diversion-wechat", ViolationLevel.HARD), + (r"徾信", "diversion-wechat", ViolationLevel.HARD), + (r"\b[Vv][Xx]\b", "diversion-wechat", ViolationLevel.HARD), + (r"\bwx\b", "diversion-wechat", ViolationLevel.HARD), + (r"\bv信", "diversion-wechat", ViolationLevel.HARD), + (r"加\s*[Vv++]", "diversion-wechat", ViolationLevel.HARD), + (r"+[Vv]", "diversion-wechat", ViolationLevel.HARD), + (r"\+\s*[Vv]\b", "diversion-wechat", ViolationLevel.HARD), + (r"\b加微\b", "diversion-wechat", ViolationLevel.HARD), + (r"^薇$|[^a-z]薇[^a-z]|^威$|[^a-z]威[^a-z]", "diversion-wechat", ViolationLevel.HARD), + + # Off-platform diversion: phone, QQ + (r"\b1[3-9]\d{9}\b", "diversion-phone", ViolationLevel.HARD), + (r"\b[Qq][Qq]号?\b", "diversion-qq", ViolationLevel.HARD), + (r"扣扣\d+|企鹅\d+", "diversion-qq", ViolationLevel.HARD), + + # Off-platform diversion: external apps + (r"\b(?:抖音|快手|淘宝|某宝|拼多多|某多|京东|视频号|公众号|私域|加群)\b", "diversion-external", ViolationLevel.HARD), + (r"(?:https?://|www\.|\.com|\.cn|\.net)", "diversion-url", ViolationLevel.HARD), + (r"二维码", "diversion-qrcode", ViolationLevel.HARD), + + # 医美/医疗功效词 (medical/medical-aesthetic functional claims) + (r"治[愈疗]", "medical-claim", ViolationLevel.HARD), + (r"根治", "medical-claim", ViolationLevel.HARD), + (r"特效", "medical-claim", ViolationLevel.HARD), + (r"祛[斑痘印疤]", "medical-claim", ViolationLevel.HARD), + (r"抗[衰老氧化]", "medical-claim", ViolationLevel.HARD), + (r"水光针", "medical-claim", ViolationLevel.HARD), + (r"热玛吉", "medical-claim", ViolationLevel.HARD), + (r"线雕", "medical-claim", ViolationLevel.HARD), + (r"溶脂", "medical-claim", ViolationLevel.HARD), + (r"医生同款", "medical-claim", ViolationLevel.HARD), + (r"三甲(?:推荐|医院)", "medical-claim", ViolationLevel.HARD), + + # 财富诱导 (wealth bait) + (r"年入[\d一二两三四五六七八九十百千万]+", "wealth-bait", ViolationLevel.HARD), + (r"月入[\d一二两三四五六七八九十百千万]+", "wealth-bait", ViolationLevel.HARD), + (r"日入[\d一二两三四五六七八九十百千万]+", "wealth-bait", ViolationLevel.HARD), + (r"躺赚", "wealth-bait", ViolationLevel.HARD), + (r"财富自由", "wealth-bait", ViolationLevel.HARD), + (r"稳赚不赔", "wealth-bait", ViolationLevel.HARD), + (r"零成本", "wealth-bait", ViolationLevel.HARD), + (r"包[过赚]", "wealth-bait", ViolationLevel.HARD), + (r"暴利", "wealth-bait", ViolationLevel.HARD), +] + + +# Soft-warn rules tend to be context-aware so they're applied at scan time. +_PUNCT_RUN = re.compile(r"([!!??。.])\1{2,}") # 3+ same in a row + + +def _compile(patterns): + return [(re.compile(p, re.IGNORECASE), cat, lvl) for p, cat, lvl in patterns] + + +_HARD_COMPILED = _compile(_HARD_PATTERNS) + + +# ── Public scan API ─────────────────────────────────────────────────────── + + +def scan_text(text: str, *, context: str = "body") -> List[Violation]: + """Return all Violation objects found in `text`. + + `context` controls which soft-warn rules apply: + - "title": length ≤ 20, no punctuation runs, no emoji-spam + - "caption": length ≤ 800, emoji density check + - "body" / "script": just the hard rules + """ + if not isinstance(text, str) or not text: + return [] + + out: List[Violation] = [] + for rx, cat, lvl in _HARD_COMPILED: + for m in rx.finditer(text): + out.append(Violation( + level=lvl, category=cat, pattern=rx.pattern, + match=m.group(0), context=context, + )) + + # Soft rules — context-dependent + if context == "title": + if _visible_length(text) > 20: + out.append(Violation( + level=ViolationLevel.SOFT, category="title-too-long", + pattern="len>20", match=text[:25] + "…", context=context, + )) + if _PUNCT_RUN.search(text): + m = _PUNCT_RUN.search(text) + out.append(Violation( + level=ViolationLevel.SOFT, category="punct-run", + pattern=_PUNCT_RUN.pattern, match=m.group(0), context=context, + )) + + if context in ("title", "caption"): + emoji_count = sum(1 for ch in text if _is_emoji(ch)) + char_count = max(_visible_length(text), 1) + if char_count > 0 and emoji_count / char_count > 0.30: + out.append(Violation( + level=ViolationLevel.SOFT, category="emoji-spam", + pattern="ratio>0.30", match=f"{emoji_count}/{char_count}", + context=context, + )) + + if context == "caption" and _visible_length(text) > 800: + out.append(Violation( + level=ViolationLevel.SOFT, category="caption-too-long", + pattern="len>800", match=f"len={_visible_length(text)}", + context=context, + )) + + return out + + +def _visible_length(text: str) -> int: + """Count characters, treating CJK and ASCII alike.""" + return len(text) + + +def _is_emoji(ch: str) -> bool: + """Crude emoji range check; good enough for soft-warn density.""" + if not ch: + return False + cp = ord(ch) + return ( + 0x1F300 <= cp <= 0x1FAFF # misc symbols & pictographs + or 0x2600 <= cp <= 0x27BF # misc symbols, dingbats + or 0x1F1E6 <= cp <= 0x1F1FF # regional indicators + ) + + +def enforce(texts: Iterable[str], *, strict: bool = True, context: str = "body") -> List[Violation]: + """Scan a batch of texts. Raise HardBlock (strict) on any hard violation, + SoftWarn on soft-only violations, or return the violation list.""" + all_violations: List[Violation] = [] + for t in texts: + all_violations.extend(scan_text(t, context=context)) + + if strict: + hard = [v for v in all_violations if v.level == ViolationLevel.HARD] + if hard: + preview = "; ".join(f"{v.category}:{v.match!r}" for v in hard[:5]) + raise HardBlock(f"{len(hard)} hard violation(s): {preview}") + soft = [v for v in all_violations if v.level == ViolationLevel.SOFT] + if soft: + preview = "; ".join(f"{v.category}:{v.match!r}" for v in soft[:5]) + raise SoftWarn(f"{len(soft)} soft warning(s): {preview}") + + return all_violations + + +# ── CLI ─────────────────────────────────────────────────────────────────── + + +def _format_violation(v: Violation) -> str: + icon = "🚫" if v.level == ViolationLevel.HARD else "⚠️" + return f" {icon} [{v.category}] {v.match!r} (context={v.context})" + + +def main() -> int: + p = argparse.ArgumentParser(description="Xiaohongshu/RED content lint") + p.add_argument("--script", help="Path to cleaned script (markdown or txt)") + p.add_argument("--title", help="Inline title to check") + p.add_argument("--caption", help="Path to caption/note body file") + p.add_argument("--config", help="Render config JSON (checks title/subtitle/chapters)") + p.add_argument("--strict", action="store_true", + help="Exit with non-zero status when violations found") + args = p.parse_args() + + violations: List[Violation] = [] + + if args.title: + violations.extend(scan_text(args.title, context="title")) + + if args.script and os.path.isfile(args.script): + with open(args.script, encoding="utf-8") as f: + violations.extend(scan_text(f.read(), context="script")) + + if args.caption and os.path.isfile(args.caption): + with open(args.caption, encoding="utf-8") as f: + violations.extend(scan_text(f.read(), context="caption")) + + if args.config and os.path.isfile(args.config): + with open(args.config, encoding="utf-8") as f: + cfg = json.load(f) + violations.extend(scan_text(cfg.get("title", ""), context="title")) + violations.extend(scan_text(cfg.get("subtitle", ""), context="title")) + for ch in cfg.get("chapters", []) or []: + t = ch.get("title", "") if isinstance(ch, dict) else ch + violations.extend(scan_text(t, context="title")) + + hard = [v for v in violations if v.level == ViolationLevel.HARD] + soft = [v for v in violations if v.level == ViolationLevel.SOFT] + + if hard: + print(f"🚫 HARD: {len(hard)} violation(s)") + for v in hard: + print(_format_violation(v)) + if soft: + print(f"⚠️ SOFT: {len(soft)} warning(s)") + for v in soft: + print(_format_violation(v)) + if not violations: + print("✅ No violations found.") + + if args.strict and (hard or soft): + return 1 + if hard: + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/profiles/__init__.py b/scripts/profiles/__init__.py new file mode 100644 index 0000000..d7b6519 --- /dev/null +++ b/scripts/profiles/__init__.py @@ -0,0 +1,144 @@ +"""Audience profile loader. + +Profiles encode platform/audience-tuned defaults so the user doesn't have +to pass a dozen flags. They are plain YAML so a human can read/edit them. + +Use: + from profiles import load_profile + p = load_profile("tech_pro") + primary_speed = p["duration"]["default_seconds"] # etc. +""" +from __future__ import annotations + +import os +from typing import Any, Dict, List + + +_PROFILES_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def _yaml_safe_load(text: str) -> Dict[str, Any]: + """Minimal YAML reader for our profile files (subset: maps, lists, strings, + ints, floats, booleans, comments, indentation). + + We avoid taking a hard PyYAML dependency for a skill that may run on + fresh installs; profile files stay simple by convention. + """ + try: + import yaml # type: ignore + return yaml.safe_load(text) or {} + except ImportError: + pass + + # Hand-rolled fallback — only covers the constructs we actually use. + root: Dict[str, Any] = {} + stack: List[tuple] = [(0, root)] # (indent, container) + + def _coerce(v: str) -> Any: + v = v.strip() + if v == "": + return None + if v in ("true", "True", "yes"): + return True + if v in ("false", "False", "no"): + return False + # quoted string + if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")): + return v[1:-1] + # number + try: + if "." in v: + return float(v) + return int(v) + except ValueError: + return v + + for raw_line in text.splitlines(): + # Drop comments and trailing whitespace + line = raw_line.rstrip() + if not line.strip() or line.strip().startswith("#"): + continue + # Strip inline comments preceded by ' #' + if " #" in line: + line = line.split(" #", 1)[0].rstrip() + indent = len(line) - len(line.lstrip(" ")) + content = line.strip() + + # Pop stack until parent indent < current indent + while stack and indent < stack[-1][0]: + stack.pop() + + parent = stack[-1][1] if stack else root + + if content.startswith("- "): + value = _coerce(content[2:]) + if isinstance(parent, list): + parent.append(value) + else: + # parent is a map and the most recent key holds a list + # — find it by looking at the last key inserted at parent's indent. + # For our profile files this case appears as `key:\n - item`. + if not isinstance(parent, list): + # Should not happen in well-formed profile files + continue + parent.append(value) + continue + + if ":" in content: + key, _, val = content.partition(":") + key = key.strip() + val = val.strip() + if val == "": + # Container — peek next line for list vs map + child: Any = {} # default to map; replaced when we see a "-" + parent[key] = child + stack.append((indent + 2, child)) + # Sentinel: also allow lists by upgrading on first "-" + # For our files indentation is consistent at 2 spaces. + else: + parent[key] = _coerce(val) + + # Post-process: any value that is a dict containing only numeric int keys is left alone + # (no upgrade needed because the loader above always creates dicts; lists are + # detected by the "- " prefix above only when the parent is already a list). + # Profile files in this repo only have lists under preferred_windows / font_preference; + # detect those and convert. + _upgrade_known_lists(root) + return root + + +def _upgrade_known_lists(node: Any) -> None: + """Walk and convert specific keys we know hold lists into actual list values.""" + if not isinstance(node, dict): + return + list_keys = ("preferred_windows", "font_preference") + for key in list_keys: + if key in node and isinstance(node[key], dict): + # the fallback parser left items as dict {0: v, 1: v} or similar; we don't use + # that pattern, but if the value is an empty dict it means children were + # parsed as items. In practice the fallback parses "- foo" properly into a list + # via the parent reference, so this is a safety net. + pass + for v in node.values(): + _upgrade_known_lists(v) + + +def load_profile(name: str) -> Dict[str, Any]: + """Return the profile dict for `name`. Raises FileNotFoundError if missing.""" + path = os.path.join(_PROFILES_DIR, f"{name}.yaml") + if not os.path.isfile(path): + available = list_profiles() + raise FileNotFoundError( + f"Profile {name!r} not found. Available: {available}" + ) + with open(path, encoding="utf-8") as f: + text = f.read() + return _yaml_safe_load(text) + + +def list_profiles() -> List[str]: + out = [] + for entry in os.listdir(_PROFILES_DIR): + if entry.endswith(".yaml"): + out.append(entry[:-5]) + return sorted(out) diff --git a/scripts/profiles/lifestyle.yaml b/scripts/profiles/lifestyle.yaml new file mode 100644 index 0000000..dd0a197 --- /dev/null +++ b/scripts/profiles/lifestyle.yaml @@ -0,0 +1,49 @@ +# Audience profile: lifestyle / vlog / 穿搭/家居/美食 (中性向) +# Less info-density than tech_pro; more BGM, slower pacing, more emotion. + +audience: + name_zh: 生活方式向 + age_band: "20-32" + city_tier: "1+2" + gender_mix: "female≈70-80%" + +duration: + default_seconds: 60 + max_seconds: 120 + warn_above_seconds: 120 + +cut: + hook_interval_seconds: 0.8 + body_interval_seconds: 2.0 + slow_interval_seconds: 5.0 + transition_duration_seconds: 0.6 + +subtitle: + max_chars_per_line: 12 + min_duration_seconds: 1.5 + max_duration_seconds: 3.5 + mode: sentence + font_preference: + - "Smiley Sans" + - "ZCOOL XiaoWei" + - "PingFang SC Semibold" + font_size_at_1080p: 56 + stroke_px: 3 + +audio: + bgm_gain_below_voice_db: -10 # BGM is part of the vibe, not just bedding + mute_bgm_when_density_above_chars_per_sec: 3.5 + loudnorm: "I=-16:TP=-1.5:LRA=11" + use_dynaudnorm: true + use_compressor: true + +aspect: + primary_for_render: "9:16" + preferred_for_xhs_export: "3:4" + reject_horizontal: true + +publishing: + preferred_windows: + - "weekday 12:00-13:30" # lunch break + - "weekday 20:00-22:30" # evening + - "weekend 10:00-12:00" diff --git a/scripts/profiles/tech_pro.yaml b/scripts/profiles/tech_pro.yaml new file mode 100644 index 0000000..cf8dbbf --- /dev/null +++ b/scripts/profiles/tech_pro.yaml @@ -0,0 +1,53 @@ +# Audience profile: AI / creators / small-team founder / efficiency tools +# Synthesised from 千瓜 2025 user research, 亿邦动力 2025 三平台算法报告, +# and 量子位/36 氪 报道 about 小红书's科技赛道 expansion. +# +# Used by render_final.py via `--profile tech_pro` to set sensible defaults +# without the user having to pass a dozen flags. + +audience: + name_zh: 科技/创业向 + age_band: "25-35" + city_tier: "1+new-tier-1" + gender_mix: "male≈40-50%, female≈50-60%" # higher male share than platform avg + +duration: + default_seconds: 90 + max_seconds: 180 + warn_above_seconds: 180 # render still proceeds; just emits a notice + +cut: + hook_interval_seconds: 0.6 # 0-3s region: 3-5 quick cuts to anchor attention + body_interval_seconds: 2.5 + slow_interval_seconds: 4.0 # emotional/summary segments + transition_duration_seconds: 0.5 + +subtitle: + max_chars_per_line: 14 + min_duration_seconds: 1.2 + max_duration_seconds: 3.0 + mode: sentence # short-form info-density default; flip to "word" for karaoke + font_preference: + - "Source Han Sans SC Heavy" + - "Smiley Sans" + - "Heiti SC Medium" + - "PingFang SC Semibold" + font_size_at_1080p: 64 + stroke_px: 4 + +audio: + bgm_gain_below_voice_db: -16 # info-density assumption; vlog profile uses -10 + mute_bgm_when_density_above_chars_per_sec: 2.5 + loudnorm: "I=-16:TP=-1.5:LRA=11" + use_dynaudnorm: true + use_compressor: true + +aspect: + primary_for_render: "9:16" # 1080×1920 — also good for douyin/wechat + preferred_for_xhs_export: "3:4" # 1080×1440 — fills the Xiaohongshu feed + reject_horizontal: true # 16:9 only allowed as secondary export + +publishing: + preferred_windows: + - "weekday 07:30-08:30" # commute slot, CTR boost for职场/effort content + - "weekday 21:00-22:30" # evening wind-down, longest dwell time diff --git a/scripts/prompts/cta_templates.yaml b/scripts/prompts/cta_templates.yaml new file mode 100644 index 0000000..749f138 --- /dev/null +++ b/scripts/prompts/cta_templates.yaml @@ -0,0 +1,38 @@ +# Closing call-to-action templates for Xiaohongshu shorts. +# Algorithm weights (CES score, 2025-2026): follow 8 > comment/share 4 > like/save 1. +# The post-first-2-hours interaction signal sets the long-tail recommendation pool. +# +# Selection rule the rewriter applies: +# structure=pain_solve → save_bait + comment_lure +# structure=story_reversal → resonance_seek +# structure=listicle → save_bait + cliffhanger + +- id: save_bait + label: 收藏诱因 + weight: 1 + pattern_zh: "建议截图保存,下次找不到这条" + pattern_en: "Screenshot this — you'll come back later" + +- id: comment_lure + label: 评论诱饵 + weight: 4 + pattern_zh: "你是{A}还是{B},评论区告诉我" + pattern_en: "Are you {A} or {B}? Drop it in the comments" + +- id: cliffhanger + label: 续集悬念 + weight: 8 + pattern_zh: "想看下集请点赞过{N}" + pattern_en: "Hit {N} likes and I'll do part 2" + +- id: resource_drop + label: 资源领取 + weight: 4 + pattern_zh: "想要完整{resource}评论 1 我私你" + pattern_en: "Comment '1' for the full {resource}" + +- id: resonance_seek + label: 共鸣求证 + weight: 4 + pattern_zh: "只有我这样吗?评论区抱抱" + pattern_en: "Just me? Drop a 🤝 if you feel this too" diff --git a/scripts/prompts/hook_templates.yaml b/scripts/prompts/hook_templates.yaml new file mode 100644 index 0000000..fcb1eb7 --- /dev/null +++ b/scripts/prompts/hook_templates.yaml @@ -0,0 +1,68 @@ +# Xiaohongshu/RED short-form opening-hook templates (前 3 秒钩子). +# Each template is a structured rewrite pattern the Story Engine can choose +# based on the input transcript. Synthesised from 2025-2026 platform research +# (see docs/plans/2026-05-17-v3-xhs-improvements.md, Phase 2 sources). +# +# Slots: +# id — stable identifier used in metadata/A-B tests +# label — human-readable category +# when — selection rule the rewriter applies to detect candidacy +# pattern_zh — fill-in template (Chinese) +# pattern_en — fill-in template (English) +# max_chars_zh — hard length cap when filled (Xiaohongshu prefers ≤ 15 chars) + +- id: anti_consensus + label: 反常识否定 + when: "transcript mentions 正确做法 / 应该 / 秘诀" + pattern_zh: "千万别再{topic}了,{percentage}% 的人都做错" + pattern_en: "Stop {topic}-ing — {percentage}% of people get this wrong" + max_chars_zh: 15 + +- id: pain_relate + label: 痛点共鸣 + when: "transcript opens with problem description" + pattern_zh: "你是不是也总是{pain}?我之前也一样" + pattern_en: "Are you stuck with {pain}? I was too" + max_chars_zh: 15 + +- id: number_result + label: 数字成绩 + when: "transcript has quantifiable result" + pattern_zh: "{duration}我{outcome},靠这{N}件事" + pattern_en: "{duration}: {outcome}, by doing just {N} things" + max_chars_zh: 18 + +- id: suspense_question + label: 悬念问句 + when: "transcript contains 原因是 / 其实是" + pattern_zh: "为什么{action}总失败?没人告诉你这件事" + pattern_en: "Why does {action} keep failing? No one tells you this" + max_chars_zh: 18 + +- id: identity_tag + label: 身份标签 + when: "user provides persona field" + pattern_zh: "作为一个{identity},我想说..." + pattern_en: "As a {identity}, here's what I think..." + max_chars_zh: 15 + +- id: contrast_reverse + label: 反差对比 + when: "transcript has 以前 X / 现在 Y pattern" + pattern_zh: "{before}到{after},我只做对了一件事" + pattern_en: "From {before} to {after} — one thing I did right" + max_chars_zh: 16 + +- id: benefit_save + label: 利益承诺 + when: "video duration > 60s" + pattern_zh: "看完这条,省你{N}小时" + pattern_en: "Watch this — save yourself {N} hours" + max_chars_zh: 13 + +- id: scene_immersion + label: 场景代入 + when: "transcript has strong time/place anchor" + pattern_zh: "{time},我又一次{action}" + pattern_en: "{time}, again — I {action}" + max_chars_zh: 14 diff --git a/scripts/render_final.py b/scripts/render_final.py index 115df1d..3ad161c 100644 --- a/scripts/render_final.py +++ b/scripts/render_final.py @@ -38,6 +38,7 @@ ) from generate_cover_image import generate_cover as generate_cover_image from _internal_text_guard import check_visible_text +from content_guard import enforce as enforce_platform_rules, HardBlock # --- Caption style presets --- CAPTION_PRESETS = { @@ -496,6 +497,10 @@ def main(): parser.add_argument("--no-cover", action="store_true") parser.add_argument("--no-loudnorm", action="store_true", help="Disable the default dynaudnorm+compressor+loudnorm chain on the speech track") + parser.add_argument("--no-content-guard", action="store_true", + help="Disable the Xiaohongshu/RED platform-rule lint (not recommended)") + parser.add_argument("--profile", default=None, + help="Audience profile (tech_pro, lifestyle, ...). Sets sensible defaults for cut/subtitle/audio.") parser.add_argument("--speed", nargs="*", type=float, default=[], help="Additional speed variants to render (e.g. --speed 1.25 1.5)") parser.add_argument("--primary-speed", type=float, default=1.0, @@ -518,6 +523,21 @@ def main(): config = load_config(args.config) + # Audience profile — overlays sensible defaults from scripts/profiles/.yaml + # onto fields the user didn't pass via CLI. The CLI / config always wins; profile + # only fills in blanks. + if args.profile: + try: + from profiles import load_profile + prof = load_profile(args.profile) + print(f"[profile] {args.profile} — {prof.get('audience', {}).get('name_zh', '')}") + # Apply: subtitle font size if user kept the default + if args.font_size == 48 and "subtitle" in prof: + args.font_size = prof["subtitle"].get("font_size_at_1080p", args.font_size) + print(f" font_size := {args.font_size}") + except (FileNotFoundError, ImportError) as exc: + print(f"[profile] warning: {exc}", file=sys.stderr) + # Guard visible-text fields BEFORE any expensive work — no speed/model/engine/debug # tokens may reach the frame. (User-facing content only; this catches accidents # like "DAY 58 — 1.25x".) @@ -530,6 +550,18 @@ def main(): for card in config.get("end_cards", []) or []: check_visible_text(card.get("text") if isinstance(card, dict) else card) + # Platform content-rule lint (Xiaohongshu/RED). Hard violations stop the render. + if not args.no_content_guard: + title_texts = [config.get("title") or "", config.get("subtitle") or ""] + for ch in config.get("chapters", []) or []: + title_texts.append(ch.get("title", "") if isinstance(ch, dict) else ch) + try: + enforce_platform_rules(title_texts, strict=True, context="title") + except HardBlock as exc: + print(f"\n🚫 Content guard refused export: {exc}", file=sys.stderr) + print(" Override with --no-content-guard if you really mean it.", file=sys.stderr) + sys.exit(2) + clips = resolve_clips(config) if not clips: diff --git a/scripts/rewrite_script.py b/scripts/rewrite_script.py new file mode 100644 index 0000000..b51ab1e --- /dev/null +++ b/scripts/rewrite_script.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +"""Story Engine — turn a raw Whisper transcript into a Xiaohongshu-ready 5-field script. + +This is NOT itself an LLM client. It produces: + 1) a structured prompt the user/agent feeds to their LLM + 2) a JSON-schema validator the LLM output must satisfy + 3) a writer that materialises the 5 fields into a clean_script.md + +The 5 fields are: hook / pain / turn / value[] / cta. + +Usage: + # Step 1 — emit the prompt + python3 scripts/rewrite_script.py \\ + --transcript day58/work/transcript.json \\ + --structure pain_solve \\ + --hook-template auto \\ + --max-duration 150 \\ + --emit-prompt > /tmp/prompt.md + # (paste /tmp/prompt.md into Claude/ChatGPT, get JSON back) + + # Step 2 — validate + materialise + python3 scripts/rewrite_script.py \\ + --transcript day58/work/transcript.json \\ + --llm-output /tmp/llm.json \\ + --output day58/work/clean_script.md +""" +import argparse +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from content_guard import enforce as enforce_platform_rules, HardBlock # noqa: E402 + + +STRUCTURES = { + "pain_solve": { + "label": "痛点解决型(教程/干货)", + "order": ["hook", "pain", "turn", "value", "cta"], + "rules": [ + "Hook < 15 字,包含核心反差/数字/痛点", + "Pain 描述真实场景,引发共鸣", + "Turn 给出转折发现,铺垫下面 N 个 value", + "Value 必须是 3 个并列短句,每条 < 25 字", + "CTA 至少含一个问句 + 一个收藏诱因", + ], + }, + "story_reversal": { + "label": "故事反转型(人设/种草)", + "order": ["hook", "pain", "turn", "value", "cta"], + "rules": [ + "Hook 用强时间锚点(凌晨 X 点 / 那天)", + "Pain 写旧状态/触发事件", + "Turn 写心理变化", + "Value 写新状态结论(1-2 条)", + "CTA 走共鸣求证类('只有我这样吗')", + ], + }, + "listicle": { + "label": "清单/盘点型", + "order": ["hook", "pain", "turn", "value", "cta"], + "rules": [ + "Hook 必须含 '我整理了 N 个'", + "Pain 可省略;如有则写'之前不知道这些'", + "Turn 一句过渡", + "Value 是 N 条清单,每条 < 20 字", + "CTA 走收藏诱因 + 续集悬念", + ], + }, +} + + +def load_hook_templates(): + path = os.path.join(os.path.dirname(__file__), "prompts", "hook_templates.yaml") + text = open(path, encoding="utf-8").read() + # Tiny custom parser tuned to our prompts file (list of maps, "- key: value") + items = [] + current = None + for line in text.splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + if line.startswith("- id:"): + if current: + items.append(current) + current = {"id": line.split(":", 1)[1].strip()} + elif current is not None and ":" in line: + k, _, v = line.strip().partition(":") + current[k.strip()] = v.strip().strip('"') + if current: + items.append(current) + return items + + +def load_cta_templates(): + path = os.path.join(os.path.dirname(__file__), "prompts", "cta_templates.yaml") + text = open(path, encoding="utf-8").read() + items = [] + current = None + for line in text.splitlines(): + if not line.strip() or line.lstrip().startswith("#"): + continue + if line.startswith("- id:"): + if current: + items.append(current) + current = {"id": line.split(":", 1)[1].strip()} + elif current is not None and ":" in line: + k, _, v = line.strip().partition(":") + current[k.strip()] = v.strip().strip('"') + if current: + items.append(current) + return items + + +def emit_prompt(transcript: dict, structure: str, hook_template: str, + max_duration_seconds: int, persona: str | None) -> str: + if structure not in STRUCTURES: + raise ValueError(f"unknown structure {structure!r}; pick from {list(STRUCTURES)}") + s = STRUCTURES[structure] + hooks = load_hook_templates() + ctas = load_cta_templates() + + # Compact transcript: just the segment texts and timings + transcript_lines = [] + for seg in transcript.get("segments", []): + start = seg.get("start", 0.0) + text = seg.get("text", "").strip() + if text: + transcript_lines.append(f"[{start:>7.2f}s] {text}") + transcript_block = "\n".join(transcript_lines) + + hook_options = "\n".join( + f"- {h['id']} — {h.get('label', '')}: `{h.get('pattern_zh', '')}`" + for h in hooks + ) + cta_options = "\n".join( + f"- {c['id']} — {c.get('label', '')}: `{c.get('pattern_zh', '')}`" + for c in ctas + ) + + prompt = f"""你是一位小红书短视频的内容编辑。把下面这份口播转写稿重写成一个结构化的发布稿。 + +**结构**: {s['label']} ({structure}) +**规则**: +{chr(10).join('- ' + r for r in s['rules'])} +**目标时长**: ≤ {max_duration_seconds} 秒 +{'**人设**: ' + persona if persona else ''} + +**钩子模板候选**({hook_template} = 让你选最合适的一个,并说明为什么): +{hook_options} + +**CTA 模板候选**(必须挑 1-2 个,含至少一个问句): +{cta_options} + +**原始口播稿** (timestamps in seconds): +{transcript_block} + +**输出格式**(严格 JSON,无其他文字): +```json +{{ + "hook": "≤ 15 字的钩子文本", + "hook_template_id": "从候选里挑的 id", + "pain": "30-50 字的痛点共鸣段落", + "turn": "20-40 字的转折发现", + "value": ["20-25 字干货 1", "20-25 字干货 2", "20-25 字干货 3"], + "cta": "20-40 字的 CTA,包含问句", + "cta_template_ids": ["挑的 cta id 列表"], + "estimated_speech_seconds": 整数估计的口播秒数, + "discarded_segments": ["你删除的原稿片段,用于回滚"] +}} +``` + +不要输出任何其他文字。不要写解释。只输出 JSON。 +""" + return prompt + + +def validate_llm_output(data: dict, max_duration_seconds: int) -> list[str]: + """Return list of validation errors (empty = OK).""" + errors = [] + required = ["hook", "pain", "turn", "value", "cta"] + for field in required: + if field not in data: + errors.append(f"missing required field: {field}") + continue + if "hook" in data: + if not isinstance(data["hook"], str): + errors.append("hook must be str") + elif len(data["hook"]) > 18: + errors.append(f"hook too long ({len(data['hook'])} > 18 chars)") + if "value" in data: + if not isinstance(data["value"], list): + errors.append("value must be a list") + elif len(data["value"]) < 1 or len(data["value"]) > 5: + errors.append(f"value should have 1-5 bullets, got {len(data['value'])}") + if "estimated_speech_seconds" in data: + if data["estimated_speech_seconds"] > max_duration_seconds: + errors.append( + f"estimated_speech_seconds {data['estimated_speech_seconds']} " + f"exceeds max_duration {max_duration_seconds}" + ) + return errors + + +def materialise(data: dict) -> str: + """Turn validated LLM output into a clean_script.md.""" + lines = [ + "# Clean Script", + "", + f"_(generated by rewrite_script.py — hook={data.get('hook_template_id')}, " + f"cta={data.get('cta_template_ids')})_", + "", + "## Hook", + data["hook"], + "", + "## Pain", + data["pain"], + "", + "## Turn", + data["turn"], + "", + "## Value", + ] + for i, v in enumerate(data["value"], 1): + lines.append(f"{i}. {v}") + lines.extend(["", "## CTA", data["cta"], ""]) + return "\n".join(lines) + "\n" + + +def main() -> int: + p = argparse.ArgumentParser(description="Story Engine for Xiaohongshu shorts") + p.add_argument("--transcript", required=True, help="Whisper transcript JSON") + p.add_argument("--structure", default="pain_solve", + choices=list(STRUCTURES), help="Story structure to apply") + p.add_argument("--hook-template", default="auto", + help="'auto' = let LLM choose; or a specific id like 'pain_relate'") + p.add_argument("--max-duration", type=int, default=150, + help="Max target spoken duration in seconds") + p.add_argument("--persona", default=None, help="Speaker identity hint (used in hook)") + p.add_argument("--emit-prompt", action="store_true", + help="Print the LLM prompt and exit") + p.add_argument("--llm-output", default=None, + help="Path to JSON file with the LLM's response, to validate + materialise") + p.add_argument("--output", default=None, + help="Where to write clean_script.md (required with --llm-output)") + args = p.parse_args() + + with open(args.transcript, encoding="utf-8") as f: + transcript = json.load(f) + + if args.emit_prompt and not args.llm_output: + sys.stdout.write(emit_prompt( + transcript, args.structure, args.hook_template, + args.max_duration, args.persona, + )) + return 0 + + if args.llm_output: + with open(args.llm_output, encoding="utf-8") as f: + data = json.load(f) + errors = validate_llm_output(data, args.max_duration) + if errors: + for e in errors: + print(f"❌ {e}", file=sys.stderr) + return 1 + # Content-guard the rewritten fields + try: + enforce_platform_rules([data["hook"]], strict=True, context="title") + enforce_platform_rules( + [data["pain"], data["turn"], data["cta"]] + list(data["value"]), + strict=True, context="script", + ) + except HardBlock as exc: + print(f"🚫 Rewritten script trips the content guard: {exc}", file=sys.stderr) + return 2 + + md = materialise(data) + if args.output: + os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True) + with open(args.output, "w", encoding="utf-8") as f: + f.write(md) + print(f"✅ Wrote {args.output} ({len(md)} bytes)") + else: + sys.stdout.write(md) + return 0 + + p.print_help() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_content_guard.py b/tests/test_content_guard.py new file mode 100644 index 0000000..1eb54d7 --- /dev/null +++ b/tests/test_content_guard.py @@ -0,0 +1,131 @@ +"""content_guard.py — Xiaohongshu/RED platform lint. + +HARD-BLOCK violations halt export; SOFT-WARN raise warnings only. +Coverage: extreme words, off-platform diversion, medical/medical-aesthetic, +wealth bait, political/sexual (block-list stub), title/punctuation rules. +""" +import os +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")) + +from content_guard import ( # noqa: E402 + scan_text, ViolationLevel, + HardBlock, SoftWarn, +) + + +# ── HARD BLOCK — extreme words (广告法极限词) ────────────────────────────── + +@pytest.mark.parametrize("text", [ + "全网最低价", + "国家级认证", + "第一品牌", + "唯一选择", + "极致体验", + "万能解决方案", + "遥遥领先", +]) +def test_extreme_words_blocked(text): + violations = scan_text(text) + assert any(v.level == ViolationLevel.HARD for v in violations), \ + f"{text!r} should hard-block (extreme words)" + + +# ── HARD BLOCK — off-platform diversion ─────────────────────────────────── + +@pytest.mark.parametrize("text", [ + "加微信 wx123abc", + "加我威信", + "加薇 18888888888", + "VX: abc123", + "我的手机 13800138000", + "QQ 12345678", + "+V 详聊", + "vx 详聊", + "加微 详聊", +]) +def test_diversion_blocked(text): + violations = scan_text(text) + assert any(v.level == ViolationLevel.HARD for v in violations), \ + f"{text!r} should hard-block (diversion)" + + +# ── HARD BLOCK — medical/medical-aesthetic functional claims ────────────── + +@pytest.mark.parametrize("text", [ + "根治痘印", + "祛斑神器", + "水光针效果", + "热玛吉同款", + "医生同款产品", + "三甲推荐", + "抗衰老必备", +]) +def test_medical_claims_blocked(text): + violations = scan_text(text) + assert any(v.level == ViolationLevel.HARD for v in violations), \ + f"{text!r} should hard-block (medical/aesthetic claim)" + + +# ── HARD BLOCK — wealth bait ────────────────────────────────────────────── + +@pytest.mark.parametrize("text", [ + "月入5万", + "年入百万", + "稳赚不赔", + "躺赚被动收入", + "财富自由秘籍", + "零成本创业", + "包过包赚", +]) +def test_wealth_bait_blocked(text): + violations = scan_text(text) + assert any(v.level == ViolationLevel.HARD for v in violations), \ + f"{text!r} should hard-block (wealth bait)" + + +# ── SOFT WARN — title/punctuation hygiene ───────────────────────────────── + +def test_title_over_20_chars_warns(): + long_title = "这是一个非常非常非常非常长的小红书标题用来测试警告" + "X" * 10 + violations = scan_text(long_title, context="title") + assert any(v.level == ViolationLevel.SOFT for v in violations), \ + "Title > 20 chars should soft-warn" + + +def test_punctuation_run_warns(): + violations = scan_text("震惊!!!!!", context="title") + assert any(v.level == ViolationLevel.SOFT for v in violations), \ + "3+ consecutive ! should soft-warn" + + +# ── PASS — clean content ────────────────────────────────────────────────── + +@pytest.mark.parametrize("text", [ + "AI失业焦虑?我看到更多机会", # day58 actual title + "DAY 58 — 一个人怎么靠 AI 接客户", + "BestAI Labs", + "看完这条视频,分享你的想法", +]) +def test_clean_text_passes(text): + violations = scan_text(text) + hard = [v for v in violations if v.level == ViolationLevel.HARD] + assert not hard, f"{text!r} should not hard-block; got: {hard}" + + +# ── Exception API ───────────────────────────────────────────────────────── + +def test_hardblock_raises_when_strict(): + from content_guard import enforce + with pytest.raises(HardBlock): + enforce(["加微信 详聊"], strict=True) + + +def test_softwarn_only_when_strict(): + from content_guard import enforce + # Soft-warn should still raise SoftWarn in strict mode + with pytest.raises((SoftWarn, HardBlock)): + enforce(["震惊!!!!!" + "X" * 30], strict=True, context="title") diff --git a/tests/test_profiles.py b/tests/test_profiles.py new file mode 100644 index 0000000..211c387 --- /dev/null +++ b/tests/test_profiles.py @@ -0,0 +1,42 @@ +"""Audience profiles: structure, presence of the two ships (tech_pro, lifestyle), +and key required fields.""" +import os +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")) + +from profiles import load_profile, list_profiles # noqa: E402 + + +def test_tech_pro_profile_loads(): + p = load_profile("tech_pro") + assert p["audience"]["name_zh"] == "科技/创业向" + assert p["duration"]["default_seconds"] == 90 + assert p["audio"]["bgm_gain_below_voice_db"] == -16 + + +def test_lifestyle_profile_loads(): + p = load_profile("lifestyle") + assert p["audience"]["name_zh"] == "生活方式向" + assert p["duration"]["default_seconds"] == 60 + + +def test_list_profiles_contains_both(): + profiles = list_profiles() + assert "tech_pro" in profiles + assert "lifestyle" in profiles + + +def test_missing_profile_raises(): + with pytest.raises(FileNotFoundError): + load_profile("nonexistent_profile_xyz") + + +def test_required_fields_present(): + """Every profile must define duration, cut, subtitle, audio, aspect.""" + for name in list_profiles(): + p = load_profile(name) + for required in ("duration", "cut", "subtitle", "audio", "aspect"): + assert required in p, f"profile {name!r} missing key {required!r}" diff --git a/tests/test_render_content_guard_integration.py b/tests/test_render_content_guard_integration.py new file mode 100644 index 0000000..e7a8a7e --- /dev/null +++ b/tests/test_render_content_guard_integration.py @@ -0,0 +1,68 @@ +"""render_final.py blocks export when title triggers a HARD content-guard rule.""" +import json +import os +import subprocess +import sys + + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def test_render_rejects_diversion_in_title(tmp_path): + fake_video = tmp_path / "fake.mp4" + fake_video.write_bytes(b"\x00") + cfg = tmp_path / "cfg.json" + cfg.write_text(json.dumps({ + "title": "加微信 wx123 详聊", + "clips": [{"video": str(fake_video), "start": 0.0, "end": 1.0}], + })) + out = subprocess.run( + [sys.executable, os.path.join(REPO, "scripts/render_final.py"), + "--config", str(cfg), "--output", str(tmp_path / "out.mp4")], + capture_output=True, text=True, + ) + combined = out.stdout + out.stderr + assert "Content guard refused" in combined or "diversion" in combined.lower(), ( + f"stdout: {out.stdout}\nstderr: {out.stderr}" + ) + assert out.returncode != 0 + + +def test_render_rejects_extreme_word_in_title(tmp_path): + fake_video = tmp_path / "fake.mp4" + fake_video.write_bytes(b"\x00") + cfg = tmp_path / "cfg.json" + cfg.write_text(json.dumps({ + "title": "全网最低价的 AI 课程", + "clips": [{"video": str(fake_video), "start": 0.0, "end": 1.0}], + })) + out = subprocess.run( + [sys.executable, os.path.join(REPO, "scripts/render_final.py"), + "--config", str(cfg), "--output", str(tmp_path / "out.mp4")], + capture_output=True, text=True, + ) + combined = out.stdout + out.stderr + assert "Content guard refused" in combined or "extreme" in combined.lower(), ( + f"stdout: {out.stdout}\nstderr: {out.stderr}" + ) + + +def test_render_no_content_guard_bypasses_check(tmp_path): + """--no-content-guard should let questionable titles through (still hits other errors).""" + fake_video = tmp_path / "fake.mp4" + fake_video.write_bytes(b"\x00") + cfg = tmp_path / "cfg.json" + cfg.write_text(json.dumps({ + "title": "全网最低价的 AI 课程", + "clips": [{"video": str(fake_video), "start": 0.0, "end": 1.0}], + })) + out = subprocess.run( + [sys.executable, os.path.join(REPO, "scripts/render_final.py"), + "--config", str(cfg), "--output", str(tmp_path / "out.mp4"), + "--no-content-guard"], + capture_output=True, text=True, + ) + combined = out.stdout + out.stderr + assert "Content guard refused" not in combined, ( + f"--no-content-guard should bypass.\nstderr: {out.stderr}" + ) diff --git a/tests/test_rewrite_script.py b/tests/test_rewrite_script.py new file mode 100644 index 0000000..570c35c --- /dev/null +++ b/tests/test_rewrite_script.py @@ -0,0 +1,157 @@ +"""Story Engine — prompt emission, LLM-output validation, materialisation.""" +import json +import os +import subprocess +import sys +import tempfile + +import pytest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "scripts")) + +from rewrite_script import ( # noqa: E402 + STRUCTURES, validate_llm_output, materialise, emit_prompt, + load_hook_templates, load_cta_templates, +) + + +def _sample_transcript(): + return { + "segments": [ + {"start": 0.0, "end": 2.5, "text": "今天聊聊 AI 失业焦虑。"}, + {"start": 2.5, "end": 5.0, "text": "我发现机会比焦虑多得多。"}, + ] + } + + +def test_templates_load(): + hooks = load_hook_templates() + ctas = load_cta_templates() + assert len(hooks) == 8, f"expected 8 hook templates, got {len(hooks)}" + assert len(ctas) == 5, f"expected 5 cta templates, got {len(ctas)}" + # spot-check structure + assert hooks[0]["id"] == "anti_consensus" + assert ctas[0]["id"] == "save_bait" + + +def test_emit_prompt_contains_all_required_blocks(): + prompt = emit_prompt(_sample_transcript(), "pain_solve", "auto", 150, None) + assert "pain_solve" in prompt + assert "钩子模板候选" in prompt + assert "CTA 模板候选" in prompt + assert "JSON" in prompt + assert "今天聊聊 AI 失业焦虑" in prompt + + +def test_validate_accepts_well_formed_output(): + data = { + "hook": "AI失业焦虑?我看到机会", + "hook_template_id": "anti_consensus", + "pain": "客户找我做网站,我用 AI 加速完成。", + "turn": "AI 没让我失业,反而让我更忙。", + "value": ["AI 让我接更多项目", "客户付费意愿没降", "时间成本反而下降了"], + "cta": "你是焦虑还是抓住机会?评论区告诉我", + "estimated_speech_seconds": 90, + } + assert validate_llm_output(data, 150) == [] + + +def test_validate_rejects_too_long_hook(): + data = { + "hook": "A" * 25, + "pain": "x", "turn": "x", "value": ["x"], "cta": "x", + } + errors = validate_llm_output(data, 150) + assert any("hook too long" in e for e in errors) + + +def test_validate_rejects_missing_field(): + data = {"hook": "x", "pain": "x", "turn": "x", "cta": "x"} # no value + errors = validate_llm_output(data, 150) + assert any("value" in e for e in errors) + + +def test_validate_rejects_duration_overrun(): + data = { + "hook": "x", "pain": "x", "turn": "x", "value": ["a"], "cta": "x", + "estimated_speech_seconds": 999, + } + errors = validate_llm_output(data, 150) + assert any("exceeds max_duration" in e for e in errors) + + +def test_materialise_produces_markdown(): + data = { + "hook": "AI失业焦虑?我看到机会", + "pain": "客户找我做网站。", + "turn": "AI 没让我失业。", + "value": ["接更多项目", "客户付费意愿没降"], + "cta": "你是焦虑还是抓住机会?", + } + md = materialise(data) + assert "# Clean Script" in md + assert "## Hook" in md + assert "## Pain" in md + assert "## Value" in md + assert "1. 接更多项目" in md + assert "2. 客户付费意愿没降" in md + assert "## CTA" in md + + +def test_cli_emit_prompt(tmp_path): + transcript_path = tmp_path / "t.json" + transcript_path.write_text(json.dumps(_sample_transcript())) + out = subprocess.run( + [sys.executable, os.path.join(REPO, "scripts/rewrite_script.py"), + "--transcript", str(transcript_path), "--emit-prompt"], + capture_output=True, text=True, check=True, + ) + assert "JSON" in out.stdout + assert "pain_solve" in out.stdout + + +def test_cli_materialise_round_trip(tmp_path): + transcript_path = tmp_path / "t.json" + transcript_path.write_text(json.dumps(_sample_transcript())) + llm_path = tmp_path / "llm.json" + llm_path.write_text(json.dumps({ + "hook": "AI失业焦虑?我看到机会", + "pain": "客户找我做网站,AI 加速完成。", + "turn": "AI 没让我失业,反而让我更忙。", + "value": ["接更多项目", "时间成本下降"], + "cta": "你是焦虑还是抓住机会?", + "estimated_speech_seconds": 90, + })) + out_path = tmp_path / "clean.md" + out = subprocess.run( + [sys.executable, os.path.join(REPO, "scripts/rewrite_script.py"), + "--transcript", str(transcript_path), + "--llm-output", str(llm_path), + "--output", str(out_path)], + capture_output=True, text=True, + ) + assert out.returncode == 0, f"stderr: {out.stderr}" + assert out_path.exists() + content = out_path.read_text() + assert "AI失业焦虑" in content + + +def test_cli_rejects_llm_output_with_diversion(tmp_path): + transcript_path = tmp_path / "t.json" + transcript_path.write_text(json.dumps(_sample_transcript())) + llm_path = tmp_path / "llm.json" + llm_path.write_text(json.dumps({ + "hook": "加微信 wx", + "pain": "x", "turn": "x", "value": ["a"], "cta": "x", + "estimated_speech_seconds": 30, + })) + out_path = tmp_path / "clean.md" + out = subprocess.run( + [sys.executable, os.path.join(REPO, "scripts/rewrite_script.py"), + "--transcript", str(transcript_path), + "--llm-output", str(llm_path), + "--output", str(out_path)], + capture_output=True, text=True, + ) + assert out.returncode != 0, "Diversion-flagged LLM output should be rejected"