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:
24
app/services/ai/__init__.py
Normal file
24
app/services/ai/__init__.py
Normal 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",
|
||||
]
|
||||
519
app/services/ai/context_engine.py
Normal file
519
app/services/ai/context_engine.py
Normal 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()
|
||||
493
app/services/ai/generator.py
Normal file
493
app/services/ai/generator.py
Normal 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()
|
||||
374
app/services/ai/platform_adapter.py
Normal file
374
app/services/ai/platform_adapter.py
Normal 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()
|
||||
353
app/services/ai/prompt_library.py
Normal file
353
app/services/ai/prompt_library.py
Normal 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()
|
||||
479
app/services/ai/validator.py
Normal file
479
app/services/ai/validator.py
Normal 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()
|
||||
Reference in New Issue
Block a user