""" Narration generation service Supports two content sources: 1. Topic: Generate narrations from a topic/theme 2. Content: Extract/refine narrations from user-provided content """ import json import re from typing import List, Optional, Literal from loguru import logger from reelforge.models.storyboard import StoryboardConfig, ContentMetadata from reelforge.prompts import ( build_topic_narration_prompt, build_content_narration_prompt, ) class NarrationGeneratorService: """Narration generation service""" def __init__(self, reelforge_core): """ Initialize Args: reelforge_core: ReelForgeCore instance (for calling llm) """ self.core = reelforge_core async def generate_narrations( self, config: StoryboardConfig, source_type: Literal["topic", "content"], content_metadata: Optional[ContentMetadata] = None, topic: Optional[str] = None, content: Optional[str] = None, ) -> List[str]: """ Generate storyboard narrations from different sources Args: config: Storyboard configuration source_type: Type of content source ("topic" or "content") content_metadata: Content metadata (optional, not currently used) topic: Topic/theme (required if source_type="topic") content: User-provided content (required if source_type="content") Returns: List of narration texts Raises: ValueError: If parameters don't match source_type or narration count mismatch json.JSONDecodeError: If unable to parse LLM response as JSON Examples: # Generate from topic >>> narrations = await service.generate_narrations( ... config=config, ... source_type="topic", ... topic="如何提高学习效率" ... ) # Generate from user content >>> narrations = await service.generate_narrations( ... config=config, ... source_type="content", ... content="Today I want to share three useful tips..." ... ) """ # 1. Build prompt based on source_type if source_type == "topic": if topic is None: raise ValueError("topic is required when source_type='topic'") logger.info(f"Generating topic narrations for: {topic}") prompt = build_topic_narration_prompt( topic=topic, n_storyboard=config.n_storyboard, min_words=config.min_narration_words, max_words=config.max_narration_words ) else: # content if content is None: raise ValueError("content is required when source_type='content'") logger.info(f"Generating narrations from user content ({len(content)} chars)") prompt = build_content_narration_prompt( content=content, n_storyboard=config.n_storyboard, min_words=config.min_narration_words, max_words=config.max_narration_words ) # 2. Call LLM (using self.core.llm) response = await self.core.llm( prompt=prompt, temperature=0.8, # Higher temperature for more creativity max_tokens=2000 ) logger.debug(f"LLM response: {response[:200]}...") # 3. Parse JSON try: result = self._parse_json(response) narrations = result["narrations"] except json.JSONDecodeError as e: logger.error(f"Failed to parse LLM response as JSON: {e}") logger.error(f"Response: {response}") raise except KeyError: logger.error("Response JSON missing 'narrations' key") logger.error(f"Response: {response}") raise ValueError("Invalid response format") # 4. Validate count (take first N if got more) if len(narrations) > config.n_storyboard: logger.warning( f"Got {len(narrations)} narrations, taking first {config.n_storyboard}" ) narrations = narrations[:config.n_storyboard] elif len(narrations) < config.n_storyboard: raise ValueError( f"Expected at least {config.n_storyboard} narrations, " f"got only {len(narrations)}" ) # 5. Validate word count for each narration for i, text in enumerate(narrations): word_count = len(text) if word_count < config.min_narration_words: logger.warning( f"Narration {i} too short: {word_count} chars " f"(min: {config.min_narration_words})" ) logger.info(f"Generated {len(narrations)} narrations successfully") return narrations def _parse_json(self, text: str) -> dict: """ Parse JSON from text, with fallback to extract JSON from markdown code blocks Args: text: Text containing JSON Returns: Parsed JSON dict """ # Try direct parsing first try: return json.loads(text) except json.JSONDecodeError: pass # Try to extract JSON from markdown code block json_pattern = r'```(?:json)?\s*([\s\S]+?)\s*```' match = re.search(json_pattern, text, re.DOTALL) if match: try: return json.loads(match.group(1)) except json.JSONDecodeError: pass # Try to find any JSON object in the text json_pattern = r'\{[^{}]*"narrations"\s*:\s*\[[^\]]*\][^{}]*\}' match = re.search(json_pattern, text, re.DOTALL) if match: try: return json.loads(match.group(0)) except json.JSONDecodeError: pass # If all fails, raise error raise json.JSONDecodeError("No valid JSON found", text, 0)