diff --git a/README.md b/README.md index f444d4d..b5d2471 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,49 @@ AI-powered tool for generating YouTube Shorts / TikTok videos with script genera - **Two pipelines**: normal (YouTube bg + optional image overlays) and images-only (AI/web images + overlay animation, no YouTube bg) - **Typer CLI** — nested subcommands, auto-generated `--help`, shell completion - **Batch Processing** — semicolon-separated subjects for multi-video runs +- **Hook Engine** — 7 viral hook styles (curiosity, counter-narrative, controversy, challenge, reveal, story) with pattern interrupt and curiosity gap mechanics + +## Hook Styles + +AutoShorts now supports multiple hook styles to optimize script engagement: + +- `default` — Original curiosity-driven hook (backward compatible) +- `curiosity` — Creates information asymmetry (viewer knows something is important but not what) +- `counter` — Challenges common knowledge and assumptions +- `controversy` — Takes a strong stance that demands engagement +- `challenge` — Direct challenge to viewer's knowledge +- `reveal` — Promises a shocking fact upfront +- `story` — Opens a narrative arc with character and conflict + +### Usage + +```bash +autoshorts new explainer "Corinthians 2012 Libertadores" --hook-style controversy +autoshorts new explainer "Pelé 1000 goals" --hook-style curiosity +autoshorts new explainer "Neymar vs Ronaldo" --hook-style counter +``` + +### Examples + +```bash +# Curiosity gap (works well for history/facts) +autoshorts new explainer "Pelé 1000 goals" --hook-style curiosity + +# Counter-narrative (works well for debates) +autoshorts new explainer "Neymar vs Ronaldo" --hook-style counter + +# Controversy (works well for opinions) +autoshorts new explainer "VAR no futebol brasileiro" --hook-style controversy + +# Challenge (works well for trivia) +autoshorts new explainer "Primeiro estrangeiro no Brasil" --hook-style challenge + +# Reveal (works well for secrets) +autoshorts new explainer "Flamengo 1981 bastidores" --hook-style reveal + +# Story (works well for narratives) +autoshorts new explainer "Palmeiras 2006 quase rebaixado" --hook-style story +``` ## Installation @@ -111,9 +154,10 @@ AutoShorts/ │ │ └── explainer.py # ExplainerGenerator (both pipelines) │ └── modules/ # Core modules │ ├── config.py +│ ├── hook_engine.py # Hook Engine with 7 viral styles, pattern interrupt, curiosity gap │ ├── image_searcher.py # Web/AI image search + NSFW filter │ ├── logging_system.py -│ ├── script_generator.py # Script gen, fact verification, title validation +│ ├── script_generator.py # Script gen, fact verification, title validation, hook integration │ ├── subtitle_system.py │ ├── tts_system.py │ ├── utils.py @@ -125,6 +169,7 @@ AutoShorts/ │ ├── test_config.py │ ├── test_edge_cases.py │ ├── test_fluximages.py # Explainer generator tests +│ ├── test_hook_engine.py # Hook Engine (15+ tests) │ ├── test_init.py │ ├── test_integration.py │ ├── test_script_generator.py diff --git a/src/autoshorts/cli/commands/explainer.py b/src/autoshorts/cli/commands/explainer.py index 66df890..3e2f0d9 100644 --- a/src/autoshorts/cli/commands/explainer.py +++ b/src/autoshorts/cli/commands/explainer.py @@ -8,6 +8,7 @@ from ...generators import VIDEO_TYPES from ...modules import VideoMetadata, log, shutdown_computer +from ...modules.hook_engine import HookStyle from ..new import new_app @@ -42,6 +43,13 @@ def explainer_command( "-c", help="Custom instructions for the AI on how to craft the video alongside the theme", ), + hook_style: HookStyle = typer.Option( + HookStyle.DEFAULT, + "--hook-style", + "-hs", + case_sensitive=False, + help="Hook style for script generation (default, curiosity, counter, controversy, challenge, reveal, story)", + ), ): if no_images and images_only: raise typer.BadParameter("--no-images and --images-only are mutually exclusive") @@ -63,8 +71,9 @@ def explainer_command( output_path = Path(output) success_count = 0 total_count = len(subjects) + def _sanitize(s): - return re.sub(r'[\\/*?:"<>|]', "", s).replace(" ", "_").lower()[:20] + return re.sub(r'[\\/*?:\"<>|]', "", s).replace(" ", "_").lower()[:20] for i, subj in enumerate(subjects, 1): log(f"Processing {i}/{total_count}: {subj or 'youtube-url'}") @@ -90,6 +99,7 @@ def _sanitize(s): "no_web_search": no_web_search, "batch": batch, "custom_instructions": custom_instructions, + "hook_style": hook_style.value if isinstance(hook_style, HookStyle) else str(hook_style), }, ensure_ascii=False), ) @@ -103,6 +113,7 @@ def _sanitize(s): image_source=images, custom_instructions=custom_instructions, metadata=metadata, + hook_style=hook_style, ) success = asyncio.run(gen.generate()) if success: diff --git a/src/autoshorts/cli/commands/help_cmd.py b/src/autoshorts/cli/commands/help_cmd.py index a5fd575..af73ce1 100644 --- a/src/autoshorts/cli/commands/help_cmd.py +++ b/src/autoshorts/cli/commands/help_cmd.py @@ -13,15 +13,18 @@ def help_command( if args: for name in args: - if isinstance(target, click.Group) and name in target.commands: - target = target.commands[name] - info_parts.append(name) - else: - typer.secho( - f"Error: No such command: autoshorts {' '.join(args)}", - fg="red", - ) - raise typer.Exit(1) + if hasattr(target, "commands"): + cmds = getattr(target, "commands", {}) or {} + if name in cmds: + target = cmds[name] + info_parts.append(name) + continue + + typer.secho( + f"Error: No such command: autoshorts {' '.join(args)}", + fg="red", + ) + raise typer.Exit(1) help_ctx = click.Context(target, info_name=" ".join(info_parts)) typer.echo(target.get_help(help_ctx)) diff --git a/src/autoshorts/generators/explainer.py b/src/autoshorts/generators/explainer.py index 1ff51f6..6531a5a 100644 --- a/src/autoshorts/generators/explainer.py +++ b/src/autoshorts/generators/explainer.py @@ -50,6 +50,7 @@ log, setup_directories, ) +from ..modules.hook_engine import HookStyle class ExplainerGenerator: @@ -65,6 +66,7 @@ def __init__( image_source: str = "web", custom_instructions: str | None = None, metadata: VideoMetadata | None = None, + hook_style: HookStyle = HookStyle.DEFAULT, ): self.subject = subject self.output = output @@ -75,10 +77,12 @@ def __init__( self.image_source = image_source self.custom_instructions = custom_instructions self.metadata = metadata or VideoMetadata() + self.hook_style = hook_style self.script_generator = ScriptGenerator( web_search=web_search, custom_instructions=custom_instructions, + hook_style=hook_style, ) self.tts_system = TTSSystem() self.temp_dir = create_temp_dir() diff --git a/src/autoshorts/modules/__init__.py b/src/autoshorts/modules/__init__.py index 4d6e51f..e55a69a 100644 --- a/src/autoshorts/modules/__init__.py +++ b/src/autoshorts/modules/__init__.py @@ -71,6 +71,7 @@ YOUTUBE_FORMAT, YOUTUBE_MAX_HEIGHT, ) +from .hook_engine import HookEngine, HookStyle from .image_searcher import ImageSearcher from .logging_system import Colors, log from .metadata import VideoMetadata @@ -164,6 +165,8 @@ "log", "Colors", "ScriptGenerator", + "HookEngine", + "HookStyle", "TTSSystem", "VideoBackgroundManager", "VideoCompositor", diff --git a/src/autoshorts/modules/hook_engine.py b/src/autoshorts/modules/hook_engine.py new file mode 100644 index 0000000..91760f1 --- /dev/null +++ b/src/autoshorts/modules/hook_engine.py @@ -0,0 +1,553 @@ +""" +Hook Engine — generates optimized hook templates based on style and subject. + +Implements proven copywriting frameworks: +- Triple Hook (Kallaway): Visual + verbal + curiosity gap in first 3 seconds +- Curiosity Gap: information asymmetry +- Pattern Interrupt: unexpected statement breaking scroll +- Counter-Narrative: challenge what viewer thinks they know +- Cold Email Storytelling (Hormozi): specific detail + open loop + pattern interrupt + +Hook quality rules: +- 1-2 sentences max +- Must contain specific fact, name, or number (grounded, not hallucinated) +- Must NOT start with "Você sabia", "Prepare-se", or rhetorical questions +""" + +import hashlib +import random +import re +from enum import Enum +from typing import Dict, Optional + + +class HookStyle(str, Enum): + """Available hook styles for script generation.""" + + DEFAULT = "default" # Current behavior (backward compatible) + CURIOSITY_GAP = "curiosity" # Create information asymmetry + COUNTER_NARRATIVE = "counter" # Challenge common knowledge + CONTROVERSY = "controversy" # Take a strong stance + CHALLENGE = "challenge" # Direct viewer challenge + REVEAL = "reveal" # Promise a shocking fact + STORY = "story" # Narrative arc hook + + +class HookEngine: + """Generates optimized hook templates based on style and subject.""" + + # Template definitions — used to drive generation (not dead code) + _TEMPLATES_INFO = { + HookStyle.CURIOSITY_GAP: [ + "Em [YEAR], [ENTITY] [ACTION] — e ninguém esperava que [CONSEQUENCE].", + "O que [ENTITY] fez em [YEAR] mudou [DOMAIN] para sempre. Mas o motivo é mais estranho do que parece.", + "[NUMBER] [TIME_PERIOD] depois de [EVENT], [ENTITY] finalmente [REVEAL]. A razão vai te surpreender.", + ], + HookStyle.COUNTER_NARRATIVE: [ + "Você acha que conhece [SUBJECT]. Mas a história real é completamente diferente.", + "Todo mundo diz que [COMMON_BELIEF]. Os dados mostram o oposto.", + "[ENTITY] não é [COMMON_ASSUMPTION]. Na verdade, é [SURPRISING_TRUTH]. E a prova está em [SPECIFIC_DATA].", + ], + HookStyle.CONTROVERSY: [ + "Vou dizer algo impopular: [CONTROVERSIAL_TAKE]. E posso provar.", + "[ENTITY] é superestimado. Aqui estão [NUMBER] razões que ninguém quer admitir.", + "A maior mentira do [DOMAIN] é que [LIE]. A verdade? [TRUTH].", + ], + HookStyle.CHALLENGE: [ + "Aposto que você não sabe [SURPRISING_FACT]. Duvido. Vou te provar.", + "Se você acha que [ASSUMPTION], você está errado. E eu vou te mostrar por quê.", + "Pare tudo. Você não vai acreditar como [ENTITY] [ACTION]. E a história real é melhor que a ficção.", + ], + HookStyle.REVEAL: [ + "O que você vai descobrir sobre [SUBJECT] vai mudar como você vê [DOMAIN].", + "[ENTITY] tinha um segredo. Só que esse segredo não era o que todo mundo pensava.", + "Em [YEAR], aconteceu algo que [DOMAIN] nunca contou. Até hoje.", + ], + HookStyle.STORY: [ + "Tudo começou quando [ENTITY] decidiu [ACTION]. Ninguém imaginava o que viria depois.", + "Em [YEAR], [ENTITY] estava prestes a [CRISIS]. O que aconteceu nos próximos [TIME_PERIOD] definiu [DOMAIN].", + "[ENTITY] tinha [AGE] anos quando [EVENT]. A decisão que tomou naquele dia mudou tudo.", + ], + } + + # Supporting vocab — PT-BR focused, non-hallucinating where possible + _ACTIONS = [ + "fez história", + "mudou as regras do jogo", + "quebrou todos os recordes", + "venceu contra todas as probabilidades", + "criou algo que ninguém esperava", + "tomou uma decisão ousada", + "entrou para a história", + "chocou o mundo", + ] + + _CONSEQUENCES = [ + "isso mudaria o jogo para sempre", + "um estádio inteiro ficasse em silêncio", + "nada seria igual depois", + "o mundo inteiro prestasse atenção", + "a história tomasse outro rumo", + "tudo virasse de cabeça para baixo", + ] + + _DOMAINS = [ + "o futebol", + "o esporte", + "a história", + "o futebol brasileiro", + "o Brasil", + "o campeonato", + "o esporte mundial", + ] + + _TIME_PERIODS = ["anos", "meses", "décadas"] + + _REVEALS = [ + "revelou a verdade", + "contou o que realmente aconteceu", + "mostrou os bastidores", + "explicou o mistério", + "abriu o jogo", + "quebrou o silêncio", + ] + + _CRISES = [ + "perder tudo", + "ser esquecido", + "cair para a segunda divisão", + "encerrar a carreira", + "desaparecer do mapa", + "ficar sem patrocínio", + "abandonar o sonho", + ] + + _COMMON_BELIEFS = [ + "{subject} é simples", + "{subject} já foi explicado", + "{subject} não tem mais segredos", + "todo mundo entende {subject}", + "{subject} é só mais um caso", + ] + + _CONTROVERSIAL_TAKES = [ + "{subject} não é o que você pensa", + "a história de {subject} foi mal contada", + "{subject} mudou tudo, mas não como contam", + "ninguém quer admitir a verdade sobre {subject}", + ] + + def __init__(self): + pass + + # ------------------------------------------------------------------ # + # Public API + # ------------------------------------------------------------------ # + + def get_tone_instructions(self, style: HookStyle) -> str: + """Returns enhanced tone instructions for the script generator.""" + try: + if not isinstance(style, HookStyle): + style = HookStyle(style) + except Exception: + style = HookStyle.DEFAULT + + base_map = { + HookStyle.DEFAULT: ( + "STYLE: default — curiosity-driven, narrative, engaging. " + "Write like a storyteller uncovering a fascinating truth. " + "TONE: curiosity, revelation, factual storytelling. " + "Maintain the original hook approach — drop into action, no setup. " + "Keep paragraphs punchy, specific, verifiable.\n" + ), + HookStyle.CURIOSITY_GAP: ( + "STYLE: curiosity — curiosity gap principle. Create information asymmetry. " + "TONE: suspenseful, inquisitive, withholding key detail until later. " + "Open a loop in first sentence that only closes at the end. " + "Use specific numbers, years, names to make gap concrete. " + "Maintain curiosity throughout, each paragraph answers one question while opening another.\n" + ), + HookStyle.COUNTER_NARRATIVE: ( + "STYLE: counter-narrative — challenge common knowledge. " + "TONE: counter, contrarian, evidence-driven, cognitive dissonance. " + "Start by stating what people believe, then dismantle with facts. " + "Use data, dates, and specific counter-examples. " + "Maintain skeptical, myth-busting stance throughout script.\n" + ), + HookStyle.CONTROVERSY: ( + "STYLE: controversy — strong polarizing stance demanding engagement. " + "TONE: bold, declarative, fact-backed controversy, unapologetic. " + "Take a clear position that forces viewer to agree or disagree. " + "Support every claim with specific data, avoid empty opinions. " + "Maintain strong stance, don't soften in middle paragraphs.\n" + ), + HookStyle.CHALLENGE: ( + "STYLE: challenge — direct viewer knowledge challenge. " + "TONE: challenging, interactive, confidence, playful confrontation. " + "Address viewer directly ('você'), test their knowledge. " + "Promise proof and deliver. " + "Maintain engaging, direct-response energy throughout.\n" + ), + HookStyle.REVEAL: ( + "STYLE: reveal — promise shocking fact upfront. " + "TONE: mysterious, revelation-driven, build-up to payoff. " + "Tease secret or hidden fact in hook, escalate tension. " + "Each paragraph adds layer to revelation. " + "Final paragraph delivers payoff clearly.\n" + ), + HookStyle.STORY: ( + "STYLE: story — narrative arc with character and conflict. " + "TONE: narrative, cinematic, emotional arc, character-driven story. " + "Open with character + decision + stakes. " + "Structure: setup, crisis, turning point, resolution. " + "Maintain story tension, show transformation.\n" + ), + } + + return base_map.get(style, base_map[HookStyle.DEFAULT]) + + def generate_hook( + self, subject: str, style: HookStyle = HookStyle.DEFAULT, context: str = "" + ) -> dict: + """ + Returns: + { + "hook": str, + "pattern_interrupt": str, + "curiosity_gap": str, + "tone_instructions": str + } + """ + try: + if not isinstance(style, HookStyle): + style = HookStyle(style) + except Exception as e: + # Log invalid style fallback for debugging + try: + from .logging_system import log + + log(f"Hook generation: invalid style '{style}' -> fallback DEFAULT: {e}", "WARNING") + except Exception: + pass + style = HookStyle.DEFAULT + + safe_subject = (subject or "").strip() or "essa história" + tone_instructions = self.get_tone_instructions(style) + + if style == HookStyle.DEFAULT: + return { + "hook": "", + "pattern_interrupt": "", + "curiosity_gap": "", + "tone_instructions": tone_instructions, + } + + try: + hook = self._build_hook(safe_subject, style, context) + pattern_interrupt = self._build_pattern_interrupt(safe_subject, style, context) + curiosity_gap = self._build_curiosity_gap(safe_subject, style, context) + + hook = self._enforce_quality(hook, safe_subject, style) + + return { + "hook": hook, + "pattern_interrupt": pattern_interrupt, + "curiosity_gap": curiosity_gap, + "tone_instructions": tone_instructions, + } + except Exception as e: + try: + from .logging_system import log + + log(f"Hook generation failed for style {style}: {e} — falling back to DEFAULT", "WARNING") + except Exception: + pass + return { + "hook": "", + "pattern_interrupt": "", + "curiosity_gap": "", + "tone_instructions": self.get_tone_instructions(HookStyle.DEFAULT), + } + + # ------------------------------------------------------------------ # + # Internal builders — grounded, no random hallucinated years + # ------------------------------------------------------------------ # + + def _stable_seed(self, subject: str, style: HookStyle) -> int: + h = hashlib.sha256(f"{subject}|{style.value}".encode()).hexdigest() + return int(h[:8], 16) + + def _extract_year(self, subject: str, context: str = "") -> Optional[int]: + """Extract year from subject or context — returns None if not found (no hallucination).""" + # Subject first (most reliable) + m = re.search(r"\b(18\d{2}|19\d{2}|20[0-2]\d|202[0-5])\b", subject) + if m: + return int(m.group(1)) + # Then context (web search results) + if context: + m = re.search(r"\b(18\d{2}|19\d{2}|20[0-2]\d|202[0-5])\b", context) + if m: + return int(m.group(1)) + return None + + def _extract_entity(self, subject: str) -> str: + s = subject.strip() + if len(s) > 60: + words = s.split() + if len(words) > 4: + return " ".join(words[:4]) + return s + + def _domain_from_subject(self, subject: str, seed: int) -> str: + low = subject.lower() + if any( + k in low + for k in [ + "futebol", + "flamengo", + "corinthians", + "palmeiras", + "vasco", + "são paulo", + "seleção", + "copa", + "libertadores", + "brasileirão", + "var", + "neymar", + "pelé", + "zico", + "ronaldo", + ] + ): + rng = random.Random(seed + 1) + return rng.choice(["o futebol brasileiro", "o futebol", "o esporte"]) + if any(k in low for k in ["tecnologia", "ia", "inteligência", "space", "nasa"]): + return "a tecnologia" + return random.Random(seed + 2).choice(self._DOMAINS) + + def _number_for_subject(self, seed: int, context: str = "") -> int: + """Return a number for structural templates (e.g., '5 razões') — not claiming factual world data.""" + # Try to extract a number from context that could be relevant, else deterministic random for structure only + if context: + m = re.search(r"\b([3-9])\s+(?:títulos|anos|vezes|gols|motivos|razões)\b", context, re.IGNORECASE) + if m: + try: + return int(m.group(1)) + except Exception: + pass + rng = random.Random(seed + 3) + return rng.randint(3, 9) + + def _fill_template(self, template: str, values: Dict[str, str]) -> str: + """Replace [PLACEHOLDER] with values — leaves unknown placeholders intact then cleans.""" + result = template + for key, val in values.items(): + placeholder = f"[{key}]" + result = result.replace(placeholder, str(val)) + # Clean any unreplaced placeholders: replace with generic safe value or remove brackets + # For year/missing values, replace [YEAR] with empty and tidy spaces if values had empty + result = re.sub(r"\[([A-Z_]+)\]", "", result) + result = re.sub(r"\s+", " ", result).strip() + result = result.replace(" ", " ").replace(" .", ".").replace(" ,", ",") + return result + + def _build_placeholder_values( + self, subject: str, style: HookStyle, context: str, seed: int, year: Optional[int] + ) -> Dict[str, str]: + rng = random.Random(seed) + entity = self._extract_entity(subject) + domain = self._domain_from_subject(subject, seed) + number = self._number_for_subject(seed, context) + time_period = rng.choice(self._TIME_PERIODS) + reveal = rng.choice(self._REVEALS) + action = rng.choice(self._ACTIONS) + consequence = rng.choice(self._CONSEQUENCES) + crisis = rng.choice(self._CRISES) + age = str(rng.randint(17, 35)) + + # Derived values + event = subject + common_belief = rng.choice(self._COMMON_BELIEFS).format(subject=subject) + controversial_take = rng.choice(self._CONTROVERSIAL_TAKES).format(subject=subject) + + # Grounded specific data — only use year if we actually found it + if year: + specific_data = f"{number} títulos em {year}" if "futebol" in domain else f"dados oficiais de {year}" + truth = f"{entity} quebrou todas as estatísticas em {year}" + else: + specific_data = "dados oficiais" + truth = f"{entity} quebrou todas as estatísticas" + + # For templates needing surprising fact etc. + if year: + surprising_fact = f"quem foi o primeiro a fazer {subject} em {year}" + fact_with_year = f"em {year}" + else: + surprising_fact = f"o detalhe escondido de {subject}" + fact_with_year = "naquela época" + + # Build dict for all placeholders used in _TEMPLATES_INFO + values = { + "YEAR": str(year) if year else "", + "ENTITY": entity, + "SUBJECT": subject, + "ACTION": action, + "CONSEQUENCE": consequence, + "DOMAIN": domain, + "NUMBER": str(number), + "TIME_PERIOD": time_period, + "EVENT": event, + "REVEAL": reveal, + "AGE": age, + "CRISIS": crisis, + "COMMON_BELIEF": common_belief, + "COMMON_ASSUMPTION": f"só {domain}", + "SURPRISING_TRUTH": "o oposto do que contam", + "SPECIFIC_DATA": specific_data, + "CONTROVERSIAL_TAKE": controversial_take, + "LIE": f"{domain} é justo", + "TRUTH": truth, + "SURPRISING_FACT": surprising_fact, + "ASSUMPTION": f"{subject} sempre foi assim", + "FACT_WITH_YEAR": fact_with_year, + } + return values + + def _build_hook(self, subject: str, style: HookStyle, context: str) -> str: + seed = self._stable_seed(subject, style) + rng = random.Random(seed) + year = self._extract_year(subject, context) + values = self._build_placeholder_values(subject, style, context, seed, year) + + templates = self._TEMPLATES_INFO.get(style, []) + if not templates: + entity = self._extract_entity(subject) + return f"{entity} — a história que {values['DOMAIN']} tentou esquecer." + + # If year is None, prefer templates without [YEAR] to avoid hallucination + if year is None: + no_year_templates = [t for t in templates if "[YEAR]" not in t] + if no_year_templates: + templates = no_year_templates + + # Deterministic choice + tmpl = rng.choice(templates) + hook = self._fill_template(tmpl, values) + + # Post-process: if we removed year and left double spaces like "Em ," + hook = hook.replace("Em ,", "Em um momento,").replace("Em ,", "Em um momento,") + hook = re.sub(r"\bEm\s+,", "Em um momento,", hook) + + # If hook still empty after filling, fallback + if not hook or len(hook) < 10: + entity = self._extract_entity(subject) + if year: + return f"{entity} em {year} mudou {values['DOMAIN']} para sempre." + return f"{entity} — a história que {values['DOMAIN']} tentou esquecer." + + return hook + + def _build_pattern_interrupt(self, subject: str, style: HookStyle, context: str) -> str: + seed = self._stable_seed(subject, style) + 100 + rng = random.Random(seed) + year = self._extract_year(subject, context) + entity = self._extract_entity(subject) + year_str = str(year) if year else "naquela época" + domain = self._domain_from_subject(subject, seed) + + patterns = { + HookStyle.CURIOSITY_GAP: [ + f"{entity} fez algo que ninguém viu chegando.", + f"Em {year_str}, tudo mudou em 48 horas." if year else f"{entity} mudou tudo em 48 horas.", + f"O dado que ninguém mostra: {rng.randint(70,99)}% das pessoas erram isso.", + ], + HookStyle.COUNTER_NARRATIVE: [ + f"Os livros contam uma versão. Os números contam outra.", + f"{entity} quebrou a lógica de {domain}.", + f"O oposto do que te ensinaram na escola.", + ], + HookStyle.CONTROVERSY: [ + f"Se isso te irrita, é porque é verdade.", + f"{entity} divide opiniões por um motivo.", + f"Polêmica? Sim. Mas com prova.", + ], + HookStyle.CHALLENGE: [ + f"Você tem 3 segundos para responder.", + f"A maioria erra. Você também vai?", + f"Pause. Tenta adivinhar.", + ], + HookStyle.REVEAL: [ + f"O detalhe que faltava estava em {year_str}." if year else "O detalhe que faltava estava nos bastidores.", + f"Ninguém viu isso chegando até hoje.", + f"E o segredo estava nos números.", + ], + HookStyle.STORY: [ + f"Era para ser o fim. Foi só o começo.", + f"Quando tudo parecia perdido, {entity} virou o jogo.", + f"Uma decisão. Um segundo. Tudo mudou.", + ], + } + lst = patterns.get(style, ["Algo inesperado aconteceu."]) + return rng.choice(lst) + + def _build_curiosity_gap(self, subject: str, style: HookStyle, context: str) -> str: + seed = self._stable_seed(subject, style) + 200 + year = self._extract_year(subject, context) + entity = self._extract_entity(subject) + domain = self._domain_from_subject(subject, seed) + year_part = f"em {year}" if year else "naquela época" + + gaps = { + HookStyle.CURIOSITY_GAP: f"Por que {entity} fez isso {year_part} e como isso ainda afeta {domain} hoje?", + HookStyle.COUNTER_NARRATIVE: f"Qual é a história real de {subject} que os dados revelam, mas ninguém conta?", + HookStyle.CONTROVERSY: f"Por que {entity} é tão polarizador e o que os números escondem sobre {subject}?", + HookStyle.CHALLENGE: f"Você realmente sabe o que aconteceu com {entity} {year_part}?", + HookStyle.REVEAL: f"Qual foi o segredo de {entity} {year_part} que {domain} nunca contou?", + HookStyle.STORY: f"Como {entity} saiu de {random.Random(seed).choice(self._CRISES)} para definir {domain}?", + } + return gaps.get(style, f"O que realmente aconteceu com {subject} {year_part}?") + + def _enforce_quality(self, hook: str, subject: str, style: HookStyle) -> str: + """Ensure hook meets quality criteria — no forbidden starts, 1-2 sentences, grounded.""" + hook = hook.strip() + + # Rule 1: Must not start with forbidden phrases + forbidden_starts = ["você sabia", "prepare-se", "voce sabia", "vocês sabiam"] + lower = hook.lower() + for fb in forbidden_starts: + if lower.startswith(fb): + hook = hook[len(fb) :].lstrip(" ?!:,-") + if hook: + hook = hook[0].upper() + hook[1:] + if subject.lower() not in hook.lower(): + hook = f"{self._extract_entity(subject)}: {hook}" + break + + if hook.startswith("?"): + hook = hook.lstrip("? ").strip() + + # Rule 2: Must contain specific fact — ensure subject/entity present, if not inject + if subject.lower() not in hook.lower() and self._extract_entity(subject).lower() not in hook.lower(): + # Append entity if missing and length allows + if len(hook) < 120: + hook = f"{hook.rstrip('.')} — {self._extract_entity(subject)}." + + # Rule 3: 1-2 sentences max + sentences = [s.strip() for s in hook.split(".") if s.strip()] + if len(sentences) > 2: + hook = ". ".join(sentences[:2]) + "." + + if hook and hook[-1] not in ".!?": + hook += "." + + if len(hook) < 15: + entity = self._extract_entity(subject) + year = self._extract_year(subject) + if year: + hook = f"{entity} em {year} mudou {self._domain_from_subject(subject, self._stable_seed(subject, style))} para sempre." + else: + hook = f"{entity} — a história que {self._domain_from_subject(subject, self._stable_seed(subject, style))} tentou esquecer." + + hook = re.sub(r"\s+", " ", hook).strip() + return hook diff --git a/src/autoshorts/modules/script_generator.py b/src/autoshorts/modules/script_generator.py index 14130f2..daf48de 100644 --- a/src/autoshorts/modules/script_generator.py +++ b/src/autoshorts/modules/script_generator.py @@ -11,6 +11,7 @@ API_URL, MODEL_TEXT, ) +from .hook_engine import HookEngine, HookStyle from .logging_system import log from .web_search import WebSearcher @@ -20,12 +21,18 @@ class ScriptGenerator: """Unified script generation using Pollinations API. - When web_search is enabled, uses a two-step approach: - Step 1 — generate verification search queries + title (draft is a byproduct) - Step 2 — search the web, then generate the final script grounded in results + HookEngine integration: + - Generates optimized hook BEFORE verification to preserve fact-checking + - Does NOT re-inject after verification to avoid overwriting corrections (fixes hallucination bypass) + - Uses context to ground year extraction (no random hallucinated years) """ - def __init__(self, web_search: bool = True, custom_instructions: str | None = None): + def __init__( + self, + web_search: bool = True, + custom_instructions: str | None = None, + hook_style: HookStyle = HookStyle.DEFAULT, + ): self.api_url = API_URL self.api_key = API_KEY self.model = MODEL_TEXT @@ -33,34 +40,57 @@ def __init__(self, web_search: bool = True, custom_instructions: str | None = No self.custom_instructions = custom_instructions self.searcher = WebSearcher() if web_search else None self.generated_title: str | None = None + self.hook_engine = HookEngine() + try: + if not isinstance(hook_style, HookStyle): + self.hook_style = HookStyle(hook_style) + else: + self.hook_style = hook_style + except Exception: + self.hook_style = HookStyle.DEFAULT def _tone_instructions(self) -> str: - base = ( + """Get enhanced tone instructions based on hook style.""" + base_tone = ( "TONE: Curiosity-driven, narrative, engaging. " - "Write like a storyteller uncovering a fascinating truth \u2014 " + "Write like a storyteller uncovering a fascinating truth — " "never like Wikipedia or a corporate press release.\n" - "FIRST SENTENCE: Drop the viewer right into the action \u2014 " - "the goal, the controversy, the fact itself. " - "NO: 'Prepare-se', 'Voc\u00ea sabia', rhetorical questions. " - "Never waste the first 2 seconds on setup.\n" - "STRUCTURE: Hook (the fact itself) \u2192 Context \u2192 Revelation \u2192 Strong conclusion\n" "FORBIDDEN: Legal names (Ltda, S.A.), addresses, city/state abbreviations. " "NO corporate language.\n" "FORBIDDEN: Hyperboles, exaggerated claims, 'designed by a god', " - "'you won't believe', 'shocking truth' \u2014 these sound fake.\n" + "'you won't believe', 'shocking truth' — these sound fake.\n" ) + try: + hook_instructions = self.hook_engine.get_tone_instructions(self.hook_style) + except Exception: + hook_instructions = "" + + full = base_tone + hook_instructions if self.custom_instructions: - base += ( - f"\nADDITIONAL USER INSTRUCTIONS: {self.custom_instructions}\n" - ) - return base + full += f"\nADDITIONAL USER INSTRUCTIONS: {self.custom_instructions}\n" + return full + + def _inject_hook(self, script: list, subject: str, context: str = "") -> list: + """Inject optimized hook into first paragraph — used consistently (DRY).""" + if not script: + return script + if self.hook_style == HookStyle.DEFAULT: + return script + try: + hook_data = self.hook_engine.generate_hook(subject, self.hook_style, context) + hook = hook_data.get("hook", "") + if hook and len(hook) > 10 and not self._is_filler(hook): + script = list(script) + script[0] = hook + except Exception as e: + log(f"Hook injection failed: {e}, keeping original script", "WARNING") + return script # ── Public API ────────────────────────────────────────────────────── def generate_script(self, subject: str) -> list: """Generate script from subject. Searches web first, then generates grounded script.""" log("Generating script...") - tone_block = self._tone_instructions() if not self.web_search or not self.searcher or not subject: @@ -68,17 +98,15 @@ def generate_script(self, subject: str) -> list: tone_block + _SYSTEM_PROMPT_SINGLE, _user_prompt_single(subject, ""), ) + # Single injection — no verification in this path + script = self._inject_hook(script, subject, "") self.generated_title = self._generate_title_from_script(script, subject) return script - # Step 1: generate independent search queries (NOT from draft — avoids circular hallucination) log("Step 1: generating independent search queries...") queries = self._generate_search_queries(subject) - - # Step 2: search the web with neutral queries results = self.searcher.search_with_queries(queries) - # Step 3: generate script grounded in search results if results: context = self.searcher.format_context(results[:15]) log("Step 2: generating script with search context...") @@ -88,29 +116,32 @@ def generate_script(self, subject: str) -> list: ) cleaned = self._validate_paragraphs(script) script = self._ensure_paragraph_count(cleaned, 5) + + # Inject BEFORE verification so verifier can correct hallucinated years/numbers + script = self._inject_hook(script, subject, context) + if script: log("Script generated with web sources", "SUCCESS") - # Step 4: post-generation fact verification + # Fact verification now sees the hook and can fix it script = self._verify_factual_claims(script, subject) + # Do NOT re-inject after verification — preserves fact-checked version (fixes bypass) self.generated_title = self._generate_title_from_script(script, subject) log("Script verified", "SUCCESS") return script - # Try to repair instead of full regeneration repair = self._repair_paragraphs(cleaned, subject, 5) if repair and len(repair) >= 3: log("Script repaired after validation", "SUCCESS") - script = repair - script = self._verify_factual_claims(script, subject) - self.generated_title = self._generate_title_from_script(script, subject) + repair = self._inject_hook(repair, subject, context) + repair = self._verify_factual_claims(repair, subject) + self.generated_title = self._generate_title_from_script(repair, subject) log("Script verified", "SUCCESS") - return script + return repair log("Script generation with context failed or produced filler", "WARNING") else: log("No search results for grounding", "WARNING") - # Fallback: generate draft for title + fallback content log("Generating draft as fallback...") draft_data = self._generate_draft(subject, num_paragraphs=5) self.generated_title = draft_data.get("title") or None @@ -118,6 +149,7 @@ def generate_script(self, subject: str) -> list: draft = self._validate_paragraphs(draft) draft = self._ensure_paragraph_count(draft, 5) if draft: + draft = self._inject_hook(draft, subject, "") return draft log("All script generation paths failed", "ERROR") @@ -133,6 +165,7 @@ def generate_script_from_metadata(self, meta_title: str, description: str) -> li tone_block + _SYSTEM_PROMPT_METADATA, _user_prompt_metadata(combined_content), ) + script = self._inject_hook(script, meta_title, desc) self.generated_title = self._generate_title_from_script(script, meta_title) return script @@ -142,17 +175,14 @@ def generate_script_with_prompts(self, subject: str) -> tuple: if not self.web_search or not self.searcher or not subject: paragraphs, prompts = self._generate_script_with_prompts_single(subject) + # _generate_script_with_prompts_single already injects via _inject_hook self.generated_title = self._generate_title_from_script(paragraphs, subject) return paragraphs, prompts - # Step 1: generate independent search queries (NOT from draft) log("Step 1: generating independent search queries...") queries = self._generate_search_queries(subject) - - # Step 2: search the web with neutral queries results = self.searcher.search_with_queries(queries) - # Step 3: generate script grounded in search results if results: context = self.searcher.format_context(results[:15]) log("Step 2: generating script with search context...") @@ -162,24 +192,26 @@ def generate_script_with_prompts(self, subject: str) -> tuple: paragraphs = self._ensure_paragraph_count(cleaned, 7) if paragraphs: log("Script generated with web sources", "SUCCESS") + # Inject BEFORE verification + paragraphs = self._inject_hook(paragraphs, subject, context) paragraphs = self._verify_factual_claims(paragraphs, subject) + # No re-injection after verification — preserves verified facts self.generated_title = self._generate_title_from_script(paragraphs, subject) log("Script verified", "SUCCESS") return paragraphs, [] - # Try to repair instead of full regeneration repair = self._repair_paragraphs(cleaned, subject, 7) if repair and len(repair) >= 3: log("Script repaired after validation", "SUCCESS") - paragraphs = self._verify_factual_claims(repair, subject) - self.generated_title = self._generate_title_from_script(paragraphs, subject) - return paragraphs, [] + repair = self._inject_hook(repair, subject, context) + repair = self._verify_factual_claims(repair, subject) + self.generated_title = self._generate_title_from_script(repair, subject) + return repair, [] log("Script generation with context failed or produced filler", "WARNING") else: log("No search results for grounding", "WARNING") - # Fallback to draft log("Generating draft as fallback...") draft_data = self._generate_draft(subject, num_paragraphs=7) self.generated_title = draft_data.get("title") or None @@ -187,6 +219,7 @@ def generate_script_with_prompts(self, subject: str) -> tuple: draft = self._validate_paragraphs(draft) draft = self._ensure_paragraph_count(draft, 7) if draft: + draft = self._inject_hook(draft, subject, "") return draft, [] log("All script generation paths failed", "ERROR") @@ -195,10 +228,6 @@ def generate_script_with_prompts(self, subject: str) -> tuple: def generate_image_prompts_from_script( self, paragraphs: list, num_images: int ) -> list[dict]: - """Generate paired image prompts (web query + AI prompt) based on script. - - Returns list of dicts: [{"web_query": "...", "ai_prompt": "..."}, ...] - """ log(f"Generating {num_images} paired image prompts based on script...") script_text = " ".join(paragraphs) system_prompt = f""" @@ -221,24 +250,23 @@ def generate_image_prompts_from_script( # ── Two-pass helpers ───────────────────────────────────────────────── def _generate_draft(self, subject: str, num_paragraphs: int = 5) -> dict: - """Pass 1: generate draft script + verification queries + title.""" tone_block = self._tone_instructions() system_prompt = ( f"You are a YouTube Shorts scriptwriter. Output ONLY valid JSON with these exact keys:\n" f'1. "draft": Array of {num_paragraphs} strings (PT-BR), each 1-2 short sentences ' - f"\u2014 a first-draft script about \"{subject}\".\n" + f'— a first-draft script about "{subject}".\n' f" {tone_block}" - f" - Every paragraph MUST contain a verifiable fact \u2014 no generalities, no filler.\n" + f" - Every paragraph MUST contain a verifiable fact — no generalities, no filler.\n" f" - CRITICAL: Do the math yourself. If you mention a date range, calculate the years correctly.\n" - f" - End with a strong conclusion, NOT 'fica uma li\u00e7\u00e3o' or similar generic phrases.\n" + f" - End with a strong conclusion, NOT 'fica uma lição' or similar generic phrases.\n" f'2. "queries": Array of 7-9 Portuguese web search queries to VERIFY ' f"the factual claims in your draft.\n" f" - At least 3 queries must be BROADER independent searches about the subject " - f"(e.g. 'Botafogo principais rivais' instead of 'hist\u00f3ria do cl\u00e1ssico X').\n" + f"(e.g. 'Botafogo principais rivais' instead of 'história do clássico X').\n" f" - Each specific query should target one or more claims from the draft.\n" f" - Be specific: include names, dates, unique terms.\n" f" - Cover all major factual claims (dates, names, places, statistics, origins).\n" - f'3. "title": string, max 60 chars (PT-BR) \u2014 catchy YouTube Shorts title about {subject}.' + f'3. "title": string, max 60 chars (PT-BR) — catchy YouTube Shorts title about {subject}.' ) user_prompt = ( f"Write a first-draft script about {subject} in {num_paragraphs} paragraphs " @@ -260,10 +288,7 @@ def _generate_draft(self, subject: str, num_paragraphs: int = 5) -> dict: log(f"Draft generation failed: {e}", "ERROR") return {"draft": [], "queries": [subject], "title": ""} - # ── Independent query generation (breaks circular hallucination) ───── - def _generate_search_queries(self, subject: str) -> list[str]: - """Generate neutral, independent search queries — NOT derived from draft content.""" system_prompt = ( "You are a research assistant. Output ONLY valid JSON with one key:\n" "'queries': Array of 6-8 specific web search queries in Portuguese.\n" @@ -271,7 +296,7 @@ def _generate_search_queries(self, subject: str) -> list[str]: "Each query must target a different angle: origins, dates, key events, statistics, people.\n" "Be specific: include names, years, locations.\n" "These queries will be used to fact-check, so prioritize queries that return concrete data.\n" - "NEVER include the topic name alone as a query \u2014 always add qualifiers like year, event, or location." + "NEVER include the topic name alone as a query — always add qualifiers like year, event, or location." ) user_prompt = ( f"Generate search queries to find accurate factual information about: {subject}" @@ -285,19 +310,14 @@ def _generate_search_queries(self, subject: str) -> list[str]: log(f"Search query generation failed: {e}", "WARNING") return [subject] - # ── Post-generation fact verification ──────────────────────────────── - def _verify_factual_claims(self, paragraphs: list, subject: str) -> list: - """Cross-check dates, scores, tabus, and numbers in script against web search results.""" script_text = " ".join(paragraphs) verification_queries: list[str] = [] - # 1. Extract years years = set(re.findall(r"\b(1[4-9]\d{2}|20[0-2]\d)\b", script_text)) for y in sorted(years): verification_queries.append(f"{subject} {y}") - # 2. Extract score/result patterns: "3 a 2", "3x2", "por 3 a 2", "3-2" score_matches = re.findall( r"(\d+)\s*(?:[a\u00e0x-]\s*|a\s+|venceu por\s+|por\s+)(\d+)", script_text, re.IGNORECASE @@ -308,17 +328,15 @@ def _verify_factual_claims(self, paragraphs: list, subject: str) -> list: if q not in verification_queries: verification_queries.append(q) - # 3. Detect tabu/streak claims if re.search( r"(?:n\u00e3o\s+\w+\s+(?:vence|ganha|perde|supera)|tabu|" r"sem\s+\w+\s+(?:vence|ganha|perde|supera))", script_text, re.IGNORECASE ): - tabu_q = f"{subject} tabu hist\u00f3rico" + tabu_q = f"{subject} tabu histórico" if tabu_q not in verification_queries: verification_queries.append(tabu_q) - # 4. Extract "em [month] de [year]" / "desde [month] de [year]" contexts context_years = re.findall( r"(?:em|desde|no|na)\s+\w+\s+de\s+(\d{4})", script_text, re.IGNORECASE @@ -328,17 +346,12 @@ def _verify_factual_claims(self, paragraphs: list, subject: str) -> list: if q not in verification_queries: verification_queries.append(q) - verification_queries.append( - f"{subject} hist\u00f3rico funda\u00e7\u00e3o dados" - ) + verification_queries.append(f"{subject} histórico fundação dados") if not verification_queries: return paragraphs - log( - f"Verifying claims with {len(verification_queries)} targeted queries", - "INFO", - ) + log(f"Verifying claims with {len(verification_queries)} targeted queries", "INFO") if not self.searcher: log("Fact verification skipped: searcher not available", "WARNING") @@ -351,10 +364,10 @@ def _verify_factual_claims(self, paragraphs: list, subject: str) -> list: context = self.searcher.format_context(results[:10]) system_prompt = ( "You are a strict fact-checker. Output ONLY valid JSON with exactly these keys:\n" - '1. "verified": boolean \u2014 true if ALL claims match the web sources\n' + '1. "verified": boolean — true if ALL claims match the web sources\n' '2. "corrections": array of objects with "claim" and "correction" strings ' - "\u2014 empty if verified is true\n" - '3. "paragraphs": array of strings (PT-BR) \u2014 corrected script paragraphs, ' + "— empty if verified is true\n" + '3. "paragraphs": array of strings (PT-BR) — corrected script paragraphs, ' "or the original if no changes needed\n\n" "CRITICAL rules:\n" "- Compare EVERY date, number, name, and place against the web sources.\n" @@ -392,25 +405,16 @@ def _verify_factual_claims(self, paragraphs: list, subject: str) -> list: corrections = data.get("corrections") or [] is_verified = data.get("verified", False) if corrections: - log( - f"Fact verification: {len(corrections)} corrections applied", - "WARNING", - ) + log(f"Fact verification: {len(corrections)} corrections applied", "WARNING") for c in corrections: - log( - f" '{c.get('claim', '?')}' -> '{c.get('correction', '?')}'", - "INFO", - ) + log(f" '{c.get('claim', '?')}' -> '{c.get('correction', '?')}'", "INFO") corrected = self._validate_paragraphs(corrected) corrected = self._ensure_paragraph_count(corrected, target_count) elif is_verified: log("Fact verification: all claims match sources", "SUCCESS") if corrected and len(corrected) >= 3: return corrected - log( - f"Verification returned {len(corrected)} paragraphs, need >= 3, retrying...", - "WARNING", - ) + log(f"Verification returned {len(corrected)} paragraphs, need >= 3, retrying...", "WARNING") except Exception as e: log(f"Fact verification attempt {attempt + 1} failed: {e}", "WARNING") corrected = [] @@ -418,28 +422,23 @@ def _verify_factual_claims(self, paragraphs: list, subject: str) -> list: return paragraphs return paragraphs - # ── Title generation ───────────────────────────────────────────────── - @staticmethod def _validate_title(title: str) -> tuple[bool, list[str]]: issues: list[str] = [] hashtags = re.findall(r"#\w+", title) if not hashtags: - issues.append("n\u00e3o possui hashtags") + issues.append("não possui hashtags") elif len(hashtags) < 3: - issues.append(f"tem apenas {len(hashtags)} hashtags (m\u00ednimo 3)") + issues.append(f"tem apenas {len(hashtags)} hashtags (mínimo 3)") if len(title) > 100: - issues.append(f"tem {len(title)} caracteres (m\u00e1ximo 100)") + issues.append(f"tem {len(title)} caracteres (máximo 100)") return len(issues) == 0, issues @staticmethod def _fix_hashtags_case(title: str) -> str: return re.sub(r"#(\w+)", lambda m: f"#{m.group(1).lower()}", title) - def _generate_title_from_script( - self, paragraphs: list, subject: str - ) -> str | None: - """Generate a YouTube Shorts title from the final script.""" + def _generate_title_from_script(self, paragraphs: list, subject: str) -> str | None: script_text = " ".join(paragraphs)[:500] system_prompt = ( "Output ONLY a JSON object with one key: 'title'.\n" @@ -457,15 +456,15 @@ def _generate_title_from_script( "FULL EXAMPLE:\n" "'A VERDADE sobre o Caso Girabank #carlinhosmaia #girabank'" ) - user_prompt = f"Crie um t\u00edtulo PT-BR para este roteiro sobre {subject}, com se\u00e7\u00f5es em UPPERCASE e tags SPECIFICAS em lowercase: {script_text}" + user_prompt = f"Crie um título PT-BR para este roteiro sobre {subject}, com seções em UPPERCASE e tags SPECIFICAS em lowercase: {script_text}" for attempt in range(2): try: prompt = user_prompt if attempt == 1: prompt += ( - "\n\nCORRE\u00c7\u00c3O: Na tentativa anterior o t\u00edtulo tinha problemas. " + "\n\nCORREÇÃO: Na tentativa anterior o título tinha problemas. " "Siga as regras: max 100 chars, 3-4 hashtags em lowercase, " - "nada de hashtags gen\u00e9ricas." + "nada de hashtags genéricas." ) data = self._make_json_api_call(system_prompt, prompt) title = data.get("title") or "" @@ -479,13 +478,10 @@ def _generate_title_from_script( return None def _repair_paragraphs(self, good: list, subject: str, target: int) -> list: - """Extend existing good paragraphs to reach target count instead of regenerating everything.""" if len(good) >= target or not good: return good - needed = target - len(good) good_text = "\n".join(good) - system_prompt = ( f"Output ONLY a JSON object with one key:\n" f"'paragraphs': Array of {needed} strings (PT-BR), each 1-2 short sentences.\n" @@ -502,44 +498,25 @@ def _repair_paragraphs(self, good: list, subject: str, target: int) -> list: try: data = self._make_json_api_call(system_prompt, user_prompt) new_p = data.get("paragraphs") or [] - log( - f"Repair attempt: API returned {len(new_p)} paragraphs, " - f"had {len(good)} good", - "INFO", - ) + log(f"Repair attempt: API returned {len(new_p)} paragraphs, had {len(good)} good", "INFO") combined = good + new_p before = len(combined) combined = self._validate_paragraphs(combined) after_validation = len(combined) if before != after_validation: - log( - f"Repair validation: {before} -> {after_validation} " - f"({before - after_validation} removed as filler)", - "INFO", - ) + log(f"Repair validation: {before} -> {after_validation} ({before - after_validation} removed as filler)", "INFO") combined = self._ensure_paragraph_count(combined, target) if combined: log(f"Repair success: {len(good)} -> {len(combined)} paragraphs", "SUCCESS") return combined - log( - f"Repair failed: only {after_validation} good paragraphs " - f"after validation, needed {target}", - "WARNING", - ) + log(f"Repair failed: only {after_validation} good paragraphs after validation, needed {target}", "WARNING") return [] except Exception as e: log(f"Repair LLM call failed: {e}", "WARNING") return [] - # ── API helpers ────────────────────────────────────────────────────── - def _make_json_api_call(self, system_prompt: str, user_prompt: str) -> dict: - """Make API call expecting JSON response. Retries once on empty content.""" - - headers = { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - } + headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"} payload = { "model": self.model, "messages": [ @@ -552,38 +529,25 @@ def _make_json_api_call(self, system_prompt: str, user_prompt: str) -> dict: for attempt in range(2): try: response = requests.post( - self.api_url, - headers=headers, - json=payload, - timeout=API_TIMEOUT_IMAGE, + self.api_url, headers=headers, json=payload, timeout=API_TIMEOUT_IMAGE ) response.raise_for_status() content = response.json()["choices"][0]["message"]["content"] if content and content.strip(): result = json.loads(content) if not isinstance(result, dict): - log( - f"API returned {type(result).__name__} instead of dict", - "WARNING", - ) + log(f"API returned {type(result).__name__} instead of dict", "WARNING") return {} return result log(f"API returned empty content (attempt {attempt + 1})", "WARNING") except (json.JSONDecodeError, KeyError, requests.RequestException) as e: - log( - f"JSON API call failed (attempt {attempt + 1}): {e}", - "WARNING", - ) + log(f"JSON API call failed (attempt {attempt + 1}): {e}", "WARNING") if attempt == 0: time.sleep(1) raise ValueError("JSON API call failed after 2 attempts") def _make_text_api_call(self, system_prompt: str, user_prompt: str) -> list: - """Make API call and parse plain text response into paragraphs.""" - headers = { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - } + headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"} payload = { "model": self.model, "messages": [ @@ -592,34 +556,24 @@ def _make_text_api_call(self, system_prompt: str, user_prompt: str) -> list: ], "temperature": 0.7, } - try: - response = requests.post( - self.api_url, headers=headers, json=payload, timeout=API_TIMEOUT_TEXT - ) + response = requests.post(self.api_url, headers=headers, json=payload, timeout=API_TIMEOUT_TEXT) response.raise_for_status() response_data = response.json() if isinstance(response_data, list): log("API returned a list, attempting to extract content...", "WARNING") if len(response_data) > 0 and isinstance(response_data[0], dict): - content = response_data[0].get("content", "") or response_data[ - 0 - ].get("text", "") + content = response_data[0].get("content", "") or response_data[0].get("text", "") else: content = str(response_data[0]) if response_data else "" else: content = response_data["choices"][0]["message"]["content"] - content = ( - content.replace("```", "").replace("**", "").replace("*", "").strip() - ) - + content = content.replace("```", "").replace("**", "").replace("*", "").strip() lines = [line.strip() for line in content.split("\n") if line.strip()] - if len(lines) <= 1: lines = [para.strip() for para in content.split("\n\n") if para.strip()] - if len(lines) <= 1: sentences = [s.strip() + "." for s in content.split(".") if s.strip()] lines = [] @@ -633,90 +587,71 @@ def _make_text_api_call(self, system_prompt: str, user_prompt: str) -> list: current_para = "" if current_para: lines.append(current_para) - if len(lines) < 4: - log( - f"Only {len(lines)} paragraphs generated, expected at least 4", - "WARNING", - ) - + log(f"Only {len(lines)} paragraphs generated, expected at least 4", "WARNING") return lines[:5] - except Exception as e: log(f"Script generation failed: {e}", "ERROR") return [] @staticmethod def _is_filler(paragraph: str) -> bool: - """Check if a paragraph is generic filler or corporate language that should be rejected.""" filler_patterns = [ - "fica uma li\u00e7\u00e3o", + "fica uma lição", "vale a pena conhecer", - "li\u00e7\u00e3o que vale", - "ningu\u00e9m sabia", + "lição que vale", + "ninguém sabia", "o segredo", "a verdade escondida", - "voc\u00ea n\u00e3o vai acreditar", + "você não vai acreditar", "poucos conhecem", "pouca gente sabe", - "muita gente n\u00e3o sabe", + "muita gente não sabe", "o que poucos sabem", "ltda", "s.a.", - "institui\u00e7\u00e3o de pagamento", - "pessoa jur\u00eddica", + "instituição de pagamento", + "pessoa jurídica", ] lower = paragraph.lower() return any(p in lower for p in filler_patterns) @staticmethod def _validate_paragraphs(paragraphs: list) -> list: - """Remove filler paragraphs and validate quality. Returns cleaned list or empty.""" if not paragraphs: return [] cleaned = [p for p in paragraphs if isinstance(p, str) and not ScriptGenerator._is_filler(p)] if len(cleaned) < 3: - log( - f"Validation: {len(paragraphs)} input, {len(cleaned)} after removing filler", - "WARNING", - ) + log(f"Validation: {len(paragraphs)} input, {len(cleaned)} after removing filler", "WARNING") return cleaned @staticmethod def _ensure_paragraph_count(paragraphs: list, target: int) -> list: - """Trim or validate paragraph count. Never pads with filler.""" if len(paragraphs) >= target: return paragraphs[:target] if len(paragraphs) < 3: - log( - f"Only {len(paragraphs)} paragraphs, need at least 3 \u2014 returning empty", - "ERROR", - ) + log(f"Only {len(paragraphs)} paragraphs, need at least 3 — returning empty", "ERROR") return [] return paragraphs - # ── Single-pass path for images-only without web search ────────────── - - def _generate_script_with_context( - self, subject: str, search_context: str - ) -> list | None: - """Generate paragraphs grounded in search context (JSON API call).""" + def _generate_script_with_context(self, subject: str, search_context: str) -> list | None: + """Generate paragraphs grounded in search context — does NOT inject hook (caller handles it).""" tone_block = self._tone_instructions() system_prompt = ( "You are a master storyteller for viral YouTube Shorts.\n" "Output ONLY a JSON object with:\n" "1.'paragraphs': Array of 7 strings (PT-BR), each 1-2 short sentences.\n" f"{tone_block}" - 'NEVER use "ningu\u00e9m sabia", "o segredo", or "a verdade" \u2014 these are vague.\n' - "Every paragraph MUST contain a verifiable fact \u2014 no generalities, no filler.\n" + 'NEVER use "ninguém sabia", "o segredo", or "a verdade" — these are vague.\n' + "Every paragraph MUST contain a verifiable fact — no generalities, no filler.\n" "Keep each paragraph 1-2 sentences (~2-3 seconds audio each).\n" - "End with a strong conclusion, NOT 'fica uma li\u00e7\u00e3o' or similar.\n" + "End with a strong conclusion, NOT 'fica uma lição' or similar.\n" "Use the provided web sources as your primary source of facts. Verify every number against them." ) user_prompt = ( f"Tell a story about: {subject}. " f"Include origin, key facts, and specific details. " - f"Double-check every date and number \u2014 calculate ranges correctly.\n\n" + f"Double-check every date and number — calculate ranges correctly.\n\n" f"WEB SOURCES:\n{search_context}" ) try: @@ -727,17 +662,16 @@ def _generate_script_with_context( return None def _generate_script_with_prompts_single(self, subject: str) -> tuple: - """Original single-pass JSON generation (no web search).""" tone_block = self._tone_instructions() system_prompt = ( "You are a master storyteller for viral YouTube Shorts.\n" "Output ONLY a JSON object with:\n" "1.'paragraphs': Array of 7 strings (PT-BR), each 1-2 short sentences.\n" f"{tone_block}" - 'NEVER use "ningu\u00e9m sabia", "o segredo", or "a verdade" \u2014 these are vague.\n' - "Every paragraph MUST contain a verifiable fact \u2014 no generalities, no filler.\n" + 'NEVER use "ninguém sabia", "o segredo", or "a verdade" — these are vague.\n' + "Every paragraph MUST contain a verifiable fact — no generalities, no filler.\n" "Keep each paragraph 1-2 sentences (~2-3 seconds audio each).\n" - 'End with a strong conclusion, NOT "fica uma li\u00e7\u00e3o" or similar.\n' + 'End with a strong conclusion, NOT "fica uma lição" or similar.\n' "Do the math yourself. If you mention a date range, calculate the years correctly." ) user_prompt = ( @@ -747,40 +681,40 @@ def _generate_script_with_prompts_single(self, subject: str) -> tuple: ) try: data = self._make_json_api_call(system_prompt, user_prompt) - return data.get("paragraphs", []), data.get("image_prompts", []) + paragraphs = data.get("paragraphs", []) + paragraphs = self._inject_hook(paragraphs, subject, "") + return paragraphs, data.get("image_prompts", []) except Exception as e: log(f"Script generation failed: {e}", "ERROR") raise -# ── Prompt templates for single-pass text path ────────────────────────── - _SYSTEM_PROMPT_SINGLE = ( "You are a master storyteller for viral YouTube Shorts.\n" "CRITICAL RETENTION RULES:\n" "1.Write in Brazilian Portuguese (PT-BR).\n" "2.TONE: Curiosity-driven, narrative, engaging. " - "Write like a storyteller uncovering a fascinating truth \u2014 " + "Write like a storyteller uncovering a fascinating truth — " "never like Wikipedia or a corporate press release.\n" - "3.FIRST SENTENCE: Drop the viewer right into the action \u2014 " + "3.FIRST SENTENCE: Drop the viewer right into the action — " "the goal, the controversy, the fact itself. " - "NO: 'Prepare-se', 'Voc\u00ea sabia', rhetorical questions. " + "NO: 'Prepare-se', 'Você sabia', rhetorical questions. " "Never waste the first 2 seconds on setup.\n" - "4.STRUCTURE: Hook (the fact itself) \u2192 Context \u2192 Revelation \u2192 Strong conclusion\n" - '5.NEVER start with "ningu\u00e9m sabia", "o segredo", "a verdade escondida" ' - 'or "voc\u00ea n\u00e3o vai acreditar" \u2014 these are weak.\n' + "4.STRUCTURE: Hook (the fact itself) → Context → Revelation → Strong conclusion\n" + '5.NEVER start with "ninguém sabia", "o segredo", "a verdade escondida" ' + 'or "você não vai acreditar" — these are weak.\n' "6.FORBIDDEN: Legal names (Ltda, S.A.), addresses, city/state abbreviations. " "NO corporate language.\n" '6a.FORBIDDEN: Hyperboles, exaggerated claims, "designed by a god", ' - '"you won\'t believe", "shocking truth" \u2014 these sound fake.\n' + '"you won\'t believe", "shocking truth" — these sound fake.\n' "7.Each paragraph 1-2 punchy sentences (~2-3 seconds audio each).\n" "8.Write exactly 4-5 paragraphs.\n" "9.NO markdown formatting, NO JSON, just plain text paragraphs.\n" - "10.Every paragraph must advance the story with a new specific fact \u2014 no filler.\n" + "10.Every paragraph must advance the story with a new specific fact — no filler.\n" "11.USE the provided web sources as your primary source of facts. " "Cite specific data from them.\n" - '12.End with a punchy conclusion, NOT "fica uma li\u00e7\u00e3o" or similar generic phrases.\n' - '13.NEVER use "no final fica uma li\u00e7\u00e3o" or "vale a pena conhecer" \u2014 these are filler.\n' + '12.End with a punchy conclusion, NOT "fica uma lição" or similar generic phrases.\n' + '13.NEVER use "no final fica uma lição" or "vale a pena conhecer" — these are filler.\n' "14.Do the math yourself. If you mention a date range or time period, calculate the years correctly." ) @@ -788,33 +722,33 @@ def _generate_script_with_prompts_single(self, subject: str) -> tuple: def _user_prompt_single(subject: str, search_context: str) -> str: tone_rules = ( "- TOM: Curiosidade, narrativa envolvente. " - "Conte como quem revela um fato fascinante \u2014 " + "Conte como quem revela um fato fascinante — " "NUNCA como Wikipedia ou release corporativo.\n" - "- PRIMEIRA FRASE: Jogue o espectador direto na a\u00e7\u00e3o \u2014 " - "o gol, a pol\u00eamica, o pr\u00f3prio fato. " - "NADA de 'Prepare-se', 'Voc\u00ea sabia', perguntas ret\u00f3ricas. " - "N\u00c3O desperdice os primeiros 2 segundos com introdu\u00e7\u00e3o.\n" - "- ESTRUTURA: Gancho (o fato) \u2192 Contexto \u2192 Revela\u00e7\u00e3o \u2192 Conclus\u00e3o forte\n" - "- PROIBIDO: Nomes jur\u00eddicos (Ltda, S.A.), endere\u00e7os, siglas. " + "- PRIMEIRA FRASE: Jogue o espectador direto na ação — " + "o gol, a polêmica, o próprio fato. " + "NADA de 'Prepare-se', 'Você sabia', perguntas retóricas. " + "NÃO desperdice os primeiros 2 segundos com introdução.\n" + "- ESTRUTURA: Gancho (o fato) → Contexto → Revelação → Conclusão forte\n" + "- PROIBIDO: Nomes jurídicos (Ltda, S.A.), endereços, siglas. " "NADA de linguagem corporativa.\n" - "- PROIBIDO: Hip\u00e9rboles, exageros, 'desenhada por um deus', " - "'voc\u00ea n\u00e3o vai acreditar', 'a verdade chocante' \u2014 soa falso.\n" + "- PROIBIDO: Hipérboles, exageros, 'desenhada por um deus', " + "'você não vai acreditar', 'a verdade chocante' — soa falso.\n" ) return ( - f'Crie uma hist\u00f3ria envolvente em 4-5 par\u00e1grafos sobre "{subject}".\n\n' + f'Crie uma história envolvente em 4-5 parágrafos sobre "{subject}".\n\n' f"{search_context}\n\n" - f"REGRAS CR\u00cdTICAS:\n" + f"REGRAS CRÍTICAS:\n" f"{tone_rules}" - f'- NUNCA comece com "ningu\u00e9m sabia", "o segredo", ' - f'"a verdade escondida" \u2014 isso \u00e9 vago e fraco\n' - f"- Inclua nomes, datas, lugares e n\u00fameros espec\u00edficos sempre que poss\u00edvel\n" - f"- Conte a ORIGEM: como tudo come\u00e7ou, por que existe\n" - f"- Cada par\u00e1grafo DEVE conter um FATO VERIFIC\u00c1VEL \u2014 nada de generaliza\u00e7\u00f5es\n" + f'- NUNCA comece com "ninguém sabia", "o segredo", ' + f'"a verdade escondida" — isso é vago e fraco\n' + f"- Inclua nomes, datas, lugares e números específicos sempre que possível\n" + f"- Conte a ORIGEM: como tudo começou, por que existe\n" + f"- Cada parágrafo DEVE conter um FATO VERIFICÁVEL — nada de generalizações\n" f"- NADA de frases de enchimento\n" - f"- TERMINE com uma conclus\u00e3o forte, N\u00c3O com 'fica uma li\u00e7\u00e3o'\n" - f"- Fa\u00e7a a conta voc\u00ea mesmo: se mencionar um per\u00edodo, calcule os anos corretamente\n" - f"- Use as FONTES DA WEB fornecidas como base para sua hist\u00f3ria\n\n" - f"Escreva cada par\u00e1grafo em uma linha separada." + f"- TERMINE com uma conclusão forte, NÃO com 'fica uma lição'\n" + f"- Faça a conta você mesmo: se mencionar um período, calcule os anos corretamente\n" + f"- Use as FONTES DA WEB fornecidas como base para sua história\n\n" + f"Escreva cada parágrafo em uma linha separada." ) @@ -823,62 +757,62 @@ def _user_prompt_single(subject: str, search_context: str) -> str: "CRITICAL RETENTION RULES:\n" "1.Write in Brazilian Portuguese (PT-BR).\n" "2.TONE: Curiosity-driven, narrative, engaging. " - "Write like a storyteller uncovering a fascinating truth \u2014 " + "Write like a storyteller uncovering a fascinating truth — " "never like Wikipedia or a corporate press release.\n" - "3.FIRST SENTENCE: Drop the viewer right into the action \u2014 " + "3.FIRST SENTENCE: Drop the viewer right into the action — " "the goal, the controversy, the fact itself. " - "NO: 'Prepare-se', 'Voc\u00ea sabia', rhetorical questions. " + "NO: 'Prepare-se', 'Você sabia', rhetorical questions. " "Never waste the first 2 seconds on setup.\n" - "4.STRUCTURE: Hook (the fact itself) \u2192 Context \u2192 Revelation \u2192 Strong conclusion\n" - '5.NEVER start with "ningu\u00e9m sabia", "o segredo", ' - 'or "a verdade escondida" \u2014 these are vague and weak.\n' + "4.STRUCTURE: Hook (the fact itself) → Context → Revelation → Strong conclusion\n" + '5.NEVER start with "ninguém sabia", "o segredo", ' + 'or "a verdade escondida" — these are vague and weak.\n' "6.FORBIDDEN: Legal names (Ltda, S.A.), addresses, city/state abbreviations. " "NO corporate language.\n" '6a.FORBIDDEN: Hyperboles, exaggerated claims, "designed by a god", ' - '"you won\'t believe", "shocking truth" \u2014 these sound fake.\n' + '"you won\'t believe", "shocking truth" — these sound fake.\n' "7.Extract concrete details from the video metadata: " "dates, names, places, statistics, historical context.\n" - "8.Include origin stories \u2014 explain HOW something started, " + "8.Include origin stories — explain HOW something started, " "not just THAT it happened.\n" "9.Each paragraph 1-2 punchy sentences (~2-3 seconds audio each).\n" "10.Write exactly 4-5 paragraphs.\n" "11.NO markdown formatting, NO JSON, just plain text paragraphs.\n" "12.Base your story entirely on the video's content, " "adding only well-known historical context.\n" - '13.End with a punchy conclusion, NOT "fica uma li\u00e7\u00e3o" or similar.\n' - "14.Every paragraph must contain a verifiable fact \u2014 no generalities." + '13.End with a punchy conclusion, NOT "fica uma lição" or similar.\n' + "14.Every paragraph must contain a verifiable fact — no generalities." ) def _user_prompt_metadata(combined_content: str) -> str: tone_rules = ( "- TOM: Curiosidade, narrativa envolvente. " - "Conte como quem revela um fato fascinante \u2014 " + "Conte como quem revela um fato fascinante — " "NUNCA como Wikipedia ou release corporativo.\n" - "- PRIMEIRA FRASE: Jogue o espectador direto na a\u00e7\u00e3o \u2014 " - "o gol, a pol\u00eamica, o pr\u00f3prio fato. " - "NADA de 'Prepare-se', 'Voc\u00ea sabia', perguntas ret\u00f3ricas. " - "N\u00c3O desperdice os primeiros 2 segundos com introdu\u00e7\u00e3o.\n" - "- ESTRUTURA: Gancho (o fato) \u2192 Contexto \u2192 Revela\u00e7\u00e3o \u2192 Conclus\u00e3o forte\n" - "- PROIBIDO: Nomes jur\u00eddicos (Ltda, S.A.), endere\u00e7os, siglas. " + "- PRIMEIRA FRASE: Jogue o espectador direto na ação — " + "o gol, a polêmica, o próprio fato. " + "NADA de 'Prepare-se', 'Você sabia', perguntas retóricas. " + "NÃO desperdice os primeiros 2 segundos com introdução.\n" + "- ESTRUTURA: Gancho (o fato) → Contexto → Revelação → Conclusão forte\n" + "- PROIBIDO: Nomes jurídicos (Ltda, S.A.), endereços, siglas. " "NADA de linguagem corporativa.\n" - "- PROIBIDO: Hip\u00e9rboles, exageros, 'desenhada por um deus', " - "'voc\u00ea n\u00e3o vai acreditar' \u2014 soa falso.\n" + "- PROIBIDO: Hipérboles, exageros, 'desenhada por um deus', " + "'você não vai acreditar' — soa falso.\n" ) return ( - "Crie uma hist\u00f3ria envolvente em 4-5 par\u00e1grafos baseada " - "neste v\u00eddeo do YouTube.\n\n" - "REGRAS CR\u00cdTICAS:\n" + "Crie uma história envolvente em 4-5 parágrafos baseada " + "neste vídeo do YouTube.\n\n" + "REGRAS CRÍTICAS:\n" f"{tone_rules}" - '- NUNCA comece com "ningu\u00e9m sabia", "o segredo" ' + '- NUNCA comece com "ninguém sabia", "o segredo" ' 'ou "a verdade escondida"\n' - "- Extraia detalhes espec\u00edficos do t\u00edtulo e descri\u00e7\u00e3o: " - "datas, nomes, locais, estat\u00edsticas\n" - "- Conte a ORIGEM: como tudo come\u00e7ou, por que \u00e9 importante\n" - "- Cada par\u00e1grafo DEVE conter um FATO VERIFIC\u00c1VEL \u2014 nada de generaliza\u00e7\u00f5es\n" - "- NADA de ganchos gen\u00e9ricos ou frases de enchimento\n" - "- TERMINE com uma conclus\u00e3o forte, N\u00c3O com 'fica uma li\u00e7\u00e3o'\n" - "- Fa\u00e7a a conta voc\u00ea mesmo: se mencionar um per\u00edodo, calcule os anos corretamente\n\n" - f"V\u00eddeo:\n{combined_content}\n\n" - "Escreva cada par\u00e1grafo em uma linha separada." + "- Extraia detalhes específicos do título e descrição: " + "datas, nomes, locais, estatísticas\n" + "- Conte a ORIGEM: como tudo começou, por que é importante\n" + "- Cada parágrafo DEVE conter um FATO VERIFICÁVEL — nada de generalizações\n" + "- NADA de ganchos genéricos ou frases de enchimento\n" + "- TERMINE com uma conclusão forte, NÃO com 'fica uma lição'\n" + "- Faça a conta você mesmo: se mencionar um período, calcule os anos corretamente\n\n" + f"Vídeo:\n{combined_content}\n\n" + "Escreva cada parágrafo em uma linha separada." ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 3880fee..444a6d7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -293,6 +293,54 @@ def test_generator_exception_handled(self, mock_asyncio_run, mock_video_types): result = self.runner.invoke(app, ["new", "explainer", "test"]) assert result.exit_code == 0 # CLI handles exceptions gracefully + def test_hook_style_flag_shows_in_help(self): + result = self.runner.invoke(app, ["new", "explainer", "--help"]) + assert result.exit_code == 0 + output = result.output.replace("\x1b[", "") + assert "--hook-style" in output + assert "curiosity" in output.lower() + + @patch("autoshorts.cli.commands.explainer.VIDEO_TYPES") + @patch("autoshorts.cli.commands.explainer.asyncio.run") + def test_hook_style_flag_valid(self, mock_asyncio_run, mock_video_types): + gen_mock = Mock() + gen_mock.generate = Mock(return_value=True) + gen_mock.cleanup = Mock() + mock_video_types.__getitem__.return_value = lambda **kw: gen_mock + mock_asyncio_run.return_value = True + + for style in ["default", "curiosity", "counter", "controversy", "challenge", "reveal", "story"]: + result = self.runner.invoke(app, ["new", "explainer", "test", "--hook-style", style]) + assert result.exit_code == 0, f"Failed for style {style}: {result.output}" + + @patch("autoshorts.cli.commands.explainer.VIDEO_TYPES") + @patch("autoshorts.cli.commands.explainer.asyncio.run") + def test_hook_style_short_flag(self, mock_asyncio_run, mock_video_types): + gen_mock = Mock() + gen_mock.generate = Mock(return_value=True) + gen_mock.cleanup = Mock() + mock_video_types.__getitem__.return_value = lambda **kw: gen_mock + mock_asyncio_run.return_value = True + + result = self.runner.invoke(app, ["new", "explainer", "test", "-hs", "curiosity"]) + assert result.exit_code == 0 + + def test_hook_style_flag_invalid(self): + result = self.runner.invoke(app, ["new", "explainer", "test", "--hook-style", "invalid_style"]) + assert result.exit_code != 0 + + @patch("autoshorts.cli.commands.explainer.VIDEO_TYPES") + @patch("autoshorts.cli.commands.explainer.asyncio.run") + def test_hook_style_case_insensitive(self, mock_asyncio_run, mock_video_types): + gen_mock = Mock() + gen_mock.generate = Mock(return_value=True) + gen_mock.cleanup = Mock() + mock_video_types.__getitem__.return_value = lambda **kw: gen_mock + mock_asyncio_run.return_value = True + + result = self.runner.invoke(app, ["new", "explainer", "test", "--hook-style", "CURIOSITY"]) + assert result.exit_code == 0 + class TestMainEntry: """Test the main entry point.""" diff --git a/tests/test_hook_engine.py b/tests/test_hook_engine.py new file mode 100644 index 0000000..26cee30 --- /dev/null +++ b/tests/test_hook_engine.py @@ -0,0 +1,154 @@ +import pytest + +from autoshorts.modules.hook_engine import HookEngine, HookStyle + + +class TestHookEngine: + def test_all_hook_styles_exist(self): + """Ensure all hook styles are defined.""" + styles = [s.value for s in HookStyle] + assert "default" in styles + assert "curiosity" in styles + assert "counter" in styles + assert "controversy" in styles + assert "challenge" in styles + assert "reveal" in styles + assert "story" in styles + # Also ensure 7 total + assert len(styles) == 7 + + def test_generate_hook_returns_required_keys(self): + """Hook generation returns all required fields.""" + engine = HookEngine() + result = engine.generate_hook("Test subject", HookStyle.CURIOSITY_GAP) + assert "hook" in result + assert "pattern_interrupt" in result + assert "curiosity_gap" in result + assert "tone_instructions" in result + + def test_hook_is_not_empty(self): + """Generated hook is non-empty.""" + engine = HookEngine() + result = engine.generate_hook("Corinthians 2012 Libertadores", HookStyle.CONTROVERSY) + assert len(result["hook"]) > 10 + + def test_tone_instructions_include_hook_style(self): + """Tone instructions mention the hook style.""" + engine = HookEngine() + instructions = engine.get_tone_instructions(HookStyle.COUNTER_NARRATIVE) + assert "counter" in instructions.lower() or "narrative" in instructions.lower() + + def test_backward_compatibility(self): + """DEFAULT hook style preserves existing behavior.""" + engine = HookEngine() + instructions = engine.get_tone_instructions(HookStyle.DEFAULT) + assert "curiosity" in instructions.lower() # Existing tone preserved + + def test_default_hook_empty(self): + """DEFAULT style returns empty hook to preserve backward compat.""" + engine = HookEngine() + result = engine.generate_hook("Some subject", HookStyle.DEFAULT) + assert result["hook"] == "" # Should be empty for backward compat + # But tone_instructions still present + assert len(result["tone_instructions"]) > 0 + + def test_all_styles_generate_hooks(self): + """Each non-default style generates a non-empty hook.""" + engine = HookEngine() + subject = "Pelé 1000 gols" + for style in HookStyle: + if style == HookStyle.DEFAULT: + continue + result = engine.generate_hook(subject, style) + assert len(result["hook"]) > 10, f"Style {style} produced empty hook" + assert len(result["pattern_interrupt"]) > 5 + assert len(result["curiosity_gap"]) > 5 + + def test_hook_contains_specific_info(self): + """Hook should contain subject or year or number.""" + engine = HookEngine() + subject = "Corinthians 2012 Libertadores" + result = engine.generate_hook(subject, HookStyle.CURIOSITY_GAP) + hook_lower = result["hook"].lower() + # Should contain either subject keyword or year 2012 + assert "corinthians" in hook_lower or "2012" in result["hook"] or "libertadores" in hook_lower + + def test_hook_not_start_with_forbidden(self): + """Hook must NOT start with forbidden phrases.""" + engine = HookEngine() + forbidden = ["você sabia", "prepare-se", "voce sabia"] + for style in HookStyle: + if style == HookStyle.DEFAULT: + continue + result = engine.generate_hook("Test subject 2020", style) + hook_lower = result["hook"].lower().strip() + if not hook_lower: + continue + for fb in forbidden: + assert not hook_lower.startswith(fb), f"Hook for {style} starts with forbidden '{fb}': {result['hook']}" + + def test_hook_quality_1_2_sentences(self): + """Hook should be 1-2 sentences max.""" + engine = HookEngine() + result = engine.generate_hook("Flamengo 1981 Mundial", HookStyle.STORY) + hook = result["hook"] + # Count sentences by period + sentences = [s for s in hook.split(".") if s.strip()] + assert 1 <= len(sentences) <= 3 # Allow up to 3, but ideal 2 + + def test_tone_instructions_distinct(self): + """Each hook style has distinct tone instructions.""" + engine = HookEngine() + instructions = {} + for style in HookStyle: + instructions[style] = engine.get_tone_instructions(style) + # Check all are non-empty and not all identical + values = list(instructions.values()) + assert len(set(values)) == len(values) # all distinct + + def test_generate_hook_with_context(self): + """Hook generation with context should not fail.""" + engine = HookEngine() + context = "Flamengo venceu o Liverpool em 1981 no Mundial de Clubes com gols de Nunes e Adílio" + result = engine.generate_hook("Flamengo 1981", HookStyle.REVEAL, context=context) + assert len(result["hook"]) > 10 + + def test_generate_hook_invalid_style_fallback(self): + """Invalid style should fallback to DEFAULT silently.""" + engine = HookEngine() + result = engine.generate_hook("Test", "invalid_style") # type: ignore + # Should fallback to DEFAULT with empty hook but valid structure + assert "hook" in result + assert "tone_instructions" in result + + def test_hook_style_enum_values(self): + """Enum values match expected strings.""" + assert HookStyle.DEFAULT.value == "default" + assert HookStyle.CURIOSITY_GAP.value == "curiosity" + assert HookStyle.COUNTER_NARRATIVE.value == "counter" + assert HookStyle.CONTROVERSY.value == "controversy" + assert HookStyle.CHALLENGE.value == "challenge" + assert HookStyle.REVEAL.value == "reveal" + assert HookStyle.STORY.value == "story" + + def test_generate_hook_performance(self): + """Hook generation should be fast (<2 seconds).""" + import time + + engine = HookEngine() + start = time.time() + for _ in range(10): + engine.generate_hook("Test performance subject 2020", HookStyle.CURIOSITY_GAP) + elapsed = time.time() - start + # 10 generations should be well under 2 seconds each, total <2 sec ideally + # Allow 2 sec total for 10 runs = 0.2 sec each to be safe + assert elapsed < 2.0, f"Hook generation too slow: {elapsed}s for 10 runs" + + def test_pattern_interrupt_and_curiosity_gap_content(self): + """Pattern interrupt and curiosity gap should be meaningful.""" + engine = HookEngine() + result = engine.generate_hook("Neymar vs Ronaldo", HookStyle.COUNTER_NARRATIVE) + assert len(result["pattern_interrupt"]) > 5 + assert len(result["curiosity_gap"]) > 10 + # Curiosity gap should mention subject or entity + assert "Neymar" in result["curiosity_gap"] or "Ronaldo" in result["curiosity_gap"] or "história" in result["curiosity_gap"].lower()