From f4eb6b7580c8f16bb1ed167afee9af672b121566 Mon Sep 17 00:00:00 2001 From: mdev34-lab <117395510+mdev34-lab@users.noreply.github.com> Date: Sun, 24 May 2026 11:39:04 -0300 Subject: [PATCH 01/23] Fix video render freezing: loop clips via _ensure_duration to prevent FFmpeg 0-byte read at EOF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona _ensure_duration() que faz loop dos clips fonte com concatenate_videoclips() para garantir que cubram todo o target_duration. Aplica em _create_with_overlay_mode (blurred bg e fg) e _create_simple_mode. Antes: FFMPEG_VideoReader devolvia 0 bytes ao ler frames além do fim do arquivo -> MoviePy silenciava com 'using last valid frame' -> output congelado e render acelerava artificialmente sem fazer decode real. --- src/autoshorts/modules/video_compositor.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/autoshorts/modules/video_compositor.py b/src/autoshorts/modules/video_compositor.py index f358dc3..4265eb9 100644 --- a/src/autoshorts/modules/video_compositor.py +++ b/src/autoshorts/modules/video_compositor.py @@ -159,6 +159,14 @@ def apply_opacity(get_frame, t): clip = clip.with_effects([Resize(scale_anim)]) return clip.transform(apply_opacity) + def _ensure_duration( + self, clip: VideoFileClip, min_duration: float + ) -> VideoFileClip: + if clip.duration <= 0 or clip.duration >= min_duration: + return clip + loops = int(min_duration / clip.duration) + 1 + return concatenate_videoclips([clip] * loops).subclipped(0, min_duration) + def _jumpcut_background(self, clip, target_duration: float) -> VideoFileClip: """Create background by randomly sampling segments instead of speed scaling.""" import random @@ -453,10 +461,11 @@ def _create_with_overlay_mode( Uses flattened structure to avoid MoviePy timing bugs with nested composites. """ blurred = self._create_blurred_background_from_clip(video) + blurred = self._ensure_duration(blurred, target_duration) content_h = int(VIDEO_HEIGHT * 0.45) - # Use full video for fg so jumpcut segments never land past its end fg = video.resized((VIDEO_WIDTH, content_h)).with_position(("center", "center")) + fg = self._ensure_duration(fg, target_duration) base_composite = CompositeVideoClip( [blurred, fg], size=(VIDEO_WIDTH, VIDEO_HEIGHT) @@ -582,6 +591,7 @@ def _create_simple_mode( target_duration: float, ) -> CompositeVideoClip: """Simple video without blur.""" + video = self._ensure_duration(video, target_duration) if BG_MODE == "jumpcut": # _jumpcut_background already handles duration trimming final_video = self._jumpcut_background(video, target_duration) From ae88153f346c7edf2aad3b026f736ed64929cf2f Mon Sep 17 00:00:00 2001 From: mdev34-lab <117395510+mdev34-lab@users.noreply.github.com> Date: Sun, 24 May 2026 19:05:53 -0300 Subject: [PATCH 02/23] Center-anchor pop-in zoom animation The Resize effect changes pixel dimensions but leaves clips at the default top-left (0,0) position, making the zoom appear anchored to the corner. Adding with_position(('center', 'center')) centers the clip dynamically at each frame, so the zoom emerges from the center. --- src/autoshorts/generators/explainer.py | 3 ++- src/autoshorts/modules/video_compositor.py | 3 ++- tests/test_fluximages.py | 3 +++ tests/test_video_compositor.py | 4 ++++ 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/autoshorts/generators/explainer.py b/src/autoshorts/generators/explainer.py index 0827069..ab3d9cf 100644 --- a/src/autoshorts/generators/explainer.py +++ b/src/autoshorts/generators/explainer.py @@ -315,7 +315,8 @@ def apply_opacity(get_frame, t): return np.minimum(255, frame * opacity).astype("uint8") clip = clip.with_effects([vfx.Resize(scale_anim)]) - return clip.transform(apply_opacity) + clip = clip.transform(apply_opacity) + return clip.with_position(("center", "center")) def _create_flux_video( self, img_paths: list, audio_path: str, paragraphs: list, output_path: str diff --git a/src/autoshorts/modules/video_compositor.py b/src/autoshorts/modules/video_compositor.py index 4265eb9..756c750 100644 --- a/src/autoshorts/modules/video_compositor.py +++ b/src/autoshorts/modules/video_compositor.py @@ -157,7 +157,8 @@ def apply_opacity(get_frame, t): return np.minimum(255, frame * opacity).astype("uint8") clip = clip.with_effects([Resize(scale_anim)]) - return clip.transform(apply_opacity) + clip = clip.transform(apply_opacity) + return clip.with_position(("center", "center")) def _ensure_duration( self, clip: VideoFileClip, min_duration: float diff --git a/tests/test_fluximages.py b/tests/test_fluximages.py index b0edc34..17bc871 100644 --- a/tests/test_fluximages.py +++ b/tests/test_fluximages.py @@ -532,12 +532,14 @@ def test_apply_overlay_animation_returns_clip(self, mock_resize): mock_clip.size = (1080, 1920) mock_clip.with_effects = Mock(return_value=mock_clip) mock_clip.transform = Mock(return_value=mock_clip) + mock_clip.with_position = Mock(return_value=mock_clip) result = self.gen._apply_overlay_animation(mock_clip, 3.0) assert result is mock_clip mock_clip.with_effects.assert_called_once() mock_clip.transform.assert_called_once() + mock_clip.with_position.assert_called_once_with(("center", "center")) @patch("autoshorts.generators.explainer.vfx.Resize") def test_apply_overlay_animation_zero_duration(self, mock_resize): @@ -545,6 +547,7 @@ def test_apply_overlay_animation_zero_duration(self, mock_resize): mock_clip.size = (1080, 1920) mock_clip.with_effects = Mock(return_value=mock_clip) mock_clip.transform = Mock(return_value=mock_clip) + mock_clip.with_position = Mock(return_value=mock_clip) result = self.gen._apply_overlay_animation(mock_clip, 0.0) diff --git a/tests/test_video_compositor.py b/tests/test_video_compositor.py index 1f1a67b..2b3927f 100644 --- a/tests/test_video_compositor.py +++ b/tests/test_video_compositor.py @@ -55,18 +55,21 @@ def test_returns_clip_with_effects_and_transform(self): mock_clip.size = (1080, 1920) mock_clip.with_effects.return_value = mock_clip mock_clip.transform.return_value = mock_clip + mock_clip.with_position.return_value = mock_clip result = self.compositor._apply_overlay_animation(mock_clip, 3.0) assert result is mock_clip mock_clip.with_effects.assert_called_once() mock_clip.transform.assert_called_once() + mock_clip.with_position.assert_called_once_with(("center", "center")) def test_zero_duration(self): mock_clip = MagicMock() mock_clip.size = (1080, 1920) mock_clip.with_effects.return_value = mock_clip mock_clip.transform.return_value = mock_clip + mock_clip.with_position.return_value = mock_clip result = self.compositor._apply_overlay_animation(mock_clip, 0.0) assert result is mock_clip @@ -102,6 +105,7 @@ def test_opacity_transform_midpoint_visible(self): mock_clip.size = (100, 100) mock_clip.with_effects = Mock(return_value=mock_clip) mock_clip.transform = Mock(return_value=mock_clip) + mock_clip.with_position = Mock(return_value=mock_clip) self.compositor._apply_overlay_animation(mock_clip, 1.0) From f92f37dff93d3d779596e60cbe9756cc1a8ba3bc Mon Sep 17 00:00:00 2001 From: mdev34-lab <117395510+mdev34-lab@users.noreply.github.com> Date: Sun, 24 May 2026 19:26:31 -0300 Subject: [PATCH 03/23] Eliminate generic filler and strengthen factual accuracy of scripts - Remove FALLBACK_PARAGRAPHS ('No final fica uma licao...') entirely - Remove 'Do NOT fact-check yourself' from the draft prompt - Add _is_filler() and _validate_paragraphs() to detect/reject vague paragraphs - All prompts now require: verifiable fact per paragraph, factual conclusion, correct date calculation, explicit ban on 'fica uma licao' - Pipeline now fails early (return False) if generated script has <3 paragraphs - Search queries with more specific terms (fundacao, dados, estatisticas) --- src/autoshorts/generators/explainer.py | 12 ++ src/autoshorts/modules/script_generator.py | 136 ++++++++++++++++----- src/autoshorts/modules/web_search.py | 6 +- tests/test_edge_cases.py | 12 +- tests/test_fluximages.py | 8 +- tests/test_script_generator.py | 48 ++++---- 6 files changed, 151 insertions(+), 71 deletions(-) diff --git a/src/autoshorts/generators/explainer.py b/src/autoshorts/generators/explainer.py index ab3d9cf..5b2cb1f 100644 --- a/src/autoshorts/generators/explainer.py +++ b/src/autoshorts/generators/explainer.py @@ -104,6 +104,12 @@ async def _run_normal_pipeline(self) -> bool: script = self.script_generator.generate_script(subject) else: script = self.script_generator.generate_script_from_metadata(title, "") + if not script or len(script) < 3: + log( + f"Script generation failed: got {len(script)} paragraphs, need >= 3", + "ERROR", + ) + return False log(f"Generated script with {len(script)} paragraphs") log("Step 3: Generating TTS audio...") @@ -226,6 +232,12 @@ async def _run_images_only_pipeline(self) -> bool: paragraphs, _ = self.script_generator.generate_script_with_prompts( self.subject ) + if not paragraphs or len(paragraphs) < 3: + log( + f"Script generation failed: got {len(paragraphs)} paragraphs, need >= 3", + "ERROR", + ) + return False log("Step 2: Generating TTS audio...") audio_path = await self.tts_system.generate_audio_only( diff --git a/src/autoshorts/modules/script_generator.py b/src/autoshorts/modules/script_generator.py index a23c097..7a96d92 100644 --- a/src/autoshorts/modules/script_generator.py +++ b/src/autoshorts/modules/script_generator.py @@ -11,13 +11,7 @@ from .logging_system import log from .web_search import WebSearcher -FALLBACK_PARAGRAPHS = [ - "Esta hist\u00f3ria come\u00e7a com um fato que marcou \u00e9poca.", - "Os detalhes revelam como tudo aconteceu ao longo do tempo.", - "Cada etapa trouxe consequ\u00eancias que mudaram o rumo dos acontecimentos.", - "O desfecho mostra por que este tema continua relevante at\u00e9 hoje.", - "No final, fica uma li\u00e7\u00e3o que vale a pena conhecer.", -] +FALLBACK_PARAGRAPHS: list[str] = [] class ScriptGenerator: @@ -71,15 +65,24 @@ def generate_script(self, subject: str) -> list: _SYSTEM_PROMPT_SINGLE, _user_prompt_single(subject, context), ) + script = self._validate_paragraphs(script) + script = self._ensure_paragraph_count(script, 5) if script: log("Script generated with web sources", "SUCCESS") - return self._ensure_paragraph_count(script, 5) - log("Script generation with context failed", "WARNING") + return script + log("Script generation with context failed or produced filler", "WARNING") else: - log("No search results, returning draft as-is", "WARNING") + log("No search results for grounding", "WARNING") + log("Falling back to draft script (ungrounded)", "WARNING") draft = draft_data.get("draft") or [] - return self._ensure_paragraph_count(draft, 5) + draft = self._validate_paragraphs(draft) + draft = self._ensure_paragraph_count(draft, 5) + if draft: + return draft + + log("All script generation paths failed", "ERROR") + return [] def generate_script_from_metadata(self, title: str, description: str) -> list: """Generate script from YouTube video title and description.""" @@ -118,14 +121,24 @@ def generate_script_with_prompts(self, subject: str) -> tuple: log("Step 2: generating script with search context...") paragraphs = self._generate_script_with_context(subject, context) if paragraphs: - log("Script generated with web sources", "SUCCESS") - return self._ensure_paragraph_count(paragraphs, 7), [] - log("Script generation with context failed", "WARNING") + paragraphs = self._validate_paragraphs(paragraphs) + paragraphs = self._ensure_paragraph_count(paragraphs, 7) + if paragraphs: + log("Script generated with web sources", "SUCCESS") + return paragraphs, [] + log("Script generation with context failed or produced filler", "WARNING") else: - log("No search results, returning draft as-is", "WARNING") + log("No search results for grounding", "WARNING") + log("Falling back to draft script (ungrounded)", "WARNING") draft = draft_data.get("draft") or [] - return self._ensure_paragraph_count(draft, 7), [] + draft = self._validate_paragraphs(draft) + draft = self._ensure_paragraph_count(draft, 7) + if draft: + return draft, [] + + log("All script generation paths failed", "ERROR") + return [], [] def generate_image_prompts_from_script( self, paragraphs: list, num_images: int @@ -157,11 +170,13 @@ def _generate_draft(self, subject: str, num_paragraphs: int = 5) -> dict: 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 2-3 sentences ' - f'\u2014 a first-draft script about "{subject}".\n' + f"\u2014 a first-draft script about \"{subject}\".\n" f" - First paragraph MUST start with a specific concrete fact (date, name, number, place).\n" - f" - Include specific names, dates, statistics, locations.\n" + f" - Every paragraph MUST contain a verifiable fact \u2014 no generalities, no filler.\n" + f" - CRITICAL: Do the math yourself. If you mention a date range, calculate the years correctly.\n" + f" - Include specific names, dates, statistics, locations \u2014 and VERIFY them in your head.\n" f" - Tell an origin story: how it started, why it matters.\n" - f" - This is a DRAFT \u2014 it may contain errors. Do NOT fact-check yourself.\n" + f" - End with a factual conclusion, NOT 'fica uma li\u00e7\u00e3o' 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 " @@ -287,20 +302,61 @@ def _make_text_api_call(self, system_prompt: str, user_prompt: str) -> list: lines.append(current_para) if len(lines) < 4: - lines.extend(FALLBACK_PARAGRAPHS[len(lines) :]) + 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 list(FALLBACK_PARAGRAPHS) + return [] + + @staticmethod + def _is_filler(paragraph: str) -> bool: + """Check if a paragraph is generic filler that should be rejected.""" + filler_patterns = [ + "fica uma li\u00e7\u00e3o", + "vale a pena conhecer", + "li\u00e7\u00e3o que vale", + "ningu\u00e9m sabia", + "o segredo", + "a verdade escondida", + "voc\u00ea n\u00e3o vai acreditar", + "poucos conhecem", + "pouca gente sabe", + "muita gente n\u00e3o sabe", + "o que poucos sabem", + ] + 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 not ScriptGenerator._is_filler(p)] + if len(cleaned) < 3: + log( + f"Validation: {len(paragraphs)} input, {len(cleaned)} after removing filler", + "WARNING", + ) + return cleaned @staticmethod def _ensure_paragraph_count(paragraphs: list, target: int) -> list: - """Pad or trim paragraphs to target count.""" + """Trim or validate paragraph count. Never pads with filler.""" if len(paragraphs) >= target: return paragraphs[:target] - return paragraphs + FALLBACK_PARAGRAPHS[len(paragraphs) : target] + if len(paragraphs) < 3: + log( + f"Only {len(paragraphs)} paragraphs, need at least 3 \u2014 returning empty", + "ERROR", + ) + return [] + return paragraphs # ── Single-pass path for images-only without web search ────────────── @@ -315,14 +371,17 @@ def _generate_script_with_context( "CRITICAL: First paragraph MUST start with a SPECIFIC FACT (date, name, number, place).\n" 'NEVER use "ningu\u00e9m sabia", "o segredo", or "a verdade" \u2014 these are vague.\n' "Always lead with concrete details: dates, names, places, statistics.\n" + "Every paragraph MUST contain a verifiable fact \u2014 no generalities, no filler.\n" "Include origin stories: explain how it started and why it matters.\n" "Keep each paragraph 2-3 sentences (~3 seconds audio each).\n" - "Use the provided web sources as your primary source of facts." + "End with a factual conclusion, NOT 'fica uma li\u00e7\u00e3o' 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"Start with a specific concrete fact (date, name, number). " - f"Include origin and specific details.\n\n" + f"Include origin and specific details. " + f"Double-check every date and number \u2014 calculate ranges correctly.\n\n" f"WEB SOURCES:\n{search_context}" ) try: @@ -340,14 +399,18 @@ def _generate_script_with_prompts_single(self, subject: str) -> tuple: "1.'paragraphs': Array of 7 strings (PT-BR).\n" "CRITICAL: First paragraph MUST start with a SPECIFIC FACT (date, name, number, place).\n" '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" "Always lead with concrete details: dates, names, places, statistics.\n" "Include origin stories: explain how it started and why it matters.\n" - "Keep each paragraph 2-3 sentences (~3 seconds audio each)." + "Keep each paragraph 2-3 sentences (~3 seconds audio each).\n" + 'End with a factual conclusion, NOT "fica uma li\u00e7\u00e3o" or similar.\n' + "Do the math yourself. If you mention a date range, calculate the years correctly." ) user_prompt = ( f"Tell a story about: {subject}. " f"Start with a specific concrete fact (date, name, number). " - f"Include origin and specific details." + f"Include origin and specific details. " + f"Double-check every date and number \u2014 calculate ranges correctly." ) try: data = self._make_json_api_call(system_prompt, user_prompt) @@ -376,7 +439,10 @@ def _generate_script_with_prompts_single(self, subject: str) -> tuple: "8.NO markdown formatting, NO JSON, just plain text paragraphs.\n" "9.Every paragraph must advance the story with a new specific fact \u2014 no filler.\n" "10.USE the provided web sources as your primary source of facts. " - "Cite specific data from them." + "Cite specific data from them.\n" + '11.End with a factual conclusion, NOT "fica uma li\u00e7\u00e3o" or similar generic phrases.\n' + '12.NEVER use "no final fica uma li\u00e7\u00e3o" or "vale a pena conhecer" \u2014 these are filler.\n' + "13.Do the math yourself. If you mention a date range or time period, calculate the years correctly." ) @@ -391,8 +457,10 @@ def _user_prompt_single(subject: str, search_context: str) -> str: 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 avan\u00e7ar a hist\u00f3ria com um novo fato concreto\n" + f"- Cada par\u00e1grafo DEVE conter um FATO VERIFIC\u00c1VEL \u2014 nada de generaliza\u00e7\u00f5es\n" f"- NADA de frases de enchimento\n" + f"- TERMINE com uma conclus\u00e3o factual, 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." ) @@ -414,7 +482,9 @@ def _user_prompt_single(subject: str, search_context: str) -> str: "7.Write exactly 4-5 paragraphs.\n" "8.NO markdown formatting, NO JSON, just plain text paragraphs.\n" "9.Base your story entirely on the video's content, " - "adding only well-known historical context." + "adding only well-known historical context.\n" + '10.End with a factual conclusion, NOT "fica uma li\u00e7\u00e3o" or similar.\n' + "11.Every paragraph must contain a verifiable fact \u2014 no generalities." ) @@ -430,8 +500,10 @@ def _user_prompt_metadata(combined_content: str) -> str: "- 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 avan\u00e7ar a hist\u00f3ria com um novo fato\n" - "- NADA de ganchos gen\u00e9ricos ou frases de enchimento\n\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 factual, 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." ) diff --git a/src/autoshorts/modules/web_search.py b/src/autoshorts/modules/web_search.py index aa37e85..c076bff 100644 --- a/src/autoshorts/modules/web_search.py +++ b/src/autoshorts/modules/web_search.py @@ -10,9 +10,9 @@ def generate_queries(subject: str) -> list[str]: s = subject.strip().strip('"').strip("'") return [ s, - f"{s} hist\u00f3ria origem", - f"{s} fatos importantes", - f"{s} contexto hist\u00f3rico", + f"{s} hist\u00f3ria origem funda\u00e7\u00e3o", + f"{s} fatos hist\u00f3ricos dados estat\u00edsticas", + f"{s} contexto hist\u00f3rico data local", ] def search(self, subject: str) -> list[dict] | None: diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py index f947954..117065c 100644 --- a/tests/test_edge_cases.py +++ b/tests/test_edge_cases.py @@ -52,22 +52,22 @@ def test_very_long_subject(self, mock_post): assert isinstance(result, list) @patch("autoshorts.modules.script_generator.requests.post") - def test_api_timeout(self, mock_post): + def test_api_timeout_returns_empty(self, mock_post): mock_post.side_effect = TimeoutError("Connection timeout") result = self.generator.generate_script("test subject") assert isinstance(result, list) - assert len(result) == 5 + assert len(result) == 0 @patch("autoshorts.modules.script_generator.requests.post") - def test_malformed_api_response(self, mock_post): + def test_malformed_api_response_returns_empty(self, mock_post): mock_response = Mock() mock_response.json.return_value = {} mock_post.return_value = mock_response result = self.generator.generate_script("test subject") assert isinstance(result, list) - assert len(result) == 5 + assert len(result) == 0 class TestEdgeCasesTTSSystem: @@ -264,7 +264,7 @@ async def test_full_pipeline_empty_inputs(self): if temp_dir.exists(): shutil.rmtree(temp_dir) - def test_error_propagation(self): + def test_error_propagation_returns_empty(self): with patch("autoshorts.modules.script_generator.requests.post") as mock_post: mock_post.side_effect = ConnectionError("Network error") @@ -272,4 +272,4 @@ def test_error_propagation(self): result = script_gen.generate_script("test") assert isinstance(result, list) - assert len(result) == 5 + assert len(result) == 0 diff --git a/tests/test_fluximages.py b/tests/test_fluximages.py index 17bc871..daea513 100644 --- a/tests/test_fluximages.py +++ b/tests/test_fluximages.py @@ -365,8 +365,8 @@ async def test_generate_normal_mode( ): gen = ExplainerGenerator(subject="test", image_source="ai") mock_script = Mock() - mock_script.generate_script.return_value = ["Para 1", "Para 2"] - mock_script.generate_script_from_metadata.return_value = ["Para 1", "Para 2"] + mock_script.generate_script.return_value = ["Para 1", "Para 2", "Para 3"] + mock_script.generate_script_from_metadata.return_value = ["Para 1", "Para 2", "Para 3"] gen.script_generator = mock_script mock_bg = Mock() @@ -395,8 +395,8 @@ async def test_generate_images_only_mode( gen = ExplainerGenerator(subject="test", images_only=True, image_source="ai") mock_script = Mock() mock_script.generate_script_with_prompts.return_value = ( - ["Para 1", "Para 2"], - ["Prompt 1", "Prompt 2"], + ["Para 1", "Para 2", "Para 3"], + ["Prompt 1", "Prompt 2", "Prompt 3"], ) gen.script_generator = mock_script diff --git a/tests/test_script_generator.py b/tests/test_script_generator.py index eee616f..f457a54 100644 --- a/tests/test_script_generator.py +++ b/tests/test_script_generator.py @@ -112,20 +112,18 @@ def test_generate_script_with_web_search(self, mock_post, mock_searcher_class): assert payload.get("response_format") == {"type": "json_object"} @patch("autoshorts.modules.script_generator.requests.post") - def test_generate_script_api_error_fallback(self, mock_post): - """Test script generation with API error returns fallback""" + def test_generate_script_api_error_returns_empty(self, mock_post): + """Test script generation with API error returns empty list""" mock_post.side_effect = Exception("API Error") - # Should return fallback script instead of raising result = self.script_generator.generate_script("test subject") assert isinstance(result, list) - assert len(result) == 5 - assert "história" in result[0].lower() + assert len(result) == 0 @patch("autoshorts.modules.script_generator.requests.post") - def test_generate_script_empty_response(self, mock_post): - """Test script generation with empty response""" + def test_generate_script_empty_response_returns_empty(self, mock_post): + """Test script generation with empty response returns empty list""" mock_response = Mock() mock_response.json.return_value = {"choices": [{"message": {"content": ""}}]} mock_response.raise_for_status.return_value = None @@ -134,7 +132,7 @@ def test_generate_script_empty_response(self, mock_post): result = self.script_generator.generate_script("test subject") assert isinstance(result, list) - assert len(result) == 5 # Should return fallback + assert len(result) == 0 @patch("autoshorts.modules.script_generator.requests.post") def test_generate_script_with_prompts_success(self, mock_post): @@ -233,8 +231,8 @@ def test_generate_script_from_metadata_with_web_search(self, mock_post): assert len(result) > 0 @patch("autoshorts.modules.script_generator.requests.post") - def test_generate_script_from_metadata_error(self, mock_post): - """Test script generation from metadata with error returns fallback""" + def test_generate_script_from_metadata_error_returns_empty(self, mock_post): + """Test script generation from metadata with error returns empty list""" mock_post.side_effect = Exception("API Error") result = self.script_generator.generate_script_from_metadata( @@ -242,7 +240,7 @@ def test_generate_script_from_metadata_error(self, mock_post): ) assert isinstance(result, list) - assert len(result) == 5 + assert len(result) == 0 @patch("autoshorts.modules.script_generator.requests.post") def test_api_request_headers(self, mock_post): @@ -379,7 +377,7 @@ def test_generate_script_with_double_newlines(self, mock_post): @patch("autoshorts.modules.script_generator.requests.post") def test_generate_script_insufficient_paragraphs(self, mock_post): - """Test script generation pads insufficient paragraphs""" + """Test script generation returns what it gets, no padding""" mock_response = Mock() mock_response.json.return_value = { "choices": [{"message": {"content": "Only one paragraph."}}] @@ -390,7 +388,7 @@ def test_generate_script_insufficient_paragraphs(self, mock_post): result = self.script_generator.generate_script("test") assert isinstance(result, list) - assert len(result) == 5 # Should be padded to 5 + assert len(result) == 1 @patch("autoshorts.modules.script_generator.requests.post") def test_generate_script_with_context_success(self, mock_post): @@ -645,9 +643,7 @@ def test_generate_script_with_prompts_empty_draft_fallback( generator = ScriptGenerator(web_search=True) paragraphs, prompts = generator.generate_script_with_prompts("test") - assert ( - len(paragraphs) == 5 - ) # _ensure_paragraph_count([], 7) pads from 5 fallback entries + assert len(paragraphs) == 0 # No fallback filler, returns empty assert isinstance(paragraphs, list) assert prompts == [] @@ -681,7 +677,7 @@ def test_generate_script_draft_api_error(self, mock_post, mock_searcher_class): generator = ScriptGenerator(web_search=True) result = generator.generate_script("test") assert isinstance(result, list) - assert len(result) == 5 + assert len(result) == 0 @patch("autoshorts.modules.script_generator.requests.post") def test_generate_draft_api_error_returns_fallback(self, mock_post): @@ -787,8 +783,8 @@ def setup_method(self): self.script_generator = ScriptGenerator(web_search=False) @patch("autoshorts.modules.script_generator.requests.post") - def test_generate_script_timeout(self, mock_post): - """Test script generation with timeout returns fallback""" + def test_generate_script_timeout_returns_empty(self, mock_post): + """Test script generation with timeout returns empty list""" import requests mock_post.side_effect = requests.Timeout("Request timed out") @@ -796,11 +792,11 @@ def test_generate_script_timeout(self, mock_post): result = self.script_generator.generate_script("test") assert isinstance(result, list) - assert len(result) == 5 + assert len(result) == 0 @patch("autoshorts.modules.script_generator.requests.post") - def test_generate_script_connection_error(self, mock_post): - """Test script generation with connection error returns fallback""" + def test_generate_script_connection_error_returns_empty(self, mock_post): + """Test script generation with connection error returns empty list""" import requests mock_post.side_effect = requests.ConnectionError("No connection") @@ -808,11 +804,11 @@ def test_generate_script_connection_error(self, mock_post): result = self.script_generator.generate_script("test") assert isinstance(result, list) - assert len(result) == 5 + assert len(result) == 0 @patch("autoshorts.modules.script_generator.requests.post") - def test_generate_script_http_error(self, mock_post): - """Test script generation with HTTP error returns fallback""" + def test_generate_script_http_error_returns_empty(self, mock_post): + """Test script generation with HTTP error returns empty list""" mock_response = Mock() mock_response.raise_for_status.side_effect = Exception("HTTP 500") mock_post.return_value = mock_response @@ -820,7 +816,7 @@ def test_generate_script_http_error(self, mock_post): result = self.script_generator.generate_script("test") assert isinstance(result, list) - assert len(result) == 5 + assert len(result) == 0 if __name__ == "__main__": From 35967268d320987f1d2e8b6d737fd3b0292b7612 Mon Sep 17 00:00:00 2001 From: mdev34-lab <117395510+mdev34-lab@users.noreply.github.com> Date: Sun, 24 May 2026 19:46:01 -0300 Subject: [PATCH 04/23] Break circular hallucination loop: independent query generation + post-generation fact verification - Replace draft-derived LLM queries with _generate_search_queries(): a separate LLM call with a neutral prompt that has never seen the draft, eliminating the circular 'draft -> queries confirming draft -> more draft' loop - Add _verify_factual_claims(): extracts all years from the generated script, searches each year + subject via DDGS, then calls the LLM as a strict fact-checker to cross-reference every claim against web sources - Add _generate_title_from_script(): generates title from final script instead of draft - Update all tests for the new 3-call flow (queries -> script -> title) --- src/autoshorts/modules/script_generator.py | 168 +++++++++++++++++---- tests/test_script_generator.py | 77 ++++++---- 2 files changed, 187 insertions(+), 58 deletions(-) diff --git a/src/autoshorts/modules/script_generator.py b/src/autoshorts/modules/script_generator.py index 7a96d92..7e15d91 100644 --- a/src/autoshorts/modules/script_generator.py +++ b/src/autoshorts/modules/script_generator.py @@ -33,7 +33,7 @@ def __init__(self, web_search: bool = True): # ── Public API ────────────────────────────────────────────────────── def generate_script(self, subject: str) -> list: - """Generate script from subject. Two-step when web_search is enabled.""" + """Generate script from subject. Searches web first, then generates grounded script.""" log("Generating script...") if not self.web_search or not self.searcher or not subject: @@ -42,22 +42,14 @@ def generate_script(self, subject: str) -> list: _user_prompt_single(subject, ""), ) - # Step 1: generate search queries + title - log("Step 1: generating search queries...") - draft_data = self._generate_draft(subject, num_paragraphs=5) - self.generated_title = draft_data.get("title") or None - queries = draft_data.get("queries") or [] + # 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) - # Search using LLM-generated queries - results = None - if queries: - log(f"Searching {len(queries)} LLM-generated queries...") - results = self.searcher.search_with_queries(queries) - else: - log("No queries generated, falling back to subject-based search") - results = self.searcher.search(subject) + # Step 2: search the web with neutral queries + results = self.searcher.search_with_queries(queries) - # Step 2: generate final script grounded in search results + # 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...") @@ -69,12 +61,19 @@ def generate_script(self, subject: str) -> list: script = self._ensure_paragraph_count(script, 5) if script: log("Script generated with web sources", "SUCCESS") + # Step 4: post-generation fact verification + script = self._verify_factual_claims(script, subject) + self.generated_title = self._generate_title_from_script(script, subject) + log("Script verified", "SUCCESS") return script log("Script generation with context failed or produced filler", "WARNING") else: log("No search results for grounding", "WARNING") - log("Falling back to draft script (ungrounded)", "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 draft = draft_data.get("draft") or [] draft = self._validate_paragraphs(draft) draft = self._ensure_paragraph_count(draft, 5) @@ -95,27 +94,20 @@ def generate_script_from_metadata(self, title: str, description: str) -> list: ) def generate_script_with_prompts(self, subject: str) -> tuple: - """Generate script paragraphs. Two-step when web_search is enabled.""" + """Generate script paragraphs. Searches web first, then generates grounded script.""" log(f"Generating script paragraphs for: {subject}...") if not self.web_search or not self.searcher or not subject: return self._generate_script_with_prompts_single(subject) - # Step 1: generate search queries + title - log("Step 1: generating search queries...") - draft_data = self._generate_draft(subject, num_paragraphs=7) - self.generated_title = draft_data.get("title") or None - queries = draft_data.get("queries") or [] + # Step 1: generate independent search queries (NOT from draft) + log("Step 1: generating independent search queries...") + queries = self._generate_search_queries(subject) - # Search using LLM-generated queries - results = None - if queries: - log(f"Searching {len(queries)} LLM-generated queries...") - results = self.searcher.search_with_queries(queries) - else: - results = self.searcher.search(subject) + # Step 2: search the web with neutral queries + results = self.searcher.search_with_queries(queries) - # Step 2: generate final script grounded in search results + # 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...") @@ -125,12 +117,18 @@ def generate_script_with_prompts(self, subject: str) -> tuple: paragraphs = self._ensure_paragraph_count(paragraphs, 7) if paragraphs: log("Script generated with web sources", "SUCCESS") + paragraphs = self._verify_factual_claims(paragraphs, subject) + self.generated_title = self._generate_title_from_script(paragraphs, subject) + log("Script verified", "SUCCESS") return paragraphs, [] log("Script generation with context failed or produced filler", "WARNING") else: log("No search results for grounding", "WARNING") - log("Falling back to draft script (ungrounded)", "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 draft = draft_data.get("draft") or [] draft = self._validate_paragraphs(draft) draft = self._ensure_paragraph_count(draft, 7) @@ -206,6 +204,114 @@ 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" + "Your goal: find ACCURATE factual data about a topic.\n" + "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." + ) + user_prompt = ( + f"Generate search queries to find accurate factual information about: {subject}" + ) + try: + data = self._make_json_api_call(system_prompt, user_prompt) + queries = data.get("queries") or [] + log(f"Generated {len(queries)} independent search queries", "SUCCESS") + return queries + except Exception as e: + 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 and numbers in script against web search results.""" + import re + + script_text = " ".join(paragraphs) + years = set(re.findall(r"\b(1[4-9]\d{2}|20[0-2]\d)\b", script_text)) + + if not years: + return paragraphs + + log( + f"Verifying factual claims for years: {', '.join(sorted(years))}", + "INFO", + ) + + verification_queries = [f"{subject} {year}" for year in years] + verification_queries.append(f"{subject} data hist\u00f3rico funda\u00e7\u00e3o") + results = self.searcher.search_with_queries(verification_queries) + if not results: + return paragraphs + + 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' + '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, ' + "or the original if no changes needed\n\n" + "CRITICAL: Compare EVERY date, number, name, and place against the web sources. " + "If a source contradicts a script claim, the SOURCE wins. " + "NEVER leave a hallucination uncorrected." + ) + user_prompt = ( + f"Subject: {subject}\n\n" + f"SCRIPT:\n{script_text}\n\n" + f"WEB SOURCES:\n{context}\n\n" + "Cross-check every date, number, and factual claim. Output corrected paragraphs." + ) + try: + data = self._make_json_api_call(system_prompt, user_prompt) + corrected = data.get("paragraphs") or [] + corrections = data.get("corrections") or [] + is_verified = data.get("verified", False) + if corrections: + log( + f"Fact verification: {len(corrections)} corrections applied", + "WARNING", + ) + for c in corrections: + log( + f" '{c.get('claim', '?')}' -> '{c.get('correction', '?')}'", + "INFO", + ) + corrected = self._validate_paragraphs(corrected) + corrected = self._ensure_paragraph_count(corrected, len(paragraphs)) + elif is_verified: + log("Fact verification: all claims match sources", "SUCCESS") + return corrected if corrected else paragraphs + except Exception as e: + log(f"Fact verification failed: {e}", "WARNING") + return paragraphs + + # ── Title generation ───────────────────────────────────────────────── + + def _generate_title_from_script( + self, paragraphs: list, subject: str + ) -> str | None: + """Generate a YouTube Shorts title from the final script.""" + script_text = " ".join(paragraphs)[:500] + system_prompt = ( + "Output ONLY a JSON object with one key: 'title'.\n" + "Max 60 characters, PT-BR, catchy YouTube Shorts title." + ) + user_prompt = f"Generate a title for this script about {subject}: {script_text}" + try: + data = self._make_json_api_call(system_prompt, user_prompt) + return data.get("title") + except Exception: + return None + # ── API helpers ────────────────────────────────────────────────────── def _make_json_api_call(self, system_prompt: str, user_prompt: str) -> dict: diff --git a/tests/test_script_generator.py b/tests/test_script_generator.py index f457a54..87ab074 100644 --- a/tests/test_script_generator.py +++ b/tests/test_script_generator.py @@ -523,18 +523,19 @@ def test_generate_script_with_search_results(self, mock_post, mock_searcher_clas mock_searcher.format_context.return_value = "FONTES DA WEB:\n..." mock_searcher_class.return_value = mock_searcher - draft_json = json.dumps( - { - "draft": ["P1", "P2", "P3", "P4", "P5"], - "queries": ["flamengo hist\u00f3ria", "fluminense origem"], - "title": "Cl\u00e1ssico", - } - ) - draft_response = Mock() - draft_response.json.return_value = { - "choices": [{"message": {"content": draft_json}}] + query_response = Mock() + query_response.json.return_value = { + "choices": [ + { + "message": { + "content": json.dumps( + {"queries": ["flamengo hist\u00f3ria", "fluminense origem"]} + ) + } + } + ] } - draft_response.raise_for_status.return_value = None + query_response.raise_for_status.return_value = None text_response = Mock() text_response.json.return_value = { @@ -554,7 +555,19 @@ def test_generate_script_with_search_results(self, mock_post, mock_searcher_clas } text_response.raise_for_status.return_value = None - mock_post.side_effect = [draft_response, text_response] + title_response = Mock() + title_response.json.return_value = { + "choices": [ + { + "message": { + "content": json.dumps({"title": "Cl\u00e1ssico"}) + } + } + ] + } + title_response.raise_for_status.return_value = None + + mock_post.side_effect = [query_response, text_response, title_response] generator = ScriptGenerator(web_search=True) result = generator.generate_script("Flamengo x Fluminense") @@ -564,8 +577,7 @@ def test_generate_script_with_search_results(self, mock_post, mock_searcher_clas assert "primeiro" in result[0].lower() mock_searcher.search_with_queries.assert_called_once() mock_searcher.format_context.assert_called_once() - # Verify _make_text_api_call received the context - assert mock_post.call_count == 2 + assert mock_post.call_count == 3 @patch("autoshorts.modules.script_generator.WebSearcher") @patch("autoshorts.modules.script_generator.requests.post") @@ -581,18 +593,17 @@ def test_generate_script_with_prompts_web_search_success( mock_searcher.format_context.return_value = "FONTES DA WEB:\n..." mock_searcher_class.return_value = mock_searcher - draft_json = json.dumps( - { - "draft": ["P1", "P2", "P3", "P4", "P5", "P6", "P7"], - "queries": ["query1", "query2"], - "title": "Test Title", - } - ) - draft_response = Mock() - draft_response.json.return_value = { - "choices": [{"message": {"content": draft_json}}] + query_response = Mock() + query_response.json.return_value = { + "choices": [ + { + "message": { + "content": json.dumps({"queries": ["query1", "query2"]}) + } + } + ] } - draft_response.raise_for_status.return_value = None + query_response.raise_for_status.return_value = None final_json = json.dumps({"paragraphs": [f"P{i}" for i in range(1, 8)]}) final_response = Mock() @@ -601,7 +612,19 @@ def test_generate_script_with_prompts_web_search_success( } final_response.raise_for_status.return_value = None - mock_post.side_effect = [draft_response, final_response] + title_response = Mock() + title_response.json.return_value = { + "choices": [ + { + "message": { + "content": json.dumps({"title": "Test Title"}) + } + } + ] + } + title_response.raise_for_status.return_value = None + + mock_post.side_effect = [query_response, final_response, title_response] generator = ScriptGenerator(web_search=True) paragraphs, prompts = generator.generate_script_with_prompts("test") @@ -609,7 +632,7 @@ def test_generate_script_with_prompts_web_search_success( assert len(paragraphs) == 7 assert prompts == [] mock_searcher.format_context.assert_called_once() - assert mock_post.call_count == 2 + assert mock_post.call_count == 3 @patch("autoshorts.modules.script_generator.WebSearcher") @patch("autoshorts.modules.script_generator.requests.post") From d1a268164b4174d10457dea4d0a58efea4f82ece Mon Sep 17 00:00:00 2001 From: mdev34-lab <117395510+mdev34-lab@users.noreply.github.com> Date: Sun, 24 May 2026 20:04:39 -0300 Subject: [PATCH 05/23] Rewrite all prompts to enforce viral dramatic tone, forbid corporate language Every prompt now includes TONE (dramatic/scandalous), FIRST SENTENCE (hook, not dry date), STRUCTURE (Hook -> Context -> Drama -> Ending), and FORBIDDEN (Ltda, S.A., addresses, corporate speak). _is_filler() expanded to catch legal/corporate patterns. Paragraphs shortened to 1-2 punchy sentences. --- src/autoshorts/modules/script_generator.py | 153 +++++++++++++-------- 1 file changed, 93 insertions(+), 60 deletions(-) diff --git a/src/autoshorts/modules/script_generator.py b/src/autoshorts/modules/script_generator.py index 7e15d91..10a6bc2 100644 --- a/src/autoshorts/modules/script_generator.py +++ b/src/autoshorts/modules/script_generator.py @@ -167,14 +167,18 @@ def _generate_draft(self, subject: str, num_paragraphs: int = 5) -> dict: """Pass 1: generate draft script + verification queries + title.""" 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 2-3 sentences ' + f'1. "draft": Array of {num_paragraphs} strings (PT-BR), each 1-2 punchy sentences ' f"\u2014 a first-draft script about \"{subject}\".\n" - f" - First paragraph MUST start with a specific concrete fact (date, name, number, place).\n" + f" - TONE: Dramatic, scandalous, like telling gossip to a friend. " + f"NEVER sound like a Wikipedia article or corporate press release.\n" + f" - FIRST SENTENCE: A dramatic hook that grabs attention \u2014 " + f"a bold claim, a shocking stat, a mystery. NOT a dry date.\n" + f" - STRUCTURE: Hook \u2192 Context \u2192 The Drama \u2192 Punchy ending\n" f" - Every paragraph MUST contain a verifiable fact \u2014 no generalities, no filler.\n" f" - CRITICAL: Do the math yourself. If you mention a date range, calculate the years correctly.\n" - f" - Include specific names, dates, statistics, locations \u2014 and VERIFY them in your head.\n" - f" - Tell an origin story: how it started, why it matters.\n" - f" - End with a factual conclusion, NOT 'fica uma li\u00e7\u00e3o' or similar generic phrases.\n" + f" - FORBIDDEN: Legal names (Ltda, S.A.), addresses, city/state abbreviations. " + f"NO corporate language \u2014 write like a human, not a registrar.\n" + f" - End with a punchy conclusion, NOT 'fica uma li\u00e7\u00e3o' 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 " @@ -187,7 +191,8 @@ def _generate_draft(self, subject: str, num_paragraphs: int = 5) -> dict: user_prompt = ( f"Write a first-draft script about {subject} in {num_paragraphs} paragraphs " f"(PT-BR), generate 4-6 Portuguese search queries to verify its facts, " - f"and suggest a catchy title." + f"and suggest a catchy title. " + f"Remember: dramatic tone, NO corporate language, grab attention in the first sentence." ) try: data = self._make_json_api_call(system_prompt, user_prompt) @@ -421,7 +426,7 @@ def _make_text_api_call(self, system_prompt: str, user_prompt: str) -> list: @staticmethod def _is_filler(paragraph: str) -> bool: - """Check if a paragraph is generic filler that should be rejected.""" + """Check if a paragraph is generic filler or corporate language that should be rejected.""" filler_patterns = [ "fica uma li\u00e7\u00e3o", "vale a pena conhecer", @@ -434,6 +439,10 @@ def _is_filler(paragraph: str) -> bool: "pouca gente sabe", "muita gente n\u00e3o sabe", "o que poucos sabem", + "ltda", + "s.a.", + "institui\u00e7\u00e3o de pagamento", + "pessoa jur\u00eddica", ] lower = paragraph.lower() return any(p in lower for p in filler_patterns) @@ -473,21 +482,25 @@ def _generate_script_with_context( 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).\n" - "CRITICAL: First paragraph MUST start with a SPECIFIC FACT (date, name, number, place).\n" + "1.'paragraphs': Array of 7 strings (PT-BR), each 1-2 punchy sentences.\n" + "TONE: Dramatic, scandalous, like telling gossip to a friend. " + "NEVER sound like Wikipedia or a press release.\n" + "FIRST SENTENCE: A dramatic hook that grabs attention \u2014 " + "a bold claim, a shocking stat, a mystery.\n" + "STRUCTURE: Hook \u2192 Context \u2192 The Drama \u2192 Punchy ending\n" 'NEVER use "ningu\u00e9m sabia", "o segredo", or "a verdade" \u2014 these are vague.\n' - "Always lead with concrete details: dates, names, places, statistics.\n" "Every paragraph MUST contain a verifiable fact \u2014 no generalities, no filler.\n" - "Include origin stories: explain how it started and why it matters.\n" - "Keep each paragraph 2-3 sentences (~3 seconds audio each).\n" - "End with a factual conclusion, NOT 'fica uma li\u00e7\u00e3o' or similar.\n" + "FORBIDDEN: Legal names (Ltda, S.A.), addresses, city/state abbreviations. " + "NO corporate language.\n" + "Keep each paragraph 1-2 punchy sentences (~2-3 seconds audio each).\n" + "End with a punchy conclusion, NOT 'fica uma li\u00e7\u00e3o' 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"Start with a specific concrete fact (date, name, number). " - f"Include origin and specific details. " - f"Double-check every date and number \u2014 calculate ranges correctly.\n\n" + f"Tell a dramatic story about: {subject}. " + f"Start with a hook that grabs attention. " + f"Include origin, the drama, and specific details. " + f"NO corporate language. Double-check every date and number \u2014 calculate ranges correctly.\n\n" f"WEB SOURCES:\n{search_context}" ) try: @@ -502,21 +515,25 @@ def _generate_script_with_prompts_single(self, subject: str) -> tuple: 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).\n" - "CRITICAL: First paragraph MUST start with a SPECIFIC FACT (date, name, number, place).\n" + "1.'paragraphs': Array of 7 strings (PT-BR), each 1-2 punchy sentences.\n" + "TONE: Dramatic, scandalous, like telling gossip to a friend. " + "NEVER sound like Wikipedia or a press release.\n" + "FIRST SENTENCE: A dramatic hook that grabs attention \u2014 " + "a bold claim, a shocking stat, a mystery.\n" + "STRUCTURE: Hook \u2192 Context \u2192 The Drama \u2192 Punchy ending\n" '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" - "Always lead with concrete details: dates, names, places, statistics.\n" - "Include origin stories: explain how it started and why it matters.\n" - "Keep each paragraph 2-3 sentences (~3 seconds audio each).\n" - 'End with a factual conclusion, NOT "fica uma li\u00e7\u00e3o" or similar.\n' + "FORBIDDEN: Legal names (Ltda, S.A.), addresses, city/state abbreviations. " + "NO corporate language.\n" + "Keep each paragraph 1-2 punchy sentences (~2-3 seconds audio each).\n" + 'End with a punchy conclusion, NOT "fica uma li\u00e7\u00e3o" or similar.\n' "Do the math yourself. If you mention a date range, calculate the years correctly." ) user_prompt = ( - f"Tell a story about: {subject}. " - f"Start with a specific concrete fact (date, name, number). " - f"Include origin and specific details. " - f"Double-check every date and number \u2014 calculate ranges correctly." + f"Tell a dramatic story about: {subject}. " + f"Start with a hook that grabs attention. " + f"Include origin, the drama, and specific details. " + f"NO corporate language. Double-check every date and number." ) try: data = self._make_json_api_call(system_prompt, user_prompt) @@ -532,23 +549,24 @@ def _generate_script_with_prompts_single(self, subject: str) -> tuple: "You are a master storyteller for viral YouTube Shorts.\n" "CRITICAL RETENTION RULES:\n" "1.Write in Brazilian Portuguese (PT-BR).\n" - "2.First paragraph MUST start with a SPECIFIC FACT (date, number, name, place) " - "\u2014 not a generic teaser.\n" - '3.NEVER start with "ningu\u00e9m sabia", "o segredo", "a verdade escondida" ' - 'or "voc\u00ea n\u00e3o vai acreditar" \u2014 these are vague and weak.\n' - '4.Always lead with concrete, specific details: "Em 1914...", ' - '"Tudo come\u00e7ou quando...", "O placar foi 8 a 0..."\n' - "5.Include origin stories \u2014 explain HOW something started or WHY it matters, " - "not just THAT it exists.\n" - "6.Each paragraph 2-3 sentences for pacing (each ~3 seconds of audio).\n" - "7.Write exactly 4-5 paragraphs.\n" - "8.NO markdown formatting, NO JSON, just plain text paragraphs.\n" - "9.Every paragraph must advance the story with a new specific fact \u2014 no filler.\n" - "10.USE the provided web sources as your primary source of facts. " + "2.TONE: Dramatic, scandalous, like telling gossip to a friend. " + "NEVER sound like Wikipedia or a corporate press release.\n" + "3.FIRST SENTENCE: A dramatic hook that grabs attention \u2014 " + "a bold claim, a shocking stat, a mystery. NOT a dry date.\n" + "4.STRUCTURE: Hook \u2192 Context \u2192 The Drama \u2192 Punchy ending\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' + "6.FORBIDDEN: Legal names (Ltda, S.A.), addresses, city/state abbreviations. " + "NO corporate language.\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" + "11.USE the provided web sources as your primary source of facts. " "Cite specific data from them.\n" - '11.End with a factual conclusion, NOT "fica uma li\u00e7\u00e3o" or similar generic phrases.\n' - '12.NEVER use "no final fica uma li\u00e7\u00e3o" or "vale a pena conhecer" \u2014 these are filler.\n' - "13.Do the math yourself. If you mention a date range or time period, calculate the years correctly." + '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' + "14.Do the math yourself. If you mention a date range or time period, calculate the years correctly." ) @@ -557,15 +575,20 @@ def _user_prompt_single(subject: str, search_context: str) -> str: f'Crie uma hist\u00f3ria envolvente em 4-5 par\u00e1grafos sobre "{subject}".\n\n' f"{search_context}\n\n" f"REGRAS CR\u00cdTICAS:\n" - f"- Primeiro par\u00e1grafo DEVE come\u00e7ar com um FATO CONCRETO " - f"(data, n\u00famero, nome, lugar) \u2014 N\u00c3O use ganchos gen\u00e9ricos\n" + f"- TOM: Dram\u00e1tico, como contando uma fofoca para um amigo. " + f"NUNCA pare uma Wikipedia ou release corporativo.\n" + f"- PRIMEIRA FRASE: Um gancho que prende aten\u00e7\u00e3o \u2014 " + f"uma afirma\u00e7\u00e3o ousada, um fato chocante, um mist\u00e9rio. N\u00c3O uma data seca.\n" + f"- ESTRUTURA: Gancho \u2192 Contexto \u2192 A Treta \u2192 Final impactante\n" f'- NUNCA comece com "ningu\u00e9m sabia", "o segredo", ' f'"a verdade escondida" \u2014 isso \u00e9 vago e fraco\n' + f"- PROIBIDO: Nomes jur\u00eddicos (Ltda, S.A.), endere\u00e7os, siglas de estado/cidade. " + f"NADA de linguagem corporativa.\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"- NADA de frases de enchimento\n" - f"- TERMINE com uma conclus\u00e3o factual, N\u00c3O com 'fica uma li\u00e7\u00e3o'\n" + f"- TERMINE com uma conclus\u00e3o impactante, 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." @@ -576,21 +599,26 @@ def _user_prompt_single(subject: str, search_context: str) -> str: "You are a master storyteller for viral YouTube Shorts.\n" "CRITICAL RETENTION RULES:\n" "1.Write in Brazilian Portuguese (PT-BR).\n" - "2.First paragraph MUST start with a SPECIFIC FACT from the video " - "(date, number, name, place).\n" - '3.NEVER start with "ningu\u00e9m sabia", "o segredo", ' + "2.TONE: Dramatic, scandalous, like telling gossip to a friend. " + "NEVER sound like Wikipedia or a corporate press release.\n" + "3.FIRST SENTENCE: A dramatic hook that grabs attention \u2014 " + "a bold claim, a shocking stat, a mystery. NOT a dry fact.\n" + "4.STRUCTURE: Hook \u2192 Context \u2192 The Drama \u2192 Punchy ending\n" + '5.NEVER start with "ningu\u00e9m sabia", "o segredo", ' 'or "a verdade escondida" \u2014 these are vague and weak.\n' - "4.Extract concrete details from the video metadata: " + "6.FORBIDDEN: Legal names (Ltda, S.A.), addresses, city/state abbreviations. " + "NO corporate language.\n" + "7.Extract concrete details from the video metadata: " "dates, names, places, statistics, historical context.\n" - "5.Include origin stories \u2014 explain HOW something started, " + "8.Include origin stories \u2014 explain HOW something started, " "not just THAT it happened.\n" - "6.Each paragraph 2-3 sentences for pacing (each ~3 seconds of audio).\n" - "7.Write exactly 4-5 paragraphs.\n" - "8.NO markdown formatting, NO JSON, just plain text paragraphs.\n" - "9.Base your story entirely on the video's content, " + "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" - '10.End with a factual conclusion, NOT "fica uma li\u00e7\u00e3o" or similar.\n' - "11.Every paragraph must contain a verifiable fact \u2014 no generalities." + '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." ) @@ -599,16 +627,21 @@ def _user_prompt_metadata(combined_content: str) -> str: "Crie uma hist\u00f3ria envolvente em 4-5 par\u00e1grafos baseada " "neste v\u00eddeo do YouTube.\n\n" "REGRAS CR\u00cdTICAS:\n" - "- Primeiro par\u00e1grafo DEVE come\u00e7ar com um FATO CONCRETO " - "extra\u00eddo do v\u00eddeo (data, nome, lugar, n\u00famero)\n" + "- TOM: Dram\u00e1tico, como contando uma fofoca para um amigo. " + "NUNCA pare uma Wikipedia ou release corporativo.\n" + "- PRIMEIRA FRASE: Um gancho que prende aten\u00e7\u00e3o \u2014 " + "uma afirma\u00e7\u00e3o ousada, um fato chocante. N\u00c3O uma data seca.\n" + "- ESTRUTURA: Gancho \u2192 Contexto \u2192 A Treta \u2192 Final impactante\n" '- NUNCA comece com "ningu\u00e9m sabia", "o segredo" ' 'ou "a verdade escondida"\n' + "- PROIBIDO: Nomes jur\u00eddicos (Ltda, S.A.), endere\u00e7os, siglas. " + "NADA de linguagem corporativa.\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 factual, N\u00c3O com 'fica uma li\u00e7\u00e3o'\n" + "- TERMINE com uma conclus\u00e3o impactante, 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." From eb2665e94725014aa8879956f735a0db089b42ad Mon Sep 17 00:00:00 2001 From: mdev34-lab <117395510+mdev34-lab@users.noreply.github.com> Date: Sun, 24 May 2026 20:26:18 -0300 Subject: [PATCH 06/23] Remove generic template keywords from YouTube bg search query prompt The old prompt forced terms like 'explicado', 'documentario', 'reportagem', 'historia' into every query, causing YouTube to return generic explainer videos (e.g. FNAF explainers) instead of subject-specific content. Removed those keywords and the 'family-friendly educational' framing. Now the LLM generates specific queries with concrete names and events. --- src/autoshorts/modules/video_background.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/autoshorts/modules/video_background.py b/src/autoshorts/modules/video_background.py index aec54da..a7c6465 100644 --- a/src/autoshorts/modules/video_background.py +++ b/src/autoshorts/modules/video_background.py @@ -53,19 +53,18 @@ def generate_search_query(self, subject: str) -> str: """Generate an AI-optimized YouTube search query.""" log("Generating AI-optimized search query...") - system_prompt = """You are an expert at crafting YouTube search queries to find high-quality, family-friendly content. + system_prompt = """You are an expert at crafting YouTube search queries. Output ONLY the query, nothing else. CRITICAL RULES: 1. Use the SAME language as the subject (do NOT translate) 2. Use NATURAL language with spaces, NOT dashes -3. Include terms like "explicado", "hist\u00f3ria", "document\u00e1rio", "reportagem" -4. Add "-shorts" to exclude YouTube Shorts -5. Make it specific and searchable -6. NO quotes, NO special formatting, NO excessive dashes -7. DO NOT just append the original text to template words -8. Example: "flash drive encontrado na rua hist\u00f3ria completa document\u00e1rio" +3. Add "-shorts" at the end to exclude YouTube Shorts +4. Be specific to the subject — use concrete names, events, places +5. NO generic template words like "explicado", "document\u00e1rio", "reportagem", "hist\u00f3ria" +6. NO quotes, NO special formatting +7. DO NOT just repeat the subject — add specific qualifiers """ - user_prompt = f"Subject: {subject}\n\nCreate a YouTube search query in the SAME language as the subject that will find family-friendly, educational videos about this subject. Focus on documentary-style content, news reports, or educational explanations. Avoid anything that might be age-restricted." + user_prompt = f"Subject: {subject}\n\nCreate a YouTube search query that returns videos directly about this subject. Be specific." headers = { "Authorization": f"Bearer {API_KEY}", From 9e0cd36bf80cbf9ff8ed8289d609699fe004c565 Mon Sep 17 00:00:00 2001 From: mdev34-lab <117395510+mdev34-lab@users.noreply.github.com> Date: Sun, 24 May 2026 21:31:11 -0300 Subject: [PATCH 07/23] Repair bad paragraphs instead of discarding; validate video title against subject - _repair_paragraphs(): generate only missing paragraphs via LLM instead of full draft regeneration - _is_suitable_video(): reject videos whose title lacks subject keywords - _generate_title_from_script: PT-BR prompt to avoid mixed-language titles - _verify_factual_claims: extract scores, tabus, and date contexts alongside years - Consolidate all prompts to use _tone_instructions() - remove duplicated tone rules --- src/autoshorts/cli/commands/explainer.py | 8 + src/autoshorts/generators/explainer.py | 4 +- src/autoshorts/modules/script_generator.py | 311 +++++++++++++++------ src/autoshorts/modules/video_background.py | 32 ++- tests/test_script_generator.py | 46 ++- tests/test_video_background.py | 4 +- 6 files changed, 292 insertions(+), 113 deletions(-) diff --git a/src/autoshorts/cli/commands/explainer.py b/src/autoshorts/cli/commands/explainer.py index b0c7bba..60da6b7 100644 --- a/src/autoshorts/cli/commands/explainer.py +++ b/src/autoshorts/cli/commands/explainer.py @@ -34,6 +34,11 @@ def explainer_command( "--images", help="Image source: 'web' (DDGS search) or 'ai' (Pollinations)", ), + tone: str = typer.Option( + "opinionated", + "--tone", + help="Script tone: 'corporate' (neutral, factual) or 'opinionated' (dramatic, viral)", + ), ): if no_images and images_only: raise typer.BadParameter("--no-images and --images-only are mutually exclusive") @@ -43,6 +48,8 @@ def explainer_command( raise typer.BadParameter("subject, --youtube-url, or --batch is required") if images not in ("web", "ai"): raise typer.BadParameter("--images must be 'web' or 'ai'") + if tone not in ("corporate", "opinionated"): + raise typer.BadParameter("--tone must be 'corporate' or 'opinionated'") subjects: list[str | None] = [] if batch: @@ -86,6 +93,7 @@ def explainer_command( no_images=no_images or images_only, images_only=images_only, image_source=images, + tone=tone, ) success = asyncio.run(gen.generate()) if success: diff --git a/src/autoshorts/generators/explainer.py b/src/autoshorts/generators/explainer.py index 5b2cb1f..6832e04 100644 --- a/src/autoshorts/generators/explainer.py +++ b/src/autoshorts/generators/explainer.py @@ -60,6 +60,7 @@ def __init__( no_images: bool = False, images_only: bool = False, image_source: str = "web", + tone: str = "opinionated", ): self.subject = subject self.output = output @@ -68,8 +69,9 @@ def __init__( self.no_images = no_images self.images_only = images_only self.image_source = image_source + self.tone = tone - self.script_generator = ScriptGenerator(web_search=web_search) + self.script_generator = ScriptGenerator(web_search=web_search, tone=tone) self.tts_system = TTSSystem() self.temp_dir = create_temp_dir() diff --git a/src/autoshorts/modules/script_generator.py b/src/autoshorts/modules/script_generator.py index 10a6bc2..2092227 100644 --- a/src/autoshorts/modules/script_generator.py +++ b/src/autoshorts/modules/script_generator.py @@ -22,24 +22,45 @@ class ScriptGenerator: Step 2 — search the web, then generate the final script grounded in results """ - def __init__(self, web_search: bool = True): + def __init__(self, web_search: bool = True, tone: str = "opinionated"): self.api_url = API_URL self.api_key = API_KEY self.model = MODEL_TEXT self.web_search = web_search + self.tone = tone self.searcher = WebSearcher() if web_search else None self.generated_title: str | None = None + def _tone_instructions(self) -> str: + if self.tone == "corporate": + return ( + "TONE: Neutral, informative, journalistic. Present facts clearly.\n" + "STRUCTURE: Start with a specific fact (date, number), then explain context, " + "then details, then conclusion.\n" + "FORBIDDEN: Clickbait, dramatic language, opinions, rhetorical questions.\n" + ) + return ( + "TONE: Dramatic, scandalous, like telling gossip to a friend. " + "NEVER sound like Wikipedia or a corporate press release.\n" + "FIRST SENTENCE: A dramatic hook that grabs attention \u2014 " + "a bold claim, a shocking stat, a mystery. NOT a dry date.\n" + "STRUCTURE: Hook \u2192 Context \u2192 The Drama \u2192 Punchy ending\n" + "FORBIDDEN: Legal names (Ltda, S.A.), addresses, city/state abbreviations. " + "NO corporate language.\n" + ) + # ── 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: return self._make_text_api_call( - _SYSTEM_PROMPT_SINGLE, - _user_prompt_single(subject, ""), + tone_block + _SYSTEM_PROMPT_SINGLE, + _user_prompt_single(subject, "", self.tone), ) # Step 1: generate independent search queries (NOT from draft — avoids circular hallucination) @@ -54,11 +75,11 @@ def generate_script(self, subject: str) -> list: context = self.searcher.format_context(results[:15]) log("Step 2: generating script with search context...") script = self._make_text_api_call( - _SYSTEM_PROMPT_SINGLE, - _user_prompt_single(subject, context), + tone_block + _SYSTEM_PROMPT_SINGLE, + _user_prompt_single(subject, context, self.tone), ) - script = self._validate_paragraphs(script) - script = self._ensure_paragraph_count(script, 5) + cleaned = self._validate_paragraphs(script) + script = self._ensure_paragraph_count(cleaned, 5) if script: log("Script generated with web sources", "SUCCESS") # Step 4: post-generation fact verification @@ -66,6 +87,17 @@ def generate_script(self, subject: str) -> list: 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: + 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) + log("Script verified", "SUCCESS") + return script + log("Script generation with context failed or produced filler", "WARNING") else: log("No search results for grounding", "WARNING") @@ -88,9 +120,10 @@ def generate_script_from_metadata(self, title: str, description: str) -> list: log("Generating script from video metadata...") desc = description[:1000] if description else "" combined_content = f"Title: {title}\n\nDescription: {desc}" + tone_block = self._tone_instructions() return self._make_text_api_call( - _SYSTEM_PROMPT_METADATA, - _user_prompt_metadata(combined_content), + tone_block + _SYSTEM_PROMPT_METADATA, + _user_prompt_metadata(combined_content, self.tone), ) def generate_script_with_prompts(self, subject: str) -> tuple: @@ -113,14 +146,23 @@ def generate_script_with_prompts(self, subject: str) -> tuple: log("Step 2: generating script with search context...") paragraphs = self._generate_script_with_context(subject, context) if paragraphs: - paragraphs = self._validate_paragraphs(paragraphs) - paragraphs = self._ensure_paragraph_count(paragraphs, 7) + cleaned = self._validate_paragraphs(paragraphs) + paragraphs = self._ensure_paragraph_count(cleaned, 7) if paragraphs: log("Script generated with web sources", "SUCCESS") paragraphs = self._verify_factual_claims(paragraphs, subject) 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: + 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, [] + log("Script generation with context failed or produced filler", "WARNING") else: log("No search results for grounding", "WARNING") @@ -150,9 +192,12 @@ def generate_image_prompts_from_script( system_prompt = f""" Output ONLY a JSON object with one key: 'images': Array of {num_images} objects, each with: - - 'web_query': short (3-6 word) search query for finding REAL photos on the web. - Use simple keywords like "subject crowd", "subject stadium", "subject close up". - NO descriptive adjectives, just concrete nouns and the subject. + - 'web_query': short (3-8 word) search query for finding REAL photos on the web. + CRITICAL: Include context qualifiers like year, league/country, team name, event name. + NEVER use a generic descriptor alone (e.g. "jogador comemorando") without the team/league context. + Example: "Corinthians Neo Quimica Arena torcida 2024" instead of "stadium crowd". + Use concrete nouns and the specific subject from the story. + NO descriptive adjectives, NO filler words. - 'ai_prompt': detailed English prompt for an AI image generator. Cinematic, dramatic lighting, ultra detailed, 4k photography style. Describe a specific scene matching the story. @@ -165,20 +210,15 @@ def generate_image_prompts_from_script( 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 punchy sentences ' + 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" - TONE: Dramatic, scandalous, like telling gossip to a friend. " - f"NEVER sound like a Wikipedia article or corporate press release.\n" - f" - FIRST SENTENCE: A dramatic hook that grabs attention \u2014 " - f"a bold claim, a shocking stat, a mystery. NOT a dry date.\n" - f" - STRUCTURE: Hook \u2192 Context \u2192 The Drama \u2192 Punchy ending\n" + f" {tone_block}" f" - Every paragraph MUST contain a verifiable fact \u2014 no generalities, no filler.\n" f" - CRITICAL: Do the math yourself. If you mention a date range, calculate the years correctly.\n" - f" - FORBIDDEN: Legal names (Ltda, S.A.), addresses, city/state abbreviations. " - f"NO corporate language \u2014 write like a human, not a registrar.\n" - f" - End with a punchy conclusion, NOT 'fica uma li\u00e7\u00e3o' or similar generic phrases.\n" + f" - End with a strong conclusion, NOT 'fica uma li\u00e7\u00e3o' 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 " @@ -191,8 +231,7 @@ def _generate_draft(self, subject: str, num_paragraphs: int = 5) -> dict: user_prompt = ( f"Write a first-draft script about {subject} in {num_paragraphs} paragraphs " f"(PT-BR), generate 4-6 Portuguese search queries to verify its facts, " - f"and suggest a catchy title. " - f"Remember: dramatic tone, NO corporate language, grab attention in the first sentence." + f"and suggest a catchy title." ) try: data = self._make_json_api_call(system_prompt, user_prompt) @@ -237,24 +276,63 @@ def _generate_search_queries(self, subject: str) -> list[str]: # ── Post-generation fact verification ──────────────────────────────── def _verify_factual_claims(self, paragraphs: list, subject: str) -> list: - """Cross-check dates and numbers in script against web search results.""" + """Cross-check dates, scores, tabus, and numbers in script against web search results.""" import re 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 + ) + for s1, s2 in score_matches: + for sep in (" a ", "x"): + q = f"{subject} {s1}{sep}{s2}" + 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" + 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 + ) + for y in context_years: + q = f"{subject} {y}" + if q not in verification_queries: + verification_queries.append(q) - if not years: + verification_queries.append( + f"{subject} hist\u00f3rico funda\u00e7\u00e3o dados" + ) + + if not verification_queries: return paragraphs log( - f"Verifying factual claims for years: {', '.join(sorted(years))}", + f"Verifying claims with {len(verification_queries)} targeted queries", "INFO", ) - verification_queries = [f"{subject} {year}" for year in years] - verification_queries.append(f"{subject} data hist\u00f3rico funda\u00e7\u00e3o") - results = self.searcher.search_with_queries(verification_queries) + results = self.searcher.search_with_queries(list(dict.fromkeys(verification_queries))) if not results: + log("Fact verification: no web sources found", "WARNING") return paragraphs context = self.searcher.format_context(results[:10]) @@ -265,15 +343,21 @@ def _verify_factual_claims(self, paragraphs: list, subject: str) -> list: "\u2014 empty if verified is true\n" '3. "paragraphs": array of strings (PT-BR) \u2014 corrected script paragraphs, ' "or the original if no changes needed\n\n" - "CRITICAL: Compare EVERY date, number, name, and place against the web sources. " - "If a source contradicts a script claim, the SOURCE wins. " - "NEVER leave a hallucination uncorrected." + "CRITICAL rules:\n" + "- Compare EVERY date, number, name, and place against the web sources.\n" + "- If a source contradicts a script claim, the SOURCE wins. " + "NEVER leave a hallucination uncorrected.\n" + "- If NO source confirms a specific claim (score, streak, percentage, event), " + "consider it UNVERIFIED and remove or rephrase it as uncertain.\n" + "- Pay attention to chronology: if sources mention an event ended or a record was broken in year X, " + "do NOT let the script claim it still holds in a later year." ) user_prompt = ( f"Subject: {subject}\n\n" f"SCRIPT:\n{script_text}\n\n" f"WEB SOURCES:\n{context}\n\n" - "Cross-check every date, number, and factual claim. Output corrected paragraphs." + "Cross-check every date, number, score, name, and factual claim. " + "Output corrected paragraphs." ) try: data = self._make_json_api_call(system_prompt, user_prompt) @@ -308,15 +392,50 @@ def _generate_title_from_script( script_text = " ".join(paragraphs)[:500] system_prompt = ( "Output ONLY a JSON object with one key: 'title'.\n" - "Max 60 characters, PT-BR, catchy YouTube Shorts title." + "Title must be in PT-BR, max 60 characters, catchy YouTube Shorts title." ) - user_prompt = f"Generate a title for this script about {subject}: {script_text}" + user_prompt = f"Crie um t\u00edtulo em PT-BR para este roteiro sobre {subject}: {script_text}" try: data = self._make_json_api_call(system_prompt, user_prompt) return data.get("title") except Exception: 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" + "Extend an existing script. Match the style, tone, and factual density of the existing paragraphs.\n" + "Each paragraph MUST contain a verifiable fact. No filler, no generalities, no conclusions.\n" + "Write paragraphs that would fit naturally BETWEEN the existing ones or after them.\n" + ) + user_prompt = ( + f"Topic: {subject}\n\n" + f"EXISTING PARAGRAPHS:\n{good_text}\n\n" + f"Write {needed} more paragraphs (PT-BR) that extend this story. " + "Do NOT repeat existing content." + ) + try: + data = self._make_json_api_call(system_prompt, user_prompt) + new_p = data.get("paragraphs") or [] + combined = good + new_p + combined = self._validate_paragraphs(combined) + combined = self._ensure_paragraph_count(combined, target) + if combined: + log(f"Repaired script: {len(good)} -> {len(combined)} paragraphs", "SUCCESS") + return combined + return good + except Exception as e: + log(f"Script repair failed: {e}", "WARNING") + return good + # ── API helpers ────────────────────────────────────────────────────── def _make_json_api_call(self, system_prompt: str, user_prompt: str) -> dict: @@ -479,28 +598,22 @@ def _generate_script_with_context( self, subject: str, search_context: str ) -> list | None: """Generate paragraphs grounded in search context (JSON API call).""" + 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 punchy sentences.\n" - "TONE: Dramatic, scandalous, like telling gossip to a friend. " - "NEVER sound like Wikipedia or a press release.\n" - "FIRST SENTENCE: A dramatic hook that grabs attention \u2014 " - "a bold claim, a shocking stat, a mystery.\n" - "STRUCTURE: Hook \u2192 Context \u2192 The Drama \u2192 Punchy ending\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" - "FORBIDDEN: Legal names (Ltda, S.A.), addresses, city/state abbreviations. " - "NO corporate language.\n" - "Keep each paragraph 1-2 punchy sentences (~2-3 seconds audio each).\n" - "End with a punchy conclusion, NOT 'fica uma li\u00e7\u00e3o' or similar.\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" "Use the provided web sources as your primary source of facts. Verify every number against them." ) user_prompt = ( - f"Tell a dramatic story about: {subject}. " - f"Start with a hook that grabs attention. " - f"Include origin, the drama, and specific details. " - f"NO corporate language. Double-check every date and number \u2014 calculate ranges correctly.\n\n" + 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"WEB SOURCES:\n{search_context}" ) try: @@ -512,28 +625,22 @@ def _generate_script_with_context( 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 punchy sentences.\n" - "TONE: Dramatic, scandalous, like telling gossip to a friend. " - "NEVER sound like Wikipedia or a press release.\n" - "FIRST SENTENCE: A dramatic hook that grabs attention \u2014 " - "a bold claim, a shocking stat, a mystery.\n" - "STRUCTURE: Hook \u2192 Context \u2192 The Drama \u2192 Punchy ending\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" - "FORBIDDEN: Legal names (Ltda, S.A.), addresses, city/state abbreviations. " - "NO corporate language.\n" - "Keep each paragraph 1-2 punchy sentences (~2-3 seconds audio each).\n" - 'End with a punchy conclusion, NOT "fica uma li\u00e7\u00e3o" or similar.\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' "Do the math yourself. If you mention a date range, calculate the years correctly." ) user_prompt = ( - f"Tell a dramatic story about: {subject}. " - f"Start with a hook that grabs attention. " - f"Include origin, the drama, and specific details. " - f"NO corporate language. Double-check every date and number." + f"Tell a story about: {subject}. " + f"Include origin, key facts, and specific details. " + f"Double-check every date and number." ) try: data = self._make_json_api_call(system_prompt, user_prompt) @@ -564,31 +671,40 @@ def _generate_script_with_prompts_single(self, subject: str) -> tuple: "10.Every paragraph must advance the story with a new specific fact \u2014 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' - "14.Do the math yourself. If you mention a date range or time period, calculate the years correctly." -) - - -def _user_prompt_single(subject: str, search_context: str) -> str: + '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' + "14.Do the math yourself. If you mention a date range or time period, calculate the years correctly." + ) + + +def _user_prompt_single(subject: str, search_context: str, tone: str = "opinionated") -> str: + tone_rules = { + "corporate": ( + "- TOM: Neutro, informativo, jornal\u00edstico. Apresente fatos com clareza.\n" + "- ESTRUTURA: Comece com um fato espec\u00edfico (data, n\u00famero), depois contexto, detalhes, conclus\u00e3o.\n" + "- PROIBIDO: Linguagem dram\u00e1tica, opini\u00f5es, perguntas ret\u00f3ricas, clickbait.\n" + ), + }.get(tone, ( + "- TOM: Dram\u00e1tico, como contando uma fofoca para um amigo. " + "NUNCA pare uma Wikipedia ou release corporativo.\n" + "- PRIMEIRA FRASE: Um gancho que prende aten\u00e7\u00e3o \u2014 " + "uma afirma\u00e7\u00e3o ousada, um fato chocante, um mist\u00e9rio. N\u00c3O uma data seca.\n" + "- ESTRUTURA: Gancho \u2192 Contexto \u2192 A Treta \u2192 Final impactante\n" + "- PROIBIDO: Nomes jur\u00eddicos (Ltda, S.A.), endere\u00e7os, siglas. " + "NADA de linguagem corporativa.\n" + )) return ( f'Crie uma hist\u00f3ria envolvente em 4-5 par\u00e1grafos sobre "{subject}".\n\n' f"{search_context}\n\n" f"REGRAS CR\u00cdTICAS:\n" - f"- TOM: Dram\u00e1tico, como contando uma fofoca para um amigo. " - f"NUNCA pare uma Wikipedia ou release corporativo.\n" - f"- PRIMEIRA FRASE: Um gancho que prende aten\u00e7\u00e3o \u2014 " - f"uma afirma\u00e7\u00e3o ousada, um fato chocante, um mist\u00e9rio. N\u00c3O uma data seca.\n" - f"- ESTRUTURA: Gancho \u2192 Contexto \u2192 A Treta \u2192 Final impactante\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"- PROIBIDO: Nomes jur\u00eddicos (Ltda, S.A.), endere\u00e7os, siglas de estado/cidade. " - f"NADA de linguagem corporativa.\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"- NADA de frases de enchimento\n" - f"- TERMINE com uma conclus\u00e3o impactante, N\u00c3O com 'fica uma li\u00e7\u00e3o'\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." @@ -617,31 +733,40 @@ def _user_prompt_single(subject: str, search_context: str) -> str: "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." -) - - -def _user_prompt_metadata(combined_content: str) -> str: - return ( - "Crie uma hist\u00f3ria envolvente em 4-5 par\u00e1grafos baseada " - "neste v\u00eddeo do YouTube.\n\n" - "REGRAS CR\u00cdTICAS:\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." + ) + + +def _user_prompt_metadata(combined_content: str, tone: str = "opinionated") -> str: + tone_rules = { + "corporate": ( + "- TOM: Neutro, informativo, jornal\u00edstico. Apresente fatos com clareza.\n" + "- ESTRUTURA: Comece com um fato espec\u00edfico, depois contexto, detalhes, conclus\u00e3o.\n" + "- PROIBIDO: Linguagem dram\u00e1tica, opini\u00f5es, clickbait.\n" + ), + }.get(tone, ( "- TOM: Dram\u00e1tico, como contando uma fofoca para um amigo. " "NUNCA pare uma Wikipedia ou release corporativo.\n" "- PRIMEIRA FRASE: Um gancho que prende aten\u00e7\u00e3o \u2014 " "uma afirma\u00e7\u00e3o ousada, um fato chocante. N\u00c3O uma data seca.\n" "- ESTRUTURA: Gancho \u2192 Contexto \u2192 A Treta \u2192 Final impactante\n" - '- NUNCA comece com "ningu\u00e9m sabia", "o segredo" ' - 'ou "a verdade escondida"\n' "- PROIBIDO: Nomes jur\u00eddicos (Ltda, S.A.), endere\u00e7os, siglas. " "NADA de linguagem corporativa.\n" + )) + return ( + "Crie uma hist\u00f3ria envolvente em 4-5 par\u00e1grafos baseada " + "neste v\u00eddeo do YouTube.\n\n" + "REGRAS CR\u00cdTICAS:\n" + f"{tone_rules}" + '- NUNCA comece com "ningu\u00e9m 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 impactante, N\u00c3O com 'fica uma li\u00e7\u00e3o'\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." diff --git a/src/autoshorts/modules/video_background.py b/src/autoshorts/modules/video_background.py index a7c6465..e0d1536 100644 --- a/src/autoshorts/modules/video_background.py +++ b/src/autoshorts/modules/video_background.py @@ -113,8 +113,9 @@ def _is_suitable_video( video_info: dict, min_duration: int = MIN_VIDEO_DURATION, max_duration: int = MAX_VIDEO_DURATION, + subject: str | None = None, ) -> bool: - """Filter videos based on duration and availability.""" + """Filter videos based on duration, availability, and title relevance.""" duration = video_info.get("duration", 0) title = video_info.get("title", "").lower() @@ -135,6 +136,17 @@ def _is_suitable_video( elif duration > max_duration: log(f"FILTERED: '{title[:30]}...' - Too long: {duration}s", "WARNING") return False + + if subject: + subject_lower = subject.lower() + subject_words = [w for w in subject_lower.split() if len(w) > 3] + if subject_words and not any(w in title for w in subject_words): + log( + f"FILTERED: '{title[:40]}...' - No subject keywords in title", + "WARNING", + ) + return False + return True def _extract_error_message(self, exc: Exception) -> str: @@ -159,14 +171,14 @@ def search_and_download(self, subject: str) -> str: """Search and download video using DDG first, then yt-dlp search as fallback.""" search_query = self.generate_search_query(subject) - video_path = self._search_with_ddg(search_query) + video_path = self._search_with_ddg(search_query, subject) if video_path: return video_path log("DDG search failed, falling back to yt-dlp search...", "WARNING") - return self._search_with_ytdlp(search_query) + return self._search_with_ytdlp(search_query, subject) - def _search_with_ddg(self, search_query: str) -> str | None: + def _search_with_ddg(self, search_query: str, subject: str | None = None) -> str | None: """Search YouTube via DuckDuckGo and download with yt-dlp.""" try: from ddgs import DDGS @@ -182,12 +194,12 @@ def _search_with_ddg(self, search_query: str) -> str | None: log("No YouTube URLs found via DDG", "WARNING") return None log(f"Found {len(urls)} YouTube videos, extracting metadata...", "INFO") - return self._download_first_suitable(urls) + return self._download_first_suitable(urls, subject) except Exception as e: log(f"DDG search failed: {e}", "WARNING") return None - def _search_with_ytdlp(self, search_query: str) -> str: + def _search_with_ytdlp(self, search_query: str, subject: str | None = None) -> str: """Fallback search using yt-dlp built-in search.""" yt_query = f"ytsearch20:{search_query}" @@ -205,7 +217,7 @@ def _search_with_ytdlp(self, search_query: str) -> str: if not all_videos: raise ValueError("No videos found in search results") - suitable_videos = [v for v in all_videos if self._is_suitable_video(v)] + suitable_videos = [v for v in all_videos if self._is_suitable_video(v, subject=subject)] if not suitable_videos: for v in info["entries"]: @@ -225,7 +237,7 @@ def _search_with_ytdlp(self, search_query: str) -> str: for v in suitable_videos[:10] if v.get("webpage_url") ] - path = self._download_first_suitable(urls) + path = self._download_first_suitable(urls, subject) if path: return path raise ValueError("No available videos could be downloaded") @@ -233,7 +245,7 @@ def _search_with_ytdlp(self, search_query: str) -> str: log(f"yt-dlp search failed: {e}", "ERROR") raise - def _download_first_suitable(self, urls: list[str]) -> str | None: + def _download_first_suitable(self, urls: list[str], subject: str | None = None) -> str | None: """Try URLs one by one, return path of first successful download.""" download_temp_dir = create_temp_dir() ydl_opts_with_dir = self.ydl_opts.copy() @@ -248,7 +260,7 @@ def _download_first_suitable(self, urls: list[str]) -> str | None: try: with yt_dlp.YoutubeDL({"quiet": True, "no_warnings": True}) as ydl: info = ydl.extract_info(video_url, download=False) - if not self._is_suitable_video(info): + if not self._is_suitable_video(info, subject=subject): log(f"Skipping unsuitable video {attempt + 1}", "WARNING") continue title = info.get("title", "Unknown") diff --git a/tests/test_script_generator.py b/tests/test_script_generator.py index 87ab074..d52691d 100644 --- a/tests/test_script_generator.py +++ b/tests/test_script_generator.py @@ -555,6 +555,22 @@ def test_generate_script_with_search_results(self, mock_post, mock_searcher_clas } text_response.raise_for_status.return_value = None + verification_response = Mock() + verification_response.json.return_value = { + "choices": [ + { + "message": { + "content": json.dumps({ + "verified": True, + "corrections": [], + "paragraphs": [], + }) + } + } + ] + } + verification_response.raise_for_status.return_value = None + title_response = Mock() title_response.json.return_value = { "choices": [ @@ -567,7 +583,7 @@ def test_generate_script_with_search_results(self, mock_post, mock_searcher_clas } title_response.raise_for_status.return_value = None - mock_post.side_effect = [query_response, text_response, title_response] + mock_post.side_effect = [query_response, text_response, verification_response, title_response] generator = ScriptGenerator(web_search=True) result = generator.generate_script("Flamengo x Fluminense") @@ -575,9 +591,9 @@ def test_generate_script_with_search_results(self, mock_post, mock_searcher_clas assert isinstance(result, list) assert len(result) == 5 assert "primeiro" in result[0].lower() - mock_searcher.search_with_queries.assert_called_once() - mock_searcher.format_context.assert_called_once() - assert mock_post.call_count == 3 + assert mock_searcher.search_with_queries.call_count == 2 + assert mock_searcher.format_context.call_count == 2 + assert mock_post.call_count == 4 @patch("autoshorts.modules.script_generator.WebSearcher") @patch("autoshorts.modules.script_generator.requests.post") @@ -612,6 +628,22 @@ def test_generate_script_with_prompts_web_search_success( } final_response.raise_for_status.return_value = None + verification_response = Mock() + verification_response.json.return_value = { + "choices": [ + { + "message": { + "content": json.dumps({ + "verified": True, + "corrections": [], + "paragraphs": [], + }) + } + } + ] + } + verification_response.raise_for_status.return_value = None + title_response = Mock() title_response.json.return_value = { "choices": [ @@ -624,15 +656,15 @@ def test_generate_script_with_prompts_web_search_success( } title_response.raise_for_status.return_value = None - mock_post.side_effect = [query_response, final_response, title_response] + mock_post.side_effect = [query_response, final_response, verification_response, title_response] generator = ScriptGenerator(web_search=True) paragraphs, prompts = generator.generate_script_with_prompts("test") assert len(paragraphs) == 7 assert prompts == [] - mock_searcher.format_context.assert_called_once() - assert mock_post.call_count == 3 + assert mock_searcher.format_context.call_count == 2 + assert mock_post.call_count == 4 @patch("autoshorts.modules.script_generator.WebSearcher") @patch("autoshorts.modules.script_generator.requests.post") diff --git a/tests/test_video_background.py b/tests/test_video_background.py index c7a09b9..2edf8e8 100644 --- a/tests/test_video_background.py +++ b/tests/test_video_background.py @@ -215,7 +215,7 @@ def test_ddg_success_returns_path(self, mock_gen_query, mock_ddg): result = self.manager.search_and_download("test") assert result == "/path/to/video.mp4" - mock_ddg.assert_called_once_with("test query") + mock_ddg.assert_called_once_with("test query", "test") @patch.object(VideoBackgroundManager, "_search_with_ddg") @patch.object(VideoBackgroundManager, "_search_with_ytdlp") @@ -229,4 +229,4 @@ def test_ddg_failure_falls_back_to_ytdlp( result = self.manager.search_and_download("test") assert result == "/path/to/video.mp4" - mock_ytdlp.assert_called_once_with("test query") + mock_ytdlp.assert_called_once_with("test query", "test") From 1e9364d5885488b53e7d7740d8afb1bf5d601797 Mon Sep 17 00:00:00 2001 From: mdev34-lab <117395510+mdev34-lab@users.noreply.github.com> Date: Sun, 24 May 2026 21:57:08 -0300 Subject: [PATCH 08/23] Title: uppercase key sections + lowercase subject-specific tags --- src/autoshorts/modules/script_generator.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/autoshorts/modules/script_generator.py b/src/autoshorts/modules/script_generator.py index 2092227..11a4e80 100644 --- a/src/autoshorts/modules/script_generator.py +++ b/src/autoshorts/modules/script_generator.py @@ -392,9 +392,21 @@ def _generate_title_from_script( script_text = " ".join(paragraphs)[:500] system_prompt = ( "Output ONLY a JSON object with one key: 'title'.\n" - "Title must be in PT-BR, max 60 characters, catchy YouTube Shorts title." + "Title must be in PT-BR, max 60 characters, YouTube Shorts title.\n" + "UPPERCASE RULES:\n" + "- Use UPPERCASE for key words or short sections of the title text itself.\n" + "- Example: 'A VERDADE sobre o Caso Girabank' (title before tags).\n" + "- Do NOT put tags in uppercase.\n\n" + "TAGS RULES:\n" + "- Append 1-3 lowercase tags at the end, no spaces between words.\n" + "- Tags MUST be SPECIFIC to the video subject, not generic categories.\n" + "- Example tags for a Mario game: '#supermario #nintendo #galaxy'\n" + "- Example tags for Carlinhos Maia: '#carlinhosmaia #girabank'\n" + "- NEVER tag unrelated topics like #futebol for a movie or #cinema for a bank story.\n\n" + "FULL EXAMPLE:\n" + "'A VERDADE sobre o Caso Girabank #carlinhosmaia #girabank'" ) - user_prompt = f"Crie um t\u00edtulo em PT-BR para este roteiro sobre {subject}: {script_text}" + 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}" try: data = self._make_json_api_call(system_prompt, user_prompt) return data.get("title") From 1b9ad732a8059dcdd0bfa7f9c42d1af26b7e77fd Mon Sep 17 00:00:00 2001 From: mdev34-lab <117395510+mdev34-lab@users.noreply.github.com> Date: Sun, 24 May 2026 22:05:00 -0300 Subject: [PATCH 09/23] Fix repair fallthrough: return empty when repair fails, check len >= 3 --- src/autoshorts/modules/script_generator.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/autoshorts/modules/script_generator.py b/src/autoshorts/modules/script_generator.py index 11a4e80..7006318 100644 --- a/src/autoshorts/modules/script_generator.py +++ b/src/autoshorts/modules/script_generator.py @@ -90,7 +90,7 @@ def generate_script(self, subject: str) -> list: # Try to repair instead of full regeneration repair = self._repair_paragraphs(cleaned, subject, 5) - if repair: + if repair and len(repair) >= 3: log("Script repaired after validation", "SUCCESS") script = repair script = self._verify_factual_claims(script, subject) @@ -157,7 +157,7 @@ def generate_script_with_prompts(self, subject: str) -> tuple: # Try to repair instead of full regeneration repair = self._repair_paragraphs(cleaned, subject, 7) - if repair: + 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) @@ -443,10 +443,11 @@ def _repair_paragraphs(self, good: list, subject: str, target: int) -> list: if combined: log(f"Repaired script: {len(good)} -> {len(combined)} paragraphs", "SUCCESS") return combined - return good + log("Script repair: not enough good paragraphs, discarding", "WARNING") + return [] except Exception as e: log(f"Script repair failed: {e}", "WARNING") - return good + return [] # ── API helpers ────────────────────────────────────────────────────── From 05421fa0d84d2418100158309e2a4514d6d5a6bf Mon Sep 17 00:00:00 2001 From: mdev34-lab <117395510+mdev34-lab@users.noreply.github.com> Date: Sun, 24 May 2026 22:07:53 -0300 Subject: [PATCH 10/23] Log why repair fails: API count, filler removals, final count vs target --- src/autoshorts/modules/script_generator.py | 23 +++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/autoshorts/modules/script_generator.py b/src/autoshorts/modules/script_generator.py index 7006318..b7f1c14 100644 --- a/src/autoshorts/modules/script_generator.py +++ b/src/autoshorts/modules/script_generator.py @@ -437,16 +437,33 @@ 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", + ) 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", + ) combined = self._ensure_paragraph_count(combined, target) if combined: - log(f"Repaired script: {len(good)} -> {len(combined)} paragraphs", "SUCCESS") + log(f"Repair success: {len(good)} -> {len(combined)} paragraphs", "SUCCESS") return combined - log("Script repair: not enough good paragraphs, discarding", "WARNING") + log( + f"Repair failed: only {after_validation} good paragraphs " + f"after validation, needed {target}", + "WARNING", + ) return [] except Exception as e: - log(f"Script repair failed: {e}", "WARNING") + log(f"Repair LLM call failed: {e}", "WARNING") return [] # ── API helpers ────────────────────────────────────────────────────── From 58b016f618149e8a2951367b45fc4a3f1bb88b0c Mon Sep 17 00:00:00 2001 From: mdev34-lab <117395510+mdev34-lab@users.noreply.github.com> Date: Sun, 24 May 2026 23:01:16 -0300 Subject: [PATCH 11/23] batch: single string with semicolon-separated topics instead of list[str] --- src/autoshorts/cli/commands/explainer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/autoshorts/cli/commands/explainer.py b/src/autoshorts/cli/commands/explainer.py index 60da6b7..b22ec9b 100644 --- a/src/autoshorts/cli/commands/explainer.py +++ b/src/autoshorts/cli/commands/explainer.py @@ -21,7 +21,7 @@ def explainer_command( goodnight: bool = typer.Option( False, "--goodnight", help="Shutdown after processing" ), - batch: list[str] = typer.Option(None, "--batch", help="Batch: multiple subjects"), + batch: str = typer.Option(None, "--batch", help="Batch: semicolon-separated subjects (e.g. 'topic1; topic2')"), no_web_search: bool = typer.Option( False, "--no-web-search", help="Disable web search (use model knowledge only)" ), @@ -53,7 +53,7 @@ def explainer_command( subjects: list[str | None] = [] if batch: - subjects = [s for s in batch] + subjects = [s.strip() for s in batch.split(";") if s.strip()] elif subject: subjects = [subject] elif youtube_url: From 0b26565ab1fbe645ddfa4ef24c0599f5ccd08310 Mon Sep 17 00:00:00 2001 From: mdev34-lab <117395510+mdev34-lab@users.noreply.github.com> Date: Mon, 25 May 2026 15:49:20 -0300 Subject: [PATCH 12/23] batch: sanitize filename; fix hidden type errors (dict .lower(), json.loads list, None title, missing .get()) --- src/autoshorts/cli/commands/explainer.py | 6 ++++-- src/autoshorts/modules/script_generator.py | 11 +++++++++-- src/autoshorts/modules/tts_system.py | 14 +++++++++----- src/autoshorts/modules/video_background.py | 4 ++-- src/autoshorts/modules/web_search.py | 4 ++-- 5 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/autoshorts/cli/commands/explainer.py b/src/autoshorts/cli/commands/explainer.py index b22ec9b..4d3020c 100644 --- a/src/autoshorts/cli/commands/explainer.py +++ b/src/autoshorts/cli/commands/explainer.py @@ -1,4 +1,5 @@ import asyncio +import re import time from pathlib import Path @@ -63,6 +64,7 @@ def explainer_command( output_path = Path(output) success_count = 0 total_count = len(subjects) + def _sanitize(s): return re.sub(r'[\\/*?:"<>|]', "", s).replace(" ", "_")[:20] for i, subj in enumerate(subjects, 1): log(f"Processing {i}/{total_count}: {subj or 'youtube-url'}") @@ -71,14 +73,14 @@ def explainer_command( out_dir = output_path.parent if is_batch: prefix = "explainer_" if images_only else "as_" - name = f"{prefix}{subj.replace(' ', '_')[:20] if subj else 'video'}_{int(time.time())}.mp4" + name = f"{prefix}{_sanitize(subj) if subj else 'video'}_{int(time.time())}.mp4" else: name = output_path.name else: out_dir = output_path if is_batch: prefix = "explainer_" if images_only else "as_" - name = f"{prefix}{subj.replace(' ', '_')[:20] if subj else 'video'}_{int(time.time())}.mp4" + name = f"{prefix}{_sanitize(subj) if subj else 'video'}_{int(time.time())}.mp4" else: prefix = "explainer_" if images_only else "autoshorts_" name = f"{prefix}{int(time.time())}.mp4" diff --git a/src/autoshorts/modules/script_generator.py b/src/autoshorts/modules/script_generator.py index b7f1c14..e976575 100644 --- a/src/autoshorts/modules/script_generator.py +++ b/src/autoshorts/modules/script_generator.py @@ -496,7 +496,14 @@ def _make_json_api_call(self, system_prompt: str, user_prompt: str) -> dict: response.raise_for_status() content = response.json()["choices"][0]["message"]["content"] if content and content.strip(): - return json.loads(content) + result = json.loads(content) + if not isinstance(result, dict): + 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( @@ -601,7 +608,7 @@ 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 not ScriptGenerator._is_filler(p)] + 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", diff --git a/src/autoshorts/modules/tts_system.py b/src/autoshorts/modules/tts_system.py index 64456bb..5543b70 100644 --- a/src/autoshorts/modules/tts_system.py +++ b/src/autoshorts/modules/tts_system.py @@ -51,11 +51,15 @@ async def generate_audio_and_subtitles( "default=noprint_wrappers=1:nokey=1", str(audio_file), ] - duration = float( - subprocess.run( - cmd, capture_output=True, text=True, timeout=FFPROBE_TIMEOUT - ).stdout.strip() - ) + try: + duration = float( + subprocess.run( + cmd, capture_output=True, text=True, timeout=FFPROBE_TIMEOUT + ).stdout.strip() + ) + except (ValueError, subprocess.TimeoutExpired, OSError) as e: + log(f"Failed to get audio duration from ffprobe: {e}", "WARNING") + duration = 0.0 # Generate subtitles using subtitle system vtt_file = self.subtitle_system.generate_subtitles( diff --git a/src/autoshorts/modules/video_background.py b/src/autoshorts/modules/video_background.py index e0d1536..58da869 100644 --- a/src/autoshorts/modules/video_background.py +++ b/src/autoshorts/modules/video_background.py @@ -117,7 +117,7 @@ def _is_suitable_video( ) -> bool: """Filter videos based on duration, availability, and title relevance.""" duration = video_info.get("duration", 0) - title = video_info.get("title", "").lower() + title = (video_info.get("title") or "").lower() if ( video_info.get("availability") == "private" @@ -188,7 +188,7 @@ def _search_with_ddg(self, search_query: str, subject: str | None = None) -> str DDGS().text(f"site:youtube.com {search_query}", max_results=10) ) urls = [ - r["href"] for r in results if "youtube.com/watch" in r.get("href", "") + r.get("href") for r in results if "youtube.com/watch" in r.get("href", "") ] if not urls: log("No YouTube URLs found via DDG", "WARNING") diff --git a/src/autoshorts/modules/web_search.py b/src/autoshorts/modules/web_search.py index c076bff..348a4a8 100644 --- a/src/autoshorts/modules/web_search.py +++ b/src/autoshorts/modules/web_search.py @@ -93,8 +93,8 @@ def format_context(results: list[dict]) -> str: "\u2500" * 60, ] for i, r in enumerate(results, 1): - snippet = r["snippet"][:200] - lines.append(f"[{i}] {r['title']}") + snippet = (r.get("snippet") or "")[:200] + lines.append(f"[{i}] {r.get('title', '')}") lines.append(f" Fonte: {r['url']}") lines.append(f" {snippet}") lines.append("") From 79a7c4765d7762d303a211c77a3056772cf970d0 Mon Sep 17 00:00:00 2001 From: mdev34-lab <117395510+mdev34-lab@users.noreply.github.com> Date: Mon, 25 May 2026 18:01:24 -0300 Subject: [PATCH 13/23] fix mypy type errors: filter None from urls, guard self.searcher; enforce ruff E731 --- src/autoshorts/modules/script_generator.py | 3 +++ src/autoshorts/modules/video_background.py | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/autoshorts/modules/script_generator.py b/src/autoshorts/modules/script_generator.py index e976575..2e40def 100644 --- a/src/autoshorts/modules/script_generator.py +++ b/src/autoshorts/modules/script_generator.py @@ -330,6 +330,9 @@ def _verify_factual_claims(self, paragraphs: list, subject: str) -> list: "INFO", ) + if not self.searcher: + log("Fact verification skipped: searcher not available", "WARNING") + return paragraphs results = self.searcher.search_with_queries(list(dict.fromkeys(verification_queries))) if not results: log("Fact verification: no web sources found", "WARNING") diff --git a/src/autoshorts/modules/video_background.py b/src/autoshorts/modules/video_background.py index 58da869..ddfb856 100644 --- a/src/autoshorts/modules/video_background.py +++ b/src/autoshorts/modules/video_background.py @@ -188,7 +188,8 @@ def _search_with_ddg(self, search_query: str, subject: str | None = None) -> str DDGS().text(f"site:youtube.com {search_query}", max_results=10) ) urls = [ - r.get("href") for r in results if "youtube.com/watch" in r.get("href", "") + u for r in results + if (u := r.get("href")) and "youtube.com/watch" in u ] if not urls: log("No YouTube URLs found via DDG", "WARNING") From 89b7cb13c2d772e47845ed598845e1cd35243851 Mon Sep 17 00:00:00 2001 From: mdev34-lab <117395510+mdev34-lab@users.noreply.github.com> Date: Mon, 25 May 2026 18:02:37 -0300 Subject: [PATCH 14/23] image_searcher: filter NSFW domains and keywords from DDGS results --- src/autoshorts/modules/image_searcher.py | 25 ++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/autoshorts/modules/image_searcher.py b/src/autoshorts/modules/image_searcher.py index 2c9ada8..24f4e93 100644 --- a/src/autoshorts/modules/image_searcher.py +++ b/src/autoshorts/modules/image_searcher.py @@ -21,6 +21,18 @@ ) from .logging_system import log +BLOCKED_DOMAINS = { + "crossdresser", "cd", "sissy", "femboy", "hentai", "rule34", + "porn", "xvideos", "xnxx", "xhamster", "pornhub", "onlyfans", + "redtube", "youporn", "adult", "sex", "erotic", "nsfw", +} + +BLOCKED_KEYWORDS = { + "crossdresser", "sissy", "femboy", "hentai", "rule34", + "porn", "nsfw", "xxx", "18+", "adult", "sex", "erotic", + "nude", "naked", "lingerie", "bikini", "seductive", +} + class ImageSearcher: def __init__( @@ -40,6 +52,18 @@ def __init__( self.max_per_query = max_per_query IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True) + @staticmethod + def _is_nsfw(result: dict) -> bool: + url = (result.get("image") or "").lower() + source = (result.get("url") or result.get("source") or "").lower() + title = (result.get("title") or "").lower() + combined = f"{url} {source} {title}" + if any(d in combined for d in BLOCKED_DOMAINS): + return True + if any(kw in combined for kw in BLOCKED_KEYWORDS): + return True + return False + def search_images(self, query: str) -> list[dict]: try: from ddgs import DDGS @@ -115,6 +139,7 @@ def get_images(self, prompts: list[str]) -> list[str]: continue results = self.search_images(prompt) + results = [r for r in results if not self._is_nsfw(r)] downloaded = False for r in results: url = r.get("image", "") From 885a3b129f5ab17bf1fb76e284ddd1bc8c147f15 Mon Sep 17 00:00:00 2001 From: mdev34-lab <117395510+mdev34-lab@users.noreply.github.com> Date: Mon, 25 May 2026 22:12:19 -0300 Subject: [PATCH 15/23] prompts: replace hyperboles with value-driven language; hooks become questions --- src/autoshorts/modules/script_generator.py | 65 +++++++++++++--------- 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/src/autoshorts/modules/script_generator.py b/src/autoshorts/modules/script_generator.py index 2e40def..26f3ebc 100644 --- a/src/autoshorts/modules/script_generator.py +++ b/src/autoshorts/modules/script_generator.py @@ -40,13 +40,16 @@ def _tone_instructions(self) -> str: "FORBIDDEN: Clickbait, dramatic language, opinions, rhetorical questions.\n" ) return ( - "TONE: Dramatic, scandalous, like telling gossip to a friend. " - "NEVER sound like Wikipedia or a corporate press release.\n" - "FIRST SENTENCE: A dramatic hook that grabs attention \u2014 " - "a bold claim, a shocking stat, a mystery. NOT a dry date.\n" - "STRUCTURE: Hook \u2192 Context \u2192 The Drama \u2192 Punchy ending\n" + "TONE: Curiosity-driven, narrative, engaging. " + "Write like a storyteller uncovering a fascinating truth \u2014 " + "never like Wikipedia or a corporate press release.\n" + "FIRST SENTENCE: An intriguing question that sparks curiosity. " + "NOT a dry date or a shocking exaggeration.\n" + "STRUCTURE: Question \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" ) # ── Public API ────────────────────────────────────────────────────── @@ -696,15 +699,18 @@ def _generate_script_with_prompts_single(self, subject: str) -> tuple: "You are a master storyteller for viral YouTube Shorts.\n" "CRITICAL RETENTION RULES:\n" "1.Write in Brazilian Portuguese (PT-BR).\n" - "2.TONE: Dramatic, scandalous, like telling gossip to a friend. " - "NEVER sound like Wikipedia or a corporate press release.\n" - "3.FIRST SENTENCE: A dramatic hook that grabs attention \u2014 " - "a bold claim, a shocking stat, a mystery. NOT a dry date.\n" - "4.STRUCTURE: Hook \u2192 Context \u2192 The Drama \u2192 Punchy ending\n" + "2.TONE: Curiosity-driven, narrative, engaging. " + "Write like a storyteller uncovering a fascinating truth \u2014 " + "never like Wikipedia or a corporate press release.\n" + "3.FIRST SENTENCE: An intriguing question that sparks curiosity. " + "NOT a dry date or a shocking exaggeration.\n" + "4.STRUCTURE: Question \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' "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' "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" @@ -725,13 +731,16 @@ def _user_prompt_single(subject: str, search_context: str, tone: str = "opiniona "- PROIBIDO: Linguagem dram\u00e1tica, opini\u00f5es, perguntas ret\u00f3ricas, clickbait.\n" ), }.get(tone, ( - "- TOM: Dram\u00e1tico, como contando uma fofoca para um amigo. " - "NUNCA pare uma Wikipedia ou release corporativo.\n" - "- PRIMEIRA FRASE: Um gancho que prende aten\u00e7\u00e3o \u2014 " - "uma afirma\u00e7\u00e3o ousada, um fato chocante, um mist\u00e9rio. N\u00c3O uma data seca.\n" - "- ESTRUTURA: Gancho \u2192 Contexto \u2192 A Treta \u2192 Final impactante\n" + "- TOM: Curiosidade, narrativa envolvente. " + "Conte como quem revela um fato fascinante \u2014 " + "NUNCA como Wikipedia ou release corporativo.\n" + "- PRIMEIRA FRASE: Uma pergunta instigante que desperta curiosidade. " + "N\u00c3O uma data seca nem um exagero chocante.\n" + "- ESTRUTURA: Pergunta \u2192 Contexto \u2192 Revela\u00e7\u00e3o \u2192 Conclus\u00e3o forte\n" "- PROIBIDO: Nomes jur\u00eddicos (Ltda, S.A.), endere\u00e7os, 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" )) return ( f'Crie uma hist\u00f3ria envolvente em 4-5 par\u00e1grafos sobre "{subject}".\n\n' @@ -755,15 +764,18 @@ def _user_prompt_single(subject: str, search_context: str, tone: str = "opiniona "You are a master storyteller for viral YouTube Shorts.\n" "CRITICAL RETENTION RULES:\n" "1.Write in Brazilian Portuguese (PT-BR).\n" - "2.TONE: Dramatic, scandalous, like telling gossip to a friend. " - "NEVER sound like Wikipedia or a corporate press release.\n" - "3.FIRST SENTENCE: A dramatic hook that grabs attention \u2014 " - "a bold claim, a shocking stat, a mystery. NOT a dry fact.\n" - "4.STRUCTURE: Hook \u2192 Context \u2192 The Drama \u2192 Punchy ending\n" + "2.TONE: Curiosity-driven, narrative, engaging. " + "Write like a storyteller uncovering a fascinating truth \u2014 " + "never like Wikipedia or a corporate press release.\n" + "3.FIRST SENTENCE: An intriguing question that sparks curiosity. " + "NOT a dry fact or a shocking exaggeration.\n" + "4.STRUCTURE: Question \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' "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' "7.Extract concrete details from the video metadata: " "dates, names, places, statistics, historical context.\n" "8.Include origin stories \u2014 explain HOW something started, " @@ -786,13 +798,16 @@ def _user_prompt_metadata(combined_content: str, tone: str = "opinionated") -> s "- PROIBIDO: Linguagem dram\u00e1tica, opini\u00f5es, clickbait.\n" ), }.get(tone, ( - "- TOM: Dram\u00e1tico, como contando uma fofoca para um amigo. " - "NUNCA pare uma Wikipedia ou release corporativo.\n" - "- PRIMEIRA FRASE: Um gancho que prende aten\u00e7\u00e3o \u2014 " - "uma afirma\u00e7\u00e3o ousada, um fato chocante. N\u00c3O uma data seca.\n" - "- ESTRUTURA: Gancho \u2192 Contexto \u2192 A Treta \u2192 Final impactante\n" + "- TOM: Curiosidade, narrativa envolvente. " + "Conte como quem revela um fato fascinante \u2014 " + "NUNCA como Wikipedia ou release corporativo.\n" + "- PRIMEIRA FRASE: Uma pergunta instigante que desperta curiosidade. " + "N\u00c3O uma data seca nem um exagero chocante.\n" + "- ESTRUTURA: Pergunta \u2192 Contexto \u2192 Revela\u00e7\u00e3o \u2192 Conclus\u00e3o forte\n" "- PROIBIDO: Nomes jur\u00eddicos (Ltda, S.A.), endere\u00e7os, siglas. " "NADA de linguagem corporativa.\n" + "- PROIBIDO: Hip\u00e9rboles, exageros, 'desenhada por um deus', " + "'voc\u00ea n\u00e3o vai acreditar' \u2014 soa falso.\n" )) return ( "Crie uma hist\u00f3ria envolvente em 4-5 par\u00e1grafos baseada " From 2507a68b65960e17cb222127febaa7164b998927 Mon Sep 17 00:00:00 2001 From: mdev34-lab <117395510+mdev34-lab@users.noreply.github.com> Date: Mon, 25 May 2026 22:54:48 -0300 Subject: [PATCH 16/23] prompts: first sentence drops viewer into action, no setup waste --- src/autoshorts/modules/script_generator.py | 40 ++++++++++++++-------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/src/autoshorts/modules/script_generator.py b/src/autoshorts/modules/script_generator.py index 26f3ebc..a63c9ab 100644 --- a/src/autoshorts/modules/script_generator.py +++ b/src/autoshorts/modules/script_generator.py @@ -43,9 +43,11 @@ def _tone_instructions(self) -> str: "TONE: Curiosity-driven, narrative, engaging. " "Write like a storyteller uncovering a fascinating truth \u2014 " "never like Wikipedia or a corporate press release.\n" - "FIRST SENTENCE: An intriguing question that sparks curiosity. " - "NOT a dry date or a shocking exaggeration.\n" - "STRUCTURE: Question \u2192 Context \u2192 Revelation \u2192 Strong conclusion\n" + "FIRST SENTENCE: Drop the viewer right into the action \u2014 " + "the goal, the controversy, the fact itself. " + "NO: 'Prepare-se', 'Voc\u00ea sabia', 'Uma pergunta', rhetorical questions. " + "YES: 'O Corinthians tomou uma virada hist\u00f3rica...'\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', " @@ -702,9 +704,11 @@ def _generate_script_with_prompts_single(self, subject: str) -> tuple: "2.TONE: Curiosity-driven, narrative, engaging. " "Write like a storyteller uncovering a fascinating truth \u2014 " "never like Wikipedia or a corporate press release.\n" - "3.FIRST SENTENCE: An intriguing question that sparks curiosity. " - "NOT a dry date or a shocking exaggeration.\n" - "4.STRUCTURE: Question \u2192 Context \u2192 Revelation \u2192 Strong conclusion\n" + "3.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" + "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' "6.FORBIDDEN: Legal names (Ltda, S.A.), addresses, city/state abbreviations. " @@ -734,9 +738,11 @@ def _user_prompt_single(subject: str, search_context: str, tone: str = "opiniona "- TOM: Curiosidade, narrativa envolvente. " "Conte como quem revela um fato fascinante \u2014 " "NUNCA como Wikipedia ou release corporativo.\n" - "- PRIMEIRA FRASE: Uma pergunta instigante que desperta curiosidade. " - "N\u00c3O uma data seca nem um exagero chocante.\n" - "- ESTRUTURA: Pergunta \u2192 Contexto \u2192 Revela\u00e7\u00e3o \u2192 Conclus\u00e3o forte\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. " "NADA de linguagem corporativa.\n" "- PROIBIDO: Hip\u00e9rboles, exageros, 'desenhada por um deus', " @@ -767,9 +773,11 @@ def _user_prompt_single(subject: str, search_context: str, tone: str = "opiniona "2.TONE: Curiosity-driven, narrative, engaging. " "Write like a storyteller uncovering a fascinating truth \u2014 " "never like Wikipedia or a corporate press release.\n" - "3.FIRST SENTENCE: An intriguing question that sparks curiosity. " - "NOT a dry fact or a shocking exaggeration.\n" - "4.STRUCTURE: Question \u2192 Context \u2192 Revelation \u2192 Strong conclusion\n" + "3.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" + "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' "6.FORBIDDEN: Legal names (Ltda, S.A.), addresses, city/state abbreviations. " @@ -801,9 +809,11 @@ def _user_prompt_metadata(combined_content: str, tone: str = "opinionated") -> s "- TOM: Curiosidade, narrativa envolvente. " "Conte como quem revela um fato fascinante \u2014 " "NUNCA como Wikipedia ou release corporativo.\n" - "- PRIMEIRA FRASE: Uma pergunta instigante que desperta curiosidade. " - "N\u00c3O uma data seca nem um exagero chocante.\n" - "- ESTRUTURA: Pergunta \u2192 Contexto \u2192 Revela\u00e7\u00e3o \u2192 Conclus\u00e3o forte\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. " "NADA de linguagem corporativa.\n" "- PROIBIDO: Hip\u00e9rboles, exageros, 'desenhada por um deus', " From 30f185da85485ceb78e1d5f1d6c42c4b66d4108d Mon Sep 17 00:00:00 2001 From: mdev34-lab <117395510+mdev34-lab@users.noreply.github.com> Date: Mon, 25 May 2026 23:01:49 -0300 Subject: [PATCH 17/23] fix: fallback to original paragraphs when verification API returns fewer than 3 --- src/autoshorts/modules/script_generator.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/autoshorts/modules/script_generator.py b/src/autoshorts/modules/script_generator.py index a63c9ab..aa63e92 100644 --- a/src/autoshorts/modules/script_generator.py +++ b/src/autoshorts/modules/script_generator.py @@ -386,7 +386,11 @@ def _verify_factual_claims(self, paragraphs: list, subject: str) -> list: corrected = self._ensure_paragraph_count(corrected, len(paragraphs)) elif is_verified: log("Fact verification: all claims match sources", "SUCCESS") - return corrected if corrected else paragraphs + if corrected and len(corrected) >= 3: + return corrected + if paragraphs and len(paragraphs) >= 3: + return paragraphs + return corrected or paragraphs except Exception as e: log(f"Fact verification failed: {e}", "WARNING") return paragraphs From 891346445c5495d914b8dc016334082933ef20ef Mon Sep 17 00:00:00 2001 From: mdev34-lab <117395510+mdev34-lab@users.noreply.github.com> Date: Mon, 25 May 2026 23:10:30 -0300 Subject: [PATCH 18/23] verify: retry API with corrective feedback when paragraph count is wrong --- src/autoshorts/modules/script_generator.py | 64 ++++++++++++++-------- tests/test_script_generator.py | 4 +- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/src/autoshorts/modules/script_generator.py b/src/autoshorts/modules/script_generator.py index aa63e92..d008ecb 100644 --- a/src/autoshorts/modules/script_generator.py +++ b/src/autoshorts/modules/script_generator.py @@ -367,33 +367,51 @@ def _verify_factual_claims(self, paragraphs: list, subject: str) -> list: "Cross-check every date, number, score, name, and factual claim. " "Output corrected paragraphs." ) - try: - data = self._make_json_api_call(system_prompt, user_prompt) - corrected = data.get("paragraphs") or [] - corrections = data.get("corrections") or [] - is_verified = data.get("verified", False) - if corrections: + target_count = len(paragraphs) + prev_count = 0 + for attempt in range(2): + try: + prompt = user_prompt + if attempt == 1 and prev_count != target_count: + prompt += ( + f"\n\nCORREÇÃO: Na tentativa anterior você retornou " + f"{prev_count} parágrafos, " + f"mas o script original tem {target_count}. " + f"Retorne EXATAMENTE {target_count} parágrafos. " + f"Não mescle, não remova, não junte parágrafos. " + f"Apenas corrija erros factuais mantendo a estrutura original." + ) + data = self._make_json_api_call(system_prompt, prompt) + corrected = data.get("paragraphs") or [] + prev_count = len(corrected) + corrections = data.get("corrections") or [] + is_verified = data.get("verified", False) + if corrections: + log( + f"Fact verification: {len(corrections)} corrections applied", + "WARNING", + ) + for c in corrections: + 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"Fact verification: {len(corrections)} corrections applied", + f"Verification returned {len(corrected)} paragraphs, need >= 3, retrying...", "WARNING", ) - for c in corrections: - log( - f" '{c.get('claim', '?')}' -> '{c.get('correction', '?')}'", - "INFO", - ) - corrected = self._validate_paragraphs(corrected) - corrected = self._ensure_paragraph_count(corrected, len(paragraphs)) - elif is_verified: - log("Fact verification: all claims match sources", "SUCCESS") - if corrected and len(corrected) >= 3: - return corrected - if paragraphs and len(paragraphs) >= 3: - return paragraphs - return corrected or paragraphs - except Exception as e: - log(f"Fact verification failed: {e}", "WARNING") + except Exception as e: + log(f"Fact verification attempt {attempt + 1} failed: {e}", "WARNING") + corrected = [] + if paragraphs and len(paragraphs) >= 3: return paragraphs + return paragraphs # ── Title generation ───────────────────────────────────────────────── diff --git a/tests/test_script_generator.py b/tests/test_script_generator.py index d52691d..d7f0fdb 100644 --- a/tests/test_script_generator.py +++ b/tests/test_script_generator.py @@ -593,7 +593,7 @@ def test_generate_script_with_search_results(self, mock_post, mock_searcher_clas assert "primeiro" in result[0].lower() assert mock_searcher.search_with_queries.call_count == 2 assert mock_searcher.format_context.call_count == 2 - assert mock_post.call_count == 4 + assert mock_post.call_count == 5 @patch("autoshorts.modules.script_generator.WebSearcher") @patch("autoshorts.modules.script_generator.requests.post") @@ -664,7 +664,7 @@ def test_generate_script_with_prompts_web_search_success( assert len(paragraphs) == 7 assert prompts == [] assert mock_searcher.format_context.call_count == 2 - assert mock_post.call_count == 4 + assert mock_post.call_count == 5 @patch("autoshorts.modules.script_generator.WebSearcher") @patch("autoshorts.modules.script_generator.requests.post") From 6e7d70e769791417fcf09bda8286442249d21fe5 Mon Sep 17 00:00:00 2001 From: mdev34-lab <117395510+mdev34-lab@users.noreply.github.com> Date: Tue, 26 May 2026 13:04:14 -0300 Subject: [PATCH 19/23] title: validate hashtag count, length, and case with retry feedback --- src/autoshorts/modules/script_generator.py | 53 +++++++++++++++++----- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/src/autoshorts/modules/script_generator.py b/src/autoshorts/modules/script_generator.py index d008ecb..6402c67 100644 --- a/src/autoshorts/modules/script_generator.py +++ b/src/autoshorts/modules/script_generator.py @@ -1,4 +1,6 @@ import json +import re +import time import requests # type: ignore[import-untyped] @@ -282,8 +284,6 @@ def _generate_search_queries(self, subject: str) -> list[str]: def _verify_factual_claims(self, paragraphs: list, subject: str) -> list: """Cross-check dates, scores, tabus, and numbers in script against web search results.""" - import re - script_text = " ".join(paragraphs) verification_queries: list[str] = [] @@ -415,6 +415,22 @@ def _verify_factual_claims(self, paragraphs: list, subject: str) -> list: # ── 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") + elif len(hashtags) < 3: + issues.append(f"tem apenas {len(hashtags)} hashtags (m\u00ednimo 3)") + if len(title) > 100: + issues.append(f"tem {len(title)} caracteres (m\u00e1ximo 100)") + return len(issues) == 0, issues + + @staticmethod + def _fix_hashtags_case(title: str) -> str: + return re.sub(r"#([A-Z][A-Za-z0-9]*)", lambda m: f"#{m.group(1).lower()}", title) + def _generate_title_from_script( self, paragraphs: list, subject: str ) -> str | None: @@ -422,26 +438,40 @@ def _generate_title_from_script( script_text = " ".join(paragraphs)[:500] system_prompt = ( "Output ONLY a JSON object with one key: 'title'.\n" - "Title must be in PT-BR, max 60 characters, YouTube Shorts title.\n" + "Title must be in PT-BR, max 100 characters, YouTube Shorts title.\n" "UPPERCASE RULES:\n" "- Use UPPERCASE for key words or short sections of the title text itself.\n" "- Example: 'A VERDADE sobre o Caso Girabank' (title before tags).\n" "- Do NOT put tags in uppercase.\n\n" "TAGS RULES:\n" - "- Append 1-3 lowercase tags at the end, no spaces between words.\n" + "- Append EXACTLY 3-4 lowercase tags at the end, no spaces between words.\n" "- Tags MUST be SPECIFIC to the video subject, not generic categories.\n" - "- Example tags for a Mario game: '#supermario #nintendo #galaxy'\n" + "- Example tags for Mario: '#supermario #nintendo #galaxy'\n" "- Example tags for Carlinhos Maia: '#carlinhosmaia #girabank'\n" - "- NEVER tag unrelated topics like #futebol for a movie or #cinema for a bank story.\n\n" + "- NEVER tag unrelated topics like #futebol for a movie.\n\n" "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}" - try: - data = self._make_json_api_call(system_prompt, user_prompt) - return data.get("title") - except Exception: - return None + 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. " + "Siga as regras: max 100 chars, 3-4 hashtags em lowercase, " + "nada de hashtags gen\u00e9ricas." + ) + data = self._make_json_api_call(system_prompt, prompt) + title = data.get("title") or "" + title = self._fix_hashtags_case(title) + ok, issues = self._validate_title(title) + if ok: + return title + log(f"Title validation: {', '.join(issues)}, retrying...", "WARNING") + except Exception: + return None + 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.""" @@ -500,7 +530,6 @@ def _repair_paragraphs(self, good: list, subject: str, target: int) -> list: def _make_json_api_call(self, system_prompt: str, user_prompt: str) -> dict: """Make API call expecting JSON response. Retries once on empty content.""" - import time headers = { "Authorization": f"Bearer {self.api_key}", From a55542eac21ca7a72221781f7260dd9fdd419f05 Mon Sep 17 00:00:00 2001 From: mdev34-lab <117395510+mdev34-lab@users.noreply.github.com> Date: Tue, 26 May 2026 13:27:15 -0300 Subject: [PATCH 20/23] fix: hashtag case regex now matches tags starting with lowercase too --- src/autoshorts/modules/script_generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/autoshorts/modules/script_generator.py b/src/autoshorts/modules/script_generator.py index 6402c67..926e7cd 100644 --- a/src/autoshorts/modules/script_generator.py +++ b/src/autoshorts/modules/script_generator.py @@ -429,7 +429,7 @@ def _validate_title(title: str) -> tuple[bool, list[str]]: @staticmethod def _fix_hashtags_case(title: str) -> str: - return re.sub(r"#([A-Z][A-Za-z0-9]*)", lambda m: f"#{m.group(1).lower()}", title) + return re.sub(r"#(\w+)", lambda m: f"#{m.group(1).lower()}", title) def _generate_title_from_script( self, paragraphs: list, subject: str From 3dff4593a327d6e51fa96863254f8d010f61f3dc Mon Sep 17 00:00:00 2001 From: mdev34-lab <117395510+mdev34-lab@users.noreply.github.com> Date: Tue, 26 May 2026 13:47:34 -0300 Subject: [PATCH 21/23] fix: whole-word NSFW filter, remove over-broad tokens (cd, adult, bikini, lingerie) --- src/autoshorts/modules/image_searcher.py | 34 +++++++++++++----------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/autoshorts/modules/image_searcher.py b/src/autoshorts/modules/image_searcher.py index 24f4e93..6e1dd7c 100644 --- a/src/autoshorts/modules/image_searcher.py +++ b/src/autoshorts/modules/image_searcher.py @@ -1,5 +1,6 @@ import hashlib import random +import re from pathlib import Path from urllib.parse import quote @@ -21,16 +22,16 @@ ) from .logging_system import log -BLOCKED_DOMAINS = { - "crossdresser", "cd", "sissy", "femboy", "hentai", "rule34", - "porn", "xvideos", "xnxx", "xhamster", "pornhub", "onlyfans", - "redtube", "youporn", "adult", "sex", "erotic", "nsfw", +BLOCKED_DOMAINS: set[str] = { + "crossdresser", "sissy", "femboy", "hentai", "rule34", + "xvideos", "xnxx", "xhamster", "pornhub", "onlyfans", + "redtube", "youporn", "erotic", "nsfw", } -BLOCKED_KEYWORDS = { +BLOCKED_KEYWORDS: set[str] = { "crossdresser", "sissy", "femboy", "hentai", "rule34", - "porn", "nsfw", "xxx", "18+", "adult", "sex", "erotic", - "nude", "naked", "lingerie", "bikini", "seductive", + "nsfw", "xxx", "18+", "erotic", + "nude", "naked", "seductive", } @@ -54,14 +55,17 @@ def __init__( @staticmethod def _is_nsfw(result: dict) -> bool: - url = (result.get("image") or "").lower() - source = (result.get("url") or result.get("source") or "").lower() - title = (result.get("title") or "").lower() - combined = f"{url} {source} {title}" - if any(d in combined for d in BLOCKED_DOMAINS): - return True - if any(kw in combined for kw in BLOCKED_KEYWORDS): - return True + text = ( + f"{result.get('image') or ''} " + f"{result.get('url') or result.get('source') or ''} " + f"{result.get('title') or ''}" + ) + for d in BLOCKED_DOMAINS: + if re.search(rf"(?:^|[\W_]){re.escape(d)}(?:$|[\W_])", text, re.IGNORECASE): + return True + for kw in BLOCKED_KEYWORDS: + if re.search(rf"(?:^|[\W_]){re.escape(kw)}(?:$|[\W_])", text, re.IGNORECASE): + return True return False def search_images(self, query: str) -> list[dict]: From 152006fc9ff05fe60f4d41cfe5331dcf5545f263 Mon Sep 17 00:00:00 2001 From: mdev34-lab <117395510+mdev34-lab@users.noreply.github.com> Date: Tue, 26 May 2026 13:51:13 -0300 Subject: [PATCH 22/23] docs: update README to match current CLI flags, features, and modules --- README.md | 55 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index af89cf3..f444d4d 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,17 @@ AI-powered tool for generating YouTube Shorts / TikTok videos with script genera ## Features -- **AI Script Generation** — viral-optimized scripts in Brazilian Portuguese via Pollinations AI -- **AI Image Generation** — background images via Pollinations AI (images-only mode) +- **AI Script Generation** — curiosity-driven scripts in Brazilian Portuguese via Pollinations AI with fact verification and hallucination guards +- **Web-Grounded Scripts** — automatic web search generates independent queries, grounds the script in real sources, then cross-checks every claim +- **Title Validation** — auto-validates hashtag count (3+), length (≤ 100 chars), and lowercases tags +- **AI Image Generation** — background images via Pollinations AI or **real web images** via DuckDuckGo search (default), with NSFW domain/keyword filter - **Text-to-Speech** — natural audio via Edge TTS - **Subtitle System** — VTT generation + word-level highlight rendering -- **Video Composition** — blurred YouTube background or AI images with smooth overlay animation +- **Video Composition** — blurred YouTube background or AI/web images with smooth overlay animation - **YouTube Integration** — download any video as background footage -- **Two pipelines**: normal (YouTube bg + optional AI image overlays) and images-only (AI images + overlay animation, no YouTube bg) +- **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 ## Installation @@ -37,7 +40,7 @@ uv pip install -e ".[dev]" # See available commands autoshorts --help -# Generate an explainer video from a topic +# Generate an explainer video from a topic (web search + web images by default) autoshorts new explainer "artificial intelligence" # AI images only (no YouTube background) @@ -46,14 +49,20 @@ autoshorts new explainer "space exploration" --images-only # Use a YouTube video as background footage autoshorts new explainer --youtube-url "https://youtube.com/watch?v=VIDEO_ID" -# Skip AI image overlays (blurred bg only) +# Skip image overlays (blurred bg only) autoshorts new explainer "climate change" --no-images -# Batch mode -autoshorts new explainer --batch "robotics" "quantum computing" "neural networks" +# Batch mode with semicolon-separated subjects +autoshorts new explainer --batch "robotics; quantum computing; neural networks" -# Web search for richer script content -autoshorts new explainer "oceanography" --web-search +# Image source: 'ai' uses Pollinations (default is 'web' via DDGS) +autoshorts new explainer "oceanography" --images ai + +# Script tone: 'corporate' (neutral, factual) or 'opinionated' (curiosity-driven, narrative) +autoshorts new explainer "bitcoin" --tone corporate + +# Disable web search (uses model knowledge only) +autoshorts new explainer "neural networks" --no-web-search # Auto-shutdown after completion autoshorts new explainer "future technology" --goodnight @@ -102,23 +111,29 @@ AutoShorts/ │ │ └── explainer.py # ExplainerGenerator (both pipelines) │ └── modules/ # Core modules │ ├── config.py +│ ├── image_searcher.py # Web/AI image search + NSFW filter │ ├── logging_system.py -│ ├── script_generator.py +│ ├── script_generator.py # Script gen, fact verification, title validation │ ├── subtitle_system.py │ ├── tts_system.py │ ├── utils.py -│ ├── video_background.py -│ └── video_compositor.py +│ ├── video_background.py # YouTube search & download +│ ├── video_compositor.py +│ └── web_search.py # DuckDuckGo web search ├── tests/ -│ ├── test_cli.py # CLI layer (28 tests) +│ ├── test_cli.py # CLI layer (32 tests) +│ ├── test_config.py +│ ├── test_edge_cases.py │ ├── test_fluximages.py # Explainer generator tests -│ ├── test_video_background.py # Video background (24 tests) -│ ├── test_video_compositor.py # Video compositor (11 tests) +│ ├── test_init.py +│ ├── test_integration.py │ ├── test_script_generator.py │ ├── test_subtitle_system.py -│ ├── test_utils.py -│ ├── test_edge_cases.py │ ├── test_tts_system.py +│ ├── test_utils.py +│ ├── test_video_background.py # Video background (24 tests) +│ ├── test_video_compositor.py # Video compositor (11 tests) +│ ├── test_web_search.py │ └── conftest.py ├── fonts/ # Bundled Bebas Neue font ├── .env.example @@ -134,13 +149,15 @@ Core: - `edge-tts` — text-to-speech - `requests` — HTTP client - `yt-dlp` — YouTube downloading +- `duckduckgo-search` — web search and image search (DDGS) +- `Pillow` — image processing and resizing - `webvtt-py` — subtitle processing - `python-dotenv` — environment loading - `typer` — CLI framework Dev: - `pytest` + `pytest-asyncio` + `pytest-cov` -- `black` + `ruff` + `mypy` +- `ruff` + `mypy` ## Development From 744ef0518197eb9c3bb981ce47bf36efbce27de5 Mon Sep 17 00:00:00 2001 From: mdev34-lab <117395510+mdev34-lab@users.noreply.github.com> Date: Tue, 26 May 2026 13:58:50 -0300 Subject: [PATCH 23/23] remove --tone flag and corporate mode; keep only opinionated/curiosity-driven style --- src/autoshorts/cli/commands/explainer.py | 8 ---- src/autoshorts/generators/explainer.py | 4 +- src/autoshorts/modules/script_generator.py | 54 ++++++++++------------ 3 files changed, 25 insertions(+), 41 deletions(-) diff --git a/src/autoshorts/cli/commands/explainer.py b/src/autoshorts/cli/commands/explainer.py index 4d3020c..046871e 100644 --- a/src/autoshorts/cli/commands/explainer.py +++ b/src/autoshorts/cli/commands/explainer.py @@ -35,11 +35,6 @@ def explainer_command( "--images", help="Image source: 'web' (DDGS search) or 'ai' (Pollinations)", ), - tone: str = typer.Option( - "opinionated", - "--tone", - help="Script tone: 'corporate' (neutral, factual) or 'opinionated' (dramatic, viral)", - ), ): if no_images and images_only: raise typer.BadParameter("--no-images and --images-only are mutually exclusive") @@ -49,8 +44,6 @@ def explainer_command( raise typer.BadParameter("subject, --youtube-url, or --batch is required") if images not in ("web", "ai"): raise typer.BadParameter("--images must be 'web' or 'ai'") - if tone not in ("corporate", "opinionated"): - raise typer.BadParameter("--tone must be 'corporate' or 'opinionated'") subjects: list[str | None] = [] if batch: @@ -95,7 +88,6 @@ def _sanitize(s): return re.sub(r'[\\/*?:"<>|]', "", s).replace(" ", "_")[:20] no_images=no_images or images_only, images_only=images_only, image_source=images, - tone=tone, ) success = asyncio.run(gen.generate()) if success: diff --git a/src/autoshorts/generators/explainer.py b/src/autoshorts/generators/explainer.py index 6832e04..5b2cb1f 100644 --- a/src/autoshorts/generators/explainer.py +++ b/src/autoshorts/generators/explainer.py @@ -60,7 +60,6 @@ def __init__( no_images: bool = False, images_only: bool = False, image_source: str = "web", - tone: str = "opinionated", ): self.subject = subject self.output = output @@ -69,9 +68,8 @@ def __init__( self.no_images = no_images self.images_only = images_only self.image_source = image_source - self.tone = tone - self.script_generator = ScriptGenerator(web_search=web_search, tone=tone) + self.script_generator = ScriptGenerator(web_search=web_search) self.tts_system = TTSSystem() self.temp_dir = create_temp_dir() diff --git a/src/autoshorts/modules/script_generator.py b/src/autoshorts/modules/script_generator.py index 926e7cd..7c724a7 100644 --- a/src/autoshorts/modules/script_generator.py +++ b/src/autoshorts/modules/script_generator.py @@ -24,23 +24,29 @@ class ScriptGenerator: Step 2 — search the web, then generate the final script grounded in results """ - def __init__(self, web_search: bool = True, tone: str = "opinionated"): + def __init__(self, web_search: bool = True): self.api_url = API_URL self.api_key = API_KEY self.model = MODEL_TEXT self.web_search = web_search - self.tone = tone self.searcher = WebSearcher() if web_search else None self.generated_title: str | None = None def _tone_instructions(self) -> str: - if self.tone == "corporate": - return ( - "TONE: Neutral, informative, journalistic. Present facts clearly.\n" - "STRUCTURE: Start with a specific fact (date, number), then explain context, " - "then details, then conclusion.\n" - "FORBIDDEN: Clickbait, dramatic language, opinions, rhetorical questions.\n" - ) + return ( + "TONE: Curiosity-driven, narrative, engaging. " + "Write like a storyteller uncovering a fascinating truth \u2014 " + "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" + ) return ( "TONE: Curiosity-driven, narrative, engaging. " "Write like a storyteller uncovering a fascinating truth \u2014 " @@ -67,7 +73,7 @@ def generate_script(self, subject: str) -> list: if not self.web_search or not self.searcher or not subject: return self._make_text_api_call( tone_block + _SYSTEM_PROMPT_SINGLE, - _user_prompt_single(subject, "", self.tone), + _user_prompt_single(subject, ""), ) # Step 1: generate independent search queries (NOT from draft — avoids circular hallucination) @@ -83,7 +89,7 @@ def generate_script(self, subject: str) -> list: log("Step 2: generating script with search context...") script = self._make_text_api_call( tone_block + _SYSTEM_PROMPT_SINGLE, - _user_prompt_single(subject, context, self.tone), + _user_prompt_single(subject, context), ) cleaned = self._validate_paragraphs(script) script = self._ensure_paragraph_count(cleaned, 5) @@ -130,7 +136,7 @@ def generate_script_from_metadata(self, title: str, description: str) -> list: tone_block = self._tone_instructions() return self._make_text_api_call( tone_block + _SYSTEM_PROMPT_METADATA, - _user_prompt_metadata(combined_content, self.tone), + _user_prompt_metadata(combined_content), ) def generate_script_with_prompts(self, subject: str) -> tuple: @@ -778,14 +784,8 @@ def _generate_script_with_prompts_single(self, subject: str) -> tuple: ) -def _user_prompt_single(subject: str, search_context: str, tone: str = "opinionated") -> str: - tone_rules = { - "corporate": ( - "- TOM: Neutro, informativo, jornal\u00edstico. Apresente fatos com clareza.\n" - "- ESTRUTURA: Comece com um fato espec\u00edfico (data, n\u00famero), depois contexto, detalhes, conclus\u00e3o.\n" - "- PROIBIDO: Linguagem dram\u00e1tica, opini\u00f5es, perguntas ret\u00f3ricas, clickbait.\n" - ), - }.get(tone, ( +def _user_prompt_single(subject: str, search_context: str) -> str: + tone_rules = ( "- TOM: Curiosidade, narrativa envolvente. " "Conte como quem revela um fato fascinante \u2014 " "NUNCA como Wikipedia ou release corporativo.\n" @@ -798,7 +798,7 @@ def _user_prompt_single(subject: str, search_context: str, tone: str = "opiniona "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" - )) + ) return ( f'Crie uma hist\u00f3ria envolvente em 4-5 par\u00e1grafos sobre "{subject}".\n\n' f"{search_context}\n\n" @@ -849,14 +849,8 @@ def _user_prompt_single(subject: str, search_context: str, tone: str = "opiniona ) -def _user_prompt_metadata(combined_content: str, tone: str = "opinionated") -> str: - tone_rules = { - "corporate": ( - "- TOM: Neutro, informativo, jornal\u00edstico. Apresente fatos com clareza.\n" - "- ESTRUTURA: Comece com um fato espec\u00edfico, depois contexto, detalhes, conclus\u00e3o.\n" - "- PROIBIDO: Linguagem dram\u00e1tica, opini\u00f5es, clickbait.\n" - ), - }.get(tone, ( +def _user_prompt_metadata(combined_content: str) -> str: + tone_rules = ( "- TOM: Curiosidade, narrativa envolvente. " "Conte como quem revela um fato fascinante \u2014 " "NUNCA como Wikipedia ou release corporativo.\n" @@ -869,7 +863,7 @@ def _user_prompt_metadata(combined_content: str, tone: str = "opinionated") -> s "NADA de linguagem corporativa.\n" "- PROIBIDO: Hip\u00e9rboles, exageros, 'desenhada por um deus', " "'voc\u00ea n\u00e3o vai acreditar' \u2014 soa falso.\n" - )) + ) return ( "Crie uma hist\u00f3ria envolvente em 4-5 par\u00e1grafos baseada " "neste v\u00eddeo do YouTube.\n\n"