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

@@ -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