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

39
.github/README.md vendored
View File

@@ -8,7 +8,8 @@
[![React](https://img.shields.io/badge/React-18+-blue.svg)](https://react.dev/) [![React](https://img.shields.io/badge/React-18+-blue.svg)](https://react.dev/)
[![Stars](https://img.shields.io/github/stars/AJaySi/AI-Writer?style=social)](https://github.com/AJaySi/AI-Writer/stargazers) [![Stars](https://img.shields.io/github/stars/AJaySi/AI-Writer?style=social)](https://github.com/AJaySi/AI-Writer/stargazers)
**Create, optimize, and publish highquality content across platforms — in minutes, not months.** **Core claim:
ALwrity is a contextual content OS: it understands your brand, website, competitors, and channels, then uses that understanding to drive every story, video, podcast, and campaign, with memory and analytics in one place.**
[🌐 Live Demo](https://www.alwrity.com) • [📚 Docs Site](https://ajaysi.github.io/ALwrity/) • [📖 Wiki](https://github.com/AJaySi/AI-Writer/wiki) • [💬 Discussions](https://github.com/AJaySi/AI-Writer/discussions) • [🐛 Issues](https://github.com/AJaySi/AI-Writer/issues) [🌐 Live Demo](https://www.alwrity.com) • [📚 Docs Site](https://ajaysi.github.io/ALwrity/) • [📖 Wiki](https://github.com/AJaySi/AI-Writer/wiki) • [💬 Discussions](https://github.com/AJaySi/AI-Writer/discussions) • [🐛 Issues](https://github.com/AJaySi/AI-Writer/issues)
@@ -22,27 +23,41 @@
--- ---
### Why ALwrity ### What ALwrity is
- **AI-first outcomes**: Strategy-to-publishing in one flow — strategy, research, creation, QA, and distribution. - **Contextual content OS**: Ingests your website, competitors, and channels to build a reusable brand brain.
- **Grounded & reliable**: Google grounding, Exa/Tavily research, citation management. - **Multi-surface by design**: Blogs, stories, YouTube, podcasts, and video all read from the same understanding.
- **Secure & scalable**: JWT auth, OAuth2, rate limiting, monitoring, subscription/usage tracking. - **Agent-driven flows**: Orchestrated research, planning, writing, and optimization instead of one-off prompts.
- **Built for solopreneurs**: Enterprise-grade capabilities with a fast, friendly UI. - **Production-ready**: JWT/OAuth2 auth, usage tracking, limits, monitoring, and cost awareness built-in.
---
### Why ALwrity exists
ALwrity exists for people who care more about **context** than prompts.
Most tools either drown you in knobs or reset to a blank page every time.
We wanted a system that:
- Remembers what your brand stands for and who youre speaking to.
- Grounds content in real data (SEO, competitors, web) before it writes.
- Reuses that understanding across every surface instead of duplicating effort.
--- ---
### Why it matters for creators & marketers ### Why it matters for creators & marketers
- **Reduce complexity of AI tools**: Guided flows (research → outline → write → optimize → publish) remove prompt engineering and tool-juggling. - **One brain, many surfaces**: The same insights power blog posts, stories, YouTube scripts, podcast outlines, and video scenes.
- **Save time, ship consistently**: Phase navigation and checklists keep you moving, ensuring on-time publishing across platforms. - **Less tool-juggling**: Guided flows replace “copy data between 5 SaaS tools and a spreadsheet”.
- **Trust the content**: Google grounding, retrieval (web/semantic/neural), and citations mean fewer rewrites and safer publishing. - **Safer, more factual content**: Grounding and citations reduce hallucinations and rewrites.
- **Stay on-brand and compliant**: Personas, tone controls, and rate limits help maintain voice and prevent platform penalties. - **On-brand by default**: Personas and brand voice settings keep outputs consistent across channels.
- **Catch issues early**: Scheduler “tasks needing intervention,” alerts, and logs highlight problems before your audience sees them. - **Operational visibility**: Scheduler “tasks needing intervention”, alerts, and logs highlight issues before your audience does.
--- ---
### Whats functional now ### Whats functional now
- **AI Blog Writer (Phases)**: Research → Outline → Content → SEO → Publish, with guarded navigation and local persistence (`frontend/src/hooks/usePhaseNavigation.ts`). - **AI Blog Writer (Phases)**: Research → Outline → Content → SEO → Publish, with guarded navigation and local persistence (`frontend/src/hooks/usePhaseNavigation.ts`).
- **Story Writer**: Premise → Outline → Chapters → Export, with phase navigation (`frontend/src/hooks/useStoryWriterPhaseNavigation.ts`).
- **YouTube Creator Studio**: Plan → scenes → avatar → render workflow for YouTube videos (`frontend/src/components/YouTubeCreator`).
- **Podcast Maker / Test Persona**: Turn voice + avatar into short videos using the shared video pipeline.
- **Video Studio**: Multi-module video creation, editing, and transformation (`frontend/src/components/VideoStudio`).
- **SEO Dashboard**: Analysis, metadata, and Google Search Console insights (see docs under `docs-site/docs/features/seo-dashboard`). - **SEO Dashboard**: Analysis, metadata, and Google Search Console insights (see docs under `docs-site/docs/features/seo-dashboard`).
- **Story Writer**: Setup (premise) → Outline → Writing → Export with phase navigation and reset (`frontend/src/hooks/useStoryWriterPhaseNavigation.ts`).
- **LinkedIn (Factual, GoogleGrounded)**: Real Google grounding + citations + quality metrics for posts/articles/carousels/scripts (see `frontend/docs/linkedin_factual_google_grounded_url_content.md`). - **LinkedIn (Factual, GoogleGrounded)**: Real Google grounding + citations + quality metrics for posts/articles/carousels/scripts (see `frontend/docs/linkedin_factual_google_grounded_url_content.md`).
- **Persona System**: Core personas and platform adaptations via APIs (`backend/api/persona.py`). - **Persona System**: Core personas and platform adaptations via APIs (`backend/api/persona.py`).
- **Facebook Persona Service**: Gemini structured JSON for Facebookspecific persona optimization (`backend/services/persona/facebook/facebook_persona_service.py`). - **Facebook Persona Service**: Gemini structured JSON for Facebookspecific persona optimization (`backend/services/persona/facebook/facebook_persona_service.py`).

View File

@@ -641,25 +641,18 @@ async def complete_style_detection(
return await loop.run_in_executor(None, partial(style_logic.perform_seo_audit, request.url, crawl_result['content'])) return await loop.run_in_executor(None, partial(style_logic.perform_seo_audit, request.url, crawl_result['content']))
async def run_sitemap_analysis(): async def run_sitemap_analysis():
"""Run AI sitemap analysis for home page"""
if not request.url: if not request.url:
return None return None
try: sitemap_url = await sitemap_service.discover_sitemap_url(request.url)
# Discover sitemap URL if sitemap_url:
sitemap_url = await sitemap_service.discover_sitemap_url(request.url) return await sitemap_service.analyze_sitemap(
if sitemap_url: sitemap_url=sitemap_url,
# Analyze sitemap with AI insights analyze_content_trends=True,
return await sitemap_service.analyze_sitemap( analyze_publishing_patterns=True,
sitemap_url=sitemap_url, include_ai_insights=True,
analyze_content_trends=True, user_id=user_id
analyze_publishing_patterns=True, )
include_ai_insights=True, return None
user_id=user_id
)
return None
except Exception as e:
logger.error(f"Sitemap analysis failed: {e}")
return None
# Execute style, patterns, SEO analysis and sitemap analysis in parallel # Execute style, patterns, SEO analysis and sitemap analysis in parallel
style_analysis, patterns_result, seo_audit_result, sitemap_result = await asyncio.gather( style_analysis, patterns_result, seo_audit_result, sitemap_result = await asyncio.gather(
@@ -710,15 +703,17 @@ async def complete_style_detection(
elif isinstance(seo_audit_result, Exception): elif isinstance(seo_audit_result, Exception):
logger.warning(f"SEO audit failed: {seo_audit_result}") logger.warning(f"SEO audit failed: {seo_audit_result}")
# Process sitemap analysis result
sitemap_analysis = None sitemap_analysis = None
sitemap_warning = None
if sitemap_result and not isinstance(sitemap_result, Exception): if sitemap_result and not isinstance(sitemap_result, Exception):
sitemap_analysis = sitemap_result sitemap_analysis = sitemap_result
elif isinstance(sitemap_result, Exception): elif isinstance(sitemap_result, Exception):
logger.warning(f"Sitemap analysis failed: {sitemap_result}") logger.warning(f"Sitemap analysis failed: {sitemap_result}")
sitemap_warning = f"Sitemap analysis failed: {sitemap_result}"
# Step 4: Generate guidelines (depends on style_analysis, must run after) # Step 4: Generate guidelines (depends on style_analysis, must run after)
style_guidelines = None style_guidelines = None
guidelines_result = None
if request.include_guidelines: if request.include_guidelines:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
guidelines_result = await loop.run_in_executor( guidelines_result = await loop.run_in_executor(
@@ -728,10 +723,14 @@ async def complete_style_detection(
if guidelines_result and guidelines_result.get('success'): if guidelines_result and guidelines_result.get('success'):
style_guidelines = guidelines_result.get('guidelines') style_guidelines = guidelines_result.get('guidelines')
# Check if there's a warning about fallback data warning_parts = []
warning = None
if style_analysis and 'warning' in style_analysis: if style_analysis and 'warning' in style_analysis:
warning = style_analysis['warning'] warning_parts.append(style_analysis['warning'])
if request.include_guidelines and guidelines_result and not guidelines_result.get('success') and guidelines_result.get('error'):
warning_parts.append(f"Guidelines generation failed: {guidelines_result.get('error')}")
if sitemap_warning:
warning_parts.append(sitemap_warning)
warning = " | ".join(warning_parts) if warning_parts else None
# Prepare response data # Prepare response data
response_data = { response_data = {
@@ -1000,4 +999,4 @@ async def get_style_detection_configuration():
} }
except Exception as e: except Exception as e:
logger.error(f"[get_style_detection_configuration] Error: {str(e)}") logger.error(f"[get_style_detection_configuration] Error: {str(e)}")
return {"error": f"Configuration error: {str(e)}"} return {"error": f"Configuration error: {str(e)}"}

View File

@@ -5,6 +5,7 @@ Handles the complex logic for completing the onboarding process.
from typing import Dict, Any, List from typing import Dict, Any, List
from datetime import datetime, timedelta from datetime import datetime, timedelta
import os
from fastapi import HTTPException from fastapi import HTTPException
from loguru import logger from loguru import logger
@@ -306,18 +307,20 @@ class OnboardingCompletionService:
db = get_session_for_user(user_id) db = get_session_for_user(user_id)
integration_service = OnboardingDataIntegrationService() integration_service = OnboardingDataIntegrationService()
# Debug logging
logger.info(f"Validating steps for user {user_id}") logger.info(f"Validating steps for user {user_id}")
# Get integrated data
integrated_data = await integration_service.process_onboarding_data(user_id, db) integrated_data = await integration_service.process_onboarding_data(user_id, db)
db.close() db.close()
from services.onboarding.progress_service import OnboardingProgressService
progress_service = OnboardingProgressService()
status = progress_service.get_onboarding_status(user_id)
current_step = status.get("current_step", 1)
# Check each required step
for step_num in self.required_steps: for step_num in self.required_steps:
step_completed = False step_completed = False
if step_num == 1: # API Keys if step_num == 1:
api_keys_data = integrated_data.get('api_keys_data', {}) api_keys_data = integrated_data.get('api_keys_data', {})
logger.info(f"Step 1 - API Keys: {api_keys_data}") logger.info(f"Step 1 - API Keys: {api_keys_data}")
step_completed = bool( step_completed = bool(
@@ -325,26 +328,49 @@ class OnboardingCompletionService:
api_keys_data.get('anthropic_api_key') or api_keys_data.get('anthropic_api_key') or
api_keys_data.get('google_api_key') api_keys_data.get('google_api_key')
) )
if not step_completed:
has_global_providers = bool(
os.getenv("EXA_API_KEY") or
os.getenv("GEMINI_API_KEY") or
os.getenv("OPENAI_API_KEY") or
os.getenv("ANTHROPIC_API_KEY") or
os.getenv("GOOGLE_API_KEY")
)
if has_global_providers:
step_completed = True
logger.info(f"Step 1 completed: {step_completed}") logger.info(f"Step 1 completed: {step_completed}")
elif step_num == 2: # Website Analysis elif step_num == 2:
website = integrated_data.get('website_analysis', {}) website = integrated_data.get('website_analysis', {})
logger.info(f"Step 2 - Website Analysis: {website}") logger.info(f"Step 2 - Website Analysis: {website}")
step_completed = bool(website and (website.get('website_url') or website.get('writing_style'))) step_completed = bool(website and (website.get('website_url') or website.get('writing_style')))
logger.info(f"Step 2 completed: {step_completed}") logger.info(f"Step 2 completed: {step_completed}")
elif step_num == 3: # Research Preferences elif step_num == 3:
research = integrated_data.get('research_preferences', {}) research = integrated_data.get('research_preferences', {})
logger.info(f"Step 3 - Research Preferences: {research}") logger.info(f"Step 3 - Research Preferences: {research}")
step_completed = bool(research and (research.get('research_depth') or research.get('content_types'))) step_completed = bool(research and (research.get('research_depth') or research.get('content_types')))
logger.info(f"Step 3 completed: {step_completed}") logger.info(f"Step 3 completed: {step_completed}")
elif step_num == 4: # Persona Generation elif step_num == 4:
persona = integrated_data.get('persona_data', {}) persona = integrated_data.get('persona_data', {})
logger.info(f"Step 4 - Persona Data: {persona}") logger.info(f"Step 4 - Persona Data: {persona}")
step_completed = bool(persona and (persona.get('corePersona') or persona.get('platformPersonas'))) step_completed = bool(persona and (persona.get('corePersona') or persona.get('platformPersonas')))
if not step_completed:
website = integrated_data.get('website_analysis', {})
research = integrated_data.get('research_preferences', {})
basic_ready = bool(
website and (website.get('website_url') or website.get('writing_style'))
) and bool(research)
if basic_ready:
step_completed = True
logger.info(f"Step 4 completed: {step_completed}") logger.info(f"Step 4 completed: {step_completed}")
elif step_num == 5: # Integrations elif step_num == 5:
# For now, consider this always completed if we reach this point
step_completed = True step_completed = True
logger.info(f"Step 5 completed: {step_completed}") logger.info(f"Step 5 completed: {step_completed}")
if not step_completed and current_step >= step_num:
step_completed = True
logger.info(
f"Step {step_num} marked completed based on progress service (current_step={current_step})"
)
if not step_completed: if not step_completed:
missing_steps.append(f"Step {step_num}") missing_steps.append(f"Step {step_num}")
@@ -357,20 +383,34 @@ class OnboardingCompletionService:
return ["Validation error"] return ["Validation error"]
async def _validate_api_keys(self, user_id: str): async def _validate_api_keys(self, user_id: str):
"""Validate that API keys are configured for the current user (SSOT).""" """Validate that API keys are configured for the current user (SSOT or environment)."""
try: try:
db = get_session_for_user(user_id) db = get_session_for_user(user_id)
integration_service = OnboardingDataIntegrationService() try:
integrated_data = await integration_service.process_onboarding_data(user_id, db) integration_service = OnboardingDataIntegrationService()
db.close() integrated_data = await integration_service.process_onboarding_data(user_id, db)
finally:
db.close()
api_keys_data = integrated_data.get('api_keys_data', {}) api_keys_data = integrated_data.get('api_keys_data', {}) if integrated_data else {}
has_keys = bool( has_user_keys = bool(
api_keys_data.get('openai_api_key') or api_keys_data.get('openai_api_key') or
api_keys_data.get('anthropic_api_key') or api_keys_data.get('anthropic_api_key') or
api_keys_data.get('google_api_key') api_keys_data.get('google_api_key') or
api_keys_data.get('exa_api_key') or
api_keys_data.get('gemini_api_key')
) )
has_env_keys = bool(
os.getenv("OPENAI_API_KEY") or
os.getenv("ANTHROPIC_API_KEY") or
os.getenv("GOOGLE_API_KEY") or
os.getenv("EXA_API_KEY") or
os.getenv("GEMINI_API_KEY")
)
has_keys = has_user_keys or has_env_keys
if not has_keys: if not has_keys:
raise HTTPException( raise HTTPException(

View File

@@ -8,7 +8,7 @@ from fastapi import HTTPException
from loguru import logger from loguru import logger
from services.onboarding.api_key_manager import get_api_key_manager from services.onboarding.api_key_manager import get_api_key_manager
from services.database import get_db from services.database import get_session_for_user
from services.website_analysis_service import WebsiteAnalysisService from services.website_analysis_service import WebsiteAnalysisService
from services.research_preferences_service import ResearchPreferencesService from services.research_preferences_service import ResearchPreferencesService
from services.persona_analysis_service import PersonaAnalysisService from services.persona_analysis_service import PersonaAnalysisService
@@ -32,10 +32,13 @@ class OnboardingSummaryService:
async def get_onboarding_summary(self) -> Dict[str, Any]: async def get_onboarding_summary(self) -> Dict[str, Any]:
"""Get comprehensive onboarding summary for FinalStep.""" """Get comprehensive onboarding summary for FinalStep."""
try: try:
# Get integrated data via SSOT db = get_session_for_user(self.user_id)
db = next(get_db()) if not db:
integrated_data = await self.integration_service.process_onboarding_data(self.user_id, db) raise HTTPException(status_code=500, detail="Database session could not be created")
db.close() try:
integrated_data = await self.integration_service.process_onboarding_data(self.user_id, db)
finally:
db.close()
# Extract components from integrated data # Extract components from integrated data
website_analysis = integrated_data.get('website_analysis', {}) website_analysis = integrated_data.get('website_analysis', {})
@@ -152,15 +155,34 @@ class OnboardingSummaryService:
return capabilities return capabilities
async def get_website_analysis_data(self) -> Dict[str, Any]:
"""Get website analysis data for the user (Step 2 output)."""
try:
db = get_session_for_user(self.user_id)
if not db:
raise HTTPException(status_code=500, detail="Database session could not be created")
try:
integrated_data = await self.integration_service.process_onboarding_data(self.user_id, db)
website_analysis = integrated_data.get("website_analysis") or {}
return website_analysis
finally:
db.close()
except Exception as e:
logger.error(f"Error getting website analysis data: {e}")
raise
async def get_research_preferences_data(self) -> Dict[str, Any]: async def get_research_preferences_data(self) -> Dict[str, Any]:
"""Get research preferences data for the user.""" """Get research preferences data for the user."""
try: try:
db = next(get_db()) db = get_session_for_user(self.user_id)
research_prefs_service = ResearchPreferencesService(db) if not db:
# Use the new method that accepts user_id directly raise HTTPException(status_code=500, detail="Database session could not be created")
result = research_prefs_service.get_research_preferences_by_user_id(self.user_id) try:
db.close() research_prefs_service = ResearchPreferencesService(db)
return result result = research_prefs_service.get_research_preferences_by_user_id(self.user_id)
return result
finally:
db.close()
except Exception as e: except Exception as e:
logger.error(f"Error getting research preferences data: {e}") logger.error(f"Error getting research preferences data: {e}")
raise raise

View File

@@ -7,54 +7,238 @@ Analysis endpoint for podcast ideas.
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any from typing import Dict, Any
import json import json
import uuid
from sqlalchemy.orm import Session
from services.database import get_db
from middleware.auth_middleware import get_current_user from middleware.auth_middleware import get_current_user
from api.story_writer.utils.auth import require_authenticated_user from api.story_writer.utils.auth import require_authenticated_user
from services.llm_providers.main_text_generation import llm_text_gen from services.llm_providers.main_text_generation import llm_text_gen
from services.llm_providers.main_image_generation import generate_image
from services.podcast_bible_service import PodcastBibleService
from utils.asset_tracker import save_asset_to_library
from loguru import logger from loguru import logger
from ..models import PodcastAnalyzeRequest, PodcastAnalyzeResponse from ..constants import PODCAST_IMAGES_DIR
from ..models import (
PodcastAnalyzeRequest,
PodcastAnalyzeResponse,
PodcastEnhanceIdeaRequest,
PodcastEnhanceIdeaResponse
)
router = APIRouter() router = APIRouter()
@router.post("/idea/enhance", response_model=PodcastEnhanceIdeaResponse)
async def enhance_podcast_idea(
request: PodcastEnhanceIdeaRequest,
current_user: Dict[str, Any] = Depends(get_current_user),
):
"""
Take raw keywords/topic and use AI to craft a presentable, detailed podcast idea.
Uses the user's Podcast Bible for hyper-personalization if available.
"""
user_id = require_authenticated_user(current_user)
# Serialize Bible context if provided or generate from onboarding
bible_context = ""
try:
bible_service = PodcastBibleService()
if request.bible:
from models.podcast_bible_models import PodcastBible
bible_data = PodcastBible(**request.bible)
bible_context = bible_service.serialize_bible(bible_data)
else:
# Generate from onboarding data directly
bible_obj = bible_service.generate_bible(user_id, "temp_enhance")
bible_context = bible_service.serialize_bible(bible_obj)
except Exception as exc:
logger.warning(f"[Podcast Enhance] Failed to parse or generate bible context: {exc}")
prompt = f"""
You are a creative podcast producer. Your goal is to take a simple podcast idea or keywords
and transform it into a compelling, professional, and detailed episode concept.
{f"USER PERSONALIZATION CONTEXT (Podcast Bible):\n{bible_context}\n" if bible_context else ""}
RAW IDEA/KEYWORDS: "{request.idea}"
TASK:
1. Rewrite the idea into a professional, presentable 2-3 sentence episode pitch.
2. Focus on making it sound expert-led and audience-focused.
3. Ensure it aligns with the host's persona and target audience interests if context was provided.
4. Keep it concise but information-rich.
Return JSON with:
- enhanced_idea: the rewritten, professional episode pitch
- rationale: 1 sentence explaining why this version works better for the target audience
"""
try:
raw = llm_text_gen(prompt=prompt, user_id=user_id, json_struct=None)
# Normalize response
if isinstance(raw, str):
data = json.loads(raw)
else:
data = raw
return PodcastEnhanceIdeaResponse(
enhanced_idea=data.get("enhanced_idea", request.idea),
rationale=data.get("rationale", "Made it more professional and listener-focused.")
)
except Exception as exc:
logger.error(f"[Podcast Enhance] Failed for user {user_id}: {exc}")
return PodcastEnhanceIdeaResponse(
enhanced_idea=request.idea,
rationale="Failed to enhance idea with AI, using original."
)
@router.post("/analyze", response_model=PodcastAnalyzeResponse) @router.post("/analyze", response_model=PodcastAnalyzeResponse)
async def analyze_podcast_idea( async def analyze_podcast_idea(
request: PodcastAnalyzeRequest, request: PodcastAnalyzeRequest,
current_user: Dict[str, Any] = Depends(get_current_user), current_user: Dict[str, Any] = Depends(get_current_user),
db: Session = Depends(get_db),
): ):
""" """
Analyze a podcast idea and return podcast-oriented outlines, keywords, and titles. Analyze a podcast idea and return podcast-oriented outlines, keywords, and titles.
This uses the shared LLM provider but with a podcast-specific prompt (not story format). If no avatar_url is provided, it generates one automatically based on the host's look.
""" """
user_id = require_authenticated_user(current_user) user_id = require_authenticated_user(current_user)
# Serialize Bible context if provided or generate from onboarding
bible_context = ""
bible_obj = None
try:
bible_service = PodcastBibleService()
if request.bible:
from models.podcast_bible_models import PodcastBible
bible_data = PodcastBible(**request.bible)
bible_context = bible_service.serialize_bible(bible_data)
bible_obj = bible_data
else:
# Generate from onboarding data directly
bible_obj = bible_service.generate_bible(user_id, "temp_analyze")
bible_context = bible_service.serialize_bible(bible_obj)
bible_obj = bible_obj
except Exception as exc:
logger.warning(f"[Podcast Analyze] Failed to parse or generate bible context: {exc}")
# --- NEW: Generate Presenter Avatar if missing ---
final_avatar_url = request.avatar_url
final_avatar_prompt = None
if not final_avatar_url:
logger.info(f"[Podcast Analyze] No avatar_url provided, generating one for user {user_id}")
try:
# 1. PRE-FLIGHT VALIDATION: Check subscription limits for image generation
from services.subscription import PricingService
from services.subscription.preflight_validator import validate_image_generation_operations
pricing_service = PricingService(db)
validate_image_generation_operations(
pricing_service=pricing_service,
user_id=user_id,
num_images=1
)
# 2. Build avatar prompt from Bible host look or fallback
host_look = bible_obj.host.look if bible_obj and bible_obj.host.look else "A professional podcast host"
visual_style = bible_obj.visual_style.style_preset if bible_obj else "Realistic Photography"
final_avatar_prompt = f"Professional headshot of a podcast host, {host_look}, {visual_style} style, clean background, soft studio lighting, center-focused, high resolution, sharp focus, professional photography quality, 16:9 aspect ratio."
# 3. Generate the image
logger.info(f"[Podcast Analyze] Generating avatar with prompt: {final_avatar_prompt}")
image_result = generate_image(
prompt=final_avatar_prompt,
user_id=user_id,
width=1024,
height=1024
)
# 4. Save to disk and library
if image_result and image_result.image_bytes:
img_id = str(uuid.uuid4())[:8]
filename = f"presenter_podcast_{user_id}_{img_id}.png"
output_path = PODCAST_IMAGES_DIR / filename
PODCAST_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
with open(output_path, "wb") as f:
f.write(image_result.image_bytes)
final_avatar_url = f"/api/podcast/images/avatars/{filename}"
# Save to asset library for reuse
save_asset_to_library(
db=db,
user_id=user_id,
asset_type="image",
file_url=final_avatar_url,
filename=filename,
title=f"Presenter Avatar - {request.idea[:40]}",
description=f"AI-generated podcast presenter for: {request.idea}",
provider=image_result.provider,
model=image_result.model,
cost=image_result.cost
)
logger.info(f"[Podcast Analyze] ✅ Generated and saved avatar to {final_avatar_url}")
except Exception as e:
logger.error(f"[Podcast Analyze] ❌ Failed to generate avatar: {e}")
# Non-fatal: continue analysis even if avatar generation fails
# --- END: Avatar Generation ---
# Incorporate user feedback if provided
feedback_context = ""
if request.feedback:
feedback_context = f"""
USER REGENERATION FEEDBACK:
The user was not satisfied with the previous analysis. They provided the following instructions for improvement:
"{request.feedback}"
Please prioritize this feedback and adjust the analysis accordingly.
"""
prompt = f""" prompt = f"""
You are an expert podcast producer. Given a podcast idea, craft concise podcast-ready assets You are an expert podcast producer and research strategist. Given a podcast idea, craft concise podcast-ready assets
that sound like episode plans (not fiction stories). that sound like episode plans (not fiction stories).
{f"USER PERSONALIZATION CONTEXT (Podcast Bible):\n{bible_context}\n" if bible_context else ""}
{feedback_context}
Podcast Idea: "{request.idea}" Podcast Idea: "{request.idea}"
Duration: ~{request.duration} minutes Duration: ~{request.duration} minutes
Speakers: {request.speakers} (host + optional guest) Speakers: {request.speakers} (host + optional guest)
TASK:
1. Define the target audience and content type aligned with the Bible's "Audience DNA" and "Brand DNA".
2. Identify 5 high-impact keywords.
3. Propose 2 episode outlines with factual segments.
4. Suggest 3 titles.
5. IMPORTANT: Generate 4-6 specific research queries for Exa. These queries MUST be highly targeted to the episode's topic, the host's expertise level, and the audience's interests as defined in the Bible.
* Do NOT use generic queries like "latest trends in X".
* DO use queries that look for case studies, specific data points, expert opinions, or contrasting viewpoints that would make for a deep, insightful podcast conversation.
Return JSON with: Return JSON with:
- audience: short target audience description - audience: short target audience description
- content_type: podcast style/format - content_type: podcast style/format
- top_keywords: 5 podcast-relevant keywords/phrases - top_keywords: 5 podcast-relevant keywords/phrases
- suggested_outlines: 2 items, each with title (<=60 chars) and 4-6 short segments (bullet-friendly, factual) - suggested_outlines: 2 items, each with title (<=60 chars) and 4-6 short segments (bullet-friendly, factual)
- title_suggestions: 3 concise episode titles (no cliffhanger storytelling) - title_suggestions: 3 concise episode titles
- exa_suggested_config: suggested Exa search options to power research (keep conservative defaults to control cost), with: - research_queries: array of {{"query": "string", "rationale": "string"}}
- exa_search_type: "auto" | "neural" | "keyword" (prefer "auto" unless clearly news-heavy) - exa_suggested_config: suggested Exa search options with:
- exa_search_type: "auto" | "neural" | "keyword"
- exa_category: one of ["research paper","news","company","github","tweet","personal site","pdf","financial report","linkedin profile"] - exa_category: one of ["research paper","news","company","github","tweet","personal site","pdf","financial report","linkedin profile"]
- exa_include_domains: up to 3 reputable domains to prioritize (optional) - exa_include_domains: up to 3 reputable domains
- exa_exclude_domains: up to 3 domains to avoid (optional) - exa_exclude_domains: up to 3 domains
- max_sources: 6-10 - max_sources: 6-10
- include_statistics: boolean (true if topic needs fresh stats) - include_statistics: boolean
- date_range: one of ["last_month","last_3_months","last_year","all_time"] (pick recent if time-sensitive) - date_range: one of ["last_month","last_3_months","last_year","all_time"]
Requirements: Requirements:
- Keep language factual, actionable, and suited for spoken audio. - Keep language factual, actionable, and suited for spoken audio.
- Avoid narrative fiction tone; focus on insights, hooks, objections, and takeaways. - Avoid narrative fiction tone.
- Prefer 2024-2025 context when relevant. - Prefer 2024-2025 context.
""" """
try: try:
@@ -82,7 +266,7 @@ Requirements:
top_keywords = data.get("top_keywords") or [] top_keywords = data.get("top_keywords") or []
suggested_outlines = data.get("suggested_outlines") or [] suggested_outlines = data.get("suggested_outlines") or []
title_suggestions = data.get("title_suggestions") or [] title_suggestions = data.get("title_suggestions") or []
research_queries = data.get("research_queries") or []
exa_suggested_config = data.get("exa_suggested_config") or None exa_suggested_config = data.get("exa_suggested_config") or None
return PodcastAnalyzeResponse( return PodcastAnalyzeResponse(
@@ -91,6 +275,10 @@ Requirements:
top_keywords=top_keywords, top_keywords=top_keywords,
suggested_outlines=suggested_outlines, suggested_outlines=suggested_outlines,
title_suggestions=title_suggestions, title_suggestions=title_suggestions,
research_queries=research_queries,
exa_suggested_config=exa_suggested_config, exa_suggested_config=exa_suggested_config,
bible=bible_obj.model_dump() if bible_obj else None,
avatar_url=final_avatar_url,
avatar_prompt=final_avatar_prompt,
) )

View File

@@ -86,6 +86,19 @@ async def generate_podcast_scene_image(
logger.info(f"[Podcast] No base avatar URL provided, will generate from scratch") logger.info(f"[Podcast] No base avatar URL provided, will generate from scratch")
base_avatar_bytes = None base_avatar_bytes = None
# Extract Podcast Bible context for hyper-personalization
bible_context = ""
bible_obj = None
if request.bible:
try:
from services.podcast_bible_service import PodcastBibleService
from models.podcast_bible_models import PodcastBible
bible_service = PodcastBibleService()
bible_obj = PodcastBible(**request.bible)
bible_context = bible_service.serialize_bible(bible_obj)
except Exception as exc:
logger.warning(f"[Podcast Image] Failed to serialize podcast bible: {exc}")
# Build optimized prompt for scene image generation # Build optimized prompt for scene image generation
# When base avatar is provided, use Ideogram Character to maintain consistency # When base avatar is provided, use Ideogram Character to maintain consistency
# Otherwise, generate from scratch with podcast-optimized prompt # Otherwise, generate from scratch with podcast-optimized prompt
@@ -106,6 +119,14 @@ async def generate_podcast_scene_image(
if request.scene_title: if request.scene_title:
prompt_parts.append(f"Scene: {request.scene_title}") prompt_parts.append(f"Scene: {request.scene_title}")
# Use Bible visual style if available
if bible_obj:
prompt_parts.append(f"Style: {bible_obj.visual_style.style_preset}")
prompt_parts.append(f"Environment: {bible_obj.visual_style.environment}")
prompt_parts.append(f"Lighting: {bible_obj.visual_style.lighting}")
if bible_obj.host.look:
prompt_parts.append(f"Host Look: {bible_obj.host.look}")
# Scene content insights for visual context # Scene content insights for visual context
if request.scene_content: if request.scene_content:
content_preview = request.scene_content[:200].replace("\n", " ").strip() content_preview = request.scene_content[:200].replace("\n", " ").strip()
@@ -127,12 +148,14 @@ async def generate_podcast_scene_image(
prompt_parts.append(f"Topic: {idea_preview}") prompt_parts.append(f"Topic: {idea_preview}")
# Studio setting (maintains podcast aesthetic) # Studio setting (maintains podcast aesthetic)
prompt_parts.extend([ if not bible_obj:
"Professional podcast recording studio", prompt_parts.extend([
"Modern microphone setup", "Professional podcast recording studio",
"Clean background, professional lighting", "Modern microphone setup",
"16:9 aspect ratio, video-optimized composition" "Clean background, professional lighting"
]) ])
prompt_parts.append("16:9 aspect ratio, video-optimized composition")
image_prompt = ", ".join(prompt_parts) image_prompt = ", ".join(prompt_parts)
@@ -221,14 +244,22 @@ async def generate_podcast_scene_image(
# Standard generation from scratch (no base avatar provided) # Standard generation from scratch (no base avatar provided)
prompt_parts = [] prompt_parts = []
# Core podcast studio elements # Use Bible visual style if available
prompt_parts.extend([ if bible_obj:
"Professional podcast recording studio", prompt_parts.append(f"Style: {bible_obj.visual_style.style_preset}")
"Modern podcast setup with high-quality microphone", prompt_parts.append(f"Environment: {bible_obj.visual_style.environment}")
"Clean, minimalist background suitable for video", prompt_parts.append(f"Lighting: {bible_obj.visual_style.lighting}")
"Professional studio lighting with soft, even illumination", if bible_obj.host.look:
"Podcast host environment, professional and inviting" prompt_parts.append(f"Host Look: {bible_obj.host.look}")
]) else:
# Core podcast studio elements
prompt_parts.extend([
"Professional podcast recording studio",
"Modern podcast setup with high-quality microphone",
"Clean, minimalist background suitable for video",
"Professional studio lighting with soft, even illumination",
"Podcast host environment, professional and inviting"
])
# Scene-specific context # Scene-specific context
if request.scene_title: if request.scene_title:
@@ -264,12 +295,13 @@ async def generate_podcast_scene_image(
]) ])
# Style constraints # Style constraints
prompt_parts.extend([ if not bible_obj:
"Realistic photography style, not illustration or cartoon", prompt_parts.extend([
"Professional broadcast quality", "Realistic photography style, not illustration or cartoon",
"Warm, inviting atmosphere", "Professional broadcast quality",
"Clean composition with breathing room for avatar placement" "Warm, inviting atmosphere",
]) "Clean composition with breathing room for avatar placement"
])
image_prompt = ", ".join(prompt_parts) image_prompt = ", ".join(prompt_parts)

View File

@@ -47,6 +47,7 @@ async def create_project(
duration=request.duration, duration=request.duration,
speakers=request.speakers, speakers=request.speakers,
budget_cap=request.budget_cap, budget_cap=request.budget_cap,
avatar_url=request.avatar_url,
) )
return PodcastProjectResponse.model_validate(project) return PodcastProjectResponse.model_validate(project)

View File

@@ -1,22 +1,26 @@
""" """
Podcast Research Handlers Podcast Research Handlers
Research endpoints using Exa provider. Research endpoints using Exa provider and LLM summarization.
""" """
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any from typing import Dict, Any, List
from types import SimpleNamespace from types import SimpleNamespace
import json
from middleware.auth_middleware import get_current_user from middleware.auth_middleware import get_current_user
from api.story_writer.utils.auth import require_authenticated_user from api.story_writer.utils.auth import require_authenticated_user
from services.blog_writer.research.exa_provider import ExaResearchProvider from services.blog_writer.research.exa_provider import ExaResearchProvider
from services.llm_providers.main_text_generation import llm_text_gen
from services.podcast_bible_service import PodcastBibleService
from loguru import logger from loguru import logger
from ..models import ( from ..models import (
PodcastExaResearchRequest, PodcastExaResearchRequest,
PodcastExaResearchResponse, PodcastExaResearchResponse,
PodcastExaSource, PodcastExaSource,
PodcastExaConfig, PodcastExaConfig,
PodcastResearchInsight,
) )
router = APIRouter() router = APIRouter()
@@ -28,7 +32,8 @@ async def podcast_research_exa(
current_user: Dict[str, Any] = Depends(get_current_user), current_user: Dict[str, Any] = Depends(get_current_user),
): ):
""" """
Run podcast research directly via Exa (no blog writer pipeline). Run podcast research via Exa and then use LLM to extract deep insights.
Uses Podcast Bible and Analysis context for hyper-personalization.
""" """
user_id = require_authenticated_user(current_user) user_id = require_authenticated_user(current_user)
@@ -47,22 +52,121 @@ async def podcast_research_exa(
) )
provider = ExaResearchProvider() provider = ExaResearchProvider()
prompt = request.topic
# --- Context Building ---
bible_service = PodcastBibleService()
bible_context = ""
if request.bible:
try:
from models.podcast_bible_models import PodcastBible
bible_data = PodcastBible(**request.bible)
bible_context = bible_service.serialize_bible(bible_data)
except Exception as exc:
logger.warning(f"[Podcast Research] Failed to serialize bible: {exc}")
analysis_context = ""
if request.analysis:
analysis_context = f"""
PODCAST ANALYSIS CONTEXT:
Audience: {request.analysis.get('audience', 'General')}
Content Type: {request.analysis.get('content_type', 'Informative')}
Top Keywords: {', '.join(request.analysis.get('top_keywords', []))}
"""
# Exa search params
industry = request.bible.get("brand", {}).get("industry", "") if request.bible else ""
target_audience = ""
if request.bible:
audience_dna = request.bible.get("audience", {})
if audience_dna:
interests = ", ".join(audience_dna.get("interests", []))
target_audience = f"Expertise: {audience_dna.get('expertise_level', '')}. Interests: {interests}."
try: try:
# 1. RUN EXA SEARCH
result = await provider.search( result = await provider.search(
prompt=prompt, prompt=request.topic,
topic=request.topic, topic=request.topic,
industry="", industry=industry,
target_audience="", target_audience=target_audience,
config=cfg, config=cfg,
user_id=user_id, user_id=user_id,
) )
except Exception as exc: except Exception as exc:
logger.error(f"[Podcast Exa Research] Failed for user {user_id}: {exc}") logger.error(f"[Podcast Exa Research] Search failed for user {user_id}: {exc}")
raise HTTPException(status_code=500, detail=f"Exa research failed: {exc}") raise HTTPException(status_code=500, detail=f"Exa research failed: {exc}")
# Track usage if available # 2. EXTRACT INSIGHTS VIA LLM
raw_content = result.get("content", "")
sources = result.get("sources", [])
summary = ""
key_insights = []
if raw_content and sources:
logger.info(f"[Podcast Research] Extracting insights from {len(sources)} sources for user {user_id}")
prompt = f"""
You are an expert research analyst for a high-end podcast production team.
Your task is to analyze the following research data and extract deep, actionable insights for a podcast episode.
PODCAST CONTEXT:
Topic: {request.topic}
{bible_context}
{analysis_context}
RESEARCH DATA (from {len(sources)} sources):
{raw_content}
TASK:
1. Provide a comprehensive summary (2-3 paragraphs) of the most important findings. Use Markdown for formatting (bolding, lists).
2. Extract 3-5 "Key Insights". Each insight should have a title and a detailed explanation.
3. For each insight, identify which source indices (e.g. 1, 2) it was derived from.
NOTE: The research data includes "Key Highlights", "Summaries", and "Excerpts" from various sources.
Pay special attention to the "Key Highlights" sections as they contain the most relevant information extracted by the neural search engine.
Return JSON structure:
{{
"summary": "Detailed markdown summary...",
"key_insights": [
{{
"title": "Insight Title",
"content": "Detailed markdown content...",
"source_indices": [1, 2]
}}
]
}}
Requirements:
- Ensure insights are deep, not just superficial facts. Look for trends, expert opinions, and specific data points.
- Tone should be professional, insightful, and ready for a podcast host to discuss.
- Avoid generic filler.
"""
try:
llm_response = llm_text_gen(prompt=prompt, user_id=user_id, json_struct=None)
# Normalize response
if isinstance(llm_response, str):
data = json.loads(llm_response)
else:
data = llm_response
summary = data.get("summary", "")
key_insights = [PodcastResearchInsight(**insight) for insight in data.get("key_insights", [])]
except Exception as exc:
logger.error(f"[Podcast Research] LLM Insight extraction failed: {exc}")
# Fallback to a basic summary if LLM fails
summary = f"Research completed for '{request.topic}'. Found {len(sources)} sources."
# Fallback: if summary is still empty (e.g. LLM returned empty string), use raw content first paragraph or basic text
if not summary:
if raw_content:
summary = raw_content[:2000] # Use first 2000 chars of raw content as summary
else:
summary = f"Research completed for '{request.topic}'. Found {len(sources)} sources."
# 3. TRACK USAGE
try: try:
cost_total = 0.0 cost_total = 0.0
if isinstance(result, dict): if isinstance(result, dict):
@@ -72,28 +176,31 @@ async def podcast_research_exa(
logger.warning(f"[Podcast Exa Research] Failed to track usage: {track_err}") logger.warning(f"[Podcast Exa Research] Failed to track usage: {track_err}")
sources_payload = [] sources_payload = []
if isinstance(result, dict): for src in sources:
for src in result.get("sources", []) or []: try:
try: sources_payload.append(PodcastExaSource(**src))
sources_payload.append(PodcastExaSource(**src)) except Exception:
except Exception: sources_payload.append(PodcastExaSource(**{
sources_payload.append(PodcastExaSource(**{ "title": src.get("title", ""),
"title": src.get("title", ""), "url": src.get("url", ""),
"url": src.get("url", ""), "excerpt": src.get("excerpt", ""),
"excerpt": src.get("excerpt", ""), "published_at": src.get("published_at"),
"published_at": src.get("published_at"), "highlights": src.get("highlights"),
"highlights": src.get("highlights"), "summary": src.get("summary"),
"summary": src.get("summary"), "source_type": src.get("source_type"),
"source_type": src.get("source_type"), "index": src.get("index"),
"index": src.get("index"), "image": src.get("image"),
})) "author": src.get("author"),
}))
return PodcastExaResearchResponse( return PodcastExaResearchResponse(
sources=sources_payload, sources=sources_payload,
search_queries=result.get("search_queries", queries) if isinstance(result, dict) else queries, search_queries=result.get("search_queries", queries) if isinstance(result, dict) else queries,
summary=summary,
key_insights=key_insights,
cost=result.get("cost") if isinstance(result, dict) else None, cost=result.get("cost") if isinstance(result, dict) else None,
search_type=result.get("search_type") if isinstance(result, dict) else None, search_type=result.get("search_type") if isinstance(result, dict) else None,
provider=result.get("provider", "exa") if isinstance(result, dict) else "exa", provider=result.get("provider", "exa") if isinstance(result, dict) else "exa",
content=result.get("content") if isinstance(result, dict) else None, content=raw_content,
) )

View File

@@ -11,6 +11,8 @@ import json
from middleware.auth_middleware import get_current_user from middleware.auth_middleware import get_current_user
from api.story_writer.utils.auth import require_authenticated_user from api.story_writer.utils.auth import require_authenticated_user
from services.llm_providers.main_text_generation import llm_text_gen from services.llm_providers.main_text_generation import llm_text_gen
from services.podcast_bible_service import PodcastBibleService
from models.podcast_bible_models import PodcastBible
from loguru import logger from loguru import logger
from ..models import ( from ..models import (
PodcastScriptRequest, PodcastScriptRequest,
@@ -62,8 +64,39 @@ async def generate_podcast_script(
logger.warning(f"Failed to parse research context: {exc}") logger.warning(f"Failed to parse research context: {exc}")
research_context = "" research_context = ""
# Extract Podcast Bible context for hyper-personalization
bible_context = ""
if request.bible:
try:
bible_service = PodcastBibleService()
bible_obj = PodcastBible(**request.bible)
bible_context = bible_service.serialize_bible(bible_obj)
except Exception as exc:
logger.warning(f"Failed to serialize podcast bible: {exc}")
# Extract Analysis and Outline context for grounding
analysis_context = ""
if request.analysis:
analysis_context = f"""
TARGET AUDIENCE: {request.analysis.get('audience', 'General')}
CONTENT TYPE: {request.analysis.get('contentType', 'Conversational')}
TOP KEYWORDS: {', '.join(request.analysis.get('topKeywords', []))}
"""
outline_context = ""
if request.outline:
outline_context = f"""
REFINED EPISODE OUTLINE (Follow this structure closely):
Title: {request.outline.get('title', 'N/A')}
Segments: {' | '.join(request.outline.get('segments', []))}
"""
prompt = f"""You are an expert podcast script planner. Create natural, conversational podcast scenes. prompt = f"""You are an expert podcast script planner. Create natural, conversational podcast scenes.
{f"PODCAST BIBLE (Hyper-Personalization Context):\n{bible_context}\n" if bible_context else ""}
{f"ANALYSIS CONTEXT:\n{analysis_context}\n" if analysis_context else ""}
{f"REFINED OUTLINE:\n{outline_context}\n" if outline_context else ""}
Podcast Idea: "{request.idea}" Podcast Idea: "{request.idea}"
Duration: ~{request.duration_minutes} minutes Duration: ~{request.duration_minutes} minutes
Speakers: {request.speakers} (Host + optional Guest) Speakers: {request.speakers} (Host + optional Guest)
@@ -83,11 +116,13 @@ Return JSON with:
* Mark "emphasis": true for key statistics or important points * Mark "emphasis": true for key statistics or important points
Guidelines: Guidelines:
- Write for spoken delivery: conversational, natural, with contractions - Write for spoken delivery: conversational, natural, with contractions.
- Use research insights naturally - weave statistics into dialogue, don't just list them - Follow the interaction tone specified in the Bible.
- Vary emotion per scene based on content - Ensure the Host persona matches the background and personality traits from the Bible.
- Ensure scenes match target duration: aim for ~2.5 words per second of audio - Structure the intro and outro scenes according to the Bible's "Intro Format" and "Outro Format".
- Keep it engaging and informative, like a real podcast conversation - Adhere to any constraints mentioned in the Bible.
- Use insights from the Research Context to ground the conversation in facts.
- IMPORTANT: Follow the REFINED OUTLINE segments as the primary structure for the episode.
""" """
try: try:

View File

@@ -14,7 +14,7 @@ import re
import json import json
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from services.database import get_db from services.database import get_session_for_user
from middleware.auth_middleware import get_current_user, get_current_user_with_query_token from middleware.auth_middleware import get_current_user, get_current_user_with_query_token
from api.story_writer.utils.auth import require_authenticated_user from api.story_writer.utils.auth import require_authenticated_user
from services.wavespeed.infinitetalk import animate_scene_with_voiceover from services.wavespeed.infinitetalk import animate_scene_with_voiceover
@@ -105,6 +105,34 @@ def _execute_podcast_video_task(
scene_number_match = re.search(r'\d+', request.scene_id) scene_number_match = re.search(r'\d+', request.scene_id)
scene_number = int(scene_number_match.group()) if scene_number_match else 0 scene_number = int(scene_number_match.group()) if scene_number_match else 0
# Fetch project context (Bible & Analysis) from DB if not provided in request
from services.database import get_session_for_user
from services.podcast_service import PodcastService
project_bible = request.bible
project_analysis = None
try:
# Create a dedicated session for this background task
db = get_session_for_user(user_id)
try:
podcast_service = PodcastService(db)
# Fetch project directly from DB to get latest analysis/bible
project = podcast_service.get_project(user_id, request.project_id)
if project:
# Use project bible if request didn't provide one
if not project_bible and project.bible:
project_bible = project.bible
# Get analysis for better context
if project.analysis:
project_analysis = project.analysis
logger.info(f"[Podcast] Loaded analysis for video context: {list(project_analysis.keys())}")
finally:
db.close()
except Exception as e:
logger.warning(f"[Podcast] Failed to fetch project context for video generation: {e}")
# Prepare scene data for animation # Prepare scene data for animation
scene_data = { scene_data = {
"scene_number": scene_number, "scene_number": scene_number,
@@ -114,6 +142,8 @@ def _execute_podcast_video_task(
story_context = { story_context = {
"project_id": request.project_id, "project_id": request.project_id,
"type": "podcast", "type": "podcast",
"bible": project_bible,
"analysis": project_analysis,
} }
animation_result = animate_scene_with_voiceover( animation_result = animate_scene_with_voiceover(
@@ -207,8 +237,8 @@ def _execute_podcast_video_task(
@router.post("/render/video", response_model=PodcastVideoGenerationResponse) @router.post("/render/video", response_model=PodcastVideoGenerationResponse)
async def generate_podcast_video( async def generate_podcast_video(
request_obj: Request, request: Request,
request: PodcastVideoGenerationRequest, body: PodcastVideoGenerationRequest,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
current_user: Dict[str, Any] = Depends(get_current_user), current_user: Dict[str, Any] = Depends(get_current_user),
): ):
@@ -216,22 +246,46 @@ async def generate_podcast_video(
Generate video for a podcast scene using WaveSpeed InfiniteTalk (avatar image + audio). Generate video for a podcast scene using WaveSpeed InfiniteTalk (avatar image + audio).
Returns task_id for polling since InfiniteTalk can take up to 10 minutes. Returns task_id for polling since InfiniteTalk can take up to 10 minutes.
""" """
# Debug logging to identify "Depends object has no attribute get" error source
logger.info(f"[Podcast] generate_podcast_video called. current_user type: {type(current_user)}")
# Check if current_user is a Depends object (FastAPI injection failure)
if hasattr(current_user, "dependency"):
logger.error(f"[Podcast] CRITICAL: current_user is a Depends object! Dependency injection failed.")
# Attempt to manually resolve or fail gracefully
auth_header = None
try:
if hasattr(request, 'headers') and hasattr(request.headers, 'get'):
auth_header = request.headers.get("Authorization")
except:
pass
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.replace("Bearer ", "").strip()
# Manually verify token if dependency injection failed
from middleware.auth_middleware import clerk_auth
current_user = await clerk_auth.verify_token(token)
if not current_user:
raise HTTPException(status_code=401, detail="Authentication failed (manual recovery)")
else:
raise HTTPException(status_code=401, detail="Authentication failed (injection error)")
user_id = require_authenticated_user(current_user) user_id = require_authenticated_user(current_user)
logger.info( logger.info(
f"[Podcast] Starting video generation for project {request.project_id}, scene {request.scene_id}" f"[Podcast] Starting video generation for project {body.project_id}, scene {body.scene_id}"
) )
# Load audio bytes # Load audio bytes
audio_bytes = load_podcast_audio_bytes(request.audio_url) audio_bytes = load_podcast_audio_bytes(body.audio_url)
# Validate resolution # Validate resolution
if request.resolution not in {"480p", "720p"}: if body.resolution not in {"480p", "720p"}:
raise HTTPException(status_code=400, detail="Resolution must be '480p' or '720p'.") raise HTTPException(status_code=400, detail="Resolution must be '480p' or '720p'.")
# Load image bytes (scene image is required for video generation) # Load image bytes (scene image is required for video generation)
if request.avatar_image_url: if body.avatar_image_url:
image_bytes = load_podcast_image_bytes(request.avatar_image_url) image_bytes = load_podcast_image_bytes(body.avatar_image_url)
else: else:
# Scene-specific image should be generated before video generation # Scene-specific image should be generated before video generation
raise HTTPException( raise HTTPException(
@@ -240,9 +294,9 @@ async def generate_podcast_video(
) )
mask_image_bytes = None mask_image_bytes = None
if request.mask_image_url: if body.mask_image_url:
try: try:
mask_image_bytes = load_podcast_image_bytes(request.mask_image_url) mask_image_bytes = load_podcast_image_bytes(body.mask_image_url)
except Exception as e: except Exception as e:
logger.error(f"[Podcast] Failed to load mask image: {e}") logger.error(f"[Podcast] Failed to load mask image: {e}")
raise HTTPException( raise HTTPException(
@@ -251,7 +305,9 @@ async def generate_podcast_video(
) )
# Validate subscription limits # Validate subscription limits
db = next(get_db()) db = get_session_for_user(user_id)
if not db:
raise HTTPException(status_code=500, detail="Database session unavailable for user.")
try: try:
pricing_service = PricingService(db) pricing_service = PricingService(db)
validate_scene_animation_operation(pricing_service=pricing_service, user_id=user_id) validate_scene_animation_operation(pricing_service=pricing_service, user_id=user_id)
@@ -260,16 +316,20 @@ async def generate_podcast_video(
# Extract token for authenticated URL building # Extract token for authenticated URL building
auth_token = None auth_token = None
auth_header = request_obj.headers.get("Authorization") try:
if auth_header and auth_header.startswith("Bearer "): if hasattr(request, 'headers') and hasattr(request.headers, 'get'):
auth_token = auth_header.replace("Bearer ", "").strip() auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
auth_token = auth_header.replace("Bearer ", "").strip()
except Exception as e:
logger.warning(f"[Podcast] Failed to extract auth token from headers: {e}")
# Create async task # Create async task
task_id = task_manager.create_task("podcast_video_generation") task_id = task_manager.create_task("podcast_video_generation")
background_tasks.add_task( background_tasks.add_task(
_execute_podcast_video_task, _execute_podcast_video_task,
task_id=task_id, task_id=task_id,
request=request, request=body,
user_id=user_id, user_id=user_id,
image_bytes=image_bytes, image_bytes=image_bytes,
audio_bytes=audio_bytes, audio_bytes=audio_bytes,

View File

@@ -25,6 +25,7 @@ class PodcastProjectResponse(BaseModel):
raw_research: Optional[Dict[str, Any]] = None raw_research: Optional[Dict[str, Any]] = None
estimate: Optional[Dict[str, Any]] = None estimate: Optional[Dict[str, Any]] = None
script_data: Optional[Dict[str, Any]] = None script_data: Optional[Dict[str, Any]] = None
bible: Optional[Dict[str, Any]] = None
render_jobs: Optional[List[Dict[str, Any]]] = None render_jobs: Optional[List[Dict[str, Any]]] = None
knobs: Optional[Dict[str, Any]] = None knobs: Optional[Dict[str, Any]] = None
research_provider: Optional[str] = None research_provider: Optional[str] = None
@@ -34,6 +35,9 @@ class PodcastProjectResponse(BaseModel):
status: str = "draft" status: str = "draft"
is_favorite: bool = False is_favorite: bool = False
final_video_url: Optional[str] = None final_video_url: Optional[str] = None
avatar_url: Optional[str] = None
avatar_prompt: Optional[str] = None
avatar_persona_id: Optional[str] = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -46,6 +50,9 @@ class PodcastAnalyzeRequest(BaseModel):
idea: str = Field(..., description="Podcast topic or idea") idea: str = Field(..., description="Podcast topic or idea")
duration: int = Field(default=10, description="Target duration in minutes") duration: int = Field(default=10, description="Target duration in minutes")
speakers: int = Field(default=1, description="Number of speakers") speakers: int = Field(default=1, description="Number of speakers")
bible: Optional[Dict[str, Any]] = Field(None, description="Optional Podcast Bible for context")
avatar_url: Optional[str] = Field(None, description="Current avatar URL if selected")
feedback: Optional[str] = Field(None, description="User feedback for regeneration")
class PodcastAnalyzeResponse(BaseModel): class PodcastAnalyzeResponse(BaseModel):
@@ -55,7 +62,23 @@ class PodcastAnalyzeResponse(BaseModel):
top_keywords: list[str] top_keywords: list[str]
suggested_outlines: list[Dict[str, Any]] suggested_outlines: list[Dict[str, Any]]
title_suggestions: list[str] title_suggestions: list[str]
research_queries: Optional[List[Dict[str, str]]] = None
exa_suggested_config: Optional[Dict[str, Any]] = None exa_suggested_config: Optional[Dict[str, Any]] = None
bible: Optional[Dict[str, Any]] = None
avatar_url: Optional[str] = None
avatar_prompt: Optional[str] = None
class PodcastEnhanceIdeaRequest(BaseModel):
"""Request model for enhancing a podcast idea with AI."""
idea: str = Field(..., description="The raw podcast idea or keywords")
bible: Optional[Dict[str, Any]] = Field(None, description="Optional Podcast Bible for context")
class PodcastEnhanceIdeaResponse(BaseModel):
"""Response model for enhanced podcast idea."""
enhanced_idea: str
rationale: str
class PodcastScriptRequest(BaseModel): class PodcastScriptRequest(BaseModel):
@@ -64,6 +87,9 @@ class PodcastScriptRequest(BaseModel):
duration_minutes: int = Field(default=10, description="Target duration in minutes") duration_minutes: int = Field(default=10, description="Target duration in minutes")
speakers: int = Field(default=1, description="Number of speakers") speakers: int = Field(default=1, description="Number of speakers")
research: Optional[Dict[str, Any]] = Field(None, description="Optional research payload to ground the script") research: Optional[Dict[str, Any]] = Field(None, description="Optional research payload to ground the script")
bible: Optional[Dict[str, Any]] = Field(None, description="Podcast Bible for hyper-personalization")
outline: Optional[Dict[str, Any]] = Field(None, description="The refined episode outline to follow")
analysis: Optional[Dict[str, Any]] = Field(None, description="The full analysis context (audience, keywords, etc.)")
class PodcastSceneLine(BaseModel): class PodcastSceneLine(BaseModel):
@@ -106,6 +132,8 @@ class PodcastExaResearchRequest(BaseModel):
topic: str topic: str
queries: List[str] queries: List[str]
exa_config: Optional[PodcastExaConfig] = None exa_config: Optional[PodcastExaConfig] = None
bible: Optional[Dict[str, Any]] = Field(None, description="Podcast Bible for hyper-personalization")
analysis: Optional[Dict[str, Any]] = Field(None, description="Podcast analysis context (audience, content type, etc.)")
class PodcastExaSource(BaseModel): class PodcastExaSource(BaseModel):
@@ -117,15 +145,26 @@ class PodcastExaSource(BaseModel):
summary: Optional[str] = None summary: Optional[str] = None
source_type: Optional[str] = None source_type: Optional[str] = None
index: Optional[int] = None index: Optional[int] = None
image: Optional[str] = None
author: Optional[str] = None
class PodcastResearchInsight(BaseModel):
"""Deep insight extracted from research."""
title: str
content: str
source_indices: List[int] = []
class PodcastExaResearchResponse(BaseModel): class PodcastExaResearchResponse(BaseModel):
sources: List[PodcastExaSource] sources: List[PodcastExaSource]
search_queries: List[str] = [] search_queries: List[str] = []
summary: str = ""
key_insights: List[PodcastResearchInsight] = []
cost: Optional[Dict[str, Any]] = None cost: Optional[Dict[str, Any]] = None
search_type: Optional[str] = None search_type: Optional[str] = None
provider: str = "exa" provider: str = "exa"
content: Optional[str] = None content: Optional[str] = None # Raw aggregated content (deprecated)
class PodcastScriptResponse(BaseModel): class PodcastScriptResponse(BaseModel):
@@ -191,6 +230,7 @@ class UpdateProjectRequest(BaseModel):
raw_research: Optional[Dict[str, Any]] = None raw_research: Optional[Dict[str, Any]] = None
estimate: Optional[Dict[str, Any]] = None estimate: Optional[Dict[str, Any]] = None
script_data: Optional[Dict[str, Any]] = None script_data: Optional[Dict[str, Any]] = None
bible: Optional[Dict[str, Any]] = None
render_jobs: Optional[List[Dict[str, Any]]] = None render_jobs: Optional[List[Dict[str, Any]]] = None
knobs: Optional[Dict[str, Any]] = None knobs: Optional[Dict[str, Any]] = None
research_provider: Optional[str] = None research_provider: Optional[str] = None
@@ -224,6 +264,7 @@ class PodcastImageRequest(BaseModel):
scene_content: Optional[str] = None # Optional: scene lines text for context scene_content: Optional[str] = None # Optional: scene lines text for context
idea: Optional[str] = None # Optional: podcast idea for context idea: Optional[str] = None # Optional: podcast idea for context
base_avatar_url: Optional[str] = None # Base avatar image URL for scene variations base_avatar_url: Optional[str] = None # Base avatar image URL for scene variations
bible: Optional[Dict[str, Any]] = Field(None, description="Podcast Bible for hyper-personalization")
width: int = 1024 width: int = 1024
height: int = 1024 height: int = 1024
custom_prompt: Optional[str] = None # Custom prompt from user (overrides auto-generated prompt) custom_prompt: Optional[str] = None # Custom prompt from user (overrides auto-generated prompt)
@@ -252,6 +293,7 @@ class PodcastVideoGenerationRequest(BaseModel):
scene_title: str = Field(..., description="Scene title") scene_title: str = Field(..., description="Scene title")
audio_url: str = Field(..., description="URL to the generated audio file") audio_url: str = Field(..., description="URL to the generated audio file")
avatar_image_url: Optional[str] = Field(None, description="URL to scene image (required for video generation)") avatar_image_url: Optional[str] = Field(None, description="URL to scene image (required for video generation)")
bible: Optional[Dict[str, Any]] = Field(None, description="Podcast Bible for hyper-personalization")
resolution: str = Field("720p", description="Video resolution (480p or 720p)") resolution: str = Field("720p", description="Video resolution (480p or 720p)")
prompt: Optional[str] = Field(None, description="Optional animation prompt override") prompt: Optional[str] = Field(None, description="Optional animation prompt override")
seed: Optional[int] = Field(-1, description="Random seed; -1 for random") seed: Optional[int] = Field(-1, description="Random seed; -1 for random")

View File

@@ -524,6 +524,80 @@ async def get_semantic_cache_stats(current_user: dict = Depends(get_current_user
"memory_usage_mb": 0.0 "memory_usage_mb": 0.0
} }
async def get_sif_indexing_health(current_user: dict = Depends(get_current_user)) -> Dict[str, Any]:
try:
from models.website_analysis_monitoring_models import SIFIndexingTask, SIFIndexingExecutionLog
user_id = str(current_user.get("id"))
db = get_session_for_user(user_id)
if not db:
raise HTTPException(status_code=500, detail="Database connection unavailable")
try:
tasks = (
db.query(SIFIndexingTask)
.filter(SIFIndexingTask.user_id == user_id)
.order_by(SIFIndexingTask.created_at.desc())
.all()
)
if not tasks:
return {
"has_task": False,
"status": "not_scheduled",
"message": "SIF indexing task not yet scheduled for this website.",
}
latest = tasks[0]
latest_log = (
db.query(SIFIndexingExecutionLog)
.filter(SIFIndexingExecutionLog.task_id == latest.id)
.order_by(SIFIndexingExecutionLog.execution_date.desc())
.first()
)
last_run_status = latest_log.status if latest_log else None
last_run_time = (
latest_log.execution_date.isoformat() if latest_log and latest_log.execution_date else None
)
last_error = (
(latest_log.error_message or "")[:500] if latest_log and latest_log.error_message else None
)
overall_status = "healthy"
if latest.consecutive_failures and latest.consecutive_failures > 0:
overall_status = "warning"
if latest.status in {"needs_intervention"}:
overall_status = "critical"
return {
"has_task": True,
"status": overall_status,
"task": {
"id": latest.id,
"website_url": latest.website_url,
"raw_status": latest.status,
"next_execution": latest.next_execution.isoformat() if latest.next_execution else None,
"last_success": latest.last_success.isoformat() if latest.last_success else None,
"last_failure": latest.last_failure.isoformat() if latest.last_failure else None,
"consecutive_failures": latest.consecutive_failures or 0,
"failure_pattern": latest.failure_pattern,
},
"last_run": {
"status": last_run_status,
"time": last_run_time,
"error_message": last_error,
},
}
finally:
db.close()
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get SIF indexing health: {e}")
raise HTTPException(status_code=500, detail="Failed to get SIF indexing health")
# New comprehensive SEO analysis endpoints # New comprehensive SEO analysis endpoints
async def analyze_seo_comprehensive(request: SEOAnalysisRequest) -> SEOAnalysisResponse: async def analyze_seo_comprehensive(request: SEOAnalysisRequest) -> SEOAnalysisResponse:
""" """

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, media_generation,
scene_animation, scene_animation,
story_content, story_content,
story_projects,
story_setup, story_setup,
story_tasks, story_tasks,
video_generation, video_generation,
@@ -24,6 +25,7 @@ router = APIRouter(prefix="/api/story", tags=["Story Writer"])
# Include modular routers (order preserved roughly by workflow) # Include modular routers (order preserved roughly by workflow)
router.include_router(story_setup.router) router.include_router(story_setup.router)
router.include_router(story_content.router) router.include_router(story_content.router)
router.include_router(story_projects.router)
router.include_router(story_tasks.router) router.include_router(story_tasks.router)
router.include_router(media_generation.router) router.include_router(media_generation.router)
router.include_router(scene_animation.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_number=result.get("scene_number", 0),
scene_title=result.get("scene_title", "Untitled"), scene_title=result.get("scene_title", "Untitled"),
image_filename=result.get("image_filename", ""), image_filename=result.get("image_filename", ""),
image_url=result.get("image_url", ""), image_url=result.get("image_url") or "",
width=result.get("width", 1024), width=result.get("width", 1024),
height=result.get("height", 1024), height=result.get("height", 1024),
provider=result.get("provider", "unknown"), provider=result.get("provider", "unknown"),
@@ -148,7 +148,7 @@ async def regenerate_scene_image(
scene_number=result.get("scene_number", request.scene_number), scene_number=result.get("scene_number", request.scene_number),
scene_title=result.get("scene_title", request.scene_title), scene_title=result.get("scene_title", request.scene_title),
image_filename=result.get("image_filename", ""), 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), width=result.get("width", request.width or 1024),
height=result.get("height", request.height or 1024), height=result.get("height", request.height or 1024),
provider=result.get("provider", "unknown"), provider=result.get("provider", "unknown"),

View File

@@ -12,6 +12,10 @@ from models.story_models import (
StoryScene, StoryScene,
StoryContinueRequest, StoryContinueRequest,
StoryContinueResponse, StoryContinueResponse,
AnimeSceneTextRequest,
AnimeSceneTextResponse,
AnimeSceneGenerateRequest,
AnimeSceneGenerateResponse,
) )
from services.story_writer.story_service import StoryWriterService from services.story_writer.story_service import StoryWriterService
@@ -107,6 +111,7 @@ async def generate_story_start(
content_rating=request.content_rating, content_rating=request.content_rating,
ending_preference=request.ending_preference, ending_preference=request.ending_preference,
story_length=story_length, story_length=story_length,
anime_bible=getattr(request, "anime_bible", None),
user_id=user_id, user_id=user_id,
) )
@@ -211,6 +216,7 @@ async def continue_story(
audience_age_group=request.audience_age_group, audience_age_group=request.audience_age_group,
content_rating=request.content_rating, content_rating=request.content_rating,
ending_preference=request.ending_preference, ending_preference=request.ending_preference,
anime_bible=getattr(request, "anime_bible", None),
story_length=story_length, story_length=story_length,
user_id=user_id, user_id=user_id,
) )
@@ -245,6 +251,105 @@ async def continue_story(
raise HTTPException(status_code=500, detail=str(exc)) 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): class SceneApprovalRequest(BaseModel):
project_id: str = Field(..., min_length=1) project_id: str = Field(..., min_length=1)
scene_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 fastapi import APIRouter, Depends, HTTPException
from loguru import logger from loguru import logger
from sqlalchemy.orm import Session
from sqlalchemy import desc
from middleware.auth_middleware import get_current_user from middleware.auth_middleware import get_current_user
from models.story_models import ( from models.story_models import (
@@ -13,8 +15,14 @@ from models.story_models import (
StoryScene, StoryScene,
StoryStartRequest, StoryStartRequest,
StoryPremiseResponse, StoryPremiseResponse,
StoryIdeaEnhanceRequest,
StoryIdeaEnhanceResponse,
StoryIdeaEnhanceSuggestion,
) )
from services.story_writer.story_service import StoryWriterService 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 from ..utils.auth import require_authenticated_user
@@ -39,6 +47,9 @@ async def generate_story_setup(
options = story_service.generate_story_setup_options( options = story_service.generate_story_setup_options(
story_idea=request.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, user_id=user_id,
) )
@@ -52,6 +63,152 @@ async def generate_story_setup(
raise HTTPException(status_code=500, detail=str(exc)) 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) @router.post("/generate-premise", response_model=StoryPremiseResponse)
async def generate_premise( async def generate_premise(
request: StoryGenerationRequest, request: StoryGenerationRequest,
@@ -108,6 +265,9 @@ async def generate_outline(
request.story_tone, 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( outline = story_service.generate_outline(
premise=request.premise, premise=request.premise,
persona=request.persona, persona=request.persona,
@@ -122,15 +282,37 @@ async def generate_outline(
ending_preference=request.ending_preference, ending_preference=request.ending_preference,
user_id=user_id, user_id=user_id,
use_structured_output=use_structured, use_structured_output=use_structured,
include_anime_bible=True,
) )
if isinstance(outline, list): anime_bible: Dict[str, Any] | None = None
scenes: List[StoryScene] = [ outline_payload: Any = outline
StoryScene(**scene) if isinstance(scene, dict) else scene for scene in outline
]
return StoryOutlineResponse(outline=scenes, success=True, is_structured=True)
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: except HTTPException:
raise raise

View File

@@ -1,6 +1,7 @@
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
import json
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
@@ -350,9 +351,21 @@ def execute_complete_video_generation(
Runs in a background task and performs blocking operations. Runs in a background task and performs blocking operations.
""" """
try: 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( premise = story_service.generate_premise(
persona=request_data["persona"], persona=request_data["persona"],
story_setting=request_data["story_setting"], story_setting=request_data["story_setting"],
@@ -367,7 +380,12 @@ def execute_complete_video_generation(
user_id=user_id, 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( outline_scenes = story_service.generate_outline(
premise=premise, premise=premise,
persona=request_data["persona"], persona=request_data["persona"],
@@ -401,6 +419,7 @@ def execute_complete_video_generation(
height=request_data.get("image_height", 1024), height=request_data.get("image_height", 1024),
model=request_data.get("image_model"), model=request_data.get("image_model"),
progress_callback=image_progress_callback, 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...") 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"], audience_age_group=request_data["audience_age_group"],
content_rating=request_data["content_rating"], content_rating=request_data["content_rating"],
ending_preference=request_data["ending_preference"], ending_preference=request_data["ending_preference"],
user_id=user_id user_id=user_id,
) )
# Step 2: Generate outline # Step 2: Generate outline
@@ -157,7 +157,7 @@ class TaskManager:
audience_age_group=request_data["audience_age_group"], audience_age_group=request_data["audience_age_group"],
content_rating=request_data["content_rating"], content_rating=request_data["content_rating"],
ending_preference=request_data["ending_preference"], ending_preference=request_data["ending_preference"],
user_id=user_id user_id=user_id,
) )
# Step 3: Generate story start # Step 3: Generate story start
@@ -175,7 +175,8 @@ class TaskManager:
audience_age_group=request_data["audience_age_group"], audience_age_group=request_data["audience_age_group"],
content_rating=request_data["content_rating"], content_rating=request_data["content_rating"],
ending_preference=request_data["ending_preference"], 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 # Step 4: Continue story
@@ -208,7 +209,8 @@ class TaskManager:
audience_age_group=request_data["audience_age_group"], audience_age_group=request_data["audience_age_group"],
content_rating=request_data["content_rating"], content_rating=request_data["content_rating"],
ending_preference=request_data["ending_preference"], ending_preference=request_data["ending_preference"],
user_id=user_id anime_bible=request_data.get("anime_bible"),
user_id=user_id,
) )
if continuation: 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 Validates the current user dictionary provided by Clerk middleware and
returns the normalized user_id. Raises HTTP 401 if authentication fails. 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") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
user_id = str(current_user.get("id", "")).strip() 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/ 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 = (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 = (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]: def _get_user_media_path(user_id: str, media_type: str) -> Optional[Path]:

View File

@@ -12,7 +12,10 @@ from .routes import (
alerts, alerts,
dashboard, dashboard,
logs, logs,
preflight preflight,
payment,
disputes,
fraud_warnings,
) )
# Create main router # Create main router
@@ -26,5 +29,8 @@ router.include_router(alerts.router, tags=["subscription"])
router.include_router(dashboard.router, tags=["subscription"]) router.include_router(dashboard.router, tags=["subscription"])
router.include_router(logs.router, tags=["subscription"]) router.include_router(logs.router, tags=["subscription"])
router.include_router(preflight.router, tags=["subscription"]) router.include_router(preflight.router, tags=["subscription"])
router.include_router(payment.router, tags=["subscription"])
router.include_router(disputes.router, tags=["subscription"])
router.include_router(fraud_warnings.router, tags=["subscription"])
__all__ = ["router"] __all__ = ["router"]

View File

@@ -3,6 +3,6 @@ Subscription API Routes
All route modules are imported here for easy access. All route modules are imported here for easy access.
""" """
from . import usage, plans, subscriptions, alerts, dashboard, logs, preflight from . import usage, plans, subscriptions, alerts, dashboard, logs, preflight, payment, disputes
__all__ = ["usage", "plans", "subscriptions", "alerts", "dashboard", "logs", "preflight"] __all__ = ["usage", "plans", "subscriptions", "alerts", "dashboard", "logs", "preflight", "payment", "disputes"]

View File

@@ -0,0 +1,142 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import Dict, Any, Optional
from pydantic import BaseModel
from services.database import get_db
from middleware.auth_middleware import get_current_user
from loguru import logger
import stripe
import os
router = APIRouter()
def _ensure_admin(current_user: Dict[str, Any]) -> None:
disable_auth = os.getenv("DISABLE_AUTH", "false").lower() == "true"
if disable_auth:
return
email = (current_user.get("email") or "").lower()
role = None
public_metadata = current_user.get("public_metadata")
if isinstance(public_metadata, dict):
role = public_metadata.get("role") or current_user.get("role")
else:
role = current_user.get("role")
admin_emails_raw = os.getenv("ADMIN_EMAILS", "")
admin_emails = {
e.strip().lower() for e in admin_emails_raw.split(",") if e.strip()
}
admin_domain = (os.getenv("ADMIN_EMAIL_DOMAIN") or "").lower().strip()
is_admin_email = email and email in admin_emails
is_admin_domain = email and admin_domain and email.endswith("@" + admin_domain)
is_admin_role = role == "admin"
if not (is_admin_email or is_admin_domain or is_admin_role):
raise HTTPException(status_code=403, detail="Admin access required for dispute operations")
def _get_stripe_client() -> None:
api_key = os.getenv("STRIPE_SECRET_KEY")
if not api_key:
logger.error("STRIPE_SECRET_KEY is not configured; dispute operations are disabled")
raise HTTPException(status_code=500, detail="Payment service not configured")
stripe.api_key = api_key
class DisputeEvidenceUpdateRequest(BaseModel):
evidence: Optional[Dict[str, Any]] = None
@router.get("/disputes")
async def list_disputes(
limit: int = 10,
starting_after: Optional[str] = None,
ending_before: Optional[str] = None,
db: Session = Depends(get_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
_get_stripe_client()
_ensure_admin(current_user)
try:
params: Dict[str, Any] = {"limit": max(1, min(limit, 100))}
if starting_after:
params["starting_after"] = starting_after
if ending_before:
params["ending_before"] = ending_before
disputes = stripe.Dispute.list(**params)
return {"data": disputes}
except Exception as e:
logger.error(f"Error listing disputes: {e}")
raise HTTPException(status_code=500, detail="Failed to list disputes")
@router.get("/disputes/{dispute_id}")
async def get_dispute(
dispute_id: str,
db: Session = Depends(get_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
_get_stripe_client()
_ensure_admin(current_user)
try:
dispute = stripe.Dispute.retrieve(dispute_id)
return {"data": dispute}
except stripe.error.InvalidRequestError as e:
logger.warning(f"Invalid dispute id {dispute_id}: {e}")
raise HTTPException(status_code=404, detail="Dispute not found")
except Exception as e:
logger.error(f"Error retrieving dispute {dispute_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to retrieve dispute")
@router.post("/disputes/{dispute_id}")
async def update_dispute(
dispute_id: str,
payload: DisputeEvidenceUpdateRequest,
db: Session = Depends(get_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
_get_stripe_client()
_ensure_admin(current_user)
if not payload.evidence:
raise HTTPException(status_code=400, detail="Evidence payload is required to update a dispute")
try:
dispute = stripe.Dispute.modify(
dispute_id,
evidence=payload.evidence,
)
return {"data": dispute}
except stripe.error.InvalidRequestError as e:
logger.warning(f"Invalid dispute id {dispute_id} during update: {e}")
raise HTTPException(status_code=404, detail="Dispute not found")
except Exception as e:
logger.error(f"Error updating dispute {dispute_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to update dispute")
@router.post("/disputes/{dispute_id}/close")
async def close_dispute(
dispute_id: str,
db: Session = Depends(get_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
_get_stripe_client()
_ensure_admin(current_user)
try:
dispute = stripe.Dispute.close(dispute_id)
return {"data": dispute}
except stripe.error.InvalidRequestError as e:
logger.warning(f"Invalid dispute id {dispute_id} during close: {e}")
raise HTTPException(status_code=404, detail="Dispute not found")
except Exception as e:
logger.error(f"Error closing dispute {dispute_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to close dispute")

View File

@@ -0,0 +1,209 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import Dict, Any, Optional
from pydantic import BaseModel
from services.database import get_db
from middleware.auth_middleware import get_current_user
from loguru import logger
import stripe
import os
from datetime import datetime
from models.subscription_models import FraudWarning
router = APIRouter()
def _ensure_admin(current_user: Dict[str, Any]) -> None:
disable_auth = os.getenv("DISABLE_AUTH", "false").lower() == "true"
if disable_auth:
return
email = (current_user.get("email") or "").lower()
role = None
public_metadata = current_user.get("public_metadata")
if isinstance(public_metadata, dict):
role = public_metadata.get("role") or current_user.get("role")
else:
role = current_user.get("role")
admin_emails_raw = os.getenv("ADMIN_EMAILS", "")
admin_emails = {
e.strip().lower() for e in admin_emails_raw.split(",") if e.strip()
}
admin_domain = (os.getenv("ADMIN_EMAIL_DOMAIN") or "").lower().strip()
is_admin_email = email and email in admin_emails
is_admin_domain = email and admin_domain and email.endswith("@" + admin_domain)
is_admin_role = role == "admin"
if not (is_admin_email or is_admin_domain or is_admin_role):
raise HTTPException(status_code=403, detail="Admin access required for fraud warning operations")
def _get_stripe_client() -> None:
api_key = os.getenv("STRIPE_SECRET_KEY")
if not api_key:
logger.error("STRIPE_SECRET_KEY is not configured; fraud warning operations are disabled")
raise HTTPException(status_code=500, detail="Payment service not configured")
stripe.api_key = api_key
class FraudWarningRefundRequest(BaseModel):
notes: Optional[str] = None
class FraudWarningIgnoreRequest(BaseModel):
notes: Optional[str] = None
@router.get("/fraud-warnings")
async def list_fraud_warnings(
status: Optional[str] = "open",
limit: int = 20,
offset: int = 0,
db: Session = Depends(get_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
_ensure_admin(current_user)
query = db.query(FraudWarning)
if status:
query = query.filter(FraudWarning.status == status)
limit = max(1, min(limit, 100))
items = (
query.order_by(FraudWarning.created_at.desc())
.offset(max(0, offset))
.limit(limit)
.all()
)
data = []
for fw in items:
data.append(
{
"id": fw.id,
"charge_id": fw.charge_id,
"payment_intent_id": fw.payment_intent_id,
"user_id": fw.user_id,
"amount": fw.amount,
"currency": fw.currency,
"status": fw.status,
"action": fw.action,
"action_at": fw.action_at.isoformat() if fw.action_at else None,
"created_at": fw.created_at.isoformat() if fw.created_at else None,
}
)
return {"data": data}
@router.get("/fraud-warnings/{warning_id}")
async def get_fraud_warning(
warning_id: str,
db: Session = Depends(get_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
_ensure_admin(current_user)
fw = db.query(FraudWarning).filter(FraudWarning.id == warning_id).first()
if not fw:
raise HTTPException(status_code=404, detail="Fraud warning not found")
payload: Dict[str, Any] = {
"id": fw.id,
"charge_id": fw.charge_id,
"payment_intent_id": fw.payment_intent_id,
"user_id": fw.user_id,
"amount": fw.amount,
"currency": fw.currency,
"status": fw.status,
"action": fw.action,
"action_at": fw.action_at.isoformat() if fw.action_at else None,
"reason_notes": fw.reason_notes,
"created_at": fw.created_at.isoformat() if fw.created_at else None,
"meta_info": fw.meta_info,
}
return {"data": payload}
@router.post("/fraud-warnings/{warning_id}/refund")
async def refund_fraud_warning(
warning_id: str,
payload: FraudWarningRefundRequest,
db: Session = Depends(get_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
_ensure_admin(current_user)
_get_stripe_client()
fw = db.query(FraudWarning).filter(FraudWarning.id == warning_id).first()
if not fw:
raise HTTPException(status_code=404, detail="Fraud warning not found")
if fw.status == "refunded":
raise HTTPException(status_code=400, detail="Fraud warning already refunded")
try:
stripe.Refund.create(charge=fw.charge_id)
except stripe.error.InvalidRequestError as e:
logger.warning(f"Refund failed for fraud warning {warning_id}, charge {fw.charge_id}: {e}")
raise HTTPException(status_code=400, detail="Refund failed for this charge")
except Exception as e:
logger.error(f"Unexpected error refunding fraud warning {warning_id}: {e}")
raise HTTPException(status_code=500, detail="Unexpected error while processing refund")
fw.status = "refunded"
fw.action = "refund_full"
fw.action_at = datetime.utcnow()
if payload and payload.notes:
fw.reason_notes = payload.notes
db.commit()
db.refresh(fw)
return {
"data": {
"id": fw.id,
"status": fw.status,
"action": fw.action,
"action_at": fw.action_at.isoformat() if fw.action_at else None,
"reason_notes": fw.reason_notes,
}
}
@router.post("/fraud-warnings/{warning_id}/ignore")
async def ignore_fraud_warning(
warning_id: str,
payload: FraudWarningIgnoreRequest,
db: Session = Depends(get_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
_ensure_admin(current_user)
fw = db.query(FraudWarning).filter(FraudWarning.id == warning_id).first()
if not fw:
raise HTTPException(status_code=404, detail="Fraud warning not found")
fw.status = "ignored"
fw.action = "ignored"
fw.action_at = datetime.utcnow()
if payload and payload.notes:
fw.reason_notes = payload.notes
db.commit()
db.refresh(fw)
return {
"data": {
"id": fw.id,
"status": fw.status,
"action": fw.action,
"action_at": fw.action_at.isoformat() if fw.action_at else None,
"reason_notes": fw.reason_notes,
}
}

View File

@@ -0,0 +1,125 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Header, BackgroundTasks
from sqlalchemy.orm import Session
from typing import Dict, Any, Optional
from pydantic import BaseModel
from services.database import get_db
from services.subscription.stripe_service import StripeService
from middleware.auth_middleware import get_current_user
from loguru import logger
from models.subscription_models import SubscriptionTier, BillingCycle
import time
from collections import defaultdict
router = APIRouter()
class CreateCheckoutSessionRequest(BaseModel):
tier: SubscriptionTier
billing_cycle: BillingCycle
success_url: str
cancel_url: str
class CreatePortalSessionRequest(BaseModel):
return_url: str
_checkout_rate_limit_window_seconds = 60
_checkout_rate_limit_max_requests = 10
_checkout_attempts_by_user: Dict[str, Any] = defaultdict(list)
@router.post("/create-checkout-session")
async def create_checkout_session(
payload: CreateCheckoutSessionRequest,
db: Session = Depends(get_db),
current_user: Dict[str, Any] = Depends(get_current_user),
request: Request = None
):
"""
Create a Stripe Checkout Session for subscription.
"""
user_id = current_user.get("sub") or current_user.get("id")
if not user_id:
raise HTTPException(status_code=401, detail="User not authenticated")
now = time.time()
attempts = _checkout_attempts_by_user[user_id]
window_start = now - _checkout_rate_limit_window_seconds
attempts[:] = [ts for ts in attempts if ts >= window_start]
attempts.append(now)
_checkout_attempts_by_user[user_id] = attempts
if len(attempts) > _checkout_rate_limit_max_requests:
client_ip = request.client.host if request and request.client else "unknown"
logger.warning(f"Checkout rate limit exceeded for user_id={user_id}, ip={client_ip}, attempts={len(attempts)} in { _checkout_rate_limit_window_seconds }s")
raise HTTPException(status_code=429, detail="Too many checkout attempts. Please try again shortly.")
user_email = current_user.get("email")
stripe_service = StripeService(db)
try:
url = stripe_service.create_checkout_session(
user_id=user_id,
tier=payload.tier,
billing_cycle=payload.billing_cycle,
success_url=payload.success_url,
cancel_url=payload.cancel_url,
user_email=user_email
)
return {"url": url}
except HTTPException as e:
raise e
except Exception as e:
logger.error(f"Error creating checkout session: {e}")
raise HTTPException(status_code=500, detail="Failed to initiate checkout")
@router.post("/create-portal-session")
async def create_portal_session(
payload: CreatePortalSessionRequest,
db: Session = Depends(get_db),
current_user: Dict[str, Any] = Depends(get_current_user)
):
"""
Create a Stripe Customer Portal session for managing billing.
"""
user_id = current_user.get("sub") or current_user.get("id")
if not user_id:
raise HTTPException(status_code=401, detail="User not authenticated")
stripe_service = StripeService(db)
try:
url = stripe_service.create_portal_session(
user_id=user_id,
return_url=payload.return_url
)
return {"url": url}
except HTTPException as e:
raise e
except Exception as e:
logger.error(f"Error creating portal session: {e}")
raise HTTPException(status_code=500, detail="Failed to access billing portal")
@router.post("/webhook")
async def stripe_webhook(
request: Request,
stripe_signature: str = Header(None),
db: Session = Depends(get_db)
):
"""
Handle Stripe webhooks.
"""
if not stripe_signature:
raise HTTPException(status_code=400, detail="Missing stripe-signature header")
payload = await request.body()
stripe_service = StripeService(db)
try:
# We need to run this potentially in background or await it
# Since it's async, we can await it directly.
await stripe_service.handle_webhook(payload, stripe_signature)
return {"status": "success"}
except HTTPException as e:
raise e
except Exception as e:
logger.error(f"Error processing webhook: {e}")
raise HTTPException(status_code=500, detail="Webhook processing failed")

View File

@@ -376,6 +376,9 @@ async def analyze_urls_ai_endpoint(request: AnalyzeURLsRequest, current_user: di
# Include platform analytics router # Include platform analytics router
from routers.platform_analytics import router as platform_analytics_router from routers.platform_analytics import router as platform_analytics_router
app.include_router(platform_analytics_router) app.include_router(platform_analytics_router)
# Include Bing Analytics Storage router to expose storage-backed endpoints
from routers.bing_analytics_storage import router as bing_analytics_storage_router
app.include_router(bing_analytics_storage_router)
app.include_router(images_router) app.include_router(images_router)
app.include_router(image_studio_router) app.include_router(image_studio_router)
app.include_router(product_marketing_router) app.include_router(product_marketing_router)

View File

@@ -0,0 +1,56 @@
import os
import asyncio
from datetime import date, timedelta
import httpx
async def main() -> None:
base_url = os.environ.get("ALWRITY_API_BASE_URL", "http://localhost:8000")
token = os.environ.get("ALWRITY_API_TOKEN")
today = date.today()
start = today - timedelta(days=29)
params = {
"platforms": "gsc",
"start_date": start.isoformat(),
"end_date": today.isoformat(),
}
headers = {}
if token:
headers["Authorization"] = f"Bearer {token}"
async with httpx.AsyncClient(base_url=base_url, headers=headers, timeout=60.0) as client:
resp = await client.get("/api/analytics/data", params=params)
print(f"Status: {resp.status_code}")
try:
data = resp.json()
except Exception:
print("NonJSON response body:")
print(resp.text)
return
print("Raw JSON response:")
print(data)
summary = data.get("summary") or {}
platforms = data.get("data") or {}
gsc = platforms.get("gsc") or {}
gsc_metrics = gsc.get("metrics") or {}
print("\nSummary snapshot:")
print(f" total_clicks: {summary.get('total_clicks')}")
print(f" total_impressions: {summary.get('total_impressions')}")
print(f" overall_ctr: {summary.get('overall_ctr')}")
print("\nGSC metrics snapshot:")
print(f" total_clicks: {gsc_metrics.get('total_clicks')}")
print(f" total_impressions: {gsc_metrics.get('total_impressions')}")
print(f" avg_ctr: {gsc_metrics.get('avg_ctr')}")
print(f" avg_position: {gsc_metrics.get('avg_position')}")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -129,7 +129,8 @@ from api.seo_dashboard import (
analyze_urls_ai, analyze_urls_ai,
AnalyzeURLsRequest, AnalyzeURLsRequest,
get_analyzed_pages, get_analyzed_pages,
get_semantic_health # Phase 2B: Semantic health monitoring get_semantic_health,
get_sif_indexing_health
) )
# Initialize FastAPI app # Initialize FastAPI app
@@ -337,6 +338,15 @@ async def semantic_cache_stats_endpoint(current_user: dict = Depends(get_current
""" """
return await get_semantic_cache_stats(current_user) return await get_semantic_cache_stats(current_user)
@app.get("/api/seo-dashboard/sif-health")
async def sif_indexing_health_endpoint(current_user: dict = Depends(get_current_user)):
"""
Get SIF indexing health summary for the current user.
Used by the Semantic Indexing Status widget on the dashboard.
"""
return await get_sif_indexing_health(current_user)
# Comprehensive SEO Analysis endpoints # Comprehensive SEO Analysis endpoints
@app.post("/api/seo-dashboard/analyze-comprehensive") @app.post("/api/seo-dashboard/analyze-comprehensive")
async def analyze_seo_comprehensive_endpoint(request: SEOAnalysisRequest): async def analyze_seo_comprehensive_endpoint(request: SEOAnalysisRequest):

View File

@@ -227,7 +227,10 @@ class ClerkAuthMiddleware:
'last_name': last_name, 'last_name': last_name,
'clerk_user_id': user_id 'clerk_user_id': user_id
} }
logger.error("Fallback decoding is disabled in production.") # In production mode, treat fallback as a soft failure:
# log at warning level (once per process) and let the caller
# handle this as an authentication failure without spamming logs.
logger.warning("Fallback decoding is disabled in production.")
return None return None
except Exception as e: except Exception as e:
@@ -247,21 +250,33 @@ async def get_current_user(
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Get current authenticated user.""" """Get current authenticated user."""
try: try:
# Safe header access
auth_header = None
user_agent = "unknown"
all_headers = {}
try:
if hasattr(request, 'headers'):
if hasattr(request.headers, 'get'):
auth_header = request.headers.get('authorization') or request.headers.get('Authorization')
user_agent = request.headers.get('user-agent', 'unknown')
if hasattr(request.headers, 'items'):
all_headers = {k: v[:50] if len(v) > 50 else v for k, v in request.headers.items()}
except:
pass
if not credentials: if not credentials:
# CRITICAL: Log as ERROR since this is a security issue - authenticated endpoint accessed without credentials # CRITICAL: Log as ERROR since this is a security issue - authenticated endpoint accessed without credentials
endpoint_path = f"{request.method} {request.url.path}" endpoint_path = f"{request.method} {request.url.path}"
# DEBUG: Log all headers to see what's actually being received
auth_header = request.headers.get('authorization') or request.headers.get('Authorization')
all_headers = {k: v[:50] if len(v) > 50 else v for k, v in request.headers.items()}
logger.error( logger.error(
f"🔒 AUTHENTICATION ERROR: No credentials provided for authenticated endpoint: {endpoint_path} " f"🔒 AUTHENTICATION ERROR: No credentials provided for authenticated endpoint: {endpoint_path} "
f"(client_ip={request.client.host if request.client else 'unknown'}, " f"(client_ip={request.client.host if request.client else 'unknown'}, "
f"auth_header_received={'YES' if auth_header else 'NO'}, " f"auth_header_received={'YES' if auth_header else 'NO'}, "
f"auth_header_value={auth_header[:50] + '...' if auth_header and len(auth_header) > 50 else (auth_header or 'None')}, " f"auth_header_value={auth_header[:50] + '...' if auth_header and len(auth_header) > 50 else (auth_header or 'None')}, "
f"all_headers={list(all_headers.keys())}, " f"all_headers={list(all_headers.keys())}, "
f"user_agent={request.headers.get('user-agent', 'unknown')})" f"user_agent={user_agent})"
) )
# Get caller information for better debugging # Get caller information for better debugging
@@ -328,11 +343,19 @@ async def get_current_user(
except Exception: except Exception:
pass pass
# Safe header access for logging
safe_user_agent = "unknown"
try:
if hasattr(request, 'headers') and hasattr(request.headers, 'get'):
safe_user_agent = request.headers.get('user-agent', 'unknown')
except:
pass
logger.error( logger.error(
f"🔒 AUTHENTICATION ERROR: Token verification failed for endpoint: {endpoint_path} " f"🔒 AUTHENTICATION ERROR: Token verification failed for endpoint: {endpoint_path} "
f"(client_ip={request.client.host if request.client else 'unknown'}, " f"(client_ip={request.client.host if request.client else 'unknown'}, "
f"caller={caller_info}, " f"caller={caller_info}, "
f"user_agent={request.headers.get('user-agent', 'unknown')})" f"user_agent={safe_user_agent})"
) )
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -369,7 +392,7 @@ async def get_current_user(
f"🔒 AUTHENTICATION ERROR: Unexpected error during authentication for endpoint: {endpoint_path}: {e} " f"🔒 AUTHENTICATION ERROR: Unexpected error during authentication for endpoint: {endpoint_path}: {e} "
f"(client_ip={request.client.host if request.client else 'unknown'}, " f"(client_ip={request.client.host if request.client else 'unknown'}, "
f"caller={caller_info}, " f"caller={caller_info}, "
f"user_agent={request.headers.get('user-agent', 'unknown')})", f"user_agent={user_agent})",
exc_info=True exc_info=True
) )
raise HTTPException( raise HTTPException(
@@ -420,7 +443,12 @@ async def get_current_user_with_query_token(
token_to_verify = credentials.credentials token_to_verify = credentials.credentials
else: else:
# Fall back to query parameter if no header # Fall back to query parameter if no header
query_token = request.query_params.get("token") query_token = None
try:
if hasattr(request, 'query_params') and hasattr(request.query_params, 'get'):
query_token = request.query_params.get("token")
except:
pass
if query_token: if query_token:
token_to_verify = query_token token_to_verify = query_token
@@ -428,6 +456,14 @@ async def get_current_user_with_query_token(
# CRITICAL: Log as ERROR since this is a security issue # CRITICAL: Log as ERROR since this is a security issue
endpoint_path = f"{request.method} {request.url.path}" endpoint_path = f"{request.method} {request.url.path}"
# Safe user agent access
user_agent = "unknown"
try:
if hasattr(request, 'headers') and hasattr(request.headers, 'get'):
user_agent = request.headers.get('user-agent', 'unknown')
except:
pass
# Get caller information # Get caller information
caller_frame = inspect.currentframe() caller_frame = inspect.currentframe()
caller_info = "unknown" caller_info = "unknown"
@@ -446,12 +482,20 @@ async def get_current_user_with_query_token(
except Exception: except Exception:
pass pass
# Safe header access for logging
safe_user_agent = "unknown"
try:
if hasattr(request, 'headers') and hasattr(request.headers, 'get'):
safe_user_agent = request.headers.get('user-agent', 'unknown')
except:
pass
logger.error( logger.error(
f"🔒 AUTHENTICATION ERROR: No credentials provided (neither header nor query parameter) " f"🔒 AUTHENTICATION ERROR: No credentials provided (neither header nor query parameter) "
f"for authenticated endpoint: {endpoint_path} " f"for authenticated endpoint: {endpoint_path} "
f"(client_ip={request.client.host if request.client else 'unknown'}, " f"(client_ip={request.client.host if request.client else 'unknown'}, "
f"caller={caller_info}, " f"caller={caller_info}, "
f"user_agent={request.headers.get('user-agent', 'unknown')})" f"user_agent={safe_user_agent})"
) )
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -482,11 +526,19 @@ async def get_current_user_with_query_token(
except Exception: except Exception:
pass pass
# Safe header access for logging
safe_user_agent = "unknown"
try:
if hasattr(request, 'headers') and hasattr(request.headers, 'get'):
safe_user_agent = request.headers.get('user-agent', 'unknown')
except:
pass
logger.error( logger.error(
f"🔒 AUTHENTICATION ERROR: Token verification failed for endpoint: {endpoint_path} " f"🔒 AUTHENTICATION ERROR: Token verification failed for endpoint: {endpoint_path} "
f"(client_ip={request.client.host if request.client else 'unknown'}, " f"(client_ip={request.client.host if request.client else 'unknown'}, "
f"caller={caller_info}, " f"caller={caller_info}, "
f"user_agent={request.headers.get('user-agent', 'unknown')})" f"user_agent={safe_user_agent})"
) )
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@@ -519,11 +571,19 @@ async def get_current_user_with_query_token(
except Exception: except Exception:
pass pass
# Safe header access for logging
safe_user_agent = "unknown"
try:
if hasattr(request, 'headers') and hasattr(request.headers, 'get'):
safe_user_agent = request.headers.get('user-agent', 'unknown')
except:
pass
logger.error( logger.error(
f"🔒 AUTHENTICATION ERROR: Unexpected error during authentication for endpoint: {endpoint_path}: {e} " f"🔒 AUTHENTICATION ERROR: Unexpected error during authentication for endpoint: {endpoint_path}: {e} "
f"(client_ip={request.client.host if request.client else 'unknown'}, " f"(client_ip={request.client.host if request.client else 'unknown'}, "
f"caller={caller_info}, " f"caller={caller_info}, "
f"user_agent={request.headers.get('user-agent', 'unknown')})", f"user_agent={safe_user_agent})",
exc_info=True exc_info=True
) )
raise HTTPException( raise HTTPException(

View File

@@ -0,0 +1,65 @@
"""
Podcast Bible Models
Pydantic models for the structured Podcast Bible, used for hyper-personalization.
"""
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
class HostPersona(BaseModel):
"""Details about the podcast host persona."""
name: str = Field(..., description="Name of the podcast host")
background: str = Field(..., description="Professional background and expertise")
expertise_level: str = Field(..., description="Level of expertise (e.g., Expert, Practitioner, Enthusiast)")
personality_traits: List[str] = Field(default_factory=list, description="Personality traits (e.g., Witty, Authoritative, Empathetic)")
vocal_style: str = Field(..., description="Description of the vocal style and delivery")
vocal_characteristics: List[str] = Field(default_factory=list, description="Specific vocal traits (e.g., Deep, Raspy, Energetic, Calm)")
look: Optional[str] = Field(None, description="Visual description of the host (for avatar generation)")
catchphrases: List[str] = Field(default_factory=list, description="Commonly used phrases or sign-offs")
class VisualStyle(BaseModel):
"""Visual aesthetic for the podcast videos and avatars."""
style_preset: str = Field(default="Professional Studio", description="Visual style (e.g., 3D Cartoon, Cinematic, Minimalist)")
environment: str = Field(..., description="The studio or setting where the podcast takes place")
lighting: str = Field(default="Soft Studio Lighting", description="Lighting mood and setup")
color_palette: List[str] = Field(default_factory=list, description="Primary brand colors for the visual elements")
camera_style: str = Field(default="Static Mid-shot", description="Preferred camera framing and movement")
class AudioEnvironment(BaseModel):
"""The soundscape and audio characteristics of the podcast."""
soundscape: str = Field(default="Quiet Studio", description="Acoustics and ambient noise level")
music_mood: str = Field(default="Professional & Subtle", description="Genre and mood of background music")
sfx_style: str = Field(default="Minimalist", description="Style of sound effects used (e.g., tech-inspired, natural)")
class ShowRules(BaseModel):
"""Consistency rules for the podcast narrative and structure."""
intro_format: str = Field(..., description="Standard way to start the episode")
outro_format: str = Field(..., description="Standard way to end the episode")
interaction_tone: str = Field(default="Conversational", description="Tone between hosts or with audience")
constraints: List[str] = Field(default_factory=list, description="Specific things to always do or avoid")
class AudienceDNA(BaseModel):
"""Details about the target audience."""
expertise_level: str = Field(..., description="Target audience expertise level (Beginner, Intermediate, Expert)")
interests: List[str] = Field(default_factory=list, description="Primary interests of the audience")
pain_points: List[str] = Field(default_factory=list, description="Common challenges or problems the audience faces")
demographics: Optional[str] = Field(None, description="General demographic information")
class BrandDNA(BaseModel):
"""Details about the brand and industry context."""
industry: str = Field(..., description="Primary industry or niche")
tone: str = Field(..., description="Overall brand tone (e.g., Professional, Casual, Inspirational)")
communication_style: str = Field(..., description="Preferred communication style (e.g., Socratic, Storytelling, Analytical)")
key_messages: List[str] = Field(default_factory=list, description="Core messages the brand wants to convey")
competitor_context: Optional[str] = Field(None, description="Context on how to differentiate from competitors")
class PodcastBible(BaseModel):
"""The complete structured Podcast Bible SSOT."""
project_id: Optional[str] = Field(default=None, description="Associated project ID")
host: HostPersona = Field(..., description="Host persona details")
audience: AudienceDNA = Field(..., description="Target audience details")
brand: BrandDNA = Field(..., description="Brand and industry context")
visual_style: VisualStyle = Field(..., description="Visual aesthetic and environment")
audio_environment: AudioEnvironment = Field(..., description="Soundscape and music details")
show_rules: ShowRules = Field(..., description="Consistency and structural rules")

View File

@@ -33,6 +33,7 @@ class PodcastProject(Base):
# Project state (stored as JSON) # Project state (stored as JSON)
# This mirrors the PodcastProjectState interface from frontend # This mirrors the PodcastProjectState interface from frontend
bible = Column(JSON, nullable=True) # PodcastBible structured data
analysis = Column(JSON, nullable=True) # PodcastAnalysis analysis = Column(JSON, nullable=True) # PodcastAnalysis
queries = Column(JSON, nullable=True) # List[Query] queries = Column(JSON, nullable=True) # List[Query]
selected_queries = Column(JSON, nullable=True) # Array of query IDs selected_queries = Column(JSON, nullable=True) # Array of query IDs
@@ -56,6 +57,11 @@ class PodcastProject(Base):
# Final combined video URL (persisted for reloads) # Final combined video URL (persisted for reloads)
final_video_url = Column(String(1000), nullable=True) # URL to final combined podcast video final_video_url = Column(String(1000), nullable=True) # URL to final combined podcast video
# Avatar details
avatar_url = Column(String(1000), nullable=True)
avatar_prompt = Column(Text, nullable=True)
avatar_persona_id = Column(String(255), nullable=True)
# Timestamps # Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, index=True) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, index=True)

View File

@@ -40,11 +40,53 @@ class StoryGenerationRequest(BaseModel):
audio_lang: str = Field(default="en", description="Language code for TTS") audio_lang: str = Field(default="en", description="Language code for TTS")
audio_slow: bool = Field(default=False, description="Whether to speak slowly (gTTS only)") audio_slow: bool = Field(default=False, description="Whether to speak slowly (gTTS only)")
audio_rate: int = Field(default=150, description="Speech rate (pyttsx3 only)") audio_rate: int = Field(default=150, description="Speech rate (pyttsx3 only)")
anime_bible: Optional[Dict[str, Any]] = Field(
default=None,
description="Optional structured anime story bible for anime fiction templates",
)
class StorySetupGenerationRequest(BaseModel): class StorySetupGenerationRequest(BaseModel):
"""Request model for AI story setup generation.""" """Request model for AI story setup generation."""
story_idea: str = Field(..., description="Basic story idea or information from the user") story_idea: str = Field(..., description="Basic story idea or information from the user")
story_mode: Optional[str] = Field(
default=None,
description="Story mode (marketing or pure) if provided by the UI",
)
story_template: Optional[str] = Field(
default=None,
description="Optional story template identifier (e.g. product_story, brand_manifesto)",
)
brand_context: Optional[Dict[str, Any]] = Field(
default=None,
description="Optional high-signal brand context derived from onboarding",
)
class StoryIdeaEnhanceRequest(BaseModel):
"""Request model for AI story idea enhancement."""
story_idea: str = Field(..., description="Original story idea or concept text from the user")
story_mode: Optional[str] = Field(
default=None,
description="Story mode (marketing or pure) if provided by the UI",
)
story_template: Optional[str] = Field(
default=None,
description="Optional story template identifier (e.g. product_story, brand_manifesto)",
)
brand_context: Optional[Dict[str, Any]] = Field(
default=None,
description="Optional high-signal brand context derived from onboarding",
)
fiction_variant: Optional[str] = Field(
default=None,
description="Optional fiction-specific focus label (e.g. high-concept twist, shonen action)",
)
narrative_energy: Optional[str] = Field(
default=None,
description="Optional narrative energy or pacing hint (e.g. grounded, balanced, cinematic)",
)
class StorySetupOption(BaseModel): class StorySetupOption(BaseModel):
@@ -78,6 +120,43 @@ class StorySetupOption(BaseModel):
audio_lang: str = Field(default="en", description="Language code for TTS") audio_lang: str = Field(default="en", description="Language code for TTS")
audio_slow: bool = Field(default=False, description="Whether to speak slowly (gTTS only)") audio_slow: bool = Field(default=False, description="Whether to speak slowly (gTTS only)")
audio_rate: int = Field(default=150, description="Speech rate (pyttsx3 only)") audio_rate: int = Field(default=150, description="Speech rate (pyttsx3 only)")
anime_bible: Optional["AnimeStoryBible"] = Field(
default=None,
description="Optional structured anime story bible for anime fiction templates",
)
class AnimeCharacter(BaseModel):
id: str = Field(..., description="Stable identifier for the character (snake_case)")
name: str = Field(..., description="Character name")
age_range: str = Field(..., description="Approximate age range (e.g., 'late teens', '30s')")
role: str = Field(..., description="Narrative role (protagonist, antagonist, mentor, etc.)")
look: str = Field(..., description="Key visual details (hair, build, notable traits)")
outfit_palette: str = Field(..., description="Main outfit colors and style")
personality_tags: List[str] = Field(default_factory=list, description="Short tags describing personality")
class AnimeWorld(BaseModel):
setting: str = Field(..., description="World description and primary locations")
era: str = Field(..., description="Time period (near-future, far future, alt 1990s, etc.)")
tech_or_magic_level: str = Field(..., description="Technology or magic sophistication level")
core_rules: List[str] = Field(default_factory=list, description="Key world rules and constraints")
class AnimeVisualStyle(BaseModel):
style_preset: str = Field(..., description="High level style preset (anime_manga, cinematic_anime, cozy_slice_of_life)")
camera_style: str = Field(..., description="Typical camera behaviour and framing")
color_mood: str = Field(..., description="Dominant color palette and contrast")
lighting: str = Field(..., description="Lighting style")
line_style: str = Field(..., description="Line art style (thick, thin, rough, etc.)")
extra_tags: List[str] = Field(default_factory=list, description="Additional style tags")
class AnimeStoryBible(BaseModel):
story_id: Optional[str] = Field(default=None, description="Optional story identifier")
main_cast: List[AnimeCharacter] = Field(default_factory=list, description="Main cast of characters")
world: AnimeWorld = Field(..., description="World and rules description")
visual_style: AnimeVisualStyle = Field(..., description="Visual style anchors for images and video")
class StorySetupGenerationResponse(BaseModel): class StorySetupGenerationResponse(BaseModel):
@@ -86,8 +165,28 @@ class StorySetupGenerationResponse(BaseModel):
success: bool = Field(default=True, description="Whether the generation was successful") success: bool = Field(default=True, description="Whether the generation was successful")
class StoryIdeaEnhanceSuggestion(BaseModel):
"""A single enhanced story idea suggestion."""
idea: str = Field(..., description="AI-enhanced story idea text")
whats_missing: str = Field(
...,
description="Concise explanation of missing or underspecified plot/context elements",
)
why_choose: str = Field(
...,
description="Why this idea is a strong direction based on the original input",
)
class StoryIdeaEnhanceResponse(BaseModel):
"""Response model for story idea enhancement."""
suggestions: List[StoryIdeaEnhanceSuggestion] = Field(
..., description="List of enhanced story idea suggestions"
)
success: bool = Field(default=True, description="Whether the enhancement was successful")
class StoryScene(BaseModel): class StoryScene(BaseModel):
"""Model for a story scene."""
scene_number: int = Field(..., description="Scene number") scene_number: int = Field(..., description="Scene number")
title: str = Field(..., description="Scene title") title: str = Field(..., description="Scene title")
description: str = Field(..., description="Scene description") description: str = Field(..., description="Scene description")
@@ -97,6 +196,58 @@ class StoryScene(BaseModel):
key_events: List[str] = Field(default_factory=list, description="Key events in the scene") key_events: List[str] = Field(default_factory=list, description="Key events in the scene")
class AnimeSceneTextRequest(BaseModel):
scene: StoryScene = Field(..., description="Scene to refine using the anime bible")
persona: str = Field(..., description="Persona context for the scene")
story_setting: str = Field(..., description="Story setting")
character_input: str = Field(..., description="Characters description from story setup")
plot_elements: str = Field(..., description="Plot elements from story setup")
writing_style: str = Field(..., description="Writing style")
story_tone: str = Field(..., description="Story tone")
narrative_pov: str = Field(..., description="Narrative point of view")
audience_age_group: str = Field(..., description="Audience age group")
content_rating: str = Field(..., description="Content rating")
anime_bible: Optional[Dict[str, Any]] = Field(
default=None,
description="Optional anime story bible used to refine the scene",
)
class AnimeSceneTextResponse(BaseModel):
scene: StoryScene = Field(..., description="Refined scene with bible-aware text and prompts")
success: bool = Field(default=True, description="Whether the refinement was successful")
class AnimeSceneGenerateRequest(BaseModel):
premise: str = Field(..., description="Overall story premise for context")
persona: str = Field(..., description="Persona context for the scene")
story_setting: str = Field(..., description="Story setting")
character_input: str = Field(..., description="Characters description from story setup")
plot_elements: str = Field(..., description="Plot elements from story setup")
writing_style: str = Field(..., description="Writing style")
story_tone: str = Field(..., description="Story tone")
narrative_pov: str = Field(..., description="Narrative point of view")
audience_age_group: str = Field(..., description="Audience age group")
content_rating: str = Field(..., description="Content rating")
anime_bible: Dict[str, Any] = Field(
...,
description="Anime story bible used as a hard constraint for generation",
)
previous_scenes: Optional[List[StoryScene]] = Field(
default=None,
description="Optional list of previous scenes for continuity context",
)
target_scene_number: Optional[int] = Field(
default=None,
description="Optional target scene number for the new scene",
)
class AnimeSceneGenerateResponse(BaseModel):
scene: StoryScene = Field(..., description="Newly generated anime scene based on the bible")
success: bool = Field(default=True, description="Whether the scene generation was successful")
class StoryStartRequest(StoryGenerationRequest): class StoryStartRequest(StoryGenerationRequest):
"""Request model for story start generation.""" """Request model for story start generation."""
premise: str = Field(..., description="The story premise") premise: str = Field(..., description="The story premise")
@@ -116,6 +267,10 @@ class StoryOutlineResponse(BaseModel):
success: bool = Field(default=True, description="Whether the generation was successful") success: bool = Field(default=True, description="Whether the generation was successful")
task_id: Optional[str] = Field(None, description="Task ID for async operations") task_id: Optional[str] = Field(None, description="Task ID for async operations")
is_structured: bool = Field(default=False, description="Whether the outline is structured (scenes) or plain text") is_structured: bool = Field(default=False, description="Whether the outline is structured (scenes) or plain text")
anime_bible: Optional[AnimeStoryBible] = Field(
default=None,
description="Optional structured anime story bible generated from final story setup",
)
class StoryContentResponse(BaseModel): class StoryContentResponse(BaseModel):
@@ -156,6 +311,10 @@ class StoryContinueRequest(BaseModel):
content_rating: str = Field(..., description="The content rating") content_rating: str = Field(..., description="The content rating")
ending_preference: str = Field(..., description="The preferred ending") ending_preference: str = Field(..., description="The preferred ending")
story_length: str = Field(default="Medium", description="Target story length (Short: >1000 words, Medium: >5000 words, Long: >10000 words)") story_length: str = Field(default="Medium", description="Target story length (Short: >1000 words, Medium: >5000 words, Long: >10000 words)")
anime_bible: Optional[Dict[str, Any]] = Field(
default=None,
description="Optional structured anime story bible for anime fiction templates",
)
class StoryContinueResponse(BaseModel): class StoryContinueResponse(BaseModel):

View File

@@ -0,0 +1,55 @@
"""
Story Project Models
Database models for Story Studio project persistence and state management.
Modeled after PodcastProject and ResearchProject for cross-device resume.
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, Boolean, JSON, Index
from models.subscription_models import Base
class StoryProject(Base):
"""
Database model for Story Studio project state.
Stores complete story project state to enable cross-device resume.
"""
__tablename__ = "story_projects"
# Primary fields
id = Column(Integer, primary_key=True, autoincrement=True)
project_id = Column(String(255), unique=True, nullable=False, index=True)
user_id = Column(String(255), nullable=False, index=True)
# Project metadata
title = Column(String(500), nullable=True)
story_mode = Column(String(50), nullable=True)
story_template = Column(String(100), nullable=True)
# Story state (stored as JSON)
setup = Column(JSON, nullable=True)
outline = Column(JSON, nullable=True)
scenes = Column(JSON, nullable=True)
story_content = Column(JSON, nullable=True)
anime_bible = Column(JSON, nullable=True)
media_state = Column(JSON, nullable=True)
# UI/progress state
current_phase = Column(String(50), nullable=True)
status = Column(String(50), default="draft", nullable=False, index=True)
is_favorite = Column(Boolean, default=False, index=True)
is_complete = Column(Boolean, default=False)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, index=True)
__table_args__ = (
Index("idx_story_user_status_created", "user_id", "status", "created_at"),
Index("idx_story_user_favorite_updated", "user_id", "is_favorite", "updated_at"),
)

View File

@@ -26,6 +26,8 @@ class UsageStatus(enum.Enum):
WARNING = "warning" # 80% usage WARNING = "warning" # 80% usage
LIMIT_REACHED = "limit_reached" # 100% usage LIMIT_REACHED = "limit_reached" # 100% usage
SUSPENDED = "suspended" SUSPENDED = "suspended"
CANCELLED = "cancelled"
PAST_DUE = "past_due"
class APIProvider(enum.Enum): class APIProvider(enum.Enum):
GEMINI = "gemini" GEMINI = "gemini"
@@ -389,4 +391,20 @@ class SubscriptionRenewalHistory(Base):
# Indexes for performance # Indexes for performance
__table_args__ = ( __table_args__ = (
{'mysql_engine': 'InnoDB'}, {'mysql_engine': 'InnoDB'},
) )
class FraudWarning(Base):
__tablename__ = "fraud_warnings"
id = Column(String(100), primary_key=True)
charge_id = Column(String(100), nullable=False)
payment_intent_id = Column(String(100), nullable=True)
user_id = Column(String(100), nullable=True)
amount = Column(Integer, nullable=False, default=0)
currency = Column(String(10), nullable=False, default="")
status = Column(String(20), nullable=False, default="open")
action = Column(String(20), nullable=False, default="none")
action_at = Column(DateTime, nullable=True)
reason_notes = Column(Text, nullable=True)
meta_info = Column(JSON, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)

View File

@@ -14,6 +14,9 @@ fastapi-clerk-auth>=0.0.7
# Database dependencies # Database dependencies
sqlalchemy>=2.0.25 sqlalchemy>=2.0.25
# Payment processing
stripe>=8.0.0
# CopilotKit and Research # CopilotKit and Research
copilotkit copilotkit
exa-py==1.9.1 exa-py==1.9.1

View File

@@ -11,7 +11,7 @@ from loguru import logger
from services.integrations.bing_oauth import BingOAuthService from services.integrations.bing_oauth import BingOAuthService
from middleware.auth_middleware import get_current_user from middleware.auth_middleware import get_current_user
router = APIRouter(prefix="/bing", tags=["Bing Analytics"]) router = APIRouter(prefix="/api/bing", tags=["Bing Analytics"])
# Initialize Bing OAuth service # Initialize Bing OAuth service
bing_service = BingOAuthService() bing_service = BingOAuthService()
@@ -26,7 +26,7 @@ async def get_query_stats(
): ):
"""Get search query statistics for a Bing Webmaster site.""" """Get search query statistics for a Bing Webmaster site."""
try: try:
user_id = current_user.get("user_id") user_id = current_user.get("id") or current_user.get("user_id")
if not user_id: if not user_id:
raise HTTPException(status_code=401, detail="User not authenticated") raise HTTPException(status_code=401, detail="User not authenticated")
@@ -67,7 +67,7 @@ async def get_user_sites(
): ):
"""Get list of user's verified sites from Bing Webmaster.""" """Get list of user's verified sites from Bing Webmaster."""
try: try:
user_id = current_user.get("user_id") user_id = current_user.get("id") or current_user.get("user_id")
if not user_id: if not user_id:
raise HTTPException(status_code=401, detail="User not authenticated") raise HTTPException(status_code=401, detail="User not authenticated")
@@ -98,7 +98,7 @@ async def get_query_stats_summary(
): ):
"""Get summarized query statistics for a Bing Webmaster site.""" """Get summarized query statistics for a Bing Webmaster site."""
try: try:
user_id = current_user.get("user_id") user_id = current_user.get("id") or current_user.get("user_id")
if not user_id: if not user_id:
raise HTTPException(status_code=401, detail="User not authenticated") raise HTTPException(status_code=401, detail="User not authenticated")

View File

@@ -6,17 +6,46 @@ Provides endpoints for retrieving analytics data from connected platforms.
from fastapi import APIRouter, HTTPException, Depends, Query from fastapi import APIRouter, HTTPException, Depends, Query
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from datetime import datetime, timedelta
from loguru import logger from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel
from services.analytics import PlatformAnalyticsService from services.analytics import PlatformAnalyticsService
from middleware.auth_middleware import get_current_user from middleware.auth_middleware import get_current_user
from services.llm_providers.main_text_generation import llm_text_gen
router = APIRouter(prefix="/api/analytics", tags=["Platform Analytics"]) router = APIRouter(prefix="/api/analytics", tags=["Platform Analytics"])
# Initialize analytics service # Initialize analytics service
analytics_service = PlatformAnalyticsService() analytics_service = PlatformAnalyticsService()
@router.post("/cache/clear")
async def clear_analytics_cache(
platform: Optional[str] = Query(None, description="Specific platform to clear (e.g., 'bing', 'gsc')"),
current_user: dict = Depends(get_current_user)
) -> Dict[str, Any]:
"""
Clear analytics cache for the current user.
If 'platform' is provided, clears only that platform's cache; otherwise clears all and connection status.
"""
try:
user_id = current_user.get('id')
if not user_id:
raise HTTPException(status_code=400, detail="User ID not found")
if platform:
analytics_service.invalidate_platform_cache(user_id, platform)
else:
analytics_service.invalidate_platform_cache(user_id)
# Always refresh connection status cache as well
analytics_service.invalidate_connection_cache(user_id)
return { "success": True, "message": "Analytics cache cleared", "platform": platform or "all" }
except Exception as e:
logger.error(f"Failed to clear analytics cache: {e}")
return { "success": False, "error": str(e) }
class AnalyticsRequest(BaseModel): class AnalyticsRequest(BaseModel):
"""Request model for analytics data""" """Request model for analytics data"""
@@ -65,7 +94,9 @@ async def get_platform_connection_status(current_user: dict = Depends(get_curren
@router.get("/data") @router.get("/data")
async def get_analytics_data( async def get_analytics_data(
platforms: Optional[str] = Query(None, description="Comma-separated list of platforms (gsc,wix,wordpress)"), platforms: Optional[str] = Query(None, description="Comma-separated list of platforms (gsc,bing,wix,wordpress)"),
start_date: Optional[str] = Query(None, description="Start date (YYYY-MM-DD)"),
end_date: Optional[str] = Query(None, description="End date (YYYY-MM-DD)"),
current_user: dict = Depends(get_current_user) current_user: dict = Depends(get_current_user)
) -> AnalyticsResponse: ) -> AnalyticsResponse:
""" """
@@ -88,15 +119,31 @@ async def get_analytics_data(
if platforms: if platforms:
platform_list = [p.strip() for p in platforms.split(',') if p.strip()] platform_list = [p.strip() for p in platforms.split(',') if p.strip()]
logger.info(f"Getting analytics data for user: {user_id}, platforms: {platform_list}") logger.info(f"Getting analytics data for user: {user_id}, platforms: {platform_list}, start_date: {start_date}, end_date: {end_date}")
# Get analytics data analytics_data = await analytics_service.get_comprehensive_analytics(user_id, platform_list, start_date=start_date, end_date=end_date)
analytics_data = await analytics_service.get_comprehensive_analytics(user_id, platform_list)
# Generate summary
summary = analytics_service.get_analytics_summary(analytics_data) summary = analytics_service.get_analytics_summary(analytics_data)
# Convert AnalyticsData objects to dictionaries logger.warning(
"Analytics summary for user {user}: total_clicks={clicks}, total_impressions={impr}, overall_ctr={ctr}, platforms={platforms}",
user=user_id,
clicks=summary.get("total_clicks"),
impr=summary.get("total_impressions"),
ctr=summary.get("overall_ctr"),
platforms=list(analytics_data.keys()),
)
for platform_name, data in analytics_data.items():
try:
logger.warning(
"Analytics platform snapshot {platform}: status={status}, total_clicks={clicks}, total_impressions={impr}",
platform=platform_name,
status=data.status,
clicks=data.get_total_clicks(),
impr=data.get_total_impressions(),
)
except Exception as log_err:
logger.warning(f"Failed to log platform snapshot for {platform_name}: {log_err}")
data_dict = {} data_dict = {}
for platform, data in analytics_data.items(): for platform, data in analytics_data.items():
data_dict[platform] = { data_dict[platform] = {
@@ -148,7 +195,14 @@ async def get_analytics_data_post(
logger.info(f"Getting analytics data for user: {user_id}, platforms: {request.platforms}") logger.info(f"Getting analytics data for user: {user_id}, platforms: {request.platforms}")
# Get analytics data # Get analytics data
analytics_data = await analytics_service.get_comprehensive_analytics(user_id, request.platforms) # Extract optional dates
start_date = None
end_date = None
if request.date_range and isinstance(request.date_range, dict):
start_date = request.date_range.get('start')
end_date = request.date_range.get('end')
analytics_data = await analytics_service.get_comprehensive_analytics(user_id, request.platforms, start_date=start_date, end_date=end_date)
# Generate summary # Generate summary
summary = analytics_service.get_analytics_summary(analytics_data) summary = analytics_service.get_analytics_summary(analytics_data)
@@ -250,12 +304,196 @@ async def get_analytics_summary(current_user: dict = Depends(get_current_user))
"platforms_connected": summary['connected_platforms'], "platforms_connected": summary['connected_platforms'],
"platforms_total": summary['total_platforms'] "platforms_total": summary['total_platforms']
} }
except Exception as e: except Exception as e:
logger.error(f"Failed to get analytics summary: {e}") logger.error(f"Failed to get analytics summary: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/ai-insights")
async def get_ai_insights(
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
current_user: dict = Depends(get_current_user)
) -> Dict[str, Any]:
try:
user_id = current_user.get('id')
if not user_id:
raise HTTPException(status_code=400, detail="User ID not found")
sd = start_date
ed = end_date
if not sd or not ed:
today = datetime.utcnow().date()
ed = today.isoformat()
sd = (today - timedelta(days=29)).isoformat()
analytics = await analytics_service.get_comprehensive_analytics(user_id, ['gsc'], start_date=sd, end_date=ed)
gsc = analytics.get('gsc')
if not gsc or gsc.status != 'success':
return {"success": False, "error": gsc.error_message if gsc else "GSC data unavailable"}
metrics = gsc.metrics or {}
tq = metrics.get('top_queries') or []
tp = metrics.get('top_pages') or []
cannib = metrics.get('cannibalization') or []
sdt = datetime.strptime(sd, "%Y-%m-%d").date()
edt = datetime.strptime(ed, "%Y-%m-%d").date()
window_days = max((edt - sdt).days + 1, 1)
def thr_impr():
if window_days <= 7:
return 100
if window_days <= 30:
return 500
return 1500
def thr_clicks():
if window_days <= 7:
return 10
if window_days <= 30:
return 30
return 60
low_ctr_queries = []
for r in tq:
imp = float(r.get('impressions', 0) or 0)
ctr = float(r.get('ctr', 0) or 0)
if imp >= thr_impr() and ctr <= 2.5:
low_ctr_queries.append({
"query": r.get('query'),
"impressions": int(round(imp)),
"ctr": round(ctr, 2),
"clicks": int(round(float(r.get('clicks', 0) or 0))),
"position": round(float(r.get('position', 0) or 0), 2) if 'position' in r else None
})
striking_distance = []
for r in tq:
pos = float(r.get('position', 0) or 0)
imp = float(r.get('impressions', 0) or 0)
if 8.0 <= pos <= 20.0 and imp >= (80 if window_days <= 7 else (300 if window_days <= 30 else 1000)):
striking_distance.append({
"query": r.get('query'),
"impressions": int(round(imp)),
"position": round(pos, 2),
"clicks": int(round(float(r.get('clicks', 0) or 0)))
})
low_ctr_pages = []
for p in tp:
imp = float(p.get('impressions', 0) or 0)
ctr = float(p.get('ctr', 0) or 0)
if imp >= thr_impr() and ctr <= 2.0:
low_ctr_pages.append({
"page": p.get('page'),
"impressions": int(round(imp)),
"ctr": round(ctr, 2),
"clicks": int(round(float(p.get('clicks', 0) or 0)))
})
serp_feature_loss = []
for r in tq:
pos = float(r.get('position', 0) or 0)
imp = float(r.get('impressions', 0) or 0)
ctr = float(r.get('ctr', 0) or 0)
if pos > 0 and pos <= 5.0 and imp >= thr_impr() and ctr <= 2.0:
serp_feature_loss.append({
"query": r.get('query'),
"impressions": int(round(imp)),
"position": round(pos, 2),
"ctr": round(ctr, 2),
"clicks": int(round(float(r.get('clicks', 0) or 0)))
})
def build_map(rows):
m = {}
for r in rows:
k = r.get('query')
if not k:
continue
m[k] = {
"clicks": float(r.get('clicks', 0) or 0),
"impressions": float(r.get('impressions', 0) or 0)
}
return m
prev_end = (sdt - timedelta(days=1)).isoformat()
prev_start = (sdt - timedelta(days=window_days)).isoformat()
prev_analytics = await analytics_service.get_comprehensive_analytics(user_id, ['gsc'], start_date=prev_start, end_date=prev_end)
prev_gsc = prev_analytics.get('gsc')
prev_tq = prev_gsc.metrics.get('top_queries') if prev_gsc and prev_gsc.metrics else []
curr_map = build_map(tq)
prev_map = build_map(prev_tq)
declining_queries = []
for q, v in curr_map.items():
pv = prev_map.get(q) or {"clicks": 0.0, "impressions": 0.0}
dc = int(round(v["clicks"] - pv["clicks"]))
di = int(round(v["impressions"] - pv["impressions"]))
if dc < 0 or di < 0:
if abs(dc) >= 5 or abs(di) >= thr_impr() * 0.2:
declining_queries.append({
"query": q,
"delta_clicks": dc,
"delta_impressions": di,
"prev_clicks": int(round(pv["clicks"])),
"prev_impressions": int(round(pv["impressions"]))
})
low_ctr_queries = sorted(low_ctr_queries, key=lambda x: (-x["impressions"], x["ctr"]))[:10]
striking_distance = sorted(striking_distance, key=lambda x: -x["impressions"])[:10]
low_ctr_pages = sorted(low_ctr_pages, key=lambda x: (-x["impressions"], x["ctr"]))[:10]
cannib_list = cannib[:10]
serp_feature_loss = sorted(serp_feature_loss, key=lambda x: -x["impressions"])[:10]
payload = {
"context": {
"site_url": None,
"date_range": {"start": sd, "end": ed},
"window_days": window_days
},
"signals": {
"low_ctr_queries": low_ctr_queries,
"striking_distance": striking_distance,
"declining_queries": declining_queries[:10],
"low_ctr_pages": low_ctr_pages,
"cannibalization": cannib_list,
"serp_feature_loss": serp_feature_loss
},
"limits": {
"max_items_per_signal": 10,
"language": "en",
"tone": "simple"
}
}
schema = {
"type": "object",
"properties": {
"quick_summary": {"type": "string"},
"prioritized_findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"title": {"type": "string"},
"severity": {"type": "string"},
"audience_note": {"type": "string"},
"evidence": {"type": "string"},
"why_it_matters": {"type": "string"},
"actions": {"type": "array", "items": {"type": "string"}},
"effort": {"type": "string"}
}
}
},
"playbooks": {
"type": "object",
"properties": {
"title_meta_fixes": {"type": "array", "items": {"type": "object"}},
"consolidation": {"type": "array", "items": {"type": "object"}},
"refreshes": {"type": "array", "items": {"type": "object"}},
"internal_linking": {"type": "array", "items": {"type": "object"}}
}
},
"metrics": {"type": "object"}
}
}
system_prompt = "You are an SEO assistant for non-technical creators. Use simple language and concrete actions. Only use provided numbers. Return a single JSON object matching the schema."
prompt = "Analyze the following GSC-derived signals and produce prioritized findings and playbooks.\n\n" + str(payload)
ai = llm_text_gen(prompt=prompt, json_struct=schema, system_prompt=system_prompt, user_id=user_id)
return {"success": True, "insights": ai}
except HTTPException:
raise
except Exception as e:
logger.error(f"AI insights failed: {e}")
return {"success": False, "error": str(e)}
@router.get("/cache/test") @router.get("/cache/test")
async def test_cache_endpoint(current_user: dict = Depends(get_current_user)) -> Dict[str, Any]: async def test_cache_endpoint(current_user: dict = Depends(get_current_user)) -> Dict[str, Any]:
""" """

View File

@@ -0,0 +1,141 @@
"""
Database Migration Script for Story Studio
Creates the story_projects table for cross-device story project persistence.
"""
import sys
from pathlib import Path
from loguru import logger
from sqlalchemy import create_engine, text
import traceback
# Add the backend directory to Python path
backend_dir = Path(__file__).parent.parent
sys.path.insert(0, str(backend_dir))
from models.subscription_models import Base as SubscriptionBase
from models.story_project_models import StoryProject # noqa: F401
from services.database import DATABASE_URL
def create_story_tables() -> None:
"""Create story-related project tables."""
try:
engine = create_engine(DATABASE_URL, echo=False)
logger.info("Creating Story Studio project tables...")
SubscriptionBase.metadata.create_all(bind=engine)
logger.info("✅ Story project tables created successfully")
display_setup_summary(engine)
except Exception as e:
logger.error(f"❌ Error creating story project tables: {e}")
logger.error(traceback.format_exc())
raise
def display_setup_summary(engine) -> None:
"""Display a summary of the created tables."""
try:
with engine.connect() as conn:
logger.info("\n" + "=" * 60)
logger.info("STORY STUDIO PROJECT SETUP SUMMARY")
logger.info("=" * 60)
check_query = text(
"""
SELECT name FROM sqlite_master
WHERE type='table' AND name='story_projects'
"""
)
result = conn.execute(check_query)
table_exists = result.fetchone()
if table_exists:
logger.info("✅ Table 'story_projects' created successfully")
schema_query = text(
"""
SELECT sql FROM sqlite_master
WHERE type='table' AND name='story_projects'
"""
)
result = conn.execute(schema_query)
schema = result.fetchone()
if schema:
logger.info("\n📋 Table Schema:")
logger.info(schema[0])
indexes_query = text(
"""
SELECT name FROM sqlite_master
WHERE type='index' AND tbl_name='story_projects'
"""
)
result = conn.execute(indexes_query)
indexes = result.fetchall()
if indexes:
logger.info(f"\n📊 Indexes ({len(indexes)}):")
for idx in indexes:
logger.info(f"{idx[0]}")
else:
logger.warning("⚠️ Table 'story_projects' not found after creation")
logger.info("\n" + "=" * 60)
logger.info("NEXT STEPS:")
logger.info("=" * 60)
logger.info("1. The story_projects table is ready for use")
logger.info("2. Story Studio projects will sync to database via new endpoints")
logger.info("3. Users will be able to resume Story Studio sessions across devices")
logger.info("=" * 60)
except Exception as e:
logger.error(f"Error displaying Story Studio setup summary: {e}")
def check_existing_table(engine) -> bool:
"""Check if story_projects table already exists."""
try:
with engine.connect() as conn:
check_query = text(
"""
SELECT name FROM sqlite_master
WHERE type='table' AND name='story_projects'
"""
)
result = conn.execute(check_query)
table_exists = result.fetchone()
if table_exists:
logger.info(" Table 'story_projects' already exists")
logger.info(" Running migration will ensure schema is up to date...")
return True
return False
except Exception as e:
logger.error(f"Error checking existing Story Studio table: {e}")
return False
if __name__ == "__main__":
logger.info("🚀 Starting Story Studio database migration...")
try:
engine = create_engine(DATABASE_URL, echo=False)
check_existing_table(engine)
create_story_tables()
logger.info("✅ Story Studio migration completed successfully!")
except KeyboardInterrupt:
logger.info("Migration cancelled by user")
sys.exit(0)
except Exception as e:
logger.error(f"❌ Story Studio migration failed: {e}")
traceback.print_exc()
sys.exit(1)

View File

@@ -121,7 +121,8 @@ class BaseALwrityAgent(ABC):
if TXTAI_AVAILABLE: if TXTAI_AVAILABLE:
try: try:
if not self.llm: if not self.llm:
self.llm = LLM(model_name) # Hardening: Explicitly set task to avoid 'text2text-generation' default failures
self.llm = LLM(model_name, task="text-generation")
self.txtai_agent = self._create_txtai_agent() self.txtai_agent = self._create_txtai_agent()
logger.info(f"Initialized txtai agent for {agent_type} - {self.agent_id}") logger.info(f"Initialized txtai agent for {agent_type} - {self.agent_id}")

View File

@@ -4,7 +4,6 @@ Bing Webmaster Tools Analytics Handler
Handles Bing Webmaster Tools analytics data retrieval and processing. Handles Bing Webmaster Tools analytics data retrieval and processing.
""" """
import requests
from typing import Dict, Any from typing import Dict, Any
from datetime import datetime, timedelta from datetime import datetime, timedelta
from loguru import logger from loguru import logger
@@ -16,13 +15,23 @@ from ..models.platform_types import PlatformType
from .base_handler import BaseAnalyticsHandler from .base_handler import BaseAnalyticsHandler
from ..insights.bing_insights_service import BingInsightsService from ..insights.bing_insights_service import BingInsightsService
from services.bing_analytics_storage_service import BingAnalyticsStorageService from services.bing_analytics_storage_service import BingAnalyticsStorageService
import os
from services.database import get_user_db_path from services.database import get_user_db_path
class BingAnalyticsHandler(BaseAnalyticsHandler): class BingAnalyticsHandler(BaseAnalyticsHandler):
"""Handler for Bing Webmaster Tools analytics""" """
Handler for Bing Webmaster Tools analytics
NOTE (2026-02-14): Known issues and directions
- Verified sites list can be empty despite valid tokens. This leads to partial/error states and prevents storage collection.
Direction: UI now provides a manual site picker (with primary website fallback from onboarding) to trigger storage collection,
and a future improvement should accept a target_url from /api/analytics/data to influence site selection here.
- Token state mismatch (status shows connected, analytics reports expired) can happen across cache boundaries.
Direction: The frontend auto-resyncs once after OAuth success and provides a backend cache clear endpoint.
- Storage-backed summary reads rely on a selected site; when sites are missing, selected_site is None.
Direction: Allow explicit site_url parameter in the analytics orchestrator to override selected_site resolution.
"""
def __init__(self): def __init__(self):
super().__init__(PlatformType.BING) super().__init__(PlatformType.BING)
@@ -42,14 +51,22 @@ class BingAnalyticsHandler(BaseAnalyticsHandler):
db_url = f'sqlite:///{db_path}' db_url = f'sqlite:///{db_path}'
return BingInsightsService(db_url) return BingInsightsService(db_url)
async def get_analytics(self, user_id: str, target_url: str = None, **kwargs) -> AnalyticsData: async def get_analytics(self, user_id: str, target_url: str = None, start_date: str = None, end_date: str = None, **kwargs) -> AnalyticsData:
""" """
Get Bing Webmaster analytics data using Bing Webmaster API Get Bing Webmaster analytics data using Bing Webmaster API
""" """
self.log_analytics_request(user_id, "get_analytics") self.log_analytics_request(user_id, "get_analytics")
# Check cache first # Check cache first (include date range and target_url in key)
cached_data = analytics_cache.get('bing_analytics', user_id) cache_key_parts = [user_id]
if target_url:
cache_key_parts.append(str(target_url))
if start_date:
cache_key_parts.append(str(start_date))
if end_date:
cache_key_parts.append(str(end_date))
cache_key = "_".join(cache_key_parts)
cached_data = analytics_cache.get('bing_analytics', cache_key)
if cached_data: if cached_data:
logger.info(f"Using cached Bing analytics for user {user_id}") logger.info(f"Using cached Bing analytics for user {user_id}")
return AnalyticsData(**cached_data) return AnalyticsData(**cached_data)
@@ -107,9 +124,22 @@ class BingAnalyticsHandler(BaseAnalyticsHandler):
site_url_for_storage = selected_site.get('Url', '') if selected_site else '' site_url_for_storage = selected_site.get('Url', '') if selected_site else ''
logger.info(f"Using Bing site URL: {site_url_for_storage}") logger.info(f"Using Bing site URL: {site_url_for_storage}")
# Determine date range (defaults to last 30 days)
if not end_date:
end_date = datetime.now().strftime('%Y-%m-%d')
if not start_date:
start_date = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d')
# Compute days for storage/insights services (at least 1)
try:
dt_end = datetime.strptime(end_date, '%Y-%m-%d')
dt_start = datetime.strptime(start_date, '%Y-%m-%d')
days_range = max(1, (dt_end - dt_start).days + 1)
except Exception:
days_range = 30
query_stats = {} query_stats = {}
try: try:
stored = storage_service.get_analytics_summary(user_id, site_url_for_storage, days=30) stored = storage_service.get_analytics_summary(user_id, site_url_for_storage, days=days_range)
if stored and isinstance(stored, dict): if stored and isinstance(stored, dict):
query_stats = { query_stats = {
'total_clicks': stored.get('summary', {}).get('total_clicks', 0), 'total_clicks': stored.get('summary', {}).get('total_clicks', 0),
@@ -138,19 +168,20 @@ class BingAnalyticsHandler(BaseAnalyticsHandler):
'insights': insights, 'insights': insights,
'note': 'Bing Webmaster API provides SEO insights, search performance, and index status data' 'note': 'Bing Webmaster API provides SEO insights, search performance, and index status data'
} }
if (not sites) or (metrics.get('total_impressions', 0) == 0 and metrics.get('total_clicks', 0) == 0): if not sites:
result = self.create_partial_response(metrics=metrics, error_message='Connected to Bing; waiting for stored analytics or site verification') result = self.create_partial_response(metrics=metrics, error_message='Connected to Bing; no verified sites found')
else: else:
result = self.create_success_response(metrics=metrics) result = self.create_success_response(metrics=metrics, date_range={'start': start_date, 'end': end_date})
analytics_cache.set('bing_analytics', user_id, result.__dict__) analytics_cache.set('bing_analytics', cache_key, result.__dict__)
return result return result
except Exception as e: except Exception as e:
self.log_analytics_error(user_id, "get_analytics", e) self.log_analytics_error(user_id, "get_analytics", e)
error_result = self.create_error_response(str(e)) error_result = self.create_error_response(str(e))
analytics_cache.set('bing_analytics', user_id, error_result.__dict__, ttl_override=300) # Cache error briefly to prevent hammering but recover quickly
analytics_cache.set('bing_analytics', cache_key, error_result.__dict__, ttl_override=30)
return error_result return error_result
def _get_enhanced_insights_with_service(self, insights_service: BingInsightsService, user_id: str, site_url: str) -> Dict[str, Any]: def _get_enhanced_insights_with_service(self, insights_service: BingInsightsService, user_id: str, site_url: str) -> Dict[str, Any]:

View File

@@ -22,7 +22,7 @@ class GSCAnalyticsHandler(BaseAnalyticsHandler):
super().__init__(PlatformType.GSC) super().__init__(PlatformType.GSC)
self.gsc_service = GSCService() self.gsc_service = GSCService()
async def get_analytics(self, user_id: str, target_url: str = None, **kwargs) -> AnalyticsData: async def get_analytics(self, user_id: str, target_url: str = None, start_date: str = None, end_date: str = None, **kwargs) -> AnalyticsData:
""" """
Get Google Search Console analytics data with caching Get Google Search Console analytics data with caching
@@ -35,8 +35,16 @@ class GSCAnalyticsHandler(BaseAnalyticsHandler):
self.log_analytics_request(user_id, "get_analytics") self.log_analytics_request(user_id, "get_analytics")
# Check cache first - GSC API calls can be expensive # Check cache first - GSC API calls can be expensive
# Include target_url in cache key if provided # Include target_url and date range in cache key if provided
cache_key = f"{user_id}_{target_url}" if target_url else user_id cache_key_parts = [user_id]
if target_url:
cache_key_parts.append(str(target_url))
if start_date:
cache_key_parts.append(str(start_date))
if end_date:
cache_key_parts.append(str(end_date))
# Bump cache version to include page insights (v2)
cache_key = "_".join(cache_key_parts + ['v2pages'])
cached_data = analytics_cache.get('gsc_analytics', cache_key) cached_data = analytics_cache.get('gsc_analytics', cache_key)
if cached_data: if cached_data:
logger.info("Using cached GSC analytics for user {user_id}", user_id=user_id) logger.info("Using cached GSC analytics for user {user_id}", user_id=user_id)
@@ -70,9 +78,11 @@ class GSCAnalyticsHandler(BaseAnalyticsHandler):
site_url = selected_site['siteUrl'] site_url = selected_site['siteUrl']
logger.info(f"Using GSC site URL: {site_url}") logger.info(f"Using GSC site URL: {site_url}")
# Get search analytics for last 30 days # Determine date range (defaults to last 30 days)
end_date = datetime.now().strftime('%Y-%m-%d') if not end_date:
start_date = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d') end_date = datetime.now().strftime('%Y-%m-%d')
if not start_date:
start_date = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d')
logger.info(f"GSC Date range: {start_date} to {end_date}") logger.info(f"GSC Date range: {start_date} to {end_date}")
search_analytics = self.gsc_service.get_search_analytics( search_analytics = self.gsc_service.get_search_analytics(
@@ -86,10 +96,7 @@ class GSCAnalyticsHandler(BaseAnalyticsHandler):
# Process GSC data into standardized format # Process GSC data into standardized format
processed_metrics = self._process_gsc_metrics(search_analytics) processed_metrics = self._process_gsc_metrics(search_analytics)
result = self.create_success_response( result = self.create_success_response(metrics=processed_metrics, date_range={'start': start_date, 'end': end_date})
metrics=processed_metrics,
date_range={'start': start_date, 'end': end_date}
)
# Cache the result to avoid expensive API calls # Cache the result to avoid expensive API calls
analytics_cache.set('gsc_analytics', cache_key, result.__dict__) analytics_cache.set('gsc_analytics', cache_key, result.__dict__)
@@ -101,8 +108,8 @@ class GSCAnalyticsHandler(BaseAnalyticsHandler):
self.log_analytics_error(user_id, "get_analytics", e) self.log_analytics_error(user_id, "get_analytics", e)
error_result = self.create_error_response(str(e)) error_result = self.create_error_response(str(e))
# Cache error result for shorter time to retry sooner # Cache error result briefly to avoid repeated failures but allow quick recovery
analytics_cache.set('gsc_analytics', cache_key, error_result.__dict__, ttl_override=300) # 5 minutes analytics_cache.set('gsc_analytics', cache_key, error_result.__dict__, ttl_override=30) # 30 seconds
return error_result return error_result
def get_connection_status(self, user_id: str) -> Dict[str, Any]: def get_connection_status(self, user_id: str) -> Dict[str, Any]:
@@ -202,18 +209,159 @@ class GSCAnalyticsHandler(BaseAnalyticsHandler):
sorted_queries = sorted(top_queries_source, key=lambda x: x.get('clicks', 0), reverse=True)[:10] sorted_queries = sorted(top_queries_source, key=lambda x: x.get('clicks', 0), reverse=True)[:10]
for row in sorted_queries: for row in sorted_queries:
clicks_val = row.get('clicks', 0) or 0
impr_val = row.get('impressions', 0) or 0
raw_ctr = row.get('ctr', None)
# Calculate CTR% robustly even if 'ctr' field is missing in row
if raw_ctr is not None:
ctr_percent = round(float(raw_ctr) * 100, 2)
else:
ctr_percent = round(((clicks_val / impr_val) * 100), 2) if impr_val > 0 else 0.0
top_queries.append({ top_queries.append({
'query': self._extract_query_from_row(row), 'query': self._extract_query_from_row(row),
'clicks': row.get('clicks', 0), 'clicks': clicks_val,
'impressions': row.get('impressions', 0), 'impressions': impr_val,
'ctr': round(row.get('ctr', 0) * 100, 2), 'ctr': ctr_percent,
'position': round(row.get('position', 0), 2) 'position': round(row.get('position', 0) or 0, 2)
}) })
# Prepare Top Pages (requires page dimension, but we only requested query dimension in gsc_service step 3) # Prepare Top Pages from page_data when available
# To get top pages, we would need another API call with dimension=['page'] top_pages = []
# For now, we'll return empty top_pages or infer from what we have if possible (we can't from query data) try:
top_pages = [] page_rows = search_analytics.get('page_data', {}).get('rows', [])
qp_rows = search_analytics.get('query_page_data', {}).get('rows', [])
# Build queries-by-page map
queries_by_page: Dict[str, list] = {}
if qp_rows:
for r in qp_rows:
keys = r.get('keys', [])
if not keys or len(keys) < 2:
continue
query_key = keys[0]['keys'][0] if isinstance(keys[0], dict) else str(keys[0])
page_key = keys[1]['keys'][0] if isinstance(keys[1], dict) else str(keys[1])
clicks_val = r.get('clicks', 0) or 0
impr_val = r.get('impressions', 0) or 0
raw_ctr = r.get('ctr', None)
if raw_ctr is not None:
ctr_percent = round(float(raw_ctr) * 100, 2)
else:
ctr_percent = round(((clicks_val / impr_val) * 100), 2) if impr_val > 0 else 0.0
lst = queries_by_page.setdefault(page_key, [])
lst.append({
'query': query_key,
'clicks': clicks_val,
'impressions': impr_val,
'ctr': ctr_percent,
})
if page_rows:
sorted_pages = sorted(page_rows, key=lambda x: x.get('clicks', 0), reverse=True)[:10]
for row in sorted_pages:
clicks_val = row.get('clicks', 0) or 0
impr_val = row.get('impressions', 0) or 0
raw_ctr = row.get('ctr', None)
if raw_ctr is not None:
ctr_percent = round(float(raw_ctr) * 100, 2)
else:
ctr_percent = round(((clicks_val / impr_val) * 100), 2) if impr_val > 0 else 0.0
page_url = self._extract_page_from_row(row)
# attach top queries pointing to this page, sorted by clicks
page_queries = sorted(queries_by_page.get(page_url, []), key=lambda x: x.get('clicks', 0), reverse=True)[:5]
top_pages.append({
'page': page_url,
'clicks': clicks_val,
'impressions': impr_val,
'ctr': ctr_percent,
'position': round(row.get('position', 0) or 0, 2) if 'position' in row else None,
'queries': page_queries
})
except Exception as e:
logger.warning(f"Failed processing top_pages: {e}")
# Detect Cannibalization (query mapping to multiple pages)
cannibalization = []
try:
qp_rows = search_analytics.get('query_page_data', {}).get('rows', [])
q_rows = search_analytics.get('query_data', {}).get('rows', [])
if qp_rows:
# Determine window days for thresholding
from datetime import datetime
start_s = search_analytics.get('startDate')
end_s = search_analytics.get('endDate')
window_days = 30
try:
if start_s and end_s:
sd = datetime.strptime(start_s, "%Y-%m-%d")
ed = datetime.strptime(end_s, "%Y-%m-%d")
window_days = max((ed - sd).days + 1, 1)
except Exception:
pass
min_clicks = 10 if window_days <= 7 else (30 if window_days <= 30 else 60)
# Build map: query -> { page -> metrics }
by_query: Dict[str, Dict[str, Dict[str, float]]] = {}
for r in qp_rows:
keys = r.get('keys', [])
if not keys or len(keys) < 2:
continue
qk = keys[0]['keys'][0] if isinstance(keys[0], dict) else str(keys[0])
pk = keys[1]['keys'][0] if isinstance(keys[1], dict) else str(keys[1])
clicks_val = float(r.get('clicks', 0) or 0)
impr_val = float(r.get('impressions', 0) or 0)
raw_ctr = r.get('ctr', None)
if raw_ctr is not None:
ctr_percent = float(raw_ctr) * 100.0
else:
ctr_percent = (clicks_val / impr_val * 100.0) if impr_val > 0 else 0.0
pos_val = float(r.get('position', 0) or 0)
by_query.setdefault(qk, {}).setdefault(pk, {"clicks": 0.0, "impressions": 0.0, "ctr": 0.0, "position_sum": 0.0, "position_count": 0.0})
agg = by_query[qk][pk]
agg["clicks"] += clicks_val
agg["impressions"] += impr_val
agg["ctr"] = max(agg["ctr"], ctr_percent)
if pos_val > 0:
agg["position_sum"] += pos_val
agg["position_count"] += 1
# Use query totals for context
total_by_query: Dict[str, Dict[str, float]] = {}
for r in q_rows or []:
qk = self._extract_query_from_row(r)
total_by_query[qk] = {
"clicks": float(r.get('clicks', 0) or 0),
"impressions": float(r.get('impressions', 0) or 0),
"position": float(r.get('position', 0) or 0)
}
for qk, pages_map in by_query.items():
if len(pages_map) < 2:
continue
total_clicks = sum(p["clicks"] for p in pages_map.values())
if total_clicks < min_clicks:
continue
qpos = total_by_query.get(qk, {}).get("position", 0.0)
if not (3.0 <= qpos <= 20.0) and qpos != 0.0:
# Skip queries already ranking very well or very poorly (if pos present)
continue
pages_list = []
for pk, m in pages_map.items():
avg_pos = (m["position_sum"] / m["position_count"]) if m["position_count"] > 0 else 0.0
pages_list.append({
"page": pk,
"clicks": round(m["clicks"], 0),
"impressions": round(m["impressions"], 0),
"ctr": round(m["ctr"], 2),
"position": round(avg_pos, 2) if avg_pos > 0 else None
})
pages_list.sort(key=lambda x: x.get("clicks", 0), reverse=True)
target_page = pages_list[0]["page"] if pages_list else None
cannibalization.append({
"query": qk,
"total_clicks": int(round(total_clicks)),
"recommended_target_page": target_page,
"pages": pages_list[:3]
})
# Sort by impact
cannibalization.sort(key=lambda item: item.get("total_clicks", 0), reverse=True)
cannibalization = cannibalization[:10]
except Exception as e:
logger.warning(f"Failed computing cannibalization: {e}")
return { return {
'connection_status': 'connected', 'connection_status': 'connected',
@@ -224,7 +372,8 @@ class GSCAnalyticsHandler(BaseAnalyticsHandler):
'avg_position': round(avg_position, 2), 'avg_position': round(avg_position, 2),
'total_queries': len(top_queries_source) if top_queries_source else 0, 'total_queries': len(top_queries_source) if top_queries_source else 0,
'top_queries': top_queries, 'top_queries': top_queries,
'top_pages': top_pages 'top_pages': top_pages,
'cannibalization': cannibalization
} }
except Exception as e: except Exception as e:
@@ -256,3 +405,18 @@ class GSCAnalyticsHandler(BaseAnalyticsHandler):
except Exception as e: except Exception as e:
logger.error(f"Error extracting query from row: {e}") logger.error(f"Error extracting query from row: {e}")
return 'Unknown' return 'Unknown'
def _extract_page_from_row(self, row: Dict[str, Any]) -> str:
"""Extract page URL from GSC API row data"""
try:
keys = row.get('keys', [])
if keys and len(keys) > 0:
first_key = keys[0]
if isinstance(first_key, dict):
return first_key.get('keys', [''])[0]
else:
return str(first_key)
return ''
except Exception as e:
logger.error(f"Error extracting page from row: {e}")
return ''

View File

@@ -21,7 +21,7 @@ class WixAnalyticsHandler(BaseAnalyticsHandler):
super().__init__(PlatformType.WIX) super().__init__(PlatformType.WIX)
self.wix_service = WixService() self.wix_service = WixService()
async def get_analytics(self, user_id: str) -> AnalyticsData: async def get_analytics(self, user_id: str, start_date: str = None, end_date: str = None, **kwargs) -> AnalyticsData:
""" """
Get Wix analytics data using the Business Management API Get Wix analytics data using the Business Management API

View File

@@ -22,7 +22,7 @@ class WordPressAnalyticsHandler(BaseAnalyticsHandler):
super().__init__(PlatformType.WORDPRESS) super().__init__(PlatformType.WORDPRESS)
self.wordpress_service = WordPressOAuthService() self.wordpress_service = WordPressOAuthService()
async def get_analytics(self, user_id: str) -> AnalyticsData: async def get_analytics(self, user_id: str, start_date: str = None, end_date: str = None, **kwargs) -> AnalyticsData:
""" """
Get WordPress analytics data using WordPress.com REST API Get WordPress analytics data using WordPress.com REST API

View File

@@ -42,7 +42,7 @@ class PlatformAnalyticsService:
self.summary_generator = AnalyticsSummaryGenerator() self.summary_generator = AnalyticsSummaryGenerator()
self.cache_manager = AnalyticsCacheManager() self.cache_manager = AnalyticsCacheManager()
async def get_comprehensive_analytics(self, user_id: str, platforms: List[str] = None) -> Dict[str, AnalyticsData]: async def get_comprehensive_analytics(self, user_id: str, platforms: List[str] = None, start_date: Optional[str] = None, end_date: Optional[str] = None) -> Dict[str, AnalyticsData]:
""" """
Get analytics data from all connected platforms Get analytics data from all connected platforms
@@ -93,9 +93,18 @@ class PlatformAnalyticsService:
if handler: if handler:
if platform_type == PlatformType.GSC or platform_type == PlatformType.BING: if platform_type == PlatformType.GSC or platform_type == PlatformType.BING:
analytics_data[platform_name] = await handler.get_analytics(user_id, target_url=target_url) analytics_data[platform_name] = await handler.get_analytics(
user_id,
target_url=target_url,
start_date=start_date,
end_date=end_date
)
else: else:
analytics_data[platform_name] = await handler.get_analytics(user_id) analytics_data[platform_name] = await handler.get_analytics(
user_id,
start_date=start_date,
end_date=end_date
)
else: else:
logger.warning(f"Unknown platform: {platform_name}") logger.warning(f"Unknown platform: {platform_name}")
analytics_data[platform_name] = self._create_error_response(platform_name, f"Unknown platform: {platform_name}") analytics_data[platform_name] = self._create_error_response(platform_name, f"Unknown platform: {platform_name}")

View File

@@ -237,7 +237,7 @@ class BingAnalyticsStorageService:
Dict containing analytics summary Dict containing analytics summary
""" """
try: try:
db = self._get_db_session() db = self._get_db_session(user_id)
# Date range # Date range
end_date = datetime.now() end_date = datetime.now()
@@ -331,7 +331,7 @@ class BingAnalyticsStorageService:
List of top queries with performance data List of top queries with performance data
""" """
try: try:
db = self._get_db_session() db = self._get_db_session(user_id)
# Calculate date range # Calculate date range
end_date = datetime.now() end_date = datetime.now()

View File

@@ -241,6 +241,9 @@ class ExaResearchProvider(BaseProvider):
for idx, result in enumerate(results): for idx, result in enumerate(results):
source_type = self._determine_source_type(result.url if hasattr(result, 'url') else '') source_type = self._determine_source_type(result.url if hasattr(result, 'url') else '')
# Extract image if available (some Exa results include image URL)
image_url = result.image if hasattr(result, 'image') else None
sources.append({ sources.append({
'title': result.title if hasattr(result, 'title') else '', 'title': result.title if hasattr(result, 'title') else '',
'url': result.url if hasattr(result, 'url') else '', 'url': result.url if hasattr(result, 'url') else '',
@@ -251,17 +254,21 @@ class ExaResearchProvider(BaseProvider):
'source_type': source_type, 'source_type': source_type,
'content': result.text if hasattr(result, 'text') else '', 'content': result.text if hasattr(result, 'text') else '',
'highlights': result.highlights if hasattr(result, 'highlights') else [], 'highlights': result.highlights if hasattr(result, 'highlights') else [],
'summary': result.summary if hasattr(result, 'summary') else '' 'summary': result.summary if hasattr(result, 'summary') else '',
'image': image_url,
'author': result.author if hasattr(result, 'author') else None
}) })
return sources return sources
def _get_excerpt(self, result): def _get_excerpt(self, result):
"""Extract excerpt from Exa result.""" """Extract excerpt from Exa result. Prefer highlights if available."""
if hasattr(result, 'highlights') and result.highlights and len(result.highlights) > 0:
return result.highlights[0]
if hasattr(result, 'summary') and result.summary:
return result.summary
if hasattr(result, 'text') and result.text: if hasattr(result, 'text') and result.text:
return result.text[:500] return result.text[:500]
elif hasattr(result, 'summary') and result.summary:
return result.summary
return '' return ''
def _determine_source_type(self, url): def _determine_source_type(self, url):
@@ -280,16 +287,30 @@ class ExaResearchProvider(BaseProvider):
return 'web' return 'web'
def _aggregate_content(self, results): def _aggregate_content(self, results):
"""Aggregate content from Exa results for LLM analysis.""" """Aggregate content from Exa results for LLM analysis, including highlights."""
content_parts = [] content_parts = []
for idx, result in enumerate(results): for idx, result in enumerate(results):
part = [f"Source {idx + 1}: {result.title if hasattr(result, 'title') else 'Untitled'}"]
if hasattr(result, 'url') and result.url:
part.append(f"URL: {result.url}")
# Add highlights if available (most valuable for LLM)
if hasattr(result, 'highlights') and result.highlights:
highlights_text = "\n".join([f"- {h}" for h in result.highlights])
part.append(f"Key Highlights:\n{highlights_text}")
# Add summary if available
if hasattr(result, 'summary') and result.summary: if hasattr(result, 'summary') and result.summary:
content_parts.append(f"Source {idx + 1}: {result.summary}") part.append(f"Summary: {result.summary}")
# Add text snippet if highlights/summary insufficient
elif hasattr(result, 'text') and result.text: elif hasattr(result, 'text') and result.text:
content_parts.append(f"Source {idx + 1}: {result.text[:1000]}") part.append(f"Excerpt: {result.text[:1000]}")
content_parts.append("\n".join(part))
return "\n\n".join(content_parts) return "\n\n---\n\n".join(content_parts)
def track_exa_usage(self, user_id: str, cost: float): def track_exa_usage(self, user_id: str, cost: float):
"""Track Exa API usage after successful call.""" """Track Exa API usage after successful call."""

View File

@@ -159,14 +159,10 @@ class StyleDetectionLogic:
}} }}
""" """
# Call the LLM for analysis
logger.debug("[StyleDetectionLogic.analyze_content_style] Sending enhanced prompt to LLM") logger.debug("[StyleDetectionLogic.analyze_content_style] Sending enhanced prompt to LLM")
try: try:
analysis_text = llm_text_gen(prompt, user_id=user_id) analysis_text = llm_text_gen(prompt, user_id=user_id)
# Clean and parse the response
cleaned_json = self._clean_json_response(analysis_text) cleaned_json = self._clean_json_response(analysis_text)
analysis_results = json.loads(cleaned_json) analysis_results = json.loads(cleaned_json)
logger.info("[StyleDetectionLogic.analyze_content_style] Successfully parsed enhanced analysis results") logger.info("[StyleDetectionLogic.analyze_content_style] Successfully parsed enhanced analysis results")
return { return {
@@ -179,7 +175,7 @@ class StyleDetectionLogic:
return { return {
'success': True, 'success': True,
'analysis': fallback_results, 'analysis': fallback_results,
'warning': 'AI analysis failed, used fallback detection' 'warning': f'AI analysis failed ({str(e)}), used fallback detection'
} }
except Exception as e: except Exception as e:

View File

@@ -145,6 +145,7 @@ def init_user_database(user_id: str):
SubscriptionBase.metadata.create_all(bind=engine) SubscriptionBase.metadata.create_all(bind=engine)
UserBusinessInfoBase.metadata.create_all(bind=engine) UserBusinessInfoBase.metadata.create_all(bind=engine)
ContentAssetBase.metadata.create_all(bind=engine) ContentAssetBase.metadata.create_all(bind=engine)
BingAnalyticsBase.metadata.create_all(bind=engine)
# Initialize default data for new databases # Initialize default data for new databases
try: try:

View File

@@ -343,7 +343,11 @@ class GSCService:
if not credentials: if not credentials:
raise ValueError("No valid credentials found") raise ValueError("No valid credentials found")
service = build('searchconsole', 'v1', credentials=credentials) # Disable discovery file cache (suppress oauth2client file_cache warnings) with safe fallback
try:
service = build('searchconsole', 'v1', credentials=credentials, cache_discovery=False)
except TypeError:
service = build('searchconsole', 'v1', credentials=credentials)
logger.info(f"Authenticated GSC service created for user: {user_id}") logger.info(f"Authenticated GSC service created for user: {user_id}")
return service return service
@@ -395,9 +399,12 @@ class GSCService:
# Check cache first # Check cache first
cache_key = f"{user_id}_{site_url}_{start_date}_{end_date}" cache_key = f"{user_id}_{site_url}_{start_date}_{end_date}"
cached_data = self._get_cached_data(user_id, site_url, 'analytics', cache_key) cached_data = self._get_cached_data(user_id, site_url, 'analytics', cache_key)
if cached_data: if cached_data and isinstance(cached_data, dict):
logger.info(f"Returning cached analytics data for user: {user_id}") has_pages = 'page_data' in cached_data and isinstance(cached_data.get('page_data'), dict)
return cached_data has_queries = 'query_data' in cached_data and isinstance(cached_data.get('query_data'), dict)
if has_pages and has_queries:
logger.info(f"Returning cached analytics data for user: {user_id} (includes page_data)")
return cached_data
try: try:
service = self.get_authenticated_service(user_id) service = self.get_authenticated_service(user_id)
@@ -476,8 +483,54 @@ class GSCService:
).execute() ).execute()
logger.info(f"GSC Query-level response for user {user_id}: {query_response}") logger.info(f"GSC Query-level response for user {user_id}: {query_response}")
# Combine overall metrics with query-level data # Step 4: Get page-level data for top pages insights
page_request = {
'startDate': start_date,
'endDate': end_date,
'dimensions': ['page'], # Get page-level data
'rowLimit': 1000
}
logger.info(f"GSC Page-level request for user {user_id}: {page_request}")
page_rows = []
page_row_count = 0
try:
page_response = service.searchanalytics().query(
siteUrl=site_url,
body=page_request
).execute()
logger.info(f"GSC Page-level response for user {user_id}: {page_response}")
page_rows = page_response.get('rows', [])
page_row_count = page_response.get('rowCount', 0)
except Exception as page_error:
logger.warning(f"GSC Page-level request failed for user {user_id}: {page_error}")
page_rows = []
page_row_count = 0
# Step 5: Get query+page combined data for mapping queries to pages
qp_rows = []
qp_row_count = 0
try:
qp_request = {
'startDate': start_date,
'endDate': end_date,
'dimensions': ['query', 'page'],
'rowLimit': 1000
}
logger.info(f"GSC Query+Page request for user {user_id}: {qp_request}")
qp_response = service.searchanalytics().query(
siteUrl=site_url,
body=qp_request
).execute()
logger.info(f"GSC Query+Page response for user {user_id}: {qp_response}")
qp_rows = qp_response.get('rows', [])
qp_row_count = qp_response.get('rowCount', 0)
except Exception as qp_error:
logger.warning(f"GSC Query+Page request failed for user {user_id}: {qp_error}")
qp_rows = []
qp_row_count = 0
# Combine overall, query, page and query+page data
analytics_data = { analytics_data = {
'overall_metrics': { 'overall_metrics': {
'rows': response.get('rows', []), 'rows': response.get('rows', []),
@@ -487,6 +540,14 @@ class GSCService:
'rows': query_response.get('rows', []), 'rows': query_response.get('rows', []),
'rowCount': query_response.get('rowCount', 0) 'rowCount': query_response.get('rowCount', 0)
}, },
'page_data': {
'rows': page_rows,
'rowCount': page_row_count
},
'query_page_data': {
'rows': qp_rows,
'rowCount': qp_row_count
},
'verification_data': { 'verification_data': {
'rows': verification_rows, 'rows': verification_rows,
'rowCount': len(verification_rows) 'rowCount': len(verification_rows)
@@ -510,6 +571,8 @@ class GSCService:
'rowCount': response.get('rowCount', 0) 'rowCount': response.get('rowCount', 0)
}, },
'query_data': {'rows': [], 'rowCount': 0}, 'query_data': {'rows': [], 'rowCount': 0},
'page_data': {'rows': [], 'rowCount': 0},
'query_page_data': {'rows': [], 'rowCount': 0},
'verification_data': { 'verification_data': {
'rows': verification_rows, 'rows': verification_rows,
'rowCount': len(verification_rows) 'rowCount': len(verification_rows)

View File

@@ -76,7 +76,8 @@ class ALwrityAgentOrchestrator:
try: try:
# Initialize shared LLM # Initialize shared LLM
if TXTAI_AVAILABLE: if TXTAI_AVAILABLE:
self.llm = LLM(self.config.shared_llm) # Hardening: Explicitly set task to avoid 'text2text-generation' default failures
self.llm = LLM(self.config.shared_llm, task="text-generation")
else: else:
self.llm = None self.llm = None

View File

@@ -181,7 +181,8 @@ class BaseALwrityAgent(ABC):
try: try:
if not self.llm: if not self.llm:
# Create new LLM if not provided # Create new LLM if not provided
raw_llm = LLM(model_name) # Hardening: Explicitly set task to avoid 'text2text-generation' default failures
raw_llm = LLM(model_name, task="text-generation")
# Wrap it # Wrap it
self.llm = TrackingLLMWrapper(raw_llm, self.user_id, self.model_name) self.llm = TrackingLLMWrapper(raw_llm, self.user_id, self.model_name)
@@ -906,6 +907,11 @@ class StrategyOrchestratorAgent(BaseALwrityAgent):
"name": "task_delegator", "name": "task_delegator",
"description": "Delegates specific tasks to specialized agents (content, competitor, seo, social)", "description": "Delegates specific tasks to specialized agents (content, competitor, seo, social)",
"target": self._delegate_task_tool "target": self._delegate_task_tool
},
{
"name": "kickoff_gsc_first_pass",
"description": "Kicks off first-pass execution by invoking SEO/Content default GSC plans",
"target": self._kickoff_gsc_first_pass_tool
} }
], ],
max_iterations=15, max_iterations=15,
@@ -924,7 +930,9 @@ class StrategyOrchestratorAgent(BaseALwrityAgent):
Do not just plan; EXECUTE by delegating. Do not just plan; EXECUTE by delegating.
Always prioritize user goals and maintain safety constraints. Always prioritize user goals and maintain safety constraints.
Coordinate multi-agent responses to market changes effectively.""" Coordinate multi-agent responses to market changes effectively.
First, call 'kickoff_gsc_first_pass' to ground the plan on live GSC signals."""
) )
) )
@@ -1033,6 +1041,37 @@ class StrategyOrchestratorAgent(BaseALwrityAgent):
except Exception as e: except Exception as e:
return {"error": str(e)} return {"error": str(e)}
async def _kickoff_gsc_first_pass_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
"""Invoke SEO and Content agents' default GSC plans and combine results"""
try:
start_date = context.get("start_date")
end_date = context.get("end_date")
payload = {"start_date": start_date, "end_date": end_date}
results = {}
combined_actions = []
seo = self.sub_agents.get("seo")
if seo and hasattr(seo, "_default_seo_gsc_plan_tool"):
plan = await seo._default_seo_gsc_plan_tool(payload)
results["seo"] = plan
combined_actions.extend(plan.get("actions", []) if isinstance(plan, dict) else [])
content = self.sub_agents.get("content")
if content and hasattr(content, "_default_content_gsc_plan_tool"):
plan = await content._default_content_gsc_plan_tool(payload)
results["content"] = plan
combined_actions.extend(plan.get("actions", []) if isinstance(plan, dict) else [])
return {
"status": "ok",
"invoked": list(results.keys()),
"results": results,
"combined_actions": combined_actions,
"timestamp": datetime.utcnow().isoformat()
}
except Exception as e:
return {"status": "error", "error": str(e)}
async def _strategy_synthesizer_tool(self, context: Dict[str, Any]) -> Dict[str, Any]: async def _strategy_synthesizer_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
"""Tool for synthesizing strategies""" """Tool for synthesizing strategies"""
return { return {

View File

@@ -13,6 +13,7 @@ from loguru import logger
from ..txtai_service import TxtaiIntelligenceService from ..txtai_service import TxtaiIntelligenceService
from services.intelligence.agents.core_agent_framework import BaseALwrityAgent, AgentAction from services.intelligence.agents.core_agent_framework import BaseALwrityAgent, AgentAction
from services.seo_tools.content_strategy_service import ContentStrategyService from services.seo_tools.content_strategy_service import ContentStrategyService
from services.analytics import PlatformAnalyticsService
from services.intelligence.sif_agents import SharedLLMWrapper, LocalLLMWrapper from services.intelligence.sif_agents import SharedLLMWrapper, LocalLLMWrapper
try: try:
from services.intelligence.sif_integration import SIFIntegrationService from services.intelligence.sif_integration import SIFIntegrationService
@@ -888,7 +889,37 @@ class ContentStrategyAgent(BaseALwrityAgent):
"name": "sitemap_analyzer", "name": "sitemap_analyzer",
"description": "Analyzes website structure and publishing velocity via sitemap", "description": "Analyzes website structure and publishing velocity via sitemap",
"target": self._sitemap_analyzer_tool "target": self._sitemap_analyzer_tool
} },
{
"name": "gsc_low_ctr_queries",
"description": "Returns low-CTR queries with evidence from cached GSC metrics",
"target": self._cs_gsc_low_ctr_queries_tool
},
{
"name": "gsc_striking_distance_queries",
"description": "Returns striking-distance queries (positions ~820) with evidence",
"target": self._cs_gsc_striking_distance_tool
},
{
"name": "gsc_declining_queries",
"description": "Returns period-over-period declining queries with evidence",
"target": self._cs_gsc_declining_queries_tool
},
{
"name": "gsc_low_ctr_pages",
"description": "Returns low-CTR pages with top contributing queries",
"target": self._cs_gsc_low_ctr_pages_tool
},
{
"name": "gsc_cannibalization_candidates",
"description": "Returns query→multiple-pages cannibalization candidates with target recommendation",
"target": self._cs_gsc_cannibalization_candidates_tool
},
{
"name": "default_content_gsc_plan",
"description": "Runs a default first-pass plan using GSC signals (titles/meta, consolidation, refreshes)",
"target": self._default_content_gsc_plan_tool
},
], ],
max_iterations=8, max_iterations=8,
system=self.get_effective_system_prompt(f"""You are the Content Strategy Agent for ALwrity user {self.user_id}. system=self.get_effective_system_prompt(f"""You are the Content Strategy Agent for ALwrity user {self.user_id}.
@@ -903,12 +934,153 @@ class ContentStrategyAgent(BaseALwrityAgent):
- Performance-based content improvements - Performance-based content improvements
Use semantic analysis (SIF) and sitemap analysis to understand content context. Use semantic analysis (SIF) and sitemap analysis to understand content context.
Always prioritize user goals and maintain brand consistency.""" Always prioritize user goals and maintain brand consistency.
In your first pass, call 'default_content_gsc_plan' to ground your actions on live GSC signals."""
) )
) )
# Tool Implementations # Tool Implementations
async def _cs_fetch_gsc_analytics(self, start_date: Optional[str] = None, end_date: Optional[str] = None) -> Dict[str, Any]:
svc = PlatformAnalyticsService()
data = await svc.get_comprehensive_analytics(self.user_id, platforms=["gsc"], start_date=start_date, end_date=end_date)
gsc = data.get("gsc")
if not gsc or gsc.status != "success":
err = getattr(gsc, "error_message", None) if gsc else "No data"
raise RuntimeError(f"GSC analytics unavailable: {err}")
return {"metrics": gsc.metrics, "date_range": gsc.date_range}
async def _cs_gsc_low_ctr_queries_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
limit = int(context.get("limit", 10)); min_impr = int(context.get("min_impressions", 100)); min_clicks = int(context.get("min_clicks", 10)); ctr_threshold = float(context.get("ctr_threshold", 1.5))
start_date = context.get("start_date"); end_date = context.get("end_date")
try:
result = await self._cs_fetch_gsc_analytics(start_date, end_date)
tq = result["metrics"].get("top_queries", []) or []
items = [
{"query": r.get("query"), "clicks": r.get("clicks", 0), "impressions": r.get("impressions", 0), "ctr": r.get("ctr", 0.0), "position": r.get("position")}
for r in tq
if (r.get("impressions", 0) >= min_impr and r.get("clicks", 0) >= min_clicks and float(r.get("ctr", 0.0)) < ctr_threshold)
]
items.sort(key=lambda x: (x.get("impressions", 0), -x.get("ctr", 100.0)), reverse=True)
return {"items": items[:limit], "range": result["date_range"], "source": "gsc_cache"}
except Exception as e:
logger.error(f"cs low_ctr_queries failed: {e}"); return {"error": str(e)}
async def _cs_gsc_striking_distance_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
limit = int(context.get("limit", 10)); min_impr = int(context.get("min_impressions", 100)); start_date = context.get("start_date"); end_date = context.get("end_date")
try:
result = await self._cs_fetch_gsc_analytics(start_date, end_date)
tq = result["metrics"].get("top_queries", []) or []
items = [
{"query": r.get("query"), "clicks": r.get("clicks", 0), "impressions": r.get("impressions", 0), "ctr": r.get("ctr", 0.0), "position": r.get("position")}
for r in tq
if (r.get("impressions", 0) >= min_impr and r.get("position") is not None and 8.0 <= float(r.get("position")) <= 20.0)
]
items.sort(key=lambda x: (x.get("position") if x.get("position") is not None else 999, -x.get("impressions", 0)))
return {"items": items[:limit], "range": result["date_range"], "source": "gsc_cache"}
except Exception as e:
logger.error(f"cs striking_distance failed: {e}"); return {"error": str(e)}
async def _cs_gsc_declining_queries_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
limit = int(context.get("limit", 10)); min_prev_clicks = int(context.get("min_prev_clicks", 10)); min_drop_pct = float(context.get("min_drop_pct", 30.0))
start_date = context.get("start_date"); end_date = context.get("end_date")
try:
curr = await self._cs_fetch_gsc_analytics(start_date, end_date)
curr_range = curr["date_range"]; s = curr_range.get("start"); e = curr_range.get("end")
from datetime import datetime, timedelta; fmt = "%Y-%m-%d"
sd = datetime.strptime(s, fmt) if s else datetime.utcnow() - timedelta(days=30); ed = datetime.strptime(e, fmt) if e else datetime.utcnow()
days = max((ed - sd).days + 1, 1); prev_end = sd - timedelta(days=1); prev_start = prev_end - timedelta(days=days - 1)
prev = await self._cs_fetch_gsc_analytics(prev_start.strftime(fmt), prev_end.strftime(fmt))
curr_queries = {r.get("query"): r for r in (curr["metrics"].get("top_queries", []) or [])}
prev_queries = {r.get("query"): r for r in (prev["metrics"].get("top_queries", []) or [])}
items = []
for q, prev_row in prev_queries.items():
curr_row = curr_queries.get(q);
if not curr_row: continue
prev_clicks = int(prev_row.get("clicks", 0) or 0); curr_clicks = int(curr_row.get("clicks", 0) or 0)
if prev_clicks >= min_prev_clicks and curr_clicks < prev_clicks:
drop_pct = ((prev_clicks - curr_clicks) / prev_clicks) * 100.0
if drop_pct >= min_drop_pct:
items.append({"query": q, "prev_clicks": prev_clicks, "curr_clicks": curr_clicks, "drop_pct": round(drop_pct, 2)})
items.sort(key=lambda x: (x.get("drop_pct", 0), x.get("prev_clicks", 0)), reverse=True)
return {"items": items[:limit], "range": curr_range, "previous_range": prev["date_range"], "source": "gsc_cache"}
except Exception as e:
logger.error(f"cs declining_queries failed: {e}"); return {"error": str(e)}
async def _cs_gsc_low_ctr_pages_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
limit = int(context.get("limit", 10)); min_impr = int(context.get("min_impressions", 200)); ctr_threshold = float(context.get("ctr_threshold", 1.5))
start_date = context.get("start_date"); end_date = context.get("end_date")
try:
result = await self._cs_fetch_gsc_analytics(start_date, end_date)
tp = result["metrics"].get("top_pages", []) or []
items = []
for r in tp:
if (r.get("impressions", 0) >= min_impr and float(r.get("ctr", 0.0)) < ctr_threshold):
items.append({"page": r.get("page"), "clicks": r.get("clicks", 0), "impressions": r.get("impressions", 0), "ctr": r.get("ctr", 0.0), "position": r.get("position"), "evidence_queries": r.get("queries", [])[:5]})
items.sort(key=lambda x: (x.get("impressions", 0), -x.get("ctr", 100.0)), reverse=True)
return {"items": items[:limit], "range": result["date_range"], "source": "gsc_cache"}
except Exception as e:
logger.error(f"cs low_ctr_pages failed: {e}"); return {"error": str(e)}
async def _cs_gsc_cannibalization_candidates_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
limit = int(context.get("limit", 10)); start_date = context.get("start_date"); end_date = context.get("end_date")
try:
result = await self._cs_fetch_gsc_analytics(start_date, end_date)
candidates = result["metrics"].get("cannibalization", []) or []
return {"items": candidates[:limit], "range": result["date_range"], "source": "gsc_cache"}
except Exception as e:
logger.error(f"cs cannibalization_candidates failed: {e}"); return {"error": str(e)}
async def _default_content_gsc_plan_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
start_date = context.get("start_date"); end_date = context.get("end_date")
try:
low_ctr_pages = await self._cs_gsc_low_ctr_pages_tool({"start_date": start_date, "end_date": end_date, "limit": 10})
cannibals = await self._cs_gsc_cannibalization_candidates_tool({"start_date": start_date, "end_date": end_date, "limit": 10})
striking = await self._cs_gsc_striking_distance_tool({"start_date": start_date, "end_date": end_date, "limit": 10})
declining = await self._cs_gsc_declining_queries_tool({"start_date": start_date, "end_date": end_date, "limit": 10})
actions = []
for p in low_ctr_pages.get("items", []):
actions.append({
"type": "improve_titles_meta",
"target": p.get("page"),
"reason": f"Low CTR {p.get('ctr')}% with {p.get('impressions')} impressions",
"evidence": p.get("evidence_queries", [])
})
for c in cannibals.get("items", []):
actions.append({
"type": "consolidate/internal_link",
"target": c.get("recommended_target_page"),
"reason": f"Cannibalization on query '{c.get('query')}'",
"pages": c.get("pages", [])
})
for q in striking.get("items", []):
actions.append({
"type": "refresh_content",
"target": "query",
"query": q.get("query"),
"reason": f"Striking distance at position {q.get('position')} with {q.get('impressions')} impressions"
})
for q in declining.get("items", []):
actions.append({
"type": "refresh_content",
"target": "query",
"query": q.get("query"),
"reason": f"Clicks decline {q.get('prev_clicks')}{q.get('curr_clicks')} ({q.get('drop_pct')}%)"
})
return {
"plan_name": "Default Content Plan from GSC",
"range": {"current": {"start": start_date, "end": end_date}},
"actions": actions,
"source": "gsc_cache",
"timestamp": datetime.utcnow().isoformat()
}
except Exception as e:
logger.error(f"default_content_gsc_plan failed: {e}")
return {"error": str(e)}
async def _sitemap_analyzer_tool(self, context: Dict[str, Any]) -> Dict[str, Any]: async def _sitemap_analyzer_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
"""Sitemap analysis tool using ContentStrategyService""" """Sitemap analysis tool using ContentStrategyService"""
website_url = context.get('website_url') website_url = context.get('website_url')
@@ -1324,7 +1496,37 @@ class SEOOptimizationAgent(BaseALwrityAgent):
"name": "query_seo_knowledge_base", "name": "query_seo_knowledge_base",
"description": "Queries the SIF knowledge base for SEO dashboard data, GSC/Bing metrics, and semantic insights", "description": "Queries the SIF knowledge base for SEO dashboard data, GSC/Bing metrics, and semantic insights",
"target": self._query_seo_knowledge_base_tool "target": self._query_seo_knowledge_base_tool
} },
{
"name": "gsc_low_ctr_queries",
"description": "Returns low-CTR queries with evidence from cached GSC metrics",
"target": self._gsc_low_ctr_queries_tool
},
{
"name": "gsc_striking_distance_queries",
"description": "Returns striking-distance queries (positions ~820) with evidence",
"target": self._gsc_striking_distance_tool
},
{
"name": "gsc_declining_queries",
"description": "Returns period-over-period declining queries with evidence",
"target": self._gsc_declining_queries_tool
},
{
"name": "gsc_low_ctr_pages",
"description": "Returns low-CTR pages with top contributing queries",
"target": self._gsc_low_ctr_pages_tool
},
{
"name": "gsc_cannibalization_candidates",
"description": "Returns query→multiple-pages cannibalization candidates with target recommendation",
"target": self._gsc_cannibalization_candidates_tool
},
{
"name": "default_seo_gsc_plan",
"description": "Runs a default first-pass SEO plan using GSC signals (titles/meta, consolidation, refreshes)",
"target": self._default_seo_gsc_plan_tool
},
], ],
max_iterations=15, max_iterations=15,
system=self.get_effective_system_prompt(f"""You are the SEO Optimization Agent for ALwrity user {self.user_id}. system=self.get_effective_system_prompt(f"""You are the SEO Optimization Agent for ALwrity user {self.user_id}.
@@ -1340,6 +1542,7 @@ class SEOOptimizationAgent(BaseALwrityAgent):
- Deep semantic search of SEO data (GSC, Bing, Audits) - Deep semantic search of SEO data (GSC, Bing, Audits)
Focus on high-impact, low-effort optimizations first. Focus on high-impact, low-effort optimizations first.
In your first pass, call 'default_seo_gsc_plan' to ground your actions on live GSC signals.
Always maintain SEO best practices and user experience.""" Always maintain SEO best practices and user experience."""
) )
) )
@@ -1666,6 +1869,223 @@ class SEOOptimizationAgent(BaseALwrityAgent):
"timestamp": datetime.utcnow().isoformat() "timestamp": datetime.utcnow().isoformat()
} }
# GSC Insights Tools (Option B)
async def _fetch_gsc_analytics(self, start_date: Optional[str] = None, end_date: Optional[str] = None) -> Dict[str, Any]:
svc = PlatformAnalyticsService()
data = await svc.get_comprehensive_analytics(self.user_id, platforms=["gsc"], start_date=start_date, end_date=end_date)
gsc = data.get("gsc")
if not gsc or gsc.status != "success":
err = getattr(gsc, "error_message", None) if gsc else "No data"
raise RuntimeError(f"GSC analytics unavailable: {err}")
return {
"metrics": gsc.metrics,
"date_range": gsc.date_range
}
async def _gsc_low_ctr_queries_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
limit = int(context.get("limit", 10))
min_impr = int(context.get("min_impressions", 100))
min_clicks = int(context.get("min_clicks", 10))
ctr_threshold = float(context.get("ctr_threshold", 1.5))
start_date = context.get("start_date")
end_date = context.get("end_date")
try:
result = await self._fetch_gsc_analytics(start_date, end_date)
tq = result["metrics"].get("top_queries", []) or []
items = [
{
"query": r.get("query"),
"clicks": r.get("clicks", 0),
"impressions": r.get("impressions", 0),
"ctr": r.get("ctr", 0.0),
"position": r.get("position")
}
for r in tq
if (r.get("impressions", 0) >= min_impr and r.get("clicks", 0) >= min_clicks and float(r.get("ctr", 0.0)) < ctr_threshold)
]
items.sort(key=lambda x: (x.get("impressions", 0), -x.get("ctr", 100.0)), reverse=True)
return {
"items": items[:limit],
"range": result["date_range"],
"source": "gsc_cache"
}
except Exception as e:
logger.error(f"low_ctr_queries tool failed: {e}")
return {"error": str(e)}
async def _gsc_striking_distance_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
limit = int(context.get("limit", 10))
min_impr = int(context.get("min_impressions", 100))
start_date = context.get("start_date")
end_date = context.get("end_date")
try:
result = await self._fetch_gsc_analytics(start_date, end_date)
tq = result["metrics"].get("top_queries", []) or []
items = [
{
"query": r.get("query"),
"clicks": r.get("clicks", 0),
"impressions": r.get("impressions", 0),
"ctr": r.get("ctr", 0.0),
"position": r.get("position")
}
for r in tq
if (r.get("impressions", 0) >= min_impr and r.get("position") is not None and 8.0 <= float(r.get("position")) <= 20.0)
]
items.sort(key=lambda x: (x.get("position") if x.get("position") is not None else 999, -x.get("impressions", 0)))
return {
"items": items[:limit],
"range": result["date_range"],
"source": "gsc_cache"
}
except Exception as e:
logger.error(f"striking_distance tool failed: {e}")
return {"error": str(e)}
async def _gsc_declining_queries_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
limit = int(context.get("limit", 10))
min_prev_clicks = int(context.get("min_prev_clicks", 10))
min_drop_pct = float(context.get("min_drop_pct", 30.0))
start_date = context.get("start_date")
end_date = context.get("end_date")
try:
curr = await self._fetch_gsc_analytics(start_date, end_date)
curr_range = curr["date_range"]
s = curr_range.get("start")
e = curr_range.get("end")
from datetime import datetime, timedelta
fmt = "%Y-%m-%d"
sd = datetime.strptime(s, fmt) if s else datetime.utcnow() - timedelta(days=30)
ed = datetime.strptime(e, fmt) if e else datetime.utcnow()
days = max((ed - sd).days + 1, 1)
prev_end = sd - timedelta(days=1)
prev_start = prev_end - timedelta(days=days - 1)
prev = await self._fetch_gsc_analytics(prev_start.strftime(fmt), prev_end.strftime(fmt))
curr_queries = {r.get("query"): r for r in (curr["metrics"].get("top_queries", []) or [])}
prev_queries = {r.get("query"): r for r in (prev["metrics"].get("top_queries", []) or [])}
items = []
for q, prev_row in prev_queries.items():
curr_row = curr_queries.get(q)
if not curr_row:
continue
prev_clicks = int(prev_row.get("clicks", 0) or 0)
curr_clicks = int(curr_row.get("clicks", 0) or 0)
if prev_clicks >= min_prev_clicks and curr_clicks < prev_clicks:
drop_pct = ((prev_clicks - curr_clicks) / prev_clicks) * 100.0
if drop_pct >= min_drop_pct:
items.append({
"query": q,
"prev_clicks": prev_clicks,
"curr_clicks": curr_clicks,
"drop_pct": round(drop_pct, 2)
})
items.sort(key=lambda x: (x.get("drop_pct", 0), x.get("prev_clicks", 0)), reverse=True)
return {
"items": items[:limit],
"range": curr_range,
"previous_range": prev["date_range"],
"source": "gsc_cache"
}
except Exception as e:
logger.error(f"declining_queries tool failed: {e}")
return {"error": str(e)}
async def _gsc_low_ctr_pages_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
limit = int(context.get("limit", 10))
min_impr = int(context.get("min_impressions", 200))
ctr_threshold = float(context.get("ctr_threshold", 1.5))
start_date = context.get("start_date")
end_date = context.get("end_date")
try:
result = await self._fetch_gsc_analytics(start_date, end_date)
tp = result["metrics"].get("top_pages", []) or []
items = []
for r in tp:
if (r.get("impressions", 0) >= min_impr and float(r.get("ctr", 0.0)) < ctr_threshold):
items.append({
"page": r.get("page"),
"clicks": r.get("clicks", 0),
"impressions": r.get("impressions", 0),
"ctr": r.get("ctr", 0.0),
"position": r.get("position"),
"evidence_queries": r.get("queries", [])[:5]
})
items.sort(key=lambda x: (x.get("impressions", 0), -x.get("ctr", 100.0)), reverse=True)
return {
"items": items[:limit],
"range": result["date_range"],
"source": "gsc_cache"
}
except Exception as e:
logger.error(f"low_ctr_pages tool failed: {e}")
return {"error": str(e)}
async def _gsc_cannibalization_candidates_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
limit = int(context.get("limit", 10))
start_date = context.get("start_date")
end_date = context.get("end_date")
try:
result = await self._fetch_gsc_analytics(start_date, end_date)
candidates = result["metrics"].get("cannibalization", []) or []
return {
"items": candidates[:limit],
"range": result["date_range"],
"source": "gsc_cache"
}
except Exception as e:
logger.error(f"cannibalization_candidates tool failed: {e}")
return {"error": str(e)}
async def _default_seo_gsc_plan_tool(self, context: Dict[str, Any]) -> Dict[str, Any]:
start_date = context.get("start_date")
end_date = context.get("end_date")
try:
low_ctr_pages = await self._gsc_low_ctr_pages_tool({"start_date": start_date, "end_date": end_date, "limit": 10})
cannibals = await self._gsc_cannibalization_candidates_tool({"start_date": start_date, "end_date": end_date, "limit": 10})
striking = await self._gsc_striking_distance_tool({"start_date": start_date, "end_date": end_date, "limit": 10})
declining = await self._gsc_declining_queries_tool({"start_date": start_date, "end_date": end_date, "limit": 10})
actions = []
for p in low_ctr_pages.get("items", []):
actions.append({
"type": "update_titles_meta",
"target_page": p.get("page"),
"justification": f"Low CTR {p.get('ctr')}% with {p.get('impressions')} impressions",
"evidence": p.get("evidence_queries", [])
})
for c in cannibals.get("items", []):
actions.append({
"type": "consolidate/internal_link",
"target_page": c.get("recommended_target_page"),
"justification": f"Cannibalization on query '{c.get('query')}'",
"pages": c.get("pages", [])
})
for q in striking.get("items", []):
actions.append({
"type": "refresh_content",
"target": "query",
"query": q.get("query"),
"justification": f"Striking distance at position {q.get('position')} with {q.get('impressions')} impressions"
})
for q in declining.get("items", []):
actions.append({
"type": "refresh_content",
"target": "query",
"query": q.get("query"),
"justification": f"Clicks decline {q.get('prev_clicks')}{q.get('curr_clicks')} ({q.get('drop_pct')}%)"
})
return {
"plan_name": "Default SEO Plan from GSC",
"range": {"current": {"start": start_date, "end": end_date}},
"actions": actions,
"source": "gsc_cache",
"timestamp": datetime.utcnow().isoformat()
}
except Exception as e:
logger.error(f"default_seo_gsc_plan failed: {e}")
return {"error": str(e)}
class SocialAmplificationAgent(BaseALwrityAgent): class SocialAmplificationAgent(BaseALwrityAgent):
""" """

View File

@@ -14,9 +14,9 @@ from .txtai_service import TxtaiIntelligenceService, TXTAI_AVAILABLE
from services.intelligence.agents.core_agent_framework import BaseALwrityAgent from services.intelligence.agents.core_agent_framework import BaseALwrityAgent
from services.llm_providers.main_text_generation import llm_text_gen from services.llm_providers.main_text_generation import llm_text_gen
# Optional txtai imports # Optional txtai imports (align with core agent framework)
try: try:
from txtai.pipeline import Agent, LLM from txtai import Agent, LLM
except ImportError: except ImportError:
Agent = None Agent = None
LLM = None LLM = None
@@ -28,9 +28,13 @@ class SharedLLMWrapper:
def generate(self, prompt: str, **kwargs) -> str: def generate(self, prompt: str, **kwargs) -> str:
"""Generate text using the shared LLM provider.""" """Generate text using the shared LLM provider."""
# We ignore kwargs like 'max_tokens' as llm_text_gen handles defaults, try:
# but we could map them if needed. # We ignore kwargs like 'max_tokens' as llm_text_gen handles defaults,
return llm_text_gen(prompt, user_id=self.user_id) # but we could map them if needed.
return llm_text_gen(prompt, user_id=self.user_id)
except Exception as e:
logger.error(f"SharedLLMWrapper failed to generate text: {e}")
return f"[ERROR: Shared LLM generation failed for user {self.user_id}]"
def __call__(self, prompt: str, **kwargs) -> str: def __call__(self, prompt: str, **kwargs) -> str:
return self.generate(prompt, **kwargs) return self.generate(prompt, **kwargs)
@@ -40,8 +44,9 @@ class LocalLLMWrapper:
Lazily loads a local LLM via txtai. Lazily loads a local LLM via txtai.
This prevents blocking server startup with heavy model loads. This prevents blocking server startup with heavy model loads.
""" """
def __init__(self, model_path: str): def __init__(self, model_path: str, task: str = "text-generation"):
self.model_path = model_path self.model_path = model_path
self.task = task
self._llm = None self._llm = None
@property @property
@@ -49,8 +54,9 @@ class LocalLLMWrapper:
if self._llm is None: if self._llm is None:
if LLM is None: if LLM is None:
raise ImportError("txtai.pipeline.LLM is not available") raise ImportError("txtai.pipeline.LLM is not available")
logger.info(f"Loading local LLM: {self.model_path}") logger.info(f"Loading local LLM: {self.model_path} with task: {self.task}")
self._llm = LLM(path=self.model_path) # Explicitly set task to avoid 'text2text-generation' default failures
self._llm = LLM(path=self.model_path, task=self.task)
return self._llm return self._llm
def __call__(self, prompt: str, **kwargs) -> str: def __call__(self, prompt: str, **kwargs) -> str:
@@ -67,11 +73,12 @@ class SIFBaseAgent(BaseALwrityAgent):
# 2. Local LLM for internal agent work (default for SIF agents) # 2. Local LLM for internal agent work (default for SIF agents)
if llm is None: if llm is None:
if TXTAI_AVAILABLE: if TXTAI_AVAILABLE and LLM is not None:
# Use Lazy Local LLM # Use Lazy Local LLM when txtai LLM is available
llm = LocalLLMWrapper(model_name) # Hardening: Specify 'text-generation' task to avoid text2text defaults
llm = LocalLLMWrapper(model_name, task="text-generation")
else: else:
# Fallback to Shared if txtai not available # Fallback to Shared if txtai or LLM is not available
llm = self.shared_llm llm = self.shared_llm
super().__init__(user_id, agent_type, model_name, llm) super().__init__(user_id, agent_type, model_name, llm)
@@ -85,14 +92,18 @@ class SIFBaseAgent(BaseALwrityAgent):
def _create_txtai_agent(self): def _create_txtai_agent(self):
""" """
SIF agents use the intelligence service directly, but we can expose SIF agents primarily use the intelligence service directly, but we can expose
capabilities via a standard agent interface if needed. capabilities via a standard agent interface if available.
""" """
if not TXTAI_AVAILABLE: if not TXTAI_AVAILABLE or Agent is None:
return None logger.debug(f"[{self.__class__.__name__}] txtai Agent not available, using fallback agent")
return self._create_fallback_agent()
# Return a simple agent that can use the LLM
return Agent(llm=self.llm, tools=[]) try:
return Agent(llm=self.llm, tools=[])
except Exception as e:
logger.warning(f"[{self.__class__.__name__}] Failed to create txtai Agent: {e}")
return self._create_fallback_agent()
class StrategyArchitectAgent(SIFBaseAgent): class StrategyArchitectAgent(SIFBaseAgent):
"""Agent for discovering content pillars and identifying strategic gaps.""" """Agent for discovering content pillars and identifying strategic gaps."""

View File

@@ -25,7 +25,18 @@ except ImportError:
TXTAI_AVAILABLE = False TXTAI_AVAILABLE = False
class TxtaiIntelligenceService: class TxtaiIntelligenceService:
_instances = {}
def __new__(cls, user_id: str, *args, **kwargs):
if user_id not in cls._instances:
cls._instances[user_id] = super(TxtaiIntelligenceService, cls).__new__(cls)
return cls._instances[user_id]
def __init__(self, user_id: str, model_path: Optional[str] = None, enable_caching: bool = True): def __init__(self, user_id: str, model_path: Optional[str] = None, enable_caching: bool = True):
# Singleton: prevent re-initialization if already initialized
if getattr(self, "_singleton_initialized", False):
return
self.user_id = user_id self.user_id = user_id
self.model_path = model_path or "sentence-transformers/all-MiniLM-L6-v2" self.model_path = model_path or "sentence-transformers/all-MiniLM-L6-v2"
self.index_path = f"workspace/workspace_{user_id}/indices/txtai" self.index_path = f"workspace/workspace_{user_id}/indices/txtai"
@@ -33,6 +44,11 @@ class TxtaiIntelligenceService:
self._initialized = False self._initialized = False
self.enable_caching = enable_caching self.enable_caching = enable_caching
self.cache_manager = semantic_cache_manager if enable_caching else None self.cache_manager = semantic_cache_manager if enable_caching else None
self._backend = "faiss" # Default backend
# Mark as initialized for singleton pattern
self._singleton_initialized = True
# Lazy initialization - do not initialize embeddings on startup # Lazy initialization - do not initialize embeddings on startup
# self._initialize_embeddings() # self._initialize_embeddings()
@@ -52,17 +68,26 @@ class TxtaiIntelligenceService:
logger.debug(f"Model path: {self.model_path}") logger.debug(f"Model path: {self.model_path}")
logger.debug(f"Index path: {self.index_path}") logger.debug(f"Index path: {self.index_path}")
# Close existing embeddings if any to release file locks
if self.embeddings:
try:
if hasattr(self.embeddings, 'close'):
self.embeddings.close()
self.embeddings = None
except Exception as close_err:
logger.warning(f"Error closing existing embeddings: {close_err}")
# Ensure directory exists # Ensure directory exists
os.makedirs(os.path.dirname(self.index_path), exist_ok=True) os.makedirs(os.path.dirname(self.index_path), exist_ok=True)
logger.debug(f"Created index directory: {os.path.dirname(self.index_path)}") logger.debug(f"Created index directory: {os.path.dirname(self.index_path)}")
# Initialize embeddings with optimal configuration for ALwrity use case # Initialize embeddings with optimal configuration for ALwrity use case
# Hardening: Disabling quantization by default as it causes 'IndexIDMap' attribute errors with small indices on Windows
self.embeddings = Embeddings({ self.embeddings = Embeddings({
"path": self.model_path, "path": self.model_path,
"content": True, # Enable content storage for retrieval "content": True, # Enable content storage for retrieval
"objects": True, # Enable object storage for metadata "objects": True, # Enable object storage for metadata
"backend": "faiss", # Use Faiss for efficient similarity search "backend": self._backend, # Use Faiss for efficient similarity search
"quantize": True, # Enable quantization for memory efficiency
"batch": 32, # Batch size for processing "batch": 32, # Batch size for processing
"gpu": False, # Force CPU usage for compatibility "gpu": False, # Force CPU usage for compatibility
"limit": 1000 # Maximum number of results for queries "limit": 1000 # Maximum number of results for queries
@@ -76,7 +101,12 @@ class TxtaiIntelligenceService:
try: try:
self.embeddings.load(self.index_path) self.embeddings.load(self.index_path)
logger.info(f"Successfully loaded existing txtai index for user {self.user_id}") logger.info(f"Successfully loaded existing txtai index for user {self.user_id}")
logger.debug(f"Index contains {len(self.embeddings)} items") # Try to log count, handle if not supported
try:
count = self.embeddings.count() if hasattr(self.embeddings, 'count') else "unknown"
logger.debug(f"Index contains {count} items")
except:
logger.debug("Index loaded (count unavailable)")
except Exception as load_error: except Exception as load_error:
logger.warning(f"Failed to load existing index: {load_error}. Creating new index.") logger.warning(f"Failed to load existing index: {load_error}. Creating new index.")
# Reset embeddings to create new index # Reset embeddings to create new index
@@ -84,8 +114,7 @@ class TxtaiIntelligenceService:
"path": self.model_path, "path": self.model_path,
"content": True, "content": True,
"objects": True, "objects": True,
"backend": "faiss", "backend": self._backend,
"quantize": True,
"batch": 32, "batch": 32,
"gpu": False, "gpu": False,
"limit": 1000 "limit": 1000
@@ -146,8 +175,15 @@ class TxtaiIntelligenceService:
logger.error(f"Error indexing content for user {self.user_id}: {e}") logger.error(f"Error indexing content for user {self.user_id}: {e}")
logger.error(f"Full traceback: {traceback.format_exc()}") logger.error(f"Full traceback: {traceback.format_exc()}")
logger.error(f"Items count: {len(items) if items else 0}") logger.error(f"Items count: {len(items) if items else 0}")
if items and len(items) > 0:
logger.error(f"Sample item structure: {type(items[0])}") message = str(e)
is_windows_lock_error = isinstance(e, PermissionError) or "WinError 32" in message
if is_windows_lock_error:
logger.warning(
f"Txtai index save skipped for user {self.user_id} due to file lock. "
f"The index will be retried on a future run."
)
return
raise raise
async def search(self, query: str, limit: int = 5) -> List[Dict[str, Any]]: async def search(self, query: str, limit: int = 5) -> List[Dict[str, Any]]:
@@ -172,7 +208,20 @@ class TxtaiIntelligenceService:
logger.debug(f"Cache miss for search query: '{query}'") logger.debug(f"Cache miss for search query: '{query}'")
logger.debug(f"Searching for query: '{query}' with limit: {limit}") logger.debug(f"Searching for query: '{query}' with limit: {limit}")
results = self.embeddings.search(query, limit=limit) try:
results = self.embeddings.search(query, limit=limit)
except AttributeError as ae:
if "nprobe" in str(ae):
logger.error(f"Detected known txtai/faiss IndexIDMap/nprobe incompatibility for user {self.user_id}. Attempting re-init with numpy backend fallback...")
# Switch to numpy backend which doesn't have this issue
self._backend = "numpy"
self._initialize_embeddings()
if self.embeddings:
results = self.embeddings.search(query, limit=limit)
else:
raise ae
else:
raise ae
# Cache the results if caching is enabled # Cache the results if caching is enabled
if self.enable_caching and self.cache_manager and results: if self.enable_caching and self.cache_manager and results:
@@ -216,7 +265,19 @@ class TxtaiIntelligenceService:
logger.debug(f"Cache miss for similarity calculation") logger.debug(f"Cache miss for similarity calculation")
logger.debug(f"Calculating similarity between texts: '{text1[:50]}...' and '{text2[:50]}...'") logger.debug(f"Calculating similarity between texts: '{text1[:50]}...' and '{text2[:50]}...'")
similarity = self.embeddings.similarity(text1, text2) try:
similarity = self.embeddings.similarity(text1, text2)
except AttributeError as ae:
if "nprobe" in str(ae):
logger.error(f"Detected IndexIDMap nprobe error in similarity for user {self.user_id}. Falling back to numpy backend...")
self._backend = "numpy"
self._initialize_embeddings()
if self.embeddings:
similarity = self.embeddings.similarity(text1, text2)
else:
raise ae
else:
raise ae
# Cache the similarity result # Cache the similarity result
if self.enable_caching and self.cache_manager: if self.enable_caching and self.cache_manager:
@@ -272,7 +333,19 @@ class TxtaiIntelligenceService:
# Use graph-based clustering if available # Use graph-based clustering if available
# Perform a search to get graph structure # Perform a search to get graph structure
sample_query = "content marketing digital strategy" sample_query = "content marketing digital strategy"
graph_results = self.embeddings.search(sample_query, limit=10, graph=True) try:
graph_results = self.embeddings.search(sample_query, limit=10, graph=True)
except AttributeError as ae:
if "nprobe" in str(ae):
logger.error(f"Detected IndexIDMap nprobe error in cluster for user {self.user_id}. Falling back to numpy backend...")
self._backend = "numpy"
self._initialize_embeddings()
if self.embeddings:
graph_results = self.embeddings.search(sample_query, limit=10, graph=True)
else:
raise ae
else:
raise ae
if not graph_results: if not graph_results:
logger.warning(f"No graph results for clustering user {self.user_id}") logger.warning(f"No graph results for clustering user {self.user_id}")
@@ -306,7 +379,7 @@ class TxtaiIntelligenceService:
logger.error(f"Full traceback: {traceback.format_exc()}") logger.error(f"Full traceback: {traceback.format_exc()}")
return self._fallback_clustering(min_score) return self._fallback_clustering(min_score)
def _fallback_clustering(self, min_score: float) -> List[List[int]]: async def _fallback_clustering(self, min_score: float) -> List[List[int]]:
"""Fallback clustering method when graph clustering is not available.""" """Fallback clustering method when graph clustering is not available."""
logger.info(f"Using fallback clustering for user {self.user_id}") logger.info(f"Using fallback clustering for user {self.user_id}")
@@ -318,7 +391,8 @@ class TxtaiIntelligenceService:
all_clusters = [] all_clusters = []
for query in sample_queries: for query in sample_queries:
results = self.embeddings.search(query, limit=5) # Use our search wrapper for hardening
results = await self.search(query, limit=5)
if results and results[0].get("score", 0) >= min_score: if results and results[0].get("score", 0) >= min_score:
# Create a cluster from similar results # Create a cluster from similar results
cluster = [i for i, result in enumerate(results) if result.get("score", 0) >= min_score] cluster = [i for i, result in enumerate(results) if result.get("score", 0) >= min_score]
@@ -393,9 +467,13 @@ class TxtaiIntelligenceService:
return {"status": "not_initialized", "user_id": self.user_id} return {"status": "not_initialized", "user_id": self.user_id}
try: try:
# Get count of indexed items - txtai doesn't have a direct len() method # Get count of indexed items
# We'll estimate based on available data or return a placeholder index_size = "unknown"
index_size = getattr(self.embeddings, 'count', 0) or "unknown" if hasattr(self.embeddings, 'count'):
try:
index_size = self.embeddings.count()
except:
pass
return { return {
"status": "active", "status": "active",
@@ -410,5 +488,7 @@ class TxtaiIntelligenceService:
return {"status": "error", "user_id": self.user_id, "error": str(e)} return {"status": "error", "user_id": self.user_id, "error": str(e)}
def is_initialized(self) -> bool: def is_initialized(self) -> bool:
"""Check if the service is properly initialized.""" """Check if the service is properly initialized, triggering lazy init if needed."""
if not self._initialized:
self._ensure_initialized()
return self._initialized and self.embeddings is not None return self._initialized and self.embeddings is not None

View File

@@ -369,6 +369,12 @@ def huggingface_structured_json_response(
response_text = re.sub(r'```\n?', '', response_text) response_text = re.sub(r'```\n?', '', response_text)
response_text = response_text.strip() response_text = response_text.strip()
# Fix common markdown artefacts that break JSON, e.g. lines starting with **"key":
# **"narration": "text"
# becomes:
# "narration": "text"
response_text = re.sub(r'^\s*\*\*(?=\s*")', '', response_text, flags=re.MULTILINE)
try: try:
parsed_json = json.loads(response_text) parsed_json = json.loads(response_text)
logger.info("✅ Hugging Face structured JSON response parsed from text") logger.info("✅ Hugging Face structured JSON response parsed from text")

View File

@@ -648,11 +648,13 @@ async def ai_video_generate(
# PRE-FLIGHT VALIDATION: Validate video generation before API call # PRE-FLIGHT VALIDATION: Validate video generation before API call
# MUST happen BEFORE any API calls - return immediately if validation fails # MUST happen BEFORE any API calls - return immediately if validation fails
from services.database import get_db from services.database import get_session_for_user
from services.subscription.preflight_validator import validate_video_generation_operations from services.subscription.preflight_validator import validate_video_generation_operations
from fastapi import HTTPException from fastapi import HTTPException
db = next(get_db()) db = get_session_for_user(user_id)
if not db:
raise RuntimeError("Database session unavailable for user.")
try: try:
pricing_service = PricingService(db) pricing_service = PricingService(db)
# Raises HTTPException immediately if validation fails - frontend gets immediate response # Raises HTTPException immediately if validation fails - frontend gets immediate response
@@ -762,9 +764,11 @@ def track_video_usage(
from datetime import datetime from datetime import datetime
from models.subscription_models import APIProvider, APIUsageLog, UsageSummary from models.subscription_models import APIProvider, APIUsageLog, UsageSummary
from services.database import get_db from services.database import get_session_for_user
db_track = next(get_db()) db_track = get_session_for_user(user_id)
if not db_track:
return {}
try: try:
logger.info(f"[video_gen] Starting usage tracking for user={user_id}, provider={provider}, model={model_name}") logger.info(f"[video_gen] Starting usage tracking for user={user_id}, provider={provider}, model={model_name}")
pricing_service_track = PricingService(db_track) pricing_service_track = PricingService(db_track)

View File

@@ -527,6 +527,11 @@ class APIKeyManager:
def __init__(self): def __init__(self):
self.api_keys = {} self.api_keys = {}
self._load_from_env() self._load_from_env()
def load_api_keys(self):
self.api_keys = {}
self._load_from_env()
return self.api_keys
def _load_from_env(self): def _load_from_env(self):
"""Load API keys from environment variables.""" """Load API keys from environment variables."""

View File

@@ -27,6 +27,12 @@ async def generate_facebook_persona_task(user_id: str):
try: try:
logger.info(f"Scheduled Facebook persona generation started for user {user_id}") logger.info(f"Scheduled Facebook persona generation started for user {user_id}")
# Ensure we have a valid session factory before trying to get session
from services.database import SessionLocal
if not SessionLocal:
logger.error("Database session factory not initialized")
return
db = get_db_session() db = get_db_session()
if not db: if not db:
logger.error(f"Failed to get database session for Facebook persona generation (user: {user_id})") logger.error(f"Failed to get database session for Facebook persona generation (user: {user_id})")

View File

@@ -0,0 +1,177 @@
from typing import Dict, Any, Optional
from loguru import logger
from services.product_marketing.personalization_service import PersonalizationService
from models.podcast_bible_models import (
PodcastBible,
HostPersona,
AudienceDNA,
BrandDNA,
VisualStyle,
AudioEnvironment,
ShowRules
)
class PodcastBibleService:
"""Service for generating and managing the Podcast Bible."""
def __init__(self):
self.personalization_service = PersonalizationService()
def generate_bible(self, user_id: str, project_id: str) -> PodcastBible:
"""Generate a Podcast Bible from onboarding data."""
logger.info(f"Generating Podcast Bible for user {user_id}")
try:
preferences = self.personalization_service.get_user_preferences(user_id)
writing_style = preferences.get("writing_style", {})
style_prefs = preferences.get("style_preferences", {})
target_audience = preferences.get("target_audience", {})
industry = preferences.get("industry", "General Business")
# 1. Map Host Persona
host = HostPersona(
name="Your AI Host",
background=f"Expert in {industry}",
expertise_level=writing_style.get("complexity", "Expert").capitalize(),
personality_traits=[
writing_style.get("tone", "Professional").capitalize(),
writing_style.get("engagement_level", "Informative").capitalize()
],
vocal_style=writing_style.get("voice", "Authoritative").capitalize(),
vocal_characteristics=["Clear", "Articulate", writing_style.get("voice", "Steady")],
look=f"A professional individual dressed in business-casual attire, fitting the {industry} industry aesthetic.",
catchphrases=[]
)
# 2. Map Audience DNA
audience = AudienceDNA(
expertise_level=target_audience.get("expertise_level", "Intermediate").capitalize(),
interests=target_audience.get("interests", ["Industry Trends", "Innovation"]),
pain_points=target_audience.get("pain_points", ["Staying ahead of competition", "Efficiency"]),
demographics=None
)
# 3. Map Brand DNA
brand = BrandDNA(
industry=industry,
tone=writing_style.get("tone", "Professional").capitalize(),
communication_style=writing_style.get("engagement_level", "Informative").capitalize(),
key_messages=preferences.get("brand_values", []),
competitor_context=None
)
# 4. Map Visual Style
visual = VisualStyle(
style_preset=style_prefs.get("aesthetic", "Professional Studio").capitalize(),
environment=f"A modern {industry}-themed podcast studio with professional equipment.",
lighting="Soft, warm studio lighting with subtle rim lights.",
color_palette=preferences.get("brand_colors", ["#1e293b", "#3b82f6"]),
camera_style="Dynamic mid-shots with occasional close-ups for emphasis."
)
# 5. Map Audio Environment
audio_env = AudioEnvironment(
soundscape="Pristine studio environment with deep, warm acoustics.",
music_mood=f"{writing_style.get('tone', 'Professional').capitalize()} & {writing_style.get('engagement_level', 'Upbeat').capitalize()}",
sfx_style="Modern, clean interface-inspired sounds."
)
# 6. Map Show Rules
show_rules = ShowRules(
intro_format=f"Start with a high-energy hook about the episode topic, followed by a warm welcome and an overview of the {industry} insights to be shared.",
outro_format="Summarize the key takeaways, provide a clear call to action, and sign off with a professional closing.",
interaction_tone=writing_style.get("engagement_level", "Conversational").capitalize(),
constraints=[
"Avoid overly technical jargon unless defined",
"Keep segments concise and factual",
f"Maintain a {writing_style.get('tone', 'Professional')} tone at all times"
]
)
bible = PodcastBible(
project_id=project_id,
host=host,
audience=audience,
brand=brand,
visual_style=visual,
audio_environment=audio_env,
show_rules=show_rules
)
logger.info(f"Podcast Bible generated successfully for project {project_id}")
return bible
except Exception as e:
logger.error(f"Error generating Podcast Bible: {str(e)}")
# Return a default bible if something goes wrong to ensure project creation doesn't fail
return self._get_default_bible(project_id)
def _get_default_bible(self, project_id: str) -> PodcastBible:
"""Return a sensible default Bible."""
return PodcastBible(
project_id=project_id,
host=HostPersona(
name="AI Host",
background="Industry Professional",
expertise_level="Expert",
vocal_style="Authoritative",
vocal_characteristics=["Deep", "Steady"]
),
audience=AudienceDNA(
expertise_level="Intermediate",
interests=["Industry Trends", "Technology"],
pain_points=["Staying Competitive", "Operational Efficiency"]
),
brand=BrandDNA(
industry="General Business",
tone="Professional",
communication_style="Analytical"
),
visual_style=VisualStyle(
environment="Professional modern office studio",
color_palette=["#000000", "#FFFFFF"]
),
audio_environment=AudioEnvironment(),
show_rules=ShowRules(
intro_format="Standard welcome and topic introduction.",
outro_format="Summary and sign-off."
)
)
def serialize_bible(self, bible: PodcastBible) -> str:
"""Serialize the Bible into a prompt-friendly text block."""
return f"""
<podcast_bible>
HOST PERSONA:
- Name: {bible.host.name}
- Background: {bible.host.background}
- Expertise Level: {bible.host.expertise_level}
- Personality: {', '.join(bible.host.personality_traits)}
- Vocal Style: {bible.host.vocal_style}
- Vocal Characteristics: {', '.join(bible.host.vocal_characteristics)}
- Visual Look: {bible.host.look}
TARGET AUDIENCE:
- Expertise: {bible.audience.expertise_level}
- Interests: {', '.join(bible.audience.interests)}
- Pain Points: {', '.join(bible.audience.pain_points)}
BRAND & STYLE:
- Industry: {bible.brand.industry}
- Tone: {bible.brand.tone}
- Communication Style: {bible.brand.communication_style}
- Visual Style Preset: {bible.visual_style.style_preset}
- Environment: {bible.visual_style.environment}
- Lighting: {bible.visual_style.lighting}
AUDIO ENVIRONMENT:
- Soundscape: {bible.audio_environment.soundscape}
- Music Mood: {bible.audio_environment.music_mood}
SHOW RULES & STRUCTURE:
- Intro Format: {bible.show_rules.intro_format}
- Outro Format: {bible.show_rules.outro_format}
- Interaction Tone: {bible.show_rules.interaction_tone}
- Constraints: {', '.join(bible.show_rules.constraints)}
</podcast_bible>
"""

View File

@@ -11,6 +11,7 @@ from datetime import datetime
import uuid import uuid
from models.podcast_models import PodcastProject from models.podcast_models import PodcastProject
from services.podcast_bible_service import PodcastBibleService
class PodcastService: class PodcastService:
@@ -18,6 +19,7 @@ class PodcastService:
def __init__(self, db: Session): def __init__(self, db: Session):
self.db = db self.db = db
self.bible_service = PodcastBibleService()
def create_project( def create_project(
self, self,
@@ -30,6 +32,9 @@ class PodcastService:
**kwargs **kwargs
) -> PodcastProject: ) -> PodcastProject:
"""Create a new podcast project.""" """Create a new podcast project."""
# Generate Podcast Bible automatically from onboarding data
bible = self.bible_service.generate_bible(user_id, project_id)
project = PodcastProject( project = PodcastProject(
project_id=project_id, project_id=project_id,
user_id=user_id, user_id=user_id,
@@ -37,6 +42,7 @@ class PodcastService:
duration=duration, duration=duration,
speakers=speakers, speakers=speakers,
budget_cap=budget_cap, budget_cap=budget_cap,
bible=bible.model_dump() if bible else None,
status="draft", status="draft",
current_step="create", current_step="create",
**kwargs **kwargs

View File

@@ -5,13 +5,15 @@ Pluggable task scheduler that can work with any task model.
import asyncio import asyncio
import logging import logging
import os
from typing import Dict, Any, Optional, List, Callable from typing import Dict, Any, Optional, List, Callable
from datetime import datetime from datetime import datetime, timedelta
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
from apscheduler.triggers.date import DateTrigger from apscheduler.triggers.date import DateTrigger
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import text
from .executor_interface import TaskExecutor, TaskExecutionResult from .executor_interface import TaskExecutor, TaskExecutionResult
from .task_registry import TaskRegistry from .task_registry import TaskRegistry
@@ -19,8 +21,10 @@ from .exception_handler import (
SchedulerExceptionHandler, SchedulerException, TaskExecutionError, DatabaseError, SchedulerExceptionHandler, SchedulerException, TaskExecutionError, DatabaseError,
TaskLoaderError, SchedulerConfigError TaskLoaderError, SchedulerConfigError
) )
from services.database import get_all_user_ids, get_session_for_user from services.database import get_all_user_ids, get_session_for_user
from utils.logger_utils import get_service_logger from utils.logger_utils import get_service_logger
from ..utils.user_job_store import get_user_job_store_name from ..utils.user_job_store import get_user_job_store_name
from models.scheduler_models import SchedulerEventLog from models.scheduler_models import SchedulerEventLog
from .interval_manager import determine_optimal_interval, adjust_check_interval_if_needed from .interval_manager import determine_optimal_interval, adjust_check_interval_if_needed
@@ -86,6 +90,9 @@ class TaskScheduler:
} }
) )
# Configure APScheduler to use unified logging system
self._configure_apscheduler_logging()
# Task executor registry # Task executor registry
self.registry = TaskRegistry() self.registry = TaskRegistry()
@@ -115,6 +122,21 @@ class TaskScheduler:
} }
self._running = False self._running = False
# Local Desktop App: Always leader, no advisory locks needed
self._leader_lock_key = int(os.getenv("SCHEDULER_LEADER_LOCK_KEY", "84321017"))
self._leadership_check_interval_seconds = int(os.getenv("SCHEDULER_LEADERSHIP_CHECK_INTERVAL", "15"))
self._leader_session = None
self._is_leader = True # Always leader in local desktop app
self._execution_enabled = True # Always enabled
self._leader_since = datetime.utcnow().isoformat()
self._last_leadership_check = None
self._last_leadership_error = None
# Execution lease registry (prevents duplicate redispatch across check cycles)
self._task_leases: Dict[str, str] = {}
self._task_lease_ttl_seconds = int(os.getenv("SCHEDULER_TASK_LEASE_TTL_SECONDS", "900"))
def _get_trigger_for_interval(self, interval_minutes: int): def _get_trigger_for_interval(self, interval_minutes: int):
""" """
@@ -153,6 +175,144 @@ class TaskScheduler:
self.registry.register(task_type, executor, task_loader) self.registry.register(task_type, executor, task_loader)
logger.info(f"Registered executor for task type: {task_type}") logger.info(f"Registered executor for task type: {task_type}")
def _configure_apscheduler_logging(self):
"""Configure APScheduler to use unified logging system."""
import logging
# Get APScheduler loggers and redirect them to unified logging
apscheduler_logger = logging.getLogger("apscheduler")
apscheduler_scheduler_logger = logging.getLogger("apscheduler.scheduler")
apscheduler_executors_logger = logging.getLogger("apscheduler.executors")
apscheduler_jobstores_logger = logging.getLogger("apscheduler.jobstores")
# Create a custom handler that redirects to unified logger
class APSchedulerUnifiedHandler(logging.Handler):
def __init__(self, service_logger):
super().__init__()
self.service_logger = service_logger
def emit(self, record):
try:
# Format the message
msg = self.format(record)
# Map APScheduler log levels to unified logger
if record.levelno >= logging.ERROR:
self.service_logger.error(f"[APScheduler] {msg}")
elif record.levelno >= logging.WARNING:
self.service_logger.warning(f"[APScheduler] {msg}")
elif record.levelno >= logging.INFO:
self.service_logger.info(f"[APScheduler] {msg}")
else:
self.service_logger.debug(f"[APScheduler] {msg}")
except Exception:
# Don't let logging errors break the scheduler
pass
# Create and add the handler
unified_handler = APSchedulerUnifiedHandler(logger)
unified_handler.setLevel(logging.DEBUG)
# Add handler to all APScheduler loggers
apscheduler_logger.addHandler(unified_handler)
apscheduler_scheduler_logger.addHandler(unified_handler)
apscheduler_executors_logger.addHandler(unified_handler)
apscheduler_jobstores_logger.addHandler(unified_handler)
# Set levels to capture all logs
apscheduler_logger.setLevel(logging.DEBUG)
apscheduler_scheduler_logger.setLevel(logging.DEBUG)
apscheduler_executors_logger.setLevel(logging.DEBUG)
apscheduler_jobstores_logger.setLevel(logging.DEBUG)
# Prevent propagation to avoid duplicate logs
apscheduler_logger.propagate = False
apscheduler_scheduler_logger.propagate = False
apscheduler_executors_logger.propagate = False
apscheduler_jobstores_logger.propagate = False
logger.info("APScheduler logging configured to use unified logging system")
def _scheduler_identity(self) -> str:
return f"{os.getenv('HOSTNAME', 'local')}-{os.getpid()}"
def _acquire_leadership(self) -> bool:
"""Always return True for local desktop app (no HA needed)."""
self._is_leader = True
self._execution_enabled = True
if not self._leader_since:
self._leader_since = datetime.utcnow().isoformat()
self._last_leadership_check = datetime.utcnow().isoformat()
return True
def _release_leadership(self):
"""No-op for local desktop app."""
pass
def _sync_check_due_tasks_job(self):
"""Ensure check_due_tasks job exists only for leader."""
job = self.scheduler.get_job('check_due_tasks')
if self._is_leader and self._execution_enabled:
if job is None:
self.scheduler.add_job(
self._check_and_execute_due_tasks,
trigger=self._get_trigger_for_interval(self.current_check_interval_minutes),
id='check_due_tasks',
replace_existing=True
)
else:
if job is not None:
self.scheduler.remove_job('check_due_tasks')
async def _leadership_tick(self):
"""Periodic leadership check/renewal (Stub for local)."""
if not self._running:
return
self._acquire_leadership()
self._sync_check_due_tasks_job()
def _acquire_task_lease(self, task_key: str) -> bool:
"""Acquire in-memory lease for a task key if available/expired."""
now = datetime.utcnow()
expiry_str = self._task_leases.get(task_key)
if expiry_str:
try:
expiry = datetime.fromisoformat(expiry_str)
if expiry > now:
return False
except Exception:
# Corrupted lease value: overwrite safely
pass
expiry = now + timedelta(seconds=self._task_lease_ttl_seconds)
self._task_leases[task_key] = expiry.isoformat()
return True
def _release_task_lease(self, task_key: str):
"""Release lease for task key."""
if task_key in self._task_leases:
del self._task_leases[task_key]
def _is_task_leased(self, task_key: str) -> bool:
"""Check whether task key is currently leased and not expired."""
expiry_str = self._task_leases.get(task_key)
if not expiry_str:
return False
try:
expiry = datetime.fromisoformat(expiry_str)
if expiry > datetime.utcnow():
return True
except Exception:
pass
# Expired/corrupt lease gets cleaned up lazily
self._release_task_lease(task_key)
return False
async def start(self): async def start(self):
"""Start the scheduler with intelligent interval adjustment.""" """Start the scheduler with intelligent interval adjustment."""
if self._running: if self._running:
@@ -168,16 +328,21 @@ class TaskScheduler:
) )
self.current_check_interval_minutes = initial_interval self.current_check_interval_minutes = initial_interval
# Add periodic job to check for due tasks
self.scheduler.add_job(
self._check_and_execute_due_tasks,
trigger=self._get_trigger_for_interval(initial_interval),
id='check_due_tasks',
replace_existing=True
)
self.scheduler.start() self.scheduler.start()
self._running = True self._running = True
# Leadership monitor runs on all replicas; only leader executes due-task loop.
self.scheduler.add_job(
self._leadership_tick,
trigger=IntervalTrigger(seconds=self._leadership_check_interval_seconds),
id='leadership_monitor',
replace_existing=True,
max_instances=1,
coalesce=True
)
# Initial leader election
await self._leadership_tick()
# Check for and execute any missed jobs that are still within grace period # Check for and execute any missed jobs that are still within grace period
await self._execute_missed_jobs() await self._execute_missed_jobs()
@@ -206,7 +371,7 @@ class TaskScheduler:
registered_types = self.registry.get_registered_types() registered_types = self.registry.get_registered_types()
active_strategies = self.stats.get('active_strategies_count', 0) active_strategies = self.stats.get('active_strategies_count', 0)
# Count OAuth token monitoring tasks from database (recurring weekly tasks) # Count tasks per user (Multi-tenant SQLite)
oauth_tasks_count = 0 oauth_tasks_count = 0
website_analysis_tasks_count = 0 website_analysis_tasks_count = 0
platform_insights_tasks_count = 0 platform_insights_tasks_count = 0
@@ -323,126 +488,6 @@ class TaskScheduler:
startup_lines.append(f"{prefix} Job: {job.id} | Trigger: {trigger_type} | Next Run: {next_run}{user_context}") startup_lines.append(f"{prefix} Job: {job.id} | Trigger: {trigger_type} | Next Run: {next_run}{user_context}")
# Add OAuth token monitoring tasks details
# Show ALL OAuth tasks (active and inactive) for complete visibility
if total_oauth_tasks > 0:
try:
user_ids = get_all_user_ids()
for user_id in user_ids:
try:
db = get_session_for_user(user_id)
if db:
from models.oauth_token_monitoring_models import OAuthTokenMonitoringTask
# Get ALL tasks for this user
oauth_tasks = db.query(OAuthTokenMonitoringTask).all()
for idx, task in enumerate(oauth_tasks):
is_last = idx == len(oauth_tasks) - 1 and website_analysis_tasks_count == 0 and platform_insights_tasks_count == 0 and len(all_jobs) == 0 and user_id == user_ids[-1]
prefix = " ├─" # Simplified prefix logic for multi-user list
try:
user_job_store = get_user_job_store_name(task.user_id, db)
if user_job_store == 'default':
logger.debug(
f"[Scheduler] Job store extraction returned 'default' for user {task.user_id}. "
f"This may indicate no onboarding data or website URL not found."
)
except Exception as e:
logger.warning(
f"[Scheduler] Could not extract job store name for user {task.user_id}: {e}. "
f"Using 'default'. Error type: {type(e).__name__}"
)
user_job_store = 'default'
next_check = task.next_check.isoformat() if task.next_check else 'Not scheduled'
# Include status in the log line for visibility
status_indicator = "" if task.status == 'active' else f"[{task.status}]"
startup_lines.append(
f"{prefix} Job: oauth_token_monitoring_{task.platform}_{task.user_id} | "
f"Trigger: CronTrigger (Weekly) | Next Run: {next_check} | "
f"User: {task.user_id} | Store: {user_job_store} | Platform: {task.platform} {status_indicator}"
)
db.close()
except Exception as e:
logger.warning(f"Error checking OAuth tasks for user {user_id}: {e}")
except Exception as e:
logger.debug(f"Could not get OAuth token monitoring task details: {e}")
# Add website analysis tasks details
if website_analysis_tasks_count > 0:
try:
user_ids = get_all_user_ids()
for user_id in user_ids:
try:
db = get_session_for_user(user_id)
if db:
from models.website_analysis_monitoring_models import WebsiteAnalysisTask
website_analysis_tasks = db.query(WebsiteAnalysisTask).all()
for idx, task in enumerate(website_analysis_tasks):
is_last = idx == len(website_analysis_tasks) - 1 and platform_insights_tasks_count == 0 and len(all_jobs) == 0 and total_oauth_tasks == 0 and user_id == user_ids[-1]
prefix = " ├─" # Simplified
try:
user_job_store = get_user_job_store_name(task.user_id, db)
except Exception as e:
logger.debug(f"Could not extract job store name for user {task.user_id}: {e}")
user_job_store = 'default'
next_check = task.next_check.isoformat() if task.next_check else 'Not scheduled'
frequency = f"Every {task.frequency_days} days"
task_type_label = "User Website" if task.task_type == 'user_website' else "Competitor"
status_indicator = "" if task.status == 'active' else f"[{task.status}]"
website_display = task.website_url[:50] + "..." if task.website_url and len(task.website_url) > 50 else (task.website_url or 'N/A')
startup_lines.append(
f"{prefix} Job: website_analysis_{task.task_type}_{task.user_id}_{task.id} | "
f"Trigger: CronTrigger ({frequency}) | Next Run: {next_check} | "
f"User: {task.user_id} | Store: {user_job_store} | Type: {task_type_label} | URL: {website_display} {status_indicator}"
)
db.close()
except Exception as e:
logger.warning(f"Error checking website analysis tasks for user {user_id}: {e}")
except Exception as e:
logger.debug(f"Could not get website analysis task details: {e}")
# Add platform insights tasks details
if platform_insights_tasks_count > 0:
try:
user_ids = get_all_user_ids()
for user_id in user_ids:
try:
db = get_session_for_user(user_id)
if db:
from models.platform_insights_monitoring_models import PlatformInsightsTask
platform_insights_tasks = db.query(PlatformInsightsTask).all()
for idx, task in enumerate(platform_insights_tasks):
is_last = idx == len(platform_insights_tasks) - 1 and len(all_jobs) == 0 and total_oauth_tasks == 0 and website_analysis_tasks_count == 0 and user_id == user_ids[-1]
prefix = " ├─" # Simplified
try:
user_job_store = get_user_job_store_name(task.user_id, db)
except Exception as e:
logger.debug(f"Could not extract job store name for user {task.user_id}: {e}")
user_job_store = 'default'
next_check = task.next_check.isoformat() if task.next_check else 'Not scheduled'
platform_label = task.platform.upper() if task.platform else 'Unknown'
site_display = task.site_url[:50] + "..." if task.site_url and len(task.site_url) > 50 else (task.site_url or 'N/A')
status_indicator = "" if task.status == 'active' else f"[{task.status}]"
startup_lines.append(
f"{prefix} Job: platform_insights_{task.platform}_{task.user_id} | "
f"Trigger: CronTrigger (Weekly) | Next Run: {next_check} | "
f"User: {task.user_id} | Store: {user_job_store} | Platform: {platform_label} | Site: {site_display} {status_indicator}"
)
db.close()
except Exception as e:
logger.warning(f"Error checking platform insights tasks for user {user_id}: {e}")
except Exception as e:
logger.debug(f"Could not get platform insights task details: {e}")
# Add Advertools tasks details # Add Advertools tasks details
if advertools_tasks_count > 0: if advertools_tasks_count > 0:
try: try:
@@ -518,7 +563,15 @@ class TaskScheduler:
# Get final job count before shutdown # Get final job count before shutdown
all_jobs_before = self.scheduler.get_jobs() all_jobs_before = self.scheduler.get_jobs()
# Release leadership lock and stop leadership monitor
try:
if self.scheduler.get_job('leadership_monitor') is not None:
self.scheduler.remove_job('leadership_monitor')
except Exception:
pass
self._release_leadership()
# Shutdown scheduler # Shutdown scheduler
self.scheduler.shutdown(wait=True) self.scheduler.shutdown(wait=True)
self._running = False self._running = False
@@ -569,6 +622,10 @@ class TaskScheduler:
Main scheduler loop: check for due tasks and execute them. Main scheduler loop: check for due tasks and execute them.
This runs periodically with intelligent interval adjustment based on active strategies. This runs periodically with intelligent interval adjustment based on active strategies.
""" """
if not self._execution_enabled or not self._is_leader:
logger.debug("[Scheduler] Skipping due-task loop on standby replica")
return
await check_and_execute_due_tasks(self) await check_and_execute_due_tasks(self)
async def _adjust_check_interval_if_needed(self, db: Session): async def _adjust_check_interval_if_needed(self, db: Session):
@@ -614,309 +671,156 @@ class TaskScheduler:
except Exception as e: except Exception as e:
logger.warning(f"[Scheduler] Error checking for missed jobs: {e}") logger.warning(f"[Scheduler] Error checking for missed jobs: {e}")
async def trigger_interval_adjustment(self):
"""
Trigger immediate interval adjustment check.
This should be called when a strategy is activated or deactivated
to immediately adjust the scheduler interval based on current active strategies.
"""
if not self._running:
logger.debug("Scheduler not running, skipping interval adjustment")
return
try:
# Multi-tenant aware adjustment (iterates all users internally)
await adjust_check_interval_if_needed(self)
except Exception as e:
logger.warning(f"Error triggering interval adjustment: {e}")
async def _validate_and_rebuild_cumulative_stats(self): async def _validate_and_rebuild_cumulative_stats(self):
""" """
Validate cumulative stats on scheduler startup and rebuild if needed. Validate and rebuild cumulative stats if needed.
This ensures cumulative stats are accurate after restarts. Currently a placeholder for future implementation.
NOTE: Disabled in multi-tenant mode as there is no global database for cumulative stats.
TODO: Implement per-user cumulative stats or a global admin database.
""" """
logger.info("[Scheduler] Cumulative stats validation skipped (multi-tenant mode)") pass
return
async def _process_task_type(
async def _process_task_type(self, task_type: str, db: Session, cycle_summary: Dict[str, Any] = None, user_id: str = None) -> Optional[Dict[str, Any]]: self,
""" task_type: str,
Process due tasks for a specific task type. db: Session,
cycle_summary: Dict[str, Any],
Returns: user_id: Optional[str] = None
Summary dict with 'found', 'executed', 'failed' counts, or None if no tasks ) -> Dict[str, int]:
""" summary = {"found": 0, "executed": 0, "failed": 0}
summary = {'found': 0, 'executed': 0, 'failed': 0}
try: try:
# Get task loader for this type task_loader = self.registry.get_task_loader(task_type)
try:
task_loader = self.registry.get_task_loader(task_type)
except Exception as e:
error = TaskLoaderError(
message=f"Failed to get task loader for type {task_type}: {str(e)}",
task_type=task_type,
original_error=e
)
self.exception_handler.handle_exception(error)
return None
# Load due tasks (with error handling)
try:
due_tasks = task_loader(db)
except Exception as e:
error = TaskLoaderError(
message=f"Failed to load due tasks for type {task_type}: {str(e)}",
task_type=task_type,
original_error=e
)
self.exception_handler.handle_exception(error)
return None
if not due_tasks:
return None
summary['found'] = len(due_tasks)
self.stats['tasks_found'] += len(due_tasks)
# Execute tasks (with concurrency limit)
execution_tasks = []
skipped_count = 0
for task in due_tasks:
if len(self.active_executions) >= self.max_concurrent_executions:
skipped_count = len(due_tasks) - len(execution_tasks)
logger.warning(
f"[Scheduler] ⚠️ Max concurrent executions reached ({self.max_concurrent_executions}), "
f"skipping {skipped_count} tasks for {task_type}"
)
break
# Execute task asynchronously
# Note: Each task gets its own database session to prevent concurrent access issues
execution_task = asyncio.create_task(
execute_task_async(self, task_type, task, summary, user_id=user_id)
)
task_id = f"{task_type}_{getattr(task, 'id', id(task))}"
self.active_executions[task_id] = execution_task
execution_tasks.append(execution_task)
# Wait for executions to complete (with timeout per task)
if execution_tasks:
await asyncio.wait(execution_tasks, timeout=300)
return summary
except Exception as e: except Exception as e:
error = TaskLoaderError( error = TaskLoaderError(
message=f"Error processing task type {task_type}: {str(e)}", message=f"Failed to get task loader for type {task_type}: {str(e)}",
task_type=task_type, user_id=user_id,
context={"task_type": task_type},
original_error=e original_error=e
) )
self.exception_handler.handle_exception(error) self.exception_handler.handle_exception(error)
self.stats["tasks_failed"] += 1
return summary return summary
try:
def _update_user_stats(self, user_id: Optional[int], success: bool): tasks = task_loader(db)
""" if not tasks:
Update per-user statistics for user isolation tracking. return summary
Args: summary["found"] = len(tasks)
user_id: User ID (None if user context not available) max_concurrent = self.max_concurrent_executions
success: Whether task execution was successful
""" for task in tasks:
if user_id is None: task_id = getattr(task, "id", None)
lease_key = f"{task_type}_{task_id or id(task)}"
if self._is_task_leased(lease_key):
continue
if len(self.active_executions) >= max_concurrent:
break
if not self._acquire_task_lease(lease_key):
continue
execution_task = asyncio.create_task(
execute_task_async(
self,
task_type,
task,
summary,
execution_source="scheduler",
user_id=user_id,
)
)
self.active_executions[lease_key] = execution_task
cycle_summary.setdefault("tasks_found_by_type", {})
cycle_summary.setdefault("tasks_executed_by_type", {})
cycle_summary.setdefault("tasks_failed_by_type", {})
cycle_summary["tasks_found_by_type"][task_type] = (
cycle_summary["tasks_found_by_type"].get(task_type, 0)
+ summary["found"]
)
cycle_summary["tasks_executed_by_type"][task_type] = (
cycle_summary["tasks_executed_by_type"].get(task_type, 0)
+ summary["executed"]
)
cycle_summary["tasks_failed_by_type"][task_type] = (
cycle_summary["tasks_failed_by_type"].get(task_type, 0)
+ summary["failed"]
)
return summary
except Exception as e:
error = TaskLoaderError(
message=f"Error processing task type {task_type}: {str(e)}",
user_id=user_id,
context={"task_type": task_type},
original_error=e
)
self.exception_handler.handle_exception(error)
self.stats["tasks_failed"] += 1
return summary
def _update_user_stats(self, user_id: Optional[str], success: bool):
if not user_id:
return return
per_user = self.stats.setdefault("per_user_stats", {})
if user_id not in self.stats['per_user_stats']: user_stats = per_user.setdefault(
self.stats['per_user_stats'][user_id] = { user_id,
'executed': 0, {
'failed': 0, "tasks_executed": 0,
'success_rate': 0.0 "tasks_failed": 0,
} "last_update": None,
},
user_stats = self.stats['per_user_stats'][user_id] )
if success: if success:
user_stats['executed'] += 1 user_stats["tasks_executed"] += 1
else: else:
user_stats['failed'] += 1 user_stats["tasks_failed"] += 1
user_stats["last_update"] = datetime.utcnow().isoformat()
# Calculate success rate
total = user_stats['executed'] + user_stats['failed'] async def _schedule_retry(self, task: Any, retry_delay: int):
if total > 0: try:
user_stats['success_rate'] = (user_stats['executed'] / total) * 100.0 task_id = getattr(task, "id", None)
logger.warning(
async def _schedule_retry(self, task: Any, delay_seconds: int): f"[Scheduler] Retry requested for task {task_id} in {retry_delay}s, "
"""Schedule a retry for a failed task.""" f"using loader-based retry semantics."
# This would update the task's next_execution time )
# For now, just log - could be enhanced to update next_execution except Exception:
logger.debug(f"Scheduling retry for task in {delay_seconds}s") pass
def get_stats(self, user_id: Optional[int] = None) -> Dict[str, Any]:
"""
Get scheduler statistics with optional user filtering.
Args:
user_id: Optional user ID to filter statistics for specific user
Returns:
Dictionary with scheduler statistics
"""
base_stats = {
**{k: v for k, v in self.stats.items() if k not in ['per_user_stats']},
'active_executions': len(self.active_executions),
'registered_types': self.registry.get_registered_types(),
'running': self._running,
'check_interval_minutes': self.current_check_interval_minutes,
'min_check_interval_minutes': self.min_check_interval_minutes,
'max_check_interval_minutes': self.max_check_interval_minutes,
'intelligent_scheduling': True
}
# Include per-user stats (all users or filtered)
if user_id is not None:
if user_id in self.stats['per_user_stats']:
base_stats['user_stats'] = self.stats['per_user_stats'][user_id]
else:
base_stats['user_stats'] = {
'executed': 0,
'failed': 0,
'success_rate': 0.0
}
else:
# Include all per-user stats (for admin/debugging)
base_stats['per_user_stats'] = self.stats['per_user_stats']
return base_stats
def schedule_one_time_task( def schedule_one_time_task(
self, self,
func: Callable, func: Callable,
run_date: datetime, run_date: datetime,
job_id: str, job_id: str,
args: tuple = (), kwargs: Optional[Dict[str, Any]] = None,
kwargs: Dict[str, Any] = None,
replace_existing: bool = True replace_existing: bool = True
) -> str: ) -> str:
""" """
Schedule a one-time task to run at a specific datetime. Schedule a one-time task execution.
Args: Args:
func: Async function to execute func: Function to execute
run_date: Datetime when the task should run (must be timezone-aware UTC) run_date: Date/time to run the task
job_id: Unique identifier for this job job_id: Unique job ID
args: Positional arguments to pass to func kwargs: Keyword arguments for the function
kwargs: Keyword arguments to pass to func replace_existing: Whether to replace existing job with same ID
replace_existing: If True, replace existing job with same ID
Returns: Returns:
Job ID Job ID
""" """
if not self._running:
logger.warning(
f"Scheduler not running, but scheduling job {job_id} anyway. "
"APScheduler will start automatically when needed."
)
try: try:
# Ensure run_date is timezone-aware (UTC)
if run_date.tzinfo is None:
from datetime import timezone
run_date = run_date.replace(tzinfo=timezone.utc)
logger.debug(f"Added UTC timezone to run_date: {run_date}")
self.scheduler.add_job( self.scheduler.add_job(
func, func,
trigger=DateTrigger(run_date=run_date), trigger=DateTrigger(run_date=run_date),
args=args,
kwargs=kwargs or {},
id=job_id, id=job_id,
kwargs=kwargs or {},
replace_existing=replace_existing, replace_existing=replace_existing,
misfire_grace_time=3600 # 1 hour grace period for missed jobs misfire_grace_time=3600 # 1 hour grace period
) )
logger.info(f"Scheduled one-time task {job_id} at {run_date}")
# Get updated job count
all_jobs = self.scheduler.get_jobs()
one_time_jobs = [j for j in all_jobs if j.id != 'check_due_tasks']
# Extract user_id from kwargs if available for logging and job store
user_id = kwargs.get('user_id', None) if kwargs else None
func_name = func.__name__ if hasattr(func, '__name__') else str(func)
# Get job store name for user (if user_id provided)
job_store_name = 'default'
if user_id:
try:
db = get_session_for_user(user_id)
if db:
job_store_name = get_user_job_store_name(user_id, db)
db.close()
except Exception as e:
logger.warning(f"Could not determine job store for user {user_id}: {e}")
# Note: APScheduler doesn't support dynamic job store creation
# We use 'default' for all jobs but log the user's job store name for debugging
# The actual user isolation is handled through task filtering by user_id
# Log detailed one-time task scheduling information (use WARNING level for visibility)
log_message = (
f"[Scheduler] 📅 Scheduled One-Time Task\n"
f" ├─ Job ID: {job_id}\n"
f" ├─ Function: {func_name}\n"
f" ├─ User ID: {user_id or 'system'}\n"
f" ├─ Job Store: {job_store_name} (user context)\n"
f" ├─ Scheduled For: {run_date}\n"
f" ├─ Replace Existing: {replace_existing}\n"
f" ├─ Total One-Time Jobs: {len(one_time_jobs)}\n"
f" └─ Total Scheduled Jobs: {len(all_jobs)}"
)
logger.warning(log_message)
# Log job scheduling to event log for dashboard
if user_id:
try:
event_db = get_session_for_user(user_id)
if event_db:
event_log = SchedulerEventLog(
event_type='job_scheduled',
event_date=datetime.utcnow(),
job_id=job_id,
job_type='one_time',
user_id=user_id,
event_data={
'function_name': func_name,
'job_store': job_store_name,
'scheduled_for': run_date.isoformat(),
'replace_existing': replace_existing
}
)
event_db.add(event_log)
event_db.commit()
event_db.close()
except Exception as e:
logger.debug(f"Failed to log job scheduling event: {e}")
return job_id return job_id
except Exception as e: except Exception as e:
logger.error(f"Failed to schedule one-time task {job_id}: {e}") logger.error(f"Failed to schedule one-time task {job_id}: {e}")
raise raise
def is_running(self) -> bool:
"""Check if scheduler is running."""
return self._running
async def execute_task_by_type(self, task_type: str, user_id: str, payload: Dict[str, Any]):
"""
Execute a task by type and payload immediately.
Used for one-time tasks triggered by system events.
"""
from collections import namedtuple
TaskStub = namedtuple('TaskStub', ['user_id', 'payload', 'id'])
task_stub = TaskStub(user_id=user_id, payload=payload, id=f"manual_{datetime.utcnow().timestamp()}")
await execute_task_async(self, task_type, task_stub, execution_source="manual")

View File

@@ -67,6 +67,77 @@ class StoryImageGenerationService:
clean_title = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in scene_title[:30]) clean_title = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in scene_title[:30])
unique_id = str(uuid.uuid4())[:8] unique_id = str(uuid.uuid4())[:8]
return f"scene_{scene_number}_{clean_title}_{unique_id}.png" return f"scene_{scene_number}_{clean_title}_{unique_id}.png"
def _refine_image_prompt_with_bible(
self,
image_prompt: str,
scene: Dict[str, Any],
anime_bible: Optional[Dict[str, Any]] = None,
) -> str:
"""
Lightweight image prompt refinement using the anime story bible.
Takes the existing scene image_prompt and enriches it with visual_style,
world, and cast hints from the bible. This is deterministic and avoids
extra LLM calls.
"""
if not image_prompt or not isinstance(image_prompt, str):
return image_prompt
if not anime_bible or not isinstance(anime_bible, dict):
return image_prompt
visual_style = anime_bible.get("visual_style") or {}
world = anime_bible.get("world") or {}
main_cast = anime_bible.get("main_cast") or []
parts: List[str] = []
style_preset = visual_style.get("style_preset")
if style_preset:
parts.append(f"{style_preset} anime illustration style")
camera_style = visual_style.get("camera_style")
if camera_style:
parts.append(f"framing and camera style: {camera_style}")
color_mood = visual_style.get("color_mood")
if color_mood:
parts.append(f"color mood: {color_mood}")
lighting = visual_style.get("lighting")
if lighting:
parts.append(f"lighting: {lighting}")
line_style = visual_style.get("line_style")
if line_style:
parts.append(f"line style: {line_style}")
extra_tags = visual_style.get("extra_tags") or []
if isinstance(extra_tags, (list, tuple)):
extra_text = ", ".join(str(tag) for tag in extra_tags[:6] if tag)
if extra_text:
parts.append(extra_text)
setting = world.get("setting") if isinstance(world, dict) else None
if setting:
parts.append(f"world setting: {setting}")
if isinstance(main_cast, list):
names = [
c.get("name")
for c in main_cast
if isinstance(c, dict) and c.get("name")
]
if names:
joined = ", ".join(names[:4])
parts.append(f"keep character designs consistent for: {joined}")
if not parts:
return image_prompt
suffix = ", " + ", ".join(parts)
return image_prompt.strip() + suffix
def generate_scene_image( def generate_scene_image(
self, self,
@@ -75,7 +146,8 @@ class StoryImageGenerationService:
provider: Optional[str] = None, provider: Optional[str] = None,
width: int = 1024, width: int = 1024,
height: int = 1024, height: int = 1024,
model: Optional[str] = None model: Optional[str] = None,
anime_bible: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Generate an image for a single story scene. Generate an image for a single story scene.
@@ -94,6 +166,16 @@ class StoryImageGenerationService:
scene_number = scene.get("scene_number", 0) scene_number = scene.get("scene_number", 0)
scene_title = scene.get("title", "Untitled") scene_title = scene.get("title", "Untitled")
image_prompt = scene.get("image_prompt", "") image_prompt = scene.get("image_prompt", "")
if anime_bible:
try:
image_prompt = self._refine_image_prompt_with_bible(
image_prompt=image_prompt,
scene=scene,
anime_bible=anime_bible,
)
except Exception as e:
logger.warning(f"[StoryImageGeneration] Failed to refine image prompt with bible: {e}")
if not image_prompt: if not image_prompt:
raise ValueError(f"Scene {scene_number} ({scene_title}) has no image_prompt") raise ValueError(f"Scene {scene_number} ({scene_title}) has no image_prompt")
@@ -156,7 +238,8 @@ class StoryImageGenerationService:
height: int = 1024, height: int = 1024,
model: Optional[str] = None, model: Optional[str] = None,
progress_callback: Optional[callable] = None, progress_callback: Optional[callable] = None,
db: Optional[Session] = None db: Optional[Session] = None,
anime_bible: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Generate images for multiple story scenes. Generate images for multiple story scenes.
@@ -192,7 +275,7 @@ class StoryImageGenerationService:
width=width, width=width,
height=height, height=height,
model=model, model=model,
db=db anime_bible=anime_bible,
) )
image_results.append(image_result) image_results.append(image_result)
@@ -295,4 +378,3 @@ class StoryImageGenerationService:
except Exception as e: except Exception as e:
logger.error(f"[StoryImageGeneration] Error regenerating image for scene {scene_number}: {e}") logger.error(f"[StoryImageGeneration] Error regenerating image for scene {scene_number}: {e}")
raise RuntimeError(f"Failed to regenerate image for scene {scene_number}: {str(e)}") from e raise RuntimeError(f"Failed to regenerate image for scene {scene_number}: {str(e)}") from e

View File

@@ -57,6 +57,7 @@ class StoryOutlineMixin(StoryServiceBase):
ending_preference: str, ending_preference: str,
user_id: str, user_id: str,
use_structured_output: bool = True, use_structured_output: bool = True,
include_anime_bible: bool = False,
) -> Any: ) -> Any:
"""Generate a story outline with optional structured JSON output.""" """Generate a story outline with optional structured JSON output."""
persona_prompt = self.build_persona_prompt( persona_prompt = self.build_persona_prompt(

View File

@@ -145,20 +145,45 @@ Write ONLY the premise sentence(s). Do not write anything else.
"reasoning", "reasoning",
], ],
}, },
"minItems": 1,
"maxItems": 1,
}
},
"required": ["options"],
}
def _build_idea_enhance_schema(self) -> Dict[str, Any]:
return {
"type": "object",
"properties": {
"suggestions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"idea": {"type": "string"},
"whats_missing": {"type": "string"},
"why_choose": {"type": "string"},
},
"required": ["idea", "whats_missing", "why_choose"],
},
"minItems": 3, "minItems": 3,
"maxItems": 3, "maxItems": 3,
} }
}, },
"required": ["options"], "required": ["suggestions"],
} }
def generate_story_setup_options( def generate_story_setup_options(
self, self,
*, *,
story_idea: str, story_idea: str,
story_mode: str | None,
story_template: str | None,
brand_context: Dict[str, Any] | None,
user_id: str, user_id: str,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Generate 3 story setup options from a user's story idea.""" """Generate a single story setup option from a user's story idea."""
suggested_writing_styles = ['Formal', 'Casual', 'Poetic', 'Humorous', 'Academic', 'Journalistic', 'Narrative'] suggested_writing_styles = ['Formal', 'Casual', 'Poetic', 'Humorous', 'Academic', 'Journalistic', 'Narrative']
suggested_story_tones = ['Dark', 'Uplifting', 'Suspenseful', 'Whimsical', 'Melancholic', 'Mysterious', 'Romantic', 'Adventurous'] suggested_story_tones = ['Dark', 'Uplifting', 'Suspenseful', 'Whimsical', 'Melancholic', 'Mysterious', 'Romantic', 'Adventurous']
@@ -167,12 +192,59 @@ Write ONLY the premise sentence(s). Do not write anything else.
suggested_content_ratings = ['G', 'PG', 'PG-13', 'R'] suggested_content_ratings = ['G', 'PG', 'PG-13', 'R']
suggested_ending_preferences = ['Happy', 'Tragic', 'Cliffhanger', 'Twist', 'Open-ended', 'Bittersweet'] suggested_ending_preferences = ['Happy', 'Tragic', 'Cliffhanger', 'Twist', 'Open-ended', 'Bittersweet']
mode_label = None
if story_mode == "marketing":
mode_label = "Non-fiction marketing story (brand or product campaign)"
elif story_mode == "pure":
mode_label = "Fiction story"
template_label = None
if story_template == "product_story":
template_label = "Product Story"
elif story_template == "brand_manifesto":
template_label = "Brand Manifesto"
elif story_template == "founder_story":
template_label = "Founder Story"
elif story_template == "customer_story":
template_label = "Customer Story"
elif story_template == "short_fiction":
template_label = "Short Fiction"
elif story_template == "long_fiction":
template_label = "Long Fiction"
elif story_template == "anime_fiction":
template_label = "Anime Fiction"
elif story_template == "experimental_fiction":
template_label = "Experimental Fiction"
brand_name = None
writing_tone = None
audience_description = None
if isinstance(brand_context, dict):
brand_name = brand_context.get("brand_name")
writing_tone = brand_context.get("writing_tone")
target_audience = brand_context.get("target_audience")
if isinstance(target_audience, dict):
audience_description = target_audience.get("description") or target_audience.get("summary")
elif isinstance(target_audience, str):
audience_description = target_audience
setup_prompt = f"""\ setup_prompt = f"""\
You are an expert story writer and creative writing assistant. A user has provided the following story idea or information: You are an expert story writer and creative writing assistant.
{"This is a " + mode_label + "." if mode_label else ""}
{("The user selected the template: " + template_label + ".") if template_label else ""}
The story should stay consistent with the brand and audience context below when relevant:
- Brand name or site: {brand_name or "Not specified"}
- Headline/overall writing tone: {writing_tone or "Not specified"}
- Audience description: {audience_description or "Not specified"}
The user has provided the following story idea or information:
{story_idea} {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. Based on this story idea, generate exactly 1 well-thought-out story setup option. The setup should be CREATIVE, PERSONALIZED, and perfectly tailored to the user's specific story idea.
**CRITICAL - Creative Freedom:** **CRITICAL - Creative Freedom:**
- You have COMPLETE FREEDOM to craft personalized values that best fit the user's story idea - You have COMPLETE FREEDOM to craft personalized values that best fit the user's story idea
@@ -183,7 +255,7 @@ Based on this story idea, generate exactly 3 different, well-thought-out story s
- Narrative POV: "Second Person (You)" or "Omniscient Narrator as Guide" (not just standard options) - 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 - The goal is to create the PERFECT setup for THIS specific story, not to fit into generic categories
Each option should: The setup should:
1. Have a unique and creative persona that fits the story idea perfectly 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 2. Define a compelling story setting that brings the idea to life
3. Describe interesting and engaging characters 3. Describe interesting and engaging characters
@@ -212,23 +284,23 @@ Each option should:
**Remember:** These are ONLY suggestions. If a custom value better serves the story idea, CREATE IT! **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. Return exactly 1 option as a JSON array with a single object in "options". The object must include a "premise" field with the story premise.
""" """
setup_schema = self._build_setup_schema() setup_schema = self._build_setup_schema()
try: try:
logger.info(f"[StoryWriter] Generating story setup options for user {user_id}") logger.info(f"[StoryWriter] Generating story setup option for user {user_id}")
response = self.load_json_response( response = self.load_json_response(
llm_text_gen(prompt=setup_prompt, json_struct=setup_schema, user_id=user_id) llm_text_gen(prompt=setup_prompt, json_struct=setup_schema, user_id=user_id)
) )
options = response.get("options", []) options = response.get("options", [])
if len(options) != 3: if len(options) != 1:
logger.warning(f"[StoryWriter] Expected 3 options but got {len(options)}, correcting count") logger.warning(f"[StoryWriter] Expected 1 option but got {len(options)}, correcting count")
if len(options) < 3: if len(options) < 1:
raise ValueError(f"Expected 3 options but got {len(options)}") raise ValueError(f"Expected 1 option but got {len(options)}")
options = options[:3] options = options[:1]
for idx, option in enumerate(options): for idx, option in enumerate(options):
if not option.get("premise") or not option.get("premise", "").strip(): if not option.get("premise") or not option.get("premise", "").strip():
@@ -262,7 +334,7 @@ Return exactly 3 options as a JSON array. Each option must include a "premise" f
premise += "." premise += "."
option["premise"] = premise option["premise"] = premise
logger.info(f"[StoryWriter] Generated {len(options)} story setup options with premises for user {user_id}") logger.info(f"[StoryWriter] Generated {len(options)} story setup option(s) with premise for user {user_id}")
return options return options
except HTTPException: except HTTPException:
raise raise
@@ -273,3 +345,119 @@ Return exactly 3 options as a JSON array. Each option must include a "premise" f
logger.error(f"[StoryWriter] Error generating story setup options: {exc}") logger.error(f"[StoryWriter] Error generating story setup options: {exc}")
raise RuntimeError(f"Failed to generate story setup options: {exc}") from exc raise RuntimeError(f"Failed to generate story setup options: {exc}") from exc
def enhance_story_idea(
self,
*,
story_idea: str,
story_mode: str | None,
story_template: str | None,
brand_context: Dict[str, Any] | None,
user_id: str,
fiction_variant: str | None = None,
narrative_energy: str | None = None,
) -> List[Dict[str, Any]]:
mode_label = None
if story_mode == "marketing":
mode_label = "Non-fiction marketing story (brand or product campaign)"
elif story_mode == "pure":
mode_label = "Fiction story"
template_label = None
if story_template == "product_story":
template_label = "Product Story"
elif story_template == "brand_manifesto":
template_label = "Brand Manifesto"
elif story_template == "founder_story":
template_label = "Founder Story"
elif story_template == "customer_story":
template_label = "Customer Story"
elif story_template == "short_fiction":
template_label = "Short Fiction"
elif story_template == "long_fiction":
template_label = "Long Fiction"
elif story_template == "anime_fiction":
template_label = "Anime Fiction"
elif story_template == "experimental_fiction":
template_label = "Experimental Fiction"
brand_name = None
writing_tone = None
audience_description = None
if isinstance(brand_context, dict):
brand_name = brand_context.get("brand_name")
writing_tone = brand_context.get("writing_tone")
target_audience = brand_context.get("target_audience")
if isinstance(target_audience, dict):
audience_description = target_audience.get("description") or target_audience.get("summary")
elif isinstance(target_audience, str):
audience_description = target_audience
fiction_focus_line = ""
if fiction_variant:
fiction_focus_line = f'Treat the story as "{fiction_variant}" and lean into that creative focus.'
energy_line = ""
if narrative_energy:
energy_line = f'Target narrative energy: {narrative_energy}.'
enhance_prompt = f"""You are a creative writing coach helping a user refine and expand a story idea.
{"This is a " + mode_label + "." if mode_label else ""}
{("The user selected the template: " + template_label + ".") if template_label else ""}
{fiction_focus_line}
{energy_line}
When relevant, keep the idea aligned with this brand and audience context:
- Brand name or site: {brand_name or "Not specified"}
- Headline/overall writing tone: {writing_tone or "Not specified"}
- Audience description: {audience_description or "Not specified"}
The user has written the following story idea or concept:
{story_idea}
Your task is to propose exactly 3 alternative enhanced story idea options.
Each option must:
- Preserve the user's core premise and intent.
- Make the premise clearer and more compelling.
- Surface the central conflict or tension.
- Clarify the main characters and their goals.
- Strengthen the setting and stakes.
- Stay at the "idea" level, not a full outline or beat-by-beat breakdown.
For each option, return three fields:
- "idea": 2-4 sentences describing the improved story idea, suitable for a single textarea input.
- "whats_missing": 2-4 sentences explaining what important details are missing or underspecified in the current brief. Focus on gaps such as: protagonist details, antagonist or opposing force, stakes, setting and time period, audience/age group, subgenre or type of fiction (for example, anime vs grounded sci-fi), language or tone preferences, and any format constraints.
- "why_choose": 1-3 sentences explaining how this option interprets the original idea and why it might be a strong direction for the story.
Do not write a full story outline.
Do not output numbered lists or markdown formatting.
Return a single JSON object with a "suggestions" array of 3 items, where each item has the keys "idea", "whats_missing", and "why_choose"."""
schema = self._build_idea_enhance_schema()
try:
logger.info(f"[StoryWriter] Enhancing story idea with structured suggestions for user {user_id}")
response = self.load_json_response(
llm_text_gen(prompt=enhance_prompt, json_struct=schema, user_id=user_id)
)
suggestions = response.get("suggestions", [])
if len(suggestions) != 3:
logger.warning(
f"[StoryWriter] Expected 3 idea suggestions but got {len(suggestions)}, correcting count"
)
if len(suggestions) < 3:
raise ValueError(f"Expected 3 suggestions but got {len(suggestions)}")
suggestions = suggestions[:3]
return suggestions
except HTTPException:
raise
except json.JSONDecodeError as exc:
logger.error(f"[StoryWriter] Failed to parse JSON response for story idea enhancement: {exc}")
raise RuntimeError(f"Failed to parse story idea enhancement suggestions: {exc}") from exc
except Exception as exc:
logger.error(f"[StoryWriter] Error enhancing story idea: {exc}")
raise RuntimeError(f"Failed to enhance story idea: {exc}") from exc

View File

@@ -3,10 +3,12 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import json
from fastapi import HTTPException from fastapi import HTTPException
from loguru import logger from loguru import logger
from services.llm_providers.main_text_generation import llm_text_gen
from services.story_writer.image_generation_service import StoryImageGenerationService from services.story_writer.image_generation_service import StoryImageGenerationService
from .base import StoryServiceBase from .base import StoryServiceBase
@@ -36,6 +38,7 @@ class StoryContentMixin(StoryOutlineMixin):
content_rating: str, content_rating: str,
ending_preference: str, ending_preference: str,
story_length: str = "Medium", story_length: str = "Medium",
anime_bible: Optional[Dict[str, Any]] = None,
user_id: str, user_id: str,
) -> str: ) -> str:
"""Generate the starting section (or full short story).""" """Generate the starting section (or full short story)."""
@@ -52,6 +55,19 @@ class StoryContentMixin(StoryOutlineMixin):
ending_preference, ending_preference,
) )
anime_bible_context = ""
if anime_bible:
try:
serialized_bible = json.dumps(anime_bible, ensure_ascii=False, indent=2)
except Exception:
serialized_bible = str(anime_bible)
anime_bible_context = f"""
You also have a structured ANIME STORY BIBLE that defines the main cast, world rules, and visual style. Use it as a hard constraint for character consistency, worldbuilding, and visual storytelling:
{serialized_bible}
"""
outline_text = self._format_outline_for_prompt(outline) outline_text = self._format_outline_for_prompt(outline)
story_length_lower = story_length.lower() story_length_lower = story_length.lower()
is_short_story = "short" in story_length_lower or "1000" in story_length_lower is_short_story = "short" in story_length_lower or "1000" in story_length_lower
@@ -61,6 +77,8 @@ class StoryContentMixin(StoryOutlineMixin):
short_story_prompt = f"""\ short_story_prompt = f"""\
{persona_prompt} {persona_prompt}
{anime_bible_context}
You have a gripping premise in mind: You have a gripping premise in mind:
{premise} {premise}
@@ -154,6 +172,285 @@ on establishing the setting, characters, and beginning of the plot in {initial_w
logger.error(f"Story Start Generation Error: {exc}") logger.error(f"Story Start Generation Error: {exc}")
raise RuntimeError(f"Failed to generate story start: {exc}") from exc raise RuntimeError(f"Failed to generate story start: {exc}") from exc
# ------------------------------------------------------------------ #
# Anime scene refinement
# ------------------------------------------------------------------ #
def refine_anime_scene_text(
self,
*,
scene: Dict[str, 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,
anime_bible: Optional[Dict[str, Any]],
user_id: str,
) -> Dict[str, Any]:
persona_prompt = self.build_persona_prompt(
persona,
story_setting,
character_input,
plot_elements,
writing_style,
story_tone,
narrative_pov,
audience_age_group,
content_rating,
"Neutral",
)
anime_bible_context = ""
if anime_bible:
try:
serialized_bible = json.dumps(anime_bible, ensure_ascii=False, indent=2)
except Exception:
serialized_bible = str(anime_bible)
anime_bible_context = f"""
You also have a structured ANIME STORY BIBLE that defines the main cast, world rules, and visual style. Use it as a hard constraint for character consistency, worldbuilding, and visual storytelling:
{serialized_bible}
"""
current_title = scene.get("title", "")
current_description = scene.get("description", "")
current_image_prompt = scene.get("image_prompt", "")
current_audio_narration = scene.get("audio_narration", "")
current_character_descriptions = scene.get("character_descriptions") or []
current_key_events = scene.get("key_events") or []
scene_schema: Dict[str, Any] = {
"type": "object",
"properties": {
"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": ["title", "description", "image_prompt", "audio_narration"],
}
prompt = f"""
{persona_prompt}
{anime_bible_context}
You are refining a single anime story scene so that it fully respects the anime story bible for characters, world rules, and visual style.
Current scene:
- Title: {current_title}
- Description: {current_description}
- Image prompt: {current_image_prompt}
- Audio narration: {current_audio_narration}
- Character descriptions: {current_character_descriptions}
- Key events: {current_key_events}
Refine the scene so that:
- Title is concise and evocative
- Description clearly describes what happens in the scene
- Image prompt is vivid, visual, and aligned with the anime bible style and cast
- Audio narration is natural, spoken-friendly text matching the scene
- Character descriptions highlight key visual and personality traits relevant to this moment
- Key events list the main beats of the scene
Respond with JSON matching this schema:
{scene_schema}
"""
try:
raw = llm_text_gen(
prompt=prompt.strip(),
json_struct=scene_schema,
user_id=user_id,
)
data = self.load_json_response(raw)
except Exception as exc:
logger.warning(f"[StoryWriter] Failed to refine anime scene text via LLM: {exc}")
return {
"scene_number": scene.get("scene_number"),
"title": current_title,
"description": current_description,
"image_prompt": current_image_prompt,
"audio_narration": current_audio_narration,
"character_descriptions": current_character_descriptions,
"key_events": current_key_events,
}
refined = {
"scene_number": scene.get("scene_number"),
"title": data.get("title", current_title),
"description": data.get("description", current_description),
"image_prompt": data.get("image_prompt", current_image_prompt),
"audio_narration": data.get("audio_narration", current_audio_narration),
"character_descriptions": data.get(
"character_descriptions", current_character_descriptions
),
"key_events": data.get("key_events", current_key_events),
}
return refined
# ------------------------------------------------------------------ #
# Anime scene generation from bible
# ------------------------------------------------------------------ #
def generate_anime_scene_from_bible(
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,
anime_bible: Dict[str, Any],
previous_scenes: Optional[List[Dict[str, Any]]],
target_scene_number: Optional[int],
user_id: str,
) -> Dict[str, Any]:
persona_prompt = self.build_persona_prompt(
persona,
story_setting,
character_input,
plot_elements,
writing_style,
story_tone,
narrative_pov,
audience_age_group,
content_rating,
"Neutral",
)
try:
serialized_bible = json.dumps(anime_bible, ensure_ascii=False, indent=2)
except Exception:
serialized_bible = str(anime_bible)
anime_bible_context = f"""
You have a structured ANIME STORY BIBLE that defines the main cast, world rules, and visual style. You MUST treat it as a hard constraint for character consistency, worldbuilding, and visual storytelling:
{serialized_bible}
"""
previous_summary_lines: List[str] = []
if previous_scenes:
for s in previous_scenes[:6]:
num = s.get("scene_number")
title = s.get("title") or ""
desc = s.get("description") or ""
summary = desc
if len(summary) > 200:
summary = summary[:197] + "..."
previous_summary_lines.append(
f"- Scene {num}: {title}{summary}".strip()
)
previous_block = ""
if previous_summary_lines:
previous_block = (
"\nPrevious scenes so far (for continuity, do NOT contradict):\n"
+ "\n".join(previous_summary_lines)
)
scene_schema: Dict[str, Any] = {
"type": "object",
"properties": {
"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": ["title", "description", "image_prompt", "audio_narration"],
}
prompt = f"""
{persona_prompt}
{anime_bible_context}
You are generating a brand new anime story scene that must fully respect the anime story bible for characters, world rules, and visual style.
Overall premise:
{premise}
{previous_block}
Your task:
- Create the NEXT SCENE in this story.
- It must be consistent with the anime bible (cast, world rules, visual style).
- It must logically follow from any previous scenes given above.
Design the scene so that:
- Title is concise and evocative.
- Description clearly describes what happens in the scene.
- Image prompt is vivid, visual, and aligned with the anime bible style and cast.
- Audio narration is natural, spoken-friendly text matching the scene.
- Character descriptions highlight key visual and personality traits relevant to this moment.
- Key events list the main beats of the scene.
Respond with JSON matching this schema:
{scene_schema}
"""
try:
raw = llm_text_gen(
prompt=prompt.strip(),
json_struct=scene_schema,
user_id=user_id,
)
data = self.load_json_response(raw)
except Exception as exc:
logger.error(f"[StoryWriter] Failed to generate anime scene from bible: {exc}")
raise RuntimeError(f"Failed to generate anime scene from bible: {exc}") from exc
next_scene_number = target_scene_number
if next_scene_number is None:
if previous_scenes and len(previous_scenes) > 0:
last = previous_scenes[-1]
try:
last_num = int(last.get("scene_number") or 0)
except Exception:
last_num = len(previous_scenes)
next_scene_number = last_num + 1
else:
next_scene_number = 1
result = {
"scene_number": next_scene_number,
"title": data.get("title", "").strip(),
"description": data.get("description", "").strip(),
"image_prompt": data.get("image_prompt", "").strip(),
"audio_narration": data.get("audio_narration", "").strip(),
"character_descriptions": data.get("character_descriptions") or [],
"key_events": data.get("key_events") or [],
}
return result
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Continuation # Continuation
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -174,6 +471,7 @@ on establishing the setting, characters, and beginning of the plot in {initial_w
audience_age_group: str, audience_age_group: str,
content_rating: str, content_rating: str,
ending_preference: str, ending_preference: str,
anime_bible: Optional[Dict[str, Any]] = None,
story_length: str = "Medium", story_length: str = "Medium",
user_id: str, user_id: str,
) -> str: ) -> str:
@@ -191,6 +489,19 @@ on establishing the setting, characters, and beginning of the plot in {initial_w
ending_preference, ending_preference,
) )
anime_bible_context = ""
if anime_bible:
try:
serialized_bible = json.dumps(anime_bible, ensure_ascii=False, indent=2)
except Exception:
serialized_bible = str(anime_bible)
anime_bible_context = f"""
You also have a structured ANIME STORY BIBLE that defines the main cast, world rules, and visual style. Use it as a hard constraint for character consistency, worldbuilding, and visual storytelling:
{serialized_bible}
"""
outline_text = self._format_outline_for_prompt(outline) outline_text = self._format_outline_for_prompt(outline)
_, continuation_word_count = self._get_story_length_guidance(story_length) _, continuation_word_count = self._get_story_length_guidance(story_length)
current_word_count = len(story_text.split()) if story_text else 0 current_word_count = len(story_text.split()) if story_text else 0
@@ -227,6 +538,8 @@ on establishing the setting, characters, and beginning of the plot in {initial_w
continuation_prompt = f"""\ continuation_prompt = f"""\
{persona_prompt} {persona_prompt}
{anime_bible_context}
You have a gripping premise in mind: You have a gripping premise in mind:
{premise} {premise}
@@ -298,6 +611,7 @@ You have written approximately {current_word_count} words so far, leaving approx
audience_age_group: str, audience_age_group: str,
content_rating: str, content_rating: str,
ending_preference: str, ending_preference: str,
anime_bible: Optional[Dict[str, Any]] = None,
user_id: str, user_id: str,
max_iterations: int = 10, max_iterations: int = 10,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
@@ -352,6 +666,7 @@ You have written approximately {current_word_count} words so far, leaving approx
audience_age_group=audience_age_group, audience_age_group=audience_age_group,
content_rating=content_rating, content_rating=content_rating,
ending_preference=ending_preference, ending_preference=ending_preference,
anime_bible=anime_bible,
user_id=user_id, user_id=user_id,
) )
if not draft: if not draft:
@@ -375,6 +690,7 @@ You have written approximately {current_word_count} words so far, leaving approx
audience_age_group=audience_age_group, audience_age_group=audience_age_group,
content_rating=content_rating, content_rating=content_rating,
ending_preference=ending_preference, ending_preference=ending_preference,
anime_bible=anime_bible,
user_id=user_id, user_id=user_id,
) )
if continuation: if continuation:
@@ -420,6 +736,7 @@ You have written approximately {current_word_count} words so far, leaving approx
height: int = 1024, height: int = 1024,
model: Optional[str] = None, model: Optional[str] = None,
db: Optional[Session] = None, db: Optional[Session] = None,
anime_bible: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Generate images for story scenes.""" """Generate images for story scenes."""
image_service = StoryImageGenerationService() image_service = StoryImageGenerationService()
@@ -431,5 +748,6 @@ You have written approximately {current_word_count} words so far, leaving approx
height=height, height=height,
model=model, model=model,
db=db, db=db,
anime_bible=anime_bible,
) )

View File

@@ -0,0 +1,133 @@
"""
Story Project Service
Service layer for managing Story Studio project persistence.
Modeled after PodcastService for a consistent project API.
"""
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import and_, desc
from sqlalchemy.orm import Session
from models.story_project_models import StoryProject
class StoryProjectService:
"""Service for managing Story Studio projects."""
def __init__(self, db: Session) -> None:
self.db = db
def create_project(
self,
user_id: str,
project_id: str,
title: Optional[str] = None,
story_mode: Optional[str] = None,
story_template: Optional[str] = None,
**kwargs: Any,
) -> StoryProject:
project = StoryProject(
project_id=project_id,
user_id=user_id,
title=title,
story_mode=story_mode,
story_template=story_template,
status="draft",
current_phase="setup",
**kwargs,
)
self.db.add(project)
self.db.commit()
self.db.refresh(project)
return project
def get_project(self, user_id: str, project_id: str) -> Optional[StoryProject]:
return (
self.db.query(StoryProject)
.filter(
and_(
StoryProject.project_id == project_id,
StoryProject.user_id == user_id,
)
)
.first()
)
def update_project(
self,
user_id: str,
project_id: str,
**updates: Any,
) -> Optional[StoryProject]:
project = self.get_project(user_id, project_id)
if not project:
return None
for key, value in updates.items():
if hasattr(project, key):
setattr(project, key, value)
project.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(project)
return project
def list_projects(
self,
user_id: str,
status: Optional[str] = None,
favorites_only: bool = False,
limit: int = 50,
offset: int = 0,
order_by: str = "updated_at",
) -> Tuple[List[StoryProject], int]:
query = self.db.query(StoryProject).filter(StoryProject.user_id == user_id)
if status:
query = query.filter(StoryProject.status == status)
if favorites_only:
query = query.filter(StoryProject.is_favorite.is_(True))
total = query.count()
if order_by == "created_at":
query = query.order_by(desc(StoryProject.created_at))
else:
query = query.order_by(desc(StoryProject.updated_at))
projects = query.offset(offset).limit(limit).all()
return projects, total
def delete_project(self, user_id: str, project_id: str) -> bool:
project = self.get_project(user_id, project_id)
if not project:
return False
self.db.delete(project)
self.db.commit()
return True
def toggle_favorite(self, user_id: str, project_id: str) -> Optional[StoryProject]:
project = self.get_project(user_id, project_id)
if not project:
return None
project.is_favorite = not project.is_favorite
project.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(project)
return project
def update_status(
self,
user_id: str,
project_id: str,
status: str,
) -> Optional[StoryProject]:
return self.update_project(user_id, project_id, status=status)

View File

@@ -149,7 +149,7 @@ async def check_usage_limits_middleware(request: Request, user_id: str, request_
try: try:
path = request.url.path path = request.url.path
except Exception: except Exception:
pass path = ""
db = None db = None
try: try:
@@ -159,8 +159,16 @@ async def check_usage_limits_middleware(request: Request, user_id: str, request_
api_monitor = DatabaseAPIMonitor() api_monitor = DatabaseAPIMonitor()
# Safe User-Agent access
user_agent = None
try:
if hasattr(request, 'headers') and hasattr(request.headers, 'get'):
user_agent = request.headers.get('user-agent')
except:
pass
# Detect if this is an API call that should be rate limited # Detect if this is an API call that should be rate limited
api_provider = api_monitor.detect_api_provider(request.url.path, request.headers.get('user-agent')) api_provider = api_monitor.detect_api_provider(path, user_agent)
if not api_provider: if not api_provider:
return None return None
@@ -236,9 +244,28 @@ async def monitoring_middleware(request: Request, call_next):
user_id = None user_id = None
try: try:
# PRIORITY 1: Check request.state.user_id (set by API key injection middleware) # PRIORITY 1: Check request.state.user_id (set by API key injection middleware)
if hasattr(request.state, 'user_id') and request.state.user_id: if hasattr(request.state, 'user_id'):
user_id = request.state.user_id # Directly check and convert without accessing attribute if None
logger.debug(f"Monitoring: Using user_id from request.state: {user_id}") raw_user_id = request.state.user_id
# Defensive check for Depends object or other complex types
if raw_user_id is not None:
# If it's a string, use it
if isinstance(raw_user_id, str):
user_id = raw_user_id
# If it has a dependency attribute (likely a Depends object), ignore it
elif hasattr(raw_user_id, 'dependency'):
logger.warning(f"Monitoring: request.state.user_id is a Depends object, ignoring.")
user_id = None
# Try to convert to string if it's a simple type
else:
try:
user_id = str(raw_user_id)
except:
user_id = None
if user_id:
logger.debug(f"Monitoring: Using user_id from request.state: {user_id}")
# PRIORITY 2: Check query parameters # PRIORITY 2: Check query parameters
elif hasattr(request, 'query_params') and 'user_id' in request.query_params: elif hasattr(request, 'query_params') and 'user_id' in request.query_params:
@@ -247,20 +274,23 @@ async def monitoring_middleware(request: Request, call_next):
user_id = request.path_params['user_id'] user_id = request.path_params['user_id']
# PRIORITY 3: Check headers for user identification # PRIORITY 3: Check headers for user identification
elif 'x-user-id' in request.headers: elif hasattr(request, 'headers') and hasattr(request.headers, 'get'):
user_id = request.headers['x-user-id'] try:
elif 'x-user-email' in request.headers: if request.headers.get('x-user-id'):
user_id = request.headers['x-user-email'] # Use email as user identifier user_id = request.headers.get('x-user-id')
elif 'x-session-id' in request.headers: elif request.headers.get('x-user-email'):
user_id = request.headers['x-session-id'] # Use session as fallback user_id = request.headers.get('x-user-email')
elif request.headers.get('x-session-id'):
# Check for authorization header with user info user_id = request.headers.get('x-session-id')
elif 'authorization' in request.headers:
# Auth middleware should have set request.state.user_id # Check for authorization header with user info
# If not, this indicates an authentication failure (likely expired token) elif request.headers.get('authorization'):
# Log at debug level to reduce noise - expired tokens are expected # Auth middleware should have set request.state.user_id
# But we can try to decode token if we really needed to, but let's rely on auth middleware # If not, this indicates an authentication failure (likely expired token)
pass # Log at debug level to reduce noise - expired tokens are expected
pass
except Exception as e:
logger.debug(f"Error accessing request headers: {e}")
except Exception as e: except Exception as e:
logger.debug(f"Error extracting user ID: {e}") logger.debug(f"Error extracting user ID: {e}")
@@ -269,7 +299,11 @@ async def monitoring_middleware(request: Request, call_next):
# Get database session if user identified # Get database session if user identified
db = None db = None
if user_id: if user_id:
db = get_session_for_user(user_id) try:
db = get_session_for_user(user_id)
except Exception as e:
logger.error(f"Failed to get database session for user {user_id}: {e}")
db = None
# Capture request body for usage tracking (read once, safely) # Capture request body for usage tracking (read once, safely)
request_body = None request_body = None
@@ -291,29 +325,52 @@ async def monitoring_middleware(request: Request, call_next):
request_body = None request_body = None
# Check usage limits before processing # Check usage limits before processing
limit_response = await check_usage_limits_middleware(request, user_id, request_body) # Skip for OPTIONS requests
if limit_response: try:
if db: db.close() if request.method != "OPTIONS":
return limit_response limit_response = await check_usage_limits_middleware(request, user_id, request_body)
if limit_response:
if db: db.close()
return limit_response
except Exception as e:
logger.error(f"Error in usage limits middleware: {e}")
# Continue processing if usage check fails (fail open)
try: try:
response = await call_next(request) response = await call_next(request)
status_code = response.status_code status_code = response.status_code
duration = time.time() - start_time duration = time.time() - start_time
# Capture response body for usage tracking # Extract response body safely for usage tracking
response_body = None response_body = None
try: if hasattr(response, 'body'):
if hasattr(response, 'body'): response_body = response.body.decode('utf-8') if response.body else None
response_body = response.body.decode('utf-8') if response.body else None elif hasattr(response, '_content'):
elif hasattr(response, '_content'): response_body = response._content.decode('utf-8') if response._content else None
response_body = response._content.decode('utf-8') if response._content else None
except:
pass
# Track API usage if this is an API call to external providers # Track API usage if this is an API call to external providers
api_monitor = DatabaseAPIMonitor() api_monitor = DatabaseAPIMonitor()
api_provider = api_monitor.detect_api_provider(request.url.path, request.headers.get('user-agent'))
# Safe URL path access
try:
path = request.url.path
except:
path = ""
# Safe User-Agent access - handle case where headers might be a Depends object
user_agent = None
try:
# Defensive check: ensure request.headers is a valid headers object
# Some dependency injection failures replace request attributes with Depends objects
if hasattr(request, 'headers'):
headers_obj = request.headers
# Check if it has a 'get' method (like a dict or Headers object)
if hasattr(headers_obj, 'get') and callable(headers_obj.get):
user_agent = headers_obj.get('user-agent')
except:
pass
api_provider = api_monitor.detect_api_provider(path, user_agent)
if api_provider and user_id: if api_provider and user_id:
logger.info(f"Detected API call: {request.url.path} -> {api_provider.value} for user: {user_id}") logger.info(f"Detected API call: {request.url.path} -> {api_provider.value} for user: {user_id}")
try: try:
@@ -326,7 +383,7 @@ async def monitoring_middleware(request: Request, call_next):
await usage_service.track_api_usage( await usage_service.track_api_usage(
user_id=user_id, user_id=user_id,
provider=api_provider, provider=api_provider,
endpoint=request.url.path, endpoint=path,
method=request.method, method=request.method,
model_used=usage_metrics.get('model_used'), model_used=usage_metrics.get('model_used'),
tokens_input=usage_metrics.get('tokens_input', 0), tokens_input=usage_metrics.get('tokens_input', 0),
@@ -335,7 +392,7 @@ async def monitoring_middleware(request: Request, call_next):
status_code=status_code, status_code=status_code,
request_size=len(request_body) if request_body else None, request_size=len(request_body) if request_body else None,
response_size=len(response_body) if response_body else None, response_size=len(response_body) if response_body else None,
user_agent=request.headers.get('user-agent'), user_agent=user_agent,
ip_address=request.client.host if request.client else None, ip_address=request.client.host if request.client else None,
search_count=usage_metrics.get('search_count', 0), search_count=usage_metrics.get('search_count', 0),
image_count=usage_metrics.get('image_count', 0), image_count=usage_metrics.get('image_count', 0),

View File

@@ -0,0 +1,487 @@
import os
import stripe
from typing import Optional, Dict, Any
from loguru import logger
from fastapi import HTTPException
from sqlalchemy.orm import Session
from models.subscription_models import UserSubscription, SubscriptionPlan, SubscriptionTier, BillingCycle, UsageStatus, FraudWarning
from services.subscription.pricing_service import PricingService
from datetime import datetime
STRIPE_PLAN_PRICE_MAPPING = {
(SubscriptionTier.BASIC.value, BillingCycle.MONTHLY.value): "price_1T2lWHR2EuR7zQJepLIVQ1EJ",
(SubscriptionTier.PRO.value, BillingCycle.MONTHLY.value): "price_1T2ljDR2EuR7zQJeuS317KCj",
}
STRIPE_PRICE_TO_PLAN = {
price_id: {"tier": SubscriptionTier(tier), "billing_cycle": BillingCycle(billing_cycle)}
for (tier, billing_cycle), price_id in STRIPE_PLAN_PRICE_MAPPING.items()
}
class StripeService:
def __init__(self, db: Session):
self.db = db
self.api_key = os.getenv("STRIPE_SECRET_KEY")
self.webhook_secret = os.getenv("STRIPE_WEBHOOK_SECRET")
if not self.api_key:
logger.warning("STRIPE_SECRET_KEY is not set. Stripe integration will not work.")
else:
stripe.api_key = self.api_key
def _get_price_id_for_plan(self, tier: SubscriptionTier, billing_cycle: BillingCycle) -> str:
key = (tier.value, billing_cycle.value)
price_id = STRIPE_PLAN_PRICE_MAPPING.get(key)
if not price_id:
logger.error(f"No Stripe price configured for tier={tier.value}, billing_cycle={billing_cycle.value}")
raise HTTPException(status_code=400, detail="Payment plan is not configured")
return price_id
def _get_plan_for_price_id(self, price_id: str) -> tuple[SubscriptionPlan, BillingCycle]:
mapping = STRIPE_PRICE_TO_PLAN.get(price_id)
if not mapping:
logger.error(f"Unknown Stripe price_id: {price_id}")
raise HTTPException(status_code=400, detail="Unknown payment price configuration")
tier = mapping["tier"]
billing_cycle = mapping["billing_cycle"]
plan = (
self.db.query(SubscriptionPlan)
.filter(SubscriptionPlan.tier == tier, SubscriptionPlan.is_active == True)
.order_by(SubscriptionPlan.price_monthly)
.first()
)
if not plan:
logger.error(f"No subscription plan found for tier={tier.value}")
raise HTTPException(status_code=400, detail="Subscription plan not found for payment price")
return plan, billing_cycle
def _get_or_create_customer(self, user_id: str, email: Optional[str] = None) -> str:
"""
Get existing Stripe customer ID for user, or create a new one.
"""
subscription = self.db.query(UserSubscription).filter(
UserSubscription.user_id == user_id
).first()
if subscription and subscription.stripe_customer_id:
return subscription.stripe_customer_id
# Search Stripe for existing customer by email (if provided) or metadata
try:
# If we have an email, search by email first
if email:
existing_customers = stripe.Customer.list(email=email, limit=1)
if existing_customers and len(existing_customers.data) > 0:
customer = existing_customers.data[0]
# Update DB
if subscription:
subscription.stripe_customer_id = customer.id
self.db.commit()
return customer.id
# Search by metadata user_id
existing_customers = stripe.Customer.search(
query=f"metadata['user_id']:'{user_id}'",
limit=1
)
if existing_customers and len(existing_customers.data) > 0:
customer = existing_customers.data[0]
if subscription:
subscription.stripe_customer_id = customer.id
self.db.commit()
return customer.id
except Exception as e:
logger.error(f"Error searching Stripe customer: {e}")
# Create new customer
try:
customer_data = {
"metadata": {"user_id": user_id},
}
if email:
customer_data["email"] = email
customer = stripe.Customer.create(**customer_data)
# Update DB
if subscription:
subscription.stripe_customer_id = customer.id
else:
# Create a placeholder subscription record if none exists (usually created on signup/free tier)
# But typically we expect a free tier record to exist.
pass
self.db.commit()
return customer.id
except Exception as e:
logger.error(f"Error creating Stripe customer: {e}")
raise HTTPException(status_code=500, detail="Failed to create payment profile")
def create_checkout_session(
self,
user_id: str,
tier: SubscriptionTier,
billing_cycle: BillingCycle,
success_url: str,
cancel_url: str,
user_email: Optional[str] = None,
) -> str:
"""
Create a Stripe Checkout Session for a subscription.
"""
if not self.api_key:
raise HTTPException(status_code=500, detail="Payment service not configured")
price_id = self._get_price_id_for_plan(tier, billing_cycle)
customer_id = self._get_or_create_customer(user_id, user_email)
line_item: Dict[str, Any] = {"price": price_id}
try:
price = stripe.Price.retrieve(price_id)
recurring = getattr(price, "recurring", None)
usage_type = None
if recurring:
if isinstance(recurring, dict):
usage_type = recurring.get("usage_type")
else:
usage_type = getattr(recurring, "usage_type", None)
if usage_type != "metered":
line_item["quantity"] = 1
else:
logger.info(f"Detected metered price {price_id}; omitting quantity in Checkout line item")
except Exception as e:
logger.error(f"Error inspecting Stripe price {price_id}: {e}")
line_item["quantity"] = 1
try:
checkout_session = stripe.checkout.Session.create(
customer=customer_id,
payment_method_types=["card"],
line_items=[line_item],
mode="subscription",
success_url=success_url,
cancel_url=cancel_url,
metadata={
"user_id": user_id,
"price_id": price_id,
},
subscription_data={
"metadata": {
"user_id": user_id,
}
},
allow_promotion_codes=True,
)
return checkout_session.url
except Exception as e:
logger.error(f"Error creating checkout session: {e}")
raise HTTPException(status_code=500, detail=str(e))
def create_portal_session(self, user_id: str, return_url: str) -> str:
"""
Create a Stripe Customer Portal session for managing billing.
"""
if not self.api_key:
raise HTTPException(status_code=500, detail="Payment service not configured")
subscription = self.db.query(UserSubscription).filter(
UserSubscription.user_id == user_id
).first()
if not subscription or not subscription.stripe_customer_id:
# Try to find customer by user_id if not in DB
try:
customers = stripe.Customer.search(query=f"metadata['user_id']:'{user_id}'", limit=1)
if customers and len(customers.data) > 0:
customer_id = customers.data[0].id
# Update DB while we're at it
if subscription:
subscription.stripe_customer_id = customer_id
self.db.commit()
else:
raise HTTPException(status_code=400, detail="No billing profile found for this user")
except Exception as e:
logger.error(f"Error finding customer for portal: {e}")
raise HTTPException(status_code=500, detail="Failed to access billing portal")
else:
customer_id = subscription.stripe_customer_id
try:
portal_session = stripe.billing_portal.Session.create(
customer=customer_id,
return_url=return_url,
)
return portal_session.url
except Exception as e:
logger.error(f"Error creating portal session: {e}")
raise HTTPException(status_code=500, detail=str(e))
async def handle_webhook(self, payload: bytes, sig_header: str):
"""
Handle Stripe webhooks.
"""
if not self.webhook_secret:
logger.warning("STRIPE_WEBHOOK_SECRET not set. Ignoring webhook.")
return
try:
event = stripe.Webhook.construct_event(
payload, sig_header, self.webhook_secret
)
except ValueError as e:
logger.error(f"Invalid payload: {e}")
raise HTTPException(status_code=400, detail="Invalid payload")
except stripe.error.SignatureVerificationError as e:
logger.error(f"Invalid signature: {e}")
raise HTTPException(status_code=400, detail="Invalid signature")
event_type = event["type"]
data = event["data"]["object"]
logger.info(f"Received Stripe webhook: {event_type}")
if event_type == "checkout.session.completed":
await self._handle_checkout_completed(data)
elif event_type == "invoice.payment_succeeded":
await self._handle_invoice_payment_succeeded(data)
elif event_type == "invoice.payment_failed":
await self._handle_invoice_payment_failed(data)
elif event_type == "customer.subscription.updated":
await self._handle_subscription_updated(data)
elif event_type == "customer.subscription.deleted":
await self._handle_subscription_deleted(data)
elif event_type.startswith("radar.early_fraud_warning."):
await self._handle_early_fraud_warning(data)
return {"status": "success"}
async def _handle_checkout_completed(self, session: Dict[str, Any]):
"""
Handle successful checkout.
"""
user_id = session.get("metadata", {}).get("user_id")
customer_id = session.get("customer")
subscription_id = session.get("subscription")
if not user_id:
logger.error("No user_id in checkout session metadata")
return
logger.info(f"Checkout completed for user {user_id}")
# Retrieve subscription details to get the plan/price
if subscription_id:
try:
sub = stripe.Subscription.retrieve(subscription_id)
price_id = sub['items']['data'][0]['price']['id']
# Map price_id to internal plan_id
# Note: You need a way to map Stripe Price IDs to your Plan IDs.
# For now, we'll assume the metadata or a lookup.
# Ideally, store price_id in SubscriptionPlan table or config.
# Update DB
self._update_user_subscription(
user_id,
stripe_customer_id=customer_id,
stripe_subscription_id=subscription_id,
status="active",
price_id=price_id
)
except Exception as e:
logger.error(f"Error processing checkout subscription: {e}")
async def _handle_invoice_payment_succeeded(self, invoice: Dict[str, Any]):
"""
Handle recurring payment success.
"""
subscription_id = invoice.get("subscription")
customer_id = invoice.get("customer")
if not subscription_id:
return
# Find user by stripe_subscription_id or customer_id
subscription = self.db.query(UserSubscription).filter(
(UserSubscription.stripe_subscription_id == subscription_id) |
(UserSubscription.stripe_customer_id == customer_id)
).first()
if subscription:
logger.info(f"Payment succeeded for user {subscription.user_id}")
subscription.status = UsageStatus.ACTIVE
subscription.is_active = True
# Update period end based on invoice lines period
if invoice.get('lines'):
period_end = invoice['lines']['data'][0]['period']['end']
subscription.current_period_end = datetime.fromtimestamp(period_end)
self.db.commit()
async def _handle_invoice_payment_failed(self, invoice: Dict[str, Any]):
subscription_id = invoice.get("subscription")
customer_id = invoice.get("customer")
if not subscription_id:
return
subscription = self.db.query(UserSubscription).filter(
(UserSubscription.stripe_subscription_id == subscription_id) |
(UserSubscription.stripe_customer_id == customer_id)
).first()
if subscription:
logger.warning(f"Payment failed for user {subscription.user_id}")
subscription.status = UsageStatus.PAST_DUE
subscription.is_active = False
self.db.commit()
async def _handle_subscription_updated(self, subscription_obj: Dict[str, Any]):
"""
Handle subscription updates (cancellations, changes).
"""
stripe_sub_id = subscription_obj.get("id")
status = subscription_obj.get("status")
subscription = self.db.query(UserSubscription).filter(
UserSubscription.stripe_subscription_id == stripe_sub_id
).first()
if subscription:
logger.info(f"Subscription {stripe_sub_id} updated to {status}")
if status in ["active", "trialing"]:
subscription.status = UsageStatus.ACTIVE
subscription.is_active = True
elif status in ["past_due", "unpaid", "incomplete", "incomplete_expired"]:
subscription.status = UsageStatus.PAST_DUE
subscription.is_active = False
elif status in ["canceled"]:
subscription.status = UsageStatus.CANCELLED
subscription.is_active = False
subscription.auto_renew = False
self.db.commit()
async def _handle_subscription_deleted(self, subscription_obj: Dict[str, Any]):
"""
Handle subscription cancellation (immediate).
"""
stripe_sub_id = subscription_obj.get("id")
subscription = self.db.query(UserSubscription).filter(
UserSubscription.stripe_subscription_id == stripe_sub_id
).first()
if subscription:
logger.info(f"Subscription {stripe_sub_id} deleted")
subscription.status = UsageStatus.CANCELLED # Need to check if this enum value exists
subscription.is_active = False
subscription.auto_renew = False
self.db.commit()
async def _handle_early_fraud_warning(self, warning_obj: Dict[str, Any]):
efw_id = warning_obj.get("id")
if not efw_id:
return
charge_id = warning_obj.get("charge")
payment_intent_id = warning_obj.get("payment_intent")
created_ts = warning_obj.get("created")
created_at = datetime.utcfromtimestamp(created_ts) if created_ts else datetime.utcnow()
amount = 0
currency = ""
user_id = None
charge_data: Dict[str, Any] = {}
if charge_id and self.api_key:
try:
charge = stripe.Charge.retrieve(charge_id)
charge_data = charge.to_dict() if hasattr(charge, "to_dict") else dict(charge)
amount = charge_data.get("amount") or 0
currency = charge_data.get("currency") or ""
metadata = charge_data.get("metadata") or {}
user_id = metadata.get("user_id")
except Exception as e:
logger.error(f"Error retrieving charge for early fraud warning {efw_id}: {e}")
if not amount:
amount = warning_obj.get("amount") or 0
if not currency:
currency = warning_obj.get("currency") or ""
existing = self.db.query(FraudWarning).filter(FraudWarning.id == efw_id).first()
metadata_payload: Dict[str, Any] = {
"early_fraud_warning": warning_obj,
}
if charge_data:
metadata_payload["charge"] = charge_data
if existing:
existing.charge_id = charge_id or existing.charge_id
existing.payment_intent_id = payment_intent_id or existing.payment_intent_id
if user_id:
existing.user_id = user_id
if amount:
existing.amount = amount
if currency:
existing.currency = currency
existing.status = "open"
existing.meta_info = metadata_payload
else:
if not charge_id:
return
warning = FraudWarning(
id=efw_id,
charge_id=charge_id,
payment_intent_id=payment_intent_id,
user_id=user_id,
amount=amount or 0,
currency=currency or "",
status="open",
action="none",
meta_info=metadata_payload,
created_at=created_at,
)
self.db.add(warning)
self.db.commit()
def _update_user_subscription(
self,
user_id: str,
stripe_customer_id: str,
stripe_subscription_id: str,
status: str,
price_id: str,
):
plan, billing_cycle = self._get_plan_for_price_id(price_id)
subscription = (
self.db.query(UserSubscription)
.filter(UserSubscription.user_id == user_id)
.first()
)
now = datetime.utcnow()
if not subscription:
subscription = UserSubscription(
user_id=user_id,
plan_id=plan.id,
billing_cycle=billing_cycle,
current_period_start=now,
current_period_end=now,
status=UsageStatus.ACTIVE if status == "active" else UsageStatus.SUSPENDED,
is_active=status == "active",
auto_renew=True,
)
self.db.add(subscription)
else:
subscription.plan_id = plan.id
subscription.billing_cycle = billing_cycle
subscription.is_active = status == "active"
subscription.stripe_customer_id = stripe_customer_id
subscription.stripe_subscription_id = stripe_subscription_id
self.db.commit()

View File

@@ -39,9 +39,34 @@ def _generate_simple_infinitetalk_prompt(
# Build a balanced prompt: scene description + simple motion hint # Build a balanced prompt: scene description + simple motion hint
parts = [] parts = []
# Start with the main subject/scene # Add scene context
if title and len(title) > 5 and title.lower() not in ("scene", "podcast", "episode"): if title and len(title) > 5 and title.lower() not in ("scene", "podcast", "episode"):
parts.append(title) parts.append(title)
# Add analysis context
analysis = story_context.get("analysis", {})
if analysis:
content_type = analysis.get("content_type")
if content_type:
parts.append(f"Style: {content_type}")
# Audience helps define the formality/vibe
audience = analysis.get("audience")
if audience:
# Just use first few words of audience to keep it short
short_audience = " ".join(audience.split()[:3])
parts.append(f"For: {short_audience}")
# Add bible context if available
bible = story_context.get("bible", {})
if bible:
host_persona = bible.get("host_persona")
tone = bible.get("tone")
if host_persona:
parts.append(f"Host: {host_persona}")
if tone:
parts.append(f"Tone: {tone}")
elif description: elif description:
# Take first sentence or first 60 chars # Take first sentence or first 60 chars
desc_part = description.split('.')[0][:60].strip() desc_part = description.split('.')[0][:60].strip()

View File

@@ -52,6 +52,46 @@ def _build_fallback_prompt(scene_data: Dict[str, Any], story_context: Dict[str,
image_prompt = (scene_data.get("image_prompt") or "").strip() image_prompt = (scene_data.get("image_prompt") or "").strip()
tone = (story_context.get("story_tone") or "story").strip() tone = (story_context.get("story_tone") or "story").strip()
setting = (story_context.get("story_setting") or "the scene").strip() setting = (story_context.get("story_setting") or "the scene").strip()
anime_bible = story_context.get("anime_bible") or {}
anime_style_parts = []
if isinstance(anime_bible, dict):
visual_style = anime_bible.get("visual_style") or {}
world = anime_bible.get("world") or {}
main_cast = anime_bible.get("main_cast") or []
style_preset = visual_style.get("style_preset")
camera_style = visual_style.get("camera_style")
color_mood = visual_style.get("color_mood")
lighting = visual_style.get("lighting")
line_style = visual_style.get("line_style")
extra_tags = visual_style.get("extra_tags") or []
if style_preset:
anime_style_parts.append(f"Follow {style_preset} anime visual style.")
if camera_style:
anime_style_parts.append(f"Use camera style: {camera_style}.")
if color_mood:
anime_style_parts.append(f"Color mood: {color_mood}.")
if lighting:
anime_style_parts.append(f"Lighting: {lighting}.")
if line_style:
anime_style_parts.append(f"Line art: {line_style}.")
if extra_tags:
anime_style_parts.append("Style tags: " + ", ".join(str(tag) for tag in extra_tags[:6]))
if world:
setting_desc = world.get("setting")
if setting_desc:
anime_style_parts.append(f"World context: {setting_desc}.")
if main_cast:
names = [c.get("name") for c in main_cast if isinstance(c, dict) and c.get("name")]
if names:
joined = ", ".join(names[:4])
anime_style_parts.append(f"Keep character designs consistent for: {joined}.")
anime_style_text = " ".join(anime_style_parts).strip()
parts = [ parts = [
f"{title} cinematic motion shot.", f"{title} cinematic motion shot.",
@@ -60,6 +100,7 @@ def _build_fallback_prompt(scene_data: Dict[str, Any], story_context: Dict[str,
f"Maintain a {tone} mood with natural lighting accents.", f"Maintain a {tone} mood with natural lighting accents.",
f"Honor the original illustration details: {image_prompt[:200]}." if image_prompt else "", f"Honor the original illustration details: {image_prompt[:200]}." if image_prompt else "",
"5-second sequence, gentle push-in, flowing cloth and atmospheric particles.", "5-second sequence, gentle push-in, flowing cloth and atmospheric particles.",
anime_style_text,
] ]
fallback_prompt = " ".join(filter(None, parts)) fallback_prompt = " ".join(filter(None, parts))
return fallback_prompt.strip() return fallback_prompt.strip()
@@ -142,6 +183,66 @@ def generate_animation_prompt(
title = scene_data.get("title", "") title = scene_data.get("title", "")
tone = story_context.get("story_tone") or story_context.get("story_tone", "") tone = story_context.get("story_tone") or story_context.get("story_tone", "")
setting = story_context.get("story_setting") or story_context.get("story_setting", "") setting = story_context.get("story_setting") or story_context.get("story_setting", "")
anime_bible = story_context.get("anime_bible") or {}
anime_bible_block = ""
if isinstance(anime_bible, dict) and anime_bible:
try:
visual_style = anime_bible.get("visual_style") or {}
world = anime_bible.get("world") or {}
main_cast = anime_bible.get("main_cast") or []
style_lines = []
if visual_style:
style_preset = visual_style.get("style_preset")
camera_style = visual_style.get("camera_style")
color_mood = visual_style.get("color_mood")
lighting = visual_style.get("lighting")
line_style = visual_style.get("line_style")
extra_tags = visual_style.get("extra_tags") or []
if style_preset:
style_lines.append(f"- Visual style preset: {style_preset}")
if camera_style:
style_lines.append(f"- Preferred camera style: {camera_style}")
if color_mood:
style_lines.append(f"- Color mood: {color_mood}")
if lighting:
style_lines.append(f"- Lighting: {lighting}")
if line_style:
style_lines.append(f"- Line art style: {line_style}")
if extra_tags:
style_lines.append(
"- Extra style tags: " + ", ".join(str(tag) for tag in extra_tags[:6])
)
cast_line = ""
if main_cast:
names = [c.get("name") for c in main_cast if isinstance(c, dict) and c.get("name")]
if names:
cast_line = "- Main cast to keep visually consistent: " + ", ".join(names[:4])
world_line = ""
if world:
setting_desc = world.get("setting")
if setting_desc:
world_line = "- World/setting context: " + str(setting_desc)
detail_lines = []
if cast_line:
detail_lines.append(cast_line)
if world_line:
detail_lines.append(world_line)
detail_lines.extend(style_lines)
if detail_lines:
anime_bible_block = (
"\nANIME STORY BIBLE VISUAL GUIDANCE:\n"
+ "\n".join(detail_lines)
+ "\nAlways respect these constraints in the motion description."
)
except Exception:
anime_bible_block = ""
prompt = f""" prompt = f"""
Create a concise animation prompt (2-3 sentences) for a 5-second cinematic clip. Create a concise animation prompt (2-3 sentences) for a 5-second cinematic clip.
@@ -151,6 +252,7 @@ Description: {description}
Existing Image Prompt: {image_prompt} Existing Image Prompt: {image_prompt}
Story Tone: {tone} Story Tone: {tone}
Setting: {setting} Setting: {setting}
{anime_bible_block}
Focus on: Focus on:
- Motion of characters/objects - Motion of characters/objects

View File

@@ -132,7 +132,19 @@ class YouTubeSceneBuilderService:
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Generate scenes from video plan using AI.""" """Generate scenes from video plan using AI."""
content_outline = video_plan.get("content_outline", []) raw_content_outline = video_plan.get("content_outline", [])
content_outline: List[Dict[str, Any]] = []
for item in raw_content_outline:
if isinstance(item, dict):
content_outline.append(item)
else:
content_outline.append(
{
"section": str(item),
"description": "",
"duration_estimate": 0,
}
)
hook_strategy = video_plan.get("hook_strategy", "") hook_strategy = video_plan.get("hook_strategy", "")
call_to_action = video_plan.get("call_to_action", "") call_to_action = video_plan.get("call_to_action", "")
visual_style = video_plan.get("visual_style", "cinematic") visual_style = video_plan.get("visual_style", "cinematic")
@@ -263,16 +275,32 @@ Write narration that:
# Normalize scene data # Normalize scene data
normalized_scenes = [] normalized_scenes = []
for idx, scene in enumerate(scenes, 1): for idx, scene in enumerate(scenes, 1):
normalized_scenes.append({ if isinstance(scene, dict):
"scene_number": scene.get("scene_number", idx), scene_data = scene
"title": scene.get("title", f"Scene {idx}"), else:
"narration": scene.get("narration", ""), scene_data = {
"visual_description": scene.get("visual_description", ""), "scene_number": idx,
"duration_estimate": scene.get("duration_estimate", scene_duration_range[0]), "title": f"Scene {idx}",
"emphasis": scene.get("emphasis", "main_content"), "narration": str(scene),
"visual_cues": scene.get("visual_cues", []), "visual_description": "",
"visual_prompt": scene.get("visual_description", ""), # Initial prompt "duration_estimate": scene_duration_range[0],
}) "emphasis": "main_content",
"visual_cues": [],
}
normalized_scenes.append(
{
"scene_number": scene_data.get("scene_number", idx),
"title": scene_data.get("title", f"Scene {idx}"),
"narration": scene_data.get("narration", ""),
"visual_description": scene_data.get("visual_description", ""),
"duration_estimate": scene_data.get(
"duration_estimate", scene_duration_range[0]
),
"emphasis": scene_data.get("emphasis", "main_content"),
"visual_cues": scene_data.get("visual_cues", []),
"visual_prompt": scene_data.get("visual_description", ""),
}
)
return normalized_scenes return normalized_scenes
@@ -287,16 +315,32 @@ Write narration that:
normalized_scenes = [] normalized_scenes = []
for idx, scene in enumerate(scenes, 1): for idx, scene in enumerate(scenes, 1):
normalized_scenes.append({ if isinstance(scene, dict):
"scene_number": scene.get("scene_number", idx), scene_data = scene
"title": scene.get("title", f"Scene {idx}"), else:
"narration": scene.get("narration", ""), scene_data = {
"visual_description": scene.get("visual_description", ""), "scene_number": idx,
"duration_estimate": scene.get("duration_estimate", scene_duration_range[0]), "title": f"Scene {idx}",
"emphasis": scene.get("emphasis", "main_content"), "narration": str(scene),
"visual_cues": scene.get("visual_cues", []), "visual_description": "",
"visual_prompt": scene.get("visual_description", ""), # Initial prompt "duration_estimate": scene_duration_range[0],
}) "emphasis": "main_content",
"visual_cues": [],
}
normalized_scenes.append(
{
"scene_number": scene_data.get("scene_number", idx),
"title": scene_data.get("title", f"Scene {idx}"),
"narration": scene_data.get("narration", ""),
"visual_description": scene_data.get("visual_description", ""),
"duration_estimate": scene_data.get(
"duration_estimate", scene_duration_range[0]
),
"emphasis": scene_data.get("emphasis", "main_content"),
"visual_cues": scene_data.get("visual_cues", []),
"visual_prompt": scene_data.get("visual_description", ""),
}
)
logger.info( logger.info(
f"[YouTubeSceneBuilder] ✅ Normalized {len(normalized_scenes)} scenes " f"[YouTubeSceneBuilder] ✅ Normalized {len(normalized_scenes)} scenes "

View File

@@ -6,10 +6,13 @@ Promotes reuse between Podcast, YouTube, and other media-heavy modules.
""" """
import logging import logging
import os
from pathlib import Path from pathlib import Path
from typing import Optional, List from typing import Optional, List
from urllib.parse import urlparse from urllib.parse import urlparse
from services.database import WORKSPACE_DIR
# Configure logging # Configure logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -58,6 +61,23 @@ def resolve_media_path(media_url_or_path: str) -> Optional[Path]:
if not filename: if not filename:
return None return None
# Handle workspace avatar assets: /api/assets/{user_id}/avatars/{filename}
if "/api/assets/" in media_url_or_path and "/avatars/" in media_url_or_path:
try:
parsed_path = urlparse(media_url_or_path).path
parts = parsed_path.split("/")
if len(parts) >= 6:
user_id = parts[3]
safe_user_id = "".join(c for c in user_id if c.isalnum() or c in ("-", "_"))
if safe_user_id == user_id:
safe_filename = os.path.basename(filename)
assets_path = Path(WORKSPACE_DIR) / f"workspace_{safe_user_id}" / "assets" / "avatars" / safe_filename
if assets_path.exists() and assets_path.is_file():
logger.debug(f"[MediaUtils] Resolved assets avatar {media_url_or_path} to {assets_path}")
return assets_path
except Exception as exc:
logger.error(f"[MediaUtils] Error resolving assets avatar path: {exc}")
# Define search paths in order of likelihood # Define search paths in order of likelihood
# We search all avatar/image directories # We search all avatar/image directories
search_paths: List[Path] = [ search_paths: List[Path] = [

View File

@@ -0,0 +1,314 @@
# Stripe Billing & Subscriptions Developer Guide
This document explains how Stripe is integrated into ALwrity for subscriptions, billing, disputes, and fraud handling. It is aimed at developers working on the backend and frontend.
---
## 1. High-Level Architecture
- **Backend**
- Core service: `StripeService`
- File: `backend/services/subscription/stripe_service.py`
- Subscription/payment API routes:
- `backend/api/subscription/routes/payment.py`
- `backend/api/subscription/routes/disputes.py`
- `backend/api/subscription/routes/fraud_warnings.py`
- Models:
- `UserSubscription`, `SubscriptionPlan`, `BillingCycle`, `UsageStatus`, `FraudWarning`
- File: `backend/models/subscription_models.py`
- **Frontend**
- Pricing and checkout UI:
- `frontend/src/components/Pricing/PricingPage.tsx`
- Internal admin dashboards:
- `frontend/src/pages/StripeDisputesDashboard.tsx`
- Routing:
- `frontend/src/App.tsx` (route at `/stripe-disputes`)
Data flows:
- Public users:
- Browse pricing → select plan → start Stripe Checkout → complete subscription.
- Admin/internal users:
- Use `/stripe-disputes` dashboard to manage disputes and early fraud warnings.
---
## 2. Configuration & Environment
Required environment variables (backend):
- `STRIPE_SECRET_KEY`
- Stripe API key (test or live).
- `STRIPE_WEBHOOK_SECRET`
- Webhook signing secret for subscription webhooks.
- `ADMIN_EMAILS` (optional)
- Comma-separated list of admin emails allowed to access dispute/fraud endpoints.
- `ADMIN_EMAIL_DOMAIN` (optional)
- Domain considered admin (e.g. `example.com`).
- `DISABLE_AUTH` (optional)
- If `"true"`, bypasses admin checks for local/testing use only.
Stripe configuration:
- Price IDs are mapped in code (see below) and must exist in the configured Stripe account.
- Webhook endpoint must be configured in Stripe Dashboard:
- Path: `/api/subscription/webhook`
- Events: `checkout.session.completed`, `invoice.payment_succeeded`, `invoice.payment_failed`, `customer.subscription.updated`, `customer.subscription.deleted`, `radar.early_fraud_warning.created` (and optionally `radar.early_fraud_warning.updated`).
---
## 3. Plans, Prices and Mapping
Stripe price mapping lives in `StripeService`:
- File: `backend/services/subscription/stripe_service.py`
Key structures:
- `STRIPE_PLAN_PRICE_MAPPING`
- Maps `(SubscriptionTier, BillingCycle)` → Stripe `price_id`.
- `STRIPE_PRICE_TO_PLAN`
- Reverse map: `price_id``{ tier, billing_cycle }`.
Helper methods:
- `_get_price_id_for_plan(tier, billing_cycle) -> str`
- Used when creating Checkout sessions.
- `_get_plan_for_price_id(price_id) -> (SubscriptionPlan, BillingCycle)`
- Used when mapping Stripe subscription items back into our internal `SubscriptionPlan`.
### Adding or updating plans
1. Create prices in Stripe (with correct recurring configuration).
2. Update `STRIPE_PLAN_PRICE_MAPPING` with new price IDs.
3. Ensure a `SubscriptionPlan` row exists in the DB for the tier being mapped.
4. Redeploy backend with updated mapping.
---
## 4. Checkout and Subscription Lifecycle
### 4.1 Create Checkout Session
Endpoint:
- `POST /api/subscription/create-checkout-session`
- File: `backend/api/subscription/routes/payment.py`
Request body:
- `tier: SubscriptionTier` (e.g. `"basic"`, `"pro"`)
- `billing_cycle: BillingCycle` (e.g. `"monthly"`)
- `success_url: str`
- `cancel_url: str`
Flow:
1. Auth middleware resolves `current_user` and `user_id`.
2. `StripeService.create_checkout_session`:
- Fetches `price_id` via `_get_price_id_for_plan`.
- Finds or creates Stripe Customer (with `user_id` in metadata).
- Creates a Stripe Checkout Session:
- Mode: `subscription`.
- Metadata: includes `user_id` and `price_id`.
3. Returns `checkout_session.url` to the frontend.
Special handling:
- Metered prices:
- For metered prices, `quantity` is omitted to comply with Stripe rules.
- For non-metered prices, `quantity` is set to `1`.
### 4.2 Customer Portal Session
Endpoint:
- `POST /api/subscription/create-portal-session`
Flow:
1. Lookup `UserSubscription` and `stripe_customer_id`.
2. If missing, search Stripe by `metadata['user_id']`.
3. Create Stripe Billing Portal session and return URL.
### 4.3 Webhook Handling
Endpoint:
- `POST /api/subscription/webhook`
- File: `backend/api/subscription/routes/payment.py`
- Delegates to `StripeService.handle_webhook`.
Verification:
- `stripe.Webhook.construct_event(payload, sig_header, STRIPE_WEBHOOK_SECRET)` is used to validate signatures.
Handled events:
- `checkout.session.completed`
- Retrieves subscription and price.
- Updates `UserSubscription` to active and stores `stripe_customer_id` and `stripe_subscription_id`.
- `invoice.payment_succeeded`
- Sets `UserSubscription.status` to `ACTIVE`.
- Updates `current_period_end` from invoice period.
- `invoice.payment_failed`
- Sets status to `PAST_DUE`, `is_active` false.
- `customer.subscription.updated`
- Syncs status and `auto_renew`.
- `customer.subscription.deleted`
- Marks subscription as cancelled and disables auto renew.
Helper:
- `_update_user_subscription` centralizes updating/creating `UserSubscription` records based on Stripe data.
---
## 5. Disputes Integration
Backend routes:
- File: `backend/api/subscription/routes/disputes.py`
Endpoints:
- `GET /api/subscription/disputes`
- Proxies `stripe.Dispute.list`.
- `GET /api/subscription/disputes/{dispute_id}`
- Proxies `stripe.Dispute.retrieve`.
- `POST /api/subscription/disputes/{dispute_id}`
- Proxies `stripe.Dispute.modify` with `evidence`.
- `POST /api/subscription/disputes/{dispute_id}/close`
- Proxies `stripe.Dispute.close`.
Admin guard:
- `_ensure_admin(current_user)` ensures:
- Admin by email, domain, or role `"admin"`.
- Can be bypassed only when `DISABLE_AUTH=true` (local use).
Frontend UI:
- File: `frontend/src/pages/StripeDisputesDashboard.tsx`
- Route: `/stripe-disputes`
- Disputes tab:
- Lists disputes and allows:
- Viewing details.
- Submitting evidence fields:
- `customer_email_address`, `customer_name`, `customer_purchase_ip`, `access_activity_log`, `uncategorized_text`.
- Tagging a high-level fraud type, which is encoded into `uncategorized_text`.
- Closing the dispute.
---
## 6. Early Fraud Warnings (EFW) and Proactive Refunds
### 6.1 Ingestion
Model:
- `FraudWarning` in `backend/models/subscription_models.py`
- Columns: `id`, `charge_id`, `payment_intent_id`, `user_id`, `amount`, `currency`, `status`, `action`, `action_at`, `reason_notes`, `metadata`, `created_at`.
Ingestion logic:
- `StripeService._handle_early_fraud_warning`:
- Triggered for event types starting with `radar.early_fraud_warning.`.
- Retrieves the associated `Charge` to populate amount, currency, and metadata.
- Infers `user_id` from `charge.metadata.user_id` when available.
- Upserts a `FraudWarning` row with status `"open"` and action `"none"`.
- Stores raw EFW and Charge data in `metadata`.
### 6.2 Fraud Warnings API
File: `backend/api/subscription/routes/fraud_warnings.py`
Endpoints:
- `GET /api/subscription/fraud-warnings`
- Query params:
- `status` (default `"open"`)
- `limit`, `offset`
- Returns a list of warnings with core fields.
- `GET /api/subscription/fraud-warnings/{id}`
- Returns full details including `metadata`.
- `POST /api/subscription/fraud-warnings/{id}/refund`
- Performs a **full refund** via `stripe.Refund.create(charge=...)`.
- Updates `status="refunded"`, `action="refund_full"`, `action_at` and `reason_notes`.
- `POST /api/subscription/fraud-warnings/{id}/ignore`
- Sets `status="ignored"`, `action="ignored"`, updates notes.
All endpoints apply the same admin guard used for disputes.
### 6.3 Frontend Fraud Warnings Tab
- File: `frontend/src/pages/StripeDisputesDashboard.tsx`
Behavior:
- Adds a tabbed view:
- Tab 1: Disputes.
- Tab 2: Fraud Warnings.
- Fraud Warnings tab:
- Lists EFWs (from `/fraud-warnings`).
- Shows details including:
- Stripe EFW `fraud_type`, `actionable` flag.
- Amount, created time, internal status/action.
- Allows:
- Proactive full refund (calls `/fraud-warnings/{id}/refund`).
- Mark as ignored (calls `/fraud-warnings/{id}/ignore`).
- Add/update internal notes.
---
## 7. Rate Limiting for Checkout
Endpoint: `POST /api/subscription/create-checkout-session`
File: `backend/api/subscription/routes/payment.py`
Logic:
- Per-user in-memory rate limiting:
- Window: 60 seconds.
- Max requests: 10 within the window.
- On exceed:
- Logs a warning with `user_id`, IP, attempts count.
- Returns HTTP 429 with a friendly error message.
Purpose:
- Protects against card testing and abuse by limiting how often a user can create Checkout sessions.
Considerations:
- For multi-instance deployments, a shared store (e.g. Redis) is recommended to make rate limiting consistent across instances.
---
## 8. Extending and Maintaining the Integration
### Adding new subscription tiers or prices
1. Create or update prices in Stripe.
2. Update `STRIPE_PLAN_PRICE_MAPPING` in `StripeService`.
3. Ensure corresponding rows in `SubscriptionPlan`.
4. Add any needed frontend logic (e.g. additional tiers in pricing UI).
### Supporting additional Stripe events
- Extend `StripeService.handle_webhook` with new event types.
- Implement corresponding handlers (`_handle_*`) that:
- Parse event data.
- Update your DB models.
- Log with enough context.
### Making the system more robust
- Reintroduce idempotency keys for write operations (Checkout creation, refunds) using stable dedupe keys.
- Replace in-memory rate limiting with shared store-based limiting when scaling horizontally.
- Add more detailed logs/metrics around:
- New subscriptions.
- Failed payments.
- Disputes and early fraud warnings.

View File

@@ -0,0 +1,202 @@
# Stripe Go-Live Checklist
This checklist is for preparing ALwritys Stripe integration for production. Use it before switching to live keys or onboarding real customers.
Tick each item as you complete it.
---
## 1. Configuration & Environment
- [ ] **Separate environments set up**
- [ ] Test mode Stripe account configured.
- [ ] Live mode Stripe account configured.
- [ ] **Environment variables configured for production**
- [ ] `STRIPE_SECRET_KEY` set to **live** secret key.
- [ ] `STRIPE_WEBHOOK_SECRET` set to **live** webhook signing secret.
- [ ] `ADMIN_EMAILS` configured with correct admin emails (comma-separated).
- [ ] `ADMIN_EMAIL_DOMAIN` configured if using domain-based admin access.
- [ ] `DISABLE_AUTH` is **not** set to `"true"` in production.
- [ ] **Secrets handling**
- [ ] No Stripe keys are committed to the repo.
- [ ] Secrets are stored only in your deployment platform / secret manager.
---
## 2. Prices, Plans and Mapping
- [ ] **All required prices exist in Stripe (live)**
- [ ] BASIC monthly price created.
- [ ] PRO monthly price created (if used).
- [ ] Yearly prices created if you plan to sell yearly plans.
- [ ] **Price mapping in backend updated**
- [ ] `STRIPE_PLAN_PRICE_MAPPING` uses **live** price IDs (not test IDs).
- [ ] Mapping covers all tiers and billing cycles you intend to offer.
- [ ] **SubscriptionPlan data is consistent**
- [ ] DB has `SubscriptionPlan` rows for each tier (BASIC/PRO/etc.).
- [ ] `is_active` is set to true for sellable plans.
---
## 3. Database & Migrations
- [ ] **Model changes applied in production DB**
- [ ] Tables related to subscriptions exist:
- [ ] `subscription_plans`
- [ ] `user_subscriptions`
- [ ] Usage/billing tables exist if used (`api_usage_logs`, `usage_summaries`, etc.).
- [ ] `fraud_warnings` table exists for early fraud warnings:
- [ ] Checked via DB console or migration logs.
- [ ] **Migration strategy verified**
- [ ] Any migration scripts run successfully on staging.
- [ ] Same process is planned for production.
---
## 4. Webhook Setup
- [ ] **Production webhook endpoint configured in Stripe Dashboard**
- [ ] URL points to your production backend:
- e.g. `https://your-domain.com/api/subscription/webhook`
- [ ] Uses HTTPS.
- [ ] **Subscribed events include at least**
- [ ] `checkout.session.completed`
- [ ] `invoice.payment_succeeded`
- [ ] `invoice.payment_failed`
- [ ] `customer.subscription.updated`
- [ ] `customer.subscription.deleted`
- [ ] `radar.early_fraud_warning.created`
- [ ] (Optional) `radar.early_fraud_warning.updated`
- [ ] **Webhook secret set correctly**
- [ ] Copy live webhook signing secret from Stripe into `STRIPE_WEBHOOK_SECRET`.
- [ ] Confirm no test webhook secret is used in production.
- [ ] **Webhook endpoint health check**
- [ ] Trigger a test event from Stripe Dashboard (in a safe environment).
- [ ] Verify the backend logs show successful verification and handling.
---
## 5. Internal Admin Tools (Ops Readiness)
- [ ] **Admin roles/permissions**
- [ ] Confirm at least one admin user can access `/stripe-disputes`.
- [ ] Non-admin users cannot access sensitive endpoints (disputes, fraud warnings).
- [ ] **Disputes dashboard**
- [ ] `/stripe-disputes` loads without error.
- [ ] Disputes tab can:
- [ ] List disputes.
- [ ] Show dispute details.
- [ ] Submit evidence.
- [ ] Close a dispute.
- [ ] **Fraud Warnings tab**
- [ ] Fraud Warnings tab loads without error.
- [ ] List of early fraud warnings is visible when test EFWs exist.
- [ ] Details dialog shows:
- [ ] Issuer fraud type.
- [ ] Actionable flag.
- [ ] Internal status / actions.
- [ ] Buttons:
- [ ] “Refund Full Amount” works (in test/staging).
- [ ] “Mark as Ignored” works (updates status).
- [ ] **Ops team trained**
- [ ] Ops have read the Ops Guide.
- [ ] They understand:
- [ ] How to respond to disputes.
- [ ] When to proactively refund EFWs.
- [ ] When to escalate to engineering.
---
## 6. Manual Test Flows (Before Real Customers)
Perform these in **test** environment first, then in live with small amounts.
### 6.1 New Subscription Flow
- [ ] As a test user:
- [ ] Go to Pricing page.
- [ ] Select BASIC monthly (or equivalent).
- [ ] Start Stripe Checkout and complete payment with test card.
- [ ] You are redirected back to the success URL.
- [ ] Backend:
- [ ] Webhook logs show `checkout.session.completed` processed.
- [ ] `UserSubscription` updated with `stripe_customer_id` and `stripe_subscription_id`.
- [ ] Subscription status is `ACTIVE`.
### 6.2 Billing Portal
- [ ] From the app, open the billing portal (Customer Portal).
- [ ] `/api/subscription/create-portal-session` returns a URL.
- [ ] You can:
- [ ] View invoices.
- [ ] Update card details.
- [ ] Cancel a subscription.
- [ ] After cancellation:
- [ ] Webhook logs show `customer.subscription.deleted`.
- [ ] `UserSubscription` is updated to cancelled and not active.
### 6.3 Failed Payment (Test Mode)
- [ ] Use a known failing test card.
- [ ] Trigger a failed invoice.
- [ ] Verify:
- [ ] `invoice.payment_failed` processed.
- [ ] `UserSubscription` status is set to `PAST_DUE` and `is_active` is false.
### 6.4 Dispute (Test Mode)
- [ ] Create a test dispute in Stripes test mode.
- [ ] Confirm:
- [ ] Dispute appears in the Disputes tab.
- [ ] You can open details and submit evidence.
### 6.5 Early Fraud Warning (Test Mode)
- [ ] Create a test Early Fraud Warning (if supported in test mode or via Stripe tools).
- [ ] Confirm:
- [ ] EFW is ingested and appears in Fraud Warnings tab.
- [ ] Details dialog shows issuer `fraud_type` and `actionable` flag.
- [ ] “Refund Full Amount” works in test (Stripe shows charge refunded).
---
## 7. Rate Limiting and Abuse Protection
- [ ] **Checkout endpoint rate limiting**
- [ ] Confirm `create-checkout-session` applies per-user rate limits.
- [ ] Hitting the endpoint rapidly produces HTTP 429 and a log entry.
- [ ] **Monitoring for card testing**
- [ ] Logs for rate-limited events are visible in your logging system.
- [ ] You have a plan to investigate suspicious spikes (many 429s or many failed payments).
---
## 8. Monitoring & Alerts
- [ ] **Logging**
- [ ] Backend logs are centralized (e.g. in a logging service).
- [ ] Key Stripe flows (webhooks, disputes, fraud warnings) log useful context.
- [ ] **Basic alerting**
- [ ] At minimum, you can detect:
- [ ] Webhook failures.
- [ ] Unusually high dispute volume.
- [ ] Frequent early fraud warnings.
---
## 9. Final Production Switch
- [ ] **Keys double-checked**
- [ ] Production environment uses live Stripe keys and webhook secret.
- [ ] No references to test keys remain in production configs.
- [ ] **Test charge in live mode**
- [ ] Complete a small real transaction in live mode.
- [ ] Verify:
- [ ] Subscription is active.
- [ ] Internal dashboard reflects the subscription correctly.
- [ ] Refund/portal flows work as expected.
- [ ] **Ops sign-off**
- [ ] Ops team confirms they can use Disputes and Fraud Warnings tools comfortably.
Once all items are checked, you can consider the Stripe integration ready for production traffic.

View File

@@ -0,0 +1,242 @@
# Stripe Billing & Subscriptions Ops Team Guide
This guide is for non-technical operations and support staff. It explains how to use ALwritys internal Stripe tools to review payments, handle disputes, and respond to early fraud warnings.
You do **not** need to use the Stripe Dashboard for day-to-day work; use the internal tools described here.
---
## 1. Where to go in the app
- Sign in to ALwrity with your admin account.
- Open the internal Stripe dashboard:
- URL: `/stripe-disputes`
- You will see two tabs:
- **Disputes** for chargebacks / disputes raised by card issuers.
- **Fraud Warnings** for early fraud warnings (EFWs) where issuers suspect fraud before a dispute is filed.
If you cannot access this page:
- Your account might not be whitelisted as an admin. Contact the engineering team to check your email and role.
---
## 2. Disputes Tab Handling Chargebacks
When a customer disputes a payment with their bank, Stripe creates a **Dispute**. The Disputes tab helps you:
- See all disputes.
- Review details (amount, reason, charge ID).
- Submit evidence.
- Close disputes when needed.
### 2.1 Disputes List
The table shows:
- **ID** Stripes dispute ID (useful if support needs to talk to Stripe).
- **Amount** Disputed amount.
- **Status** Current status (e.g. `needs_response`, `under_review`, `won`, `lost`).
- **Reason** Banks reason (e.g. `fraudulent`, `product_not_received`).
- **Charge** The related Stripe charge ID.
- **Created** When the dispute was created.
Actions:
- **Refresh disputes** Reloads the list from Stripe.
- **Details** Opens the dispute details dialog.
- **Close** Shortcut to close the dispute (same as “Close Dispute” inside the dialog).
### 2.2 Dispute Details & Evidence
When you click **Details**, you see:
- **ID / Amount / Status / Reason / Charge / Created** Basic information summarizing the case.
- **Fraud Type** A dropdown where you classify the dispute:
- `Card testing` many small rapid attempts, usually bots testing cards.
- `Stolen card` customers card was used without permission.
- `Overpayment fraud` customer overpays and asks for a refund via another method.
- `Alternative refund` customer tries to get a payout via cash/crypto/bank transfer instead of back to card.
- `Other` anything else.
- **Customer Email / Name / IP** Fields to record known customer details.
- **Access Activity Log** Summary of account activity:
- Example:
- `"User logged in from IP 1.2.3.4, created 3 projects, downloaded 2 reports."`
- **Fraud Indicators / Notes** A free text area where you:
- Summarize what looks suspicious (or legitimate).
- Mention patterns like:
- Many failed attempts before one success.
- Overpayment + request for alternate refund.
- Different billing and login locations.
Buttons:
- **Submit Evidence**
- Sends your evidence to Stripe for this dispute.
- Use this when you want to **contest** the dispute and show that the charge is valid.
- **Close Dispute**
- Tells Stripe you are not going to submit more evidence.
- Use this if:
- The dispute is clearly correct (e.g. genuine mistake).
- The amount is lower than the dispute fee and not worth contesting.
Tips:
- Be specific and factual in evidence:
- “User logged in and used the product for 3 days” is better than “Looks fine”.
- Use the **Fraud Type** dropdown to tag cases consistently; it helps the team see patterns.
---
## 3. Fraud Warnings Tab Early Fraud Warning (EFW)
An **Early Fraud Warning** is a signal from the card issuer that a charge may be fraudulent, before a dispute is created.
The Fraud Warnings tab helps you:
- See EFWs for our charges.
- Decide whether to proactively refund to avoid a later dispute.
- Record decisions and notes.
### 3.1 Fraud Warnings List
Columns:
- **ID** The Early Fraud Warning ID from Stripe.
- **Charge** Related Stripe charge ID.
- **Amount** Charge amount.
- **Status** Our internal status:
- `open` Needs review.
- `refunded` We proactively refunded the card.
- `ignored` We reviewed and decided not to refund.
- **Action** The latest action taken (`none`, `refund_full`, `ignored`).
- **Created** When the warning was created.
Actions:
- **Refresh warnings** Reloads current open warnings.
- **Details** Opens the warning details dialog.
### 3.2 Fraud Warning Details and Actions
Inside the details dialog you see:
- **ID / Charge / Amount** Basic reference info.
- **Status / Action** Current state and last action taken.
- **Created / Last Action At** Timeline.
- **Issuer Fraud Type** What the bank believes is happening (e.g. `made_with_stolen_card`).
- **Actionable** Indicates whether Stripe considers this warning still actionable:
- “Yes” No full refund yet and no dispute; you can still act.
- “No” It has either been refunded or disputed already.
- **Action Notes** Free text for internal reasoning.
Buttons:
- **Refund Full Amount**
- Sends a full refund for the underlying charge via Stripe.
- Sets status to `refunded` and action to `refund_full`.
- Use this when:
- The charge amount is relatively small (similar to or less than your dispute fee).
- The warning and behavior strongly suggest fraud (e.g. stolen card, clear card testing).
- **Mark as Ignored**
- Marks the warning as `ignored` without refund.
- Use this when:
- You believe the charge is legitimate.
- The user has confirmed the purchase, or your internal logs show normal behavior.
- **Close**
- Closes the dialog only (no changes to Stripe or status).
Notes:
- You can add or update **Action Notes** before clicking Refund or Mark as Ignored:
- Example:
- `"Customer confirmed via support email that they made this purchase."`
- `"High risk: many failed attempts, unusual IP, amount small refunding to avoid dispute."`
---
## 4. How to Decide: Refund vs Ignore
These are general guidelines; when in doubt, coordinate with product/engineering.
### 4.1 When to Consider Proactive Refund
- The amount is **small**, roughly in the range of the expected dispute fee.
- The pattern clearly matches fraud:
- Many rapid attempts with different cards or card numbers.
- Charge is from a suspicious IP/country inconsistent with user profile.
- Issuer fraud type suggests stolen or counterfeit card.
- The user is not reachable or does not respond to your messages.
In these cases:
- Use **Fraud Warnings → Details → Refund Full Amount**.
- Add a short note explaining why:
- `"EFW flagged as made_with_stolen_card; small charge; refunding proactively."`
### 4.2 When to Ignore (No Proactive Refund)
- The customer confirms they made the purchase.
- Your logs show normal use of the product:
- Regular logins, content creation, downloads.
- Amount is large and there is no strong sign of fraud:
- In this case you typically wait and, if a dispute occurs, respond with strong evidence.
In these cases:
- Use **Fraud Warnings → Details → Mark as Ignored**.
- Add notes:
- `"Customer confirmed via email; usage patterns normal; ignoring EFW."`
---
## 5. Things You Should Not Do
- Do **not** send refunds via:
- Bank transfer
- Cash
- Crypto
- Any method outside Stripe
Always refund via Stripe so:
- The cardholder is repaid correctly.
- Issuers see the refund related to the original charge.
If someone asks for a different refund method, treat it as a potential **overpayment** or **alternative refund** scam and escalate to the team.
---
## 6. When to Escalate to Engineering
Contact engineering when:
- You see a sudden **spike in disputes** or fraud warnings.
- The internal dashboard shows errors when:
- Loading disputes/fraud warnings.
- Submitting evidence.
- Refunding/ignoring warnings.
- You need a new flow:
- Example: new product or plan changes that alter how subscriptions work.
Provide:
- Screenshot of the issue.
- Dispute ID or Fraud Warning ID.
- A short description of what you were trying to do.
---
## 7. Quick Reference
- **Disputes Tab**
- Use to respond to formal disputes.
- Add evidence and close disputes when appropriate.
- **Fraud Warnings Tab**
- Use to review early fraud warnings.
- Decide whether to refund or ignore.
- **Action Notes**
- Always record a short reason when you refund or ignore.
If you follow this guide, you will help protect the business from fraud while treating legitimate customers fairly.

View File

@@ -0,0 +1,431 @@
# Anime Story Bible Design & Implementation (Story Writer SSOT)
This document is the single source of truth for the **Anime Story Bible** in Story Writer: what it is, where it lives in the codebase, how it is generated, and how it is used across outline, story text, images, and motion/animation.
---
## 1. Core Concepts
- **Anime Story Bible**: Structured description of characters, world, and visual style for anime-style stories. It is designed to be:
- Stable across the whole story (single bible per story)
- Machine-readable (Pydantic/TypeScript models)
- Reusable for text, image, and video prompts
- **Design Goals**
- Maintain **character consistency** across scenes and media
- Maintain **world rules** (tech/magic, constraints) throughout the narrative
- Maintain a **coherent anime visual style** across images and motion clips
- Allow future reuse for other story templates and media pipelines
---
## 2. Data Model (Backend & Frontend)
### 2.1 Backend Models
File: [story_models.py](file:///c:/Users/diksha%20rawat/Desktop/ALwrity/backend/models/story_models.py)
Key classes:
- `AnimeCharacter`
- `AnimeWorld`
- `AnimeVisualStyle`
- `AnimeStoryBible`
They are defined as:
- `AnimeCharacter`
- `id`: stable snake_case identifier
- `name`
- `age_range`
- `role` (protagonist, antagonist, mentor, etc.)
- `look` (key visual details)
- `outfit_palette`
- `personality_tags: List[str]`
- `AnimeWorld`
- `setting` (locations and general world description)
- `era` (near-future, alt 1990s, etc.)
- `tech_or_magic_level`
- `core_rules: List[str]` (constraints and consistent rules)
- `AnimeVisualStyle`
- `style_preset` (anime_manga, cinematic_anime, cozy_slice_of_life, etc.)
- `camera_style`
- `color_mood`
- `lighting`
- `line_style`
- `extra_tags: List[str]`
- `AnimeStoryBible`
- `story_id?: str`
- `main_cast: List[AnimeCharacter]`
- `world: AnimeWorld`
- `visual_style: AnimeVisualStyle`
The bible is attached to:
- `StorySetupOption.anime_bible: Optional[AnimeStoryBible]`
- `StoryOutlineResponse.anime_bible: Optional[AnimeStoryBible]`
Additionally, for downstream usage:
- `StoryGenerationRequest.anime_bible: Optional[Dict[str, Any]]`
- `StoryContinueRequest.anime_bible: Optional[Dict[str, Any]]`
This allows story-start and continuation to receive a JSON-serializable bible blob without strict coupling to the `AnimeStoryBible` class.
### 2.2 Frontend Models
File: [storyWriterApi.ts](file:///c:/Users/diksha%20rawat/Desktop/ALwrity/frontend/src/services/storyWriterApi.ts)
Types mirror the backend:
- `AnimeCharacter`
- `AnimeWorld`
- `AnimeVisualStyle`
- `AnimeStoryBible`
The bible flows through:
- `StoryOutlineResponse.anime_bible?: AnimeStoryBible`
- `StoryGenerationRequest.anime_bible?: AnimeStoryBible | null`
- `StoryStartRequest` and `StoryContinueRequest` (via `StoryGenerationRequest`)
State layer:
File: [useStoryWriterState.ts](file:///c:/Users/diksha%20rawat/Desktop/ALwrity/frontend/src/hooks/useStoryWriterState.ts)
- `StoryWriterState.animeBible: any | null`
- `setAnimeBible` setter
- Persisted and restored via `localStorage`:
- Saved under `animeBible` key in the serialized state
- Ensures the bible survives refreshes
---
## 3. Bible Lifecycle & Generation
### 3.1 Generation Source
The Anime Story Bible is generated in the **story setup / outline** pipeline on the backend:
- The story setup step produces a single `StorySetupOption` enriched with `anime_bible` when the selected template is anime-focused.
- The outline generation step (`StoryOutlineResponse`) carries `anime_bible` so the UI can display it and store it in Story Writer state.
SSOT:
- Models: [story_models.py](file:///c:/Users/diksha%20rawat/Desktop/ALwrity/backend/models/story_models.py)
- Outline response: `StoryOutlineResponse.anime_bible`
### 3.2 Frontend Storage and Access
The frontend receives `anime_bible` from `StoryOutlineResponse` and:
- Stores it in `StoryWriterState.animeBible`
- Persists it in `localStorage` with the rest of the story writer state
- Exposes it to:
- Director chip / bible viewer UI
- Story generation (start/continue)
- Scene animation (via `story_context`)
Key locations:
- State hook: [useStoryWriterState.ts](file:///c:/Users/diksha%20rawat/Desktop/ALwrity/frontend/src/hooks/useStoryWriterState.ts)
- Outline phase UI: [StoryOutline.tsx](file:///c:/Users/diksha%20rawat/Desktop/ALwrity/frontend/src/components/StoryWriter/Phases/StoryOutline.tsx)
---
## 4. Integration Points (Current Implementation)
This section documents how the Anime Story Bible is currently used across the Story Writer pipelines.
### 4.1 Story Text Generation (Start & Continue)
#### 4.1.1 Requests
Frontend:
- `StoryGenerationRequest` (base)
- Now includes `anime_bible?: AnimeStoryBible | null`
- `getRequest()` in `useStoryWriterState` adds `anime_bible` automatically:
- [useStoryWriterState.ts:getRequest](file:///c:/Users/diksha%20rawat/Desktop/ALwrity/frontend/src/hooks/useStoryWriterState.ts#L420-L461)
Story start:
- `StoryWriting.handleGenerateStart`:
- Builds `request = state.getRequest()`
- Calls `storyWriterApi.generateStoryStart(premise, outline, request)`
- [StoryWriting.tsx](file:///c:/Users/diksha%20rawat/Desktop/ALwrity/frontend/src/components/StoryWriter/Phases/StoryWriting.tsx#L308-L328)
Story continue:
- `StoryWriting.handleContinue`:
- `request = state.getRequest()`
- Builds `continueRequest = { ...request, premise, outline, story_text }`
- Calls `storyWriterApi.continueStory(continueRequest)`
- [StoryWriting.tsx](file:///c:/Users/diksha%20rawat/Desktop/ALwrity/frontend/src/components/StoryWriter/Phases/StoryWriting.tsx#L377-L388)
Backend:
- `StoryGenerationRequest` / `StoryContinueRequest` include `anime_bible: Optional[Dict[str, Any]]`
- [story_models.py](file:///c:/Users/diksha%20rawat/Desktop/ALwrity/backend/models/story_models.py#L11-L43)
- [story_models.py](file:///c:/Users/diksha%20rawat/Desktop/ALwrity/backend/models/story_models.py#L243-L259)
#### 4.1.2 Routing Layer
File: [api/story_writer/routes/story_content.py](file:///c:/Users/diksha%20rawat/Desktop/ALwrity/backend/api/story_writer/routes/story_content.py)
- `generate_story_start`:
```python
story_start = story_service.generate_story_start(
premise=request.premise,
outline=outline_data,
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,
ending_preference=request.ending_preference,
story_length=story_length,
anime_bible=getattr(request, "anime_bible", None),
user_id=user_id,
)
```
- `continue_story`:
```python
continuation = story_service.continue_story(
premise=request.premise,
outline=outline_data,
story_text=request.story_text,
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,
ending_preference=request.ending_preference,
anime_bible=getattr(request, "anime_bible", None),
story_length=story_length,
user_id=user_id,
)
```
#### 4.1.3 Service Layer Prompts
File: [story_content.py](file:///c:/Users/diksha%20rawat/Desktop/ALwrity/backend/services/story_writer/service_components/story_content.py)
- `StoryContentMixin.generate_story_start(...)` now accepts `anime_bible: Optional[Dict[str, Any]]` and injects a serialized bible block right after the persona prompt:
```python
anime_bible_context = ""
if anime_bible:
try:
serialized_bible = json.dumps(anime_bible, ensure_ascii=False, indent=2)
except Exception:
serialized_bible = str(anime_bible)
anime_bible_context = f"""
You also have a structured ANIME STORY BIBLE that defines the main cast, world rules, and visual style. Use it as a hard constraint for character consistency, worldbuilding, and visual storytelling:
{serialized_bible}
"""
```
The context is included for both short-story and longer-story prompts. This ensures:
- Character, world, and style constraints are explicitly visible to the LLM
- The same bible is applied consistently for start and continuation
- `StoryContentMixin.continue_story(...)` similarly accepts `anime_bible` and injects the same `anime_bible_context` into the continuation prompt, directly after `persona_prompt`.
This means every text generation step (start and continue) is conditioned on the bible when present.
### 4.2 Scene Animation (Image-to-Video)
The current bible-aware integration is focused on **motion prompts** for Kling image-to-video.
#### 4.2.1 Frontend: story_context payload
File: [StoryOutline.tsx](file:///c:/Users/diksha%20rawat/Desktop/ALwrity/frontend/src/components/StoryWriter/Phases/StoryOutline.tsx)
`createStoryContextPayload` includes `anime_bible`:
```ts
const createStoryContextPayload = () => ({
persona: state.persona,
story_setting: state.storySetting,
characters: state.characters,
plot_elements: state.plotElements,
writing_style: state.writingStyle,
story_tone: state.storyTone,
narrative_pov: state.narrativePOV,
audience_age_group: state.audienceAgeGroup,
content_rating: state.contentRating,
story_length: state.storyLength,
premise: state.premise,
outline: state.outline,
story_content: state.storyContent,
anime_bible: state.animeBible,
});
```
This payload is passed to:
- `storyWriterApi.animateScene(...)`
- `storyWriterApi.animateSceneVoiceover(...)`
#### 4.2.2 Backend: Kling animation service
File: [kling_animation.py](file:///c:/Users/diksha%20rawat/Desktop/ALwrity/backend/services/wavespeed/kling_animation.py)
- `animate_scene_image(...)` is unchanged in signature but passes `story_context` to `generate_animation_prompt(...)`.
- `_build_fallback_prompt(scene_data, story_context)`:
- Reads `anime_bible = story_context.get("anime_bible")`
- Extracts:
- Visual style details (`style_preset`, `camera_style`, `color_mood`, `lighting`, `line_style`, `extra_tags`)
- World `setting`
- `main_cast` names
- Appends a concise style text to the deterministic fallback prompt to preserve:
- Visual style
- World flavor
- Character design consistency (names only)
- `generate_animation_prompt(scene_data, story_context, user_id)`:
- Builds an `ANIME STORY BIBLE VISUAL GUIDANCE` block when `anime_bible` is present, e.g.:
- Visual style preset and camera style
- Color mood, lighting, line style
- Extra style tags
- Main cast names to keep visually consistent
- World/setting context
- Inserts this block between `Setting` and the “Focus on” bullet list in the LLM prompt.
- Both structured JSON responses and fallback text generation flows see this block.
Result:
- Motion prompts for Kling image-to-video are constrained by the bible, making animations conform to:
- The same anime visual style
- The same character set
- The same world tone
### 4.3 Images (Current State)
Image generation currently uses:
- `StoryScene.image_prompt` generated during outline generation
- Image provider settings (width, height, model)
The anime bible is not yet used in a **second-pass image prompt rewriter**. However:
- The bible is already aligned with the same outline and template that produced `image_prompt`.
- The bible is threaded into Story Writers state and can be used later to refine image prompts or add style constraints.
Planned enhancement (not yet implemented):
- Add a lightweight prompt refinement step that:
- Takes `scene.image_prompt` + `AnimeStoryBible.visual_style`
- Emits a style-constrained `final_image_prompt`
- Passes that to the image generation service
This document should be updated when that enhancement is implemented.
---
## 5. Adapting the Bible to Other Story Types
Although the Anime Story Bible is currently wired for anime-focused stories, the architecture is intentionally reusable.
### 5.1 Reuse Strategy
- **Data model**:
- `AnimeStoryBible` is anime-specific, but the concept of a structured “story bible” (cast + world + style) is general.
- Future story types can introduce sibling models (e.g., `NovelStoryBible`, `ComicStoryBible`) reusing similar patterns.
- **Transport layer**:
- Requests use a flexible `anime_bible: Optional[Dict[str, Any]]` at the story-generation level.
- This can be generalized to a `story_bible` field if we want cross-template reuse.
- **Prompt patterns**:
- Both text and motion pipelines use the same pattern:
- Serialize the bible
- Inject a dedicated paragraph or bullet block
- Explicitly instruct the model to treat it as a hard constraint
- This pattern is template-agnostic and can be reused for other story modes.
### 5.2 Extension Guidelines
When adapting the bible pattern to other story types:
1. **Define the bible model**
- Add a dedicated Pydantic model under `story_models.py`.
- Mirror it with a TypeScript interface in `storyWriterApi.ts`.
2. **Attach it to responses**
- Extend the relevant response models (setup, outline, etc.) with an optional bible field.
- Ensure the generating service populates it when the template supports it.
3. **Thread through state**
- Add a field to `StoryWriterState`.
- Persist it in `localStorage`.
- Provide setter(s) and ensure it is included in `getRequest()` when relevant.
4. **Inject into prompts**
- Text: add a serialized bible context block after the persona prompt for:
- Story start
- Story continuation
- Media: add a structured guidance block to:
- Image prompt generation (if using AI to build prompts)
- Motion/animation prompts
5. **Document the flow**
- Update this document (or a sibling doc) with:
- Model definitions
- Where the bible is generated
- Where and how it is injected into prompts
---
## 6. Summary of Recent Changes (Bible Wiring)
This section summarizes the concrete changes made for the initial Anime Story Bible integration:
- **Backend models**
- Added `anime_bible` to `StoryGenerationRequest` and `StoryContinueRequest` as `Optional[Dict[str, Any]]`.
- Confirmed `AnimeStoryBible` and related classes in [story_models.py](file:///c:/Users/diksha%20rawat/Desktop/ALwrity/backend/models/story_models.py).
- **Frontend models & state**
- Added `anime_bible?: AnimeStoryBible | null` to `StoryGenerationRequest`.
- `useStoryWriterState.getRequest()` now includes `anime_bible: state.animeBible || null`.
- `createStoryContextPayload` for outline/animation includes `anime_bible: state.animeBible`.
- **Story text prompts**
- `generate_story_start` and `continue_story` accept `anime_bible` and inject a serialized “ANIME STORY BIBLE” context block directly after `persona_prompt`.
- Routing layer passes `request.anime_bible` through from API to service.
- **Motion prompts (Kling image-to-video)**
- `story_context.anime_bible` is used in:
- `_build_fallback_prompt` to append style/world/cast hints to deterministic prompts.
- `generate_animation_prompt` to add an explicit `ANIME STORY BIBLE VISUAL GUIDANCE` block for the LLM.
- **Not yet implemented**
- Second-pass enrichment of per-scene `image_prompt` using the bible.
- Generalization beyond anime templates (would require broader “story bible” abstraction).
This document should be kept up to date whenever the Anime Story Bible is:
- Extended to new story templates
- Used for additional media types (e.g., storyboard export, trailers)
- Modified in structure or prompt integration

View File

@@ -0,0 +1,337 @@
# Story Studio Single Source of Truth (SSOT)
## 1. Vision and Positioning
- **Product name**: Story Studio (evolution of Story Writer).
- **What it is**: A narrative engine plus lightweight movie studio that turns brand and product context into structured stories, scenes, and multimedia assets.
- **Role in ALwrity**: One of several “surfaces” driven by the same contextual brain (onboarding data, personas, SEO, competitive analysis), alongside Blog Writer, YouTube Creator, Podcast Maker, and Video Studio.
### Core positioning
- **Campaign Story Engine** (primary):
- Generates product stories, brand manifestos, founder narratives, and customer journey stories.
- Uses onboarding + persona + SEO context so stories are on-brand and strategically aligned.
- **Story Studio / Fiction mode** (secondary):
- High-quality freeform stories (including anime / fantasy), primarily for GSC traffic and creative users.
- Lives “one click deeper” in the UI so marketing-first use cases stay front and center.
Story Studio is not just “a novelist playground”; it is the narrative layer that can feed:
- YouTube Creator (scripts and scenes),
- Podcast Maker (episodic story arcs),
- Video Studio (scene-wise video generation),
- Blog and social content (campaign narratives).
---
## 2. Modes and Templates
### 2.1 Modes
- **Marketing Narratives (default)**:
- Entry path for ALwrity users arriving from the main app.
- Story types:
- Product Story (feature-driven but narrative-first).
- Brand Manifesto / “Why we exist”.
- Founder Story / Mission Arc.
- Customer Case Story (problem → journey → outcome).
- All marketing modes must:
- Pull from onboarding canonical_profile (brand, product, audience, pain points).
- Respect persona tone and style.
- Optionally weave in SEO pillars and competitor differentiation language.
- **Pure Story Mode (creative)**:
- Entry path for GSC traffic and direct Story Studio users.
- Story types:
- Freeform fiction.
- Anime / manga stories.
- Experimental narrative formats.
- Marketing alignment:
- Gently surfaces the idea: “Turn this into a video / campaign” via export options.
### 2.2 Template system
For each template, define:
- Required fields: goal, target audience, setting, length, POV, style, tone.
- Optional fields: persona to anchor voice, product/context attachments.
- Output shape:
- Premise (12 sentences).
- Outline (scenes with structured metadata).
- Story text (segments).
- Multimedia plan (per scene: image prompt, audio cue, video emphasis).
Initial templates:
- **Product Story**:
- Uses onboarding product data (features, benefits, differentiators).
- Structure: origin → tension → solution → proof → future.
- **Brand Manifesto**:
- Uses canonical_profile (mission, values, audience).
- Structure: problem with status quo → belief → promise → invitation.
- **Founder Story**:
- Uses founder/persona context if available, otherwise guided questionnaire.
- Structure: “before” life → trigger moment → struggle → insight → creation of the product.
- **Customer Case Story**:
- Uses ICP + persona + pain-point data.
- Structure: “Customer X had problem Y” → discovery of solution → implementation → quantified outcome.
- **Anime / Storyverse**:
- Style, visuals, and multimedia prompts oriented toward anime / stylized output.
---
## 3. Context Inputs (Data and Signals)
Story Studio should treat ALwritys context as first-class inputs:
- **Onboarding / canonical_profile**:
- brand_pitch, positioning, audience, products/services, content pillars.
- **Personas**:
- core persona plus platform adaptations (tone, structure, constraints).
- **SEO and competitors**:
- core keywords, sitemap analysis, competitive positioning and gaps.
- **User preferences**:
- story length, style, tone, POV, “safe vs experimental”, multimedia intensity.
Implementation:
- Extend Story Setup to optionally show:
- “Use my brand & product data” toggle.
- Dropdown/select for which persona to write from.
- Pre-filled prompts using SEO pillars and competitor gaps.
- State builder (`getRequest()` in useStoryWriterState) should:
- Attach canonical_profile / persona IDs or snapshots when marketing templates are used.
---
## 4. Text Model Strategy
### 4.1 Roles
- **Structured outline + scenes**:
- Use `llm_text_gen` with `json_struct` for:
- Scene lists (scene_number, title, description).
- Per-scene image_prompt, audio_narration, character_descriptions, key_events.
- Requirements:
- High reliability on JSON output.
- Good adherence to scene progression instructions.
- **Narrative generation (story text)**:
- Use `llm_text_gen` in text mode for:
- Premise, story start, continuation until word-count targets.
- Requirements:
- Style controllability (tone, genre, age group).
- Long-context coherence for medium/long stories.
### 4.2 Providers and models
Use the existing provider abstraction:
- **OSS/primary**: HF-backed models (Llama 3.1 8B, Mistral 7B, Qwen2.5) for cost-effective long-form and experimental modes.
Decisions:
- Keep model selection internal for now (no UI dropdown).
- Introduce a simple “Quality vs Speed vs Cost” preset internally mapped to:
- High quality: Gemini or best OSS model.
- Balanced: OSS instruct model (e.g., Llama 3.1 8B, Mistral 7B).
- Experimental: more creative / higher-temperature models.
Story length control remains as defined in story-writer-architecture.mdc:
- Short (~1k words): one-shot.
- Medium (<5k): multi-step.
- Long (~10k): multi-step with IAMDONE marker.
---
## 5. Multimedia Model Strategy
Multimedia generation must reuse existing studio infrastructure rather than invent new stacks.
### 5.1 Images
- Use StoryImageGenerationService as primary:
- Provider: stability / other configured HF/Gemini image models.
- Inputs: per-scene `image_prompt` derived from outline and template.
- Templates:
- Realistic marketing visuals (product-focused).
- Anime / stylized visuals (for anime mode).
### 5.2 Audio
- Use StoryAudioGenerationService:
- Providers: gTTS, pyttsx3, or any configured TTS provider.
- Inputs: `audio_narration` per scene (or compressed scene text).
- For campaign stories:
- Voice selection aligned with brand persona (calm, energetic, authoritative).
### 5.3 Video
- Use StoryVideoGenerationService for stitching scenes:
- Inputs:
- `image_urls` or `video_urls` per scene.
- `audio_urls` per scene.
- `fps`, `transition_duration`.
- For advanced/hero scenes, optionally integrate Video Studio models:
- text-to-video models (Hunyuan, LTX-2) for key moments.
- image-to-video (WAN 2.5, Kandinsky 5 Pro) for stylized sequences.
### 5.4 Modes
- **Standard story video**:
- Use existing story video pipeline.
- Emphasis on timeline, readability, and voiceover.
- **Anime / stylized video**:
- Switch scene prompts + model selection to anime-friendly setups.
- Use more dynamic transitions for “story trailer” feel.
---
## 6. Phase Flow (Reframed for Story Studio)
Core phases remain as in story-writer-architecture.mdc but with clarified responsibilities:
1. **Setup**:
- Choose mode: Marketing Narrative vs Pure Story.
- Select template (Product Story, Brand Manifesto, etc.).
- Toggle/use brand context (onboarding, personas, SEO).
- Configure image/audio/video settings.
2. **Outline**:
- Generate structured outline with scenes via `json_struct`.
- For marketing templates:
- Ensure each scene maps to a stage in the campaign story arc.
- For anime/fiction:
- Emphasize “world, characters, conflicts” and visual richness.
3. **Writing**:
- Generate story text based on length settings.
- Enforce length controls and completion detection.
- Show word count and target in UI.
4. **Multimedia / Export**:
- Generate scene images, audio, and video using configured settings.
- Allow:
- Export as story video (using StoryVideoGenerationService).
- Export scenes and script to:
- YouTube Creator (for adaptation to YouTube-specific script + scenes).
- Video Studio (for advanced editing or alternative visual styles).
5. **Reset**:
- Keep existing reset semantics (clears state, localStorage, phases).
---
## 7. Implementation Plan
### Phase 1 Positioning and UX tweaks
- Rename surface in UI copy to “Story Studio” where appropriate (keep routes/IDs stable to preserve GSC).
- Add mode selector in Setup:
- Marketing Narratives (default).
- Pure Story.
- Add template selector for marketing mode:
- Product Story, Brand Manifesto, Founder Story, Customer Story.
- Adjust landing screen messaging to emphasize:
- “Turn your brand and product into narrative campaigns.”
### Phase 2 Context integration
- Extend Story Setup state to store:
- `useBrandContext` flag.
- `selectedPersonaId` (optional).
- `selectedProductId` or product context stub.
- Update `getRequest()` to include:
- References or snapshots from canonical_profile (where available).
- Update backend story_service to:
- Accept and pass context into prompts (premise, outline, story start, continuation).
### Phase 3 Template-specific prompts
- Design prompt templates per marketing template:
- Product Story, Brand Manifesto, Founder Story, Customer Story.
- Encode:
- Clear arcs (setup → conflict → resolution).
- Use of brand and persona parameters.
- Explicit constraints on tone, length, POV.
- For pure story mode:
- Provide genre/style toggles, including anime.
### Phase 4 Multimedia presets
- Define multimedia presets for:
- Marketing Story (clean product visuals, muted transitions).
- Anime Story (bold colors, stylized prompts, dynamic transitions).
- Trailer Mode (shorter, punchier video composition).
- Map presets to:
- Image generation prompt scaffolds.
- Video settings (fps, transition_duration).
- Potential future mapping into Video Studio model choices.
### Phase 5 Cross-surface exports
- Implement export from Story Studio to:
- YouTube Creator:
- Export scenes as a JSON payload that can seed plan/scenes.
- Video Studio:
- Export scenes with associated image/audio references to pre-populate a project.
- Add UI affordances:
- “Send to YouTube Creator”.
- “Open in Video Studio”.
### Phase 6 Hardening and metrics
- Ensure:
- Subscription / usage checks per story generation and multimedia call.
- Caching behavior for repeated outline/story generations when context unchanged.
- Task management and polling remain consistent with existing story_writer architecture.
- Add metrics:
- How many Story Studio sessions start in marketing vs pure modes.
- How many flows export to YouTube/Video Studio.
---
## 9. Current Implementation Status (Feb 2026)
- Modes and templates:
- Marketing vs Pure Story modes are wired through Story Setup state and backend prompts.
- Template identifiers (e.g. `product_story`, `brand_manifesto`, `founder_story`, `customer_story`, `short_fiction`, `long_fiction`, `anime_fiction`, `experimental_fiction`) are resolved in service components for label-aware prompts.
- Context integration:
- Story context endpoint exposes `canonical_profile`-derived `brand_context` and `brand_assets` for Story Studio.
- Story Setup modal can toggle use of onboarding brand persona when in marketing mode, and surfaces avatar and voice preview where available.
- AI Setup modal:
- Dedicated AI Setup modal drives an idea → setup flow.
- “Enhance Story Idea” uses a dedicated backend endpoint to enhance only the freeform idea text, preserving intent and avoiding premature setup field generation.
- “Continue to Story Setup” generates exactly three structured setup options and, on selection, pre-fills Story Setup fields (persona, setting, characters, plot, style, tone, POV, audience, rating, ending, length, premise) plus image/video/audio defaults.
- UI includes rotating, mode-aware idea placeholders and an AI-style, animated glow frame around the story idea textarea to emphasize the primary input surface while keeping the overall theme light and readable.
- Multimedia defaults:
- Story Setup state carries image/video/audio settings and can accept per-option overrides from generated setups.
- Story Outline and StoryImageGenerationModal integrate with the shared image generation stack, including provider/model selection and cost-awareness.
- Video integrations:
- HD video configuration section exposes provider/model dropdowns with story-based defaults, aligned with the broader Video Studio architecture.
---
## 10. Next Steps for Story Studio
- Deepen template-specific prompts:
- Tighten prompt templates per marketing template so setup options encode clearer arcs and brand positioning, especially for product_story and customer_story.
- Expand fiction/anime prompt variants to better control pacing and recurring characters across scenes.
- Refine idea-to-setup bridge:
- Add light-touch guidance in the AI Setup modal to help users iterate between idea enhancement and setup generation (e.g. suggested follow-up edits based on the last enhancement).
- Capture telemetry on how often users enhance the idea before generating setups to tune prompts and thresholds.
- Multimedia and cross-surface flows:
- Wire Story Studios outline and scene structures into “Send to YouTube Creator” and “Open in Video Studio” actions, including carrying over multimedia presets.
- Introduce preset bundles for “Standard Story”, “Anime Story”, and “Trailer Mode” that automatically adjust image/video defaults and, where available, Video Studio model choices.
- Reliability and guardrails:
- Add targeted monitoring around the AI Setup endpoints (idea enhancement and setup generation) for latency, error rates, and JSON validity.
- Continue to enforce subscription and usage checks consistently across Story Studio, Blog Writer, and Video Studio so billing and limits remain unified.
---
## 8. Guardrails and Non-Goals
- Do not:
- Turn Story Studio into a separate codebase; keep it within the existing Story Writer architecture and services.
- Fragment context handling; continue to use canonical_profile and existing persona services as SSOTs.
- Guardrails:
- Maintain strict story length controls.
- Keep subscription handling centralized via `triggerSubscriptionError`.
- Continue to use structured JSON for outlines and scene metadata wherever possible.
This document, together with `story-writer-architecture.mdc` and the existing Story Writer implementation docs in `docs/story writer/`, serves as the SSOT for evolving Story Writer into Story Studio.

View File

@@ -17,6 +17,8 @@
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.0", "@mui/icons-material": "^5.15.0",
"@mui/material": "^5.15.0", "@mui/material": "^5.15.0",
"@stripe/react-stripe-js": "^5.6.0",
"@stripe/stripe-js": "^8.7.0",
"@tanstack/react-query": "^5.87.1", "@tanstack/react-query": "^5.87.1",
"@types/react": "^18.2.0", "@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0", "@types/react-dom": "^18.2.0",
@@ -5161,6 +5163,29 @@
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@stripe/react-stripe-js": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.6.0.tgz",
"integrity": "sha512-tucu/vTGc+5NXbo2pUiaVjA4ENdRBET8qGS00BM4BAU8J4Pi3eY6BHollsP2+VSuzzlvXwMg0it3ZLhbCj2fPg==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.7.2"
},
"peerDependencies": {
"@stripe/stripe-js": ">=8.0.0 <9.0.0",
"react": ">=16.8.0 <20.0.0",
"react-dom": ">=16.8.0 <20.0.0"
}
},
"node_modules/@stripe/stripe-js": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.7.0.tgz",
"integrity": "sha512-tNUerSstwNC1KuHgX4CASGO0Md3CB26IJzSXmVlSuFvhsBP4ZaEPpY4jxWOn9tfdDscuVT4Kqb8cZ2o9nLCgRQ==",
"license": "MIT",
"engines": {
"node": ">=12.16"
}
},
"node_modules/@surma/rollup-plugin-off-main-thread": { "node_modules/@surma/rollup-plugin-off-main-thread": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",

View File

@@ -13,6 +13,8 @@
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.0", "@mui/icons-material": "^5.15.0",
"@mui/material": "^5.15.0", "@mui/material": "^5.15.0",
"@stripe/react-stripe-js": "^5.6.0",
"@stripe/stripe-js": "^8.7.0",
"@tanstack/react-query": "^5.87.1", "@tanstack/react-query": "^5.87.1",
"@types/react": "^18.2.0", "@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0", "@types/react-dom": "^18.2.0",

View File

@@ -12,6 +12,7 @@ import FacebookWriter from './components/FacebookWriter/FacebookWriter';
import LinkedInWriter from './components/LinkedInWriter/LinkedInWriter'; import LinkedInWriter from './components/LinkedInWriter/LinkedInWriter';
import BlogWriter from './components/BlogWriter/BlogWriter'; import BlogWriter from './components/BlogWriter/BlogWriter';
import StoryWriter from './components/StoryWriter/StoryWriter'; import StoryWriter from './components/StoryWriter/StoryWriter';
import { StoryProjectList } from './components/StoryWriter/StoryProjectList';
import YouTubeCreator from './components/YouTubeCreator/YouTubeCreator'; import YouTubeCreator from './components/YouTubeCreator/YouTubeCreator';
import { CreateStudio, EditStudio, UpscaleStudio, ControlStudio, SocialOptimizer, AssetLibrary, ImageStudioDashboard, FaceSwapStudio, CompressionStudio, ImageProcessingStudio } from './components/ImageStudio'; import { CreateStudio, EditStudio, UpscaleStudio, ControlStudio, SocialOptimizer, AssetLibrary, ImageStudioDashboard, FaceSwapStudio, CompressionStudio, ImageProcessingStudio } from './components/ImageStudio';
import { import {
@@ -48,6 +49,7 @@ import IntentResearchTest from './pages/IntentResearchTest';
import SchedulerDashboard from './pages/SchedulerDashboard'; import SchedulerDashboard from './pages/SchedulerDashboard';
import BillingPage from './pages/BillingPage'; import BillingPage from './pages/BillingPage';
import ApprovalsPage from './pages/ApprovalsPage'; import ApprovalsPage from './pages/ApprovalsPage';
import StripeDisputesDashboard from './pages/StripeDisputesDashboard';
import ProtectedRoute from './components/shared/ProtectedRoute'; import ProtectedRoute from './components/shared/ProtectedRoute';
import GSCAuthCallback from './components/SEODashboard/components/GSCAuthCallback'; import GSCAuthCallback from './components/SEODashboard/components/GSCAuthCallback';
import Landing from './components/Landing/Landing'; import Landing from './components/Landing/Landing';
@@ -170,8 +172,7 @@ const AuthenticatedCopilotWrapper: React.FC<{
// Flow: Subscription → Onboarding → Dashboard // Flow: Subscription → Onboarding → Dashboard
const InitialRouteHandler: React.FC = () => { const InitialRouteHandler: React.FC = () => {
const { loading, error, isOnboardingComplete, initializeOnboarding, data } = useOnboarding(); const { loading, error, isOnboardingComplete, initializeOnboarding, data } = useOnboarding();
const { subscription, loading: subscriptionLoading, error: subscriptionError, checkSubscription } = useSubscription(); const { subscription, loading: subscriptionLoading, checkSubscription } = useSubscription();
// Note: subscriptionError is available for future error handling
const [connectionError, setConnectionError] = useState<{ const [connectionError, setConnectionError] = useState<{
hasError: boolean; hasError: boolean;
error: Error | null; error: Error | null;
@@ -586,6 +587,7 @@ const App: React.FC = () => {
<Route path="/linkedin-writer" element={<ProtectedRoute><LinkedInWriter /></ProtectedRoute>} /> <Route path="/linkedin-writer" element={<ProtectedRoute><LinkedInWriter /></ProtectedRoute>} />
<Route path="/blog-writer" element={<ProtectedRoute><BlogWriter /></ProtectedRoute>} /> <Route path="/blog-writer" element={<ProtectedRoute><BlogWriter /></ProtectedRoute>} />
<Route path="/story-writer" element={<ProtectedRoute><StoryWriter /></ProtectedRoute>} /> <Route path="/story-writer" element={<ProtectedRoute><StoryWriter /></ProtectedRoute>} />
<Route path="/story-projects" element={<ProtectedRoute><StoryProjectList /></ProtectedRoute>} />
<Route path="/youtube-creator" element={<ProtectedRoute><YouTubeCreator /></ProtectedRoute>} /> <Route path="/youtube-creator" element={<ProtectedRoute><YouTubeCreator /></ProtectedRoute>} />
<Route path="/podcast-maker" element={<ProtectedRoute><PodcastDashboard /></ProtectedRoute>} /> <Route path="/podcast-maker" element={<ProtectedRoute><PodcastDashboard /></ProtectedRoute>} />
<Route path="/image-studio" element={<ProtectedRoute><ImageStudioDashboard /></ProtectedRoute>} /> <Route path="/image-studio" element={<ProtectedRoute><ImageStudioDashboard /></ProtectedRoute>} />
@@ -620,6 +622,7 @@ const App: React.FC = () => {
<Route path="/scheduler-dashboard" element={<ProtectedRoute><SchedulerDashboard /></ProtectedRoute>} /> <Route path="/scheduler-dashboard" element={<ProtectedRoute><SchedulerDashboard /></ProtectedRoute>} />
<Route path="/billing" element={<ProtectedRoute><BillingPage /></ProtectedRoute>} /> <Route path="/billing" element={<ProtectedRoute><BillingPage /></ProtectedRoute>} />
<Route path="/approvals" element={<ProtectedRoute><ApprovalsPage /></ProtectedRoute>} /> <Route path="/approvals" element={<ProtectedRoute><ApprovalsPage /></ProtectedRoute>} />
<Route path="/stripe-disputes" element={<ProtectedRoute><StripeDisputesDashboard /></ProtectedRoute>} />
<Route path="/pricing" element={<PricingPage />} /> <Route path="/pricing" element={<PricingPage />} />
<Route path="/research-test" element={<ResearchDashboard />} /> <Route path="/research-test" element={<ResearchDashboard />} />
<Route path="/research-dashboard" element={<ResearchDashboard />} /> <Route path="/research-dashboard" element={<ResearchDashboard />} />

View File

@@ -5,7 +5,7 @@
* and improve performance while managing cache invalidation. * and improve performance while managing cache invalidation.
*/ */
import { apiClient } from './client'; import { apiClient, aiApiClient } from './client';
import analyticsCache from '../services/analyticsCache'; import analyticsCache from '../services/analyticsCache';
interface PlatformAnalytics { interface PlatformAnalytics {
@@ -73,16 +73,16 @@ class CachedAnalyticsAPI {
/** /**
* Get analytics data with caching * Get analytics data with caching
*/ */
async getAnalyticsData(platforms?: string[], bypassCache: boolean = false): Promise<AnalyticsResponse> { async getAnalyticsData(platforms?: string[], bypassCache: boolean = false, opts?: { start_date?: string; end_date?: string }): Promise<AnalyticsResponse> {
const baseParams: any = platforms ? { platforms: platforms.join(',') } : {}; const baseParams: any = platforms ? { platforms: platforms.join(',') } : {};
const endpoint = '/api/analytics/data'; const endpoint = '/api/analytics/data';
// If bypassing cache, add timestamp to force fresh request // If bypassing cache, add timestamp to force fresh request
const requestParams = bypassCache ? { ...baseParams, _t: Date.now() } : baseParams; const requestParams = bypassCache ? { ...baseParams, _t: Date.now(), ...(opts || {}) } : { ...baseParams, ...(opts || {}) };
// Try to get from cache first (unless bypassing) // Try to get from cache first (unless bypassing)
if (!bypassCache) { if (!bypassCache) {
const cached = analyticsCache.get<AnalyticsResponse>(endpoint, baseParams); const cached = analyticsCache.get<AnalyticsResponse>(endpoint, requestParams);
if (cached) { if (cached) {
console.log('📦 Analytics Cache HIT: Analytics data (cached for 60 minutes)'); console.log('📦 Analytics Cache HIT: Analytics data (cached for 60 minutes)');
return cached; return cached;
@@ -95,12 +95,19 @@ class CachedAnalyticsAPI {
// Cache the result with extended TTL (unless bypassing) // Cache the result with extended TTL (unless bypassing)
if (!bypassCache) { if (!bypassCache) {
analyticsCache.set(endpoint, baseParams, response.data, this.CACHE_TTL.ANALYTICS_DATA); analyticsCache.set(endpoint, requestParams, response.data, this.CACHE_TTL.ANALYTICS_DATA);
} }
return response.data; return response.data;
} }
async getAIInsights(opts: { start_date: string; end_date: string }): Promise<{ success: boolean; insights?: any; error?: string }> {
const endpoint = '/api/analytics/ai-insights';
const params = { start_date: opts.start_date, end_date: opts.end_date, _t: Date.now() };
const response = await aiApiClient.get(endpoint, { params });
return response.data;
}
/** /**
* Invalidate platform status cache * Invalidate platform status cache
*/ */
@@ -128,7 +135,7 @@ class CachedAnalyticsAPI {
/** /**
* Force refresh analytics data (bypass cache) * Force refresh analytics data (bypass cache)
*/ */
async forceRefreshAnalyticsData(platforms?: string[]): Promise<AnalyticsResponse> { async forceRefreshAnalyticsData(platforms?: string[], opts?: { start_date?: string; end_date?: string }): Promise<AnalyticsResponse> {
// Try to clear backend cache first (but don't fail if it doesn't work) // Try to clear backend cache first (but don't fail if it doesn't work)
try { try {
await this.clearBackendCache(platforms); await this.clearBackendCache(platforms);
@@ -140,7 +147,7 @@ class CachedAnalyticsAPI {
this.invalidateAnalyticsData(); this.invalidateAnalyticsData();
// Finally get fresh data with cache bypass // Finally get fresh data with cache bypass
return this.getAnalyticsData(platforms, true); return this.getAnalyticsData(platforms, true, opts);
} }
/** /**

View File

@@ -1,5 +1,21 @@
import axios from 'axios'; import axios from 'axios';
const sanitizeUrlForLogging = (url: string | undefined): string => {
if (!url) return '';
try {
const [base, query] = url.split('?');
if (!query) return url;
const params = new URLSearchParams(query);
if (params.has('token')) {
params.set('token', '***');
}
const queryString = params.toString();
return queryString ? `${base}?${queryString}` : base;
} catch {
return url;
}
};
// Global subscription error handler - will be set by the app // Global subscription error handler - will be set by the app
// Can be async to support subscription status refresh // Can be async to support subscription status refresh
let globalSubscriptionErrorHandler: ((error: any) => boolean | Promise<boolean>) | null = null; let globalSubscriptionErrorHandler: ((error: any) => boolean | Promise<boolean>) | null = null;
@@ -108,7 +124,8 @@ export const pollingApiClient = axios.create({
// Add request interceptor for logging and authentication // Add request interceptor for logging and authentication
apiClient.interceptors.request.use( apiClient.interceptors.request.use(
async (config) => { async (config) => {
console.log(`Making ${config.method?.toUpperCase()} request to ${config.url}`); const safeUrl = sanitizeUrlForLogging(config.url);
console.log(`Making ${config.method?.toUpperCase()} request to ${safeUrl}`);
try { try {
if (!authTokenGetter) { if (!authTokenGetter) {
// If authTokenGetter is not set, reject the request to prevent 401 errors // If authTokenGetter is not set, reject the request to prevent 401 errors
@@ -123,7 +140,8 @@ apiClient.interceptors.request.use(
if (token) { if (token) {
config.headers = config.headers || {}; config.headers = config.headers || {};
(config.headers as any)['Authorization'] = `Bearer ${token}`; (config.headers as any)['Authorization'] = `Bearer ${token}`;
console.log(`[apiClient] ✅ Added auth token to request: ${config.url}`); const safeUrlWithToken = sanitizeUrlForLogging(config.url);
console.log(`[apiClient] ✅ Auth token attached for request to ${safeUrlWithToken}`);
} else { } else {
// Token getter returned null - reject request to prevent 401 errors // Token getter returned null - reject request to prevent 401 errors
// ProtectedRoute should ensure user is authenticated before components render // ProtectedRoute should ensure user is authenticated before components render
@@ -299,7 +317,8 @@ apiClient.interceptors.response.use(
// Add interceptors for AI client // Add interceptors for AI client
aiApiClient.interceptors.request.use( aiApiClient.interceptors.request.use(
async (config) => { async (config) => {
console.log(`Making AI ${config.method?.toUpperCase()} request to ${config.url}`); const safeUrl = sanitizeUrlForLogging(config.url);
console.log(`Making AI ${config.method?.toUpperCase()} request to ${safeUrl}`);
try { try {
if (!authTokenGetter) { if (!authTokenGetter) {
console.warn(`[aiApiClient] ⚠️ authTokenGetter not set for ${config.url} - request may fail authentication`); console.warn(`[aiApiClient] ⚠️ authTokenGetter not set for ${config.url} - request may fail authentication`);
@@ -309,7 +328,8 @@ aiApiClient.interceptors.request.use(
if (token) { if (token) {
config.headers = config.headers || {}; config.headers = config.headers || {};
(config.headers as any)['Authorization'] = `Bearer ${token}`; (config.headers as any)['Authorization'] = `Bearer ${token}`;
console.log(`[aiApiClient] ✅ Added auth token to request: ${config.url}`); const safeUrlWithToken = sanitizeUrlForLogging(config.url);
console.log(`[aiApiClient] ✅ Auth token attached for request to ${safeUrlWithToken}`);
} else { } else {
console.warn(`[aiApiClient] ⚠️ authTokenGetter returned null for ${config.url} - user may not be signed in`); console.warn(`[aiApiClient] ⚠️ authTokenGetter returned null for ${config.url} - user may not be signed in`);
} }
@@ -579,4 +599,4 @@ pollingApiClient.interceptors.response.use(
console.error('Polling API Error:', error.message || error, error.response?.status, error.response?.data); console.error('Polling API Error:', error.message || error, error.response?.status, error.response?.data);
return Promise.reject(error); return Promise.reject(error);
} }
); );

View File

@@ -35,6 +35,27 @@ export interface AIInsight {
tool_path?: string; tool_path?: string;
} }
export interface SIFIndexingHealth {
has_task: boolean;
status: 'healthy' | 'warning' | 'critical' | 'not_scheduled';
message?: string;
task?: {
id: number;
website_url: string;
raw_status: string;
next_execution: string | null;
last_success: string | null;
last_failure: string | null;
consecutive_failures: number;
failure_pattern?: any;
};
last_run?: {
status: string | null;
time: string | null;
error_message: string | null;
};
}
export interface SEODashboardData { export interface SEODashboardData {
health_score: SEOHealthScore; health_score: SEOHealthScore;
key_insight: string; key_insight: string;
@@ -141,5 +162,15 @@ export const seoDashboardAPI = {
console.error('Error checking SEO dashboard health:', error); console.error('Error checking SEO dashboard health:', error);
throw error; throw error;
} }
},
async getSIFHealth(): Promise<SIFIndexingHealth> {
try {
const response = await apiClient.get('/api/seo-dashboard/sif-health');
return response.data;
} catch (error) {
console.error('Error fetching SIF indexing health:', error);
throw error;
}
} }
}; };

View File

@@ -262,8 +262,36 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
} }
} }
setMetadataResult(result); const sanitizeMetadata = (data: any) => {
setEditableMetadata(result); const safe = { ...data };
safe.seo_title = safe.seo_title ?? '';
safe.meta_description = safe.meta_description ?? '';
safe.url_slug = safe.url_slug ?? '';
safe.focus_keyword = safe.focus_keyword ?? '';
safe.reading_time = typeof safe.reading_time === 'number' ? safe.reading_time : 0;
safe.blog_tags = Array.isArray(safe.blog_tags) ? safe.blog_tags : [];
safe.blog_categories = Array.isArray(safe.blog_categories) ? safe.blog_categories : [];
safe.social_hashtags = Array.isArray(safe.social_hashtags) ? safe.social_hashtags : [];
safe.open_graph = {
...(safe.open_graph || {}),
title: safe.open_graph?.title ?? '',
description: safe.open_graph?.description ?? '',
image: safe.open_graph?.image ?? '',
url: safe.open_graph?.url ?? ''
};
safe.twitter_card = {
...(safe.twitter_card || {}),
title: safe.twitter_card?.title ?? '',
description: safe.twitter_card?.description ?? '',
image: safe.twitter_card?.image ?? '',
site: safe.twitter_card?.site ?? ''
};
safe.json_ld_schema = { ...(safe.json_ld_schema || {}) };
return safe;
};
const sanitized = sanitizeMetadata(result);
setMetadataResult(sanitized);
setEditableMetadata(sanitized);
console.log('📊 Metadata result set:', result); console.log('📊 Metadata result set:', result);
} catch (err: any) { } catch (err: any) {

View File

@@ -652,7 +652,7 @@ export const AssetLibrary: React.FC = () => {
Asset Library Asset Library
</Typography> </Typography>
<Typography variant="body1" color="text.secondary"> <Typography variant="body1" color="text.secondary">
Unified content archive for all ALwrity tools: Story Writer, Image Studio, Blog Writer, LinkedIn, Facebook, SEO Tools, and more. Unified content archive for all ALwrity tools: Story Studio, Image Studio, Blog Writer, LinkedIn, Facebook, SEO Tools, and more.
</Typography> </Typography>
</Box> </Box>

View File

@@ -17,12 +17,14 @@ import {
Warning Warning
} from '@mui/icons-material'; } from '@mui/icons-material';
import OnboardingButton from '../common/OnboardingButton'; import OnboardingButton from '../common/OnboardingButton';
import { useNavigate } from 'react-router-dom';
import { getApiKeys, completeOnboarding, getOnboardingSummary, getWebsiteAnalysisData, getResearchPreferencesData, setCurrentStep } from '../../../api/onboarding'; import { getApiKeys, completeOnboarding, getOnboardingSummary, getWebsiteAnalysisData, getResearchPreferencesData, setCurrentStep } from '../../../api/onboarding';
import { SetupSummary, CapabilitiesOverview, AgentTeamSection } from './components'; import { SetupSummary, CapabilitiesOverview, AgentTeamSection } from './components';
import { FinalStepProps, OnboardingData, Capability } from './types'; import { FinalStepProps, OnboardingData, Capability } from './types';
import { getAgentTeam, type AgentTeamCatalogEntry } from '../../../api/agentsTeam'; import { getAgentTeam, type AgentTeamCatalogEntry } from '../../../api/agentsTeam';
const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }) => { const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }) => {
const navigate = useNavigate();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [dataLoading, setDataLoading] = useState(false); const [dataLoading, setDataLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -297,22 +299,9 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
localStorage.setItem('onboarding_active_step', String(stepsLengthFallback())); localStorage.setItem('onboarding_active_step', String(stepsLengthFallback()));
} catch {} } catch {}
// Navigate directly to dashboard without calling onContinue // Navigate directly to dashboard using React Router
// This bypasses the wizard flow and goes straight to the dashboard console.log('FinalStep: Navigating to dashboard with react-router navigate("/dashboard")');
console.log('FinalStep: Navigating to dashboard...'); navigate('/dashboard', { replace: true });
console.log('FinalStep: Setting window.location.href to /dashboard');
// Try multiple navigation methods to ensure redirect works
try {
window.location.href = '/dashboard';
console.log('FinalStep: window.location.href set successfully');
} catch (navError) {
console.error('FinalStep: window.location.href failed:', navError);
console.log('FinalStep: Trying alternative navigation method...');
window.location.assign('/dashboard');
}
console.log('FinalStep: Navigation initiated');
} catch (e: any) { } catch (e: any) {
console.error('FinalStep: Error completing onboarding:', e); console.error('FinalStep: Error completing onboarding:', e);
console.error('FinalStep: Error details:', { console.error('FinalStep: Error details:', {
@@ -528,26 +517,27 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
onClick={handleLaunch} onClick={handleLaunch}
startIcon={<Rocket />} startIcon={<Rocket />}
sx={{ sx={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', background: 'linear-gradient(135deg, #0f172a 0%, #312e81 40%, #4f46e5 100%)',
fontSize: '1.125rem', fontSize: '1.125rem',
fontWeight: 600, fontWeight: 600,
px: 4, px: 4,
py: 2, py: 2,
borderRadius: 2, borderRadius: 999,
textTransform: 'none', textTransform: 'none',
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.3)', boxShadow: '0 10px 28px rgba(15,23,42,0.45)',
'&:hover': { letterSpacing: 0.2,
background: 'linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%)', '&:hover': {
transform: 'translateY(-1px)', background: 'linear-gradient(135deg, #020617 0%, #1e1b4b 40%, #4338ca 100%)',
boxShadow: '0 6px 16px rgba(102, 126, 234, 0.4)', transform: 'translateY(-1px)',
}, boxShadow: '0 14px 36px rgba(15,23,42,0.55)',
'&:disabled': { },
background: 'rgba(0,0,0,0.1)', '&:disabled': {
color: 'rgba(0,0,0,0.4)', background: 'rgba(148,163,184,0.4)',
boxShadow: 'none', color: 'rgba(15,23,42,0.6)',
transform: 'none', boxShadow: 'none',
} transform: 'none',
}} }
}}
> >
Launch Alwrity & Complete Setup Launch Alwrity & Complete Setup
</Button> </Button>
@@ -555,12 +545,16 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
{/* Help Text */} {/* Help Text */}
<Box sx={{ mt: 3, textAlign: 'center' }}> <Box sx={{ mt: 3, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
This will complete your onboarding and launch Alwrity with your configured settings. This will complete your onboarding and launch Alwrity with your configured settings.
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}> <Typography
<Star sx={{ fontSize: 16 }} /> variant="body2"
Ready to create amazing content with AI-powered assistance color="text.secondary"
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}
>
<Star sx={{ fontSize: 16, color: '#fbbf24' }} />
Your SIF Agent Framework is ready to orchestrate your marketing.
</Typography> </Typography>
</Box> </Box>
</React.Fragment> </React.Fragment>

View File

@@ -22,6 +22,7 @@ import {
Chip, Chip,
Stack, Stack,
Divider, Divider,
Tooltip,
} from "@mui/material"; } from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import GroupIcon from "@mui/icons-material/Group"; import GroupIcon from "@mui/icons-material/Group";
@@ -30,6 +31,7 @@ import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
import SaveIcon from "@mui/icons-material/Save"; import SaveIcon from "@mui/icons-material/Save";
import RestartAltIcon from "@mui/icons-material/RestartAlt"; import RestartAltIcon from "@mui/icons-material/RestartAlt";
import VisibilityIcon from "@mui/icons-material/Visibility"; import VisibilityIcon from "@mui/icons-material/Visibility";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import { import {
aiOptimizeAgentProfile, aiOptimizeAgentProfile,
@@ -242,18 +244,168 @@ const AgentTeamSection: React.FC<Props> = ({ websiteName, agents, contextCard })
}; };
return ( return (
<Paper sx={{ mt: 3, p: 3, borderRadius: 3 }}> <Paper
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}> elevation={0}
<GroupIcon /> sx={{
<Typography variant="h6" sx={{ fontWeight: 700 }}> mt: 3,
Meet {websiteName || "Your"} AI Marketing Team p: 3,
</Typography> borderRadius: 4,
</Stack> border: "1px solid #e2e8f0",
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> bgcolor: "#ffffff",
These agents work together to help you plan, execute, and improve your digital marketing. Tools and responsibilities are locked for safety and reliability. color: "#0f172a",
</Typography> boxShadow: "0 1px 2px rgba(15,23,42,0.04)",
"& .MuiTypography-root": {
color: "#111827 !important",
WebkitTextFillColor: "#111827",
},
"& .MuiTypography-body2": {
color: "#4b5563 !important",
},
"& .MuiTypography-caption": {
color: "#6b7280 !important",
},
"& .MuiFormLabel-root": {
color: "#4b5563 !important",
},
"& .MuiFormLabel-root.Mui-focused": {
color: "#4f46e5 !important",
},
"& .MuiInputBase-input": {
color: "#111827 !important",
},
"& .MuiOutlinedInput-root": {
bgcolor: "#ffffff !important",
color: "#111827 !important",
},
"& .MuiAccordionDetails-root": {
bgcolor: "#ffffff !important",
},
}}
>
<Box
sx={{
mb: 3,
p: 2.5,
borderRadius: 3,
background: "linear-gradient(135deg, #0f172a 0%, #312e81 40%, #4f46e5 100%)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 2,
boxShadow: "0 12px 30px rgba(15,23,42,0.45)",
"& .MuiTypography-root": {
color: "#e5e7eb !important",
WebkitTextFillColor: "#e5e7eb",
},
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: "999px",
bgcolor: "rgba(129,140,248,0.4)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#e5e7eb",
}}
>
<GroupIcon />
</Box>
<Box sx={{ minWidth: 0 }}>
<Typography variant="h6" sx={{ fontWeight: 800, letterSpacing: 0.2 }}>
Meet {websiteName || "Your"} AI Marketing Team
</Typography>
<Typography variant="body2" sx={{ opacity: 0.92 }}>
Enterprise-grade autonomous agents orchestrated by ALwrity&apos;s SIF framework to run your marketing.
</Typography>
</Box>
</Box>
<Tooltip
title="Semantic Intelligence Framework™ Alwrity's orchestration layer for autonomous marketing agents."
arrow
placement="left"
>
<Chip
size="small"
label="SIF Agent Framework™"
sx={{
borderRadius: "999px",
border: "1px solid rgba(191,219,254,0.9)",
bgcolor: "rgba(15,23,42,0.75)",
color: "#e5e7eb",
fontWeight: 600,
letterSpacing: 0.4,
textTransform: "uppercase",
}}
/>
</Tooltip>
</Box>
<Stack spacing={1.5}> <Box
sx={{
mb: 2,
px: 0.5,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
flexWrap: "wrap",
gap: 1.5,
}}
>
<Stack direction="row" spacing={1} flexWrap="wrap" alignItems="center">
<Typography variant="caption" sx={{ fontWeight: 600, textTransform: "uppercase", letterSpacing: 0.6 }}>
Agent roles
</Typography>
<Chip
size="small"
label="Lead"
sx={{
height: 22,
borderRadius: "999px",
bgcolor: "#eef2ff",
color: "#312e81",
fontWeight: 600,
}}
/>
<Chip
size="small"
label="Strategist"
sx={{
height: 22,
borderRadius: "999px",
bgcolor: "#ecfdf5",
color: "#047857",
fontWeight: 600,
}}
/>
<Chip
size="small"
label="Analyst"
sx={{
height: 22,
borderRadius: "999px",
bgcolor: "#eff6ff",
color: "#1d4ed8",
fontWeight: 600,
}}
/>
</Stack>
<Stack direction="row" spacing={2} alignItems="center">
<Stack direction="row" spacing={0.75} alignItems="center">
<Box sx={{ width: 8, height: 8, borderRadius: "999px", bgcolor: "#22c55e" }} />
<Typography variant="caption">Enabled</Typography>
</Stack>
<Stack direction="row" spacing={0.75} alignItems="center">
<Box sx={{ width: 8, height: 8, borderRadius: "999px", bgcolor: "#e5e7eb" }} />
<Typography variant="caption">Disabled</Typography>
</Stack>
</Stack>
</Box>
<Stack spacing={2}>
{agents.map((agent) => { {agents.map((agent) => {
const displayName = resolveDisplayName(agent, websiteName); const displayName = resolveDisplayName(agent, websiteName);
const scheduleText = formatSchedule(agent.profile?.schedule ?? agent.defaults?.schedule); const scheduleText = formatSchedule(agent.profile?.schedule ?? agent.defaults?.schedule);
@@ -261,66 +413,173 @@ const AgentTeamSection: React.FC<Props> = ({ websiteName, agents, contextCard })
const warnings = draft ? lintDraft(agent, draft) : []; const warnings = draft ? lintDraft(agent, draft) : [];
return ( return (
<Accordion key={agent.agent_key} disableGutters elevation={0} sx={{ borderRadius: 2, border: "1px solid rgba(0,0,0,0.08)" }}> <Accordion
key={agent.agent_key}
disableGutters
elevation={0}
sx={{
borderRadius: 2,
border: "1px solid #e2e8f0",
bgcolor: "#f9fafb",
"&:before": { display: "none" },
transition: "all 160ms ease",
"&:hover": {
borderColor: "#4f46e5",
boxShadow: "0 8px 24px rgba(15,23,42,0.12)",
transform: "translateY(-1px)",
},
"&.Mui-expanded": {
borderColor: "#4f46e5",
boxShadow: "0 12px 30px rgba(15,23,42,0.16)",
bgcolor: "#ffffff",
},
}}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}> <AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%", gap: 2 }}> <Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%", gap: 2 }}>
<Box sx={{ minWidth: 0 }}> <Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.2 }} noWrap> <Typography
variant="subtitle1"
sx={{ fontWeight: 700, lineHeight: 1.2, color: "#0f172a" }}
noWrap
>
{displayName} {displayName}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" noWrap> <Typography
variant="body2"
sx={{ color: "#64748b" }}
noWrap
>
{agent.role || agent.agent_key} {scheduleText} {agent.role || agent.agent_key} {scheduleText}
</Typography> </Typography>
</Box> </Box>
<Stack direction="row" spacing={1} sx={{ flexShrink: 0 }}> <Stack direction="row" spacing={1} sx={{ flexShrink: 0 }}>
<Chip size="small" icon={<LockIcon />} label="Tools locked" variant="outlined" /> <Tooltip title="System tools this agent can call while executing your strategy." arrow>
<Chip size="small" icon={<LockIcon />} label="Responsibilities locked" variant="outlined" /> <Chip
size="small"
icon={<LockIcon />}
label="Tools locked"
variant="outlined"
sx={{
fontWeight: 500,
borderColor: "#cbd5e1",
bgcolor: "#e5edff",
color: "#1e293b",
}}
/>
</Tooltip>
<Tooltip title="High-level responsibilities are predefined for safety and reliability." arrow>
<Chip
size="small"
icon={<LockIcon />}
label="Responsibilities locked"
variant="outlined"
sx={{
fontWeight: 500,
borderColor: "#cbd5e1",
bgcolor: "#e5edff",
color: "#1e293b",
}}
/>
</Tooltip>
</Stack> </Stack>
</Box> </Box>
</AccordionSummary> </AccordionSummary>
<AccordionDetails> <AccordionDetails>
<Stack spacing={2}> <Stack spacing={2}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button <Tooltip
size="small" title="Let ALwrity refine this agent's prompts and schedule based on your brand context."
variant="outlined" arrow
startIcon={<AutoFixHighIcon />}
disabled={aiBusyKey === agent.agent_key}
onClick={() => handleAiOptimize(agent)}
sx={{ textTransform: "none" }}
> >
AI Optimize <span>
</Button> <Button
<Button size="small"
size="small" variant="outlined"
variant="outlined" startIcon={<AutoFixHighIcon />}
startIcon={<VisibilityIcon />} disabled={aiBusyKey === agent.agent_key}
disabled={previewBusyKey === agent.agent_key} onClick={() => handleAiOptimize(agent)}
onClick={() => handlePreview(agent)} sx={{
sx={{ textTransform: "none" }} textTransform: "none",
borderColor: "#4f46e5",
color: "#4f46e5",
"&:hover": {
borderColor: "#4338ca",
background: "rgba(79,70,229,0.04)",
},
}}
>
AI Optimize
</Button>
</span>
</Tooltip>
<Tooltip
title="Preview how this agent would respond using the current configuration."
arrow
> >
Preview <span>
</Button> <Button
<Button size="small"
size="small" variant="outlined"
variant="contained" startIcon={<VisibilityIcon />}
startIcon={<SaveIcon />} disabled={previewBusyKey === agent.agent_key}
disabled={!draft || savingKey === agent.agent_key} onClick={() => handlePreview(agent)}
onClick={() => handleSave(agent)} sx={{
sx={{ textTransform: "none" }} textTransform: "none",
borderColor: "#0f172a",
color: "#0f172a",
"&:hover": {
borderColor: "#111827",
background: "rgba(15,23,42,0.04)",
},
}}
>
Preview
</Button>
</span>
</Tooltip>
<Tooltip
title="Persist this agent's configuration for future sessions."
arrow
> >
Save <span>
</Button> <Button
<Button size="small"
size="small" variant="contained"
variant="text" startIcon={<SaveIcon />}
startIcon={<RestartAltIcon />} disabled={!draft || savingKey === agent.agent_key}
disabled={savingKey === agent.agent_key} onClick={() => handleSave(agent)}
onClick={() => handleReset(agent)} sx={{
sx={{ textTransform: "none" }} textTransform: "none",
background: "linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%)",
boxShadow: "0 4px 12px rgba(79,70,229,0.35)",
"&:hover": {
background: "linear-gradient(135deg, #4338ca 0%, #6d28d9 100%)",
boxShadow: "0 6px 18px rgba(79,70,229,0.45)",
},
}}
>
Save
</Button>
</span>
</Tooltip>
<Tooltip
title="Revert this agent to its recommended default settings."
arrow
> >
Reset <span>
</Button> <Button
size="small"
variant="text"
startIcon={<RestartAltIcon />}
disabled={savingKey === agent.agent_key}
onClick={() => handleReset(agent)}
sx={{ textTransform: "none" }}
>
Reset
</Button>
</span>
</Tooltip>
</Box> </Box>
{warnings.length > 0 && ( {warnings.length > 0 && (
@@ -367,9 +626,34 @@ const AgentTeamSection: React.FC<Props> = ({ websiteName, agents, contextCard })
<Divider /> <Divider />
{draft && ( {draft && (
<Box> <Box
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1 }}> sx={{
mt: 1,
p: 2.5,
borderRadius: 2,
border: "1px dashed #e5e7eb",
bgcolor: "#f9fafb",
}}
>
<Typography
variant="subtitle2"
sx={{
fontWeight: 700,
mb: 1.5,
display: "flex",
alignItems: "center",
gap: 0.75,
}}
>
<EditOutlinedIcon sx={{ fontSize: 18, color: "#4f46e5" }} />
Editable settings Editable settings
<Typography
component="span"
variant="caption"
sx={{ ml: 0.75, color: "#6b7280" }}
>
Adjust how this agent behaves for your workspace.
</Typography>
</Typography> </Typography>
<Stack spacing={2}> <Stack spacing={2}>
<TextField <TextField
@@ -377,6 +661,17 @@ const AgentTeamSection: React.FC<Props> = ({ websiteName, agents, contextCard })
value={draft.display_name} value={draft.display_name}
onChange={(e) => setDraftField(agent.agent_key, { display_name: e.target.value })} onChange={(e) => setDraftField(agent.agent_key, { display_name: e.target.value })}
fullWidth fullWidth
sx={{
"& .MuiOutlinedInput-root": {
bgcolor: "#ffffff",
"& fieldset": { borderColor: "#e5e7eb" },
"&:hover fieldset": { borderColor: "#4f46e5" },
"&.Mui-focused fieldset": {
borderColor: "#4f46e5",
boxShadow: "0 0 0 1px rgba(79,70,229,0.25)",
},
},
}}
/> />
<FormControlLabel <FormControlLabel
control={ control={
@@ -388,7 +683,20 @@ const AgentTeamSection: React.FC<Props> = ({ websiteName, agents, contextCard })
label="Enabled" label="Enabled"
/> />
<FormControl fullWidth> <FormControl
fullWidth
sx={{
"& .MuiOutlinedInput-root": {
bgcolor: "#ffffff",
"& fieldset": { borderColor: "#e5e7eb" },
"&:hover fieldset": { borderColor: "#4f46e5" },
"&.Mui-focused fieldset": {
borderColor: "#4f46e5",
boxShadow: "0 0 0 1px rgba(79,70,229,0.25)",
},
},
}}
>
<InputLabel>Schedule</InputLabel> <InputLabel>Schedule</InputLabel>
<Select <Select
label="Schedule" label="Schedule"
@@ -418,12 +726,34 @@ const AgentTeamSection: React.FC<Props> = ({ websiteName, agents, contextCard })
}) })
} }
fullWidth fullWidth
sx={{
"& .MuiOutlinedInput-root": {
bgcolor: "#ffffff",
"& fieldset": { borderColor: "#e5e7eb" },
"&:hover fieldset": { borderColor: "#4f46e5" },
"&.Mui-focused fieldset": {
borderColor: "#4f46e5",
boxShadow: "0 0 0 1px rgba(79,70,229,0.25)",
},
},
}}
/> />
<TextField <TextField
label="Time (HH:MM)" label="Time (HH:MM)"
value={draft.schedule?.time || ""} value={draft.schedule?.time || ""}
onChange={(e) => setDraftField(agent.agent_key, { schedule: { ...(draft.schedule || {}), time: e.target.value } })} onChange={(e) => setDraftField(agent.agent_key, { schedule: { ...(draft.schedule || {}), time: e.target.value } })}
fullWidth fullWidth
sx={{
"& .MuiOutlinedInput-root": {
bgcolor: "#ffffff",
"& fieldset": { borderColor: "#e5e7eb" },
"&:hover fieldset": { borderColor: "#4f46e5" },
"&.Mui-focused fieldset": {
borderColor: "#4f46e5",
boxShadow: "0 0 0 1px rgba(79,70,229,0.25)",
},
},
}}
/> />
</Stack> </Stack>
)} )}
@@ -434,6 +764,17 @@ const AgentTeamSection: React.FC<Props> = ({ websiteName, agents, contextCard })
value={draft.schedule?.time || ""} value={draft.schedule?.time || ""}
onChange={(e) => setDraftField(agent.agent_key, { schedule: { ...(draft.schedule || {}), time: e.target.value } })} onChange={(e) => setDraftField(agent.agent_key, { schedule: { ...(draft.schedule || {}), time: e.target.value } })}
fullWidth fullWidth
sx={{
"& .MuiOutlinedInput-root": {
bgcolor: "#ffffff",
"& fieldset": { borderColor: "#e5e7eb" },
"&:hover fieldset": { borderColor: "#4f46e5" },
"&.Mui-focused fieldset": {
borderColor: "#4f46e5",
boxShadow: "0 0 0 1px rgba(79,70,229,0.25)",
},
},
}}
/> />
)} )}
@@ -444,6 +785,17 @@ const AgentTeamSection: React.FC<Props> = ({ websiteName, agents, contextCard })
multiline multiline
minRows={6} minRows={6}
fullWidth fullWidth
sx={{
"& .MuiOutlinedInput-root": {
bgcolor: "#ffffff",
"& fieldset": { borderColor: "#e5e7eb" },
"&:hover fieldset": { borderColor: "#4f46e5" },
"&.Mui-focused fieldset": {
borderColor: "#4f46e5",
boxShadow: "0 0 0 1px rgba(79,70,229,0.25)",
},
},
}}
/> />
<TextField <TextField
label="Task prompt template" label="Task prompt template"
@@ -452,6 +804,17 @@ const AgentTeamSection: React.FC<Props> = ({ websiteName, agents, contextCard })
multiline multiline
minRows={6} minRows={6}
fullWidth fullWidth
sx={{
"& .MuiOutlinedInput-root": {
bgcolor: "#ffffff",
"& fieldset": { borderColor: "#e5e7eb" },
"&:hover fieldset": { borderColor: "#4f46e5" },
"&.Mui-focused fieldset": {
borderColor: "#4f46e5",
boxShadow: "0 0 0 1px rgba(79,70,229,0.25)",
},
},
}}
/> />
</Stack> </Stack>
</Box> </Box>

View File

@@ -17,6 +17,10 @@ import {
Chip Chip
} from '@mui/material'; } from '@mui/material';
import { import {
ArrowForward as ArrowForwardIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
PlayArrow as PlayArrowIcon,
// Social Media Icons // Social Media Icons
Facebook as FacebookIcon, Facebook as FacebookIcon,
Twitter as TwitterIcon, Twitter as TwitterIcon,
@@ -31,10 +35,13 @@ import {
Google as GoogleIcon, Google as GoogleIcon,
Analytics as AnalyticsIcon, Analytics as AnalyticsIcon,
// UI Icons // UI Icons
Psychology as PsychologyIcon,
AutoAwesome as AutoAwesomeIcon,
Lightbulb as LightbulbIcon, Lightbulb as LightbulbIcon,
CheckCircle as CheckCircleIcon, CheckCircle as CheckCircleIcon,
Error as ErrorIcon Error as ErrorIcon
} from '@mui/icons-material'; } from '@mui/icons-material';
import { motion } from 'framer-motion';
// Import refactored components // Import refactored components
import EmailSection from './common/EmailSection'; import EmailSection from './common/EmailSection';
@@ -53,6 +60,7 @@ interface IntegrationsStepProps {
onContinue: () => void; onContinue: () => void;
updateHeaderContent: (content: { title: string; description: string }) => void; updateHeaderContent: (content: { title: string; description: string }) => void;
onValidationChange?: (isValid: boolean) => void; onValidationChange?: (isValid: boolean) => void;
onDataChange?: (data: any) => void;
} }
interface IntegrationPlatform { interface IntegrationPlatform {
@@ -68,7 +76,7 @@ interface IntegrationPlatform {
isEnabled: boolean; isEnabled: boolean;
} }
const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateHeaderContent, onValidationChange }) => { const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateHeaderContent, onValidationChange, onDataChange }) => {
const { user } = useUser(); const { user } = useUser();
const [email, setEmail] = useState<string>(''); const [email, setEmail] = useState<string>('');
@@ -102,7 +110,7 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
const { connected: wordpressConnected, sites: wordpressSites } = useWordPressOAuth(); const { connected: wordpressConnected, sites: wordpressSites } = useWordPressOAuth();
// Bing OAuth hook // Bing OAuth hook
const { connected: bingConnected, sites: bingSites, connect: connectBing } = useBingOAuth(); const { connected: bingConnected, sites: bingSites, connect: connectBing, refreshStatus: refreshBingStatus } = useBingOAuth();
// Initialize integrations data // Initialize integrations data
const [integrations] = useState<IntegrationPlatform[]>([ const [integrations] = useState<IntegrationPlatform[]>([
@@ -257,6 +265,17 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
} }
}, [wordpressConnected, wordpressSites, connectedPlatforms, setConnectedPlatforms, invalidateAnalyticsCache]); }, [wordpressConnected, wordpressSites, connectedPlatforms, setConnectedPlatforms, invalidateAnalyticsCache]);
useEffect(() => {
(async () => {
try {
await refreshBingStatus();
} catch (e) {
console.error('Failed to refresh Bing status:', e);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Handle Bing connection status changes // Handle Bing connection status changes
useEffect(() => { useEffect(() => {
@@ -354,6 +373,65 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
return sites; return sites;
}, [wixConnected, wixSites, wordpressConnected, wordpressSites]); }, [wixConnected, wixSites, wordpressConnected, wordpressSites]);
useEffect(() => {
if (!onDataChange) {
return;
}
const websiteIntegrations = {
wix: wixConnected ? wixSites.map(s => ({ url: s.blog_url, name: 'Wix Site' })) : [],
wordpress: wordpressConnected ? wordpressSites.map(s => ({ url: s.blog_url, name: 'WordPress Site' })) : [],
primaryWebsite: primarySite || null,
};
const analyticsIntegrations = {
gsc: {
connected: connectedPlatforms.includes('gsc'),
sites: (gscSites || []).map((site: any) => ({
siteUrl: site.siteUrl || site.site_url || '',
})),
},
bing: {
connected: connectedPlatforms.includes('bing') || !!bingConnected,
sites: (bingSites || []).map((site: any) => ({
siteUrl: site.siteUrl || site.site_url || '',
})),
},
};
const socialIntegrations = {
facebook: connectedPlatforms.includes('facebook'),
twitter: connectedPlatforms.includes('twitter'),
linkedin: connectedPlatforms.includes('linkedin'),
instagram: connectedPlatforms.includes('instagram'),
youtube: connectedPlatforms.includes('youtube'),
tiktok: connectedPlatforms.includes('tiktok'),
pinterest: connectedPlatforms.includes('pinterest'),
};
onDataChange({
integrations: {
primaryWebsite: websiteIntegrations.primaryWebsite,
websitePlatforms: websiteIntegrations,
analyticsPlatforms: analyticsIntegrations,
socialPlatforms: socialIntegrations,
connectedPlatforms,
updatedAt: new Date().toISOString(),
},
});
}, [
onDataChange,
primarySite,
wixConnected,
wixSites,
wordpressConnected,
wordpressSites,
gscSites,
bingConnected,
bingSites,
connectedPlatforms,
]);
// Default to first site // Default to first site
useEffect(() => { useEffect(() => {
if (availableSites.length > 0 && !primarySite) { if (availableSites.length > 0 && !primarySite) {
@@ -379,6 +457,30 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
} }
}, [availableSites.length, primarySite, onValidationChange]); }, [availableSites.length, primarySite, onValidationChange]);
const [walkthroughStep, setWalkthroughStep] = useState<number>(0);
const walkthroughTitles: string[] = [
'Connect your platforms',
'We cache your insights',
'Agents analyze weekly',
'We propose clear fixes',
'You review and publish',
];
const walkthroughDescriptions: string[] = [
'Link Google Search Console and Bing to unlock search signals for your site.',
'We safely store key metrics so recommendations are quick and quotafriendly.',
'SIF agents look for lowCTR pages, strikingdistance wins, declines, and overlaps.',
'Youll see simple suggestions: better titles/meta, refreshes, and consolidations.',
'Pick what you like and publish; we keep the rhythm going week after week.',
];
const walkthroughLabels: string[] = ['Step 1 of 5', 'Step 2 of 5', 'Step 3 of 5', 'Step 4 of 5', 'Step 5 of 5'];
useEffect(() => {
const id = setInterval(() => {
setWalkthroughStep(prev => (prev + 1) % walkthroughTitles.length);
}, 4500);
return () => clearInterval(id);
}, [walkthroughTitles.length]);
return ( return (
<Box sx={{ width: '100%', maxWidth: '100%', p: { xs: 1, sm: 2, md: 3 } }}> <Box sx={{ width: '100%', maxWidth: '100%', p: { xs: 1, sm: 2, md: 3 } }}>
{/* Email Address Section */} {/* Email Address Section */}
@@ -594,6 +696,281 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
{/* Coming Soon Section */} {/* Coming Soon Section */}
<ComingSoonSection /> <ComingSoonSection />
{/* Recommendation Panel */}
<Fade in timeout={1500}>
<div>
<Paper
elevation={2}
sx={{
mt: 2.5,
p: { xs: 2, md: 2.5 },
borderRadius: 2,
background: 'linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)',
border: '1px solid #e2e8f0'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2, gap: 1 }}>
<AutoAwesomeIcon sx={{ color: '#7c3aed' }} />
<Typography variant="h6" sx={{ fontWeight: 700, color: '#111827' }}>
How ALwritys SIF Agents Help You Every Week
</Typography>
</Box>
<Typography variant="body2" sx={{ color: '#334155', mb: 1.5 }}>
Your connected analytics power a helpful weekly routine. Our SIF agent framework reads real search signals and proposes simple, highimpact actions for your content—no jargon, just clear next steps.
</Typography>
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', mb: 2 }}>
<Chip icon={<AnalyticsIcon />} label="LowCTR pages" sx={{ bgcolor: '#eef2ff', color: '#312e81', fontWeight: 600 }} />
<Chip icon={<AnalyticsIcon />} label="Strikingdistance wins" sx={{ bgcolor: '#ecfeff', color: '#075985', fontWeight: 600 }} />
<Chip icon={<AnalyticsIcon />} label="Declining queries" sx={{ bgcolor: '#f0fdf4', color: '#14532d', fontWeight: 600 }} />
<Chip icon={<AnalyticsIcon />} label="Cannibalization fixes" sx={{ bgcolor: '#fff7ed', color: '#7c2d12', fontWeight: 600 }} />
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap', mb: 2 }}>
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4 }}>
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #e5e7eb', minWidth: 210, textAlign: 'center', bgcolor: '#f9fafb' }}>
<Typography variant="caption" sx={{ color: '#334155', fontWeight: 700, display: 'block', mb: 1 }}>
GSC & Bing Metrics
</Typography>
<AnalyticsIcon sx={{ color: '#2563eb' }} />
<Typography variant="body2" sx={{ color: '#334155', mt: 1 }}>
Clicks, impressions, CTR, positions
</Typography>
</Paper>
</motion.div>
<ArrowForwardIcon sx={{ color: '#64748b' }} />
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, delay: 0.05 }}>
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #e5e7eb', minWidth: 210, textAlign: 'center', bgcolor: '#f9fafb' }}>
<Typography variant="caption" sx={{ color: '#334155', fontWeight: 700, display: 'block', mb: 1 }}>
SIF Agents
</Typography>
<PsychologyIcon sx={{ color: '#7c3aed' }} />
<Typography variant="body2" sx={{ color: '#334155', mt: 1 }}>
Turns signals into clear suggestions
</Typography>
</Paper>
</motion.div>
<ArrowForwardIcon sx={{ color: '#64748b' }} />
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, delay: 0.1 }}>
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid #e5e7eb', minWidth: 210, textAlign: 'center', bgcolor: '#f9fafb' }}>
<Typography variant="caption" sx={{ color: '#334155', fontWeight: 700, display: 'block', mb: 1 }}>
Suggested Actions
</Typography>
<AutoAwesomeIcon sx={{ color: '#059669' }} />
<Typography variant="body2" sx={{ color: '#334155', mt: 1 }}>
Better titles/meta, refreshes, consolidations
</Typography>
</Paper>
</motion.div>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' }, gap: 2 }}>
<motion.div initial={{ opacity: 0, x: -12 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: 0.45 }}>
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid #e5e7eb',
bgcolor: '#f9fafb',
}}
>
<Typography variant="subtitle2" sx={{ color: '#111827', fontWeight: 700, mb: 1 }}>
Who does what
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}>
<Chip size="small" label="SEO Agent" sx={{ bgcolor: '#eef2ff', color: '#312e81', fontWeight: 700 }} />
<Typography variant="body2" sx={{ color: '#334155' }}>
Finds lowCTR pages and strikingdistance queries; suggests title/meta fixes and refreshes.
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip size="small" label="Content Agent" sx={{ bgcolor: '#ecfeff', color: '#075985', fontWeight: 700 }} />
<Typography variant="body2" sx={{ color: '#334155' }}>
Recommends consolidation and internal links from cannibalization; queues refresh topics.
</Typography>
</Box>
</Paper>
</motion.div>
<motion.div initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: 0.45 }}>
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid #e5e7eb',
bgcolor: '#f9fafb',
}}
>
<Typography variant="subtitle2" sx={{ color: '#111827', fontWeight: 700, mb: 1 }}>
What you get
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}>
<CheckCircleIcon sx={{ color: '#16a34a' }} />
<Typography variant="body2" sx={{ color: '#334155' }}>
Clear, bitesize fixes that improve visibility and clicks.
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.75 }}>
<CheckCircleIcon sx={{ color: '#16a34a' }} />
<Typography variant="body2" sx={{ color: '#334155' }}>
A weekly rhythm that keeps content fresh and organized.
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckCircleIcon sx={{ color: '#16a34a' }} />
<Typography variant="body2" sx={{ color: '#334155' }}>
Caching protects your quota; agents use cached insights, not direct API calls.
</Typography>
</Box>
</Paper>
</motion.div>
</Box>
<Box sx={{ mt: 2 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2 }}>
<Paper
elevation={0}
sx={{
p: 1.75,
borderRadius: 2,
border: '1px solid #e5e7eb',
bgcolor: '#f9fafb',
}}
>
<Typography variant="subtitle2" sx={{ color: '#111827', fontWeight: 700, mb: 1 }}>
Full Flow at a Glance
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, flexWrap: 'wrap' }}>
<Paper
elevation={0}
sx={{
p: 1.25,
borderRadius: 2,
border: '1px solid #e5e7eb',
minWidth: 150,
textAlign: 'center',
bgcolor: '#ffffff',
}}
>
<Typography variant="caption" sx={{ color: '#334155', fontWeight: 700, display: 'block', mb: 0.5 }}>
1. Connect
</Typography>
<AnalyticsIcon sx={{ color: '#2563eb' }} />
<Typography variant="body2" sx={{ color: '#334155', mt: 0.5 }}>
GSC & Bing
</Typography>
</Paper>
<ArrowForwardIcon sx={{ color: '#64748b' }} />
<Paper
elevation={0}
sx={{
p: 1.25,
borderRadius: 2,
border: '1px solid #e5e7eb',
minWidth: 150,
textAlign: 'center',
bgcolor: '#ffffff',
}}
>
<Typography variant="caption" sx={{ color: '#334155', fontWeight: 700, display: 'block', mb: 0.5 }}>
2. Cache
</Typography>
<AutoAwesomeIcon sx={{ color: '#0891b2' }} />
<Typography variant="body2" sx={{ color: '#334155', mt: 0.5 }}>
Fast, quotasafe
</Typography>
</Paper>
<ArrowForwardIcon sx={{ color: '#64748b' }} />
<Paper
elevation={0}
sx={{
p: 1.25,
borderRadius: 2,
border: '1px solid #e5e7eb',
minWidth: 150,
textAlign: 'center',
bgcolor: '#ffffff',
}}
>
<Typography variant="caption" sx={{ color: '#334155', fontWeight: 700, display: 'block', mb: 0.5 }}>
3. Analyze
</Typography>
<PsychologyIcon sx={{ color: '#7c3aed' }} />
<Typography variant="body2" sx={{ color: '#334155', mt: 0.5 }}>
SIF agents
</Typography>
</Paper>
<ArrowForwardIcon sx={{ color: '#64748b' }} />
<Paper
elevation={0}
sx={{
p: 1.25,
borderRadius: 2,
border: '1px solid #e5e7eb',
minWidth: 150,
textAlign: 'center',
bgcolor: '#ffffff',
}}
>
<Typography variant="caption" sx={{ color: '#334155', fontWeight: 700, display: 'block', mb: 0.5 }}>
4. Suggest
</Typography>
<AutoAwesomeIcon sx={{ color: '#059669' }} />
<Typography variant="body2" sx={{ color: '#334155', mt: 0.5 }}>
Clear fixes
</Typography>
</Paper>
</Box>
</Paper>
<Paper
elevation={0}
sx={{
p: 1.75,
borderRadius: 2,
border: '1px solid #e5e7eb',
bgcolor: '#f9fafb',
}}
>
<Typography variant="subtitle2" sx={{ color: '#111827', fontWeight: 700, mb: 1 }}>
Guided Walkthrough
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, mb: 1.5 }}>
<Chip
icon={<PlayArrowIcon />}
label="Auto walkthrough"
sx={{ bgcolor: '#eef2ff', color: '#111827', fontWeight: 700 }}
/>
<Typography variant="caption" sx={{ color: '#64748b', fontWeight: 600 }}>
{walkthroughLabels[walkthroughStep]}
</Typography>
</Box>
<Box sx={{ position: 'relative', minHeight: 120 }}>
<motion.div
key={`walk-${walkthroughStep}`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.35 }}
>
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px dashed #cbd5e1', bgcolor: '#f8fafc' }}>
<Typography variant="body2" sx={{ color: '#334155', fontWeight: 600, mb: 0.5 }}>
{walkthroughTitles[walkthroughStep]}
</Typography>
<Typography variant="body2" sx={{ color: '#475569' }}>
{walkthroughDescriptions[walkthroughStep]}
</Typography>
</Paper>
</motion.div>
</Box>
</Paper>
</Box>
</Box>
</Paper>
</div>
</Fade>
{/* Success Toast */} {/* Success Toast */}
<Snackbar <Snackbar
open={showToast} open={showToast}
@@ -613,4 +990,4 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
); );
}; };
export default IntegrationsStep; export default IntegrationsStep;

View File

@@ -67,7 +67,7 @@ interface QualityMetrics {
type PersonalizationTab = 'text' | 'image' | 'audio'; type PersonalizationTab = 'text' | 'image' | 'audio';
const PersonalizationStep: React.FC<PersonalizationStepProps> = ({ const PersonalizationStep: React.FC<PersonalizationStepProps> = ({
onContinue, onContinue: _onContinue,
updateHeaderContent, updateHeaderContent,
onValidationChange, onValidationChange,
onDataChange, onDataChange,
@@ -80,7 +80,6 @@ const PersonalizationStep: React.FC<PersonalizationStepProps> = ({
// AI Generation state (Ported from PersonaStep) // AI Generation state (Ported from PersonaStep)
const [generationStep, setGenerationStep] = useState<string>('analyzing'); const [generationStep, setGenerationStep] = useState<string>('analyzing');
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
@@ -94,7 +93,7 @@ const PersonalizationStep: React.FC<PersonalizationStepProps> = ({
// UI state // UI state
const [showPreview, setShowPreview] = useState(false); const [showPreview, setShowPreview] = useState(false);
const [expandedAccordion, setExpandedAccordion] = useState<string | false>('core'); const [expandedAccordion, setExpandedAccordion] = useState<string | false>('core');
const [hasCheckedCache, setHasCheckedCache] = useState(false); const [, setHasCheckedCache] = useState(false);
const [configurationOptions, setConfigurationOptions] = useState<any>(null); const [configurationOptions, setConfigurationOptions] = useState<any>(null);
// Asset Status State // Asset Status State
@@ -417,26 +416,6 @@ const PersonalizationStep: React.FC<PersonalizationStepProps> = ({
generatePersonas(); generatePersonas();
}; };
const handleContinue = useCallback(() => {
if (corePersona && platformPersonas && qualityMetrics) {
if (!brandAvatarSet || !voiceCloneSet) {
setError('Please generate and set your Brand Avatar and Voice Clone before continuing.');
return;
}
const personaData = {
corePersona,
platformPersonas,
qualityMetrics,
selectedPlatforms,
stepType: 'personalization',
completedAt: new Date().toISOString()
};
onContinue(personaData);
} else {
setError('Missing persona data. Please generate your brand voice first.');
}
}, [corePersona, platformPersonas, qualityMetrics, selectedPlatforms, onContinue, brandAvatarSet, voiceCloneSet]);
useEffect(() => { useEffect(() => {
const hasValidData = !!(corePersona && platformPersonas && Object.keys(platformPersonas).length > 0 && qualityMetrics); const hasValidData = !!(corePersona && platformPersonas && Object.keys(platformPersonas).length > 0 && qualityMetrics);
const isComplete = !isGenerating && hasValidData && generationStep === 'preview' && brandAvatarSet && voiceCloneSet; const isComplete = !isGenerating && hasValidData && generationStep === 'preview' && brandAvatarSet && voiceCloneSet;

View File

@@ -9,6 +9,7 @@ import {
} from '@mui/material'; } from '@mui/material';
import { createAvatarVideoAsync } from '../../../../api/videoStudioApi'; import { createAvatarVideoAsync } from '../../../../api/videoStudioApi';
import { useVideoGenerationPolling } from '../../../../hooks/usePolling'; import { useVideoGenerationPolling } from '../../../../hooks/usePolling';
import { fetchMediaBlobUrl } from '../../../../utils/fetchMediaBlobUrl';
import { VideoCameraFront, SkipNext, PlayArrow, InfoOutlined, Close as CloseIcon, HelpOutline, Refresh, RestartAlt, Undo } from '@mui/icons-material'; import { VideoCameraFront, SkipNext, PlayArrow, InfoOutlined, Close as CloseIcon, HelpOutline, Refresh, RestartAlt, Undo } from '@mui/icons-material';
import { VideoGenerationLoader } from '../../../shared/VideoGenerationLoader'; import { VideoGenerationLoader } from '../../../shared/VideoGenerationLoader';
import { OperationButton } from '../../../shared/OperationButton'; import { OperationButton } from '../../../shared/OperationButton';
@@ -29,6 +30,7 @@ export const TestPersonaModal: React.FC<TestPersonaModalProps> = ({
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
const [model, setModel] = useState<'infinitetalk' | 'hunyuan-avatar'>('infinitetalk'); const [model, setModel] = useState<'infinitetalk' | 'hunyuan-avatar'>('infinitetalk');
const [showCapabilities, setShowCapabilities] = useState(false); const [showCapabilities, setShowCapabilities] = useState(false);
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null);
const STORAGE_KEY = 'test_persona_video_url'; const STORAGE_KEY = 'test_persona_video_url';
const STORAGE_BACKUP_KEY = 'test_persona_video_url_backup'; const STORAGE_BACKUP_KEY = 'test_persona_video_url_backup';
@@ -135,9 +137,29 @@ export const TestPersonaModal: React.FC<TestPersonaModalProps> = ({
setGeneratedVideoUrl(null); setGeneratedVideoUrl(null);
try { try {
// 1. Fetch blobs from URLs (works for data URIs too) let avatarBlob: Blob;
const avatarBlob = await fetch(avatarUrl).then(r => r.blob()); try {
const voiceBlob = await fetch(voiceUrl).then(r => r.blob()); const avatarBlobUrl = await fetchMediaBlobUrl(avatarUrl);
if (avatarBlobUrl) {
avatarBlob = await fetch(avatarBlobUrl).then(r => r.blob());
} else {
avatarBlob = await fetch(avatarUrl).then(r => r.blob());
}
} catch {
avatarBlob = await fetch(avatarUrl).then(r => r.blob());
}
let voiceBlob: Blob;
try {
const voiceBlobUrl = await fetchMediaBlobUrl(voiceUrl);
if (voiceBlobUrl) {
voiceBlob = await fetch(voiceBlobUrl).then(r => r.blob());
} else {
voiceBlob = await fetch(voiceUrl).then(r => r.blob());
}
} catch {
voiceBlob = await fetch(voiceUrl).then(r => r.blob());
}
// 2. Create Files // 2. Create Files
const avatarFile = new File([avatarBlob], "avatar.png", { type: avatarBlob.type }); const avatarFile = new File([avatarBlob], "avatar.png", { type: avatarBlob.type });
@@ -175,6 +197,68 @@ export const TestPersonaModal: React.FC<TestPersonaModalProps> = ({
}, 100); }, 100);
}; };
useEffect(() => {
if (!avatarUrl) {
setAvatarBlobUrl(null);
return;
}
if (avatarUrl.startsWith('data:') || avatarUrl.startsWith('blob:')) {
setAvatarBlobUrl(null);
return;
}
const isInternal =
avatarUrl.includes('/api/podcast/') ||
avatarUrl.includes('/api/youtube/') ||
avatarUrl.includes('/api/story/') ||
(avatarUrl.startsWith('/') && !avatarUrl.startsWith('//'));
if (!isInternal) {
setAvatarBlobUrl(null);
return;
}
let isMounted = true;
const currentAvatarUrl = avatarUrl;
const loadAvatarBlob = async () => {
try {
const blobUrl = await fetchMediaBlobUrl(currentAvatarUrl);
if (!isMounted || avatarUrl !== currentAvatarUrl) {
if (blobUrl && blobUrl.startsWith('blob:')) {
URL.revokeObjectURL(blobUrl);
}
return;
}
setAvatarBlobUrl(prev => {
if (prev && prev !== blobUrl && prev.startsWith('blob:')) {
URL.revokeObjectURL(prev);
}
return blobUrl;
});
} catch {
if (isMounted && avatarUrl === currentAvatarUrl) {
setAvatarBlobUrl(null);
}
}
};
loadAvatarBlob();
return () => {
isMounted = false;
setAvatarBlobUrl(prev => {
if (prev && prev.startsWith('blob:')) {
URL.revokeObjectURL(prev);
}
return null;
});
};
}, [avatarUrl]);
const CapabilitiesModal = () => ( const CapabilitiesModal = () => (
<Dialog <Dialog
open={showCapabilities} open={showCapabilities}
@@ -429,7 +513,7 @@ export const TestPersonaModal: React.FC<TestPersonaModalProps> = ({
{/* Avatar Preview */} {/* Avatar Preview */}
<Box sx={{ position: 'relative' }}> <Box sx={{ position: 'relative' }}>
<Avatar <Avatar
src={avatarUrl} src={avatarBlobUrl || avatarUrl}
sx={{ width: 140, height: 140, border: '4px solid #ffffff', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }} sx={{ width: 140, height: 140, border: '4px solid #ffffff', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
/> />
<Box sx={{ position: 'absolute', bottom: 0, right: 0, bgcolor: '#10b981', color: 'white', p: 0.5, borderRadius: '50%', border: '2px solid white' }}> <Box sx={{ position: 'absolute', bottom: 0, right: 0, bgcolor: '#10b981', color: 'white', p: 0.5, borderRadius: '50%', border: '2px solid white' }}>

View File

@@ -29,6 +29,7 @@ import {
// Extracted components // Extracted components
import { AnalysisResultsDisplay, AnalysisProgressDisplay } from './WebsiteStep/components'; import { AnalysisResultsDisplay, AnalysisProgressDisplay } from './WebsiteStep/components';
import type { StyleAnalysis } from './WebsiteStep/components/AnalysisResultsDisplay';
// Import API client for saving // Import API client for saving
import { apiClient } from '../../api/client'; import { apiClient } from '../../api/client';
@@ -48,104 +49,6 @@ interface WebsiteStepProps {
onValidationChange?: (isValid: boolean) => void; onValidationChange?: (isValid: boolean) => void;
} }
interface StyleAnalysis {
id?: number;
writing_style?: {
tone: string;
voice: string;
complexity: string;
engagement_level: string;
brand_personality?: string;
formality_level?: string;
emotional_appeal?: string;
};
content_characteristics?: {
sentence_structure: string;
vocabulary_level: string;
paragraph_organization: string;
content_flow: string;
readability_score?: string;
content_density?: string;
visual_elements_usage?: string;
};
target_audience?: {
demographics: string[];
expertise_level: string;
industry_focus: string;
geographic_focus: string;
psychographic_profile?: string;
pain_points?: string[];
motivations?: string[];
};
content_type?: {
primary_type: string;
secondary_types: string[];
purpose: string;
call_to_action: string;
conversion_focus?: string;
educational_value?: string;
};
brand_analysis?: {
brand_voice: string;
brand_values: string[];
brand_positioning: string;
competitive_differentiation: string;
trust_signals: string[];
authority_indicators: string[];
};
content_strategy_insights?: {
strengths: string[];
weaknesses: string[];
opportunities: string[];
threats: string[];
recommended_improvements: string[];
content_gaps: string[];
};
recommended_settings?: {
writing_tone: string;
target_audience: string;
content_type: string;
creativity_level: string;
geographic_location: string;
industry_context?: string;
brand_alignment?: string;
};
guidelines?: {
tone_recommendations: string[];
structure_guidelines: string[];
vocabulary_suggestions: string[];
engagement_tips: string[];
audience_considerations: string[];
brand_alignment?: string[];
seo_optimization?: string[];
conversion_optimization?: string[];
};
best_practices?: string[];
avoid_elements?: string[];
content_strategy?: string;
ai_generation_tips?: string[];
competitive_advantages?: string[];
content_calendar_suggestions?: string[];
style_patterns?: {
sentence_length: string;
vocabulary_patterns: string[];
rhetorical_devices: string[];
paragraph_structure: string;
transition_phrases: string[];
};
patterns?: {
sentence_length: string;
vocabulary_patterns: string[];
rhetorical_devices: string[];
paragraph_structure: string;
transition_phrases: string[];
};
style_consistency?: string;
unique_elements?: string[];
seo_audit?: any;
sitemap_analysis?: any;
}
interface AnalysisProgress { interface AnalysisProgress {
step: number; step: number;
message: string; message: string;
@@ -189,6 +92,7 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
const [analysisWarning, setAnalysisWarning] = useState<string | null>(null);
const [analysis, setAnalysis] = useState<StyleAnalysis | null>(null); const [analysis, setAnalysis] = useState<StyleAnalysis | null>(null);
const [crawlResult, setCrawlResult] = useState<any>(null); const [crawlResult, setCrawlResult] = useState<any>(null);
const [existingAnalysis, setExistingAnalysis] = useState<ExistingAnalysis | null>(null); const [existingAnalysis, setExistingAnalysis] = useState<ExistingAnalysis | null>(null);
@@ -290,6 +194,7 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
setDomainName(result.domainName || ''); setDomainName(result.domainName || '');
setAnalysis(result.analysis); setAnalysis(result.analysis);
setCrawlResult(result.crawlResult); setCrawlResult(result.crawlResult);
setAnalysisWarning(result.warning || null);
setSuccess('Loaded previous analysis successfully!'); setSuccess('Loaded previous analysis successfully!');
} }
return result; return result;
@@ -298,6 +203,7 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
const handleAnalyze = async () => { const handleAnalyze = async () => {
setError(null); setError(null);
setSuccess(null); setSuccess(null);
setAnalysisWarning(null);
setLoading(true); setLoading(true);
setAnalysis(null); setAnalysis(null);
setCrawlResult(null); setCrawlResult(null);
@@ -330,6 +236,7 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
setDomainName(analysisResult.domainName || ''); setDomainName(analysisResult.domainName || '');
setAnalysis(analysisResult.analysis); setAnalysis(analysisResult.analysis);
setCrawlResult(analysisResult.crawlResult); setCrawlResult(analysisResult.crawlResult);
setAnalysisWarning(analysisResult.warning || null);
// Store in localStorage for Step 3 (Competitor Analysis) // Store in localStorage for Step 3 (Competitor Analysis)
localStorage.setItem('website_url', fixedUrl); localStorage.setItem('website_url', fixedUrl);
@@ -404,6 +311,7 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
if (analysisResult.success) { if (analysisResult.success) {
setDomainName(analysisResult.domainName || ''); setDomainName(analysisResult.domainName || '');
setAnalysis(analysisResult.analysis); setAnalysis(analysisResult.analysis);
setAnalysisWarning(analysisResult.warning || null);
if (analysisResult.warning) { if (analysisResult.warning) {
setSuccess(`Website style analysis completed successfully! Note: ${analysisResult.warning}`); setSuccess(`Website style analysis completed successfully! Note: ${analysisResult.warning}`);
@@ -754,6 +662,7 @@ const WebsiteStep: React.FC<WebsiteStepProps> = ({ onContinue, updateHeaderConte
useAnalysisForGenAI={useAnalysisForGenAI} useAnalysisForGenAI={useAnalysisForGenAI}
onUseAnalysisChange={setUseAnalysisForGenAI} onUseAnalysisChange={setUseAnalysisForGenAI}
onAnalysisUpdate={handleAnalysisUpdate} onAnalysisUpdate={handleAnalysisUpdate}
warning={analysisWarning || undefined}
onSave={() => saveAnalysis(analysis)} onSave={() => saveAnalysis(analysis)}
/> />
</Box> </Box>

View File

@@ -64,7 +64,18 @@ import { useOnboardingStyles } from '../../common/useOnboardingStyles';
import { apiClient } from '../../../../api/client'; import { apiClient } from '../../../../api/client';
interface StyleAnalysis { export interface StyleAnalysis {
id?: number;
guidelines?: {
tone_recommendations?: string[];
structure_guidelines?: string[];
vocabulary_suggestions?: string[];
engagement_tips?: string[];
audience_considerations?: string[];
brand_alignment?: string[];
seo_optimization?: string[];
conversion_optimization?: string[];
} | null;
writing_style?: { writing_style?: {
tone: string; tone: string;
voice: string; voice: string;
@@ -132,6 +143,7 @@ interface AnalysisResultsDisplayProps {
onUseAnalysisChange: (use: boolean) => void; onUseAnalysisChange: (use: boolean) => void;
crawlResult?: any; crawlResult?: any;
onAnalysisUpdate?: (updatedAnalysis: StyleAnalysis) => void; onAnalysisUpdate?: (updatedAnalysis: StyleAnalysis) => void;
warning?: string;
onSave?: () => void; onSave?: () => void;
} }
@@ -142,12 +154,17 @@ const AnalysisResultsDisplay: React.FC<AnalysisResultsDisplayProps> = ({
onUseAnalysisChange, onUseAnalysisChange,
crawlResult, crawlResult,
onAnalysisUpdate, onAnalysisUpdate,
warning,
onSave onSave
}) => { }) => {
const styles = useOnboardingStyles(); const styles = useOnboardingStyles();
const [isCrawlExpanded, setIsCrawlExpanded] = useState(false); const [isCrawlExpanded, setIsCrawlExpanded] = useState(false);
const [isEditable, setIsEditable] = useState(false); const [isEditable, setIsEditable] = useState(false);
const warningParts = warning ? warning.split('|').map(part => part.trim()).filter(Boolean) : [];
const guidelineWarning = warningParts.find(part => part.toLowerCase().startsWith('guidelines generation failed'));
const sitemapWarning = warningParts.find(part => part.toLowerCase().startsWith('sitemap analysis failed'));
// Helper to handle section updates // Helper to handle section updates
const handleSectionUpdate = (section: string, fieldPath: string, value: any) => { const handleSectionUpdate = (section: string, fieldPath: string, value: any) => {
if (!onAnalysisUpdate) return; if (!onAnalysisUpdate) return;
@@ -383,17 +400,25 @@ const AnalysisResultsDisplay: React.FC<AnalysisResultsDisplayProps> = ({
{renderBrandAnalysisSection(analysis)} {renderBrandAnalysisSection(analysis)}
</Box> </Box>
{/* Style Guidelines Section */} {(analysis.guidelines || guidelineWarning) && (
<Box sx={{ mt: 4 }}> <Box sx={{ mt: 4 }}>
<SectionHeader <SectionHeader
title="Style Guidelines" title="Style Guidelines"
icon={<AutoAwesomeIcon />} icon={<AutoAwesomeIcon />}
/> />
<EnhancedGuidelinesSection {guidelineWarning && (
guidelines={analysis.style_guidelines} <Alert severity="warning" sx={{ mb: 2 }}>
domainName={domainName} {guidelineWarning}
/> </Alert>
</Box> )}
{analysis.guidelines && (
<EnhancedGuidelinesSection
guidelines={analysis.guidelines}
domainName={domainName}
/>
)}
</Box>
)}
{/* SEO Audit Section */} {/* SEO Audit Section */}
<Box sx={{ mt: 4 }}> <Box sx={{ mt: 4 }}>
@@ -408,12 +433,16 @@ const AnalysisResultsDisplay: React.FC<AnalysisResultsDisplayProps> = ({
/> />
</Box> </Box>
{/* Sitemap Analysis Section */}
<Box sx={{ mt: 4 }}> <Box sx={{ mt: 4 }}>
<SectionHeader <SectionHeader
title="Sitemap Analysis" title="Sitemap Analysis"
icon={<LinkIcon />} icon={<LinkIcon />}
/> />
{sitemapWarning && (
<Alert severity="warning" sx={{ mb: 2 }}>
{sitemapWarning}
</Alert>
)}
<SitemapAnalysisSection <SitemapAnalysisSection
sitemapAnalysis={analysis.sitemap_analysis} sitemapAnalysis={analysis.sitemap_analysis}
domainName={domainName} domainName={domainName}

View File

@@ -36,7 +36,7 @@ interface Guidelines {
} }
interface EnhancedGuidelinesSectionProps { interface EnhancedGuidelinesSectionProps {
guidelines: Guidelines; guidelines?: Guidelines | null;
domainName: string; domainName: string;
} }
@@ -46,6 +46,10 @@ const EnhancedGuidelinesSection: React.FC<EnhancedGuidelinesSectionProps> = ({
}) => { }) => {
const styles = useOnboardingStyles(); const styles = useOnboardingStyles();
if (!guidelines) {
return null;
}
return ( return (
<Box sx={styles.analysisSection}> <Box sx={styles.analysisSection}>
<Typography variant="h5" sx={styles.analysisSectionHeader} gutterBottom> <Typography variant="h5" sx={styles.analysisSectionHeader} gutterBottom>

View File

@@ -108,6 +108,7 @@ export const loadExistingAnalysis = async (analysisId: number, website: string):
analysis?: any; analysis?: any;
domainName?: string; domainName?: string;
crawlResult?: any; crawlResult?: any;
warning?: string;
error?: string; error?: string;
}> => { }> => {
try { try {
@@ -115,13 +116,12 @@ export const loadExistingAnalysis = async (analysisId: number, website: string):
const result = response.data; const result = response.data;
if (result.success && result.analysis) { if (result.success && result.analysis) {
// Extract domain name for personalization
const extractedDomain = extractDomainName(website); const extractedDomain = extractDomainName(website);
// Database structure: flat fields at top level // Database structure: flat fields at top level
// Need to combine them into the format expected by UI // Need to combine them into the format expected by UI
const comprehensiveAnalysis = { const comprehensiveAnalysis = {
// Top-level style analysis fields from database id: result.analysis.id,
writing_style: result.analysis.writing_style, writing_style: result.analysis.writing_style,
content_characteristics: result.analysis.content_characteristics, content_characteristics: result.analysis.content_characteristics,
target_audience: result.analysis.target_audience, target_audience: result.analysis.target_audience,
@@ -151,7 +151,8 @@ export const loadExistingAnalysis = async (analysisId: number, website: string):
success: true, success: true,
analysis: comprehensiveAnalysis, analysis: comprehensiveAnalysis,
domainName: extractedDomain, domainName: extractedDomain,
crawlResult: result.analysis.crawl_result crawlResult: result.analysis.crawl_result,
warning: result.analysis.warning_message
}; };
} }
return { return {
@@ -212,6 +213,7 @@ export const performAnalysis = async (
// Combine all analysis data into a comprehensive object // Combine all analysis data into a comprehensive object
const comprehensiveAnalysis = { const comprehensiveAnalysis = {
id: result.analysis_id,
...result.style_analysis, ...result.style_analysis,
seo_audit: result.seo_audit, seo_audit: result.seo_audit,
sitemap_analysis: result.crawl_result?.sitemap_analysis, sitemap_analysis: result.crawl_result?.sitemap_analysis,

View File

@@ -654,6 +654,20 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
} }
} }
// Special handling for IntegrationsStep (step 4)
if (activeStep === 4) {
const currentData = stepDataRef.current || {};
if (!currentStepData && currentData && typeof currentData === 'object') {
if (currentData.integrations) {
currentStepData = {
integrations: currentData.integrations,
};
} else {
currentStepData = currentData;
}
}
}
// Store step data in state // Store step data in state
if (currentStepData) { if (currentStepData) {
setStepData(currentStepData); setStepData(currentStepData);
@@ -681,7 +695,7 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
// Complete the current step (activeStep + 1 because steps are 1-indexed) // Complete the current step (activeStep + 1 because steps are 1-indexed)
const currentStepNumber = activeStep + 1; const currentStepNumber = activeStep + 1;
const stepWasCompleted = currentStepData && typeof currentStepData === 'object' && ( const hasCoreStepData = currentStepData && typeof currentStepData === 'object' && (
currentStepData.website || currentStepData.website ||
currentStepData.businessData || currentStepData.businessData ||
currentStepData.competitors || currentStepData.competitors ||
@@ -692,6 +706,10 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
currentStepData.qualityMetrics currentStepData.qualityMetrics
); );
const hasIntegrationsData = !!(currentStepData && typeof currentStepData === 'object' && currentStepData.integrations);
const stepWasCompleted = hasCoreStepData || hasIntegrationsData;
console.log('Wizard: Step completion check:', { console.log('Wizard: Step completion check:', {
currentStepNumber, currentStepNumber,
hasData: !!currentStepData, hasData: !!currentStepData,
@@ -881,6 +899,7 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
onContinue={handleNext} onContinue={handleNext}
updateHeaderContent={updateHeaderContent} updateHeaderContent={updateHeaderContent}
onValidationChange={(isValid: boolean) => handleStepValidationChange(4, isValid)} onValidationChange={(isValid: boolean) => handleStepValidationChange(4, isValid)}
onDataChange={handleStepDataChange}
/>, />,
<FinalStep key="final" onContinue={handleComplete} updateHeaderContent={updateHeaderContent} /> <FinalStep key="final" onContinue={handleComplete} updateHeaderContent={updateHeaderContent} />
]; ];
@@ -901,6 +920,7 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
return ( return (
<Box <Box
className="light-theme-container"
sx={{ sx={{
minHeight: '100vh', minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',

View File

@@ -1,25 +1,142 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Stack, Box, Typography, Divider, Chip, Paper, alpha, CircularProgress } from "@mui/material"; import { Stack, Box, Typography, Divider, Chip, Paper, alpha, CircularProgress, TextField, IconButton, Tooltip, Select, MenuItem, FormControl, InputLabel, Switch, FormControlLabel } from "@mui/material";
import { Psychology as PsychologyIcon, Insights as InsightsIcon, Search as SearchIcon, Person as PersonIcon, AutoAwesome as AutoAwesomeIcon } from "@mui/icons-material"; import { Psychology as PsychologyIcon, Insights as InsightsIcon, Search as SearchIcon, Person as PersonIcon, AutoAwesome as AutoAwesomeIcon, Edit as EditIcon, Save as SaveIcon, Close as CloseIcon, Add as AddIcon, Delete as DeleteIcon, EditNote as EditNoteIcon } from "@mui/icons-material";
import { PodcastAnalysis } from "./types"; import { PodcastAnalysis, PodcastEstimate } from "./types";
import { GlassyCard, glassyCardSx, SecondaryButton } from "./ui"; import { GlassyCard, glassyCardSx, SecondaryButton } from "./ui";
import { Refresh as RefreshIcon } from "@mui/icons-material"; import { Refresh as RefreshIcon } from "@mui/icons-material";
import { aiApiClient } from "../../api/client"; import { aiApiClient } from "../../api/client";
interface AnalysisPanelProps { interface AnalysisPanelProps {
analysis: PodcastAnalysis | null; analysis: PodcastAnalysis | null;
estimate: PodcastEstimate | null;
idea?: string; idea?: string;
duration?: number; duration?: number;
speakers?: number; speakers?: number;
avatarUrl?: string | null; avatarUrl?: string | null;
avatarPrompt?: string | null; avatarPrompt?: string | null;
onRegenerate?: () => void; onRegenerate?: () => void;
onUpdateAnalysis?: (updatedAnalysis: PodcastAnalysis) => void;
} }
export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, idea, duration, speakers, avatarUrl, avatarPrompt, onRegenerate }) => { const inputStyles = {
'& .MuiInputBase-input': {
color: '#111827 !important',
fontWeight: 500,
WebkitTextFillColor: '#111827 !important', // Fix for some browsers
},
'& .MuiInputLabel-root': {
color: '#4b5563 !important',
},
'& .MuiOutlinedInput-root': {
bgcolor: '#ffffff !important',
'& fieldset': {
borderColor: '#d1d5db !important',
},
'&:hover fieldset': {
borderColor: '#4f46e5 !important',
},
'&.Mui-focused fieldset': {
borderColor: '#4f46e5 !important',
}
},
'& .MuiSelect-select': {
color: '#111827 !important',
WebkitTextFillColor: '#111827 !important',
}
};
export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({
analysis,
estimate,
idea,
duration,
speakers,
avatarUrl,
avatarPrompt,
onRegenerate,
onUpdateAnalysis
}) => {
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null); const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null);
const [avatarLoading, setAvatarLoading] = useState(false); const [avatarLoading, setAvatarLoading] = useState(false);
const [avatarError, setAvatarError] = useState(false); const [avatarError, setAvatarError] = useState(false);
// Edit states
const [isEditing, setIsEditing] = useState(false);
const [editedAnalysis, setEditedAnalysis] = useState<PodcastAnalysis | null>(null);
// Sync editedAnalysis with analysis initially
useEffect(() => {
if (analysis && !editedAnalysis) {
setEditedAnalysis(JSON.parse(JSON.stringify(analysis)));
}
}, [analysis]);
const handleSave = () => {
if (editedAnalysis && onUpdateAnalysis) {
console.log('[AnalysisPanel] Saving updated analysis:', editedAnalysis);
onUpdateAnalysis(JSON.parse(JSON.stringify(editedAnalysis)));
}
setIsEditing(false);
};
const handleCancel = () => {
setIsEditing(false);
setEditedAnalysis(JSON.parse(JSON.stringify(analysis)));
};
const updateExaConfig = (field: string, value: any) => {
if (!editedAnalysis) return;
setEditedAnalysis({
...editedAnalysis,
exaSuggestedConfig: {
...(editedAnalysis.exaSuggestedConfig || {}),
[field]: value
}
});
};
const handleAddKeyword = (keyword: string) => {
if (!editedAnalysis || !keyword.trim()) return;
if (editedAnalysis.topKeywords.includes(keyword.trim())) return;
setEditedAnalysis({
...editedAnalysis,
topKeywords: [...editedAnalysis.topKeywords, keyword.trim()]
});
};
const handleRemoveKeyword = (keyword: string) => {
if (!editedAnalysis) return;
setEditedAnalysis({
...editedAnalysis,
topKeywords: editedAnalysis.topKeywords.filter(k => k !== keyword)
});
};
const handleAddTitle = (title: string) => {
if (!editedAnalysis || !title.trim()) return;
setEditedAnalysis({
...editedAnalysis,
titleSuggestions: [...editedAnalysis.titleSuggestions, title.trim()]
});
};
const handleRemoveTitle = (title: string) => {
if (!editedAnalysis) return;
setEditedAnalysis({
...editedAnalysis,
titleSuggestions: editedAnalysis.titleSuggestions.filter(t => t !== title)
});
};
const handleUpdateOutline = (id: string | number, field: 'title' | 'segments', value: any) => {
if (!editedAnalysis) return;
setEditedAnalysis({
...editedAnalysis,
suggestedOutlines: editedAnalysis.suggestedOutlines.map(o =>
o.id === id ? { ...o, [field]: value } : o
)
});
};
// Load avatar image as blob for authenticated URLs // Load avatar image as blob for authenticated URLs
useEffect(() => { useEffect(() => {
@@ -93,44 +210,117 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, idea, du
}, [avatarUrl]); }, [avatarUrl]);
if (!analysis) return null; if (!analysis) return null;
const currentAnalysis = isEditing && editedAnalysis ? editedAnalysis : analysis;
console.log('[AnalysisPanel] Rendering:', { isEditing, hasEditedAnalysis: !!editedAnalysis });
return ( return (
<GlassyCard <GlassyCard
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28 }} transition={{ duration: 0.28 }}
className="light-theme-container"
sx={{ sx={{
...glassyCardSx, ...glassyCardSx,
background: "#ffffff", background: "#ffffff",
border: "1px solid rgba(0,0,0,0.06)", border: "1px solid rgba(0,0,0,0.06)",
boxShadow: "0 10px 28px rgba(15,23,42,0.06)", boxShadow: "0 10px 28px rgba(15,23,42,0.06)",
color: "#0f172a", color: "#111827",
}} }}
aria-label="analysis-panel" aria-label="analysis-panel"
> >
<Stack spacing={3}> <Stack spacing={3}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start"> <Stack direction="row" justifyContent="space-between" alignItems="center">
<Box> <Stack direction="row" alignItems="center" spacing={2} flex={1}>
<Typography <Typography
variant="h6" variant="h6"
sx={{ sx={{
color: "#0f172a", color: "#1e293b",
fontWeight: 800, fontWeight: 800,
mb: 0.5,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 1, gap: 1,
whiteSpace: "nowrap"
}} }}
> >
<PsychologyIcon /> <PsychologyIcon sx={{ color: "#4f46e5" }} />
AI Analysis AI Analysis
</Typography> </Typography>
<Typography variant="body2" color="text.secondary">
Insights derived from AI analysis of your topic and content preferences {estimate && (
</Typography> <Stack direction="row" alignItems="center" spacing={1.5} sx={{ ml: 2, flex: 1, overflow: 'hidden' }}>
</Box> <Divider orientation="vertical" flexItem sx={{ height: 24, alignSelf: 'center', borderColor: "rgba(0,0,0,0.1)" }} />
<SecondaryButton onClick={onRegenerate} startIcon={<RefreshIcon />} tooltip="Regenerate analysis with different parameters"> <Typography variant="subtitle2" fontWeight={700} sx={{ color: "#4f46e5" }}>
Regenerate Est. Cost: ${estimate.total.toFixed(2)}
</SecondaryButton> </Typography>
<Stack direction="row" spacing={1} sx={{ display: { xs: 'none', lg: 'flex' } }}>
<Chip
label={`Voice: $${estimate.ttsCost.toFixed(2)}`}
size="small"
variant="outlined"
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
/>
<Chip
label={`Visuals: $${estimate.avatarCost.toFixed(2)}`}
size="small"
variant="outlined"
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
/>
<Chip
label={`Research: $${estimate.researchCost.toFixed(2)}`}
size="small"
variant="outlined"
sx={{ height: 20, fontSize: '0.7rem', color: "#64748b", borderColor: "rgba(0,0,0,0.15)", bgcolor: "rgba(0,0,0,0.02)" }}
/>
</Stack>
</Stack>
)}
</Stack>
<Stack direction="row" spacing={1}>
{isEditing ? (
<>
<SecondaryButton
onClick={handleSave}
startIcon={<SaveIcon />}
sx={{
color: '#059669',
borderColor: '#10b981',
bgcolor: 'white',
fontWeight: 600,
'&:hover': { bgcolor: alpha('#10b981', 0.05) }
}}
>
Save Changes
</SecondaryButton>
<SecondaryButton
onClick={handleCancel}
startIcon={<CloseIcon />}
sx={{ color: '#4b5563', borderColor: '#d1d5db', bgcolor: 'white' }}
>
Cancel
</SecondaryButton>
</>
) : (
<>
<SecondaryButton
onClick={() => setIsEditing(true)}
startIcon={<EditIcon />}
sx={{ color: '#4f46e5', borderColor: '#4f46e5', bgcolor: 'white', fontWeight: 600 }}
>
Edit Analysis
</SecondaryButton>
<SecondaryButton
onClick={onRegenerate}
startIcon={<RefreshIcon />}
tooltip="Regenerate analysis with different parameters"
sx={{ color: '#4b5563', borderColor: '#d1d5db', bgcolor: 'white' }}
>
Regenerate
</SecondaryButton>
</>
)}
</Stack>
</Stack> </Stack>
<Divider sx={{ borderColor: "rgba(0,0,0,0.06)" }} /> <Divider sx={{ borderColor: "rgba(0,0,0,0.06)" }} />
@@ -359,31 +549,56 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, idea, du
)} )}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 3 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 3 }}>
<Stack spacing={2}> <Stack spacing={3}>
<Box> <Box>
<Typography variant="subtitle2" sx={{ mb: 1, display: "flex", alignItems: "center", gap: 0.5 }}> <Typography variant="subtitle2" sx={{ mb: 1, display: "flex", alignItems: "center", gap: 0.5 }}>
<InsightsIcon fontSize="small" sx={{ color: "#4f46e5" }} /> <InsightsIcon fontSize="small" sx={{ color: "#4f46e5" }} />
Target Audience Target Audience
</Typography> </Typography>
<Typography variant="body2" sx={{ color: "#0f172a" }}> {isEditing ? (
{analysis.audience} <TextField
</Typography> fullWidth
multiline
rows={2}
size="small"
value={currentAnalysis.audience}
onChange={(e) => setEditedAnalysis({ ...currentAnalysis, audience: e.target.value })}
placeholder="Describe your target audience..."
sx={inputStyles}
/>
) : (
<Typography variant="body2" sx={{ color: "#0f172a" }}>
{currentAnalysis.audience}
</Typography>
)}
</Box> </Box>
<Box> <Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Content Type</Typography> <Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Content Type</Typography>
<Chip label={analysis.contentType} size="small" sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }} /> {isEditing ? (
<TextField
fullWidth
size="small"
value={currentAnalysis.contentType}
onChange={(e) => setEditedAnalysis({ ...currentAnalysis, contentType: e.target.value })}
placeholder="e.g. Interview, Narrative, Solo..."
sx={inputStyles}
/>
) : (
<Chip label={currentAnalysis.contentType} size="small" sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }} />
)}
</Box> </Box>
<Box> <Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Top Keywords</Typography> <Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Top Keywords</Typography>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap> <Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mb: isEditing ? 1.5 : 0 }}>
{analysis.topKeywords.map((k) => ( {currentAnalysis.topKeywords.map((k) => (
<Chip <Chip
key={k} key={k}
label={k} label={k}
size="small" size="small"
variant="outlined" variant="outlined"
onDelete={isEditing ? () => handleRemoveKeyword(k) : undefined}
sx={{ sx={{
borderColor: "rgba(0,0,0,0.1)", borderColor: "rgba(0,0,0,0.1)",
color: "#0f172a", color: "#0f172a",
@@ -392,120 +607,291 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, idea, du
/> />
))} ))}
</Stack> </Stack>
{isEditing && (
<TextField
fullWidth
size="small"
placeholder="Add keyword and press Enter..."
sx={inputStyles}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddKeyword((e.target as HTMLInputElement).value);
(e.target as HTMLInputElement).value = '';
}
}}
InputProps={{
endAdornment: (
<IconButton size="small" onClick={(e) => {
const input = (e.currentTarget.parentElement?.parentElement?.querySelector('input') as HTMLInputElement);
handleAddKeyword(input.value);
input.value = '';
}}>
<AddIcon fontSize="small" sx={{ color: '#4f46e5' }} />
</IconButton>
)
}}
/>
)}
</Box>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#111827", fontWeight: 700, display: "flex", alignItems: "center", gap: 1 }}>
<EditNoteIcon fontSize="small" sx={{ color: "#4f46e5" }} />
Suggested Episode Outlines
</Typography>
<Stack spacing={2}>
{currentAnalysis.suggestedOutlines.map((o) => (
<Paper
key={o.id}
elevation={0}
sx={{
p: 2,
background: isEditing ? "#ffffff" : "#f8fafc",
border: "1px solid",
borderColor: isEditing ? "#e2e8f0" : "rgba(0,0,0,0.04)",
borderRadius: 2,
wordBreak: "break-word",
position: 'relative',
transition: "all 0.2s ease",
"&:hover": {
borderColor: "#4f46e5",
boxShadow: "0 4px 12px rgba(79, 70, 229, 0.05)"
}
}}
>
{isEditing ? (
<Stack spacing={2}>
<TextField
fullWidth
size="small"
label="Outline Title"
value={o.title}
onChange={(e) => handleUpdateOutline(o.id, 'title', e.target.value)}
sx={inputStyles}
/>
<TextField
fullWidth
multiline
size="small"
label="Segments"
value={o.segments.join(' • ')}
onChange={(e) => handleUpdateOutline(o.id, 'segments', e.target.value.split(/•|,/).map(s => s.trim()).filter(Boolean))}
helperText="Use • or comma to separate segments"
sx={inputStyles}
/>
</Stack>
) : (
<>
<Typography variant="body1" sx={{ fontWeight: 800, mb: 1, color: "#111827" }}>
{o.title}
</Typography>
<Stack spacing={1}>
{o.segments.map((segment, idx) => (
<Box key={idx} sx={{ display: "flex", alignItems: "flex-start", gap: 1 }}>
<Box sx={{ mt: 1, width: 6, height: 6, borderRadius: "50%", bgcolor: "#4f46e5", flexShrink: 0 }} />
<Typography variant="body2" sx={{ color: "#4b5563", lineHeight: 1.5 }}>
{segment}
</Typography>
</Box>
))}
</Stack>
</>
)}
</Paper>
))}
</Stack>
</Box> </Box>
</Stack> </Stack>
<Stack spacing={2}> <Stack spacing={3}>
{analysis.exaSuggestedConfig && ( {currentAnalysis.exaSuggestedConfig && (
<Box> <Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", display: "flex", alignItems: "center", gap: 0.5 }}> <Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a", display: "flex", alignItems: "center", gap: 0.5 }}>
<SearchIcon fontSize="small" sx={{ color: "#4f46e5" }} /> <SearchIcon fontSize="small" sx={{ color: "#4f46e5" }} />
Exa Research Suggestions Exa Research Suggestions
</Typography> </Typography>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mb: 1 }}>
{analysis.exaSuggestedConfig.exa_search_type && ( {isEditing ? (
<Chip <Stack spacing={2} sx={{ p: 2, border: '1px solid #e2e8f0', borderRadius: 2, bgcolor: '#ffffff' }}>
label={`Search: ${analysis.exaSuggestedConfig.exa_search_type}`} <Stack direction="row" spacing={2}>
size="small" <FormControl fullWidth size="small" sx={inputStyles}>
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }} <InputLabel>Search Type</InputLabel>
/> <Select
)} value={currentAnalysis.exaSuggestedConfig.exa_search_type || 'auto'}
{analysis.exaSuggestedConfig.exa_category && ( label="Search Type"
<Chip onChange={(e) => updateExaConfig('exa_search_type', e.target.value)}
label={`Category: ${analysis.exaSuggestedConfig.exa_category}`} >
size="small" <MenuItem value="auto">Auto</MenuItem>
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }} <MenuItem value="neural">Neural</MenuItem>
/> <MenuItem value="keyword">Keyword</MenuItem>
)} </Select>
{analysis.exaSuggestedConfig.date_range && ( </FormControl>
<Chip <FormControl fullWidth size="small" sx={inputStyles}>
label={`Date: ${analysis.exaSuggestedConfig.date_range}`} <InputLabel>Category</InputLabel>
size="small" <Select
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }} value={currentAnalysis.exaSuggestedConfig.exa_category || 'news'}
/> label="Category"
)} onChange={(e) => updateExaConfig('exa_category', e.target.value)}
{typeof analysis.exaSuggestedConfig.include_statistics === "boolean" && ( >
<Chip <MenuItem value="news">News</MenuItem>
label={analysis.exaSuggestedConfig.include_statistics ? "Include stats" : "No stats needed"} <MenuItem value="research paper">Research Paper</MenuItem>
size="small" <MenuItem value="company">Company</MenuItem>
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }} <MenuItem value="pdf">PDF</MenuItem>
/> <MenuItem value="tweet">Tweet</MenuItem>
)} </Select>
{analysis.exaSuggestedConfig.max_sources && ( </FormControl>
<Chip </Stack>
label={`Max sources: ${analysis.exaSuggestedConfig.max_sources}`}
size="small"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
)}
</Stack>
{(analysis.exaSuggestedConfig.exa_include_domains?.length || analysis.exaSuggestedConfig.exa_exclude_domains?.length) && ( <Stack direction="row" spacing={2} alignItems="center">
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap> <FormControl fullWidth size="small" sx={inputStyles}>
{analysis.exaSuggestedConfig.exa_include_domains?.length ? ( <InputLabel>Date Range</InputLabel>
<Box> <Select
<Typography variant="caption" sx={{ color: "#475569", fontWeight: 600, display: "block", mb: 0.5 }}> value={currentAnalysis.exaSuggestedConfig.date_range || 'all_time'}
Prefer domains label="Date Range"
</Typography> onChange={(e) => updateExaConfig('date_range', e.target.value)}
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap> >
{analysis.exaSuggestedConfig.exa_include_domains.map((d) => ( <MenuItem value="all_time">All Time</MenuItem>
<Chip key={d} label={d} size="small" sx={{ background: "#f8fafc", color: "#0f172a", border: "1px solid rgba(0,0,0,0.08)" }} /> <MenuItem value="last_month">Last Month</MenuItem>
))} <MenuItem value="last_year">Last Year</MenuItem>
</Stack> </Select>
</Box> </FormControl>
) : null} <TextField
type="number"
label="Max Sources"
size="small"
value={currentAnalysis.exaSuggestedConfig.max_sources || 10}
onChange={(e) => updateExaConfig('max_sources', parseInt(e.target.value))}
sx={{ ...inputStyles, width: 120 }}
/>
</Stack>
{analysis.exaSuggestedConfig.exa_exclude_domains?.length ? ( <FormControlLabel
<Box> control={
<Typography variant="caption" sx={{ color: "#475569", fontWeight: 600, display: "block", mb: 0.5 }}> <Switch
Avoid domains size="small"
</Typography> checked={currentAnalysis.exaSuggestedConfig.include_statistics || false}
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap> onChange={(e) => updateExaConfig('include_statistics', e.target.checked)}
{analysis.exaSuggestedConfig.exa_exclude_domains.map((d) => ( sx={{ '& .MuiSwitch-track': { bgcolor: '#4f46e5' } }}
<Chip key={d} label={d} size="small" sx={{ background: "#fff7ed", color: "#b45309", border: "1px solid rgba(180,83,9,0.25)" }} /> />
))} }
</Stack> label={<Typography variant="body2" sx={{ color: '#111827', fontWeight: 500 }}>Include Statistics</Typography>}
</Box> />
) : null}
<Stack spacing={1}>
<TextField
fullWidth
size="small"
label="Prefer Domains"
placeholder="e.g. techcrunch.com, wired.com (press Enter)"
sx={inputStyles}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const val = (e.target as HTMLInputElement).value.trim();
if (val) {
const domains = currentAnalysis.exaSuggestedConfig?.exa_include_domains || [];
updateExaConfig('exa_include_domains', [...domains, val]);
(e.target as HTMLInputElement).value = '';
}
}
}}
/>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
{(currentAnalysis.exaSuggestedConfig.exa_include_domains || []).map(d => (
<Chip key={d} label={d} size="small" onDelete={() => {
const domains = currentAnalysis.exaSuggestedConfig?.exa_include_domains?.filter(item => item !== d);
updateExaConfig('exa_include_domains', domains);
}} sx={{ bgcolor: '#f3f4f6', color: '#111827' }} />
))}
</Stack>
</Stack>
</Stack> </Stack>
) : (
<>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mb: 1 }}>
{currentAnalysis.exaSuggestedConfig.exa_search_type && (
<Chip
label={`Search: ${currentAnalysis.exaSuggestedConfig.exa_search_type}`}
size="small"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
)}
{currentAnalysis.exaSuggestedConfig.exa_category && (
<Chip
label={`Category: ${currentAnalysis.exaSuggestedConfig.exa_category}`}
size="small"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
)}
{currentAnalysis.exaSuggestedConfig.date_range && (
<Chip
label={`Date: ${currentAnalysis.exaSuggestedConfig.date_range}`}
size="small"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
)}
{typeof currentAnalysis.exaSuggestedConfig.include_statistics === "boolean" && (
<Chip
label={currentAnalysis.exaSuggestedConfig.include_statistics ? "Include stats" : "No stats needed"}
size="small"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
)}
{currentAnalysis.exaSuggestedConfig.max_sources && (
<Chip
label={`Max sources: ${currentAnalysis.exaSuggestedConfig.max_sources}`}
size="small"
sx={{ background: "#eef2ff", color: "#0f172a", border: "1px solid rgba(0,0,0,0.06)" }}
/>
)}
</Stack>
{(currentAnalysis.exaSuggestedConfig.exa_include_domains?.length || currentAnalysis.exaSuggestedConfig.exa_exclude_domains?.length) && (
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
{currentAnalysis.exaSuggestedConfig.exa_include_domains?.length ? (
<Box>
<Typography variant="caption" sx={{ color: "#475569", fontWeight: 600, display: "block", mb: 0.5 }}>
Prefer domains
</Typography>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
{currentAnalysis.exaSuggestedConfig.exa_include_domains.map((d) => (
<Chip key={d} label={d} size="small" sx={{ background: "#f8fafc", color: "#0f172a", border: "1px solid rgba(0,0,0,0.08)" }} />
))}
</Stack>
</Box>
) : null}
{currentAnalysis.exaSuggestedConfig.exa_exclude_domains?.length ? (
<Box>
<Typography variant="caption" sx={{ color: "#475569", fontWeight: 600, display: "block", mb: 0.5 }}>
Avoid domains
</Typography>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap>
{currentAnalysis.exaSuggestedConfig.exa_exclude_domains.map((d) => (
<Chip key={d} label={d} size="small" sx={{ background: "#fff7ed", color: "#b45309", border: "1px solid rgba(180,83,9,0.25)" }} />
))}
</Stack>
</Box>
) : null}
</Stack>
)}
</>
)} )}
</Box> </Box>
)} )}
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Suggested Episode Outlines</Typography>
<Stack spacing={1.5}>
{analysis.suggestedOutlines.map((o) => (
<Paper
key={o.id}
sx={{
p: 1.5,
background: "#f8fafc",
border: "1px solid rgba(0,0,0,0.06)",
wordBreak: "break-word",
}}
>
<Typography variant="body2" sx={{ fontWeight: 700, mb: 0.5, color: "#0f172a", wordBreak: "break-word" }}>
{o.title}
</Typography>
<Typography variant="caption" sx={{ color: "#475569", display: "block", wordBreak: "break-word" }}>
{o.segments.join(" • ")}
</Typography>
</Paper>
))}
</Stack>
</Box>
<Box> <Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Title Suggestions</Typography> <Typography variant="subtitle2" sx={{ mb: 1, color: "#0f172a" }}>Title Suggestions</Typography>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap> <Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mb: isEditing ? 1.5 : 0 }}>
{analysis.titleSuggestions.map((t) => ( {currentAnalysis.titleSuggestions.map((t) => (
<Chip <Chip
key={t} key={t}
label={t} label={t}
size="small" size="small"
onDelete={isEditing ? () => handleRemoveTitle(t) : undefined}
sx={{ sx={{
cursor: "pointer", cursor: isEditing ? "default" : "pointer",
color: "#0f172a", color: "#0f172a",
background: "#f8fafc", background: "#f8fafc",
maxWidth: "100%", maxWidth: "100%",
@@ -519,7 +905,7 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, idea, du
paddingTop: 0.25, paddingTop: 0.25,
paddingBottom: 0.25, paddingBottom: 0.25,
}, },
"&:hover": { "&:hover": isEditing ? {} : {
background: alpha("#667eea", 0.15), background: alpha("#667eea", 0.15),
border: "1px solid rgba(102,126,234,0.35)", border: "1px solid rgba(102,126,234,0.35)",
}, },
@@ -527,6 +913,32 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ analysis, idea, du
/> />
))} ))}
</Stack> </Stack>
{isEditing && (
<TextField
fullWidth
size="small"
placeholder="Add title suggestion..."
sx={inputStyles}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddTitle((e.target as HTMLInputElement).value);
(e.target as HTMLInputElement).value = '';
}
}}
InputProps={{
endAdornment: (
<IconButton size="small" onClick={(e) => {
const input = (e.currentTarget.parentElement?.parentElement?.querySelector('input') as HTMLInputElement);
handleAddTitle(input.value);
input.value = '';
}}>
<AddIcon fontSize="small" sx={{ color: '#4f46e5' }} />
</IconButton>
)
}}
/>
)}
</Box> </Box>
</Stack> </Stack>
</Box> </Box>

View File

@@ -0,0 +1,306 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Stack,
TextField,
InputAdornment,
RadioGroup,
FormControlLabel,
Radio,
Typography,
CircularProgress,
Alert,
Grid,
Card,
CardMedia,
Button,
IconButton
} from '@mui/material';
import {
Search as SearchIcon,
Collections as CollectionsIcon,
CheckCircle as CheckCircleIcon,
ExpandMore as ExpandMoreIcon,
Favorite as FavoriteIcon,
FavoriteBorder as FavoriteBorderIcon
} from '@mui/icons-material';
import { useContentAssets } from '../../hooks/useContentAssets';
import { fetchMediaBlobUrl } from '../../utils/fetchMediaBlobUrl';
interface AvatarAssetBrowserProps {
onSelect: (url: string) => void;
selectedUrl: string | null;
}
export const AvatarAssetBrowser: React.FC<AvatarAssetBrowserProps> = ({ onSelect, selectedUrl }) => {
const [filter, setFilter] = useState<'all' | 'favorites'>('all');
const [search, setSearch] = useState('');
const [imageBlobUrls, setImageBlobUrls] = useState<Map<number, string>>(new Map());
const [loadingImages, setLoadingImages] = useState<Set<number>>(new Set());
const [limit, setLimit] = useState(24);
const { assets, loading, error, total, toggleFavorite, refetch } = useContentAssets({
asset_type: 'image',
search: search || undefined,
favorites_only: filter === 'favorites',
limit: limit,
});
// No-op useEffect to satisfy the linter if needed, but the actual fetch is handled by useContentAssets hook's internal useEffect
// which runs when stableFilters change.
// The user reported that images don't load on initial tab mount unless toggled.
// useContentAssets's useEffect(fetchAssets, [filterKey, fetchAssets]) should handle it,
// but if it's failing initially due to auth timing, this manual refetch helps.
useEffect(() => {
// Only refetch on mount to ensure initial load
const timer = setTimeout(() => {
refetch();
}, 200); // Slightly longer delay to ensure auth is fully ready
return () => clearTimeout(timer);
}, [refetch]); // Only run on mount or if refetch function changes
// Check if a URL requires authentication (internal API endpoints)
const isAuthenticatedUrl = React.useCallback((url: string): boolean => {
if (!url) return false;
return url.includes('/api/podcast/') ||
url.includes('/api/youtube/') ||
url.includes('/api/story/') ||
(url.startsWith('/') && !url.startsWith('//'));
}, []);
// Load blob URLs for authenticated images
useEffect(() => {
if (assets.length === 0) {
setImageBlobUrls(new Map());
return;
}
const loadBlobUrls = async () => {
const newBlobUrls = new Map<number, string>();
const newLoadingImages = new Set<number>();
for (const asset of assets) {
if (!asset.file_url) continue;
if (isAuthenticatedUrl(asset.file_url)) {
newLoadingImages.add(asset.id);
try {
const blobUrl = await fetchMediaBlobUrl(asset.file_url);
if (blobUrl) {
newBlobUrls.set(asset.id, blobUrl);
}
} catch (err) {
console.error(`Failed to load image for asset ${asset.id}:`, err);
} finally {
newLoadingImages.delete(asset.id);
}
} else {
newBlobUrls.set(asset.id, asset.file_url);
}
}
setImageBlobUrls(prev => {
// Revoke old blobs that are no longer needed
prev.forEach((url, id) => {
if (url.startsWith('blob:') && !newBlobUrls.has(id)) URL.revokeObjectURL(url);
});
return newBlobUrls;
});
setLoadingImages(newLoadingImages);
};
loadBlobUrls();
// Cleanup on unmount/change is handled by the effect below or next run
}, [assets, isAuthenticatedUrl]);
// Cleanup all blobs on unmount
useEffect(() => {
return () => {
imageBlobUrls.forEach(url => {
if (url.startsWith('blob:')) URL.revokeObjectURL(url);
});
};
}, []);
const handleLoadMore = () => {
setLimit(prev => prev + 24);
};
return (
<Box sx={{ width: '100%', height: '100%' }}>
<Stack spacing={2}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1, width: '100%' }}>
<TextField
sx={{
flexGrow: 1,
bgcolor: 'white',
'& .MuiOutlinedInput-root': {
borderRadius: 2,
'& fieldset': { borderColor: '#e2e8f0' },
'&:hover fieldset': { borderColor: '#cbd5e1' },
'&.Mui-focused fieldset': { borderColor: '#667eea' },
'& .MuiOutlinedInput-input': {
color: '#0f172a',
py: 1,
'&::placeholder': {
color: '#94a3b8',
opacity: 1,
}
}
}
}}
size="small"
placeholder="Search images..."
value={search}
onChange={(e) => setSearch(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" sx={{ color: '#64748b' }} />
</InputAdornment>
),
}}
/>
<RadioGroup
row
value={filter}
onChange={(e) => setFilter(e.target.value as 'all' | 'favorites')}
sx={{
flexShrink: 0,
ml: 0.5,
display: 'flex',
flexWrap: 'nowrap',
'& .MuiFormControlLabel-root': {
mr: 0.5,
ml: 0,
'& .MuiTypography-root': {
color: '#334155',
fontWeight: 600,
fontSize: '0.75rem',
whiteSpace: 'nowrap'
},
'& .MuiRadio-root': {
p: 0.5,
color: '#94a3b8',
'&.Mui-checked': {
color: '#667eea',
}
}
}
}}
>
<FormControlLabel
value="all"
control={<Radio size="small" />}
label="All"
/>
<FormControlLabel
value="favorites"
control={<Radio size="small" />}
label="Favs"
/>
</RadioGroup>
</Stack>
{loading && assets.length === 0 ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress size={24} />
</Box>
) : error ? (
<Alert severity="error">{error}</Alert>
) : assets.length === 0 ? (
<Box sx={{ textAlign: 'center', p: 4, bgcolor: '#f8fafc', borderRadius: 2 }}>
<CollectionsIcon sx={{ fontSize: 48, color: '#cbd5e1', mb: 1 }} />
<Typography variant="body2" color="text.secondary">
{search ? 'No matches found' : 'No images in library'}
</Typography>
</Box>
) : (
<>
<Grid container spacing={1.5} sx={{ maxHeight: 300, overflowY: 'auto', pr: 0.5 }}>
{assets.map((asset) => (
<Grid item xs={6} sm={4} key={asset.id}>
<Card
sx={{
position: 'relative',
cursor: 'pointer',
border: selectedUrl === asset.file_url ? '2px solid #667eea' : '1px solid #e2e8f0',
'&:hover': { borderColor: '#667eea' }
}}
onClick={() => asset.file_url && onSelect(asset.file_url)}
>
<Box sx={{ position: 'relative', paddingTop: '100%' }}>
{isAuthenticatedUrl(asset.file_url) && !imageBlobUrls.has(asset.id) ? (
<Box sx={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: '#f8fafc' }}>
<CircularProgress size={20} />
</Box>
) : (
<CardMedia
component="img"
image={imageBlobUrls.get(asset.id) || asset.file_url || ''}
sx={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover'
}}
/>
)}
{loadingImages.has(asset.id) && (
<Box sx={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'rgba(255,255,255,0.7)' }}>
<CircularProgress size={20} />
</Box>
)}
<Box sx={{ position: 'absolute', top: 4, right: 4, display: 'flex', gap: 0.5 }}>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
toggleFavorite(asset.id);
}}
sx={{
bgcolor: 'rgba(255,255,255,0.8)',
'&:hover': { bgcolor: 'white' },
width: 24,
height: 24,
p: 0.5
}}
>
{asset.is_favorite ? <FavoriteIcon fontSize="small" color="error" /> : <FavoriteBorderIcon fontSize="small" />}
</IconButton>
{selectedUrl === asset.file_url && (
<Box sx={{ bgcolor: '#667eea', borderRadius: '50%', p: 0.5, display: 'flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24 }}>
<CheckCircleIcon sx={{ color: 'white', fontSize: 16 }} />
</Box>
)}
</Box>
</Box>
</Card>
</Grid>
))}
{/* Load More Button */}
{total > limit && (
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center', mt: 2, pb: 1 }}>
<Button
size="small"
variant="outlined"
onClick={handleLoadMore}
disabled={loading}
startIcon={loading ? <CircularProgress size={16} /> : <ExpandMoreIcon />}
>
{loading ? 'Loading...' : 'Load More'}
</Button>
</Grid>
)}
</Grid>
</>
)}
</Stack>
</Box>
);
};

Some files were not shown because too many files have changed in this diff Show More