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 <noreply@anthropic.com>
This commit is contained in:
2026-01-28 20:55:28 +00:00
parent f458f809ca
commit 11b0ba46fa
36 changed files with 6266 additions and 55 deletions

View File

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

View File

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

View File

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

124
app/config/quality.yaml Normal file
View File

@@ -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": <número 0-100>,
"breakdown": {
"hook_strength": <número 0-25>,
"clarity": <número 0-20>,
"actionability": <número 0-20>,
"originality": <número 0-15>,
"brand_voice": <número 0-10>,
"cta_effectiveness": <número 0-10>
},
"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

View File

@@ -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"])

View File

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

View File

@@ -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"<ContentMemory post_id={self.post_id} score={self.engagement_score}>"
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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

447
tests/test_ai_engine.py Normal file
View File

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

View File

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

View File

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

View File

@@ -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:
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] = 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
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:
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] = 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
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:
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] = 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
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()