story writer backend migration complete, Blog writer SEO and story writer backend migration complete, Blog writer SEO and story writer frontend migration complete
This commit is contained in:
96
backend/services/story_writer/README.md
Normal file
96
backend/services/story_writer/README.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Story Writer Service
|
||||
|
||||
Story generation service using prompt chaining approach, migrated from `ToBeMigrated/ai_writers/ai_story_writer/`.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── services/
|
||||
│ └── story_writer/
|
||||
│ ├── __init__.py
|
||||
│ ├── story_service.py # Core story generation logic
|
||||
│ └── README.md
|
||||
├── api/
|
||||
│ └── story_writer/
|
||||
│ ├── __init__.py
|
||||
│ ├── router.py # API endpoints
|
||||
│ ├── task_manager.py # Async task management
|
||||
│ └── cache_manager.py # Result caching
|
||||
└── models/
|
||||
└── story_models.py # Pydantic models
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Prompt Chaining**: Generates stories through premise → outline → start → continuation
|
||||
- **Multiple Personas**: Supports 11 predefined author personas/genres
|
||||
- **Configurable Parameters**:
|
||||
- Story setting, characters, plot elements
|
||||
- Writing style, tone, narrative POV
|
||||
- Audience age group, content rating, ending preference
|
||||
- **Subscription Integration**: Automatic usage tracking via `main_text_generation`
|
||||
- **Provider Support**: Works with both Gemini and HuggingFace
|
||||
- **Async Task Management**: Long-running story generation with polling
|
||||
- **Caching**: Result caching for identical requests
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Synchronous Endpoints
|
||||
|
||||
- `POST /api/story/generate-premise` - Generate story premise
|
||||
- `POST /api/story/generate-outline` - Generate outline from premise
|
||||
- `POST /api/story/generate-start` - Generate story beginning
|
||||
- `POST /api/story/continue` - Continue story generation
|
||||
|
||||
### Asynchronous Endpoints
|
||||
|
||||
- `POST /api/story/generate-full` - Generate complete story (returns task_id)
|
||||
- `GET /api/story/task/{task_id}/status` - Get task status
|
||||
- `GET /api/story/task/{task_id}/result` - Get completed task result
|
||||
|
||||
### Cache Management
|
||||
|
||||
- `GET /api/story/cache/stats` - Get cache statistics
|
||||
- `POST /api/story/cache/clear` - Clear cache
|
||||
|
||||
## Usage Example
|
||||
|
||||
```python
|
||||
from services.story_writer.story_service import StoryWriterService
|
||||
|
||||
service = StoryWriterService()
|
||||
|
||||
# Generate full story
|
||||
result = service.generate_full_story(
|
||||
persona="Award-Winning Science Fiction Author",
|
||||
story_setting="A bustling futuristic city in 2150",
|
||||
character_input="John, a tall muscular man with a kind heart",
|
||||
plot_elements="The hero's journey, Good vs. evil",
|
||||
writing_style="Formal",
|
||||
story_tone="Suspenseful",
|
||||
narrative_pov="Third Person Limited",
|
||||
audience_age_group="Adults",
|
||||
content_rating="PG-13",
|
||||
ending_preference="Happy",
|
||||
user_id="clerk_user_id",
|
||||
max_iterations=10
|
||||
)
|
||||
|
||||
print(result["premise"])
|
||||
print(result["outline"])
|
||||
print(result["story"])
|
||||
```
|
||||
|
||||
## Migration Notes
|
||||
|
||||
- Updated imports from legacy `...gpt_providers.text_generation.main_text_generation` to `services.llm_providers.main_text_generation`
|
||||
- Added `user_id` parameter to all LLM calls for subscription support
|
||||
- Removed Streamlit dependencies (UI moved to frontend)
|
||||
- Added proper error handling with HTTPException support
|
||||
- Added async task management for long-running operations
|
||||
- Added caching support for identical requests
|
||||
|
||||
## Integration
|
||||
|
||||
The router is automatically registered via `alwrity_utils/router_manager.py` in the optional routers section.
|
||||
10
backend/services/story_writer/__init__.py
Normal file
10
backend/services/story_writer/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Story Writer Service
|
||||
|
||||
Provides story generation functionality using prompt chaining.
|
||||
Supports multiple personas, styles, and iterative story generation.
|
||||
"""
|
||||
|
||||
from .story_service import StoryWriterService
|
||||
|
||||
__all__ = ['StoryWriterService']
|
||||
291
backend/services/story_writer/audio_generation_service.py
Normal file
291
backend/services/story_writer/audio_generation_service.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
Audio Generation Service for Story Writer
|
||||
|
||||
Generates audio narration for story scenes using TTS (Text-to-Speech) providers.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from typing import List, Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
from loguru import logger
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
class StoryAudioGenerationService:
|
||||
"""Service for generating audio narration for story scenes."""
|
||||
|
||||
def __init__(self, output_dir: Optional[str] = None):
|
||||
"""
|
||||
Initialize the audio generation service.
|
||||
|
||||
Parameters:
|
||||
output_dir (str, optional): Directory to save generated audio files.
|
||||
Defaults to 'backend/story_audio' if not provided.
|
||||
"""
|
||||
if output_dir:
|
||||
self.output_dir = Path(output_dir)
|
||||
else:
|
||||
# Default to backend/story_audio directory
|
||||
base_dir = Path(__file__).parent.parent.parent
|
||||
self.output_dir = base_dir / "story_audio"
|
||||
|
||||
# Create output directory if it doesn't exist
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"[StoryAudioGeneration] Initialized with output directory: {self.output_dir}")
|
||||
|
||||
def _generate_audio_filename(self, scene_number: int, scene_title: str) -> str:
|
||||
"""Generate a unique filename for a scene audio file."""
|
||||
# Clean scene title for filename
|
||||
clean_title = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in scene_title[:30])
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
return f"scene_{scene_number}_{clean_title}_{unique_id}.mp3"
|
||||
|
||||
def _generate_audio_gtts(
|
||||
self,
|
||||
text: str,
|
||||
output_path: Path,
|
||||
lang: str = "en",
|
||||
slow: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Generate audio using Google Text-to-Speech (gTTS).
|
||||
|
||||
Parameters:
|
||||
text (str): Text to convert to speech.
|
||||
output_path (Path): Path to save the audio file.
|
||||
lang (str): Language code (default: "en").
|
||||
slow (bool): Whether to speak slowly (default: False).
|
||||
|
||||
Returns:
|
||||
bool: True if generation was successful, False otherwise.
|
||||
"""
|
||||
try:
|
||||
from gtts import gTTS
|
||||
|
||||
# Generate speech
|
||||
tts = gTTS(text=text, lang=lang, slow=slow)
|
||||
|
||||
# Save to file
|
||||
tts.save(str(output_path))
|
||||
|
||||
logger.info(f"[StoryAudioGeneration] Generated audio using gTTS: {output_path}")
|
||||
return True
|
||||
|
||||
except ImportError:
|
||||
logger.error("[StoryAudioGeneration] gTTS not installed. Install with: pip install gtts")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"[StoryAudioGeneration] Error generating audio with gTTS: {e}")
|
||||
return False
|
||||
|
||||
def _generate_audio_pyttsx3(
|
||||
self,
|
||||
text: str,
|
||||
output_path: Path,
|
||||
rate: int = 150,
|
||||
voice: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Generate audio using pyttsx3 (offline TTS).
|
||||
|
||||
Parameters:
|
||||
text (str): Text to convert to speech.
|
||||
output_path (Path): Path to save the audio file.
|
||||
rate (int): Speech rate (default: 150).
|
||||
voice (str, optional): Voice ID to use.
|
||||
|
||||
Returns:
|
||||
bool: True if generation was successful, False otherwise.
|
||||
"""
|
||||
try:
|
||||
import pyttsx3
|
||||
|
||||
# Initialize TTS engine
|
||||
engine = pyttsx3.init()
|
||||
|
||||
# Set speech rate
|
||||
engine.setProperty('rate', rate)
|
||||
|
||||
# Set voice if provided
|
||||
if voice:
|
||||
voices = engine.getProperty('voices')
|
||||
for v in voices:
|
||||
if voice in v.id:
|
||||
engine.setProperty('voice', v.id)
|
||||
break
|
||||
|
||||
# Generate speech and save to file
|
||||
engine.save_to_file(text, str(output_path))
|
||||
engine.runAndWait()
|
||||
|
||||
logger.info(f"[StoryAudioGeneration] Generated audio using pyttsx3: {output_path}")
|
||||
return True
|
||||
|
||||
except ImportError:
|
||||
logger.error("[StoryAudioGeneration] pyttsx3 not installed. Install with: pip install pyttsx3")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"[StoryAudioGeneration] Error generating audio with pyttsx3: {e}")
|
||||
return False
|
||||
|
||||
def generate_scene_audio(
|
||||
self,
|
||||
scene: Dict[str, Any],
|
||||
user_id: str,
|
||||
provider: str = "gtts",
|
||||
lang: str = "en",
|
||||
slow: bool = False,
|
||||
rate: int = 150
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate audio narration for a single story scene.
|
||||
|
||||
Parameters:
|
||||
scene (Dict[str, Any]): Scene data with audio_narration text.
|
||||
user_id (str): Clerk user ID for subscription checking (for future usage tracking).
|
||||
provider (str): TTS provider to use ("gtts", "pyttsx3", etc.).
|
||||
lang (str): Language code for TTS (default: "en").
|
||||
slow (bool): Whether to speak slowly (default: False, gTTS only).
|
||||
rate (int): Speech rate (default: 150, pyttsx3 only).
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Audio metadata including file path, URL, and scene info.
|
||||
"""
|
||||
scene_number = scene.get("scene_number", 0)
|
||||
scene_title = scene.get("title", "Untitled")
|
||||
audio_narration = scene.get("audio_narration", "")
|
||||
|
||||
if not audio_narration:
|
||||
raise ValueError(f"Scene {scene_number} ({scene_title}) has no audio_narration")
|
||||
|
||||
try:
|
||||
logger.info(f"[StoryAudioGeneration] Generating audio for scene {scene_number}: {scene_title}")
|
||||
logger.debug(f"[StoryAudioGeneration] Audio narration: {audio_narration[:100]}...")
|
||||
|
||||
# Generate audio filename
|
||||
audio_filename = self._generate_audio_filename(scene_number, scene_title)
|
||||
audio_path = self.output_dir / audio_filename
|
||||
|
||||
# Generate audio based on provider
|
||||
success = False
|
||||
if provider == "gtts":
|
||||
success = self._generate_audio_gtts(
|
||||
text=audio_narration,
|
||||
output_path=audio_path,
|
||||
lang=lang,
|
||||
slow=slow
|
||||
)
|
||||
elif provider == "pyttsx3":
|
||||
success = self._generate_audio_pyttsx3(
|
||||
text=audio_narration,
|
||||
output_path=audio_path,
|
||||
rate=rate
|
||||
)
|
||||
else:
|
||||
# Default to gTTS
|
||||
logger.warning(f"[StoryAudioGeneration] Unknown provider '{provider}', using gTTS")
|
||||
success = self._generate_audio_gtts(
|
||||
text=audio_narration,
|
||||
output_path=audio_path,
|
||||
lang=lang,
|
||||
slow=slow
|
||||
)
|
||||
|
||||
if not success or not audio_path.exists():
|
||||
raise RuntimeError(f"Failed to generate audio file: {audio_path}")
|
||||
|
||||
# Get file size
|
||||
file_size = audio_path.stat().st_size
|
||||
|
||||
logger.info(f"[StoryAudioGeneration] Saved audio to: {audio_path} ({file_size} bytes)")
|
||||
|
||||
# Return audio metadata
|
||||
return {
|
||||
"scene_number": scene_number,
|
||||
"scene_title": scene_title,
|
||||
"audio_path": str(audio_path),
|
||||
"audio_filename": audio_filename,
|
||||
"audio_url": f"/api/story/audio/{audio_filename}", # API endpoint to serve audio
|
||||
"provider": provider,
|
||||
"file_size": file_size,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTPExceptions (e.g., 429 subscription limit)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[StoryAudioGeneration] Error generating audio for scene {scene_number}: {e}")
|
||||
raise RuntimeError(f"Failed to generate audio for scene {scene_number}: {str(e)}") from e
|
||||
|
||||
def generate_scene_audio_list(
|
||||
self,
|
||||
scenes: List[Dict[str, Any]],
|
||||
user_id: str,
|
||||
provider: str = "gtts",
|
||||
lang: str = "en",
|
||||
slow: bool = False,
|
||||
rate: int = 150,
|
||||
progress_callback: Optional[callable] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Generate audio narration for multiple story scenes.
|
||||
|
||||
Parameters:
|
||||
scenes (List[Dict[str, Any]]): List of scene data with audio_narration text.
|
||||
user_id (str): Clerk user ID for subscription checking.
|
||||
provider (str): TTS provider to use ("gtts", "pyttsx3", etc.).
|
||||
lang (str): Language code for TTS (default: "en").
|
||||
slow (bool): Whether to speak slowly (default: False, gTTS only).
|
||||
rate (int): Speech rate (default: 150, pyttsx3 only).
|
||||
progress_callback (callable, optional): Callback function for progress updates.
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: List of audio metadata for each scene.
|
||||
"""
|
||||
if not scenes:
|
||||
raise ValueError("No scenes provided for audio generation")
|
||||
|
||||
logger.info(f"[StoryAudioGeneration] Generating audio for {len(scenes)} scenes")
|
||||
|
||||
audio_results = []
|
||||
total_scenes = len(scenes)
|
||||
|
||||
for idx, scene in enumerate(scenes):
|
||||
try:
|
||||
# Generate audio for scene
|
||||
audio_result = self.generate_scene_audio(
|
||||
scene=scene,
|
||||
user_id=user_id,
|
||||
provider=provider,
|
||||
lang=lang,
|
||||
slow=slow,
|
||||
rate=rate
|
||||
)
|
||||
|
||||
audio_results.append(audio_result)
|
||||
|
||||
# Call progress callback if provided
|
||||
if progress_callback:
|
||||
progress = ((idx + 1) / total_scenes) * 100
|
||||
progress_callback(progress, f"Generated audio for scene {scene.get('scene_number', idx + 1)}")
|
||||
|
||||
logger.info(f"[StoryAudioGeneration] Generated audio {idx + 1}/{total_scenes}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[StoryAudioGeneration] Failed to generate audio for scene {idx + 1}: {e}")
|
||||
# Continue with next scene instead of failing completely
|
||||
# Use empty strings for required fields instead of None
|
||||
audio_results.append({
|
||||
"scene_number": scene.get("scene_number", idx + 1),
|
||||
"scene_title": scene.get("title", "Untitled"),
|
||||
"audio_filename": "",
|
||||
"audio_url": "",
|
||||
"provider": provider,
|
||||
"file_size": 0,
|
||||
"error": str(e),
|
||||
})
|
||||
|
||||
logger.info(f"[StoryAudioGeneration] Generated {len(audio_results)} audio files out of {total_scenes} scenes")
|
||||
return audio_results
|
||||
|
||||
196
backend/services/story_writer/image_generation_service.py
Normal file
196
backend/services/story_writer/image_generation_service.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
Image Generation Service for Story Writer
|
||||
|
||||
Generates images for story scenes using the existing image generation service.
|
||||
"""
|
||||
|
||||
import os
|
||||
import base64
|
||||
import uuid
|
||||
from typing import List, Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
from fastapi import HTTPException
|
||||
|
||||
from services.llm_providers.main_image_generation import generate_image
|
||||
from services.llm_providers.image_generation import ImageGenerationResult
|
||||
from utils.logger_utils import get_service_logger
|
||||
|
||||
logger = get_service_logger("story_writer.image_generation")
|
||||
|
||||
|
||||
class StoryImageGenerationService:
|
||||
"""Service for generating images for story scenes."""
|
||||
|
||||
def __init__(self, output_dir: Optional[str] = None):
|
||||
"""
|
||||
Initialize the image generation service.
|
||||
|
||||
Parameters:
|
||||
output_dir (str, optional): Directory to save generated images.
|
||||
Defaults to 'backend/story_images' if not provided.
|
||||
"""
|
||||
if output_dir:
|
||||
self.output_dir = Path(output_dir)
|
||||
else:
|
||||
# Default to backend/story_images directory
|
||||
base_dir = Path(__file__).parent.parent.parent
|
||||
self.output_dir = base_dir / "story_images"
|
||||
|
||||
# Create output directory if it doesn't exist
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"[StoryImageGeneration] Initialized with output directory: {self.output_dir}")
|
||||
|
||||
def _generate_image_filename(self, scene_number: int, scene_title: str) -> str:
|
||||
"""Generate a unique filename for a scene image."""
|
||||
# Clean scene title for filename
|
||||
clean_title = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in scene_title[:30])
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
return f"scene_{scene_number}_{clean_title}_{unique_id}.png"
|
||||
|
||||
def generate_scene_image(
|
||||
self,
|
||||
scene: Dict[str, Any],
|
||||
user_id: str,
|
||||
provider: Optional[str] = None,
|
||||
width: int = 1024,
|
||||
height: int = 1024,
|
||||
model: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate an image for a single story scene.
|
||||
|
||||
Parameters:
|
||||
scene (Dict[str, Any]): Scene data with image_prompt.
|
||||
user_id (str): Clerk user ID for subscription checking.
|
||||
provider (str, optional): Image generation provider (gemini, huggingface, stability).
|
||||
width (int): Image width (default: 1024).
|
||||
height (int): Image height (default: 1024).
|
||||
model (str, optional): Model to use for image generation.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Image metadata including file path, URL, and scene info.
|
||||
"""
|
||||
scene_number = scene.get("scene_number", 0)
|
||||
scene_title = scene.get("title", "Untitled")
|
||||
image_prompt = scene.get("image_prompt", "")
|
||||
|
||||
if not image_prompt:
|
||||
raise ValueError(f"Scene {scene_number} ({scene_title}) has no image_prompt")
|
||||
|
||||
try:
|
||||
logger.info(f"[StoryImageGeneration] Generating image for scene {scene_number}: {scene_title}")
|
||||
logger.debug(f"[StoryImageGeneration] Image prompt: {image_prompt[:100]}...")
|
||||
|
||||
# Generate image using main_image_generation service
|
||||
image_options = {
|
||||
"provider": provider,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"model": model,
|
||||
}
|
||||
|
||||
result: ImageGenerationResult = generate_image(
|
||||
prompt=image_prompt,
|
||||
options=image_options,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
# Save image to file
|
||||
image_filename = self._generate_image_filename(scene_number, scene_title)
|
||||
image_path = self.output_dir / image_filename
|
||||
|
||||
with open(image_path, "wb") as f:
|
||||
f.write(result.image_bytes)
|
||||
|
||||
logger.info(f"[StoryImageGeneration] Saved image to: {image_path}")
|
||||
|
||||
# Return image metadata
|
||||
# Use relative path for image_url (will be served via API endpoint)
|
||||
return {
|
||||
"scene_number": scene_number,
|
||||
"scene_title": scene_title,
|
||||
"image_path": str(image_path),
|
||||
"image_filename": image_filename,
|
||||
"image_url": f"/api/story/images/{image_filename}", # API endpoint to serve images
|
||||
"width": result.width,
|
||||
"height": result.height,
|
||||
"provider": result.provider,
|
||||
"model": result.model,
|
||||
"seed": result.seed,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTPExceptions (e.g., 429 subscription limit)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[StoryImageGeneration] Error generating image for scene {scene_number}: {e}")
|
||||
raise RuntimeError(f"Failed to generate image for scene {scene_number}: {str(e)}") from e
|
||||
|
||||
def generate_scene_images(
|
||||
self,
|
||||
scenes: List[Dict[str, Any]],
|
||||
user_id: str,
|
||||
provider: Optional[str] = None,
|
||||
width: int = 1024,
|
||||
height: int = 1024,
|
||||
model: Optional[str] = None,
|
||||
progress_callback: Optional[callable] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Generate images for multiple story scenes.
|
||||
|
||||
Parameters:
|
||||
scenes (List[Dict[str, Any]]): List of scene data with image_prompts.
|
||||
user_id (str): Clerk user ID for subscription checking.
|
||||
provider (str, optional): Image generation provider (gemini, huggingface, stability).
|
||||
width (int): Image width (default: 1024).
|
||||
height (int): Image height (default: 1024).
|
||||
model (str, optional): Model to use for image generation.
|
||||
progress_callback (callable, optional): Callback function for progress updates.
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: List of image metadata for each scene.
|
||||
"""
|
||||
if not scenes:
|
||||
raise ValueError("No scenes provided for image generation")
|
||||
|
||||
logger.info(f"[StoryImageGeneration] Generating images for {len(scenes)} scenes")
|
||||
|
||||
image_results = []
|
||||
total_scenes = len(scenes)
|
||||
|
||||
for idx, scene in enumerate(scenes):
|
||||
try:
|
||||
# Generate image for scene
|
||||
image_result = self.generate_scene_image(
|
||||
scene=scene,
|
||||
user_id=user_id,
|
||||
provider=provider,
|
||||
width=width,
|
||||
height=height,
|
||||
model=model
|
||||
)
|
||||
|
||||
image_results.append(image_result)
|
||||
|
||||
# Call progress callback if provided
|
||||
if progress_callback:
|
||||
progress = ((idx + 1) / total_scenes) * 100
|
||||
progress_callback(progress, f"Generated image for scene {scene.get('scene_number', idx + 1)}")
|
||||
|
||||
logger.info(f"[StoryImageGeneration] Generated image {idx + 1}/{total_scenes}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[StoryImageGeneration] Failed to generate image for scene {idx + 1}: {e}")
|
||||
# Continue with next scene instead of failing completely
|
||||
image_results.append({
|
||||
"scene_number": scene.get("scene_number", idx + 1),
|
||||
"scene_title": scene.get("title", "Untitled"),
|
||||
"error": str(e),
|
||||
"image_path": None,
|
||||
"image_url": None,
|
||||
})
|
||||
|
||||
logger.info(f"[StoryImageGeneration] Generated {len(image_results)} images out of {total_scenes} scenes")
|
||||
return image_results
|
||||
|
||||
14
backend/services/story_writer/service_components/__init__.py
Normal file
14
backend/services/story_writer/service_components/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Story Writer service component helpers."""
|
||||
|
||||
from .base import StoryServiceBase
|
||||
from .setup import StorySetupMixin
|
||||
from .outline import StoryOutlineMixin
|
||||
from .story_content import StoryContentMixin
|
||||
|
||||
__all__ = [
|
||||
"StoryServiceBase",
|
||||
"StorySetupMixin",
|
||||
"StoryOutlineMixin",
|
||||
"StoryContentMixin",
|
||||
]
|
||||
|
||||
332
backend/services/story_writer/service_components/base.py
Normal file
332
backend/services/story_writer/service_components/base.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""Core shared functionality for Story Writer service components."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
|
||||
|
||||
class StoryServiceBase:
|
||||
"""Base class providing shared helpers for story writer operations."""
|
||||
|
||||
guidelines: str = """\
|
||||
Writing Guidelines:
|
||||
|
||||
Delve deeper. Lose yourself in the world you're building. Unleash vivid
|
||||
descriptions to paint the scenes in your reader's mind.
|
||||
Develop your characters — let their motivations, fears, and complexities unfold naturally.
|
||||
Weave in the threads of your outline, but don't feel constrained by it.
|
||||
Allow your story to surprise you as you write. Use rich imagery, sensory details, and
|
||||
evocative language to bring the setting, characters, and events to life.
|
||||
Introduce elements subtly that can blossom into complex subplots, relationships,
|
||||
or worldbuilding details later in the story.
|
||||
Keep things intriguing but not fully resolved.
|
||||
Avoid boxing the story into a corner too early.
|
||||
Plant the seeds of subplots or potential character arc shifts that can be expanded later.
|
||||
|
||||
IMPORTANT: Respect the story length target. Write with appropriate detail and pacing
|
||||
to reach the target word count, but do NOT exceed it. Once you've reached the target
|
||||
length and provided satisfying closure, conclude the story by writing IAMDONE.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# LLM Utilities
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def generate_with_retry(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
system_prompt: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Generate content using llm_text_gen with retry handling and subscription support."""
|
||||
if not user_id:
|
||||
raise RuntimeError("user_id is required for subscription checking")
|
||||
|
||||
try:
|
||||
return llm_text_gen(prompt=prompt, system_prompt=system_prompt, user_id=user_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"Error generating content: {exc}")
|
||||
raise RuntimeError(f"Failed to generate content: {exc}") from exc
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Prompt helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def build_persona_prompt(
|
||||
self,
|
||||
persona: str,
|
||||
story_setting: str,
|
||||
character_input: str,
|
||||
plot_elements: str,
|
||||
writing_style: str,
|
||||
story_tone: str,
|
||||
narrative_pov: str,
|
||||
audience_age_group: str,
|
||||
content_rating: str,
|
||||
ending_preference: str,
|
||||
) -> str:
|
||||
"""Build the persona prompt with all story parameters."""
|
||||
return f"""{persona}
|
||||
|
||||
**STORY SETUP CONTEXT:**
|
||||
|
||||
**Setting:**
|
||||
{story_setting}
|
||||
- Use this specific setting throughout the story
|
||||
- Incorporate setting details naturally into scenes and descriptions
|
||||
- Ensure the setting is clearly established and consistent
|
||||
|
||||
**Characters:**
|
||||
{character_input}
|
||||
- Use these specific characters in the story
|
||||
- Develop these characters according to their descriptions
|
||||
- Maintain character consistency across all scenes
|
||||
- Create character arcs that align with the plot elements
|
||||
|
||||
**Plot Elements:**
|
||||
{plot_elements}
|
||||
- Incorporate these plot elements into the story structure
|
||||
- Address each plot element in relevant scenes
|
||||
- Build connections between plot elements logically
|
||||
- Ensure the ending addresses the main plot elements
|
||||
|
||||
**Writing Style:**
|
||||
{writing_style}
|
||||
- This writing style should be reflected in EVERY aspect of the story
|
||||
- The language, sentence structure, and narrative approach must match this style exactly
|
||||
- If this is a custom or combined style, interpret it in the context of the audience age group
|
||||
- Adapt the style's complexity to match {audience_age_group}
|
||||
|
||||
**Story Tone:**
|
||||
{story_tone}
|
||||
- This tone must be maintained consistently throughout the entire story
|
||||
- The emotional atmosphere, mood, and overall feeling must match this tone
|
||||
- If this is a custom or combined tone, interpret it age-appropriately for {audience_age_group}
|
||||
- Ensure the tone is suitable for {content_rating} content rating
|
||||
|
||||
**Narrative Point of View:**
|
||||
{narrative_pov}
|
||||
- Use this perspective consistently throughout the story
|
||||
- Maintain the chosen perspective in all narration
|
||||
- Apply the perspective appropriately for {audience_age_group}
|
||||
|
||||
**Target Audience:**
|
||||
{audience_age_group}
|
||||
- ALL content must be age-appropriate for this audience
|
||||
- Language complexity, vocabulary, sentence length, and themes must match this age group
|
||||
- Concepts must be understandable and relatable to this audience
|
||||
- Adjust all story elements (style, tone, plot) to be appropriate for this age group
|
||||
|
||||
**Content Rating:**
|
||||
{content_rating}
|
||||
- All content must stay within these content boundaries
|
||||
- Themes, language, and subject matter must respect this rating
|
||||
- Ensure the writing style and tone are compatible with this rating
|
||||
|
||||
**Ending Preference:**
|
||||
{ending_preference}
|
||||
- The story should build toward this type of ending
|
||||
- All plot development should lead naturally to this ending style
|
||||
- Create expectations that align with this ending preference
|
||||
- Ensure the ending is appropriate for {audience_age_group} and {content_rating}
|
||||
|
||||
**CRITICAL INSTRUCTIONS:**
|
||||
- Use ALL of the above story setup parameters to guide your writing
|
||||
- The writing style, tone, narrative POV, audience age group, and content rating are NOT optional - they are REQUIRED constraints
|
||||
- Every word, sentence, and description must align with these parameters
|
||||
- When parameters interact (e.g., style + age group, tone + content rating), ensure they work together harmoniously
|
||||
- Tailor the language complexity, vocabulary, and concepts to the specified audience age group
|
||||
- Maintain consistency with the specified writing style and tone throughout
|
||||
- Ensure all content is appropriate for the specified content rating
|
||||
- Build the narrative toward the specified ending preference
|
||||
- Use the setting, characters, and plot elements provided to create a coherent, engaging story
|
||||
|
||||
Make sure the story is engaging, well-crafted, and perfectly tailored to ALL of the specified parameters above.
|
||||
"""
|
||||
|
||||
def _get_parameter_interaction_guidance(
|
||||
self,
|
||||
writing_style: str,
|
||||
story_tone: str,
|
||||
audience_age_group: str,
|
||||
content_rating: str,
|
||||
) -> str:
|
||||
"""Generate guidance for interpreting custom/combined parameter values and their interactions."""
|
||||
guidance = "**PARAMETER INTERACTION GUIDANCE:**\n\n"
|
||||
|
||||
style_words = writing_style.lower().split()
|
||||
if len(style_words) > 1:
|
||||
guidance += f"**Writing Style Analysis:** The style '{writing_style}' appears to combine multiple approaches:\n"
|
||||
for word in style_words:
|
||||
guidance += f"- '{word.title()}': Interpret this aspect in the context of {audience_age_group}\n"
|
||||
guidance += (
|
||||
"Combine all aspects naturally. For example, if 'Educational Playful':\n"
|
||||
f" → Use playful, engaging language to teach concepts naturally\n"
|
||||
f" → Make learning fun and interactive for {audience_age_group}\n"
|
||||
" → Combine educational content with fun, magical elements\n\n"
|
||||
)
|
||||
else:
|
||||
guidance += f"**Writing Style:** '{writing_style}'\n"
|
||||
guidance += f"- Interpret this style appropriately for {audience_age_group}\n"
|
||||
guidance += "- Adapt the style's complexity to match the audience's reading level\n\n"
|
||||
|
||||
tone_words = story_tone.lower().split()
|
||||
if len(tone_words) > 1:
|
||||
guidance += f"**Story Tone Analysis:** The tone '{story_tone}' combines multiple emotional qualities:\n"
|
||||
for word in tone_words:
|
||||
guidance += f"- '{word.title()}': Express this emotion in an age-appropriate way for {audience_age_group}\n"
|
||||
guidance += (
|
||||
"Blend these emotions throughout the story. For example, if 'Educational Whimsical':\n"
|
||||
" → Use whimsical, playful language to convey educational concepts\n"
|
||||
" → Make the tone both informative and magical\n"
|
||||
f" → Combine wonder and learning in an age-appropriate way for {audience_age_group}\n\n"
|
||||
)
|
||||
else:
|
||||
guidance += f"**Story Tone:** '{story_tone}'\n"
|
||||
guidance += f"- Interpret this tone age-appropriately for {audience_age_group}\n"
|
||||
guidance += f"- Ensure the tone is suitable for {content_rating} content rating\n\n"
|
||||
|
||||
guidance += "**PARAMETER INTERACTION EXAMPLES:**\n\n"
|
||||
|
||||
if "Children (5-12)" in audience_age_group:
|
||||
guidance += f"- When writing_style is '{writing_style}' AND audience_age_group is 'Children (5-12)':\n"
|
||||
guidance += " → Simplify the style's complexity while maintaining its essence\n"
|
||||
guidance += " → Use age-appropriate vocabulary and sentence structure\n"
|
||||
guidance += " → Make the style engaging and accessible for children\n\n"
|
||||
|
||||
if "Children (5-12)" in audience_age_group and "dark" in story_tone.lower():
|
||||
guidance += f"- When story_tone is '{story_tone}' AND audience_age_group is 'Children (5-12)':\n"
|
||||
guidance += " → Interpret 'dark' as mysterious and adventurous, not scary or frightening\n"
|
||||
guidance += " → Use shadows, secrets, and puzzles rather than fear or horror\n"
|
||||
guidance += " → Maintain a sense of wonder and excitement\n"
|
||||
guidance += " → Keep it thrilling but age-appropriate\n\n"
|
||||
|
||||
guidance += f"- When writing_style is '{writing_style}' AND story_tone is '{story_tone}':\n"
|
||||
guidance += " → Combine the style and tone naturally\n"
|
||||
guidance += " → Use the style to express the tone effectively\n"
|
||||
guidance += f" → Ensure both work together harmoniously for {audience_age_group}\n\n"
|
||||
|
||||
guidance += f"- When content_rating is '{content_rating}':\n"
|
||||
guidance += " → Ensure the writing style and tone respect these content boundaries\n"
|
||||
guidance += " → Adjust language, themes, and subject matter to fit the rating\n"
|
||||
guidance += f" → Maintain age-appropriateness for {audience_age_group}\n\n"
|
||||
|
||||
guidance += "**PARAMETER CONFLICT RESOLUTION:**\n"
|
||||
guidance += "If parameters seem to conflict, prioritize in this order:\n"
|
||||
guidance += "1. Audience age group appropriateness (safety and comprehension) - HIGHEST PRIORITY\n"
|
||||
guidance += "2. Content rating compliance (content boundaries)\n"
|
||||
guidance += "3. Writing style and tone (creative expression)\n"
|
||||
guidance += "4. Other parameters (narrative POV, ending preference)\n\n"
|
||||
guidance += "Always ensure that ALL parameters work together to create appropriate, engaging content.\n"
|
||||
|
||||
return guidance
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Outline helpers shared across modules
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _format_outline_for_prompt(self, outline: Any) -> str:
|
||||
"""Format outline (structured or text) for use in prompts."""
|
||||
if isinstance(outline, list):
|
||||
outline_text = "\n".join(
|
||||
[
|
||||
f"Scene {scene.get('scene_number', idx + 1)}: {scene.get('title', 'Untitled')}\n"
|
||||
f" Description: {scene.get('description', '')}\n"
|
||||
f" Key Events: {', '.join(scene.get('key_events', []))}"
|
||||
for idx, scene in enumerate(outline)
|
||||
]
|
||||
)
|
||||
return outline_text
|
||||
return str(outline)
|
||||
|
||||
def _parse_text_outline(self, outline_prompt: str, user_id: str) -> List[Dict[str, Any]]:
|
||||
"""Fallback method to parse text outline if JSON parsing fails."""
|
||||
outline_text = self.generate_with_retry(outline_prompt, user_id=user_id)
|
||||
|
||||
lines = outline_text.strip().split("\n")
|
||||
scenes: List[Dict[str, Any]] = []
|
||||
current_scene: Optional[Dict[str, Any]] = None
|
||||
|
||||
for line in lines:
|
||||
cleaned = line.strip()
|
||||
if not cleaned:
|
||||
continue
|
||||
|
||||
if cleaned[0].isdigit() or cleaned.startswith("Scene") or cleaned.startswith("Chapter"):
|
||||
if current_scene:
|
||||
scenes.append(current_scene)
|
||||
|
||||
scene_number = len(scenes) + 1
|
||||
title = cleaned.replace(f"{scene_number}.", "").replace("Scene", "").replace("Chapter", "").strip()
|
||||
current_scene = {
|
||||
"scene_number": scene_number,
|
||||
"title": title or f"Scene {scene_number}",
|
||||
"description": "",
|
||||
"image_prompt": f"A scene from the story: {title}",
|
||||
"audio_narration": "",
|
||||
"character_descriptions": [],
|
||||
"key_events": [],
|
||||
}
|
||||
continue
|
||||
|
||||
if current_scene:
|
||||
if current_scene["description"]:
|
||||
current_scene["description"] += " " + cleaned
|
||||
else:
|
||||
current_scene["description"] = cleaned
|
||||
|
||||
if current_scene["image_prompt"].startswith("A scene from the story"):
|
||||
current_scene["image_prompt"] = f"A detailed visual representation of: {current_scene['description'][:200]}"
|
||||
if not current_scene["audio_narration"]:
|
||||
current_scene["audio_narration"] = (
|
||||
current_scene["description"][:150] + "..."
|
||||
if len(current_scene["description"]) > 150
|
||||
else current_scene["description"]
|
||||
)
|
||||
|
||||
if current_scene:
|
||||
scenes.append(current_scene)
|
||||
|
||||
if not scenes:
|
||||
scenes.append(
|
||||
{
|
||||
"scene_number": 1,
|
||||
"title": "Story Outline",
|
||||
"description": outline_text.strip(),
|
||||
"image_prompt": f"A scene from the story: {outline_text[:200]}",
|
||||
"audio_narration": outline_text[:150] + "..." if len(outline_text) > 150 else outline_text,
|
||||
"character_descriptions": [],
|
||||
"key_events": [],
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"[StoryWriter] Parsed {len(scenes)} scenes from text outline")
|
||||
return scenes
|
||||
|
||||
def _get_story_length_guidance(self, story_length: str) -> tuple[int, int]:
|
||||
"""Return word count guidance based on story length."""
|
||||
story_length_lower = story_length.lower()
|
||||
if "short" in story_length_lower or "1000" in story_length_lower:
|
||||
return (1000, 0)
|
||||
if "long" in story_length_lower or "10000" in story_length_lower:
|
||||
return (3000, 2500)
|
||||
return (2000, 1500)
|
||||
|
||||
@staticmethod
|
||||
def load_json_response(response_text: Any) -> Dict[str, Any]:
|
||||
"""Normalize responses from llm_text_gen (dict or json string)."""
|
||||
if isinstance(response_text, dict):
|
||||
return response_text
|
||||
if isinstance(response_text, str):
|
||||
return json.loads(response_text)
|
||||
raise ValueError(f"Unexpected response type: {type(response_text)}")
|
||||
|
||||
171
backend/services/story_writer/service_components/outline.py
Normal file
171
backend/services/story_writer/service_components/outline.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Story outline generation helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
|
||||
from .base import StoryServiceBase
|
||||
|
||||
|
||||
class StoryOutlineMixin(StoryServiceBase):
|
||||
"""Provides outline generation behaviour."""
|
||||
|
||||
def _get_outline_schema(self) -> Dict[str, Any]:
|
||||
"""Return JSON schema for structured story outlines."""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scenes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scene_number": {"type": "integer"},
|
||||
"title": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"image_prompt": {"type": "string"},
|
||||
"audio_narration": {"type": "string"},
|
||||
"character_descriptions": {"type": "array", "items": {"type": "string"}},
|
||||
"key_events": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"required": ["scene_number", "title", "description", "image_prompt", "audio_narration"],
|
||||
},
|
||||
}
|
||||
},
|
||||
"required": ["scenes"],
|
||||
}
|
||||
|
||||
def generate_outline(
|
||||
self,
|
||||
*,
|
||||
premise: str,
|
||||
persona: str,
|
||||
story_setting: str,
|
||||
character_input: str,
|
||||
plot_elements: str,
|
||||
writing_style: str,
|
||||
story_tone: str,
|
||||
narrative_pov: str,
|
||||
audience_age_group: str,
|
||||
content_rating: str,
|
||||
ending_preference: str,
|
||||
user_id: str,
|
||||
use_structured_output: bool = True,
|
||||
) -> Any:
|
||||
"""Generate a story outline with optional structured JSON output."""
|
||||
persona_prompt = self.build_persona_prompt(
|
||||
persona,
|
||||
story_setting,
|
||||
character_input,
|
||||
plot_elements,
|
||||
writing_style,
|
||||
story_tone,
|
||||
narrative_pov,
|
||||
audience_age_group,
|
||||
content_rating,
|
||||
ending_preference,
|
||||
)
|
||||
|
||||
parameter_guidance = self._get_parameter_interaction_guidance(
|
||||
writing_style, story_tone, audience_age_group, content_rating
|
||||
)
|
||||
|
||||
outline_prompt = f"""\
|
||||
{persona_prompt}
|
||||
|
||||
**PREMISE:**
|
||||
{premise}
|
||||
|
||||
{parameter_guidance}
|
||||
|
||||
**YOUR TASK:**
|
||||
Create a detailed story outline with multiple scenes that brings this premise to life. The outline must perfectly align with ALL of the story setup parameters provided above.
|
||||
|
||||
**SCENE PROGRESSION STRUCTURE:**
|
||||
|
||||
**Scene 1-2 (Opening):**
|
||||
- Introduce the setting ({story_setting}) and main characters ({character_input})
|
||||
- Establish the {story_tone} tone from the beginning
|
||||
- Set up the main conflict or adventure based on the plot elements ({plot_elements})
|
||||
- Hook the audience with an engaging opening that matches {writing_style} style
|
||||
- Use the {narrative_pov} perspective to establish the story world
|
||||
- Create intrigue and interest appropriate for {audience_age_group}
|
||||
- Respect the {content_rating} content rating from the start
|
||||
|
||||
**Scene 3-7 (Development):**
|
||||
- Develop the plot elements ({plot_elements}) in detail
|
||||
- Build character relationships and growth using the specified characters ({character_input})
|
||||
- Create tension, obstacles, or challenges that advance the story
|
||||
- Maintain the {writing_style} style consistently throughout
|
||||
- Progress toward the {ending_preference} ending
|
||||
- Explore the setting ({story_setting}) more deeply
|
||||
- Ensure all content is age-appropriate for {audience_age_group}
|
||||
- Maintain the {story_tone} tone while developing the plot
|
||||
- Respect the {content_rating} content rating in all scenes
|
||||
- Use the {narrative_pov} perspective consistently
|
||||
|
||||
**Final Scenes (Resolution):**
|
||||
- Resolve the main conflict established in the plot elements ({plot_elements})
|
||||
- Deliver the {ending_preference} ending
|
||||
- Tie together all plot elements and character arcs
|
||||
- Provide satisfying closure appropriate for {audience_age_group}
|
||||
- Maintain the {writing_style} style and {story_tone} tone until the end
|
||||
- Ensure the ending respects the {content_rating} content rating
|
||||
- Use the {narrative_pov} perspective to conclude the story
|
||||
|
||||
**OUTLINE STRUCTURE:**
|
||||
For each scene, provide:
|
||||
1. **Scene Number and Title**
|
||||
2. **Description** (written in {writing_style}, maintaining {story_tone}, and age-appropriate for {audience_age_group})
|
||||
3. **Image Prompt** (vivid, visually descriptive, includes setting/characters, age-appropriate)
|
||||
4. **Audio Narration** (2-3 sentences, engaging, maintains style/tone, suitable for narration)
|
||||
5. **Character Descriptions** (for characters appearing in the scene)
|
||||
6. **Key Events** (bullet list of important happenings)
|
||||
|
||||
**CONTEXT INTEGRATION REQUIREMENTS:**
|
||||
- Ensure every scene reflects the setting ({story_setting})
|
||||
- Keep characters consistent with ({character_input})
|
||||
- Integrate plot elements ({plot_elements}) logically
|
||||
- Maintain persona voice ({persona})
|
||||
- Respect audience age group ({audience_age_group}) and content rating ({content_rating})
|
||||
|
||||
Before finalizing, verify that every scene adheres to the writing style, tone, age appropriateness, content rating, and narrative POV. Create 5-10 scenes that tell a complete, engaging story with clear progression and satisfying resolution.
|
||||
"""
|
||||
|
||||
try:
|
||||
if use_structured_output:
|
||||
outline_schema = self._get_outline_schema()
|
||||
try:
|
||||
response = self.load_json_response(
|
||||
llm_text_gen(prompt=outline_prompt, json_struct=outline_schema, user_id=user_id)
|
||||
)
|
||||
scenes = response.get("scenes", [])
|
||||
if scenes:
|
||||
logger.info(f"[StoryWriter] Generated {len(scenes)} structured scenes for user {user_id}")
|
||||
logger.info(
|
||||
"[StoryWriter] Outline generated with parameters: "
|
||||
f"audience={audience_age_group}, style={writing_style}, tone={story_tone}"
|
||||
)
|
||||
return scenes
|
||||
logger.warning("[StoryWriter] No scenes found in structured output, falling back to text parsing")
|
||||
raise ValueError("No scenes found in structured output")
|
||||
except (json.JSONDecodeError, ValueError, KeyError) as exc:
|
||||
logger.warning(
|
||||
f"[StoryWriter] Failed to parse structured JSON outline ({exc}), falling back to text parsing"
|
||||
)
|
||||
return self._parse_text_outline(outline_prompt, user_id)
|
||||
|
||||
outline = self.generate_with_retry(outline_prompt, user_id=user_id)
|
||||
return outline.strip()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"Outline Generation Error: {exc}")
|
||||
raise RuntimeError(f"Failed to generate outline: {exc}") from exc
|
||||
|
||||
273
backend/services/story_writer/service_components/setup.py
Normal file
273
backend/services/story_writer/service_components/setup.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""Story setup generation helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from .base import StoryServiceBase
|
||||
|
||||
|
||||
class StorySetupMixin(StoryServiceBase):
|
||||
"""Provides story setup generation behaviour."""
|
||||
|
||||
def generate_premise(
|
||||
self,
|
||||
*,
|
||||
persona: str,
|
||||
story_setting: str,
|
||||
character_input: str,
|
||||
plot_elements: str,
|
||||
writing_style: str,
|
||||
story_tone: str,
|
||||
narrative_pov: str,
|
||||
audience_age_group: str,
|
||||
content_rating: str,
|
||||
ending_preference: str,
|
||||
user_id: str,
|
||||
) -> str:
|
||||
"""Generate a story premise."""
|
||||
persona_prompt = self.build_persona_prompt(
|
||||
persona,
|
||||
story_setting,
|
||||
character_input,
|
||||
plot_elements,
|
||||
writing_style,
|
||||
story_tone,
|
||||
narrative_pov,
|
||||
audience_age_group,
|
||||
content_rating,
|
||||
ending_preference,
|
||||
)
|
||||
|
||||
parameter_guidance = self._get_parameter_interaction_guidance(
|
||||
writing_style, story_tone, audience_age_group, content_rating
|
||||
)
|
||||
|
||||
premise_prompt = f"""\
|
||||
{persona_prompt}
|
||||
|
||||
{parameter_guidance}
|
||||
|
||||
**TASK: Write a SINGLE, BRIEF premise sentence (1-2 sentences maximum, approximately 20-40 words) for this story.**
|
||||
|
||||
The premise MUST:
|
||||
1. Be written in the specified {writing_style} writing style
|
||||
- Interpret and apply this style appropriately for {audience_age_group}
|
||||
- Match the language complexity, sentence structure, and narrative approach of this style
|
||||
2. Match the {story_tone} story tone exactly
|
||||
- Express the emotional atmosphere and mood indicated by this tone
|
||||
- Ensure the tone is age-appropriate for {audience_age_group}
|
||||
3. Be appropriate for {audience_age_group} with {content_rating} content rating
|
||||
- Use language complexity that matches this audience's reading level
|
||||
- Use vocabulary that is understandable to this age group
|
||||
- Present concepts that are relatable and explainable to this audience
|
||||
- Respect the {content_rating} content rating boundaries
|
||||
4. Briefly describe the story elements:
|
||||
- Setting: {story_setting}
|
||||
- Characters: {character_input}
|
||||
- Main plot: {plot_elements}
|
||||
5. Be clear, engaging, and set up the story without telling the whole story
|
||||
6. Be written from the {narrative_pov} point of view
|
||||
7. Set up for a {ending_preference} ending
|
||||
|
||||
**CRITICAL: This is a PREMISE, not the full story.**
|
||||
- Keep it to 1-2 sentences maximum (approximately 20-40 words)
|
||||
- Do NOT write the full story or multiple paragraphs
|
||||
- Do NOT reveal the resolution or ending
|
||||
- Focus on the setup: who, where, and what the main challenge/adventure is
|
||||
- Use ALL story setup parameters to guide your language and content choices
|
||||
- Tailor every word to the target audience ({audience_age_group}) and writing style ({writing_style})
|
||||
|
||||
Write ONLY the premise sentence(s). Do not write anything else.
|
||||
"""
|
||||
|
||||
try:
|
||||
premise = self.generate_with_retry(premise_prompt, user_id=user_id).strip()
|
||||
sentences = premise.split(". ")
|
||||
if len(sentences) > 2:
|
||||
premise = ". ".join(sentences[:2])
|
||||
if not premise.endswith("."):
|
||||
premise += "."
|
||||
return premise
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"Premise Generation Error: {exc}")
|
||||
raise RuntimeError(f"Failed to generate premise: {exc}") from exc
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Setup options
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _build_setup_schema(self) -> Dict[str, Any]:
|
||||
"""Return JSON schema for structured setup options."""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"options": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"persona": {"type": "string"},
|
||||
"story_setting": {"type": "string"},
|
||||
"character_input": {"type": "string"},
|
||||
"plot_elements": {"type": "string"},
|
||||
"writing_style": {"type": "string"},
|
||||
"story_tone": {"type": "string"},
|
||||
"narrative_pov": {"type": "string"},
|
||||
"audience_age_group": {"type": "string"},
|
||||
"content_rating": {"type": "string"},
|
||||
"ending_preference": {"type": "string"},
|
||||
"story_length": {"type": "string"},
|
||||
"premise": {"type": "string"},
|
||||
"reasoning": {"type": "string"},
|
||||
},
|
||||
"required": [
|
||||
"persona",
|
||||
"story_setting",
|
||||
"character_input",
|
||||
"plot_elements",
|
||||
"writing_style",
|
||||
"story_tone",
|
||||
"narrative_pov",
|
||||
"audience_age_group",
|
||||
"content_rating",
|
||||
"ending_preference",
|
||||
"story_length",
|
||||
"premise",
|
||||
"reasoning",
|
||||
],
|
||||
},
|
||||
"minItems": 3,
|
||||
"maxItems": 3,
|
||||
}
|
||||
},
|
||||
"required": ["options"],
|
||||
}
|
||||
|
||||
def generate_story_setup_options(
|
||||
self,
|
||||
*,
|
||||
story_idea: str,
|
||||
user_id: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Generate 3 story setup options from a user's story idea."""
|
||||
|
||||
suggested_writing_styles = ['Formal', 'Casual', 'Poetic', 'Humorous', 'Academic', 'Journalistic', 'Narrative']
|
||||
suggested_story_tones = ['Dark', 'Uplifting', 'Suspenseful', 'Whimsical', 'Melancholic', 'Mysterious', 'Romantic', 'Adventurous']
|
||||
suggested_narrative_povs = ['First Person', 'Third Person Limited', 'Third Person Omniscient']
|
||||
suggested_audience_age_groups = ['Children (5-12)', 'Young Adults (13-17)', 'Adults (18+)', 'All Ages']
|
||||
suggested_content_ratings = ['G', 'PG', 'PG-13', 'R']
|
||||
suggested_ending_preferences = ['Happy', 'Tragic', 'Cliffhanger', 'Twist', 'Open-ended', 'Bittersweet']
|
||||
|
||||
setup_prompt = f"""\
|
||||
You are an expert story writer and creative writing assistant. A user has provided the following story idea or information:
|
||||
|
||||
{story_idea}
|
||||
|
||||
Based on this story idea, generate exactly 3 different, well-thought-out story setup options. Each option should be CREATIVE, PERSONALIZED, and perfectly tailored to the user's specific story idea.
|
||||
|
||||
**CRITICAL - Creative Freedom:**
|
||||
- You have COMPLETE FREEDOM to craft personalized values that best fit the user's story idea
|
||||
- Do NOT limit yourself to predefined options - create custom, creative values that perfectly match the story concept
|
||||
- For example, if the user wants "a story about how stars are made for a 5-year-old", you might create:
|
||||
- Writing Style: "Educational Playful" or "Simple Scientific" (not just "Casual" or "Poetic")
|
||||
- Story Tone: "Wonder-filled" or "Curious Discovery" (not just "Whimsical" or "Uplifting")
|
||||
- Narrative POV: "Second Person (You)" or "Omniscient Narrator as Guide" (not just standard options)
|
||||
- The goal is to create the PERFECT setup for THIS specific story, not to fit into generic categories
|
||||
|
||||
Each option should:
|
||||
1. Have a unique and creative persona that fits the story idea perfectly
|
||||
2. Define a compelling story setting that brings the idea to life
|
||||
3. Describe interesting and engaging characters
|
||||
4. Include key plot elements that drive the narrative
|
||||
5. Create CUSTOM, PERSONALIZED values for writing style, story tone, narrative POV, audience age group, content rating, and ending preference that best serve the story idea
|
||||
6. Select an appropriate story length: "Short (>1000 words)" for brief stories, "Medium (>5000 words)" for standard-length stories, or "Long (>10000 words)" for extended, detailed stories
|
||||
7. Generate a brief story premise (1-2 sentences, approximately 20-40 words) that summarizes the story concept
|
||||
8. Provide a brief reasoning (2-3 sentences) explaining why this setup works well for the story idea
|
||||
|
||||
**IMPORTANT - Premise Requirements:**
|
||||
- The premise MUST be age-appropriate for the selected audience_age_group
|
||||
- For Children (5-12): Use simple, everyday words. Avoid complex vocabulary like "nebular", "ionized", "cosmic", "stellar", "melancholic", "bittersweet"
|
||||
- The premise MUST match the selected writing_style (e.g., if custom style is "Educational Playful", use playful educational language)
|
||||
- The premise MUST match the selected story_tone (e.g., if custom tone is "Wonder-filled", create a sense of wonder)
|
||||
- Keep the premise to 1-2 sentences maximum
|
||||
- Focus on who, where, and what the main challenge/adventure is
|
||||
|
||||
**Suggested Options (for reference only - feel free to create better custom values):**
|
||||
- Writing Styles (suggestions): {', '.join(suggested_writing_styles)}
|
||||
- Story Tones (suggestions): {', '.join(suggested_story_tones)}
|
||||
- Narrative POVs (suggestions): {', '.join(suggested_narrative_povs)}
|
||||
- Audience Age Groups (suggestions): {', '.join(suggested_audience_age_groups)}
|
||||
- Content Ratings (suggestions): {', '.join(suggested_content_ratings)}
|
||||
- Ending Preferences (suggestions): {', '.join(suggested_ending_preferences)}
|
||||
- Story Lengths: "Short (>1000 words)", "Medium (>5000 words)", "Long (>10000 words)"
|
||||
|
||||
**Remember:** These are ONLY suggestions. If a custom value better serves the story idea, CREATE IT!
|
||||
|
||||
Return exactly 3 options as a JSON array. Each option must include a "premise" field with the story premise.
|
||||
"""
|
||||
|
||||
setup_schema = self._build_setup_schema()
|
||||
|
||||
try:
|
||||
logger.info(f"[StoryWriter] Generating story setup options for user {user_id}")
|
||||
response = self.load_json_response(
|
||||
llm_text_gen(prompt=setup_prompt, json_struct=setup_schema, user_id=user_id)
|
||||
)
|
||||
|
||||
options = response.get("options", [])
|
||||
if len(options) != 3:
|
||||
logger.warning(f"[StoryWriter] Expected 3 options but got {len(options)}, correcting count")
|
||||
if len(options) < 3:
|
||||
raise ValueError(f"Expected 3 options but got {len(options)}")
|
||||
options = options[:3]
|
||||
|
||||
for idx, option in enumerate(options):
|
||||
if not option.get("premise") or not option.get("premise", "").strip():
|
||||
logger.info(f"[StoryWriter] Generating premise for option {idx + 1}")
|
||||
try:
|
||||
option["premise"] = self.generate_premise(
|
||||
persona=option.get("persona", ""),
|
||||
story_setting=option.get("story_setting", ""),
|
||||
character_input=option.get("character_input", ""),
|
||||
plot_elements=option.get("plot_elements", ""),
|
||||
writing_style=option.get("writing_style", "Narrative"),
|
||||
story_tone=option.get("story_tone", "Adventurous"),
|
||||
narrative_pov=option.get("narrative_pov", "Third Person Limited"),
|
||||
audience_age_group=option.get("audience_age_group", "All Ages"),
|
||||
content_rating=option.get("content_rating", "G"),
|
||||
ending_preference=option.get("ending_preference", "Happy"),
|
||||
user_id=user_id,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - fallback clause
|
||||
logger.warning(f"[StoryWriter] Failed to generate premise for option {idx + 1}: {exc}")
|
||||
option["premise"] = (
|
||||
f"A {option.get('story_setting', 'story')} story featuring "
|
||||
f"{option.get('character_input', 'characters')}."
|
||||
)
|
||||
else:
|
||||
premise = option["premise"].strip()
|
||||
sentences = premise.split(". ")
|
||||
if len(sentences) > 2:
|
||||
premise = ". ".join(sentences[:2])
|
||||
if not premise.endswith("."):
|
||||
premise += "."
|
||||
option["premise"] = premise
|
||||
|
||||
logger.info(f"[StoryWriter] Generated {len(options)} story setup options with premises for user {user_id}")
|
||||
return options
|
||||
except HTTPException:
|
||||
raise
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.error(f"[StoryWriter] Failed to parse JSON response for story setup: {exc}")
|
||||
raise RuntimeError(f"Failed to parse story setup options: {exc}") from exc
|
||||
except Exception as exc:
|
||||
logger.error(f"[StoryWriter] Error generating story setup options: {exc}")
|
||||
raise RuntimeError(f"Failed to generate story setup options: {exc}") from exc
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
"""Story content generation helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from services.story_writer.image_generation_service import StoryImageGenerationService
|
||||
|
||||
from .base import StoryServiceBase
|
||||
from .outline import StoryOutlineMixin
|
||||
|
||||
|
||||
class StoryContentMixin(StoryOutlineMixin):
|
||||
"""Provides story drafting and continuation behaviour."""
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Story start
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def generate_story_start(
|
||||
self,
|
||||
*,
|
||||
premise: str,
|
||||
outline: Any,
|
||||
persona: str,
|
||||
story_setting: str,
|
||||
character_input: str,
|
||||
plot_elements: str,
|
||||
writing_style: str,
|
||||
story_tone: str,
|
||||
narrative_pov: str,
|
||||
audience_age_group: str,
|
||||
content_rating: str,
|
||||
ending_preference: str,
|
||||
story_length: str = "Medium",
|
||||
user_id: str,
|
||||
) -> str:
|
||||
"""Generate the starting section (or full short story)."""
|
||||
persona_prompt = self.build_persona_prompt(
|
||||
persona,
|
||||
story_setting,
|
||||
character_input,
|
||||
plot_elements,
|
||||
writing_style,
|
||||
story_tone,
|
||||
narrative_pov,
|
||||
audience_age_group,
|
||||
content_rating,
|
||||
ending_preference,
|
||||
)
|
||||
|
||||
outline_text = self._format_outline_for_prompt(outline)
|
||||
story_length_lower = story_length.lower()
|
||||
is_short_story = "short" in story_length_lower or "1000" in story_length_lower
|
||||
|
||||
if is_short_story:
|
||||
logger.info(f"[StoryWriter] Generating complete short story (~1000 words) in single call for user {user_id}")
|
||||
short_story_prompt = f"""\
|
||||
{persona_prompt}
|
||||
|
||||
You have a gripping premise in mind:
|
||||
|
||||
{premise}
|
||||
|
||||
Your imagination has crafted a rich narrative outline:
|
||||
|
||||
{outline_text}
|
||||
|
||||
**YOUR TASK:**
|
||||
Write the COMPLETE story from beginning to end. This is a SHORT story, so you need to write the entire narrative in a single response.
|
||||
|
||||
**STORY LENGTH TARGET:**
|
||||
- Target: Approximately 1000 words (900-1100 words acceptable)
|
||||
- This is a SHORT story, so be concise but complete
|
||||
- Cover all key scenes from your outline
|
||||
- Provide a satisfying conclusion that addresses all plot elements
|
||||
- Ensure the story makes sense as a complete narrative
|
||||
|
||||
**STORY STRUCTURE:**
|
||||
1. **Opening**: Establish setting, characters, and initial situation
|
||||
2. **Development**: Develop the plot, introduce conflicts, build tension
|
||||
3. **Climax**: Reach the story's peak moment
|
||||
4. **Resolution**: Resolve conflicts and provide closure
|
||||
|
||||
**IMPORTANT INSTRUCTIONS:**
|
||||
- Write the COMPLETE story in this single response
|
||||
- Aim for approximately 1000 words (900-1100 words)
|
||||
- Ensure the story is complete and makes sense as a standalone narrative
|
||||
- Include all essential elements from your outline
|
||||
- Provide a satisfying ending that matches the ending preference: {ending_preference}
|
||||
- Do NOT leave the story incomplete - this is the only generation call for short stories
|
||||
- Once you've finished the complete story, conclude naturally - do NOT write IAMDONE
|
||||
|
||||
**WRITING STYLE:**
|
||||
{self.guidelines}
|
||||
|
||||
**REMEMBER:**
|
||||
- This is a SHORT story - be concise but complete
|
||||
- Write the ENTIRE story in this response
|
||||
- Aim for ~1000 words
|
||||
- Ensure the story is complete and satisfying
|
||||
- Cover all key elements from your outline
|
||||
"""
|
||||
try:
|
||||
complete_story = self.generate_with_retry(short_story_prompt, user_id=user_id)
|
||||
complete_story = complete_story.replace("IAMDONE", "").strip()
|
||||
logger.info(
|
||||
f"[StoryWriter] Generated complete short story ({len(complete_story.split())} words) for user {user_id}"
|
||||
)
|
||||
return complete_story
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"Short Story Generation Error: {exc}")
|
||||
raise RuntimeError(f"Failed to generate short story: {exc}") from exc
|
||||
|
||||
initial_word_count, _ = self._get_story_length_guidance(story_length)
|
||||
|
||||
starting_prompt = f"""\
|
||||
{persona_prompt}
|
||||
|
||||
You have a gripping premise in mind:
|
||||
|
||||
{premise}
|
||||
|
||||
Your imagination has crafted a rich narrative outline:
|
||||
|
||||
{outline_text}
|
||||
|
||||
First, silently review the outline and the premise. Consider how to start the story.
|
||||
|
||||
Start to write the very beginning of the story. You are not expected to finish
|
||||
the whole story now. Your writing should be detailed enough that you are only
|
||||
scratching the surface of the first bullet of your outline. Try to write AT
|
||||
MINIMUM {initial_word_count} WORDS.
|
||||
|
||||
**STORY LENGTH TARGET:**
|
||||
This story is targeted to be {story_length}. Write with appropriate detail and pacing
|
||||
to reach this target length across the entire story. For this initial section, focus
|
||||
on establishing the setting, characters, and beginning of the plot in {initial_word_count} words.
|
||||
|
||||
{self.guidelines}
|
||||
"""
|
||||
|
||||
try:
|
||||
starting_draft = self.generate_with_retry(starting_prompt, user_id=user_id)
|
||||
return starting_draft.strip()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"Story Start Generation Error: {exc}")
|
||||
raise RuntimeError(f"Failed to generate story start: {exc}") from exc
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Continuation
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def continue_story(
|
||||
self,
|
||||
*,
|
||||
premise: str,
|
||||
outline: Any,
|
||||
story_text: str,
|
||||
persona: str,
|
||||
story_setting: str,
|
||||
character_input: str,
|
||||
plot_elements: str,
|
||||
writing_style: str,
|
||||
story_tone: str,
|
||||
narrative_pov: str,
|
||||
audience_age_group: str,
|
||||
content_rating: str,
|
||||
ending_preference: str,
|
||||
story_length: str = "Medium",
|
||||
user_id: str,
|
||||
) -> str:
|
||||
"""Continue writing the story."""
|
||||
persona_prompt = self.build_persona_prompt(
|
||||
persona,
|
||||
story_setting,
|
||||
character_input,
|
||||
plot_elements,
|
||||
writing_style,
|
||||
story_tone,
|
||||
narrative_pov,
|
||||
audience_age_group,
|
||||
content_rating,
|
||||
ending_preference,
|
||||
)
|
||||
|
||||
outline_text = self._format_outline_for_prompt(outline)
|
||||
_, continuation_word_count = self._get_story_length_guidance(story_length)
|
||||
current_word_count = len(story_text.split()) if story_text else 0
|
||||
|
||||
story_length_lower = story_length.lower()
|
||||
if "short" in story_length_lower or "1000" in story_length_lower:
|
||||
# Safety check: short stories shouldn't reach here
|
||||
return "IAMDONE"
|
||||
|
||||
if "long" in story_length_lower or "10000" in story_length_lower:
|
||||
target_total_words = 10000
|
||||
else:
|
||||
target_total_words = 4500
|
||||
|
||||
buffer_target = int(target_total_words * 1.05)
|
||||
|
||||
if current_word_count >= buffer_target:
|
||||
logger.info(
|
||||
f"[StoryWriter] Word count ({current_word_count}) at or past buffer target ({buffer_target}). Story is complete."
|
||||
)
|
||||
return "IAMDONE"
|
||||
|
||||
if current_word_count >= target_total_words and (current_word_count - target_total_words) < 50:
|
||||
logger.info(
|
||||
f"[StoryWriter] Word count ({current_word_count}) is very close to target ({target_total_words}). Story is complete."
|
||||
)
|
||||
return "IAMDONE"
|
||||
|
||||
remaining_words = max(0, buffer_target - current_word_count)
|
||||
if remaining_words < 50:
|
||||
logger.info(f"[StoryWriter] Remaining words ({remaining_words}) are minimal. Story is complete.")
|
||||
return "IAMDONE"
|
||||
|
||||
continuation_prompt = f"""\
|
||||
{persona_prompt}
|
||||
|
||||
You have a gripping premise in mind:
|
||||
|
||||
{premise}
|
||||
|
||||
Your imagination has crafted a rich narrative outline:
|
||||
|
||||
{outline_text}
|
||||
|
||||
You've begun to immerse yourself in this world, and the words are flowing.
|
||||
Here's what you've written so far:
|
||||
|
||||
{story_text}
|
||||
|
||||
=====
|
||||
|
||||
First, silently review the outline and story so far. Identify what the single
|
||||
next part of your outline you should write.
|
||||
|
||||
Your task is to continue where you left off and write the next part of the story.
|
||||
You are not expected to finish the whole story now. Your writing should be
|
||||
detailed enough that you are only scratching the surface of the next part of
|
||||
your outline. Try to write AT MINIMUM {continuation_word_count} WORDS.
|
||||
|
||||
**STORY LENGTH TARGET:**
|
||||
This story is targeted to be {story_length} (target: {target_total_words} words total, with 5% buffer allowed).
|
||||
You have written approximately {current_word_count} words so far, leaving approximately
|
||||
{remaining_words} words remaining.
|
||||
|
||||
**CRITICAL INSTRUCTIONS - READ CAREFULLY:**
|
||||
1. Write the next section with appropriate detail, aiming for approximately {min(continuation_word_count, remaining_words)} words.
|
||||
2. **STOP CONDITION:** If after writing this continuation, the total word count will reach or exceed {target_total_words} words, you MUST conclude the story immediately by writing IAMDONE.
|
||||
3. The story should reach a natural conclusion that addresses all plot elements and provides satisfying closure.
|
||||
4. Once you've written IAMDONE, do NOT write any more content - stop immediately.
|
||||
|
||||
**WORD COUNT LIMIT:**
|
||||
- Target: {target_total_words} words total (with 5% buffer: {int(target_total_words * 1.05)} words maximum)
|
||||
- Current word count: {current_word_count} words
|
||||
- Remaining words: {remaining_words} words
|
||||
- **CRITICAL: If your continuation would bring the total to {target_total_words} words or more, conclude the story NOW and write IAMDONE.**
|
||||
- **Do NOT exceed {int(target_total_words * 1.05)} words. This is a hard limit.**
|
||||
- **Ensure the story is complete and makes sense when you write IAMDONE.**
|
||||
|
||||
{self.guidelines}
|
||||
"""
|
||||
|
||||
try:
|
||||
continuation = self.generate_with_retry(continuation_prompt, user_id=user_id)
|
||||
return continuation.strip()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"Story Continuation Error: {exc}")
|
||||
raise RuntimeError(f"Failed to continue story: {exc}") from exc
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Full generation orchestration
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def generate_full_story(
|
||||
self,
|
||||
*,
|
||||
persona: str,
|
||||
story_setting: str,
|
||||
character_input: str,
|
||||
plot_elements: str,
|
||||
writing_style: str,
|
||||
story_tone: str,
|
||||
narrative_pov: str,
|
||||
audience_age_group: str,
|
||||
content_rating: str,
|
||||
ending_preference: str,
|
||||
user_id: str,
|
||||
max_iterations: int = 10,
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate a complete story using prompt chaining."""
|
||||
try:
|
||||
logger.info(f"[StoryWriter] Generating premise for user {user_id}")
|
||||
premise = self.generate_premise(
|
||||
persona=persona,
|
||||
story_setting=story_setting,
|
||||
character_input=character_input,
|
||||
plot_elements=plot_elements,
|
||||
writing_style=writing_style,
|
||||
story_tone=story_tone,
|
||||
narrative_pov=narrative_pov,
|
||||
audience_age_group=audience_age_group,
|
||||
content_rating=content_rating,
|
||||
ending_preference=ending_preference,
|
||||
user_id=user_id,
|
||||
)
|
||||
if not premise:
|
||||
raise RuntimeError("Failed to generate premise")
|
||||
|
||||
logger.info(f"[StoryWriter] Generating outline for user {user_id}")
|
||||
outline = self.generate_outline(
|
||||
premise=premise,
|
||||
persona=persona,
|
||||
story_setting=story_setting,
|
||||
character_input=character_input,
|
||||
plot_elements=plot_elements,
|
||||
writing_style=writing_style,
|
||||
story_tone=story_tone,
|
||||
narrative_pov=narrative_pov,
|
||||
audience_age_group=audience_age_group,
|
||||
content_rating=content_rating,
|
||||
ending_preference=ending_preference,
|
||||
user_id=user_id,
|
||||
)
|
||||
if not outline:
|
||||
raise RuntimeError("Failed to generate outline")
|
||||
|
||||
logger.info(f"[StoryWriter] Generating story start for user {user_id}")
|
||||
draft = self.generate_story_start(
|
||||
premise=premise,
|
||||
outline=outline,
|
||||
persona=persona,
|
||||
story_setting=story_setting,
|
||||
character_input=character_input,
|
||||
plot_elements=plot_elements,
|
||||
writing_style=writing_style,
|
||||
story_tone=story_tone,
|
||||
narrative_pov=narrative_pov,
|
||||
audience_age_group=audience_age_group,
|
||||
content_rating=content_rating,
|
||||
ending_preference=ending_preference,
|
||||
user_id=user_id,
|
||||
)
|
||||
if not draft:
|
||||
raise RuntimeError("Failed to generate story start")
|
||||
|
||||
iteration = 0
|
||||
while "IAMDONE" not in draft and iteration < max_iterations:
|
||||
iteration += 1
|
||||
logger.info(f"[StoryWriter] Continuation iteration {iteration}/{max_iterations}")
|
||||
continuation = self.continue_story(
|
||||
premise=premise,
|
||||
outline=outline,
|
||||
story_text=draft,
|
||||
persona=persona,
|
||||
story_setting=story_setting,
|
||||
character_input=character_input,
|
||||
plot_elements=plot_elements,
|
||||
writing_style=writing_style,
|
||||
story_tone=story_tone,
|
||||
narrative_pov=narrative_pov,
|
||||
audience_age_group=audience_age_group,
|
||||
content_rating=content_rating,
|
||||
ending_preference=ending_preference,
|
||||
user_id=user_id,
|
||||
)
|
||||
if continuation:
|
||||
draft += "\n\n" + continuation
|
||||
else:
|
||||
logger.warning(f"[StoryWriter] Empty continuation at iteration {iteration}")
|
||||
break
|
||||
|
||||
final_story = draft.replace("IAMDONE", "").strip()
|
||||
|
||||
outline_response = outline
|
||||
if isinstance(outline, list):
|
||||
outline_response = "\n".join(
|
||||
[
|
||||
f"Scene {scene.get('scene_number', idx + 1)}: {scene.get('title', 'Untitled')}\n"
|
||||
f" {scene.get('description', '')}"
|
||||
for idx, scene in enumerate(outline)
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
"premise": premise,
|
||||
"outline": str(outline_response),
|
||||
"story": final_story,
|
||||
"iterations": iteration,
|
||||
"is_complete": "IAMDONE" in draft or iteration >= max_iterations,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.error(f"[StoryWriter] Error generating full story: {exc}")
|
||||
raise RuntimeError(f"Failed to generate full story: {exc}") from exc
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Multimedia helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def generate_scene_images(
|
||||
self,
|
||||
*,
|
||||
scenes: List[Dict[str, Any]],
|
||||
user_id: str,
|
||||
provider: Optional[str] = None,
|
||||
width: int = 1024,
|
||||
height: int = 1024,
|
||||
model: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Generate images for story scenes."""
|
||||
image_service = StoryImageGenerationService()
|
||||
return image_service.generate_scene_images(
|
||||
scenes=scenes, user_id=user_id, provider=provider, width=width, height=height, model=model
|
||||
)
|
||||
|
||||
30
backend/services/story_writer/story_service.py
Normal file
30
backend/services/story_writer/story_service.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
Story Writer Service
|
||||
|
||||
Core service for generating stories using prompt chaining approach.
|
||||
Migrated from ToBeMigrated/ai_writers/ai_story_writer/ai_story_generator.py
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional, List
|
||||
from loguru import logger
|
||||
from fastapi import HTTPException
|
||||
import json
|
||||
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
from services.story_writer.service_components import (
|
||||
StoryContentMixin,
|
||||
StoryOutlineMixin,
|
||||
StoryServiceBase,
|
||||
StorySetupMixin,
|
||||
)
|
||||
|
||||
|
||||
class StoryWriterService(
|
||||
StoryContentMixin,
|
||||
StorySetupMixin,
|
||||
StoryOutlineMixin,
|
||||
StoryServiceBase,
|
||||
):
|
||||
"""Facade class combining story writer behaviours via modular mixins."""
|
||||
|
||||
__slots__ = ()
|
||||
294
backend/services/story_writer/video_generation_service.py
Normal file
294
backend/services/story_writer/video_generation_service.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""
|
||||
Video Generation Service for Story Writer
|
||||
|
||||
Combines images and audio into animated video clips using MoviePy.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from typing import List, Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
from loguru import logger
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
class StoryVideoGenerationService:
|
||||
"""Service for generating videos from story scenes, images, and audio."""
|
||||
|
||||
def __init__(self, output_dir: Optional[str] = None):
|
||||
"""
|
||||
Initialize the video generation service.
|
||||
|
||||
Parameters:
|
||||
output_dir (str, optional): Directory to save generated videos.
|
||||
Defaults to 'backend/story_videos' if not provided.
|
||||
"""
|
||||
if output_dir:
|
||||
self.output_dir = Path(output_dir)
|
||||
else:
|
||||
# Default to backend/story_videos directory
|
||||
base_dir = Path(__file__).parent.parent.parent
|
||||
self.output_dir = base_dir / "story_videos"
|
||||
|
||||
# Create output directory if it doesn't exist
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"[StoryVideoGeneration] Initialized with output directory: {self.output_dir}")
|
||||
|
||||
def _generate_video_filename(self, story_title: str = "story") -> str:
|
||||
"""Generate a unique filename for a story video."""
|
||||
# Clean story title for filename
|
||||
clean_title = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in story_title[:30])
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
return f"story_{clean_title}_{unique_id}.mp4"
|
||||
|
||||
def generate_scene_video(
|
||||
self,
|
||||
scene: Dict[str, Any],
|
||||
image_path: str,
|
||||
audio_path: str,
|
||||
user_id: str,
|
||||
duration: Optional[float] = None,
|
||||
fps: int = 24
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate a video clip for a single story scene.
|
||||
|
||||
Parameters:
|
||||
scene (Dict[str, Any]): Scene data.
|
||||
image_path (str): Path to the scene image file.
|
||||
audio_path (str): Path to the scene audio file.
|
||||
user_id (str): Clerk user ID for subscription checking (for future usage tracking).
|
||||
duration (float, optional): Video duration in seconds. If None, uses audio duration.
|
||||
fps (int): Frames per second for video (default: 24).
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Video metadata including file path, URL, and scene info.
|
||||
"""
|
||||
scene_number = scene.get("scene_number", 0)
|
||||
scene_title = scene.get("title", "Untitled")
|
||||
|
||||
try:
|
||||
logger.info(f"[StoryVideoGeneration] Generating video for scene {scene_number}: {scene_title}")
|
||||
|
||||
# Import MoviePy
|
||||
try:
|
||||
from moviepy.editor import ImageClip, AudioFileClip, concatenate_videoclips, CompositeVideoClip
|
||||
except ImportError:
|
||||
logger.error("[StoryVideoGeneration] MoviePy not installed. Install with: pip install moviepy imageio imageio-ffmpeg")
|
||||
raise RuntimeError("MoviePy is not installed. Please install it to generate videos.")
|
||||
|
||||
# Load image and audio
|
||||
image_file = Path(image_path)
|
||||
audio_file = Path(audio_path)
|
||||
|
||||
if not image_file.exists():
|
||||
raise FileNotFoundError(f"Image not found: {image_path}")
|
||||
if not audio_file.exists():
|
||||
raise FileNotFoundError(f"Audio not found: {audio_path}")
|
||||
|
||||
# Load audio to get duration
|
||||
audio_clip = AudioFileClip(str(audio_file))
|
||||
audio_duration = audio_clip.duration
|
||||
|
||||
# Use provided duration or audio duration
|
||||
video_duration = duration if duration is not None else audio_duration
|
||||
|
||||
# Create image clip
|
||||
image_clip = ImageClip(str(image_file)).set_duration(video_duration)
|
||||
image_clip = image_clip.set_fps(fps)
|
||||
|
||||
# Set audio to image clip
|
||||
video_clip = image_clip.set_audio(audio_clip)
|
||||
|
||||
# Generate video filename
|
||||
video_filename = f"scene_{scene_number}_{scene_title.replace(' ', '_').replace('/', '_')[:50]}_{uuid.uuid4().hex[:8]}.mp4"
|
||||
video_path = self.output_dir / video_filename
|
||||
|
||||
# Write video file
|
||||
video_clip.write_videofile(
|
||||
str(video_path),
|
||||
fps=fps,
|
||||
codec='libx264',
|
||||
audio_codec='aac',
|
||||
preset='medium',
|
||||
threads=4,
|
||||
logger=None # Disable MoviePy's default logger
|
||||
)
|
||||
|
||||
# Clean up clips
|
||||
video_clip.close()
|
||||
audio_clip.close()
|
||||
image_clip.close()
|
||||
|
||||
# Get file size
|
||||
file_size = video_path.stat().st_size
|
||||
|
||||
logger.info(f"[StoryVideoGeneration] Saved video to: {video_path} ({file_size} bytes)")
|
||||
|
||||
# Return video metadata
|
||||
return {
|
||||
"scene_number": scene_number,
|
||||
"scene_title": scene_title,
|
||||
"video_path": str(video_path),
|
||||
"video_filename": video_filename,
|
||||
"video_url": f"/api/story/videos/{video_filename}", # API endpoint to serve videos
|
||||
"duration": video_duration,
|
||||
"fps": fps,
|
||||
"file_size": file_size,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTPExceptions (e.g., 429 subscription limit)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[StoryVideoGeneration] Error generating video for scene {scene_number}: {e}")
|
||||
raise RuntimeError(f"Failed to generate video for scene {scene_number}: {str(e)}") from e
|
||||
|
||||
def generate_story_video(
|
||||
self,
|
||||
scenes: List[Dict[str, Any]],
|
||||
image_paths: List[str],
|
||||
audio_paths: List[str],
|
||||
user_id: str,
|
||||
story_title: str = "Story",
|
||||
fps: int = 24,
|
||||
transition_duration: float = 0.5,
|
||||
progress_callback: Optional[callable] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate a complete story video from multiple scenes.
|
||||
|
||||
Parameters:
|
||||
scenes (List[Dict[str, Any]]): List of scene data.
|
||||
image_paths (List[str]): List of image file paths for each scene.
|
||||
audio_paths (List[str]): List of audio file paths for each scene.
|
||||
user_id (str): Clerk user ID for subscription checking.
|
||||
story_title (str): Title of the story (default: "Story").
|
||||
fps (int): Frames per second for video (default: 24).
|
||||
transition_duration (float): Duration of transitions between scenes in seconds (default: 0.5).
|
||||
progress_callback (callable, optional): Callback function for progress updates.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Video metadata including file path, URL, and story info.
|
||||
"""
|
||||
if not scenes or not image_paths or not audio_paths:
|
||||
raise ValueError("Scenes, image paths, and audio paths are required")
|
||||
|
||||
if len(scenes) != len(image_paths) or len(scenes) != len(audio_paths):
|
||||
raise ValueError("Number of scenes, image paths, and audio paths must match")
|
||||
|
||||
try:
|
||||
logger.info(f"[StoryVideoGeneration] Generating story video for {len(scenes)} scenes")
|
||||
|
||||
# Import MoviePy
|
||||
try:
|
||||
from moviepy.editor import ImageClip, AudioFileClip, concatenate_videoclips, CompositeVideoClip
|
||||
except ImportError:
|
||||
logger.error("[StoryVideoGeneration] MoviePy not installed. Install with: pip install moviepy imageio imageio-ffmpeg")
|
||||
raise RuntimeError("MoviePy is not installed. Please install it to generate videos.")
|
||||
|
||||
scene_clips = []
|
||||
total_duration = 0.0
|
||||
|
||||
for idx, (scene, image_path, audio_path) in enumerate(zip(scenes, image_paths, audio_paths)):
|
||||
try:
|
||||
scene_number = scene.get("scene_number", idx + 1)
|
||||
scene_title = scene.get("title", "Untitled")
|
||||
|
||||
logger.info(f"[StoryVideoGeneration] Processing scene {scene_number}/{len(scenes)}: {scene_title}")
|
||||
|
||||
# Load image and audio
|
||||
image_file = Path(image_path)
|
||||
audio_file = Path(audio_path)
|
||||
|
||||
if not image_file.exists():
|
||||
logger.warning(f"[StoryVideoGeneration] Image not found: {image_path}, skipping scene {scene_number}")
|
||||
continue
|
||||
if not audio_file.exists():
|
||||
logger.warning(f"[StoryVideoGeneration] Audio not found: {audio_path}, skipping scene {scene_number}")
|
||||
continue
|
||||
|
||||
# Load audio to get duration
|
||||
audio_clip = AudioFileClip(str(audio_file))
|
||||
audio_duration = audio_clip.duration
|
||||
|
||||
# Create image clip
|
||||
image_clip = ImageClip(str(image_file)).set_duration(audio_duration)
|
||||
image_clip = image_clip.set_fps(fps)
|
||||
|
||||
# Set audio to image clip
|
||||
video_clip = image_clip.set_audio(audio_clip)
|
||||
scene_clips.append(video_clip)
|
||||
|
||||
total_duration += audio_duration
|
||||
|
||||
# Call progress callback if provided
|
||||
if progress_callback:
|
||||
progress = ((idx + 1) / len(scenes)) * 90 # Reserve 10% for final composition
|
||||
progress_callback(progress, f"Processed scene {scene_number}/{len(scenes)}")
|
||||
|
||||
logger.info(f"[StoryVideoGeneration] Processed scene {idx + 1}/{len(scenes)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[StoryVideoGeneration] Failed to process scene {idx + 1}: {e}")
|
||||
# Continue with next scene instead of failing completely
|
||||
continue
|
||||
|
||||
if not scene_clips:
|
||||
raise RuntimeError("No valid scene clips were created")
|
||||
|
||||
# Concatenate all scene clips
|
||||
logger.info(f"[StoryVideoGeneration] Concatenating {len(scene_clips)} scene clips")
|
||||
final_video = concatenate_videoclips(scene_clips, method="compose")
|
||||
|
||||
# Generate video filename
|
||||
video_filename = self._generate_video_filename(story_title)
|
||||
video_path = self.output_dir / video_filename
|
||||
|
||||
# Call progress callback
|
||||
if progress_callback:
|
||||
progress_callback(95, "Rendering final video...")
|
||||
|
||||
# Write video file
|
||||
final_video.write_videofile(
|
||||
str(video_path),
|
||||
fps=fps,
|
||||
codec='libx264',
|
||||
audio_codec='aac',
|
||||
preset='medium',
|
||||
threads=4,
|
||||
logger=None # Disable MoviePy's default logger
|
||||
)
|
||||
|
||||
# Get file size
|
||||
file_size = video_path.stat().st_size
|
||||
|
||||
# Clean up clips
|
||||
final_video.close()
|
||||
for clip in scene_clips:
|
||||
clip.close()
|
||||
|
||||
# Call progress callback
|
||||
if progress_callback:
|
||||
progress_callback(100, "Video generation complete!")
|
||||
|
||||
logger.info(f"[StoryVideoGeneration] Saved story video to: {video_path} ({file_size} bytes)")
|
||||
|
||||
# Return video metadata
|
||||
return {
|
||||
"video_path": str(video_path),
|
||||
"video_filename": video_filename,
|
||||
"video_url": f"/api/story/videos/{video_filename}", # API endpoint to serve videos
|
||||
"duration": total_duration,
|
||||
"fps": fps,
|
||||
"file_size": file_size,
|
||||
"num_scenes": len(scene_clips),
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTPExceptions (e.g., 429 subscription limit)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[StoryVideoGeneration] Error generating story video: {e}")
|
||||
raise RuntimeError(f"Failed to generate story video: {str(e)}") from e
|
||||
|
||||
Reference in New Issue
Block a user