From 11b0ba46fa1cea965fa5946b8ce7292a2bc2c898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Consultor=C3=ADa=20AS?= Date: Wed, 28 Jan 2026 20:55:28 +0000 Subject: [PATCH] feat: Add Content Generation Engine v2 with quality scoring Major improvements to AI content generation: ## New Components (app/services/ai/) - PromptLibrary: YAML-based prompt templates with inheritance - ContextEngine: Anti-repetition and best performers tracking - ContentGeneratorV2: Enhanced generation with dynamic parameters - PlatformAdapter: Platform-specific content adaptation - ContentValidator: AI-powered quality scoring (0-100) ## Prompt Library (app/prompts/) - 3 personalities: default, educational, promotional - 5 templates: tip_tech, product_post, service_post, thread, response - 4 platform configs: x, threads, instagram, facebook - Few-shot examples by category: ia, productividad, seguridad ## Database Changes - New table: content_memory (tracks generated content) - New columns in posts: quality_score, score_breakdown, generation_attempts ## New API Endpoints (/api/v2/generate/) - POST /generate - Generation with quality check - POST /generate/batch - Batch generation - POST /quality/evaluate - Evaluate content quality - GET /templates, /personalities, /platforms - List configs ## Celery Tasks - update_engagement_scores (every 6h) - cleanup_old_memory (monthly) - refresh_best_posts_yaml (weekly) ## Tests - Comprehensive tests for all AI engine components Co-Authored-By: Claude Opus 4.5 --- alembic/env.py | 6 +- ...20260128_add_content_memory_and_quality.py | 100 +++ app/api/routes/generate_v2.py | 617 ++++++++++++++++++ app/config/quality.yaml | 124 ++++ app/main.py | 3 +- app/models/__init__.py | 4 +- app/models/content_memory.py | 158 +++++ app/models/post.py | 10 +- app/prompts/examples/best_posts.yaml | 155 +++++ app/prompts/examples/by_category/ia.yaml | 94 +++ .../examples/by_category/productividad.yaml | 113 ++++ .../examples/by_category/seguridad.yaml | 117 ++++ app/prompts/personalities/default.yaml | 72 ++ app/prompts/personalities/educational.yaml | 37 ++ app/prompts/personalities/promotional.yaml | 45 ++ app/prompts/platforms/facebook.yaml | 83 +++ app/prompts/platforms/instagram.yaml | 86 +++ app/prompts/platforms/threads.yaml | 81 +++ app/prompts/platforms/x.yaml | 79 +++ app/prompts/templates/product_post.yaml | 82 +++ app/prompts/templates/response.yaml | 97 +++ app/prompts/templates/service_post.yaml | 87 +++ app/prompts/templates/thread.yaml | 84 +++ app/prompts/templates/tip_tech.yaml | 85 +++ app/services/ai/__init__.py | 24 + app/services/ai/context_engine.py | 519 +++++++++++++++ app/services/ai/generator.py | 493 ++++++++++++++ app/services/ai/platform_adapter.py | 374 +++++++++++ app/services/ai/prompt_library.py | 353 ++++++++++ app/services/ai/validator.py | 479 ++++++++++++++ app/services/content_generator.py | 416 +++++++++++- requirements.txt | 2 + tests/test_ai_engine.py | 447 +++++++++++++ worker/celery_app.py | 21 +- worker/tasks/content_memory.py | 342 ++++++++++ worker/tasks/generate_content.py | 432 ++++++++++-- 36 files changed, 6266 insertions(+), 55 deletions(-) create mode 100644 alembic/versions/20260128_add_content_memory_and_quality.py create mode 100644 app/api/routes/generate_v2.py create mode 100644 app/config/quality.yaml create mode 100644 app/models/content_memory.py create mode 100644 app/prompts/examples/best_posts.yaml create mode 100644 app/prompts/examples/by_category/ia.yaml create mode 100644 app/prompts/examples/by_category/productividad.yaml create mode 100644 app/prompts/examples/by_category/seguridad.yaml create mode 100644 app/prompts/personalities/default.yaml create mode 100644 app/prompts/personalities/educational.yaml create mode 100644 app/prompts/personalities/promotional.yaml create mode 100644 app/prompts/platforms/facebook.yaml create mode 100644 app/prompts/platforms/instagram.yaml create mode 100644 app/prompts/platforms/threads.yaml create mode 100644 app/prompts/platforms/x.yaml create mode 100644 app/prompts/templates/product_post.yaml create mode 100644 app/prompts/templates/response.yaml create mode 100644 app/prompts/templates/service_post.yaml create mode 100644 app/prompts/templates/thread.yaml create mode 100644 app/prompts/templates/tip_tech.yaml create mode 100644 app/services/ai/__init__.py create mode 100644 app/services/ai/context_engine.py create mode 100644 app/services/ai/generator.py create mode 100644 app/services/ai/platform_adapter.py create mode 100644 app/services/ai/prompt_library.py create mode 100644 app/services/ai/validator.py create mode 100644 tests/test_ai_engine.py create mode 100644 worker/tasks/content_memory.py diff --git a/alembic/env.py b/alembic/env.py index 3567549..e3138dd 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -16,9 +16,13 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from app.core.config import settings from app.core.database import Base +# Import all models for autogenerate support from app.models import ( User, Product, Service, TipTemplate, - Post, ContentCalendar, ImageTemplate, Interaction + Post, ContentCalendar, ImageTemplate, Interaction, + PostMetrics, AnalyticsReport, Lead, OdooSyncLog, + ABTest, ABTestVariant, RecycledPost, ThreadSeries, ThreadPost, + ContentMemory ) # this is the Alembic Config object diff --git a/alembic/versions/20260128_add_content_memory_and_quality.py b/alembic/versions/20260128_add_content_memory_and_quality.py new file mode 100644 index 0000000..ccfbb14 --- /dev/null +++ b/alembic/versions/20260128_add_content_memory_and_quality.py @@ -0,0 +1,100 @@ +"""Add content_memory table and quality columns to posts + +Revision ID: 20260128001 +Revises: +Create Date: 2026-01-28 + +This migration adds: +1. New table 'content_memory' for tracking generated content +2. New columns in 'posts' for quality scoring +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '20260128001' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # === Create content_memory table === + op.create_table( + 'content_memory', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('post_id', sa.Integer(), nullable=False), + + # Content analysis + sa.Column('topics', postgresql.ARRAY(sa.String()), nullable=True), + sa.Column('key_phrases', postgresql.ARRAY(sa.String()), nullable=True), + sa.Column('hook_type', sa.String(50), nullable=True), + sa.Column('content_summary', sa.Text(), nullable=True), + sa.Column('content_embedding', postgresql.JSON(), nullable=True), + + # Engagement metrics + sa.Column('engagement_score', sa.Float(), nullable=True), + sa.Column('engagement_breakdown', postgresql.JSON(), nullable=True), + sa.Column('is_top_performer', sa.Boolean(), default=False), + + # Quality score + sa.Column('quality_score', sa.Integer(), nullable=True), + sa.Column('quality_breakdown', postgresql.JSON(), nullable=True), + + # Example usage tracking + sa.Column('times_used_as_example', sa.Integer(), default=0), + sa.Column('last_used_as_example', sa.DateTime(), nullable=True), + + # Metadata + sa.Column('platform', sa.String(20), nullable=True), + sa.Column('content_type', sa.String(50), nullable=True), + sa.Column('personality_used', sa.String(50), nullable=True), + sa.Column('template_used', sa.String(50), nullable=True), + + # Timestamps + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['post_id'], ['posts.id'], ondelete='CASCADE'), + ) + + # Create indexes for content_memory + op.create_index('ix_content_memory_post_id', 'content_memory', ['post_id'], unique=True) + op.create_index('ix_content_memory_engagement_score', 'content_memory', ['engagement_score']) + op.create_index('ix_content_memory_is_top_performer', 'content_memory', ['is_top_performer']) + op.create_index('ix_content_memory_hook_type', 'content_memory', ['hook_type']) + op.create_index('ix_content_memory_platform', 'content_memory', ['platform']) + op.create_index('ix_content_memory_content_type', 'content_memory', ['content_type']) + op.create_index('ix_content_memory_created_at', 'content_memory', ['created_at']) + + # === Add quality columns to posts table === + op.add_column('posts', sa.Column('quality_score', sa.Integer(), nullable=True)) + op.add_column('posts', sa.Column('score_breakdown', postgresql.JSON(), nullable=True)) + op.add_column('posts', sa.Column('generation_attempts', sa.Integer(), server_default='1', nullable=True)) + + # Create index for quality_score + op.create_index('ix_posts_quality_score', 'posts', ['quality_score']) + + +def downgrade() -> None: + # Remove indexes from posts + op.drop_index('ix_posts_quality_score', table_name='posts') + + # Remove columns from posts + op.drop_column('posts', 'generation_attempts') + op.drop_column('posts', 'score_breakdown') + op.drop_column('posts', 'quality_score') + + # Remove indexes from content_memory + op.drop_index('ix_content_memory_created_at', table_name='content_memory') + op.drop_index('ix_content_memory_content_type', table_name='content_memory') + op.drop_index('ix_content_memory_platform', table_name='content_memory') + op.drop_index('ix_content_memory_hook_type', table_name='content_memory') + op.drop_index('ix_content_memory_is_top_performer', table_name='content_memory') + op.drop_index('ix_content_memory_engagement_score', table_name='content_memory') + op.drop_index('ix_content_memory_post_id', table_name='content_memory') + + # Drop content_memory table + op.drop_table('content_memory') diff --git a/app/api/routes/generate_v2.py b/app/api/routes/generate_v2.py new file mode 100644 index 0000000..dadce3a --- /dev/null +++ b/app/api/routes/generate_v2.py @@ -0,0 +1,617 @@ +""" +API endpoints v2 para generación de contenido con IA. + +Nuevas funcionalidades: +- Quality scoring +- Regeneración automática +- Context-aware generation +- Métricas de generación +""" + +from typing import Optional, List, Dict, Any +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.config import settings + + +router = APIRouter() + + +# ============================================================ +# Schemas +# ============================================================ + +class GenerateV2Request(BaseModel): + """Solicitud genérica de generación v2.""" + template: str = Field(..., description="Nombre del template (tip_tech, product_post, etc.)") + variables: Dict[str, Any] = Field(default_factory=dict, description="Variables para el template") + platform: str = Field(default="x", description="Plataforma destino") + personality: Optional[str] = Field(None, description="Override de personalidad") + use_context: bool = Field(default=True, description="Usar anti-repetición") + use_few_shot: bool = Field(default=True, description="Usar ejemplos de posts exitosos") + validate_quality: bool = Field(default=True, description="Validar y scorear calidad") + max_attempts: int = Field(default=2, ge=1, le=5, description="Máximo intentos de regeneración") + + +class GenerateV2Response(BaseModel): + """Respuesta de generación v2.""" + success: bool + content: Optional[str] = None + adapted_content: Optional[str] = None + quality_score: Optional[int] = None + score_breakdown: Optional[Dict[str, int]] = None + is_top_quality: bool = False + attempts: int = 1 + metadata: Optional[Dict[str, Any]] = None + error: Optional[str] = None + + +class BatchGenerateV2Request(BaseModel): + """Solicitud de generación por lotes v2.""" + template: str + variables_list: List[Dict[str, Any]] + platforms: List[str] = ["x"] + validate_quality: bool = True + + +class BatchGenerateV2Response(BaseModel): + """Respuesta de generación por lotes v2.""" + success: bool + total_requested: int + total_generated: int + results: List[GenerateV2Response] + average_quality: Optional[float] = None + + +class AdaptContentV2Request(BaseModel): + """Solicitud de adaptación v2.""" + content: str + source_platform: str = "instagram" + target_platforms: List[str] + use_ai: bool = True # Si usar IA para adaptación más precisa + + +class ContextInfoRequest(BaseModel): + """Solicitud de información de contexto.""" + content_type: str + category: Optional[str] = None + + +class QualityEvaluateRequest(BaseModel): + """Solicitud de evaluación de calidad.""" + content: str + platform: str = "x" + + +# ============================================================ +# Helpers +# ============================================================ + +def check_api_configured(): + """Verificar que la API de DeepSeek esté configurada.""" + if not settings.DEEPSEEK_API_KEY: + raise HTTPException( + status_code=503, + detail="DeepSeek API no configurada. Agrega DEEPSEEK_API_KEY en .env" + ) + + +def get_content_generator(): + """Obtener instancia del generador v2.""" + check_api_configured() + from app.services.content_generator import content_generator + if not content_generator._use_new_engine: + raise HTTPException( + status_code=503, + detail="Motor v2 no disponible. Verifica app.services.ai" + ) + return content_generator + + +# ============================================================ +# Endpoints de Generación +# ============================================================ + +@router.post("/generate", response_model=GenerateV2Response) +async def generate_content_v2( + request: GenerateV2Request, + db: Session = Depends(get_db) +): + """ + Generar contenido con el motor v2. + + Características: + - **template**: tip_tech, product_post, service_post, thread, response + - **variables**: Dependen del template (ver /templates para detalles) + - **validate_quality**: Activa scoring con IA + - **use_context**: Evita repetir temas recientes + - **use_few_shot**: Usa posts exitosos como ejemplos + """ + generator = get_content_generator() + + try: + if request.validate_quality: + result = await generator.generate_with_quality_check( + template_name=request.template, + variables=request.variables, + platform=request.platform, + db=db, + max_attempts=request.max_attempts + ) + + return GenerateV2Response( + success=True, + content=result["content"], + adapted_content=result["content"], # Ya está adaptado + quality_score=result.get("quality_score"), + score_breakdown=result.get("score_breakdown"), + is_top_quality=result.get("is_top_performer", False), + attempts=result.get("attempts", 1), + metadata=result.get("metadata") + ) + else: + result = await generator._v2.generate( + template_name=request.template, + variables=request.variables, + platform=request.platform, + db=db if request.use_context else None, + use_context=request.use_context, + use_few_shot=request.use_few_shot, + personality=request.personality + ) + + return GenerateV2Response( + success=True, + content=result["content"], + adapted_content=result["adapted_content"], + metadata=result["metadata"] + ) + + except Exception as e: + return GenerateV2Response( + success=False, + error=str(e) + ) + + +@router.post("/generate/batch", response_model=BatchGenerateV2Response) +async def generate_batch_v2( + request: BatchGenerateV2Request, + db: Session = Depends(get_db) +): + """ + Generar múltiples contenidos por lotes. + + Útil para: + - Generar tips para toda la semana + - Crear variaciones de un mismo tema + - Preparar contenido para múltiples plataformas + """ + generator = get_content_generator() + + results = [] + total_score = 0 + scored_count = 0 + + for variables in request.variables_list: + for platform in request.platforms: + try: + if request.validate_quality: + result = await generator.generate_with_quality_check( + template_name=request.template, + variables=variables, + platform=platform, + db=db, + max_attempts=2 + ) + + score = result.get("quality_score") + if score: + total_score += score + scored_count += 1 + + results.append(GenerateV2Response( + success=True, + content=result["content"], + quality_score=score, + is_top_quality=result.get("is_top_performer", False), + attempts=result.get("attempts", 1) + )) + else: + result = await generator._v2.generate( + template_name=request.template, + variables=variables, + platform=platform, + db=db + ) + + results.append(GenerateV2Response( + success=True, + content=result["content"], + adapted_content=result["adapted_content"] + )) + + except Exception as e: + results.append(GenerateV2Response( + success=False, + error=str(e) + )) + + return BatchGenerateV2Response( + success=all(r.success for r in results), + total_requested=len(request.variables_list) * len(request.platforms), + total_generated=sum(1 for r in results if r.success), + results=results, + average_quality=total_score / scored_count if scored_count > 0 else None + ) + + +@router.post("/generate/multiplatform") +async def generate_for_all_platforms( + template: str, + variables: Dict[str, Any], + platforms: List[str] = ["x", "threads", "instagram", "facebook"], + db: Session = Depends(get_db) +): + """ + Generar contenido optimizado para múltiples plataformas. + + Genera una versión base y la adapta inteligentemente + para cada plataforma usando IA. + """ + generator = get_content_generator() + + try: + results = await generator._v2.generate_for_all_platforms( + template_name=template, + variables=variables, + platforms=platforms, + db=db + ) + + return { + "success": True, + "platforms": { + platform: { + "content": result["content"], + "adapted_content": result["adapted_content"], + "metadata": result.get("metadata", {}) + } + for platform, result in results.items() + } + } + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================================ +# Endpoints de Adaptación +# ============================================================ + +@router.post("/adapt") +async def adapt_content_v2(request: AdaptContentV2Request): + """ + Adaptar contenido a múltiples plataformas. + + Si use_ai=True, usa IA para adaptación más precisa. + Si use_ai=False, usa reglas heurísticas (más rápido). + """ + generator = get_content_generator() + + results = {} + + for target in request.target_platforms: + try: + if request.use_ai: + adapted = await generator._v2.adapt_content( + content=request.content, + source_platform=request.source_platform, + target_platform=target + ) + else: + from app.services.ai import platform_adapter + adapted_result = platform_adapter.adapt(request.content, target) + adapted = adapted_result.content + + results[target] = { + "content": adapted, + "success": True + } + + except Exception as e: + results[target] = { + "content": None, + "success": False, + "error": str(e) + } + + return { + "original": request.content, + "source_platform": request.source_platform, + "adaptations": results + } + + +# ============================================================ +# Endpoints de Calidad +# ============================================================ + +@router.post("/quality/evaluate") +async def evaluate_content_quality(request: QualityEvaluateRequest): + """ + Evaluar calidad de contenido existente. + + Retorna score 0-100 con breakdown detallado. + """ + check_api_configured() + + from app.services.ai import content_validator + + try: + result = await content_validator.evaluate( + content=request.content, + platform=request.platform + ) + + return { + "success": True, + "validation": { + "passed": result.validation.passed, + "issues": result.validation.issues + }, + "scoring": { + "total_score": result.scoring.total_score if result.scoring else None, + "breakdown": result.scoring.breakdown if result.scoring else None, + "feedback": result.scoring.feedback if result.scoring else None, + "is_top_quality": result.scoring.is_top_performer if result.scoring else False + }, + "decision": result.final_decision + } + + except Exception as e: + return { + "success": False, + "error": str(e) + } + + +@router.post("/quality/validate") +async def validate_content( + content: str, + platform: str = "x" +): + """ + Validar contenido sin scoring (más rápido, sin costo de tokens). + + Verifica: + - Longitud dentro de límites + - Sin contenido prohibido + - Formato válido + """ + from app.services.ai import content_validator + + result = content_validator.validate(content, platform) + + return { + "valid": result.passed, + "issues": result.issues + } + + +# ============================================================ +# Endpoints de Contexto +# ============================================================ + +@router.post("/context/info") +async def get_context_info( + request: ContextInfoRequest, + db: Session = Depends(get_db) +): + """ + Obtener información de contexto para generación. + + Muestra: + - Temas usados recientemente + - Frases a evitar + - Hook sugerido + - Ejemplos de top performers + """ + from app.services.ai import context_engine + + recent_topics = context_engine.get_recent_topics(db) + recent_phrases = context_engine.get_recent_phrases(db) + recent_hooks = context_engine.get_recent_hooks(db) + suggested_hook = context_engine.suggest_hook_type(db) + + top_performers = context_engine.get_top_performers( + db, + content_type=request.content_type, + limit=3 + ) + + return { + "recent_topics": recent_topics[:10], + "recent_phrases": recent_phrases[:5], + "hook_usage": recent_hooks, + "suggested_hook": suggested_hook, + "top_performers": [ + { + "post_id": tp.post_id, + "engagement_score": tp.engagement_score, + "hook_type": tp.hook_type, + "topics": tp.topics + } + for tp in top_performers + ] + } + + +@router.get("/context/exclusions") +async def get_exclusion_context( + content_type: str, + category: Optional[str] = None, + db: Session = Depends(get_db) +): + """ + Obtener contexto de exclusión formateado. + + Útil para ver qué se excluirá en la próxima generación. + """ + from app.services.ai import context_engine + + exclusion_text = context_engine.build_exclusion_context( + db, content_type, category + ) + + return { + "content_type": content_type, + "category": category, + "exclusion_context": exclusion_text + } + + +# ============================================================ +# Endpoints de Templates y Configuración +# ============================================================ + +@router.get("/templates") +async def list_templates(): + """ + Listar todos los templates disponibles con sus variables. + """ + from app.services.ai import prompt_library + + templates = prompt_library.list_templates() + + result = [] + for name in templates: + try: + template = prompt_library.get_template(name) + result.append({ + "name": name, + "description": template.get("description", ""), + "personality": template.get("personality", "default"), + "variables": [ + { + "name": v["name"], + "type": v.get("type", "string"), + "required": v.get("required", False), + "default": v.get("default"), + "options": v.get("options") + } + for v in template.get("variables", []) + ], + "parameters": template.get("parameters", {}) + }) + except Exception: + continue + + return {"templates": result} + + +@router.get("/personalities") +async def list_personalities(): + """ + Listar personalidades disponibles. + """ + from app.services.ai import prompt_library + + personalities = prompt_library.list_personalities() + + result = [] + for name in personalities: + try: + pers = prompt_library.get_personality(name) + result.append({ + "name": name, + "description": pers.get("description", ""), + "voice": pers.get("voice", {}), + "extends": pers.get("extends") + }) + except Exception: + continue + + return {"personalities": result} + + +@router.get("/platforms") +async def list_platforms(): + """ + Listar plataformas configuradas con sus límites. + """ + from app.services.ai import prompt_library + + platforms = prompt_library.list_platforms() + + result = [] + for name in platforms: + try: + config = prompt_library.get_platform_config(name) + result.append({ + "name": name, + "display_name": config.get("display_name", name), + "limits": config.get("limits", {}), + "tone": config.get("tone", {}) + }) + except Exception: + continue + + return {"platforms": result} + + +@router.get("/status") +async def get_v2_status(): + """ + Verificar estado del motor v2. + """ + result = { + "api_configured": bool(settings.DEEPSEEK_API_KEY), + "provider": "DeepSeek", + "model": "deepseek-chat", + "v2_engine": False, + "components": {} + } + + # Verificar componentes + try: + from app.services.ai import PromptLibrary + lib = PromptLibrary() + result["components"]["prompt_library"] = { + "status": "ok", + "templates": len(lib.list_templates()), + "personalities": len(lib.list_personalities()), + "platforms": len(lib.list_platforms()) + } + except Exception as e: + result["components"]["prompt_library"] = {"status": "error", "error": str(e)} + + try: + from app.services.ai import ContextEngine + result["components"]["context_engine"] = {"status": "ok"} + except Exception as e: + result["components"]["context_engine"] = {"status": "error", "error": str(e)} + + try: + from app.services.ai import PlatformAdapter + result["components"]["platform_adapter"] = {"status": "ok"} + except Exception as e: + result["components"]["platform_adapter"] = {"status": "error", "error": str(e)} + + try: + from app.services.ai import ContentValidator + result["components"]["validator"] = {"status": "ok"} + except Exception as e: + result["components"]["validator"] = {"status": "error", "error": str(e)} + + try: + from app.services.ai import ContentGeneratorV2 + result["v2_engine"] = True + result["components"]["generator_v2"] = {"status": "ok"} + except Exception as e: + result["components"]["generator_v2"] = {"status": "error", "error": str(e)} + + return result diff --git a/app/config/quality.yaml b/app/config/quality.yaml new file mode 100644 index 0000000..580e35e --- /dev/null +++ b/app/config/quality.yaml @@ -0,0 +1,124 @@ +# Configuración de calidad para el Content Generation Engine +# Este archivo controla los umbrales de validación y scoring + +version: "1.0" + +# Umbrales de calidad +thresholds: + minimum_score: 60 # Score mínimo para aceptar contenido + regenerate_below: 60 # Regenerar si score está debajo de esto + excellent_score: 85 # Marcar como "top quality" si supera esto + +# Control de regeneración +regeneration: + max_attempts: 2 # Máximo intentos de regeneración + temperature_increment: 0.1 # Aumentar temperature en cada intento + +# Acciones según score +actions: + below_40: + action: reject + log: true + reason: "Calidad muy baja, posible error en generación" + range_40_60: + action: regenerate + log: true + reason: "Calidad insuficiente" + range_60_85: + action: accept + log: false + above_85: + action: accept_and_flag + flag_as: top_performer + log: true + reason: "Excelente calidad, usar como ejemplo" + +# Pesos para scoring con IA +scoring_weights: + hook_strength: 25 # ¿El inicio captura atención? + clarity: 20 # ¿Se entiende fácil? + actionability: 20 # ¿El lector puede hacer algo? + originality: 15 # ¿Diferente a posts anteriores? + brand_voice: 10 # ¿Suena como la marca? + cta_effectiveness: 10 # ¿El CTA es claro? (si aplica) + +# Prompt para scoring con IA +scoring_prompt: | + Evalúa este post para {platform} en escala 0-100. + + POST: + {content} + + CRITERIOS (suma = 100): + - Hook (0-25): ¿La primera línea captura atención inmediata? + - Claridad (0-20): ¿Se entiende sin esfuerzo? ¿Oraciones claras? + - Accionabilidad (0-20): ¿Qué puede hacer el lector después de leer? + - Originalidad (0-15): ¿Evita clichés? ¿Tiene perspectiva única? + - Voz de marca (0-10): ¿Profesional pero cercano? ¿Consistente? + - CTA (0-10): Si tiene CTA, ¿es claro y natural? Si no aplica, dar 5. + + RESPONDE EXACTAMENTE EN ESTE FORMATO JSON: + { + "total": , + "breakdown": { + "hook_strength": , + "clarity": , + "actionability": , + "originality": , + "brand_voice": , + "cta_effectiveness": + }, + "feedback": "<1 oración con sugerencia de mejora principal>" + } + +# Validaciones obligatorias (pass/fail) +validations: + length: + enabled: true + description: "Contenido dentro del límite de la plataforma" + action_on_fail: reject + + prohibited_content: + enabled: true + description: "Sin palabras o temas prohibidos" + action_on_fail: reject + prohibited_words: + - "mierda" + - "estafa" + - "garantizado 100%" + - "hazte rico" + prohibited_patterns: + - "compra ahora.*últimas unidades" # Urgencia falsa + + format: + enabled: true + description: "Formato válido y completo" + action_on_fail: reject + checks: + - not_truncated + - has_content + - proper_encoding + + language: + enabled: true + description: "En el idioma configurado" + expected_language: "es" + action_on_fail: reject + + repetition: + enabled: true + description: "No idéntico a posts recientes" + action_on_fail: reject + similarity_threshold: 0.85 # Rechazar si >85% similar a post reciente + lookback_days: 30 + +# Context Engine settings +context: + memory_window: 50 # Recordar últimos N posts + anti_repetition: + topic_cooldown_days: 7 # No repetir mismo tema en N días + phrase_cooldown_days: 14 # No repetir frases distintivas + best_performers: + top_percentile: 20 # Usar top 20% como examples + min_examples: 3 # Mínimo ejemplos a incluir + max_examples: 5 # Máximo ejemplos a incluir diff --git a/app/main.py b/app/main.py index a896cc0..def8fa2 100644 --- a/app/main.py +++ b/app/main.py @@ -11,7 +11,7 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware -from app.api.routes import posts, products, services, calendar, dashboard, interactions, auth, publish, generate, notifications, analytics, odoo, leads, ab_testing, recycling, threads, image_templates +from app.api.routes import posts, products, services, calendar, dashboard, interactions, auth, publish, generate, generate_v2, notifications, analytics, odoo, leads, ab_testing, recycling, threads, image_templates from app.core.config import settings from app.core.database import engine from app.models import Base @@ -64,6 +64,7 @@ app.include_router(calendar.router, prefix="/api/calendar", tags=["Calendar"]) app.include_router(interactions.router, prefix="/api/interactions", tags=["Interactions"]) app.include_router(publish.router, prefix="/api/publish", tags=["Publish"]) app.include_router(generate.router, prefix="/api/generate", tags=["AI Generation"]) +app.include_router(generate_v2.router, prefix="/api/v2/generate", tags=["AI Generation v2"]) app.include_router(notifications.router, prefix="/api/notifications", tags=["Notifications"]) app.include_router(analytics.router, prefix="/api/analytics", tags=["Analytics"]) app.include_router(odoo.router, prefix="/api/odoo", tags=["Odoo"]) diff --git a/app/models/__init__.py b/app/models/__init__.py index fb43302..90110a3 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -19,6 +19,7 @@ from app.models.odoo_sync_log import OdooSyncLog from app.models.ab_test import ABTest, ABTestVariant from app.models.recycled_post import RecycledPost from app.models.thread_series import ThreadSeries, ThreadPost +from app.models.content_memory import ContentMemory __all__ = [ "Base", @@ -38,5 +39,6 @@ __all__ = [ "ABTestVariant", "RecycledPost", "ThreadSeries", - "ThreadPost" + "ThreadPost", + "ContentMemory" ] diff --git a/app/models/content_memory.py b/app/models/content_memory.py new file mode 100644 index 0000000..3bdb2ac --- /dev/null +++ b/app/models/content_memory.py @@ -0,0 +1,158 @@ +""" +Modelo ContentMemory - Memoria de contenido para el Context Engine. + +Este modelo almacena análisis de posts generados para: +- Evitar repetición de temas y frases +- Identificar posts de alto rendimiento para few-shot learning +- Trackear patrones de éxito +""" + +from datetime import datetime +from sqlalchemy import ( + Column, Integer, String, Text, Float, Boolean, + DateTime, ForeignKey, JSON +) +from sqlalchemy.dialects.postgresql import ARRAY +from sqlalchemy.orm import relationship + +from app.core.database import Base + + +class ContentMemory(Base): + """ + Memoria de contenido generado. + + Almacena análisis semántico de cada post para que el Context Engine + pueda evitar repeticiones y aprender de posts exitosos. + """ + + __tablename__ = "content_memory" + + id = Column(Integer, primary_key=True, index=True) + + # Relación con el post original + post_id = Column(Integer, ForeignKey("posts.id"), nullable=False, unique=True, index=True) + + # === Análisis del contenido === + + # Temas/categorías detectadas + # Ejemplo: ["ia", "productividad", "python"] + topics = Column(ARRAY(String), nullable=True) + + # Frases distintivas usadas (para evitar repetición) + # Ejemplo: ["la regla 2-2-2", "el 90% ignora esto"] + key_phrases = Column(ARRAY(String), nullable=True) + + # Tipo de hook usado + # Ejemplo: "pregunta", "dato_impactante", "tip_directo", "historia" + hook_type = Column(String(50), nullable=True, index=True) + + # Resumen semántico del contenido (para comparación de similitud) + content_summary = Column(Text, nullable=True) + + # Embedding del contenido (para búsqueda semántica futura) + # Por ahora null, se puede agregar después con pgvector + content_embedding = Column(JSON, nullable=True) + + # === Métricas de éxito === + + # Score de engagement calculado (normalizado 0-100) + engagement_score = Column(Float, nullable=True, index=True) + + # Breakdown de métricas + # {"likes": 45, "comments": 12, "shares": 8, "saves": 3} + engagement_breakdown = Column(JSON, nullable=True) + + # ¿Está en el top 20% de engagement? + is_top_performer = Column(Boolean, default=False, index=True) + + # Score de calidad asignado al generar + quality_score = Column(Integer, nullable=True) + + # Breakdown del quality score + quality_breakdown = Column(JSON, nullable=True) + + # === Control de uso como ejemplo === + + # Veces que se ha usado como few-shot example + times_used_as_example = Column(Integer, default=0) + + # Última vez que se usó como ejemplo + last_used_as_example = Column(DateTime, nullable=True) + + # === Metadata === + + # Plataforma para la que se generó originalmente + platform = Column(String(20), nullable=True, index=True) + + # Tipo de contenido + content_type = Column(String(50), nullable=True, index=True) + + # Personalidad usada para generar + personality_used = Column(String(50), nullable=True) + + # Template usado + template_used = Column(String(50), nullable=True) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, index=True) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def __repr__(self): + return f"" + + def to_dict(self) -> dict: + """Convertir a diccionario.""" + return { + "id": self.id, + "post_id": self.post_id, + "topics": self.topics, + "key_phrases": self.key_phrases, + "hook_type": self.hook_type, + "content_summary": self.content_summary, + "engagement_score": self.engagement_score, + "engagement_breakdown": self.engagement_breakdown, + "is_top_performer": self.is_top_performer, + "quality_score": self.quality_score, + "quality_breakdown": self.quality_breakdown, + "times_used_as_example": self.times_used_as_example, + "platform": self.platform, + "content_type": self.content_type, + "created_at": self.created_at.isoformat() if self.created_at else None, + } + + def mark_as_top_performer(self) -> None: + """Marcar este contenido como top performer.""" + self.is_top_performer = True + self.updated_at = datetime.utcnow() + + def record_example_usage(self) -> None: + """Registrar que se usó como ejemplo.""" + self.times_used_as_example += 1 + self.last_used_as_example = datetime.utcnow() + self.updated_at = datetime.utcnow() + + def update_engagement(self, metrics: dict) -> None: + """ + Actualizar métricas de engagement. + + Args: + metrics: Dict con likes, comments, shares, saves, etc. + """ + self.engagement_breakdown = metrics + + # Calcular score normalizado + # Fórmula: likes + (comments * 2) + (shares * 3) + (saves * 2) + # Normalizado a 0-100 basado en promedios históricos + likes = metrics.get("likes", 0) + comments = metrics.get("comments", 0) + shares = metrics.get("shares", 0) + metrics.get("retweets", 0) + saves = metrics.get("saves", 0) + metrics.get("bookmarks", 0) + + raw_score = likes + (comments * 2) + (shares * 3) + (saves * 2) + + # Normalización simple (ajustar según datos reales) + # Asume que un post "promedio" tiene ~20 puntos raw + self.engagement_score = min(100, (raw_score / 50) * 100) + + self.updated_at = datetime.utcnow() diff --git a/app/models/post.py b/app/models/post.py index b3e6613..2dc011c 100644 --- a/app/models/post.py +++ b/app/models/post.py @@ -102,6 +102,11 @@ class Post(Base): recycled_from_id = Column(Integer, ForeignKey("posts.id"), nullable=True) recycle_count = Column(Integer, default=0) # Times this post has been recycled + # AI Generation Quality + quality_score = Column(Integer, nullable=True, index=True) # 0-100 score from validator + score_breakdown = Column(JSON, nullable=True) # Detailed scoring breakdown + generation_attempts = Column(Integer, default=1) # Times regenerated before acceptance + # Timestamps created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) @@ -136,7 +141,10 @@ class Post(Base): "ab_test_id": self.ab_test_id, "is_recyclable": self.is_recyclable, "recycled_from_id": self.recycled_from_id, - "recycle_count": self.recycle_count + "recycle_count": self.recycle_count, + "quality_score": self.quality_score, + "score_breakdown": self.score_breakdown, + "generation_attempts": self.generation_attempts } def get_content_for_platform(self, platform: str) -> str: diff --git a/app/prompts/examples/best_posts.yaml b/app/prompts/examples/best_posts.yaml new file mode 100644 index 0000000..36ad2fa --- /dev/null +++ b/app/prompts/examples/best_posts.yaml @@ -0,0 +1,155 @@ +# Best Posts - Ejemplos de alto rendimiento para few-shot learning +# Este archivo se actualiza automáticamente con posts que superan el umbral de engagement + +metadata: + description: "Colección de posts con mejor rendimiento para usar como few-shot examples" + auto_update: true + top_percentile: 20 # Top 20% de engagement + min_engagement_score: 50 + last_updated: null # Se actualiza automáticamente + +# Ejemplos manuales iniciales (reemplazar con posts reales de la marca) +examples: + + tip_tech: + - content: | + ¿Pasas horas buscando archivos en tu compu? + + Tip: Usa "Everything" (Windows) o "Alfred" (Mac). + + Búsqueda instantánea de cualquier archivo en milisegundos. + + Yo recuperé 30 min diarios con esto. + + #Productividad #Tech + platform: x + engagement_score: 85 + metrics: + likes: 45 + retweets: 12 + comments: 8 + analysis: + hook_type: pregunta_dolor + strength: dato_específico_personal + + - content: | + El 73% de developers usan ChatGPT para debugging. + + Pero el 90% lo usa mal. + + El truco: no le des solo el error. + Dale contexto: + → Qué intentas hacer + → Qué esperabas + → Qué obtuviste + + La diferencia es brutal. + + #IA #Programming + platform: x + engagement_score: 92 + metrics: + likes: 78 + retweets: 34 + comments: 15 + analysis: + hook_type: dato_impactante + strength: framework_accionable + + product_post: + - content: | + 💻 Para los que editan video y están hartos del lag: + + MacBook Pro M3 Pro disponible. + + → Renderiza 4K en tiempo real + → 18h de batería editando + → Pantalla que hace justicia a tu trabajo + + Inversión desde $45,999 MXN. + + ¿Te interesa? Más detalles por DM. + + #Apple #VideoEditing + platform: x + engagement_score: 78 + metrics: + likes: 34 + retweets: 8 + comments: 12 + analysis: + hook_type: dolor_específico + strength: beneficios_no_specs + + service_post: + - content: | + "Pasamos 20 horas/semana en reportes manuales" + + Eso nos dijo un cliente hace 3 meses. + + Hoy su equipo dedica esas horas a estrategia. + + ¿Cómo? Automatizamos: + → Extracción de datos + → Generación de reportes + → Envío programado + + Si tu equipo pierde tiempo en tareas repetitivas, hablemos. + + #Automatización #Productividad + platform: threads + engagement_score: 81 + metrics: + likes: 56 + retweets: 15 + comments: 9 + analysis: + hook_type: caso_cliente + strength: resultado_medible + + thread: + - posts: + - "🧵 5 herramientas de IA que uso todos los días (y son gratis):" + - "1/ Claude para escribir y pensar. Mejor que ChatGPT para textos largos y razonamiento complejo. Mi favorito para brainstorming." + - "2/ Perplexity para investigar. Google + IA. Te da respuestas con fuentes. Ya no uso Google para research." + - "3/ Gamma para presentaciones. Describes tu idea y genera slides. 30 min de trabajo en 5." + - "4/ ElevenLabs para audio. Convierte texto a voz natural. Perfecto para demos y videos." + - "5/ Canva con IA para diseño. Magic Edit y texto a imagen. No necesitas Photoshop para el 90% de cosas. ¿Cuál agregarías? #IA #Herramientas" + platform: x + engagement_score: 95 + metrics: + total_likes: 234 + retweets: 89 + comments: 45 + analysis: + hook_type: lista_útil + strength: herramientas_específicas_opinión_personal + +# Patrones identificados de posts exitosos +success_patterns: + hooks: + - type: dato_específico + description: "Números concretos generan credibilidad" + example: "El 73% de developers..." + - type: dolor_relatable + description: "Problemas que la audiencia reconoce" + example: "¿Pasas horas buscando archivos...?" + - type: resultado_medible + description: "Transformaciones cuantificables" + example: "De 20 horas a 0 en reportes manuales" + + content: + - pattern: personal_experience + description: "Incluir experiencia personal aumenta engagement" + - pattern: actionable_value + description: "Contenido que se puede aplicar inmediatamente" + - pattern: specific_over_generic + description: "Herramientas específicas > consejos generales" + + structure: + - pattern: short_sentences + description: "Oraciones cortas, una idea por línea" + - pattern: visual_hierarchy + description: "Uso de → y bullets para escaneo" + - pattern: strong_close + description: "Terminar con pregunta o CTA claro" diff --git a/app/prompts/examples/by_category/ia.yaml b/app/prompts/examples/by_category/ia.yaml new file mode 100644 index 0000000..a29e240 --- /dev/null +++ b/app/prompts/examples/by_category/ia.yaml @@ -0,0 +1,94 @@ +# Ejemplos de posts sobre Inteligencia Artificial + +category: ia +display_name: "Inteligencia Artificial" +description: "Posts sobre IA, machine learning, ChatGPT, automatización inteligente" + +keywords: + - inteligencia artificial + - ia + - machine learning + - chatgpt + - claude + - deepseek + - prompts + - llm + - modelos de lenguaje + +examples: + + tips: + - content: | + ChatGPT responde mejor cuando le das un rol. + + En lugar de: "Escribe un email" + + Prueba: "Eres un copywriter senior. Escribe un email que..." + + La diferencia en calidad es notable. + + #IA #ChatGPT + quality_score: 88 + why_it_works: "Tip específico y accionable con ejemplo claro" + + - content: | + El prompt perfecto tiene 4 partes: + + 1. Contexto (quién eres) + 2. Tarea (qué quieres) + 3. Formato (cómo lo quieres) + 4. Restricciones (qué evitar) + + Ejemplo en 🧵 + + #PromptEngineering + quality_score: 85 + why_it_works: "Framework memorable y fácil de aplicar" + + educational: + - content: | + ¿Cuándo usar ChatGPT vs Claude vs Gemini? + + ChatGPT: Conversaciones, código, uso general + Claude: Textos largos, análisis, razonamiento + Gemini: Búsqueda, datos actuales, Google integrado + + No hay "mejor", hay "mejor para X". + + #IA + quality_score: 82 + why_it_works: "Comparación útil sin sesgo, ayuda a decidir" + + misconceptions: + - content: | + "La IA va a reemplazar mi trabajo" + + Realidad: La IA amplifica, no reemplaza. + + Los que dominan IA + su expertise harán en 1 hora lo que otros en 8. + + El skill a desarrollar no es "usar IA", es "pensar con IA". + + #IA #Futuro + quality_score: 90 + why_it_works: "Aborda miedo común con perspectiva práctica" + +hooks_that_work: + - "La mayoría usa [herramienta IA] mal..." + - "El prompt que cambió mi flujo de trabajo:" + - "ChatGPT no puede hacer esto (pero Claude sí):" + - "Dejé de usar [herramienta] después de descubrir..." + +topics_to_cover: + - Comparativas de herramientas + - Prompts específicos para casos de uso + - Limitaciones y cómo superarlas + - Integración de IA en flujos existentes + - Ética y uso responsable + - Novedades relevantes (sin ser news ticker) + +avoid: + - Hype sin sustancia ("La IA lo cambia TODO") + - Predicciones apocalípticas + - Comparativas sin valor ("X es mejor que Y" sin contexto) + - Contenido que se desactualiza rápido diff --git a/app/prompts/examples/by_category/productividad.yaml b/app/prompts/examples/by_category/productividad.yaml new file mode 100644 index 0000000..cdd8373 --- /dev/null +++ b/app/prompts/examples/by_category/productividad.yaml @@ -0,0 +1,113 @@ +# Ejemplos de posts sobre Productividad + +category: productividad +display_name: "Productividad" +description: "Posts sobre gestión del tiempo, herramientas, workflows, hábitos" + +keywords: + - productividad + - gestión del tiempo + - herramientas + - workflow + - hábitos + - eficiencia + - organización + - automatización + +examples: + + tips: + - content: | + La regla de los 2 minutos: + + Si algo toma menos de 2 minutos, hazlo ahora. + + Suena simple pero elimina el 50% de tu lista de pendientes. + + El truco: ser honesto con lo que realmente toma 2 min. + + #Productividad + quality_score: 87 + why_it_works: "Regla simple con beneficio claro y matiz útil" + + - content: | + Mi stack de productividad: + + → Notion: Todo mi cerebro externo + → Todoist: Tareas diarias + → Cal.com: Agenda sin emails + → Raycast: Launcher + snippets + + Cero apps más. Simplicidad > features. + + #Productividad #Tools + quality_score: 84 + why_it_works: "Recomendaciones específicas con filosofía clara" + + - content: | + Tip que me tomó años aprender: + + No planees tu día por tareas. + Planéalo por bloques de energía. + + Alta energía → Trabajo creativo/difícil + Media energía → Reuniones/colaboración + Baja energía → Admin/emails + + Game changer. + + #Productividad + quality_score: 91 + why_it_works: "Insight contraintuitivo con framework aplicable" + + frameworks: + - content: | + Cómo digo "no" sin decir no: + + "Me encantaría, pero mi calendario no me lo permite ahora" + + "No soy la persona indicada, pero [nombre] podría ayudarte" + + "Podría en [fecha futura], ¿funciona?" + + Proteger tu tiempo no te hace mala persona. + + #Productividad + quality_score: 86 + why_it_works: "Scripts copiables para situación incómoda común" + + myths: + - content: | + "Trabaja más duro" es mal consejo. + + Trabajar duro en lo incorrecto = desperdicio. + + Mejor: trabaja en lo correcto. + + 1 hora en la tarea que mueve la aguja > 8 horas en ocupación disfrazada. + + #Productividad + quality_score: 89 + why_it_works: "Desafía sabiduría convencional con lógica clara" + +hooks_that_work: + - "El tip que me tomó años aprender:" + - "Dejé de hacer [cosa común] y..." + - "La diferencia entre ocupado y productivo:" + - "Lo que nadie te dice sobre productividad:" + - "Mi rutina de [mañana/semana] en 5 pasos:" + +topics_to_cover: + - Herramientas específicas con opinión + - Frameworks de gestión del tiempo + - Hábitos y rutinas que funcionan + - Errores comunes de productividad + - Balance trabajo-vida + - Automatización de tareas repetitivas + +avoid: + - "Levántate a las 5am" sin contexto + - Hustle culture tóxica + - Productividad como fin en sí misma + - Consejos que solo funcionan para privilegiados + - Listas genéricas sin experiencia personal diff --git a/app/prompts/examples/by_category/seguridad.yaml b/app/prompts/examples/by_category/seguridad.yaml new file mode 100644 index 0000000..96f343d --- /dev/null +++ b/app/prompts/examples/by_category/seguridad.yaml @@ -0,0 +1,117 @@ +# Ejemplos de posts sobre Seguridad Digital + +category: seguridad +display_name: "Seguridad Digital" +description: "Posts sobre ciberseguridad, privacidad, passwords, protección de datos" + +keywords: + - seguridad + - ciberseguridad + - passwords + - privacidad + - phishing + - malware + - protección + - 2fa + - autenticación + +examples: + + tips: + - content: | + Tu contraseña más débil no es "123456". + + Es la respuesta a "¿Cuál es el nombre de tu mascota?" + + Esa info está en tu Instagram. + + Tip: usa respuestas falsas que solo tú conozcas. + + #Seguridad #CyberSecurity + quality_score: 92 + why_it_works: "Insight no obvio con solución simple" + + - content: | + 3 señales de email phishing que el 90% ignora: + + 1. Urgencia artificial ("actúa en 24h") + 2. Errores sutiles en el dominio (@paypa1.com) + 3. "Haz clic aquí" sin preview del link + + Cuando dudes: ve directo al sitio oficial. + + #Seguridad + quality_score: 88 + why_it_works: "Checklist específico y accionable" + + - content: | + La mejor inversión en seguridad: un password manager. + + → Bitwarden (gratis, código abierto) + → 1Password (mejor UX, $36/año) + + Una contraseña maestra fuerte > 100 contraseñas mediocres. + + Si solo haces una cosa de seguridad, que sea esta. + + #Seguridad #Passwords + quality_score: 85 + why_it_works: "Recomendación específica con opciones claras" + + awareness: + - content: | + WiFi gratis del café = WiFi que puede ver todo tu tráfico. + + Opciones: + + 1. Usa VPN siempre + 2. Solo visita sitios HTTPS + 3. Nunca hagas operaciones bancarias + + O mejor: usa tu hotspot móvil. + + #Seguridad #WiFi + quality_score: 84 + why_it_works: "Situación común con soluciones prácticas" + + - content: | + "No tengo nada que esconder" es mal argumento. + + Tienes: + → Credenciales bancarias + → Fotos personales + → Conversaciones privadas + → Tu identidad digital + + La privacidad no es para criminales. Es para todos. + + #Privacidad #Seguridad + quality_score: 90 + why_it_works: "Contraargumento a objeción común" + +hooks_that_work: + - "El error de seguridad que todos cometen:" + - "Esto es lo que un hacker ve cuando..." + - "La función de seguridad que deberías activar HOY:" + - "Por qué [práctica común] es peligroso:" + - "3 minutos que pueden salvarte horas de problemas:" + +topics_to_cover: + - Password managers y 2FA + - Reconocer phishing y scams + - Privacidad en redes sociales + - Seguridad en WiFi público + - Backups y recuperación + - Actualizaciones de software + +tone_guidelines: + - Informar sin causar paranoia + - Soluciones prácticas, no solo problemas + - Evitar jerga técnica innecesaria + - Urgencia apropiada sin alarmismo + +avoid: + - Asustar sin dar soluciones + - Jerga que solo entienden expertos + - Promesas de "seguridad 100%" + - Contenido que se desactualiza (vulnerabilidades específicas) diff --git a/app/prompts/personalities/default.yaml b/app/prompts/personalities/default.yaml new file mode 100644 index 0000000..82d91d7 --- /dev/null +++ b/app/prompts/personalities/default.yaml @@ -0,0 +1,72 @@ +name: default +description: Personalidad principal de Consultoría AS - profesional pero cercana + +identity: + brand_name: "Consultoría AS" + location: "Tijuana, México" + industry: "Tecnología, IA y Automatización" + website: "consultoriaas.com" + +voice: + tone: professional_friendly + formality: medium # 1=muy casual, 5=muy formal -> 3 + humor: subtle # Humor sutil cuando es apropiado + expertise_level: expert_accessible # Experto pero accesible + +personality_traits: + - helpful # Siempre busca ayudar + - knowledgeable # Demuestra conocimiento sin presumir + - approachable # Cercano, no intimidante + - practical # Enfocado en soluciones reales + - honest # Transparente, sin exageraciones + +communication_style: + sentence_length: short_to_medium # Oraciones concisas + paragraph_style: scannable # Fácil de escanear + emoji_usage: moderate # Emojis con propósito + hashtag_style: relevant_only # Solo hashtags útiles + +inspirations: + - "@midudev" # Tips técnicos accesibles + - "@MoureDev" # Contenido educativo de valor + - "@SoyDalto" # Explicaciones claras + +rules: + always: + - Enfócate en el valor para el lector + - Sé específico, evita generalidades + - Incluye algo accionable cuando sea posible + - Mantén consistencia con posts anteriores + never: + - Usar lenguaje ofensivo o controversial + - Hacer promesas exageradas o falsas + - Ser condescendiente o arrogante + - Vender agresivamente (soft-sell siempre) + - Copiar contenido de otros sin crédito + +system_prompt: | + Eres el Community Manager de {brand_name}, una empresa de tecnología ubicada en {location}. + + SOBRE LA EMPRESA: + - Especializada en soluciones de IA, automatización y transformación digital + - Vende equipos de cómputo e impresoras 3D + - Sitio web: {website} + + TU PERSONALIDAD: + - Eres un experto en tecnología que explica conceptos de forma accesible + - Tu tono es profesional pero cercano, como un colega que sabe mucho + - Usas humor sutil cuando es apropiado, pero nunca forzado + - Te inspiras en creadores como @midudev, @MoureDev y @SoyDalto + + ESTILO DE COMUNICACIÓN: + - Tips cortos y accionables + - Contenido educativo que aporta valor real + - Emojis con propósito (no decorativos) + - Hashtags relevantes y limitados (máximo 3-5) + + REGLAS INQUEBRANTABLES: + - Nunca uses lenguaje ofensivo + - No hagas promesas exageradas + - Sé honesto y transparente + - Enfócate en ayudar, no en vender directamente + - Cada post debe aportar valor al lector diff --git a/app/prompts/personalities/educational.yaml b/app/prompts/personalities/educational.yaml new file mode 100644 index 0000000..6ea2cd0 --- /dev/null +++ b/app/prompts/personalities/educational.yaml @@ -0,0 +1,37 @@ +name: educational +description: Personalidad didáctica para hilos y contenido educativo extenso + +extends: default # Hereda de default y sobrescribe + +voice: + tone: teacher_mentor + formality: medium + expertise_level: expert_teacher + +personality_traits: + - patient # Explica sin prisa + - thorough # Cubre el tema completo + - encouraging # Motiva al aprendizaje + - structured # Organiza la información + +communication_style: + sentence_length: medium + paragraph_style: structured + use_examples: always + use_analogies: when_helpful + +teaching_techniques: + - Empezar con el "por qué" importa + - Usar analogías del mundo real + - Dividir conceptos complejos en pasos + - Incluir ejemplos prácticos + - Terminar con siguiente paso accionable + +system_prompt_addition: | + + MODO EDUCATIVO ACTIVADO: + - Estás creando contenido para enseñar, no para impresionar + - Asume que el lector es inteligente pero nuevo en el tema + - Usa analogías del mundo real para conceptos abstractos + - Estructura: Problema → Concepto → Ejemplo → Acción + - Si algo es complejo, divídelo en partes digeribles diff --git a/app/prompts/personalities/promotional.yaml b/app/prompts/personalities/promotional.yaml new file mode 100644 index 0000000..75d447b --- /dev/null +++ b/app/prompts/personalities/promotional.yaml @@ -0,0 +1,45 @@ +name: promotional +description: Personalidad para contenido de productos y servicios + +extends: default + +voice: + tone: enthusiastic_helpful + formality: medium + sales_approach: soft_consultative + +personality_traits: + - solution_focused # Enfocado en resolver problemas + - value_oriented # Destaca valor, no características + - trustworthy # Genera confianza + - helpful_first # Ayudar primero, vender después + +communication_style: + focus_on: benefits_over_features + cta_style: inviting_not_pushing + social_proof: when_available + urgency: natural_only # Solo si es urgencia real + +sales_principles: + - Liderar con el problema que resuelve + - Mostrar beneficios antes que características + - Usar prueba social cuando exista + - CTA claro pero no agresivo + - Precio como inversión, no costo + +avoid: + - "OFERTA IMPERDIBLE" + - "ÚLTIMAS UNIDADES" # A menos que sea verdad + - Presión artificial + - Comparaciones negativas con competencia + - Promesas que no se pueden cumplir + +system_prompt_addition: | + + MODO PROMOCIONAL ACTIVADO: + - Estás presentando un producto/servicio, pero tu objetivo es AYUDAR + - El lector tiene un problema; tú tienes una solución + - Lidera con el beneficio, no con la característica + - Sé entusiasta pero no exagerado + - El CTA debe ser una invitación, no presión + - Si mencionas precio, enmárcalo como inversión con retorno diff --git a/app/prompts/platforms/facebook.yaml b/app/prompts/platforms/facebook.yaml new file mode 100644 index 0000000..d943139 --- /dev/null +++ b/app/prompts/platforms/facebook.yaml @@ -0,0 +1,83 @@ +platform: facebook +display_name: "Facebook" +description: "Plataforma de comunidad. Contenido más extenso y profesional." + +limits: + max_characters: 63206 # Prácticamente ilimitado + recommended_characters: 400-800 + max_hashtags: 3 # Menos importantes en FB + max_mentions: 50 + max_links: unlimited + media_optional: true + +tone: + style: professional_community + emoji_usage: moderate + formality: medium_to_high + energy: informative + audience: broader_age_range + +formatting: + line_breaks: important + use_bullets: yes + bullet_style: "• ✓ →" + paragraphs: medium # 3-4 oraciones OK + whitespace: moderate + +structure: + hook: attention_in_first_line + body: detailed_valuable_content + cta: clear_next_step + link: if_relevant + hashtags: minimal_at_end + +hooks: + preferred: + - pregunta_comunidad # "¿Qué opinan sobre...?" + - anuncio_valor # "Nueva guía disponible:" + - insight_profesional # "Después de 5 años..." + - contenido_largo_preview # "Escribí sobre..." + avoid: + - demasiado_casual + - solo_emojis + - clickbait_obvio + +cta: + frequency: 0.5 + types: + - comment: "Cuéntanos en comentarios" + - share: "Comparte si te fue útil" + - click: "Más información en el link" + - message: "Escríbenos por Messenger" + - visit: "Visita nuestro sitio" + supports_links: true + +hashtags: + count: 2-3 + importance: low # Menos relevantes en FB + placement: end + style: professional + +best_practices: + - Contenido más largo está bien + - Links clicables son ventaja vs otras plataformas + - Audiencia más amplia en edad y tech-savviness + - Posts informativos/educativos funcionan bien + - Grupos y comunidades son poderosos + - Responder comentarios aumenta alcance + +audience_considerations: + - Rango de edad más amplio que otras plataformas + - No asumir conocimiento técnico avanzado + - Explicar términos cuando sea necesario + - Tono más profesional que casual + +adaptation_rules: | + Cuando adaptes contenido para Facebook: + 1. Puedes expandir y dar más contexto + 2. Incluye links si aportan valor + 3. Tono más profesional que en Threads + 4. Hashtags opcionales y mínimos (2-3 max) + 5. Piensa en audiencia más amplia/diversa + 6. CTAs pueden ser más directos + 7. Formato legible con párrafos cortos diff --git a/app/prompts/platforms/instagram.yaml b/app/prompts/platforms/instagram.yaml new file mode 100644 index 0000000..c58f8f6 --- /dev/null +++ b/app/prompts/platforms/instagram.yaml @@ -0,0 +1,86 @@ +platform: instagram +display_name: "Instagram" +description: "Plataforma visual. El texto complementa la imagen." + +limits: + max_characters: 2200 + max_hashtags: 30 # Pero recomendado 5-10 + max_mentions: 20 + max_links: 0 # Solo en bio/stories + media_required: true + +tone: + style: visual_first_inspirational + emoji_usage: high # Parte de la cultura de IG + formality: low + energy: positive_uplifting + aesthetic: clean_modern + +formatting: + line_breaks: important + use_bullets: common + bullet_style: "✨ • ✅ 💡" # Emojis como bullets + whitespace: generous + paragraphs: very_short # 1-2 oraciones + +structure: + first_line: hook_with_emoji + body: value_with_formatting + cta: engagement_focused + hashtags: in_comment_or_end + +hooks: + preferred: + - emoji_opener # "💡 Tip del día:" + - beneficio_directo # "Duplica tu productividad con..." + - transformación # "De [antes] a [después]" + - lista_preview # "3 formas de..." + avoid: + - texto_puro_largo + - sin_emojis + - demasiado_técnico + +cta: + frequency: 0.6 # 60% de posts + types: + - save: "💾 Guarda este post" + - share: "📤 Comparte con alguien que necesite esto" + - comment: "💬 ¿Cuál es tu favorito?" + - follow: "➡️ Síguenos para más" + - link_in_bio: "🔗 Link en bio" + placement: after_value_before_hashtags + +hashtags: + count: 5-10 + placement: end_or_first_comment + strategy: mix_of_sizes + categories: + large: ["#tecnologia", "#productividad", "#tips"] # 100k+ + medium: ["#techlife", "#productividadpersonal"] # 10k-100k + small: ["#tipstech", "#automatizacion"] # <10k + avoid: + - hashtags_banned + - hashtags_irrelevantes_para_reach + +best_practices: + - El texto complementa la imagen, no al revés + - Primera línea visible en feed = hook crucial + - Formato con espacios y emojis para legibilidad + - CTAs claros aumentan engagement + - Hashtags en primer comentario = más limpio + - Historias para links y contenido efímero + +image_text_relationship: + - La imagen atrae, el texto profundiza + - No repetir en texto lo que dice la imagen + - Texto debe añadir contexto/valor + +adaptation_rules: | + Cuando adaptes contenido para Instagram: + 1. Piensa primero en qué imagen acompañará + 2. Primera línea MUY importante (es lo que se ve) + 3. Usa emojis como estructura visual (💡 ✅ ➡️) + 4. Separa ideas con saltos de línea + 5. CTA explícito (guardar/compartir/comentar) + 6. Hashtags al final o en primer comentario + 7. Más extenso está bien, la gente scrollea diff --git a/app/prompts/platforms/threads.yaml b/app/prompts/platforms/threads.yaml new file mode 100644 index 0000000..e681fce --- /dev/null +++ b/app/prompts/platforms/threads.yaml @@ -0,0 +1,81 @@ +platform: threads +display_name: "Threads" +description: "Plataforma conversacional de Meta. Tono más casual y auténtico." + +limits: + max_characters: 500 + max_hashtags: 5 + max_mentions: 5 + max_links: 1 + media_optional: true + +tone: + style: conversational_authentic + emoji_usage: moderate # 2-4 + formality: low_to_medium + energy: friendly + personal_voice: true # Más "yo" menos "nosotros" + +formatting: + line_breaks: true + use_bullets: optional + bullet_style: "•" + whitespace: natural + paragraphs: short # 2-3 oraciones máximo + +hooks: + preferred: + - pregunta_personal # "¿Alguna vez te ha pasado...?" + - historia_corta # "Ayer me di cuenta..." + - opinión_honesta # "Honestamente, creo que..." + - reflexión # "He estado pensando en..." + avoid: + - demasiado_corporativo + - hooks_de_ventas + - formalidad_excesiva + +cta: + frequency: 0.4 # 40% de posts + types: + - engage: "¿Qué piensan?" + - share: "¿Les ha pasado?" + - follow: "Más contenido así aquí" + - discuss: "Debatamos en comentarios" + style: natural_not_forced + +hashtags: + count: 3-5 + placement: end + style: trending_relevant + recommended: + - "#Tech" + - "#Threads" + - "#Productividad" + - "#IA" + - "#Tips" + +best_practices: + - Tono como si hablaras con un amigo + - Okay ser vulnerable/honesto sobre errores + - Engagement genuino en comentarios + - Contenido que invita a conversación + - Menos "profesional", más "persona real" + +voice_examples: + good: + - "Voy a ser honesto: tardé años en entender esto..." + - "¿Solo a mí me pasa que...?" + - "Unpopular opinion: la productividad está sobrevalorada" + bad: + - "Nuestra empresa ofrece soluciones innovadoras..." + - "Estimados seguidores..." + - "Les compartimos información importante..." + +adaptation_rules: | + Cuando adaptes contenido para Threads: + 1. Hazlo más personal y conversacional + 2. Añade opinión o experiencia propia + 3. Invita a la conversación genuinamente + 4. Puedes ser más extenso que X + 5. Emojis naturales, no decorativos + 6. Está bien mostrar vulnerabilidad diff --git a/app/prompts/platforms/x.yaml b/app/prompts/platforms/x.yaml new file mode 100644 index 0000000..7378e91 --- /dev/null +++ b/app/prompts/platforms/x.yaml @@ -0,0 +1,79 @@ +platform: x +display_name: "X (Twitter)" +description: "Plataforma de microblogging. Contenido conciso y directo." + +limits: + max_characters: 280 + max_hashtags: 2 + max_mentions: 3 + max_links: 1 + media_optional: true + +tone: + style: direct_punchy + emoji_usage: minimal # 1-2 máximo + formality: medium + energy: high + +formatting: + line_breaks: true + use_bullets: true + bullet_style: "→" + numbered_lists: false + whitespace: strategic # Usa espacios para legibilidad + +hooks: + preferred: + - dato_impactante # "El 90% de..." + - pregunta_retórica # "¿Sabías que...?" + - afirmación_bold # "La productividad no es..." + - tip_directo # "Tip: usa..." + avoid: + - historia_larga # No hay espacio + - introducción_suave # Pierde caracteres + - múltiples_ideas # Un concepto solo + +cta: + frequency: 0.3 # 30% de posts + types: + - follow: "Síguenos para más tips" + - save: "Guarda este tip" + - share: "RT si te fue útil" + - engage: "¿Qué opinas?" + placement: end_before_hashtags + +hashtags: + count: 1-2 + placement: end + style: no_spaces # #IA no # IA + avoid: + - hashtags_largos_incomprensibles + - más_de_2_palabras + recommended: + - "#Tech" + - "#IA" + - "#Productividad" + - "#Tips" + - "#Python" + +best_practices: + - Primera línea es crucial (aparece en preview) + - Usa saltos de línea para escaneo rápido + - Un solo mensaje/idea por post + - Threads para contenido largo (no comprimir) + - Los posts con datos específicos performan mejor + +avoid: + - Comprimir contenido largo en 280 chars + - Más de 2 hashtags + - Emojis excesivos + - Links en el medio del texto + - "Hilo:" sin contenido de hilo real + +adaptation_rules: | + Cuando adaptes contenido para X: + 1. Extrae la idea principal únicamente + 2. Usa formato de lista con → si hay múltiples puntos + 3. Hook en primera línea obligatorio + 4. Si no cabe, sugiere crear hilo en su lugar + 5. Hashtags al final, máximo 2 diff --git a/app/prompts/templates/product_post.yaml b/app/prompts/templates/product_post.yaml new file mode 100644 index 0000000..cddce8e --- /dev/null +++ b/app/prompts/templates/product_post.yaml @@ -0,0 +1,82 @@ +name: product_post +description: Posts promocionales para productos +personality: promotional + +purpose: | + Presentar productos de forma atractiva enfocándose en cómo + resuelven problemas reales del cliente. + +requirements: + - Liderar con el problema que resuelve + - Destacar 2-3 beneficios clave + - Incluir precio como inversión + - CTA claro pero no agresivo + - NO inventar especificaciones + +structure: + hook: problem_or_benefit_hook + benefits: 2_3_key_benefits + specs: relevant_specs_only + price: price_as_investment + cta: soft_call_to_action + hashtags: 2_3_relevant + +variables: + - name: product_name + type: string + required: true + - name: product_description + type: string + required: true + - name: price + type: number + required: true + - name: category + type: string + required: true + - name: specs + type: object + required: false + - name: highlights + type: array + required: false + +parameters: + temperature: 0.7 + max_tokens: 400 + +template: | + Genera un post promocional para este producto: + + PRODUCTO: {product_name} + DESCRIPCIÓN: {product_description} + PRECIO: ${price:,.2f} MXN + CATEGORÍA: {category} + ESPECIFICACIONES: {specs} + PUNTOS DESTACADOS: {highlights} + + ESTRUCTURA: + 1. HOOK: Problema que resuelve O beneficio principal + 2. BENEFICIOS: 2-3 beneficios clave (no características) + 3. PRECIO: Presentado como inversión + 4. CTA: Invitación a saber más (no presión) + 5. HASHTAGS: 2-3 relevantes + + REGLAS: + - Beneficios > Características (no "8GB RAM", sino "edita video sin lag") + - El precio es una inversión, no un gasto + - CTA suave: "Más info en DM", "Conoce más", etc. + - NO inventes especificaciones que no están en los datos + - Usa emojis con propósito (máximo 3-4) + + Responde SOLO con el texto del post. + +tone_guidelines: + do: + - "Edita videos 4K sin esperar renderizados eternos" + - "Inversión que se paga sola en productividad" + - "¿Te interesa? Escríbenos para más detalles" + dont: + - "¡¡¡OFERTA INCREÍBLE!!!" + - "ÚLTIMAS UNIDADES" (a menos que sea verdad) + - "Compra ahora antes de que se acabe" diff --git a/app/prompts/templates/response.yaml b/app/prompts/templates/response.yaml new file mode 100644 index 0000000..6f66715 --- /dev/null +++ b/app/prompts/templates/response.yaml @@ -0,0 +1,97 @@ +name: response +description: Respuestas a interacciones de usuarios +personality: default + +purpose: | + Generar respuestas apropiadas a comentarios, menciones y mensajes, + manteniendo la voz de marca y fomentando la relación con el usuario. + +requirements: + - Responder al contexto específico del usuario + - Mantener tono de marca + - Fomentar continuación de conversación cuando apropiado + - Ser útil sin ser condescendiente + +response_types: + question: + priority: high + goal: answer_helpfully + follow_up: offer_more_help + compliment: + priority: medium + goal: thank_genuinely + follow_up: invite_engagement + complaint: + priority: critical + goal: acknowledge_and_solve + follow_up: take_to_dm_if_complex + mention: + priority: medium + goal: engage_positively + follow_up: depends_on_context + +variables: + - name: interaction_content + type: string + required: true + - name: interaction_type + type: string + required: true + options: ["comment", "mention", "reply", "dm"] + - name: sentiment + type: string + required: false + options: ["positive", "neutral", "negative", "question"] + - name: context + type: string + required: false + description: "Contexto adicional (post original, historial, etc.)" + +parameters: + temperature: 0.8 + max_tokens: 500 + +template: | + Un usuario escribió esto en redes sociales: + + "{interaction_content}" + + TIPO DE INTERACCIÓN: {interaction_type} + SENTIMIENTO DETECTADO: {sentiment} + CONTEXTO: {context} + + Genera 3 opciones de respuesta diferentes: + + 1. RESPUESTA CORTA: Directa y amigable (máx 100 caracteres) + 2. RESPUESTA CONVERSACIONAL: Invita a continuar el diálogo (máx 200 caracteres) + 3. RESPUESTA CON CTA: Dirige a más info o contacto (máx 200 caracteres) + + REGLAS SEGÚN TIPO: + + Si es PREGUNTA: + - Responde de forma útil y específica + - Si no sabes algo, admítelo honestamente + - Ofrece investigar más si es necesario + + Si es QUEJA: + - Empatiza primero, no te defiendas + - Ofrece solución o escalación + - Sugiere continuar por DM si es complejo + + Si es CUMPLIDO: + - Agradece genuinamente (no genérico) + - Comparte crédito si aplica + - Invita a más engagement + + Si es MENCIÓN: + - Reconoce la mención + - Aporta valor si es posible + - Sé natural, no forzado + + TONO: + - Humano, no robótico + - Amigable pero profesional + - Útil sin ser condescendiente + - Nunca defensivo o pasivo-agresivo + + Responde con las 3 opciones numeradas, una por línea. diff --git a/app/prompts/templates/service_post.yaml b/app/prompts/templates/service_post.yaml new file mode 100644 index 0000000..e1ed77c --- /dev/null +++ b/app/prompts/templates/service_post.yaml @@ -0,0 +1,87 @@ +name: service_post +description: Posts promocionales para servicios +personality: promotional + +purpose: | + Presentar servicios enfocándose en el problema que resuelven + y los resultados que el cliente puede esperar. + +requirements: + - Enfocarse en el problema/dolor del cliente + - Mostrar resultados, no procesos + - Incluir prueba social si está disponible + - CTA consultivo (no de venta directa) + +structure: + hook: pain_point_or_result + problem: what_client_struggles_with + solution: how_service_helps + results: expected_outcomes + cta: consultative_invitation + hashtags: 2_3_relevant + +variables: + - name: service_name + type: string + required: true + - name: service_description + type: string + required: true + - name: category + type: string + required: true + - name: target_sectors + type: array + required: false + - name: benefits + type: array + required: false + - name: call_to_action + type: string + required: false + default: "Contáctanos para una consulta sin compromiso" + +parameters: + temperature: 0.7 + max_tokens: 400 + +template: | + Genera un post promocional para este servicio: + + SERVICIO: {service_name} + DESCRIPCIÓN: {service_description} + CATEGORÍA: {category} + SECTORES OBJETIVO: {target_sectors} + BENEFICIOS: {benefits} + CTA DESEADO: {call_to_action} + + ESTRUCTURA: + 1. HOOK: Dolor del cliente O resultado transformador + 2. PROBLEMA: Lo que el cliente enfrenta (empatía) + 3. SOLUCIÓN: Cómo el servicio ayuda (sin tecnicismos) + 4. RESULTADOS: Qué puede esperar el cliente + 5. CTA: Invitación consultiva + 6. HASHTAGS: 2-3 relevantes + + TONO: + - Consultivo, no vendedor + - Empático con el problema del cliente + - Confiado pero no arrogante + - Enfocado en resultados medibles + + REGLAS: + - Usa "tú" no "usted" (cercano) + - Evita jerga técnica innecesaria + - Si mencionas resultados, que sean realistas + - CTA debe ser bajo compromiso + + Responde SOLO con el texto del post. + +examples: + hooks: + good: + - "¿Tu equipo pierde 10+ horas/semana en tareas repetitivas?" + - "Empresas que automatizan reducen errores un 80%" + bad: + - "Ofrecemos servicios de automatización" + - "Somos expertos en IA" diff --git a/app/prompts/templates/thread.yaml b/app/prompts/templates/thread.yaml new file mode 100644 index 0000000..951b26c --- /dev/null +++ b/app/prompts/templates/thread.yaml @@ -0,0 +1,84 @@ +name: thread +description: Hilos educativos de múltiples posts +personality: educational + +purpose: | + Crear hilos educativos que expliquen un tema en profundidad, + manteniendo engagement a lo largo de todos los posts. + +requirements: + - Cada post debe poder funcionar solo pero mejor en conjunto + - Progresión lógica de información + - Hooks internos para mantener lectura + - Valor concreto en cada post + +structure: + post_1: hook_and_promise + posts_middle: educational_content + post_final: conclusion_and_cta + +variables: + - name: topic + type: string + required: true + - name: num_posts + type: integer + required: false + default: 5 + min: 3 + max: 10 + - name: depth + type: string + required: false + default: "intermedio" + options: ["básico", "intermedio", "avanzado"] + +parameters: + temperature: 0.7 + max_tokens: 1500 + +template: | + Genera un hilo educativo de {num_posts} posts sobre: {topic} + + NIVEL DE PROFUNDIDAD: {depth} + + ESTRUCTURA DEL HILO: + + POST 1 (HOOK): + - Captura atención con dato sorprendente, pregunta provocadora, o promesa de valor + - Indica que es un hilo: "🧵 Hilo:" + - Anticipa lo que van a aprender + + POSTS 2 a {num_posts-1} (CONTENIDO): + - Cada post = 1 concepto/punto + - Empieza cada post con conexión al anterior + - Incluye ejemplo práctico cuando sea posible + - Usa formato escaneable (bullets, numeración) + + POST {num_posts} (CIERRE): + - Resume los puntos clave + - Da un paso accionable + - CTA de engagement (guardar, compartir, seguir) + - Hashtags relevantes + + REGLAS POR POST: + - Máximo 280 caracteres cada uno + - Numera cada post (1/, 2/, etc.) + - Emojis con propósito (1-2 por post) + - El último post lleva los hashtags + + TÉCNICAS DE ENGAGEMENT: + - "Pero aquí viene lo interesante..." (transiciones) + - Preguntas retóricas entre posts + - "La mayoría no sabe esto..." (curiosidad) + - Ejemplos concretos y relatable + + FORMATO DE RESPUESTA: + Responde con cada post separado por una línea vacía. + No incluyas explicaciones, solo los posts. + +example_structure: + post_1: "🧵 Hilo: 5 errores de productividad que cometes sin darte cuenta (y cómo evitarlos)" + post_2: "1/ El primero: revisar email como primera tarea del día..." + post_3: "2/ El segundo error es más sutil..." + post_n: "5/ Para resumir: [puntos clave]. ¿Cuál vas a cambiar primero? #Productividad" diff --git a/app/prompts/templates/tip_tech.yaml b/app/prompts/templates/tip_tech.yaml new file mode 100644 index 0000000..4b7c97a --- /dev/null +++ b/app/prompts/templates/tip_tech.yaml @@ -0,0 +1,85 @@ +name: tip_tech +description: Tips de tecnología cortos y accionables +personality: default + +purpose: | + Generar tips prácticos que el lector pueda aplicar inmediatamente. + El valor está en la accionabilidad, no en la teoría. + +requirements: + - Accionable en menos de 5 minutos + - Un solo concepto por tip + - Incluye el "por qué" importa + - Específico, no genérico + +structure: + hook: attention_grabbing_first_line + body: the_tip_with_context + why: why_it_matters + close: hashtags_only + +variables: + - name: category + type: string + required: true + examples: ["productividad", "ia", "seguridad", "python", "automatización"] + - name: difficulty_level + type: string + required: false + default: "principiante" + options: ["principiante", "intermedio", "avanzado"] + - name: target_audience + type: string + required: false + default: "profesionales tech" + +parameters: + temperature: 0.8 + max_tokens: 300 + +template: | + Genera un tip de tecnología sobre: {category} + + NIVEL DE DIFICULTAD: {difficulty_level} + AUDIENCIA: {target_audience} + + ESTRUCTURA REQUERIDA: + 1. HOOK: Primera línea que capture atención (pregunta, dato sorprendente, o afirmación bold) + 2. TIP: El consejo concreto y específico + 3. POR QUÉ: Una línea explicando el beneficio + 4. HASHTAGS: 2-3 hashtags relevantes + + CRITERIOS DE CALIDAD: + - El lector debe poder aplicarlo HOY + - Debe ser específico (no "usa IA para ser más productivo", sino "usa ChatGPT para resumir emails largos") + - El hook debe generar curiosidad o resonar con un dolor común + + EVITAR: + - Tips genéricos que todos conocen + - Consejos que requieran comprar algo + - Jerga técnica sin explicación + + Responde SOLO con el texto del post, sin explicaciones ni meta-comentarios. + +examples: + good: + - | + ¿Pasas horas en reuniones improductivas? + + La regla 2-2-2: máximo 2 temas, 2 decisiones, 2 acciones. + + Mis reuniones ahora duran la mitad. + + #Productividad #Tips + - | + El 90% ignora esto en Python: + + Usa enumerate() en lugar de range(len()). + + Código más limpio y 0 errores de índice. + + #Python #Programming + bad: + - "Usa IA para ser más productivo" # Muy genérico + - "Compra una segunda pantalla para trabajar mejor" # Requiere compra + - "El machine learning es útil" # No es accionable diff --git a/app/services/ai/__init__.py b/app/services/ai/__init__.py new file mode 100644 index 0000000..7e89e72 --- /dev/null +++ b/app/services/ai/__init__.py @@ -0,0 +1,24 @@ +""" +AI Services - Content Generation Engine v2.0 + +Este módulo contiene los componentes del motor de generación de contenido: +- PromptLibrary: Carga y renderiza prompts desde YAML +- ContextEngine: Anti-repetición y selección de best performers +- ContentGeneratorV2: Interfaz mejorada con DeepSeek +- PlatformAdapter: Adapta contenido por plataforma +- ContentValidator: Validación y scoring con IA +""" + +from app.services.ai.prompt_library import PromptLibrary +from app.services.ai.context_engine import ContextEngine +from app.services.ai.generator import ContentGeneratorV2 +from app.services.ai.platform_adapter import PlatformAdapter +from app.services.ai.validator import ContentValidator + +__all__ = [ + "PromptLibrary", + "ContextEngine", + "ContentGeneratorV2", + "PlatformAdapter", + "ContentValidator", +] diff --git a/app/services/ai/context_engine.py b/app/services/ai/context_engine.py new file mode 100644 index 0000000..14b20c9 --- /dev/null +++ b/app/services/ai/context_engine.py @@ -0,0 +1,519 @@ +""" +ContextEngine - Motor de contexto para generación inteligente. + +Este módulo maneja: +- Anti-repetición de temas y frases +- Selección de best performers para few-shot learning +- Ventana de memoria de posts recientes +- Análisis semántico de contenido +""" + +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Any, Tuple +from sqlalchemy import func, desc +from sqlalchemy.orm import Session + +from app.models.content_memory import ContentMemory +from app.models.post import Post + + +class ContextEngine: + """ + Motor de contexto para generación de contenido. + + Responsabilidades: + 1. Rastrear contenido generado para evitar repeticiones + 2. Identificar y usar posts exitosos como ejemplos + 3. Sugerir variaciones de hooks y estilos + """ + + def __init__( + self, + memory_window: int = 50, + topic_cooldown_days: int = 7, + phrase_cooldown_days: int = 14, + top_percentile: int = 20 + ): + """ + Inicializar el Context Engine. + + Args: + memory_window: Cantidad de posts recientes a recordar + topic_cooldown_days: Días antes de repetir un tema + phrase_cooldown_days: Días antes de repetir una frase distintiva + top_percentile: Percentil para considerar "top performer" + """ + self.memory_window = memory_window + self.topic_cooldown_days = topic_cooldown_days + self.phrase_cooldown_days = phrase_cooldown_days + self.top_percentile = top_percentile + + # === Anti-Repetición === + + def get_recent_topics( + self, + db: Session, + days: Optional[int] = None, + limit: int = 100 + ) -> List[str]: + """ + Obtener temas usados recientemente. + + Args: + db: Sesión de base de datos + days: Días hacia atrás (default: topic_cooldown_days) + limit: Máximo de registros a consultar + + Returns: + Lista de temas usados recientemente + """ + if days is None: + days = self.topic_cooldown_days + + since = datetime.utcnow() - timedelta(days=days) + + memories = db.query(ContentMemory).filter( + ContentMemory.created_at >= since, + ContentMemory.topics.isnot(None) + ).order_by(desc(ContentMemory.created_at)).limit(limit).all() + + # Flatten y contar frecuencia + all_topics = [] + for mem in memories: + if mem.topics: + all_topics.extend(mem.topics) + + return list(set(all_topics)) + + def get_recent_phrases( + self, + db: Session, + days: Optional[int] = None, + limit: int = 100 + ) -> List[str]: + """ + Obtener frases distintivas usadas recientemente. + + Args: + db: Sesión de base de datos + days: Días hacia atrás (default: phrase_cooldown_days) + limit: Máximo de registros a consultar + + Returns: + Lista de frases usadas recientemente + """ + if days is None: + days = self.phrase_cooldown_days + + since = datetime.utcnow() - timedelta(days=days) + + memories = db.query(ContentMemory).filter( + ContentMemory.created_at >= since, + ContentMemory.key_phrases.isnot(None) + ).order_by(desc(ContentMemory.created_at)).limit(limit).all() + + all_phrases = [] + for mem in memories: + if mem.key_phrases: + all_phrases.extend(mem.key_phrases) + + return list(set(all_phrases)) + + def get_recent_hooks( + self, + db: Session, + days: int = 14, + limit: int = 50 + ) -> Dict[str, int]: + """ + Obtener tipos de hooks usados recientemente con frecuencia. + + Args: + db: Sesión de base de datos + days: Días hacia atrás + limit: Máximo de registros + + Returns: + Dict de hook_type -> count + """ + since = datetime.utcnow() - timedelta(days=days) + + memories = db.query(ContentMemory).filter( + ContentMemory.created_at >= since, + ContentMemory.hook_type.isnot(None) + ).order_by(desc(ContentMemory.created_at)).limit(limit).all() + + hook_counts: Dict[str, int] = {} + for mem in memories: + hook_counts[mem.hook_type] = hook_counts.get(mem.hook_type, 0) + 1 + + return hook_counts + + def suggest_hook_type( + self, + db: Session, + preferred_hooks: Optional[List[str]] = None + ) -> str: + """ + Sugerir un tipo de hook basado en lo menos usado recientemente. + + Args: + db: Sesión de base de datos + preferred_hooks: Lista de hooks preferidos para esta plataforma + + Returns: + Tipo de hook sugerido + """ + recent_hooks = self.get_recent_hooks(db) + + if not preferred_hooks: + preferred_hooks = [ + "pregunta_retórica", + "dato_impactante", + "tip_directo", + "afirmación_bold", + "historia_corta" + ] + + # Encontrar el hook menos usado + min_count = float("inf") + suggested = preferred_hooks[0] + + for hook in preferred_hooks: + count = recent_hooks.get(hook, 0) + if count < min_count: + min_count = count + suggested = hook + + return suggested + + def build_exclusion_context( + self, + db: Session, + content_type: str, + category: Optional[str] = None + ) -> str: + """ + Construir contexto de exclusión para el prompt. + + Args: + db: Sesión de base de datos + content_type: Tipo de contenido (tip_tech, product_post, etc.) + category: Categoría específica (ia, productividad, etc.) + + Returns: + String con instrucciones de exclusión para el prompt + """ + recent_topics = self.get_recent_topics(db) + recent_phrases = self.get_recent_phrases(db) + + exclusions = [] + + if recent_topics: + topics_str = ", ".join(recent_topics[:10]) + exclusions.append(f"TEMAS YA CUBIERTOS RECIENTEMENTE (evitar): {topics_str}") + + if recent_phrases: + phrases_str = "; ".join(recent_phrases[:5]) + exclusions.append(f"FRASES YA USADAS (no repetir): {phrases_str}") + + # Sugerir hook menos usado + suggested_hook = self.suggest_hook_type(db) + exclusions.append(f"HOOK SUGERIDO: {suggested_hook} (poco usado recientemente)") + + if exclusions: + return "\n".join(exclusions) + return "" + + # === Best Performers === + + def get_top_performers( + self, + db: Session, + content_type: Optional[str] = None, + platform: Optional[str] = None, + limit: int = 5 + ) -> List[ContentMemory]: + """ + Obtener posts con mejor rendimiento. + + Args: + db: Sesión de base de datos + content_type: Filtrar por tipo de contenido + platform: Filtrar por plataforma + limit: Máximo de resultados + + Returns: + Lista de ContentMemory de top performers + """ + query = db.query(ContentMemory).filter( + ContentMemory.is_top_performer == True, + ContentMemory.engagement_score.isnot(None) + ) + + if content_type: + query = query.filter(ContentMemory.content_type == content_type) + + if platform: + query = query.filter(ContentMemory.platform == platform) + + # Ordenar por score y limitar uso excesivo + return query.order_by( + desc(ContentMemory.engagement_score), + ContentMemory.times_used_as_example # Preferir menos usados + ).limit(limit).all() + + def get_few_shot_examples( + self, + db: Session, + content_type: str, + platform: Optional[str] = None, + min_examples: int = 2, + max_examples: int = 5 + ) -> List[str]: + """ + Obtener ejemplos de contenido real para few-shot prompting. + + Args: + db: Sesión de base de datos + content_type: Tipo de contenido + platform: Plataforma específica + min_examples: Mínimo de ejemplos + max_examples: Máximo de ejemplos + + Returns: + Lista de contenidos de posts exitosos + """ + top_performers = self.get_top_performers( + db, content_type, platform, limit=max_examples + ) + + examples = [] + for mem in top_performers: + # Obtener contenido del post original + post = db.query(Post).filter(Post.id == mem.post_id).first() + if post: + content = post.get_content_for_platform(platform or "x") + examples.append(content) + + # Registrar uso como ejemplo + mem.record_example_usage() + + db.commit() + + return examples[:max_examples] + + def build_few_shot_context( + self, + db: Session, + content_type: str, + platform: Optional[str] = None + ) -> str: + """ + Construir contexto de few-shot para el prompt. + + Args: + db: Sesión de base de datos + content_type: Tipo de contenido + platform: Plataforma + + Returns: + String con ejemplos formateados para el prompt + """ + examples = self.get_few_shot_examples(db, content_type, platform) + + if not examples: + return "" + + formatted = ["EJEMPLOS DE POSTS EXITOSOS (inspírate en el estilo):"] + for i, ex in enumerate(examples, 1): + formatted.append(f"\n--- Ejemplo {i} ---\n{ex}") + + return "\n".join(formatted) + + # === Análisis de Contenido === + + def analyze_content( + self, + content: str, + content_type: str, + platform: str + ) -> Dict[str, Any]: + """ + Analizar contenido generado para almacenar en memoria. + + Este es un análisis básico basado en reglas. + Para análisis más sofisticado, usar el Validator con IA. + + Args: + content: Contenido a analizar + content_type: Tipo de contenido + platform: Plataforma destino + + Returns: + Dict con análisis (topics, key_phrases, hook_type) + """ + # Detectar hook type basado en primera línea + first_line = content.split("\n")[0].strip() + hook_type = self._detect_hook_type(first_line) + + # Extraer posibles temas (simplificado) + topics = self._extract_topics(content) + + # Extraer frases distintivas + key_phrases = self._extract_key_phrases(content) + + return { + "hook_type": hook_type, + "topics": topics, + "key_phrases": key_phrases, + "content_summary": content[:200], # Resumen simple + } + + def _detect_hook_type(self, first_line: str) -> str: + """Detectar tipo de hook basado en la primera línea.""" + first_line_lower = first_line.lower() + + if first_line.endswith("?"): + return "pregunta_retórica" + elif any(char.isdigit() for char in first_line) and "%" in first_line: + return "dato_impactante" + elif first_line_lower.startswith(("tip:", "consejo:", "truco:")): + return "tip_directo" + elif "🧵" in first_line or "hilo" in first_line_lower: + return "hilo_intro" + elif any(word in first_line_lower for word in ["nunca", "siempre", "error", "mito"]): + return "afirmación_bold" + else: + return "general" + + def _extract_topics(self, content: str) -> List[str]: + """Extraer temas del contenido (basado en keywords).""" + content_lower = content.lower() + + topic_keywords = { + "ia": ["ia", "inteligencia artificial", "chatgpt", "claude", "deepseek", "llm"], + "productividad": ["productividad", "tiempo", "eficiencia", "organización", "tareas"], + "python": ["python", "django", "flask", "pip"], + "seguridad": ["seguridad", "password", "contraseña", "phishing", "privacidad"], + "automatización": ["automatización", "automatizar", "script", "workflow"], + "hardware": ["laptop", "computadora", "impresora", "monitor", "teclado"], + } + + found_topics = [] + for topic, keywords in topic_keywords.items(): + if any(kw in content_lower for kw in keywords): + found_topics.append(topic) + + return found_topics + + def _extract_key_phrases(self, content: str) -> List[str]: + """Extraer frases distintivas del contenido.""" + # Buscar patrones como "la regla X", "el método Y", etc. + phrases = [] + + import re + + # Patrones comunes de frases distintivas + patterns = [ + r"la regla [\w\-]+", + r"el método [\w\-]+", + r"el truco [\w\-]+", + r"la técnica [\w\-]+", + r"\d+[%] [\w\s]{5,20}", # "90% de developers..." + ] + + for pattern in patterns: + matches = re.findall(pattern, content.lower()) + phrases.extend(matches) + + return list(set(phrases))[:5] # Max 5 frases + + # === Persistencia === + + def save_to_memory( + self, + db: Session, + post_id: int, + content: str, + content_type: str, + platform: str, + quality_score: Optional[int] = None, + quality_breakdown: Optional[Dict] = None, + template_used: Optional[str] = None, + personality_used: Optional[str] = None + ) -> ContentMemory: + """ + Guardar contenido en memoria para tracking. + + Args: + db: Sesión de base de datos + post_id: ID del post + content: Contenido generado + content_type: Tipo de contenido + platform: Plataforma + quality_score: Score de calidad + quality_breakdown: Breakdown del score + template_used: Template usado + personality_used: Personalidad usada + + Returns: + ContentMemory creado + """ + # Analizar contenido + analysis = self.analyze_content(content, content_type, platform) + + memory = ContentMemory( + post_id=post_id, + topics=analysis["topics"], + key_phrases=analysis["key_phrases"], + hook_type=analysis["hook_type"], + content_summary=analysis["content_summary"], + quality_score=quality_score, + quality_breakdown=quality_breakdown, + platform=platform, + content_type=content_type, + template_used=template_used, + personality_used=personality_used, + ) + + db.add(memory) + db.commit() + db.refresh(memory) + + return memory + + def update_engagement_scores(self, db: Session) -> int: + """ + Actualizar scores de engagement y marcar top performers. + + Debe ejecutarse periódicamente (ej: tarea Celery diaria). + + Returns: + Número de registros actualizados + """ + # Calcular percentil para top performer + all_scores = db.query(ContentMemory.engagement_score).filter( + ContentMemory.engagement_score.isnot(None) + ).all() + + if not all_scores: + return 0 + + scores = sorted([s[0] for s in all_scores], reverse=True) + threshold_idx = max(0, int(len(scores) * self.top_percentile / 100) - 1) + threshold_score = scores[threshold_idx] + + # Marcar top performers + updated = db.query(ContentMemory).filter( + ContentMemory.engagement_score >= threshold_score, + ContentMemory.is_top_performer == False + ).update({"is_top_performer": True}) + + db.commit() + + return updated + + +# Instancia global con configuración por defecto +context_engine = ContextEngine() diff --git a/app/services/ai/generator.py b/app/services/ai/generator.py new file mode 100644 index 0000000..e6800cc --- /dev/null +++ b/app/services/ai/generator.py @@ -0,0 +1,493 @@ +""" +ContentGeneratorV2 - Generador de contenido mejorado con DeepSeek. + +Este módulo maneja: +- Generación de contenido usando prompts de la biblioteca +- Integración con Context Engine para anti-repetición +- Soporte para few-shot learning +- Parámetros dinámicos por tipo de contenido +""" + +import json +from typing import Optional, List, Dict, Any +from openai import OpenAI +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.services.ai.prompt_library import prompt_library, PromptLibrary +from app.services.ai.context_engine import context_engine, ContextEngine +from app.services.ai.platform_adapter import platform_adapter, PlatformAdapter + + +class ContentGeneratorV2: + """ + Generador de contenido v2.0 con motor modular. + + Mejoras sobre v1: + - Prompts externalizados en YAML + - Anti-repetición via Context Engine + - Few-shot learning con posts exitosos + - Adaptación automática por plataforma + """ + + def __init__( + self, + prompt_lib: Optional[PromptLibrary] = None, + ctx_engine: Optional[ContextEngine] = None, + plt_adapter: Optional[PlatformAdapter] = None + ): + """ + Inicializar el generador. + + Args: + prompt_lib: PromptLibrary (usa global si no se especifica) + ctx_engine: ContextEngine (usa global si no se especifica) + plt_adapter: PlatformAdapter (usa global si no se especifica) + """ + self._client = None + self.model = "deepseek-chat" + + self.prompt_lib = prompt_lib or prompt_library + self.ctx_engine = ctx_engine or context_engine + self.plt_adapter = plt_adapter or platform_adapter + + @property + def client(self) -> OpenAI: + """Lazy initialization del cliente OpenAI/DeepSeek.""" + if self._client is None: + if not settings.DEEPSEEK_API_KEY: + raise ValueError( + "DEEPSEEK_API_KEY no configurada. " + "Configura la variable de entorno." + ) + self._client = OpenAI( + api_key=settings.DEEPSEEK_API_KEY, + base_url=settings.DEEPSEEK_BASE_URL + ) + return self._client + + # === Generación Principal === + + async def generate( + self, + template_name: str, + variables: Dict[str, Any], + platform: str, + db: Optional[Session] = None, + use_context: bool = True, + use_few_shot: bool = True, + personality: Optional[str] = None, + temperature_override: Optional[float] = None, + ) -> Dict[str, Any]: + """ + Generar contenido usando un template. + + Args: + template_name: Nombre del template (tip_tech, product_post, etc.) + variables: Variables para el template + platform: Plataforma destino + db: Sesión de DB (requerida para context) + use_context: Si usar anti-repetición + use_few_shot: Si usar ejemplos de posts exitosos + personality: Override de personalidad + temperature_override: Override de temperature + + Returns: + Dict con: + - content: Contenido generado + - adapted_content: Contenido adaptado a plataforma + - metadata: Info de generación + """ + # 1. Renderizar template + rendered = self.prompt_lib.render_template( + template_name, + variables, + personality or "default" + ) + + system_prompt = rendered["system_prompt"] + user_prompt = rendered["user_prompt"] + params = rendered["parameters"] + + # 2. Agregar contexto de exclusión si hay DB + if db and use_context: + exclusion_context = self.ctx_engine.build_exclusion_context( + db, + template_name, + variables.get("category") + ) + if exclusion_context: + user_prompt += f"\n\n{exclusion_context}" + + # 3. Agregar few-shot examples si hay DB + if db and use_few_shot: + few_shot_context = self.ctx_engine.build_few_shot_context( + db, + template_name, + platform + ) + if few_shot_context: + user_prompt += f"\n\n{few_shot_context}" + + # También agregar ejemplos del YAML si hay categoría + category = variables.get("category") + if category: + yaml_examples = self.prompt_lib.get_few_shot_examples( + template_name, + category, + max_examples=2 + ) + if yaml_examples: + user_prompt += "\n\nEJEMPLOS DE REFERENCIA:\n" + for ex in yaml_examples: + user_prompt += f"\n---\n{ex}\n---" + + # 4. Agregar límites de plataforma + limits = self.plt_adapter.get_limits(platform) + user_prompt += f"\n\nPLATAFORMA: {platform}" + user_prompt += f"\nLÍMITE DE CARACTERES: {limits.get('max_characters', 500)}" + user_prompt += f"\nMÁXIMO HASHTAGS: {limits.get('max_hashtags', 5)}" + + # 5. Llamar a DeepSeek + temperature = temperature_override or params.get("temperature", 0.7) + max_tokens = params.get("max_tokens", 400) + + response = self.client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + max_tokens=max_tokens, + temperature=temperature + ) + + content = response.choices[0].message.content.strip() + + # 6. Adaptar a plataforma + adapted = self.plt_adapter.adapt(content, platform) + + return { + "content": content, + "adapted_content": adapted.content, + "truncated": adapted.truncated, + "metadata": { + "template": template_name, + "personality": rendered["personality"], + "platform": platform, + "temperature": temperature, + "tokens_used": response.usage.total_tokens if response.usage else None, + "changes_made": adapted.changes_made, + } + } + + # === Métodos de Conveniencia === + + async def generate_tip( + self, + category: str, + platform: str, + db: Optional[Session] = None, + difficulty: str = "principiante", + **kwargs + ) -> Dict[str, Any]: + """ + Generar un tip de tecnología. + + Args: + category: Categoría del tip (ia, productividad, seguridad, etc.) + platform: Plataforma destino + db: Sesión de DB + difficulty: Nivel de dificultad + **kwargs: Argumentos adicionales para generate() + + Returns: + Dict con contenido generado + """ + return await self.generate( + template_name="tip_tech", + variables={ + "category": category, + "difficulty_level": difficulty, + "target_audience": "profesionales tech" + }, + platform=platform, + db=db, + **kwargs + ) + + async def generate_product_post( + self, + product: Dict[str, Any], + platform: str, + db: Optional[Session] = None, + **kwargs + ) -> Dict[str, Any]: + """ + Generar post para un producto. + + Args: + product: Dict con datos del producto + platform: Plataforma destino + db: Sesión de DB + **kwargs: Argumentos adicionales + + Returns: + Dict con contenido generado + """ + return await self.generate( + template_name="product_post", + variables={ + "product_name": product["name"], + "product_description": product.get("description", ""), + "price": product["price"], + "category": product["category"], + "specs": json.dumps(product.get("specs", {}), ensure_ascii=False), + "highlights": ", ".join(product.get("highlights", [])), + }, + platform=platform, + db=db, + personality="promotional", + **kwargs + ) + + async def generate_service_post( + self, + service: Dict[str, Any], + platform: str, + db: Optional[Session] = None, + **kwargs + ) -> Dict[str, Any]: + """ + Generar post para un servicio. + + Args: + service: Dict con datos del servicio + platform: Plataforma destino + db: Sesión de DB + **kwargs: Argumentos adicionales + + Returns: + Dict con contenido generado + """ + return await self.generate( + template_name="service_post", + variables={ + "service_name": service["name"], + "service_description": service.get("description", ""), + "category": service["category"], + "target_sectors": ", ".join(service.get("target_sectors", [])), + "benefits": ", ".join(service.get("benefits", [])), + "call_to_action": service.get("call_to_action", "Contáctanos"), + }, + platform=platform, + db=db, + personality="promotional", + **kwargs + ) + + async def generate_thread( + self, + topic: str, + num_posts: int = 5, + db: Optional[Session] = None, + **kwargs + ) -> List[str]: + """ + Generar un hilo educativo. + + Args: + topic: Tema del hilo + num_posts: Número de posts en el hilo + db: Sesión de DB + **kwargs: Argumentos adicionales + + Returns: + Lista de posts del hilo + """ + result = await self.generate( + template_name="thread", + variables={ + "topic": topic, + "num_posts": num_posts, + "depth": "intermedio", + }, + platform="x", # Threads son para X principalmente + db=db, + personality="educational", + **kwargs + ) + + # Parsear posts del hilo + content = result["content"] + posts = [p.strip() for p in content.split("\n\n") if p.strip()] + + # Si no se separaron bien, intentar por números + if len(posts) < num_posts - 1: + import re + posts = re.split(r"\n(?=\d+[/\)])", content) + posts = [p.strip() for p in posts if p.strip()] + + return posts + + async def generate_response( + self, + interaction_content: str, + interaction_type: str, + sentiment: str = "neutral", + context: Optional[str] = None, + **kwargs + ) -> List[str]: + """ + Generar sugerencias de respuesta. + + Args: + interaction_content: Contenido de la interacción + interaction_type: Tipo (comment, mention, reply, dm) + sentiment: Sentimiento detectado + context: Contexto adicional + **kwargs: Argumentos adicionales + + Returns: + Lista de 3 opciones de respuesta + """ + result = await self.generate( + template_name="response", + variables={ + "interaction_content": interaction_content, + "interaction_type": interaction_type, + "sentiment": sentiment, + "context": context or "Sin contexto adicional", + }, + platform="x", + use_context=False, + use_few_shot=False, + **kwargs + ) + + # Parsear las 3 opciones + content = result["content"] + lines = [l.strip() for l in content.split("\n") if l.strip()] + + # Limpiar numeración + responses = [] + for line in lines: + if line and line[0].isdigit() and len(line) > 2: + # Remover "1.", "1)", "1:" etc. + clean = line[2:].strip() if line[1] in ".):,-" else line + if clean: + responses.append(clean) + elif line and not line[0].isdigit(): + responses.append(line) + + return responses[:3] + + async def adapt_content( + self, + content: str, + source_platform: str, + target_platform: str + ) -> str: + """ + Adaptar contenido existente a otra plataforma usando IA. + + Args: + content: Contenido original + source_platform: Plataforma de origen + target_platform: Plataforma destino + + Returns: + Contenido adaptado + """ + # Generar prompt de adaptación + adaptation_prompt = self.plt_adapter.get_adaptation_prompt( + content, source_platform, target_platform + ) + + system_prompt = self.prompt_lib.get_system_prompt() + + response = self.client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": adaptation_prompt} + ], + max_tokens=500, + temperature=0.6 + ) + + return response.choices[0].message.content.strip() + + async def generate_for_all_platforms( + self, + template_name: str, + variables: Dict[str, Any], + platforms: List[str], + db: Optional[Session] = None, + **kwargs + ) -> Dict[str, Dict[str, Any]]: + """ + Generar contenido para múltiples plataformas. + + Genera una versión base y la adapta para cada plataforma. + + Args: + template_name: Nombre del template + variables: Variables del template + platforms: Lista de plataformas + db: Sesión de DB + **kwargs: Argumentos adicionales + + Returns: + Dict de plataforma -> resultado de generación + """ + results = {} + + # Generar versión base (para la plataforma con más espacio) + base_platform = "instagram" # Más espacio = más contexto + if "facebook" in platforms: + base_platform = "facebook" + + base_result = await self.generate( + template_name=template_name, + variables=variables, + platform=base_platform, + db=db, + **kwargs + ) + + base_content = base_result["content"] + results[base_platform] = base_result + + # Adaptar para otras plataformas + for platform in platforms: + if platform == base_platform: + continue + + # Usar adaptación con IA para mejor calidad + adapted_content = await self.adapt_content( + base_content, + base_platform, + platform + ) + + # Validar adaptación + adapted = self.plt_adapter.adapt(adapted_content, platform) + + results[platform] = { + "content": adapted_content, + "adapted_content": adapted.content, + "truncated": adapted.truncated, + "metadata": { + "template": template_name, + "platform": platform, + "adapted_from": base_platform, + "changes_made": adapted.changes_made, + } + } + + return results + + +# Instancia global +content_generator_v2 = ContentGeneratorV2() diff --git a/app/services/ai/platform_adapter.py b/app/services/ai/platform_adapter.py new file mode 100644 index 0000000..8f1b003 --- /dev/null +++ b/app/services/ai/platform_adapter.py @@ -0,0 +1,374 @@ +""" +PlatformAdapter - Adapta contenido según reglas de cada plataforma. + +Este módulo maneja: +- Transformación de contenido base a formato específico de plataforma +- Ajuste de longitud, tono, hashtags y formato +- Validación de límites de plataforma +""" + +from typing import Dict, Optional, Any, List +from dataclasses import dataclass + +from app.services.ai.prompt_library import prompt_library + + +@dataclass +class AdaptedContent: + """Contenido adaptado para una plataforma.""" + content: str + platform: str + original_content: str + truncated: bool + hashtags_adjusted: bool + changes_made: List[str] + + +class PlatformAdapter: + """ + Adaptador de contenido por plataforma. + + Transforma contenido generado según las reglas específicas + de cada red social (X, Threads, Instagram, Facebook). + """ + + def __init__(self): + """Inicializar el adaptador.""" + self.prompt_lib = prompt_library + + # Límites rápidos (fallback si no hay YAML) + self._default_limits = { + "x": {"max_characters": 280, "max_hashtags": 2}, + "threads": {"max_characters": 500, "max_hashtags": 5}, + "instagram": {"max_characters": 2200, "max_hashtags": 10}, + "facebook": {"max_characters": 2000, "max_hashtags": 3}, + } + + def get_limits(self, platform: str) -> Dict[str, int]: + """ + Obtener límites de una plataforma. + + Args: + platform: Nombre de la plataforma + + Returns: + Dict con límites + """ + try: + return self.prompt_lib.get_platform_limits(platform) + except FileNotFoundError: + return self._default_limits.get(platform, self._default_limits["x"]) + + def get_config(self, platform: str) -> Dict: + """ + Obtener configuración completa de una plataforma. + + Args: + platform: Nombre de la plataforma + + Returns: + Dict con configuración + """ + try: + return self.prompt_lib.get_platform_config(platform) + except FileNotFoundError: + return {"platform": platform, "limits": self.get_limits(platform)} + + # === Adaptación Principal === + + def adapt( + self, + content: str, + platform: str, + preserve_hashtags: bool = True + ) -> AdaptedContent: + """ + Adaptar contenido a una plataforma específica. + + Esta es adaptación basada en reglas (sin IA). + Para adaptación con IA, usar adapt_with_ai(). + + Args: + content: Contenido a adaptar + platform: Plataforma destino + preserve_hashtags: Si preservar hashtags existentes + + Returns: + AdaptedContent con el contenido adaptado + """ + limits = self.get_limits(platform) + config = self.get_config(platform) + + changes = [] + adapted = content + truncated = False + hashtags_adjusted = False + + # 1. Extraer y procesar hashtags + main_content, hashtags = self._extract_hashtags(adapted) + + # 2. Ajustar hashtags según plataforma + max_hashtags = limits.get("max_hashtags", 5) + if len(hashtags) > max_hashtags: + hashtags = hashtags[:max_hashtags] + hashtags_adjusted = True + changes.append(f"Hashtags reducidos a {max_hashtags}") + + # 3. Verificar y ajustar longitud + max_chars = limits.get("max_characters", 2000) + + # Calcular espacio para hashtags + hashtag_space = len(" ".join(hashtags)) + 2 if hashtags else 0 + available_for_content = max_chars - hashtag_space + + if len(main_content) > available_for_content: + main_content = self._smart_truncate(main_content, available_for_content) + truncated = True + changes.append(f"Contenido truncado a {available_for_content} caracteres") + + # 4. Aplicar formato de plataforma + main_content = self._apply_platform_formatting(main_content, platform, config) + if main_content != content.replace("#", "").strip(): + changes.append("Formato ajustado para plataforma") + + # 5. Recombinar con hashtags + if hashtags and preserve_hashtags: + adapted = main_content + "\n\n" + " ".join(hashtags) + else: + adapted = main_content + + return AdaptedContent( + content=adapted.strip(), + platform=platform, + original_content=content, + truncated=truncated, + hashtags_adjusted=hashtags_adjusted, + changes_made=changes, + ) + + def adapt_for_all_platforms( + self, + content: str, + platforms: List[str] + ) -> Dict[str, AdaptedContent]: + """ + Adaptar contenido para múltiples plataformas. + + Args: + content: Contenido base + platforms: Lista de plataformas + + Returns: + Dict de plataforma -> AdaptedContent + """ + return { + platform: self.adapt(content, platform) + for platform in platforms + } + + # === Helpers === + + def _extract_hashtags(self, content: str) -> tuple[str, List[str]]: + """ + Extraer hashtags del contenido. + + Returns: + Tuple de (contenido sin hashtags, lista de hashtags) + """ + import re + + hashtags = re.findall(r"#\w+", content) + + # Remover hashtags del contenido + main_content = re.sub(r"\s*#\w+", "", content).strip() + + return main_content, hashtags + + def _smart_truncate(self, content: str, max_length: int) -> str: + """ + Truncar contenido de forma inteligente. + + Intenta cortar en un punto natural (oración, párrafo). + + Args: + content: Contenido a truncar + max_length: Longitud máxima + + Returns: + Contenido truncado + """ + if len(content) <= max_length: + return content + + # Reservar espacio para "..." + target_length = max_length - 3 + + # Intentar cortar en punto/salto de línea + truncated = content[:target_length] + + # Buscar último punto o salto de línea + last_period = truncated.rfind(".") + last_newline = truncated.rfind("\n") + + cut_point = max(last_period, last_newline) + + if cut_point > target_length * 0.5: # Solo si no perdemos mucho + return content[:cut_point + 1].strip() + + # Si no hay buen punto de corte, cortar en última palabra completa + last_space = truncated.rfind(" ") + if last_space > target_length * 0.7: + return content[:last_space].strip() + "..." + + return truncated + "..." + + def _apply_platform_formatting( + self, + content: str, + platform: str, + config: Dict + ) -> str: + """ + Aplicar formato específico de plataforma. + + Args: + content: Contenido a formatear + platform: Plataforma + config: Configuración de la plataforma + + Returns: + Contenido formateado + """ + formatting = config.get("formatting", {}) + + # Aplicar estilo de bullets si está configurado + bullet_style = formatting.get("bullet_style", "•") + + # Reemplazar bullets genéricos + content = content.replace("• ", f"{bullet_style} ") + content = content.replace("- ", f"{bullet_style} ") + + # Ajustar saltos de línea según plataforma + if platform == "x": + # X: menos saltos de línea, más compacto + content = self._compact_line_breaks(content) + elif platform == "instagram": + # Instagram: más saltos para legibilidad + content = self._expand_line_breaks(content) + + return content + + def _compact_line_breaks(self, content: str) -> str: + """Reducir saltos de línea múltiples a uno.""" + import re + return re.sub(r"\n{3,}", "\n\n", content) + + def _expand_line_breaks(self, content: str) -> str: + """Asegurar separación entre párrafos.""" + import re + # Reemplazar un salto por dos donde hay oraciones + return re.sub(r"\.(\n)(?=[A-Z])", ".\n\n", content) + + # === Validación === + + def validate_for_platform( + self, + content: str, + platform: str + ) -> Dict[str, Any]: + """ + Validar que contenido cumple con límites de plataforma. + + Args: + content: Contenido a validar + platform: Plataforma + + Returns: + Dict con resultado de validación + """ + limits = self.get_limits(platform) + _, hashtags = self._extract_hashtags(content) + + issues = [] + + # Verificar longitud + max_chars = limits.get("max_characters", 2000) + if len(content) > max_chars: + issues.append({ + "type": "length", + "message": f"Contenido excede límite ({len(content)}/{max_chars})", + "severity": "error" + }) + + # Verificar hashtags + max_hashtags = limits.get("max_hashtags", 10) + if len(hashtags) > max_hashtags: + issues.append({ + "type": "hashtags", + "message": f"Demasiados hashtags ({len(hashtags)}/{max_hashtags})", + "severity": "warning" + }) + + return { + "valid": len([i for i in issues if i["severity"] == "error"]) == 0, + "issues": issues, + "stats": { + "characters": len(content), + "max_characters": max_chars, + "hashtags": len(hashtags), + "max_hashtags": max_hashtags, + } + } + + # === Generación de Prompts de Adaptación === + + def get_adaptation_prompt( + self, + content: str, + source_platform: str, + target_platform: str + ) -> str: + """ + Generar prompt para adaptar contenido con IA. + + Args: + content: Contenido original + source_platform: Plataforma de origen + target_platform: Plataforma destino + + Returns: + Prompt para enviar a la IA + """ + target_config = self.get_config(target_platform) + limits = self.get_limits(target_platform) + + adaptation_rules = target_config.get("adaptation_rules", "") + tone_style = target_config.get("tone", {}).get("style", "neutral") + + prompt = f"""Adapta este contenido de {source_platform} para {target_platform}: + +CONTENIDO ORIGINAL: +{content} + +LÍMITES DE {target_platform.upper()}: +- Máximo caracteres: {limits.get('max_characters', 2000)} +- Máximo hashtags: {limits.get('max_hashtags', 5)} + +TONO PARA {target_platform.upper()}: {tone_style} + +REGLAS DE ADAPTACIÓN: +{adaptation_rules} + +IMPORTANTE: +- Mantén la esencia y mensaje principal +- Adapta el tono según la plataforma +- Ajusta hashtags apropiadamente +- NO inventes información nueva + +Responde SOLO con el contenido adaptado, sin explicaciones.""" + + return prompt + + +# Instancia global +platform_adapter = PlatformAdapter() diff --git a/app/services/ai/prompt_library.py b/app/services/ai/prompt_library.py new file mode 100644 index 0000000..eb74f50 --- /dev/null +++ b/app/services/ai/prompt_library.py @@ -0,0 +1,353 @@ +""" +PromptLibrary - Carga y renderiza prompts desde archivos YAML. + +Este módulo maneja: +- Carga de templates de prompts desde YAML +- Renderizado con variables dinámicas +- Herencia de personalidades +- Cache para evitar lecturas repetidas de disco +""" + +import os +from pathlib import Path +from typing import Dict, Optional, Any, List +from functools import lru_cache +import yaml + +from app.core.config import settings + + +class PromptLibrary: + """ + Biblioteca de prompts cargados desde YAML. + + Estructura de directorios esperada: + app/prompts/ + ├── personalities/ # Personalidades de marca + ├── templates/ # Templates de contenido + ├── examples/ # Ejemplos para few-shot + └── platforms/ # Configuración por plataforma + """ + + def __init__(self, prompts_dir: Optional[str] = None): + """ + Inicializar la biblioteca de prompts. + + Args: + prompts_dir: Directorio raíz de prompts. Si no se especifica, + usa app/prompts/ relativo al proyecto. + """ + if prompts_dir: + self.prompts_dir = Path(prompts_dir) + else: + # Detectar directorio del proyecto + base_dir = Path(__file__).parent.parent.parent # app/services/ai -> app + self.prompts_dir = base_dir / "prompts" + + self._cache: Dict[str, Any] = {} + + def _load_yaml(self, file_path: Path) -> Dict: + """ + Cargar archivo YAML con cache. + + Args: + file_path: Ruta al archivo YAML + + Returns: + Diccionario con contenido del YAML + """ + cache_key = str(file_path) + + if cache_key not in self._cache: + if not file_path.exists(): + raise FileNotFoundError(f"Archivo de prompt no encontrado: {file_path}") + + with open(file_path, "r", encoding="utf-8") as f: + self._cache[cache_key] = yaml.safe_load(f) + + return self._cache[cache_key] + + def clear_cache(self) -> None: + """Limpiar cache de YAMLs cargados.""" + self._cache.clear() + + # === Personalidades === + + def get_personality(self, name: str = "default") -> Dict: + """ + Obtener una personalidad de marca. + + Args: + name: Nombre de la personalidad (default, educational, promotional) + + Returns: + Dict con configuración de personalidad + """ + file_path = self.prompts_dir / "personalities" / f"{name}.yaml" + personality = self._load_yaml(file_path) + + # Si hereda de otra personalidad, mergear + if "extends" in personality: + base_name = personality["extends"] + base = self.get_personality(base_name) + personality = self._merge_personalities(base, personality) + + return personality + + def _merge_personalities(self, base: Dict, child: Dict) -> Dict: + """ + Mergear personalidad hija con base. + + La hija sobrescribe valores de la base. + """ + merged = base.copy() + + for key, value in child.items(): + if key == "extends": + continue + if isinstance(value, dict) and key in merged and isinstance(merged[key], dict): + merged[key] = {**merged[key], **value} + else: + merged[key] = value + + return merged + + def get_system_prompt(self, personality: str = "default") -> str: + """ + Obtener system prompt para una personalidad. + + Args: + personality: Nombre de la personalidad + + Returns: + System prompt renderizado con variables de negocio + """ + pers = self.get_personality(personality) + + system_prompt = pers.get("system_prompt", "") + + # Si tiene system_prompt_addition (de herencia), agregarlo + if "system_prompt_addition" in pers: + system_prompt += pers["system_prompt_addition"] + + # Renderizar variables de negocio + system_prompt = system_prompt.format( + brand_name=settings.BUSINESS_NAME, + location=settings.BUSINESS_LOCATION, + website=settings.BUSINESS_WEBSITE, + tone=settings.CONTENT_TONE, + ) + + return system_prompt + + # === Templates === + + def get_template(self, name: str) -> Dict: + """ + Obtener un template de contenido. + + Args: + name: Nombre del template (tip_tech, product_post, etc.) + + Returns: + Dict con configuración del template + """ + file_path = self.prompts_dir / "templates" / f"{name}.yaml" + return self._load_yaml(file_path) + + def render_template( + self, + template_name: str, + variables: Dict[str, Any], + personality: str = "default" + ) -> Dict[str, Any]: + """ + Renderizar un template con variables. + + Args: + template_name: Nombre del template + variables: Variables para sustituir en el template + personality: Personalidad a usar + + Returns: + Dict con: + - system_prompt: Prompt del sistema + - user_prompt: Prompt del usuario renderizado + - parameters: Parámetros para la API (temperature, max_tokens) + """ + template = self.get_template(template_name) + + # Obtener system prompt de la personalidad especificada en template o parámetro + pers_name = template.get("personality", personality) + system_prompt = self.get_system_prompt(pers_name) + + # Agregar contexto del template al system prompt si existe + if "system_context" in template: + system_prompt += "\n\n" + template["system_context"] + + # Obtener y renderizar el user prompt + user_template = template.get("template", "") + + # Aplicar defaults a variables faltantes + template_vars = {} + for var_def in template.get("variables", []): + var_name = var_def["name"] + if var_name in variables: + template_vars[var_name] = variables[var_name] + elif "default" in var_def: + template_vars[var_name] = var_def["default"] + elif var_def.get("required", False): + raise ValueError(f"Variable requerida no proporcionada: {var_name}") + + # Renderizar template + try: + user_prompt = user_template.format(**template_vars) + except KeyError as e: + raise ValueError(f"Variable faltante en template: {e}") + + # Obtener parámetros + parameters = template.get("parameters", {}) + + return { + "system_prompt": system_prompt, + "user_prompt": user_prompt, + "parameters": parameters, + "template_name": template_name, + "personality": pers_name, + } + + # === Plataformas === + + def get_platform_config(self, platform: str) -> Dict: + """ + Obtener configuración de una plataforma. + + Args: + platform: Nombre de la plataforma (x, threads, instagram, facebook) + + Returns: + Dict con configuración de la plataforma + """ + file_path = self.prompts_dir / "platforms" / f"{platform}.yaml" + return self._load_yaml(file_path) + + def get_platform_limits(self, platform: str) -> Dict: + """ + Obtener límites de una plataforma. + + Args: + platform: Nombre de la plataforma + + Returns: + Dict con límites (max_characters, max_hashtags, etc.) + """ + config = self.get_platform_config(platform) + return config.get("limits", {}) + + def get_platform_adaptation_rules(self, platform: str) -> str: + """ + Obtener reglas de adaptación para una plataforma. + + Args: + platform: Nombre de la plataforma + + Returns: + String con reglas de adaptación + """ + config = self.get_platform_config(platform) + return config.get("adaptation_rules", "") + + # === Ejemplos === + + def get_examples(self, category: Optional[str] = None) -> Dict: + """ + Obtener ejemplos para few-shot learning. + + Args: + category: Categoría específica (ia, productividad, seguridad) + Si es None, retorna best_posts.yaml + + Returns: + Dict con ejemplos + """ + if category: + file_path = self.prompts_dir / "examples" / "by_category" / f"{category}.yaml" + else: + file_path = self.prompts_dir / "examples" / "best_posts.yaml" + + return self._load_yaml(file_path) + + def get_few_shot_examples( + self, + template_type: str, + category: Optional[str] = None, + max_examples: int = 3 + ) -> List[str]: + """ + Obtener ejemplos formateados para few-shot prompting. + + Args: + template_type: Tipo de template (tip_tech, product_post, etc.) + category: Categoría de contenido (opcional) + max_examples: Máximo de ejemplos a retornar + + Returns: + Lista de strings con ejemplos formateados + """ + examples = [] + + # Primero intentar ejemplos de categoría específica + if category: + try: + cat_examples = self.get_examples(category) + if "examples" in cat_examples: + for example_type, example_list in cat_examples["examples"].items(): + for ex in example_list[:max_examples]: + if "content" in ex: + examples.append(ex["content"]) + except FileNotFoundError: + pass + + # Luego best_posts generales + if len(examples) < max_examples: + try: + best = self.get_examples() + if "examples" in best and template_type in best["examples"]: + for ex in best["examples"][template_type]: + if len(examples) >= max_examples: + break + if "content" in ex: + examples.append(ex["content"]) + elif "posts" in ex: # Para threads + examples.append("\n\n".join(ex["posts"])) + except FileNotFoundError: + pass + + return examples[:max_examples] + + # === Utilidades === + + def list_templates(self) -> List[str]: + """Listar todos los templates disponibles.""" + templates_dir = self.prompts_dir / "templates" + if not templates_dir.exists(): + return [] + return [f.stem for f in templates_dir.glob("*.yaml")] + + def list_personalities(self) -> List[str]: + """Listar todas las personalidades disponibles.""" + pers_dir = self.prompts_dir / "personalities" + if not pers_dir.exists(): + return [] + return [f.stem for f in pers_dir.glob("*.yaml")] + + def list_platforms(self) -> List[str]: + """Listar todas las plataformas configuradas.""" + platforms_dir = self.prompts_dir / "platforms" + if not platforms_dir.exists(): + return [] + return [f.stem for f in platforms_dir.glob("*.yaml")] + + +# Instancia global +prompt_library = PromptLibrary() diff --git a/app/services/ai/validator.py b/app/services/ai/validator.py new file mode 100644 index 0000000..9e73f3a --- /dev/null +++ b/app/services/ai/validator.py @@ -0,0 +1,479 @@ +""" +ContentValidator - Validación y scoring de contenido con IA. + +Este módulo maneja: +- Validaciones obligatorias (pass/fail) +- Scoring de calidad con IA +- Decisiones de regeneración +- Marcado de top performers +""" + +import json +import re +from pathlib import Path +from typing import Dict, Any, Optional, Tuple, List +from dataclasses import dataclass +from openai import OpenAI +import yaml + +from app.core.config import settings +from app.services.ai.platform_adapter import platform_adapter + + +@dataclass +class ValidationResult: + """Resultado de validación.""" + passed: bool + issues: List[Dict[str, Any]] + content: str + + +@dataclass +class ScoringResult: + """Resultado de scoring.""" + total_score: int + breakdown: Dict[str, int] + feedback: str + is_top_performer: bool + action: str # "accept", "regenerate", "reject" + + +@dataclass +class ContentQualityResult: + """Resultado completo de validación y scoring.""" + validation: ValidationResult + scoring: Optional[ScoringResult] + final_decision: str # "accept", "regenerate", "reject" + content: str + + +class ContentValidator: + """ + Validador de contenido generado. + + Combina validaciones basadas en reglas (rápidas, sin costo) + con scoring usando IA (más preciso, con costo de tokens). + """ + + def __init__(self, config_path: Optional[str] = None): + """ + Inicializar el validador. + + Args: + config_path: Ruta al archivo quality.yaml + """ + self._client = None + self.model = "deepseek-chat" + + # Cargar configuración + if config_path: + self.config_path = Path(config_path) + else: + base_dir = Path(__file__).parent.parent.parent + self.config_path = base_dir / "config" / "quality.yaml" + + self.config = self._load_config() + + def _load_config(self) -> Dict: + """Cargar configuración de quality.yaml.""" + if self.config_path.exists(): + with open(self.config_path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) + + # Config por defecto si no existe el archivo + return { + "thresholds": { + "minimum_score": 60, + "excellent_score": 85, + }, + "regeneration": { + "max_attempts": 2, + }, + "validations": { + "prohibited_content": { + "prohibited_words": [], + "prohibited_patterns": [], + } + } + } + + @property + def client(self) -> OpenAI: + """Lazy initialization del cliente.""" + if self._client is None: + if not settings.DEEPSEEK_API_KEY: + raise ValueError("DEEPSEEK_API_KEY no configurada") + self._client = OpenAI( + api_key=settings.DEEPSEEK_API_KEY, + base_url=settings.DEEPSEEK_BASE_URL + ) + return self._client + + # === Validaciones (Pass/Fail) === + + def validate( + self, + content: str, + platform: str, + expected_language: str = "es" + ) -> ValidationResult: + """ + Ejecutar validaciones obligatorias. + + Args: + content: Contenido a validar + platform: Plataforma destino + expected_language: Idioma esperado + + Returns: + ValidationResult con resultado de validaciones + """ + issues = [] + + # 1. Validar longitud + length_result = self._validate_length(content, platform) + if not length_result["passed"]: + issues.append(length_result) + + # 2. Validar contenido prohibido + prohibited_result = self._validate_prohibited_content(content) + if not prohibited_result["passed"]: + issues.append(prohibited_result) + + # 3. Validar formato + format_result = self._validate_format(content) + if not format_result["passed"]: + issues.append(format_result) + + # 4. Validar que no esté vacío o muy corto + if len(content.strip()) < 20: + issues.append({ + "type": "empty_content", + "message": "Contenido demasiado corto", + "severity": "error", + "passed": False + }) + + passed = all(i.get("severity") != "error" for i in issues) + + return ValidationResult( + passed=passed, + issues=issues, + content=content + ) + + def _validate_length(self, content: str, platform: str) -> Dict: + """Validar longitud contra límites de plataforma.""" + limits = platform_adapter.get_limits(platform) + max_chars = limits.get("max_characters", 2000) + + if len(content) > max_chars: + return { + "type": "length", + "message": f"Contenido excede límite: {len(content)}/{max_chars}", + "severity": "error", + "passed": False, + "current": len(content), + "max": max_chars + } + + return {"type": "length", "passed": True} + + def _validate_prohibited_content(self, content: str) -> Dict: + """Validar que no contenga palabras/patrones prohibidos.""" + validations = self.config.get("validations", {}) + prohibited = validations.get("prohibited_content", {}) + + content_lower = content.lower() + + # Verificar palabras prohibidas + prohibited_words = prohibited.get("prohibited_words", []) + for word in prohibited_words: + if word.lower() in content_lower: + return { + "type": "prohibited_content", + "message": f"Contenido contiene palabra prohibida: {word}", + "severity": "error", + "passed": False, + "word": word + } + + # Verificar patrones prohibidos + prohibited_patterns = prohibited.get("prohibited_patterns", []) + for pattern in prohibited_patterns: + if re.search(pattern, content_lower): + return { + "type": "prohibited_pattern", + "message": f"Contenido coincide con patrón prohibido", + "severity": "error", + "passed": False, + "pattern": pattern + } + + return {"type": "prohibited_content", "passed": True} + + def _validate_format(self, content: str) -> Dict: + """Validar formato del contenido.""" + issues = [] + + # Verificar que no esté truncado (terminando en medio de palabra) + if content and not content[-1] in ".!?\"')#\n": + # Podría estar truncado + last_word = content.split()[-1] if content.split() else "" + if len(last_word) > 15: # Palabra muy larga al final = truncado + issues.append("Posiblemente truncado") + + # Verificar encoding (caracteres extraños) + try: + content.encode("utf-8").decode("utf-8") + except Exception: + issues.append("Problemas de encoding") + + if issues: + return { + "type": "format", + "message": "; ".join(issues), + "severity": "warning", + "passed": True # Warning, no error + } + + return {"type": "format", "passed": True} + + # === Scoring con IA === + + async def score( + self, + content: str, + platform: str + ) -> ScoringResult: + """ + Evaluar calidad del contenido usando IA. + + Args: + content: Contenido a evaluar + platform: Plataforma + + Returns: + ScoringResult con score y breakdown + """ + # Obtener prompt de scoring del config + scoring_prompt = self.config.get("scoring_prompt", "") + if not scoring_prompt: + scoring_prompt = self._default_scoring_prompt() + + # Renderizar prompt + prompt = scoring_prompt.format( + content=content, + platform=platform + ) + + # Llamar a DeepSeek + response = self.client.chat.completions.create( + model=self.model, + messages=[ + { + "role": "system", + "content": "Eres un evaluador de contenido para redes sociales. " + "Evalúa de forma objetiva y estricta. " + "Responde SOLO en JSON válido." + }, + {"role": "user", "content": prompt} + ], + max_tokens=300, + temperature=0.3 # Bajo para consistencia + ) + + response_text = response.choices[0].message.content.strip() + + # Parsear respuesta JSON + try: + # Limpiar respuesta si tiene markdown + if "```json" in response_text: + response_text = response_text.split("```json")[1].split("```")[0] + elif "```" in response_text: + response_text = response_text.split("```")[1].split("```")[0] + + result = json.loads(response_text) + except json.JSONDecodeError: + # Si falla el parsing, intentar extraer números + result = self._extract_score_from_text(response_text) + + total_score = result.get("total", 50) + breakdown = result.get("breakdown", {}) + feedback = result.get("feedback", "") + + # Determinar acción + thresholds = self.config.get("thresholds", {}) + min_score = thresholds.get("minimum_score", 60) + excellent_score = thresholds.get("excellent_score", 85) + + if total_score < 40: + action = "reject" + elif total_score < min_score: + action = "regenerate" + else: + action = "accept" + + is_top = total_score >= excellent_score + + return ScoringResult( + total_score=total_score, + breakdown=breakdown, + feedback=feedback, + is_top_performer=is_top, + action=action + ) + + def _default_scoring_prompt(self) -> str: + """Prompt por defecto para scoring.""" + return """Evalúa este post para {platform} en escala 0-100. + +POST: +{content} + +CRITERIOS (suma = 100): +- Hook (0-25): ¿La primera línea captura atención? +- Claridad (0-20): ¿Se entiende fácilmente? +- Accionabilidad (0-20): ¿Qué puede hacer el lector? +- Originalidad (0-15): ¿Evita clichés? +- Voz de marca (0-10): ¿Profesional pero cercano? +- CTA (0-10): ¿CTA claro si aplica? + +RESPONDE EN JSON: +{{"total": N, "breakdown": {{"hook_strength": N, "clarity": N, "actionability": N, "originality": N, "brand_voice": N, "cta_effectiveness": N}}, "feedback": "sugerencia"}}""" + + def _extract_score_from_text(self, text: str) -> Dict: + """Extraer score de texto si falla JSON parsing.""" + # Buscar patrones como "total: 75" o "score: 75" + import re + + total_match = re.search(r"total[:\s]+(\d+)", text.lower()) + total = int(total_match.group(1)) if total_match else 50 + + return { + "total": min(100, max(0, total)), + "breakdown": {}, + "feedback": "No se pudo parsear respuesta completa" + } + + # === Evaluación Completa === + + async def evaluate( + self, + content: str, + platform: str, + skip_scoring: bool = False + ) -> ContentQualityResult: + """ + Evaluación completa: validación + scoring. + + Args: + content: Contenido a evaluar + platform: Plataforma + skip_scoring: Si omitir scoring (solo validación) + + Returns: + ContentQualityResult con resultado completo + """ + # 1. Validaciones obligatorias + validation = self.validate(content, platform) + + # Si falla validación, no hace falta scoring + if not validation.passed: + return ContentQualityResult( + validation=validation, + scoring=None, + final_decision="reject", + content=content + ) + + # 2. Scoring con IA (si no se omite) + scoring = None + if not skip_scoring: + scoring = await self.score(content, platform) + + # 3. Decisión final + if scoring: + final_decision = scoring.action + else: + final_decision = "accept" # Sin scoring, aceptar si pasó validación + + return ContentQualityResult( + validation=validation, + scoring=scoring, + final_decision=final_decision, + content=content + ) + + # === Utilidades === + + def should_regenerate( + self, + quality_result: ContentQualityResult, + attempt: int = 1 + ) -> bool: + """ + Determinar si se debe regenerar el contenido. + + Args: + quality_result: Resultado de evaluación + attempt: Número de intento actual + + Returns: + True si se debe regenerar + """ + max_attempts = self.config.get("regeneration", {}).get("max_attempts", 2) + + if attempt >= max_attempts: + return False + + return quality_result.final_decision == "regenerate" + + def get_regeneration_hints( + self, + quality_result: ContentQualityResult + ) -> str: + """ + Obtener hints para mejorar en la regeneración. + + Args: + quality_result: Resultado de evaluación + + Returns: + String con instrucciones para mejorar + """ + hints = [] + + # Hints de validación + for issue in quality_result.validation.issues: + if issue.get("type") == "length": + hints.append(f"Reducir longitud a máximo {issue.get('max')} caracteres") + elif issue.get("type") == "prohibited_content": + hints.append(f"Evitar: {issue.get('word', 'contenido prohibido')}") + + # Hints de scoring + if quality_result.scoring: + if quality_result.scoring.feedback: + hints.append(quality_result.scoring.feedback) + + # Identificar áreas débiles + breakdown = quality_result.scoring.breakdown + if breakdown: + weak_areas = [] + if breakdown.get("hook_strength", 25) < 15: + weak_areas.append("mejorar el hook inicial") + if breakdown.get("clarity", 20) < 12: + weak_areas.append("hacer el mensaje más claro") + if breakdown.get("actionability", 20) < 12: + weak_areas.append("hacerlo más accionable") + + if weak_areas: + hints.append("Enfocarse en: " + ", ".join(weak_areas)) + + if hints: + return "\n\nPARA MEJORAR:\n- " + "\n- ".join(hints) + return "" + + +# Instancia global +content_validator = ContentValidator() diff --git a/app/services/content_generator.py b/app/services/content_generator.py index 03f9c67..54a59c1 100644 --- a/app/services/content_generator.py +++ b/app/services/content_generator.py @@ -1,20 +1,65 @@ """ Servicio de generación de contenido con DeepSeek API. + +Este archivo mantiene la interfaz original (ContentGenerator) para +compatibilidad con código existente, pero internamente usa el nuevo +motor modular (ContentGeneratorV2) cuando está disponible. + +Para nuevas integraciones, usar directamente: + from app.services.ai import ContentGeneratorV2, content_generator_v2 """ import json -from typing import Optional, List, Dict +from typing import Optional, List, Dict, Any from openai import OpenAI +from sqlalchemy.orm import Session from app.core.config import settings +# Importar nuevo motor +try: + from app.services.ai import ( + ContentGeneratorV2, + content_generator_v2, + ContextEngine, + context_engine, + ContentValidator, + content_validator, + ) + NEW_ENGINE_AVAILABLE = True +except ImportError: + NEW_ENGINE_AVAILABLE = False + class ContentGenerator: - """Generador de contenido usando DeepSeek API.""" + """ + Generador de contenido usando DeepSeek API. - def __init__(self): + Esta clase mantiene la interfaz original para compatibilidad. + Internamente delega al nuevo motor cuando está disponible. + + Para nuevas funcionalidades, usar ContentGeneratorV2 directamente. + """ + + def __init__(self, use_new_engine: bool = True): + """ + Inicializar el generador. + + Args: + use_new_engine: Si usar el nuevo motor v2 (default: True) + """ self._client = None self.model = "deepseek-chat" + self._use_new_engine = use_new_engine and NEW_ENGINE_AVAILABLE + + if self._use_new_engine: + self._v2 = content_generator_v2 + self._validator = content_validator + self._context = context_engine + else: + self._v2 = None + self._validator = None + self._context = None @property def client(self): @@ -30,6 +75,15 @@ class ContentGenerator: def _get_system_prompt(self) -> str: """Obtener el prompt del sistema con la personalidad de la marca.""" + # Si hay nuevo motor, usar su prompt + if self._use_new_engine: + try: + from app.services.ai import prompt_library + return prompt_library.get_system_prompt() + except Exception: + pass + + # Fallback al prompt original return f"""Eres el Community Manager de {settings.BUSINESS_NAME}, una empresa de tecnología ubicada en {settings.BUSINESS_LOCATION}. SOBRE LA EMPRESA: @@ -54,13 +108,70 @@ REGLAS: - Enfócate en ayudar, no en vender directamente - Adapta el contenido a cada plataforma""" + # === Métodos principales con nuevo motor === + async def generate_tip_tech( + self, + category: str, + platform: str, + template: Optional[str] = None, + db: Optional[Session] = None, + validate: bool = True + ) -> str: + """ + Generar un tip tech. + + Args: + category: Categoría del tip + platform: Plataforma destino + template: Template opcional (ignorado en v2) + db: Sesión de DB para context engine + validate: Si validar el contenido generado + + Returns: + Contenido del tip + """ + if self._use_new_engine: + result = await self._v2.generate_tip( + category=category, + platform=platform, + db=db + ) + + content = result["adapted_content"] + + # Validar y regenerar si es necesario + if validate and self._validator: + quality = await self._validator.evaluate(content, platform) + + if quality.final_decision == "regenerate": + # Regenerar con hints + hints = self._validator.get_regeneration_hints(quality) + result = await self._v2.generate( + template_name="tip_tech", + variables={ + "category": category, + "difficulty_level": "principiante", + "target_audience": "profesionales tech" + }, + platform=platform, + db=db, + temperature_override=0.9 # Más creatividad en retry + ) + content = result["adapted_content"] + + return content + + # Fallback a implementación original + return await self._generate_tip_tech_legacy(category, platform, template) + + async def _generate_tip_tech_legacy( self, category: str, platform: str, template: Optional[str] = None ) -> str: - """Generar un tip tech.""" + """Implementación original de generate_tip_tech.""" char_limits = { "x": 280, "threads": 500, @@ -97,11 +208,55 @@ Responde SOLO con el texto del post, sin explicaciones.""" return response.choices[0].message.content.strip() async def generate_product_post( + self, + product: Dict, + platform: str, + db: Optional[Session] = None, + validate: bool = True + ) -> str: + """ + Generar post para un producto. + + Args: + product: Dict con datos del producto + platform: Plataforma destino + db: Sesión de DB + validate: Si validar contenido + + Returns: + Contenido del post + """ + if self._use_new_engine: + result = await self._v2.generate_product_post( + product=product, + platform=platform, + db=db + ) + + content = result["adapted_content"] + + if validate and self._validator: + quality = await self._validator.evaluate(content, platform) + if quality.final_decision == "regenerate": + result = await self._v2.generate_product_post( + product=product, + platform=platform, + db=db, + temperature_override=0.9 + ) + content = result["adapted_content"] + + return content + + # Fallback a implementación original + return await self._generate_product_post_legacy(product, platform) + + async def _generate_product_post_legacy( self, product: Dict, platform: str ) -> str: - """Generar post para un producto.""" + """Implementación original de generate_product_post.""" char_limits = { "x": 280, "threads": 500, @@ -144,11 +299,55 @@ Responde SOLO con el texto del post.""" return response.choices[0].message.content.strip() async def generate_service_post( + self, + service: Dict, + platform: str, + db: Optional[Session] = None, + validate: bool = True + ) -> str: + """ + Generar post para un servicio. + + Args: + service: Dict con datos del servicio + platform: Plataforma destino + db: Sesión de DB + validate: Si validar contenido + + Returns: + Contenido del post + """ + if self._use_new_engine: + result = await self._v2.generate_service_post( + service=service, + platform=platform, + db=db + ) + + content = result["adapted_content"] + + if validate and self._validator: + quality = await self._validator.evaluate(content, platform) + if quality.final_decision == "regenerate": + result = await self._v2.generate_service_post( + service=service, + platform=platform, + db=db, + temperature_override=0.9 + ) + content = result["adapted_content"] + + return content + + # Fallback + return await self._generate_service_post_legacy(service, platform) + + async def _generate_service_post_legacy( self, service: Dict, platform: str ) -> str: - """Generar post para un servicio.""" + """Implementación original de generate_service_post.""" char_limits = { "x": 280, "threads": 500, @@ -191,11 +390,38 @@ Responde SOLO con el texto del post.""" return response.choices[0].message.content.strip() async def generate_thread( + self, + topic: str, + num_posts: int = 5, + db: Optional[Session] = None + ) -> List[str]: + """ + Generar un hilo educativo. + + Args: + topic: Tema del hilo + num_posts: Número de posts + db: Sesión de DB + + Returns: + Lista de posts del hilo + """ + if self._use_new_engine: + return await self._v2.generate_thread( + topic=topic, + num_posts=num_posts, + db=db + ) + + # Fallback + return await self._generate_thread_legacy(topic, num_posts) + + async def _generate_thread_legacy( self, topic: str, num_posts: int = 5 ) -> List[str]: - """Generar un hilo educativo.""" + """Implementación original de generate_thread.""" prompt = f"""Genera un hilo educativo de {num_posts} posts sobre: {topic} REQUISITOS: @@ -222,7 +448,6 @@ Responde con cada post en una línea separada, sin explicaciones.""" ) content = response.choices[0].message.content.strip() - # Separar posts por líneas no vacías posts = [p.strip() for p in content.split('\n') if p.strip()] return posts @@ -233,7 +458,36 @@ Responde con cada post en una línea separada, sin explicaciones.""" interaction_type: str, context: Optional[str] = None ) -> List[str]: - """Generar sugerencias de respuesta para una interacción.""" + """ + Generar sugerencias de respuesta para una interacción. + + Args: + interaction_content: Contenido de la interacción + interaction_type: Tipo de interacción + context: Contexto adicional + + Returns: + Lista de 3 opciones de respuesta + """ + if self._use_new_engine: + return await self._v2.generate_response( + interaction_content=interaction_content, + interaction_type=interaction_type, + context=context + ) + + # Fallback + return await self._generate_response_legacy( + interaction_content, interaction_type, context + ) + + async def _generate_response_legacy( + self, + interaction_content: str, + interaction_type: str, + context: Optional[str] = None + ) -> List[str]: + """Implementación original de generate_response_suggestion.""" prompt = f"""Un usuario escribió esto en redes sociales: "{interaction_content}" @@ -267,21 +521,46 @@ Responde con las 3 opciones numeradas, una por línea.""" content = response.choices[0].message.content.strip() suggestions = [s.strip() for s in content.split('\n') if s.strip()] - # Limpiar numeración si existe cleaned = [] for s in suggestions: if s[0].isdigit() and (s[1] == '.' or s[1] == ')'): s = s[2:].strip() cleaned.append(s) - return cleaned[:3] # Máximo 3 sugerencias + return cleaned[:3] async def adapt_content_for_platform( self, content: str, target_platform: str ) -> str: - """Adaptar contenido existente a una plataforma específica.""" + """ + Adaptar contenido existente a una plataforma específica. + + Args: + content: Contenido original + target_platform: Plataforma destino + + Returns: + Contenido adaptado + """ + if self._use_new_engine: + # Detectar plataforma de origen (asumimos la más genérica) + return await self._v2.adapt_content( + content=content, + source_platform="instagram", # Asume origen genérico + target_platform=target_platform + ) + + # Fallback + return await self._adapt_content_legacy(content, target_platform) + + async def _adapt_content_legacy( + self, + content: str, + target_platform: str + ) -> str: + """Implementación original de adapt_content_for_platform.""" char_limits = { "x": 280, "threads": 500, @@ -318,6 +597,119 @@ Responde SOLO con el contenido adaptado.""" return response.choices[0].message.content.strip() + # === Nuevos métodos (solo v2) === + + async def generate_with_quality_check( + self, + template_name: str, + variables: Dict[str, Any], + platform: str, + db: Optional[Session] = None, + max_attempts: int = 2 + ) -> Dict[str, Any]: + """ + Generar contenido con validación y regeneración automática. + + Solo disponible con el nuevo motor. + + Args: + template_name: Nombre del template + variables: Variables para el template + platform: Plataforma destino + db: Sesión de DB + max_attempts: Máximo intentos de regeneración + + Returns: + Dict con contenido, score, y metadata + """ + if not self._use_new_engine: + raise RuntimeError( + "Este método requiere el nuevo motor. " + "Asegúrate de que app.services.ai esté disponible." + ) + + attempt = 0 + temperature = 0.7 + + while attempt < max_attempts: + attempt += 1 + + # Generar + result = await self._v2.generate( + template_name=template_name, + variables=variables, + platform=platform, + db=db, + temperature_override=temperature + ) + + content = result["adapted_content"] + + # Evaluar calidad + quality = await self._validator.evaluate(content, platform) + + # Si pasa, retornar + if quality.final_decision == "accept": + return { + "content": content, + "quality_score": quality.scoring.total_score if quality.scoring else None, + "score_breakdown": quality.scoring.breakdown if quality.scoring else None, + "is_top_performer": quality.scoring.is_top_performer if quality.scoring else False, + "attempts": attempt, + "metadata": result["metadata"] + } + + # Si debe regenerar, aumentar temperature + temperature = min(1.0, temperature + 0.1) + + # Si llegamos aquí, usar el último intento aunque no sea ideal + return { + "content": content, + "quality_score": quality.scoring.total_score if quality.scoring else None, + "score_breakdown": quality.scoring.breakdown if quality.scoring else None, + "is_top_performer": False, + "attempts": attempt, + "metadata": result["metadata"], + "warning": "Contenido aceptado después de máximos intentos" + } + + async def save_to_memory( + self, + db: Session, + post_id: int, + content: str, + content_type: str, + platform: str, + quality_score: Optional[int] = None, + quality_breakdown: Optional[Dict] = None + ): + """ + Guardar contenido en memoria para tracking. + + Solo disponible con el nuevo motor. + + Args: + db: Sesión de DB + post_id: ID del post + content: Contenido generado + content_type: Tipo de contenido + platform: Plataforma + quality_score: Score de calidad + quality_breakdown: Breakdown del score + """ + if not self._use_new_engine: + return # Silenciosamente ignorar si no hay nuevo motor + + self._context.save_to_memory( + db=db, + post_id=post_id, + content=content, + content_type=content_type, + platform=platform, + quality_score=quality_score, + quality_breakdown=quality_breakdown + ) + # Instancia global content_generator = ContentGenerator() diff --git a/requirements.txt b/requirements.txt index 9c5e64f..22c1a8c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,10 +29,12 @@ html2image==2.0.4.3 # Utilidades python-dotenv==1.0.0 +PyYAML==6.0.1 pydantic==2.5.3 pydantic-settings==2.1.0 python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 # Estadísticas (A/B Testing) scipy==1.11.4 diff --git a/tests/test_ai_engine.py b/tests/test_ai_engine.py new file mode 100644 index 0000000..b9219df --- /dev/null +++ b/tests/test_ai_engine.py @@ -0,0 +1,447 @@ +""" +Tests para el Content Generation Engine v2. + +Tests para: +- PromptLibrary: Carga de YAMLs, renderizado +- ContextEngine: Anti-repetición, best performers +- PlatformAdapter: Adaptación por plataforma +- ContentValidator: Validación y scoring +""" + +import pytest +from pathlib import Path +from unittest.mock import MagicMock, patch, AsyncMock +import json + + +# ============================================================ +# PromptLibrary Tests +# ============================================================ + +class TestPromptLibrary: + """Tests para PromptLibrary.""" + + @pytest.fixture + def prompt_lib(self): + """Create PromptLibrary instance.""" + from app.services.ai.prompt_library import PromptLibrary + return PromptLibrary() + + def test_list_templates(self, prompt_lib): + """Test listado de templates disponibles.""" + templates = prompt_lib.list_templates() + + assert isinstance(templates, list) + assert "tip_tech" in templates + assert "product_post" in templates + assert "service_post" in templates + assert "thread" in templates + + def test_list_personalities(self, prompt_lib): + """Test listado de personalidades disponibles.""" + personalities = prompt_lib.list_personalities() + + assert isinstance(personalities, list) + assert "default" in personalities + assert "educational" in personalities + assert "promotional" in personalities + + def test_list_platforms(self, prompt_lib): + """Test listado de plataformas configuradas.""" + platforms = prompt_lib.list_platforms() + + assert isinstance(platforms, list) + assert "x" in platforms + assert "threads" in platforms + assert "instagram" in platforms + assert "facebook" in platforms + + def test_get_personality_default(self, prompt_lib): + """Test carga de personalidad default.""" + personality = prompt_lib.get_personality("default") + + assert "name" in personality + assert personality["name"] == "default" + assert "voice" in personality + assert "system_prompt" in personality + + def test_get_personality_with_inheritance(self, prompt_lib): + """Test que personalidades heredan correctamente.""" + educational = prompt_lib.get_personality("educational") + + # Debe tener propiedades de base (default) + assert "system_prompt" in educational + + # Debe tener propiedades propias + assert "teaching_techniques" in educational + + def test_get_template(self, prompt_lib): + """Test carga de template.""" + template = prompt_lib.get_template("tip_tech") + + assert "name" in template + assert template["name"] == "tip_tech" + assert "template" in template + assert "variables" in template + assert "parameters" in template + + def test_render_template_basic(self, prompt_lib): + """Test renderizado básico de template.""" + with patch('app.services.ai.prompt_library.settings') as mock_settings: + mock_settings.BUSINESS_NAME = "Test Corp" + mock_settings.BUSINESS_LOCATION = "Test City" + mock_settings.BUSINESS_WEBSITE = "test.com" + mock_settings.CONTENT_TONE = "Professional" + + rendered = prompt_lib.render_template( + "tip_tech", + {"category": "productividad"} + ) + + assert "system_prompt" in rendered + assert "user_prompt" in rendered + assert "parameters" in rendered + assert "productividad" in rendered["user_prompt"] + + def test_render_template_with_defaults(self, prompt_lib): + """Test que variables con defaults funcionan.""" + with patch('app.services.ai.prompt_library.settings') as mock_settings: + mock_settings.BUSINESS_NAME = "Test Corp" + mock_settings.BUSINESS_LOCATION = "Test City" + mock_settings.BUSINESS_WEBSITE = "test.com" + mock_settings.CONTENT_TONE = "Professional" + + # Solo category es requerida, difficulty_level tiene default + rendered = prompt_lib.render_template( + "tip_tech", + {"category": "ia"} + ) + + # Debe funcionar sin error y usar default + assert "user_prompt" in rendered + + def test_get_platform_config(self, prompt_lib): + """Test carga de configuración de plataforma.""" + config = prompt_lib.get_platform_config("x") + + assert "platform" in config + assert config["platform"] == "x" + assert "limits" in config + assert "max_characters" in config["limits"] + assert config["limits"]["max_characters"] == 280 + + def test_get_platform_limits(self, prompt_lib): + """Test obtención de límites de plataforma.""" + limits = prompt_lib.get_platform_limits("instagram") + + assert "max_characters" in limits + assert limits["max_characters"] == 2200 + assert "max_hashtags" in limits + + def test_cache_works(self, prompt_lib): + """Test que el cache evita lecturas repetidas.""" + # Primera carga + prompt_lib.get_template("tip_tech") + + # Debe estar en cache + assert any("tip_tech" in key for key in prompt_lib._cache.keys()) + + # Segunda carga usa cache (no debería fallar) + template = prompt_lib.get_template("tip_tech") + assert template["name"] == "tip_tech" + + def test_clear_cache(self, prompt_lib): + """Test limpieza de cache.""" + prompt_lib.get_template("tip_tech") + assert len(prompt_lib._cache) > 0 + + prompt_lib.clear_cache() + assert len(prompt_lib._cache) == 0 + + +# ============================================================ +# PlatformAdapter Tests +# ============================================================ + +class TestPlatformAdapter: + """Tests para PlatformAdapter.""" + + @pytest.fixture + def adapter(self): + """Create PlatformAdapter instance.""" + from app.services.ai.platform_adapter import PlatformAdapter + return PlatformAdapter() + + def test_get_limits(self, adapter): + """Test obtención de límites.""" + limits = adapter.get_limits("x") + + assert limits["max_characters"] == 280 + assert limits["max_hashtags"] == 2 + + def test_adapt_short_content(self, adapter): + """Test adaptación de contenido corto.""" + content = "Tip corto #Tech" + result = adapter.adapt(content, "x") + + assert result.content == content.strip() + assert not result.truncated + assert result.platform == "x" + + def test_adapt_long_content_truncates(self, adapter): + """Test que contenido largo se trunca.""" + # Contenido de 400 caracteres + content = "Este es un contenido muy largo. " * 15 + "#Tech #AI" + + result = adapter.adapt(content, "x") + + assert len(result.content) <= 280 + assert result.truncated + assert "Contenido truncado" in str(result.changes_made) + + def test_adapt_reduces_hashtags(self, adapter): + """Test que hashtags se reducen al límite.""" + content = "Tip importante #Tech #AI #Python #Tips #Code #Dev" + + result = adapter.adapt(content, "x") + + # X solo permite 2 hashtags + hashtag_count = result.content.count("#") + assert hashtag_count <= 2 + assert result.hashtags_adjusted + + def test_validate_for_platform_valid(self, adapter): + """Test validación de contenido válido.""" + content = "Tip corto #Tech" + validation = adapter.validate_for_platform(content, "x") + + assert validation["valid"] + assert len(validation["issues"]) == 0 + + def test_validate_for_platform_too_long(self, adapter): + """Test validación de contenido muy largo.""" + content = "X" * 300 # Excede límite de X + + validation = adapter.validate_for_platform(content, "x") + + assert not validation["valid"] + assert any(i["type"] == "length" for i in validation["issues"]) + + def test_adapt_for_all_platforms(self, adapter): + """Test adaptación para múltiples plataformas.""" + content = "Contenido de prueba con mucho texto. " * 5 + "#Tech" + + results = adapter.adapt_for_all_platforms( + content, + ["x", "threads", "instagram"] + ) + + assert "x" in results + assert "threads" in results + assert "instagram" in results + + # X debe ser más corto + assert len(results["x"].content) <= len(results["instagram"].content) + + +# ============================================================ +# ContextEngine Tests +# ============================================================ + +class TestContextEngine: + """Tests para ContextEngine.""" + + @pytest.fixture + def context_engine(self): + """Create ContextEngine instance.""" + from app.services.ai.context_engine import ContextEngine + return ContextEngine() + + def test_analyze_content_detects_hook(self, context_engine): + """Test detección de tipo de hook.""" + # Pregunta retórica + content1 = "¿Sabías que el 90% de developers usan IA? #Tech" + analysis1 = context_engine.analyze_content(content1, "tip_tech", "x") + assert analysis1["hook_type"] == "pregunta_retórica" + + # Dato impactante + content2 = "El 73% de empresas ya usan IA. #Tech" + analysis2 = context_engine.analyze_content(content2, "tip_tech", "x") + assert analysis2["hook_type"] == "dato_impactante" + + # Tip directo + content3 = "Tip: usa ChatGPT para debugging. #Tech" + analysis3 = context_engine.analyze_content(content3, "tip_tech", "x") + assert analysis3["hook_type"] == "tip_directo" + + def test_analyze_content_extracts_topics(self, context_engine): + """Test extracción de temas.""" + content = "ChatGPT y Claude son herramientas de IA geniales para productividad. #Tech" + analysis = context_engine.analyze_content(content, "tip_tech", "x") + + assert "ia" in analysis["topics"] + assert "productividad" in analysis["topics"] + + def test_suggest_hook_type_varies(self, context_engine): + """Test que sugiere hooks variados.""" + # Sin DB, debería retornar el primer preferido + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [] + + suggested = context_engine.suggest_hook_type(mock_db) + + # Debería ser uno de los hooks preferidos + assert suggested in [ + "pregunta_retórica", "dato_impactante", "tip_directo", + "afirmación_bold", "historia_corta" + ] + + +# ============================================================ +# ContentValidator Tests +# ============================================================ + +class TestContentValidator: + """Tests para ContentValidator.""" + + @pytest.fixture + def validator(self): + """Create ContentValidator instance.""" + from app.services.ai.validator import ContentValidator + return ContentValidator() + + def test_validate_length_passes(self, validator): + """Test validación de longitud válida.""" + content = "Contenido corto #Tech" + result = validator.validate(content, "x") + + assert result.passed + assert not any(i["type"] == "length" for i in result.issues) + + def test_validate_length_fails(self, validator): + """Test validación de longitud excedida.""" + content = "X" * 300 # Excede 280 de X + result = validator.validate(content, "x") + + assert not result.passed + assert any(i["type"] == "length" and i["severity"] == "error" for i in result.issues) + + def test_validate_prohibited_content(self, validator): + """Test validación de contenido prohibido.""" + # Modificar config temporalmente para test + validator.config["validations"]["prohibited_content"]["prohibited_words"] = ["test_banned"] + + content = "Este contenido tiene test_banned palabra" + result = validator.validate(content, "x") + + assert not result.passed + assert any(i["type"] == "prohibited_content" for i in result.issues) + + def test_validate_empty_content_fails(self, validator): + """Test que contenido vacío falla.""" + content = " " + result = validator.validate(content, "x") + + assert not result.passed + assert any(i["type"] == "empty_content" for i in result.issues) + + def test_should_regenerate_respects_max_attempts(self, validator): + """Test que regeneración respeta máximo de intentos.""" + from app.services.ai.validator import ContentQualityResult, ValidationResult, ScoringResult + + mock_quality = ContentQualityResult( + validation=ValidationResult(passed=True, issues=[], content="test"), + scoring=ScoringResult( + total_score=55, + breakdown={}, + feedback="Needs improvement", + is_top_performer=False, + action="regenerate" + ), + final_decision="regenerate", + content="test" + ) + + # Primer intento: debería regenerar + assert validator.should_regenerate(mock_quality, attempt=1) + + # Segundo intento: debería regenerar + assert validator.should_regenerate(mock_quality, attempt=2) + + # Tercer intento: NO debería regenerar (max_attempts=2) + assert not validator.should_regenerate(mock_quality, attempt=3) + + +# ============================================================ +# Integration Tests +# ============================================================ + +class TestAIEngineIntegration: + """Tests de integración del motor completo.""" + + @pytest.mark.asyncio + async def test_full_generation_flow_mocked(self, mock_openai_client): + """Test flujo completo de generación con mock.""" + with patch('app.services.ai.generator.settings') as mock_settings: + mock_settings.DEEPSEEK_API_KEY = "test-key" + mock_settings.DEEPSEEK_BASE_URL = "https://api.deepseek.com" + mock_settings.BUSINESS_NAME = "Test Corp" + mock_settings.BUSINESS_LOCATION = "Test City" + mock_settings.BUSINESS_WEBSITE = "test.com" + mock_settings.CONTENT_TONE = "Professional" + + from app.services.ai.generator import ContentGeneratorV2 + + generator = ContentGeneratorV2() + generator._client = mock_openai_client + + result = await generator.generate( + template_name="tip_tech", + variables={"category": "productividad"}, + platform="x", + use_context=False, + use_few_shot=False + ) + + assert "content" in result + assert "adapted_content" in result + assert "metadata" in result + assert result["metadata"]["template"] == "tip_tech" + assert result["metadata"]["platform"] == "x" + + def test_prompt_library_to_adapter_flow(self): + """Test flujo de PromptLibrary a PlatformAdapter.""" + from app.services.ai.prompt_library import PromptLibrary + from app.services.ai.platform_adapter import PlatformAdapter + + lib = PromptLibrary() + adapter = PlatformAdapter() + + # Obtener límites de plataforma desde library + limits_from_lib = lib.get_platform_limits("x") + + # Obtener límites desde adapter + limits_from_adapter = adapter.get_limits("x") + + # Deberían ser consistentes + assert limits_from_lib["max_characters"] == limits_from_adapter["max_characters"] + + +# ============================================================ +# Fixtures adicionales +# ============================================================ + +@pytest.fixture +def mock_openai_client(): + """Mock OpenAI client for DeepSeek API tests.""" + mock_client = MagicMock() + + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Generated test content #TechTip #AI" + mock_response.usage = MagicMock() + mock_response.usage.total_tokens = 100 + + mock_client.chat.completions.create.return_value = mock_response + + return mock_client diff --git a/worker/celery_app.py b/worker/celery_app.py index 65d28ef..7261fea 100644 --- a/worker/celery_app.py +++ b/worker/celery_app.py @@ -16,7 +16,8 @@ celery_app = Celery( "worker.tasks.generate_content", "worker.tasks.publish_post", "worker.tasks.fetch_interactions", - "worker.tasks.cleanup" + "worker.tasks.cleanup", + "worker.tasks.content_memory" ] ) @@ -58,4 +59,22 @@ celery_app.conf.beat_schedule = { "task": "worker.tasks.cleanup.daily_cleanup", "schedule": crontab(hour=3, minute=0), }, + + # Actualizar engagement scores cada 6 horas + "update-engagement-scores": { + "task": "worker.tasks.content_memory.update_engagement_scores", + "schedule": crontab(hour="*/6", minute=15), + }, + + # Limpiar memoria antigua mensualmente + "cleanup-old-memory": { + "task": "worker.tasks.content_memory.cleanup_old_memory", + "schedule": crontab(day_of_month=1, hour=4, minute=0), + }, + + # Actualizar best_posts.yaml semanalmente + "refresh-best-posts": { + "task": "worker.tasks.content_memory.refresh_best_posts_yaml", + "schedule": crontab(day_of_week=0, hour=5, minute=0), # Domingos 5 AM + }, } diff --git a/worker/tasks/content_memory.py b/worker/tasks/content_memory.py new file mode 100644 index 0000000..cbaa5f5 --- /dev/null +++ b/worker/tasks/content_memory.py @@ -0,0 +1,342 @@ +""" +Tareas de gestión de memoria de contenido. + +Incluye: +- Actualización periódica de engagement scores +- Marcado de top performers +- Sincronización de métricas desde posts +- Limpieza de memoria antigua +""" + +import logging +from datetime import datetime, timedelta +from typing import Optional + +from worker.celery_app import celery_app +from app.core.database import SessionLocal +from app.models.post import Post +from app.models.post_metrics import PostMetrics +from app.models.content_memory import ContentMemory + +logger = logging.getLogger(__name__) + + +@celery_app.task(name="worker.tasks.content_memory.update_engagement_scores") +def update_engagement_scores(): + """ + Actualizar scores de engagement basado en métricas reales. + + Esta tarea: + 1. Obtiene métricas recientes de posts + 2. Calcula engagement score normalizado + 3. Actualiza ContentMemory + 4. Marca top performers + + Se ejecuta cada 6 horas. + """ + db = SessionLocal() + + try: + updated_count = 0 + new_top_performers = 0 + + # Obtener posts con métricas que tengan ContentMemory + memories = db.query(ContentMemory).filter( + ContentMemory.created_at >= datetime.utcnow() - timedelta(days=90) + ).all() + + for memory in memories: + # Obtener el post asociado + post = db.query(Post).filter(Post.id == memory.post_id).first() + if not post or not post.metrics: + continue + + # Calcular engagement score + metrics = post.metrics + old_score = memory.engagement_score + + memory.update_engagement(metrics) + updated_count += 1 + + # Logging si cambió significativamente + if old_score and abs((memory.engagement_score or 0) - old_score) > 10: + logger.info( + f"Post {post.id} engagement cambió: {old_score:.1f} -> {memory.engagement_score:.1f}" + ) + + db.commit() + + # Recalcular top performers + new_top_performers = _recalculate_top_performers(db) + + logger.info( + f"Engagement actualizado: {updated_count} posts, " + f"{new_top_performers} nuevos top performers" + ) + + return { + "updated": updated_count, + "new_top_performers": new_top_performers + } + + except Exception as e: + logger.error(f"Error actualizando engagement: {e}") + db.rollback() + raise + + finally: + db.close() + + +def _recalculate_top_performers(db, top_percentile: int = 20) -> int: + """ + Recalcular qué posts son top performers. + + Args: + db: Sesión de DB + top_percentile: Percentil para considerar top performer + + Returns: + Número de nuevos top performers marcados + """ + # Obtener todos los scores + all_scores = db.query(ContentMemory.engagement_score).filter( + ContentMemory.engagement_score.isnot(None) + ).all() + + if not all_scores: + return 0 + + scores = sorted([s[0] for s in all_scores], reverse=True) + + # Calcular umbral + threshold_idx = max(0, int(len(scores) * top_percentile / 100) - 1) + threshold_score = scores[threshold_idx] + + # Marcar nuevos top performers + new_tops = db.query(ContentMemory).filter( + ContentMemory.engagement_score >= threshold_score, + ContentMemory.is_top_performer == False + ).update({"is_top_performer": True}) + + # Desmarcar los que ya no califican + db.query(ContentMemory).filter( + ContentMemory.engagement_score < threshold_score, + ContentMemory.is_top_performer == True + ).update({"is_top_performer": False}) + + db.commit() + + return new_tops + + +@celery_app.task(name="worker.tasks.content_memory.sync_post_metrics") +def sync_post_metrics(post_id: int): + """ + Sincronizar métricas de un post específico a ContentMemory. + + Se llama después de obtener nuevas métricas. + + Args: + post_id: ID del post + """ + db = SessionLocal() + + try: + post = db.query(Post).filter(Post.id == post_id).first() + if not post or not post.metrics: + return {"status": "skipped", "reason": "no_metrics"} + + memory = db.query(ContentMemory).filter( + ContentMemory.post_id == post_id + ).first() + + if not memory: + return {"status": "skipped", "reason": "no_memory"} + + memory.update_engagement(post.metrics) + db.commit() + + return { + "status": "updated", + "post_id": post_id, + "engagement_score": memory.engagement_score + } + + finally: + db.close() + + +@celery_app.task(name="worker.tasks.content_memory.analyze_and_save_content") +def analyze_and_save_content( + post_id: int, + content: str, + content_type: str, + platform: str, + quality_score: Optional[int] = None, + quality_breakdown: Optional[dict] = None, + template_used: Optional[str] = None, + personality_used: Optional[str] = None +): + """ + Analizar contenido generado y guardarlo en memoria. + + Se llama después de generar un nuevo post. + + Args: + post_id: ID del post creado + content: Contenido generado + content_type: Tipo de contenido + platform: Plataforma principal + quality_score: Score de calidad (si se evaluó) + quality_breakdown: Breakdown del score + template_used: Template usado + personality_used: Personalidad usada + """ + db = SessionLocal() + + try: + # Verificar que no exista ya + existing = db.query(ContentMemory).filter( + ContentMemory.post_id == post_id + ).first() + + if existing: + return {"status": "skipped", "reason": "already_exists"} + + # Importar context engine para análisis + from app.services.ai import context_engine + + # Guardar en memoria + memory = context_engine.save_to_memory( + db=db, + post_id=post_id, + content=content, + content_type=content_type, + platform=platform, + quality_score=quality_score, + quality_breakdown=quality_breakdown, + template_used=template_used, + personality_used=personality_used + ) + + return { + "status": "created", + "memory_id": memory.id, + "topics": memory.topics, + "hook_type": memory.hook_type + } + + except Exception as e: + logger.error(f"Error guardando en memoria: {e}") + db.rollback() + raise + + finally: + db.close() + + +@celery_app.task(name="worker.tasks.content_memory.cleanup_old_memory") +def cleanup_old_memory(days_to_keep: int = 180): + """ + Limpiar registros de memoria antiguos. + + Mantiene top performers indefinidamente. + + Args: + days_to_keep: Días de registros a mantener (excepto top performers) + """ + db = SessionLocal() + + try: + cutoff_date = datetime.utcnow() - timedelta(days=days_to_keep) + + # Eliminar registros viejos que NO son top performers + deleted = db.query(ContentMemory).filter( + ContentMemory.created_at < cutoff_date, + ContentMemory.is_top_performer == False + ).delete() + + db.commit() + + logger.info(f"Limpieza de memoria: {deleted} registros eliminados") + + return {"deleted": deleted} + + finally: + db.close() + + +@celery_app.task(name="worker.tasks.content_memory.refresh_best_posts_yaml") +def refresh_best_posts_yaml(): + """ + Actualizar el archivo best_posts.yaml con top performers reales. + + Se ejecuta semanalmente para mantener ejemplos actualizados. + """ + import yaml + from pathlib import Path + + db = SessionLocal() + + try: + # Obtener top performers por tipo de contenido + content_types = ["tip_tech", "producto", "servicio"] + examples = {} + + for content_type in content_types: + top = db.query(ContentMemory).filter( + ContentMemory.content_type == content_type, + ContentMemory.is_top_performer == True + ).order_by( + ContentMemory.engagement_score.desc() + ).limit(5).all() + + if top: + examples[content_type] = [] + for mem in top: + post = db.query(Post).filter(Post.id == mem.post_id).first() + if post: + examples[content_type].append({ + "content": post.content, + "platform": mem.platform, + "engagement_score": mem.engagement_score, + "metrics": post.metrics, + "analysis": { + "hook_type": mem.hook_type, + "topics": mem.topics + } + }) + + if not examples: + return {"status": "skipped", "reason": "no_top_performers"} + + # Actualizar archivo YAML + yaml_path = Path(__file__).parent.parent.parent / "app" / "prompts" / "examples" / "best_posts.yaml" + + # Cargar archivo existente + with open(yaml_path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + + # Actualizar ejemplos + data["examples"] = examples + data["metadata"]["last_updated"] = datetime.utcnow().isoformat() + data["metadata"]["auto_update"] = True + + # Guardar + with open(yaml_path, "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False, allow_unicode=True) + + logger.info(f"best_posts.yaml actualizado con {sum(len(v) for v in examples.values())} ejemplos") + + return { + "status": "updated", + "examples_by_type": {k: len(v) for k, v in examples.items()} + } + + except Exception as e: + logger.error(f"Error actualizando best_posts.yaml: {e}") + raise + + finally: + db.close() diff --git a/worker/tasks/generate_content.py b/worker/tasks/generate_content.py index b08df50..5cb7687 100644 --- a/worker/tasks/generate_content.py +++ b/worker/tasks/generate_content.py @@ -1,9 +1,16 @@ """ Tareas de generación de contenido. + +Usa el nuevo Content Generation Engine v2 con: +- Quality scoring +- Anti-repetición via Context Engine +- Almacenamiento en Content Memory """ import asyncio +import logging from datetime import datetime, timedelta +from typing import Optional, Dict, Any from worker.celery_app import celery_app from app.core.database import SessionLocal @@ -14,6 +21,8 @@ from app.models.product import Product from app.models.service import Service from app.services.content_generator import content_generator +logger = logging.getLogger(__name__) + def run_async(coro): """Helper para ejecutar coroutines en Celery.""" @@ -21,6 +30,31 @@ def run_async(coro): return loop.run_until_complete(coro) +def _save_to_memory( + post_id: int, + content: str, + content_type: str, + platform: str, + quality_score: Optional[int] = None, + quality_breakdown: Optional[Dict] = None, + template_used: Optional[str] = None +): + """ + Encolar guardado en memoria como tarea async. + Evita bloquear la generación principal. + """ + from worker.tasks.content_memory import analyze_and_save_content + analyze_and_save_content.delay( + post_id=post_id, + content=content, + content_type=content_type, + platform=platform, + quality_score=quality_score, + quality_breakdown=quality_breakdown, + template_used=template_used + ) + + @celery_app.task(name="worker.tasks.generate_content.generate_scheduled_content") def generate_scheduled_content(): """ @@ -40,6 +74,7 @@ def generate_scheduled_content(): ContentCalendar.is_active == True ).all() + generated = 0 for entry in entries: # Verificar si es la hora correcta if entry.time.hour != current_hour: @@ -52,20 +87,23 @@ def generate_scheduled_content(): category_filter=entry.category_filter, requires_approval=entry.requires_approval ) + generated += 1 elif entry.content_type == "producto": generate_product_post.delay( platforms=entry.platforms, requires_approval=entry.requires_approval ) + generated += 1 elif entry.content_type == "servicio": generate_service_post.delay( platforms=entry.platforms, requires_approval=entry.requires_approval ) + generated += 1 - return f"Procesadas {len(entries)} entradas del calendario" + return f"Procesadas {len(entries)} entradas, {generated} generaciones iniciadas" finally: db.close() @@ -75,9 +113,18 @@ def generate_scheduled_content(): def generate_tip_post( platforms: list, category_filter: str = None, - requires_approval: bool = False + requires_approval: bool = False, + use_quality_check: bool = True ): - """Generar un post de tip tech.""" + """ + Generar un post de tip tech con quality scoring. + + Args: + platforms: Lista de plataformas destino + category_filter: Categoría específica (opcional) + requires_approval: Si requiere aprobación manual + use_quality_check: Si usar validación de calidad + """ db = SessionLocal() try: @@ -96,23 +143,66 @@ def generate_tip_post( ).first() if not tip: - return "No hay tips disponibles" + return {"status": "skipped", "reason": "no_tips_available"} # Generar contenido para cada plataforma content_by_platform = {} + quality_info = {} + for platform in platforms: - content = run_async( - content_generator.generate_tip_tech( - category=tip.category, - platform=platform, - template=tip.template + if use_quality_check: + # Usar generación con validación de calidad + result = run_async( + content_generator.generate_with_quality_check( + template_name="tip_tech", + variables={ + "category": tip.category, + "difficulty_level": "principiante", + "target_audience": "profesionales tech" + }, + platform=platform, + db=db, + max_attempts=2 + ) ) - ) - content_by_platform[platform] = content + content_by_platform[platform] = result["content"] + quality_info[platform] = { + "score": result.get("quality_score"), + "breakdown": result.get("score_breakdown"), + "attempts": result.get("attempts", 1) + } + else: + # Generación simple (fallback) + content = run_async( + content_generator.generate_tip_tech( + category=tip.category, + platform=platform, + template=tip.template, + db=db + ) + ) + content_by_platform[platform] = content + + # Obtener mejor score entre plataformas + best_score = None + best_breakdown = None + total_attempts = 1 + + if quality_info: + scores = [q.get("score") for q in quality_info.values() if q.get("score")] + if scores: + best_score = max(scores) + breakdowns = [q.get("breakdown") for q in quality_info.values() if q.get("breakdown")] + if breakdowns: + best_breakdown = breakdowns[0] + attempts = [q.get("attempts", 1) for q in quality_info.values()] + if attempts: + total_attempts = max(attempts) # Crear post + main_platform = platforms[0] post = Post( - content=content_by_platform.get(platforms[0], ""), + content=content_by_platform.get(main_platform, ""), content_type="tip_tech", platforms=platforms, content_x=content_by_platform.get("x"), @@ -122,7 +212,10 @@ def generate_tip_post( status="pending_approval" if requires_approval else "scheduled", scheduled_at=datetime.utcnow() + timedelta(minutes=5), approval_required=requires_approval, - tip_template_id=tip.id + tip_template_id=tip.id, + quality_score=best_score, + score_breakdown=best_breakdown, + generation_attempts=total_attempts ) db.add(post) @@ -133,7 +226,33 @@ def generate_tip_post( db.commit() - return f"Post de tip generado: {post.id}" + # Guardar en memoria (async) + _save_to_memory( + post_id=post.id, + content=post.content, + content_type="tip_tech", + platform=main_platform, + quality_score=best_score, + quality_breakdown=best_breakdown, + template_used="tip_tech" + ) + + logger.info( + f"Tip generado: post_id={post.id}, score={best_score}, " + f"attempts={total_attempts}, category={tip.category}" + ) + + return { + "status": "success", + "post_id": post.id, + "quality_score": best_score, + "attempts": total_attempts + } + + except Exception as e: + logger.error(f"Error generando tip: {e}") + db.rollback() + raise finally: db.close() @@ -143,9 +262,18 @@ def generate_tip_post( def generate_product_post( platforms: list, product_id: int = None, - requires_approval: bool = True + requires_approval: bool = True, + use_quality_check: bool = True ): - """Generar un post de producto.""" + """ + Generar un post de producto con quality scoring. + + Args: + platforms: Lista de plataformas destino + product_id: ID del producto específico (opcional) + requires_approval: Si requiere aprobación manual + use_quality_check: Si usar validación de calidad + """ db = SessionLocal() try: @@ -161,22 +289,66 @@ def generate_product_post( ).first() if not product: - return "No hay productos disponibles" + return {"status": "skipped", "reason": "no_products_available"} # Generar contenido content_by_platform = {} + quality_info = {} + for platform in platforms: - content = run_async( - content_generator.generate_product_post( - product=product.to_dict(), - platform=platform + if use_quality_check: + result = run_async( + content_generator.generate_with_quality_check( + template_name="product_post", + variables={ + "product_name": product.name, + "product_description": product.description or "", + "price": product.price, + "category": product.category, + "specs": product.specs or {}, + "highlights": product.highlights or [] + }, + platform=platform, + db=db, + max_attempts=2 + ) ) - ) - content_by_platform[platform] = content + content_by_platform[platform] = result["content"] + quality_info[platform] = { + "score": result.get("quality_score"), + "breakdown": result.get("score_breakdown"), + "attempts": result.get("attempts", 1) + } + else: + content = run_async( + content_generator.generate_product_post( + product=product.to_dict(), + platform=platform, + db=db + ) + ) + content_by_platform[platform] = content + + # Obtener mejor score + best_score = None + best_breakdown = None + total_attempts = 1 + + if quality_info: + scores = [q.get("score") for q in quality_info.values() if q.get("score")] + if scores: + best_score = max(scores) + breakdowns = [q.get("breakdown") for q in quality_info.values() if q.get("breakdown")] + if breakdowns: + best_breakdown = breakdowns[0] + attempts = [q.get("attempts", 1) for q in quality_info.values()] + if attempts: + total_attempts = max(attempts) # Crear post + main_platform = platforms[0] post = Post( - content=content_by_platform.get(platforms[0], ""), + content=content_by_platform.get(main_platform, ""), content_type="producto", platforms=platforms, content_x=content_by_platform.get("x"), @@ -187,7 +359,10 @@ def generate_product_post( scheduled_at=datetime.utcnow() + timedelta(minutes=5), approval_required=requires_approval, product_id=product.id, - image_url=product.main_image + image_url=product.main_image, + quality_score=best_score, + score_breakdown=best_breakdown, + generation_attempts=total_attempts ) db.add(post) @@ -197,7 +372,33 @@ def generate_product_post( db.commit() - return f"Post de producto generado: {post.id}" + # Guardar en memoria + _save_to_memory( + post_id=post.id, + content=post.content, + content_type="producto", + platform=main_platform, + quality_score=best_score, + quality_breakdown=best_breakdown, + template_used="product_post" + ) + + logger.info( + f"Producto generado: post_id={post.id}, product={product.name}, " + f"score={best_score}" + ) + + return { + "status": "success", + "post_id": post.id, + "product_id": product.id, + "quality_score": best_score + } + + except Exception as e: + logger.error(f"Error generando producto: {e}") + db.rollback() + raise finally: db.close() @@ -207,9 +408,18 @@ def generate_product_post( def generate_service_post( platforms: list, service_id: int = None, - requires_approval: bool = True + requires_approval: bool = True, + use_quality_check: bool = True ): - """Generar un post de servicio.""" + """ + Generar un post de servicio con quality scoring. + + Args: + platforms: Lista de plataformas destino + service_id: ID del servicio específico (opcional) + requires_approval: Si requiere aprobación manual + use_quality_check: Si usar validación de calidad + """ db = SessionLocal() try: @@ -224,22 +434,66 @@ def generate_service_post( ).first() if not service: - return "No hay servicios disponibles" + return {"status": "skipped", "reason": "no_services_available"} # Generar contenido content_by_platform = {} + quality_info = {} + for platform in platforms: - content = run_async( - content_generator.generate_service_post( - service=service.to_dict(), - platform=platform + if use_quality_check: + result = run_async( + content_generator.generate_with_quality_check( + template_name="service_post", + variables={ + "service_name": service.name, + "service_description": service.description or "", + "category": service.category, + "target_sectors": service.target_sectors or [], + "benefits": service.benefits or [], + "call_to_action": service.call_to_action or "Contáctanos" + }, + platform=platform, + db=db, + max_attempts=2 + ) ) - ) - content_by_platform[platform] = content + content_by_platform[platform] = result["content"] + quality_info[platform] = { + "score": result.get("quality_score"), + "breakdown": result.get("score_breakdown"), + "attempts": result.get("attempts", 1) + } + else: + content = run_async( + content_generator.generate_service_post( + service=service.to_dict(), + platform=platform, + db=db + ) + ) + content_by_platform[platform] = content + + # Obtener mejor score + best_score = None + best_breakdown = None + total_attempts = 1 + + if quality_info: + scores = [q.get("score") for q in quality_info.values() if q.get("score")] + if scores: + best_score = max(scores) + breakdowns = [q.get("breakdown") for q in quality_info.values() if q.get("breakdown")] + if breakdowns: + best_breakdown = breakdowns[0] + attempts = [q.get("attempts", 1) for q in quality_info.values()] + if attempts: + total_attempts = max(attempts) # Crear post + main_platform = platforms[0] post = Post( - content=content_by_platform.get(platforms[0], ""), + content=content_by_platform.get(main_platform, ""), content_type="servicio", platforms=platforms, content_x=content_by_platform.get("x"), @@ -250,7 +504,10 @@ def generate_service_post( scheduled_at=datetime.utcnow() + timedelta(minutes=5), approval_required=requires_approval, service_id=service.id, - image_url=service.main_image + image_url=service.main_image, + quality_score=best_score, + score_breakdown=best_breakdown, + generation_attempts=total_attempts ) db.add(post) @@ -260,7 +517,106 @@ def generate_service_post( db.commit() - return f"Post de servicio generado: {post.id}" + # Guardar en memoria + _save_to_memory( + post_id=post.id, + content=post.content, + content_type="servicio", + platform=main_platform, + quality_score=best_score, + quality_breakdown=best_breakdown, + template_used="service_post" + ) + + logger.info( + f"Servicio generado: post_id={post.id}, service={service.name}, " + f"score={best_score}" + ) + + return { + "status": "success", + "post_id": post.id, + "service_id": service.id, + "quality_score": best_score + } + + except Exception as e: + logger.error(f"Error generando servicio: {e}") + db.rollback() + raise + + finally: + db.close() + + +@celery_app.task(name="worker.tasks.generate_content.generate_thread") +def generate_thread( + topic: str, + num_posts: int = 5, + requires_approval: bool = True +): + """ + Generar un hilo educativo. + + Args: + topic: Tema del hilo + num_posts: Número de posts + requires_approval: Si requiere aprobación + """ + db = SessionLocal() + + try: + # Generar hilo + posts = run_async( + content_generator.generate_thread( + topic=topic, + num_posts=num_posts, + db=db + ) + ) + + if not posts: + return {"status": "error", "reason": "no_posts_generated"} + + # Crear posts individuales (vinculados) + created_posts = [] + for i, content in enumerate(posts): + post = Post( + content=content, + content_type="hilo_educativo", + platforms=["x"], # Hilos principalmente para X + content_x=content, + status="pending_approval" if requires_approval else "scheduled", + scheduled_at=datetime.utcnow() + timedelta(minutes=5 + i), # Escalonado + approval_required=requires_approval, + ) + db.add(post) + created_posts.append(post) + + db.commit() + + # Guardar primer post en memoria (representa el hilo) + if created_posts: + _save_to_memory( + post_id=created_posts[0].id, + content="\n\n".join(posts), + content_type="hilo_educativo", + platform="x", + template_used="thread" + ) + + logger.info(f"Hilo generado: {len(created_posts)} posts sobre '{topic}'") + + return { + "status": "success", + "post_ids": [p.id for p in created_posts], + "topic": topic + } + + except Exception as e: + logger.error(f"Error generando hilo: {e}") + db.rollback() + raise finally: db.close()