269 lines
12 KiB
Python
269 lines
12 KiB
Python
"""
|
|
Outline Service - Core outline generation and management functionality.
|
|
|
|
Handles AI-powered outline generation, refinement, and optimization.
|
|
"""
|
|
|
|
from typing import Dict, Any, List
|
|
import asyncio
|
|
from loguru import logger
|
|
|
|
from models.blog_models import (
|
|
BlogOutlineRequest,
|
|
BlogOutlineResponse,
|
|
BlogOutlineRefineRequest,
|
|
BlogOutlineSection,
|
|
)
|
|
|
|
from .outline_generator import OutlineGenerator
|
|
from .outline_optimizer import OutlineOptimizer
|
|
from .section_enhancer import SectionEnhancer
|
|
from services.cache.persistent_outline_cache import persistent_outline_cache
|
|
|
|
|
|
class OutlineService:
|
|
"""Service for generating and managing blog outlines using AI."""
|
|
|
|
def __init__(self):
|
|
self.outline_generator = OutlineGenerator()
|
|
self.outline_optimizer = OutlineOptimizer()
|
|
self.section_enhancer = SectionEnhancer()
|
|
|
|
async def generate_outline(self, request: BlogOutlineRequest, user_id: str) -> BlogOutlineResponse:
|
|
"""
|
|
Stage 2: Content Planning with AI-generated outline using research results.
|
|
Uses Gemini with research data to create comprehensive, SEO-optimized outline.
|
|
|
|
Args:
|
|
request: Outline generation request with research data
|
|
user_id: User ID (required for subscription checks and usage tracking)
|
|
|
|
Raises:
|
|
ValueError: If user_id is not provided
|
|
"""
|
|
if not user_id:
|
|
raise ValueError("user_id is required for outline generation (subscription checks and usage tracking)")
|
|
|
|
# Extract cache parameters - use original user keywords for consistent caching
|
|
keywords = request.research.original_keywords or request.research.keyword_analysis.get('primary', [])
|
|
industry = getattr(request.persona, 'industry', 'general') if request.persona else 'general'
|
|
target_audience = getattr(request.persona, 'target_audience', 'general') if request.persona else 'general'
|
|
word_count = request.word_count or 1500
|
|
custom_instructions = request.custom_instructions or ""
|
|
persona_data = request.persona.dict() if request.persona else None
|
|
|
|
# Check cache first
|
|
cached_result = persistent_outline_cache.get_cached_outline(
|
|
keywords=keywords,
|
|
industry=industry,
|
|
target_audience=target_audience,
|
|
word_count=word_count,
|
|
custom_instructions=custom_instructions,
|
|
persona_data=persona_data
|
|
)
|
|
|
|
if cached_result:
|
|
logger.info(f"Using cached outline for keywords: {keywords}")
|
|
return BlogOutlineResponse(**cached_result)
|
|
|
|
# Generate new outline if not cached (user_id required)
|
|
logger.info(f"Generating new outline for keywords: {keywords}")
|
|
result = await self.outline_generator.generate(request, user_id)
|
|
|
|
# Cache the result
|
|
persistent_outline_cache.cache_outline(
|
|
keywords=keywords,
|
|
industry=industry,
|
|
target_audience=target_audience,
|
|
word_count=word_count,
|
|
custom_instructions=custom_instructions,
|
|
persona_data=persona_data,
|
|
result=result.dict()
|
|
)
|
|
|
|
return result
|
|
|
|
async def generate_outline_with_progress(self, request: BlogOutlineRequest, task_id: str, user_id: str) -> BlogOutlineResponse:
|
|
"""
|
|
Outline generation method with progress updates for real-time feedback.
|
|
"""
|
|
# Extract cache parameters - use original user keywords for consistent caching
|
|
keywords = request.research.original_keywords or request.research.keyword_analysis.get('primary', [])
|
|
industry = getattr(request.persona, 'industry', 'general') if request.persona else 'general'
|
|
target_audience = getattr(request.persona, 'target_audience', 'general') if request.persona else 'general'
|
|
word_count = request.word_count or 1500
|
|
custom_instructions = request.custom_instructions or ""
|
|
persona_data = request.persona.dict() if request.persona else None
|
|
|
|
# Check cache first
|
|
cached_result = persistent_outline_cache.get_cached_outline(
|
|
keywords=keywords,
|
|
industry=industry,
|
|
target_audience=target_audience,
|
|
word_count=word_count,
|
|
custom_instructions=custom_instructions,
|
|
persona_data=persona_data
|
|
)
|
|
|
|
if cached_result:
|
|
logger.info(f"Using cached outline for keywords: {keywords} (with progress updates)")
|
|
# Update progress to show cache hit
|
|
from api.blog_writer.task_manager import task_manager
|
|
await task_manager.update_progress(task_id, "✅ Using cached outline (saved generation time!)")
|
|
return BlogOutlineResponse(**cached_result)
|
|
|
|
# Generate new outline if not cached
|
|
logger.info(f"Generating new outline for keywords: {keywords} (with progress updates)")
|
|
result = await self.outline_generator.generate_with_progress(request, task_id, user_id)
|
|
|
|
# Cache the result
|
|
persistent_outline_cache.cache_outline(
|
|
keywords=keywords,
|
|
industry=industry,
|
|
target_audience=target_audience,
|
|
word_count=word_count,
|
|
custom_instructions=custom_instructions,
|
|
persona_data=persona_data,
|
|
result=result.dict()
|
|
)
|
|
|
|
return result
|
|
|
|
async def refine_outline(self, request: BlogOutlineRefineRequest) -> BlogOutlineResponse:
|
|
"""
|
|
Refine outline with HITL (Human-in-the-Loop) operations
|
|
Supports add, remove, move, merge, rename operations
|
|
"""
|
|
outline = request.outline.copy()
|
|
operation = request.operation.lower()
|
|
section_id = request.section_id
|
|
payload = request.payload or {}
|
|
|
|
try:
|
|
if operation == 'add':
|
|
# Add new section
|
|
new_section = BlogOutlineSection(
|
|
id=f"s{len(outline) + 1}",
|
|
heading=payload.get('heading', 'New Section'),
|
|
subheadings=payload.get('subheadings', []),
|
|
key_points=payload.get('key_points', []),
|
|
references=[],
|
|
target_words=payload.get('target_words', 300)
|
|
)
|
|
outline.append(new_section)
|
|
logger.info(f"Added new section: {new_section.heading}")
|
|
|
|
elif operation == 'remove' and section_id:
|
|
# Remove section
|
|
outline = [s for s in outline if s.id != section_id]
|
|
logger.info(f"Removed section: {section_id}")
|
|
|
|
elif operation == 'rename' and section_id:
|
|
# Rename section
|
|
for section in outline:
|
|
if section.id == section_id:
|
|
section.heading = payload.get('heading', section.heading)
|
|
break
|
|
logger.info(f"Renamed section {section_id} to: {payload.get('heading')}")
|
|
|
|
elif operation == 'move' and section_id:
|
|
# Move section (reorder)
|
|
direction = payload.get('direction', 'down') # 'up' or 'down'
|
|
current_index = next((i for i, s in enumerate(outline) if s.id == section_id), -1)
|
|
|
|
if current_index != -1:
|
|
if direction == 'up' and current_index > 0:
|
|
outline[current_index], outline[current_index - 1] = outline[current_index - 1], outline[current_index]
|
|
elif direction == 'down' and current_index < len(outline) - 1:
|
|
outline[current_index], outline[current_index + 1] = outline[current_index + 1], outline[current_index]
|
|
logger.info(f"Moved section {section_id} {direction}")
|
|
|
|
elif operation == 'merge' and section_id:
|
|
# Merge with next section
|
|
current_index = next((i for i, s in enumerate(outline) if s.id == section_id), -1)
|
|
if current_index != -1 and current_index < len(outline) - 1:
|
|
current_section = outline[current_index]
|
|
next_section = outline[current_index + 1]
|
|
|
|
# Merge sections
|
|
current_section.heading = f"{current_section.heading} & {next_section.heading}"
|
|
current_section.subheadings.extend(next_section.subheadings)
|
|
current_section.key_points.extend(next_section.key_points)
|
|
current_section.references.extend(next_section.references)
|
|
current_section.target_words = (current_section.target_words or 0) + (next_section.target_words or 0)
|
|
|
|
# Remove the next section
|
|
outline.pop(current_index + 1)
|
|
logger.info(f"Merged section {section_id} with next section")
|
|
|
|
elif operation == 'update' and section_id:
|
|
# Update section details
|
|
for section in outline:
|
|
if section.id == section_id:
|
|
if 'heading' in payload:
|
|
section.heading = payload['heading']
|
|
if 'subheadings' in payload:
|
|
section.subheadings = payload['subheadings']
|
|
if 'key_points' in payload:
|
|
section.key_points = payload['key_points']
|
|
if 'target_words' in payload:
|
|
section.target_words = payload['target_words']
|
|
break
|
|
logger.info(f"Updated section {section_id}")
|
|
|
|
# Reassign IDs to maintain order
|
|
for i, section in enumerate(outline):
|
|
section.id = f"s{i+1}"
|
|
|
|
return BlogOutlineResponse(
|
|
success=True,
|
|
title_options=["Refined Outline"],
|
|
outline=outline
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Outline refinement failed: {e}")
|
|
return BlogOutlineResponse(
|
|
success=False,
|
|
title_options=["Error"],
|
|
outline=request.outline
|
|
)
|
|
|
|
async def enhance_section_with_ai(self, section: BlogOutlineSection, focus: str = "general improvement") -> BlogOutlineSection:
|
|
"""Enhance a section using AI with research context."""
|
|
return await self.section_enhancer.enhance(section, focus)
|
|
|
|
async def optimize_outline_with_ai(self, outline: List[BlogOutlineSection], focus: str = "general optimization") -> List[BlogOutlineSection]:
|
|
"""Optimize entire outline for better flow, SEO, and engagement."""
|
|
return await self.outline_optimizer.optimize(outline, focus)
|
|
|
|
def rebalance_word_counts(self, outline: List[BlogOutlineSection], target_words: int) -> List[BlogOutlineSection]:
|
|
"""Rebalance word count distribution across sections."""
|
|
return self.outline_optimizer.rebalance_word_counts(outline, target_words)
|
|
|
|
# Cache Management Methods
|
|
|
|
def get_outline_cache_stats(self) -> Dict[str, Any]:
|
|
"""Get outline cache statistics."""
|
|
return persistent_outline_cache.get_cache_stats()
|
|
|
|
def clear_outline_cache(self):
|
|
"""Clear all cached outline entries."""
|
|
persistent_outline_cache.clear_cache()
|
|
logger.info("Outline cache cleared")
|
|
|
|
def invalidate_outline_cache_for_keywords(self, keywords: List[str]):
|
|
"""
|
|
Invalidate outline cache entries for specific keywords.
|
|
Useful when research data is updated.
|
|
|
|
Args:
|
|
keywords: Keywords to invalidate cache for
|
|
"""
|
|
persistent_outline_cache.invalidate_cache_for_keywords(keywords)
|
|
logger.info(f"Invalidated outline cache for keywords: {keywords}")
|
|
|
|
def get_recent_outline_cache_entries(self, limit: int = 20) -> List[Dict[str, Any]]:
|
|
"""Get recent outline cache entries for debugging."""
|
|
return persistent_outline_cache.get_cache_entries(limit)
|