""" Content Recycling Service - Republish high-performing evergreen content. """ from datetime import datetime, timedelta from typing import List, Dict, Optional import logging from app.core.database import SessionLocal from app.models.post import Post from app.models.recycled_post import RecycledPost logger = logging.getLogger(__name__) class RecyclingService: """Service for recycling high-performing content.""" # Default settings MIN_DAYS_SINCE_PUBLISH = 30 # Don't recycle recent posts MIN_ENGAGEMENT_RATE = 2.0 # Minimum engagement rate to consider MAX_RECYCLE_COUNT = 3 # Maximum times to recycle a post def _get_db(self): """Get database session.""" return SessionLocal() def _calculate_engagement_rate(self, metrics: dict) -> float: """Calculate engagement rate from metrics.""" if not metrics: return 0.0 impressions = metrics.get("impressions", 0) if impressions == 0: return 0.0 engagements = ( metrics.get("likes", 0) + metrics.get("comments", 0) + metrics.get("shares", 0) + metrics.get("retweets", 0) ) return (engagements / impressions) * 100 async def find_recyclable_posts( self, platform: str = None, content_type: str = None, min_engagement_rate: float = None, min_days_since_publish: int = None, limit: int = 20 ) -> List[Dict]: """ Find posts that are good candidates for recycling. Args: platform: Filter by platform content_type: Filter by content type min_engagement_rate: Minimum engagement rate threshold min_days_since_publish: Minimum days since original publish limit: Maximum candidates to return Returns: List of recyclable post candidates with scores """ db = self._get_db() try: min_eng = min_engagement_rate or self.MIN_ENGAGEMENT_RATE min_days = min_days_since_publish or self.MIN_DAYS_SINCE_PUBLISH cutoff_date = datetime.utcnow() - timedelta(days=min_days) # Query for published posts query = db.query(Post).filter( Post.status == "published", Post.published_at <= cutoff_date, Post.is_recyclable == True, Post.recycle_count < self.MAX_RECYCLE_COUNT, Post.metrics.isnot(None) ) if platform: query = query.filter(Post.platforms.contains([platform])) if content_type: query = query.filter(Post.content_type == content_type) posts = query.all() # Calculate engagement and filter/sort candidates = [] for post in posts: engagement_rate = self._calculate_engagement_rate(post.metrics) if engagement_rate >= min_eng: # Calculate a recycling score days_since_publish = (datetime.utcnow() - post.published_at).days recency_factor = min(days_since_publish / 90, 1.0) # Max out at 90 days recycled_penalty = 1 - (post.recycle_count * 0.2) # 20% penalty per recycle score = engagement_rate * recency_factor * recycled_penalty candidates.append({ "id": post.id, "content": post.content[:100] + "..." if len(post.content) > 100 else post.content, "full_content": post.content, "content_type": post.content_type, "platforms": post.platforms, "published_at": post.published_at.isoformat(), "days_since_publish": days_since_publish, "engagement_rate": round(engagement_rate, 2), "recycle_count": post.recycle_count, "score": round(score, 2), "metrics": post.metrics }) # Sort by score candidates.sort(key=lambda x: x["score"], reverse=True) return candidates[:limit] finally: db.close() async def recycle_post( self, post_id: int, modifications: Dict = None, scheduled_for: datetime = None, platforms: List[str] = None, reason: str = "manual" ) -> Dict: """ Create a recycled version of a post. Args: post_id: Original post ID modifications: Dict of modifications {content, hashtags, image_url} scheduled_for: When to publish (defaults to now + 1 hour) platforms: Override platforms (defaults to original) reason: Reason for recycling (high_performer, evergreen, seasonal, manual) Returns: Dict with new post info """ db = self._get_db() try: original = db.query(Post).filter(Post.id == post_id).first() if not original: return {"success": False, "error": "Original post not found"} if not original.is_recyclable: return {"success": False, "error": "Post is marked as not recyclable"} if original.recycle_count >= self.MAX_RECYCLE_COUNT: return {"success": False, "error": f"Post has been recycled {original.recycle_count} times (max {self.MAX_RECYCLE_COUNT})"} # Create new post new_content = modifications.get("content") if modifications else None new_hashtags = modifications.get("hashtags") if modifications else None new_image = modifications.get("image_url") if modifications else None new_post = Post( content=new_content or original.content, content_type=original.content_type, platforms=platforms or original.platforms, status="scheduled", scheduled_at=scheduled_for or (datetime.utcnow() + timedelta(hours=1)), hashtags=new_hashtags or original.hashtags, image_url=new_image or original.image_url, recycled_from_id=original.id, is_recyclable=True ) db.add(new_post) db.flush() # Track the recycling recycle_record = RecycledPost( original_post_id=original.id, new_post_id=new_post.id, recycle_number=original.recycle_count + 1, modifications={ "content_changed": bool(new_content), "hashtags_updated": bool(new_hashtags), "image_changed": bool(new_image) }, original_engagement_rate=self._calculate_engagement_rate(original.metrics), reason=reason, status="pending", scheduled_for=new_post.scheduled_at ) db.add(recycle_record) # Update original's recycle count original.recycle_count += 1 db.commit() db.refresh(new_post) return { "success": True, "new_post_id": new_post.id, "recycle_record_id": recycle_record.id, "scheduled_for": new_post.scheduled_at.isoformat(), "platforms": new_post.platforms } except Exception as e: logger.error(f"Error recycling post: {e}") db.rollback() return {"success": False, "error": str(e)} finally: db.close() async def auto_recycle( self, platform: str = None, count: int = 1, min_engagement_rate: float = None ) -> Dict: """ Automatically select and recycle top-performing posts. Args: platform: Filter by platform count: Number of posts to recycle min_engagement_rate: Minimum engagement rate threshold Returns: Dict with recycled posts info """ db = self._get_db() try: # Find candidates candidates = await self.find_recyclable_posts( platform=platform, min_engagement_rate=min_engagement_rate or self.MIN_ENGAGEMENT_RATE, limit=count * 2 # Get extra in case some fail ) if not candidates: return { "success": True, "recycled": 0, "message": "No eligible posts found for recycling" } recycled = [] for candidate in candidates[:count]: result = await self.recycle_post( post_id=candidate["id"], reason="high_performer" ) if result.get("success"): recycled.append({ "original_id": candidate["id"], "new_post_id": result["new_post_id"], "engagement_rate": candidate["engagement_rate"] }) return { "success": True, "recycled": len(recycled), "posts": recycled } finally: db.close() async def get_recycling_history( self, original_post_id: int = None, limit: int = 50 ) -> List[Dict]: """ Get recycling history. Args: original_post_id: Filter by original post limit: Maximum records to return Returns: List of recycling records """ db = self._get_db() try: query = db.query(RecycledPost) if original_post_id: query = query.filter(RecycledPost.original_post_id == original_post_id) records = query.order_by(RecycledPost.recycled_at.desc()).limit(limit).all() return [r.to_dict() for r in records] finally: db.close() async def mark_post_not_recyclable(self, post_id: int) -> Dict: """Mark a post as not eligible for recycling.""" db = self._get_db() try: post = db.query(Post).filter(Post.id == post_id).first() if not post: return {"success": False, "error": "Post not found"} post.is_recyclable = False db.commit() return {"success": True, "message": "Post marked as not recyclable"} finally: db.close() # Global instance recycling_service = RecyclingService()