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

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