Release Candidate: Production Release with Multi-Tenant & Onboarding Enhancements

This commit is contained in:
ajaysi
2026-02-28 20:06:26 +05:30
parent 08a1f4a1d8
commit 4828274cbf
162 changed files with 19489 additions and 4300 deletions

View File

@@ -0,0 +1,73 @@
"""
Story Project API Models
Pydantic models for Story Studio project endpoints.
"""
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class StoryProjectResponse(BaseModel):
id: int
project_id: str
user_id: str
title: Optional[str] = None
story_mode: Optional[str] = None
story_template: Optional[str] = None
setup: Optional[Dict[str, Any]] = None
outline: Optional[Dict[str, Any]] = None
scenes: Optional[List[Dict[str, Any]]] = None
story_content: Optional[Dict[str, Any]] = None
anime_bible: Optional[Dict[str, Any]] = None
media_state: Optional[Dict[str, Any]] = None
current_phase: Optional[str] = None
status: str = "draft"
is_favorite: bool = False
is_complete: bool = False
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class StoryProjectListResponse(BaseModel):
projects: List[StoryProjectResponse]
total: int
limit: int
offset: int
class CreateStoryProjectRequest(BaseModel):
project_id: str = Field(..., description="Unique story project ID")
title: Optional[str] = Field(None, description="Optional story project title or idea")
story_mode: Optional[str] = Field(
None, description="Story mode (marketing or pure) if provided by the UI"
)
story_template: Optional[str] = Field(
None,
description="Optional story template identifier (e.g. product_story, anime_fiction)",
)
setup: Optional[Dict[str, Any]] = Field(
None,
description="Initial story setup payload to persist with the project",
)
class UpdateStoryProjectRequest(BaseModel):
title: Optional[str] = None
story_mode: Optional[str] = None
story_template: Optional[str] = None
setup: Optional[Dict[str, Any]] = None
outline: Optional[Dict[str, Any]] = None
scenes: Optional[List[Dict[str, Any]]] = None
story_content: Optional[Dict[str, Any]] = None
anime_bible: Optional[Dict[str, Any]] = None
media_state: Optional[Dict[str, Any]] = None
current_phase: Optional[str] = None
status: Optional[str] = None
is_complete: Optional[bool] = None

View File

@@ -14,6 +14,7 @@ from .routes import (
media_generation,
scene_animation,
story_content,
story_projects,
story_setup,
story_tasks,
video_generation,
@@ -24,6 +25,7 @@ router = APIRouter(prefix="/api/story", tags=["Story Writer"])
# Include modular routers (order preserved roughly by workflow)
router.include_router(story_setup.router)
router.include_router(story_content.router)
router.include_router(story_projects.router)
router.include_router(story_tasks.router)
router.include_router(media_generation.router)
router.include_router(scene_animation.router)

View File

@@ -65,7 +65,7 @@ async def generate_scene_images(
scene_number=result.get("scene_number", 0),
scene_title=result.get("scene_title", "Untitled"),
image_filename=result.get("image_filename", ""),
image_url=result.get("image_url", ""),
image_url=result.get("image_url") or "",
width=result.get("width", 1024),
height=result.get("height", 1024),
provider=result.get("provider", "unknown"),
@@ -148,7 +148,7 @@ async def regenerate_scene_image(
scene_number=result.get("scene_number", request.scene_number),
scene_title=result.get("scene_title", request.scene_title),
image_filename=result.get("image_filename", ""),
image_url=result.get("image_url", ""),
image_url=result.get("image_url") or "",
width=result.get("width", request.width or 1024),
height=result.get("height", request.height or 1024),
provider=result.get("provider", "unknown"),

View File

@@ -12,6 +12,10 @@ from models.story_models import (
StoryScene,
StoryContinueRequest,
StoryContinueResponse,
AnimeSceneTextRequest,
AnimeSceneTextResponse,
AnimeSceneGenerateRequest,
AnimeSceneGenerateResponse,
)
from services.story_writer.story_service import StoryWriterService
@@ -107,6 +111,7 @@ async def generate_story_start(
content_rating=request.content_rating,
ending_preference=request.ending_preference,
story_length=story_length,
anime_bible=getattr(request, "anime_bible", None),
user_id=user_id,
)
@@ -211,6 +216,7 @@ async def continue_story(
audience_age_group=request.audience_age_group,
content_rating=request.content_rating,
ending_preference=request.ending_preference,
anime_bible=getattr(request, "anime_bible", None),
story_length=story_length,
user_id=user_id,
)
@@ -245,6 +251,105 @@ async def continue_story(
raise HTTPException(status_code=500, detail=str(exc))
@router.post("/anime/scene-text", response_model=AnimeSceneTextResponse)
async def refine_anime_scene_text(
request: AnimeSceneTextRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
) -> AnimeSceneTextResponse:
try:
user_id = require_authenticated_user(current_user)
scene_dict = request.scene.dict()
if not scene_dict.get("title") and not scene_dict.get("description"):
raise HTTPException(status_code=400, detail="Scene title or description is required")
refined = story_service.refine_anime_scene_text(
scene=scene_dict,
persona=request.persona,
story_setting=request.story_setting,
character_input=request.character_input,
plot_elements=request.plot_elements,
writing_style=request.writing_style,
story_tone=request.story_tone,
narrative_pov=request.narrative_pov,
audience_age_group=request.audience_age_group,
content_rating=request.content_rating,
anime_bible=request.anime_bible,
user_id=user_id,
)
refined_scene = StoryScene(
scene_number=refined.get("scene_number", request.scene.scene_number),
title=refined.get("title", request.scene.title),
description=refined.get("description", request.scene.description),
image_prompt=refined.get("image_prompt", request.scene.image_prompt),
audio_narration=refined.get("audio_narration", request.scene.audio_narration),
character_descriptions=refined.get(
"character_descriptions", request.scene.character_descriptions
),
key_events=refined.get("key_events", request.scene.key_events),
)
return AnimeSceneTextResponse(scene=refined_scene, success=True)
except HTTPException:
raise
except Exception as exc:
logger.error(f"[StoryWriter] Failed to refine anime scene text: {exc}")
raise HTTPException(status_code=500, detail=str(exc))
@router.post("/anime/scene-generate", response_model=AnimeSceneGenerateResponse)
async def generate_anime_scene_from_bible(
request: AnimeSceneGenerateRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
) -> AnimeSceneGenerateResponse:
try:
user_id = require_authenticated_user(current_user)
if not request.anime_bible:
raise HTTPException(status_code=400, detail="Anime story bible is required")
previous_scenes_payload: Optional[List[Dict[str, Any]]] = None
if request.previous_scenes:
previous_scenes_payload = [scene.dict() for scene in request.previous_scenes]
generated = story_service.generate_anime_scene_from_bible(
premise=request.premise,
persona=request.persona,
story_setting=request.story_setting,
character_input=request.character_input,
plot_elements=request.plot_elements,
writing_style=request.writing_style,
story_tone=request.story_tone,
narrative_pov=request.narrative_pov,
audience_age_group=request.audience_age_group,
content_rating=request.content_rating,
anime_bible=request.anime_bible,
previous_scenes=previous_scenes_payload,
target_scene_number=request.target_scene_number,
user_id=user_id,
)
scene = StoryScene(
scene_number=generated.get("scene_number"),
title=generated.get("title", ""),
description=generated.get("description", ""),
image_prompt=generated.get("image_prompt", ""),
audio_narration=generated.get("audio_narration", ""),
character_descriptions=generated.get("character_descriptions") or [],
key_events=generated.get("key_events") or [],
)
return AnimeSceneGenerateResponse(scene=scene, success=True)
except HTTPException:
raise
except Exception as exc:
logger.error(f"[StoryWriter] Failed to generate anime scene from bible: {exc}")
raise HTTPException(status_code=500, detail=str(exc))
class SceneApprovalRequest(BaseModel):
project_id: str = Field(..., min_length=1)
scene_id: str = Field(..., min_length=1)

View File

@@ -0,0 +1,189 @@
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from middleware.auth_middleware import get_current_user
from services.database import get_db
from services.story_writer.story_project_service import StoryProjectService
from ..models_projects import (
CreateStoryProjectRequest,
StoryProjectListResponse,
StoryProjectResponse,
UpdateStoryProjectRequest,
)
router = APIRouter()
@router.post("/projects", response_model=StoryProjectResponse, status_code=201)
async def create_story_project(
request: CreateStoryProjectRequest,
db: Session = Depends(get_db),
current_user: Dict[str, Any] = Depends(get_current_user),
) -> StoryProjectResponse:
try:
user_id = current_user.get("user_id") or current_user.get("id")
if not user_id:
raise HTTPException(status_code=401, detail="User ID not found")
service = StoryProjectService(db)
existing = service.get_project(user_id, request.project_id)
if existing:
raise HTTPException(status_code=400, detail="Project ID already exists")
project = service.create_project(
user_id=user_id,
project_id=request.project_id,
title=request.title,
story_mode=request.story_mode,
story_template=request.story_template,
setup=request.setup,
)
return StoryProjectResponse.model_validate(project)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error creating story project: {str(e)}")
@router.get("/projects/{project_id}", response_model=StoryProjectResponse)
async def get_story_project(
project_id: str,
db: Session = Depends(get_db),
current_user: Dict[str, Any] = Depends(get_current_user),
) -> StoryProjectResponse:
try:
user_id = current_user.get("user_id") or current_user.get("id")
if not user_id:
raise HTTPException(status_code=401, detail="User ID not found")
service = StoryProjectService(db)
project = service.get_project(user_id, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return StoryProjectResponse.model_validate(project)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error fetching story project: {str(e)}")
@router.put("/projects/{project_id}", response_model=StoryProjectResponse)
async def update_story_project(
project_id: str,
request: UpdateStoryProjectRequest,
db: Session = Depends(get_db),
current_user: Dict[str, Any] = Depends(get_current_user),
) -> StoryProjectResponse:
try:
user_id = current_user.get("user_id") or current_user.get("id")
if not user_id:
raise HTTPException(status_code=401, detail="User ID not found")
service = StoryProjectService(db)
updates = request.model_dump(exclude_unset=True)
project = service.update_project(user_id, project_id, **updates)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return StoryProjectResponse.model_validate(project)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error updating story project: {str(e)}")
@router.get("/projects", response_model=StoryProjectListResponse)
async def list_story_projects(
status: Optional[str] = Query(None, description="Filter by status"),
favorites_only: bool = Query(False, description="Only favorites"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
order_by: str = Query("updated_at", description="Order by: updated_at or created_at"),
db: Session = Depends(get_db),
current_user: Dict[str, Any] = Depends(get_current_user),
) -> StoryProjectListResponse:
try:
user_id = current_user.get("user_id") or current_user.get("id")
if not user_id:
raise HTTPException(status_code=401, detail="User ID not found")
if order_by not in ["updated_at", "created_at"]:
raise HTTPException(status_code=400, detail="order_by must be 'updated_at' or 'created_at'")
service = StoryProjectService(db)
projects, total = service.list_projects(
user_id=user_id,
status=status,
favorites_only=favorites_only,
limit=limit,
offset=offset,
order_by=order_by,
)
return StoryProjectListResponse(
projects=[StoryProjectResponse.model_validate(p) for p in projects],
total=total,
limit=limit,
offset=offset,
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error listing story projects: {str(e)}")
@router.delete("/projects/{project_id}", status_code=204)
async def delete_story_project(
project_id: str,
db: Session = Depends(get_db),
current_user: Dict[str, Any] = Depends(get_current_user),
) -> None:
try:
user_id = current_user.get("user_id") or current_user.get("id")
if not user_id:
raise HTTPException(status_code=401, detail="User ID not found")
service = StoryProjectService(db)
deleted = service.delete_project(user_id, project_id)
if not deleted:
raise HTTPException(status_code=404, detail="Project not found")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error deleting story project: {str(e)}")
@router.post("/projects/{project_id}/favorite", response_model=StoryProjectResponse)
async def toggle_story_project_favorite(
project_id: str,
db: Session = Depends(get_db),
current_user: Dict[str, Any] = Depends(get_current_user),
) -> StoryProjectResponse:
try:
user_id = current_user.get("user_id") or current_user.get("id")
if not user_id:
raise HTTPException(status_code=401, detail="User ID not found")
service = StoryProjectService(db)
project = service.toggle_favorite(user_id, project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return StoryProjectResponse.model_validate(project)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error toggling story project favorite: {str(e)}")

View File

@@ -2,6 +2,8 @@ from typing import Any, Dict, List
from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
from sqlalchemy.orm import Session
from sqlalchemy import desc
from middleware.auth_middleware import get_current_user
from models.story_models import (
@@ -13,8 +15,14 @@ from models.story_models import (
StoryScene,
StoryStartRequest,
StoryPremiseResponse,
StoryIdeaEnhanceRequest,
StoryIdeaEnhanceResponse,
StoryIdeaEnhanceSuggestion,
)
from services.story_writer.story_service import StoryWriterService
from api.onboarding_utils.onboarding_summary_service import OnboardingSummaryService
from services.database import get_session_for_user
from models.content_asset_models import ContentAsset, AssetType, AssetSource
from ..utils.auth import require_authenticated_user
@@ -39,6 +47,9 @@ async def generate_story_setup(
options = story_service.generate_story_setup_options(
story_idea=request.story_idea,
story_mode=request.story_mode,
story_template=request.story_template,
brand_context=request.brand_context,
user_id=user_id,
)
@@ -52,6 +63,152 @@ async def generate_story_setup(
raise HTTPException(status_code=500, detail=str(exc))
@router.post("/enhance-idea", response_model=StoryIdeaEnhanceResponse)
async def enhance_story_idea(
request: StoryIdeaEnhanceRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
) -> StoryIdeaEnhanceResponse:
try:
user_id = require_authenticated_user(current_user)
if not request.story_idea or not request.story_idea.strip():
raise HTTPException(status_code=400, detail="Story idea is required")
logger.info(f"[StoryWriter] Enhancing story idea for user {user_id}")
suggestions = story_service.enhance_story_idea(
story_idea=request.story_idea,
story_mode=request.story_mode,
story_template=request.story_template,
brand_context=request.brand_context,
user_id=user_id,
fiction_variant=request.fiction_variant,
narrative_energy=request.narrative_energy,
)
return StoryIdeaEnhanceResponse(
suggestions=[StoryIdeaEnhanceSuggestion(**s) for s in suggestions],
success=True,
)
except HTTPException:
raise
except Exception as exc:
logger.error(f"[StoryWriter] Failed to enhance story idea: {exc}")
raise HTTPException(status_code=500, detail=str(exc))
@router.get("/context")
async def get_story_context(
current_user: Dict[str, Any] = Depends(get_current_user),
) -> Dict[str, Any]:
"""Return onboarding-based story context for the current user."""
try:
user_id = require_authenticated_user(current_user)
summary_service = OnboardingSummaryService(user_id)
summary = await summary_service.get_onboarding_summary()
canonical_profile = summary.get("canonical_profile") or {}
persona_readiness = summary.get("persona_readiness") or {}
capabilities = summary.get("capabilities") or {}
website_url = summary.get("website_url")
style_analysis = summary.get("style_analysis") or {}
research_preferences = summary.get("research_preferences") or {}
brand_name = None
if isinstance(style_analysis, dict):
brand_name = style_analysis.get("brand_name") or style_analysis.get("site_title")
writing_tone = canonical_profile.get("writing_tone")
target_audience = canonical_profile.get("target_audience")
brand_context = {
"brand_name": brand_name,
"writing_tone": writing_tone,
"target_audience": target_audience,
}
avatar_url = None
voice_preview_url = None
custom_voice_id = None
db: Session | None = get_session_for_user(user_id)
if db:
try:
avatar_asset = (
db.query(ContentAsset)
.filter(
ContentAsset.user_id == user_id,
ContentAsset.asset_type == AssetType.IMAGE,
ContentAsset.source_module.in_(
[AssetSource.BRAND_AVATAR_GENERATOR, AssetSource.STORY_WRITER]
),
)
.order_by(desc(ContentAsset.created_at))
.limit(50)
.all()
)
selected_avatar = None
for candidate in avatar_asset:
if candidate.source_module == AssetSource.BRAND_AVATAR_GENERATOR:
selected_avatar = candidate
break
meta = candidate.asset_metadata or {}
if meta.get("category") == "brand_avatar":
selected_avatar = candidate
break
if selected_avatar:
avatar_url = selected_avatar.file_url
voice_asset = (
db.query(ContentAsset)
.filter(
ContentAsset.user_id == user_id,
ContentAsset.asset_type == AssetType.AUDIO,
ContentAsset.source_module == AssetSource.VOICE_CLONER,
)
.order_by(desc(ContentAsset.created_at))
.first()
)
if voice_asset:
meta = voice_asset.asset_metadata or {}
voice_preview_url = meta.get("preview_url") or voice_asset.file_url
custom_voice_id = meta.get("custom_voice_id")
finally:
db.close()
persona_enabled = bool(persona_readiness.get("ready")) and bool(
capabilities.get("persona_generation")
)
has_persona_context = persona_enabled and bool(
brand_name or writing_tone or target_audience or avatar_url or voice_preview_url
)
return {
"canonical_profile": canonical_profile,
"website_url": website_url,
"research_preferences": research_preferences,
"brand_context": brand_context,
"brand_assets": {
"avatar_url": avatar_url,
"voice_preview_url": voice_preview_url,
"custom_voice_id": custom_voice_id,
},
"persona_enabled": persona_enabled,
"has_persona_context": has_persona_context,
}
except HTTPException:
raise
except Exception as exc:
logger.error(f"[StoryWriter] Failed to get story context: {exc}")
raise HTTPException(status_code=500, detail="Failed to load story context")
@router.post("/generate-premise", response_model=StoryPremiseResponse)
async def generate_premise(
request: StoryGenerationRequest,
@@ -108,6 +265,9 @@ async def generate_outline(
request.story_tone,
)
# For now, treat all outlines as potentially anime-aware. The downstream
# generation logic will decide whether to actually create a bible based
# on how the prompt is interpreted (e.g., anime templates in persona).
outline = story_service.generate_outline(
premise=request.premise,
persona=request.persona,
@@ -122,15 +282,37 @@ async def generate_outline(
ending_preference=request.ending_preference,
user_id=user_id,
use_structured_output=use_structured,
include_anime_bible=True,
)
if isinstance(outline, list):
scenes: List[StoryScene] = [
StoryScene(**scene) if isinstance(scene, dict) else scene for scene in outline
]
return StoryOutlineResponse(outline=scenes, success=True, is_structured=True)
anime_bible: Dict[str, Any] | None = None
outline_payload: Any = outline
return StoryOutlineResponse(outline=str(outline), success=True, is_structured=False)
if isinstance(outline, dict):
if "anime_bible" in outline:
anime_bible = outline.get("anime_bible")
if "scenes" in outline:
outline_payload = outline.get("scenes")
elif "outline" in outline:
outline_payload = outline.get("outline")
if isinstance(outline_payload, list):
scenes: List[StoryScene] = [
StoryScene(**scene) if isinstance(scene, dict) else scene for scene in outline_payload
]
return StoryOutlineResponse(
outline=scenes,
success=True,
is_structured=True,
anime_bible=anime_bible,
)
return StoryOutlineResponse(
outline=str(outline_payload),
success=True,
is_structured=False,
anime_bible=anime_bible,
)
except HTTPException:
raise

View File

@@ -1,6 +1,7 @@
from pathlib import Path
from typing import Any, Dict, List, Optional
from concurrent.futures import ThreadPoolExecutor
import json
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from fastapi.responses import FileResponse
@@ -350,9 +351,21 @@ def execute_complete_video_generation(
Runs in a background task and performs blocking operations.
"""
try:
task_manager.update_task_status(task_id, "processing", progress=5.0, message="Starting complete video generation...")
task_manager.update_task_status(
task_id,
"processing",
progress=5.0,
message="Starting complete video generation...",
)
task_manager.update_task_status(task_id, "processing", progress=10.0, message="Generating story premise...")
anime_bible = request_data.get("anime_bible")
task_manager.update_task_status(
task_id,
"processing",
progress=10.0,
message="Generating story premise...",
)
premise = story_service.generate_premise(
persona=request_data["persona"],
story_setting=request_data["story_setting"],
@@ -367,7 +380,12 @@ def execute_complete_video_generation(
user_id=user_id,
)
task_manager.update_task_status(task_id, "processing", progress=20.0, message="Generating structured outline with scenes...")
task_manager.update_task_status(
task_id,
"processing",
progress=20.0,
message="Generating structured outline with scenes...",
)
outline_scenes = story_service.generate_outline(
premise=premise,
persona=request_data["persona"],
@@ -401,6 +419,7 @@ def execute_complete_video_generation(
height=request_data.get("image_height", 1024),
model=request_data.get("image_model"),
progress_callback=image_progress_callback,
anime_bible=anime_bible,
)
task_manager.update_task_status(task_id, "processing", progress=50.0, message="Generating audio narration for scenes...")

View File

@@ -140,7 +140,7 @@ class TaskManager:
audience_age_group=request_data["audience_age_group"],
content_rating=request_data["content_rating"],
ending_preference=request_data["ending_preference"],
user_id=user_id
user_id=user_id,
)
# Step 2: Generate outline
@@ -157,7 +157,7 @@ class TaskManager:
audience_age_group=request_data["audience_age_group"],
content_rating=request_data["content_rating"],
ending_preference=request_data["ending_preference"],
user_id=user_id
user_id=user_id,
)
# Step 3: Generate story start
@@ -175,7 +175,8 @@ class TaskManager:
audience_age_group=request_data["audience_age_group"],
content_rating=request_data["content_rating"],
ending_preference=request_data["ending_preference"],
user_id=user_id
anime_bible=request_data.get("anime_bible"),
user_id=user_id,
)
# Step 4: Continue story
@@ -208,7 +209,8 @@ class TaskManager:
audience_age_group=request_data["audience_age_group"],
content_rating=request_data["content_rating"],
ending_preference=request_data["ending_preference"],
user_id=user_id
anime_bible=request_data.get("anime_bible"),
user_id=user_id,
)
if continuation:

View File

@@ -8,7 +8,7 @@ def require_authenticated_user(current_user: Dict[str, Any] | None) -> str:
Validates the current user dictionary provided by Clerk middleware and
returns the normalized user_id. Raises HTTP 401 if authentication fails.
"""
if not current_user:
if not current_user or not isinstance(current_user, dict):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
user_id = str(current_user.get("id", "")).strip()

View File

@@ -12,13 +12,11 @@ from services.user_workspace_manager import UserWorkspaceManager
BASE_DIR = Path(__file__).resolve().parents[4] # root/
DATA_MEDIA_DIR = BASE_DIR / "workspace" / "media"
# Default global media directory matches story image/audio services (root/data/media)
DATA_MEDIA_DIR = BASE_DIR / "data" / "media"
STORY_IMAGES_DIR = (DATA_MEDIA_DIR / "story_images").resolve()
# STORY_IMAGES_DIR.mkdir(parents=True, exist_ok=True) # Disabled global creation
STORY_AUDIO_DIR = (DATA_MEDIA_DIR / "story_audio").resolve()
# STORY_AUDIO_DIR.mkdir(parents=True, exist_ok=True) # Disabled global creation
def _get_user_media_path(user_id: str, media_type: str) -> Optional[Path]: