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:
@@ -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
|
||||
|
||||
100
alembic/versions/20260128_add_content_memory_and_quality.py
Normal file
100
alembic/versions/20260128_add_content_memory_and_quality.py
Normal 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')
|
||||
617
app/api/routes/generate_v2.py
Normal file
617
app/api/routes/generate_v2.py
Normal 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
124
app/config/quality.yaml
Normal 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
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
158
app/models/content_memory.py
Normal file
158
app/models/content_memory.py
Normal 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()
|
||||
@@ -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:
|
||||
|
||||
155
app/prompts/examples/best_posts.yaml
Normal file
155
app/prompts/examples/best_posts.yaml
Normal 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"
|
||||
94
app/prompts/examples/by_category/ia.yaml
Normal file
94
app/prompts/examples/by_category/ia.yaml
Normal 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
|
||||
113
app/prompts/examples/by_category/productividad.yaml
Normal file
113
app/prompts/examples/by_category/productividad.yaml
Normal 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
|
||||
117
app/prompts/examples/by_category/seguridad.yaml
Normal file
117
app/prompts/examples/by_category/seguridad.yaml
Normal 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)
|
||||
72
app/prompts/personalities/default.yaml
Normal file
72
app/prompts/personalities/default.yaml
Normal 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
|
||||
37
app/prompts/personalities/educational.yaml
Normal file
37
app/prompts/personalities/educational.yaml
Normal 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
|
||||
45
app/prompts/personalities/promotional.yaml
Normal file
45
app/prompts/personalities/promotional.yaml
Normal 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
|
||||
83
app/prompts/platforms/facebook.yaml
Normal file
83
app/prompts/platforms/facebook.yaml
Normal 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
|
||||
86
app/prompts/platforms/instagram.yaml
Normal file
86
app/prompts/platforms/instagram.yaml
Normal 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
|
||||
81
app/prompts/platforms/threads.yaml
Normal file
81
app/prompts/platforms/threads.yaml
Normal 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
|
||||
79
app/prompts/platforms/x.yaml
Normal file
79
app/prompts/platforms/x.yaml
Normal 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
|
||||
82
app/prompts/templates/product_post.yaml
Normal file
82
app/prompts/templates/product_post.yaml
Normal 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"
|
||||
97
app/prompts/templates/response.yaml
Normal file
97
app/prompts/templates/response.yaml
Normal 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.
|
||||
87
app/prompts/templates/service_post.yaml
Normal file
87
app/prompts/templates/service_post.yaml
Normal 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"
|
||||
84
app/prompts/templates/thread.yaml
Normal file
84
app/prompts/templates/thread.yaml
Normal 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"
|
||||
85
app/prompts/templates/tip_tech.yaml
Normal file
85
app/prompts/templates/tip_tech.yaml
Normal 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
|
||||
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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
447
tests/test_ai_engine.py
Normal 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
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
342
worker/tasks/content_memory.py
Normal file
342
worker/tasks/content_memory.py
Normal 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()
|
||||
@@ -1,9 +1,16 @@
|
||||
"""
|
||||
Tareas de generación de contenido.
|
||||
|
||||
Usa el nuevo Content Generation Engine v2 con:
|
||||
- Quality scoring
|
||||
- Anti-repetición via Context Engine
|
||||
- Almacenamiento en Content Memory
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from worker.celery_app import celery_app
|
||||
from app.core.database import SessionLocal
|
||||
@@ -14,6 +21,8 @@ from app.models.product import Product
|
||||
from app.models.service import Service
|
||||
from app.services.content_generator import content_generator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_async(coro):
|
||||
"""Helper para ejecutar coroutines en Celery."""
|
||||
@@ -21,6 +30,31 @@ def run_async(coro):
|
||||
return loop.run_until_complete(coro)
|
||||
|
||||
|
||||
def _save_to_memory(
|
||||
post_id: int,
|
||||
content: str,
|
||||
content_type: str,
|
||||
platform: str,
|
||||
quality_score: Optional[int] = None,
|
||||
quality_breakdown: Optional[Dict] = None,
|
||||
template_used: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
Encolar guardado en memoria como tarea async.
|
||||
Evita bloquear la generación principal.
|
||||
"""
|
||||
from worker.tasks.content_memory import analyze_and_save_content
|
||||
analyze_and_save_content.delay(
|
||||
post_id=post_id,
|
||||
content=content,
|
||||
content_type=content_type,
|
||||
platform=platform,
|
||||
quality_score=quality_score,
|
||||
quality_breakdown=quality_breakdown,
|
||||
template_used=template_used
|
||||
)
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tasks.generate_content.generate_scheduled_content")
|
||||
def generate_scheduled_content():
|
||||
"""
|
||||
@@ -40,6 +74,7 @@ def generate_scheduled_content():
|
||||
ContentCalendar.is_active == True
|
||||
).all()
|
||||
|
||||
generated = 0
|
||||
for entry in entries:
|
||||
# Verificar si es la hora correcta
|
||||
if entry.time.hour != current_hour:
|
||||
@@ -52,20 +87,23 @@ def generate_scheduled_content():
|
||||
category_filter=entry.category_filter,
|
||||
requires_approval=entry.requires_approval
|
||||
)
|
||||
generated += 1
|
||||
|
||||
elif entry.content_type == "producto":
|
||||
generate_product_post.delay(
|
||||
platforms=entry.platforms,
|
||||
requires_approval=entry.requires_approval
|
||||
)
|
||||
generated += 1
|
||||
|
||||
elif entry.content_type == "servicio":
|
||||
generate_service_post.delay(
|
||||
platforms=entry.platforms,
|
||||
requires_approval=entry.requires_approval
|
||||
)
|
||||
generated += 1
|
||||
|
||||
return f"Procesadas {len(entries)} entradas del calendario"
|
||||
return f"Procesadas {len(entries)} entradas, {generated} generaciones iniciadas"
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
@@ -75,9 +113,18 @@ def generate_scheduled_content():
|
||||
def generate_tip_post(
|
||||
platforms: list,
|
||||
category_filter: str = None,
|
||||
requires_approval: bool = False
|
||||
requires_approval: bool = False,
|
||||
use_quality_check: bool = True
|
||||
):
|
||||
"""Generar un post de tip tech."""
|
||||
"""
|
||||
Generar un post de tip tech con quality scoring.
|
||||
|
||||
Args:
|
||||
platforms: Lista de plataformas destino
|
||||
category_filter: Categoría específica (opcional)
|
||||
requires_approval: Si requiere aprobación manual
|
||||
use_quality_check: Si usar validación de calidad
|
||||
"""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
@@ -96,23 +143,66 @@ def generate_tip_post(
|
||||
).first()
|
||||
|
||||
if not tip:
|
||||
return "No hay tips disponibles"
|
||||
return {"status": "skipped", "reason": "no_tips_available"}
|
||||
|
||||
# Generar contenido para cada plataforma
|
||||
content_by_platform = {}
|
||||
quality_info = {}
|
||||
|
||||
for platform in platforms:
|
||||
content = run_async(
|
||||
content_generator.generate_tip_tech(
|
||||
category=tip.category,
|
||||
platform=platform,
|
||||
template=tip.template
|
||||
if use_quality_check:
|
||||
# Usar generación con validación de calidad
|
||||
result = run_async(
|
||||
content_generator.generate_with_quality_check(
|
||||
template_name="tip_tech",
|
||||
variables={
|
||||
"category": tip.category,
|
||||
"difficulty_level": "principiante",
|
||||
"target_audience": "profesionales tech"
|
||||
},
|
||||
platform=platform,
|
||||
db=db,
|
||||
max_attempts=2
|
||||
)
|
||||
)
|
||||
)
|
||||
content_by_platform[platform] = content
|
||||
content_by_platform[platform] = result["content"]
|
||||
quality_info[platform] = {
|
||||
"score": result.get("quality_score"),
|
||||
"breakdown": result.get("score_breakdown"),
|
||||
"attempts": result.get("attempts", 1)
|
||||
}
|
||||
else:
|
||||
# Generación simple (fallback)
|
||||
content = run_async(
|
||||
content_generator.generate_tip_tech(
|
||||
category=tip.category,
|
||||
platform=platform,
|
||||
template=tip.template,
|
||||
db=db
|
||||
)
|
||||
)
|
||||
content_by_platform[platform] = content
|
||||
|
||||
# Obtener mejor score entre plataformas
|
||||
best_score = None
|
||||
best_breakdown = None
|
||||
total_attempts = 1
|
||||
|
||||
if quality_info:
|
||||
scores = [q.get("score") for q in quality_info.values() if q.get("score")]
|
||||
if scores:
|
||||
best_score = max(scores)
|
||||
breakdowns = [q.get("breakdown") for q in quality_info.values() if q.get("breakdown")]
|
||||
if breakdowns:
|
||||
best_breakdown = breakdowns[0]
|
||||
attempts = [q.get("attempts", 1) for q in quality_info.values()]
|
||||
if attempts:
|
||||
total_attempts = max(attempts)
|
||||
|
||||
# Crear post
|
||||
main_platform = platforms[0]
|
||||
post = Post(
|
||||
content=content_by_platform.get(platforms[0], ""),
|
||||
content=content_by_platform.get(main_platform, ""),
|
||||
content_type="tip_tech",
|
||||
platforms=platforms,
|
||||
content_x=content_by_platform.get("x"),
|
||||
@@ -122,7 +212,10 @@ def generate_tip_post(
|
||||
status="pending_approval" if requires_approval else "scheduled",
|
||||
scheduled_at=datetime.utcnow() + timedelta(minutes=5),
|
||||
approval_required=requires_approval,
|
||||
tip_template_id=tip.id
|
||||
tip_template_id=tip.id,
|
||||
quality_score=best_score,
|
||||
score_breakdown=best_breakdown,
|
||||
generation_attempts=total_attempts
|
||||
)
|
||||
|
||||
db.add(post)
|
||||
@@ -133,7 +226,33 @@ def generate_tip_post(
|
||||
|
||||
db.commit()
|
||||
|
||||
return f"Post de tip generado: {post.id}"
|
||||
# Guardar en memoria (async)
|
||||
_save_to_memory(
|
||||
post_id=post.id,
|
||||
content=post.content,
|
||||
content_type="tip_tech",
|
||||
platform=main_platform,
|
||||
quality_score=best_score,
|
||||
quality_breakdown=best_breakdown,
|
||||
template_used="tip_tech"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Tip generado: post_id={post.id}, score={best_score}, "
|
||||
f"attempts={total_attempts}, category={tip.category}"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"post_id": post.id,
|
||||
"quality_score": best_score,
|
||||
"attempts": total_attempts
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generando tip: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
@@ -143,9 +262,18 @@ def generate_tip_post(
|
||||
def generate_product_post(
|
||||
platforms: list,
|
||||
product_id: int = None,
|
||||
requires_approval: bool = True
|
||||
requires_approval: bool = True,
|
||||
use_quality_check: bool = True
|
||||
):
|
||||
"""Generar un post de producto."""
|
||||
"""
|
||||
Generar un post de producto con quality scoring.
|
||||
|
||||
Args:
|
||||
platforms: Lista de plataformas destino
|
||||
product_id: ID del producto específico (opcional)
|
||||
requires_approval: Si requiere aprobación manual
|
||||
use_quality_check: Si usar validación de calidad
|
||||
"""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
@@ -161,22 +289,66 @@ def generate_product_post(
|
||||
).first()
|
||||
|
||||
if not product:
|
||||
return "No hay productos disponibles"
|
||||
return {"status": "skipped", "reason": "no_products_available"}
|
||||
|
||||
# Generar contenido
|
||||
content_by_platform = {}
|
||||
quality_info = {}
|
||||
|
||||
for platform in platforms:
|
||||
content = run_async(
|
||||
content_generator.generate_product_post(
|
||||
product=product.to_dict(),
|
||||
platform=platform
|
||||
if use_quality_check:
|
||||
result = run_async(
|
||||
content_generator.generate_with_quality_check(
|
||||
template_name="product_post",
|
||||
variables={
|
||||
"product_name": product.name,
|
||||
"product_description": product.description or "",
|
||||
"price": product.price,
|
||||
"category": product.category,
|
||||
"specs": product.specs or {},
|
||||
"highlights": product.highlights or []
|
||||
},
|
||||
platform=platform,
|
||||
db=db,
|
||||
max_attempts=2
|
||||
)
|
||||
)
|
||||
)
|
||||
content_by_platform[platform] = content
|
||||
content_by_platform[platform] = result["content"]
|
||||
quality_info[platform] = {
|
||||
"score": result.get("quality_score"),
|
||||
"breakdown": result.get("score_breakdown"),
|
||||
"attempts": result.get("attempts", 1)
|
||||
}
|
||||
else:
|
||||
content = run_async(
|
||||
content_generator.generate_product_post(
|
||||
product=product.to_dict(),
|
||||
platform=platform,
|
||||
db=db
|
||||
)
|
||||
)
|
||||
content_by_platform[platform] = content
|
||||
|
||||
# Obtener mejor score
|
||||
best_score = None
|
||||
best_breakdown = None
|
||||
total_attempts = 1
|
||||
|
||||
if quality_info:
|
||||
scores = [q.get("score") for q in quality_info.values() if q.get("score")]
|
||||
if scores:
|
||||
best_score = max(scores)
|
||||
breakdowns = [q.get("breakdown") for q in quality_info.values() if q.get("breakdown")]
|
||||
if breakdowns:
|
||||
best_breakdown = breakdowns[0]
|
||||
attempts = [q.get("attempts", 1) for q in quality_info.values()]
|
||||
if attempts:
|
||||
total_attempts = max(attempts)
|
||||
|
||||
# Crear post
|
||||
main_platform = platforms[0]
|
||||
post = Post(
|
||||
content=content_by_platform.get(platforms[0], ""),
|
||||
content=content_by_platform.get(main_platform, ""),
|
||||
content_type="producto",
|
||||
platforms=platforms,
|
||||
content_x=content_by_platform.get("x"),
|
||||
@@ -187,7 +359,10 @@ def generate_product_post(
|
||||
scheduled_at=datetime.utcnow() + timedelta(minutes=5),
|
||||
approval_required=requires_approval,
|
||||
product_id=product.id,
|
||||
image_url=product.main_image
|
||||
image_url=product.main_image,
|
||||
quality_score=best_score,
|
||||
score_breakdown=best_breakdown,
|
||||
generation_attempts=total_attempts
|
||||
)
|
||||
|
||||
db.add(post)
|
||||
@@ -197,7 +372,33 @@ def generate_product_post(
|
||||
|
||||
db.commit()
|
||||
|
||||
return f"Post de producto generado: {post.id}"
|
||||
# Guardar en memoria
|
||||
_save_to_memory(
|
||||
post_id=post.id,
|
||||
content=post.content,
|
||||
content_type="producto",
|
||||
platform=main_platform,
|
||||
quality_score=best_score,
|
||||
quality_breakdown=best_breakdown,
|
||||
template_used="product_post"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Producto generado: post_id={post.id}, product={product.name}, "
|
||||
f"score={best_score}"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"post_id": post.id,
|
||||
"product_id": product.id,
|
||||
"quality_score": best_score
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generando producto: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
@@ -207,9 +408,18 @@ def generate_product_post(
|
||||
def generate_service_post(
|
||||
platforms: list,
|
||||
service_id: int = None,
|
||||
requires_approval: bool = True
|
||||
requires_approval: bool = True,
|
||||
use_quality_check: bool = True
|
||||
):
|
||||
"""Generar un post de servicio."""
|
||||
"""
|
||||
Generar un post de servicio con quality scoring.
|
||||
|
||||
Args:
|
||||
platforms: Lista de plataformas destino
|
||||
service_id: ID del servicio específico (opcional)
|
||||
requires_approval: Si requiere aprobación manual
|
||||
use_quality_check: Si usar validación de calidad
|
||||
"""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
@@ -224,22 +434,66 @@ def generate_service_post(
|
||||
).first()
|
||||
|
||||
if not service:
|
||||
return "No hay servicios disponibles"
|
||||
return {"status": "skipped", "reason": "no_services_available"}
|
||||
|
||||
# Generar contenido
|
||||
content_by_platform = {}
|
||||
quality_info = {}
|
||||
|
||||
for platform in platforms:
|
||||
content = run_async(
|
||||
content_generator.generate_service_post(
|
||||
service=service.to_dict(),
|
||||
platform=platform
|
||||
if use_quality_check:
|
||||
result = run_async(
|
||||
content_generator.generate_with_quality_check(
|
||||
template_name="service_post",
|
||||
variables={
|
||||
"service_name": service.name,
|
||||
"service_description": service.description or "",
|
||||
"category": service.category,
|
||||
"target_sectors": service.target_sectors or [],
|
||||
"benefits": service.benefits or [],
|
||||
"call_to_action": service.call_to_action or "Contáctanos"
|
||||
},
|
||||
platform=platform,
|
||||
db=db,
|
||||
max_attempts=2
|
||||
)
|
||||
)
|
||||
)
|
||||
content_by_platform[platform] = content
|
||||
content_by_platform[platform] = result["content"]
|
||||
quality_info[platform] = {
|
||||
"score": result.get("quality_score"),
|
||||
"breakdown": result.get("score_breakdown"),
|
||||
"attempts": result.get("attempts", 1)
|
||||
}
|
||||
else:
|
||||
content = run_async(
|
||||
content_generator.generate_service_post(
|
||||
service=service.to_dict(),
|
||||
platform=platform,
|
||||
db=db
|
||||
)
|
||||
)
|
||||
content_by_platform[platform] = content
|
||||
|
||||
# Obtener mejor score
|
||||
best_score = None
|
||||
best_breakdown = None
|
||||
total_attempts = 1
|
||||
|
||||
if quality_info:
|
||||
scores = [q.get("score") for q in quality_info.values() if q.get("score")]
|
||||
if scores:
|
||||
best_score = max(scores)
|
||||
breakdowns = [q.get("breakdown") for q in quality_info.values() if q.get("breakdown")]
|
||||
if breakdowns:
|
||||
best_breakdown = breakdowns[0]
|
||||
attempts = [q.get("attempts", 1) for q in quality_info.values()]
|
||||
if attempts:
|
||||
total_attempts = max(attempts)
|
||||
|
||||
# Crear post
|
||||
main_platform = platforms[0]
|
||||
post = Post(
|
||||
content=content_by_platform.get(platforms[0], ""),
|
||||
content=content_by_platform.get(main_platform, ""),
|
||||
content_type="servicio",
|
||||
platforms=platforms,
|
||||
content_x=content_by_platform.get("x"),
|
||||
@@ -250,7 +504,10 @@ def generate_service_post(
|
||||
scheduled_at=datetime.utcnow() + timedelta(minutes=5),
|
||||
approval_required=requires_approval,
|
||||
service_id=service.id,
|
||||
image_url=service.main_image
|
||||
image_url=service.main_image,
|
||||
quality_score=best_score,
|
||||
score_breakdown=best_breakdown,
|
||||
generation_attempts=total_attempts
|
||||
)
|
||||
|
||||
db.add(post)
|
||||
@@ -260,7 +517,106 @@ def generate_service_post(
|
||||
|
||||
db.commit()
|
||||
|
||||
return f"Post de servicio generado: {post.id}"
|
||||
# Guardar en memoria
|
||||
_save_to_memory(
|
||||
post_id=post.id,
|
||||
content=post.content,
|
||||
content_type="servicio",
|
||||
platform=main_platform,
|
||||
quality_score=best_score,
|
||||
quality_breakdown=best_breakdown,
|
||||
template_used="service_post"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Servicio generado: post_id={post.id}, service={service.name}, "
|
||||
f"score={best_score}"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"post_id": post.id,
|
||||
"service_id": service.id,
|
||||
"quality_score": best_score
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generando servicio: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tasks.generate_content.generate_thread")
|
||||
def generate_thread(
|
||||
topic: str,
|
||||
num_posts: int = 5,
|
||||
requires_approval: bool = True
|
||||
):
|
||||
"""
|
||||
Generar un hilo educativo.
|
||||
|
||||
Args:
|
||||
topic: Tema del hilo
|
||||
num_posts: Número de posts
|
||||
requires_approval: Si requiere aprobación
|
||||
"""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# Generar hilo
|
||||
posts = run_async(
|
||||
content_generator.generate_thread(
|
||||
topic=topic,
|
||||
num_posts=num_posts,
|
||||
db=db
|
||||
)
|
||||
)
|
||||
|
||||
if not posts:
|
||||
return {"status": "error", "reason": "no_posts_generated"}
|
||||
|
||||
# Crear posts individuales (vinculados)
|
||||
created_posts = []
|
||||
for i, content in enumerate(posts):
|
||||
post = Post(
|
||||
content=content,
|
||||
content_type="hilo_educativo",
|
||||
platforms=["x"], # Hilos principalmente para X
|
||||
content_x=content,
|
||||
status="pending_approval" if requires_approval else "scheduled",
|
||||
scheduled_at=datetime.utcnow() + timedelta(minutes=5 + i), # Escalonado
|
||||
approval_required=requires_approval,
|
||||
)
|
||||
db.add(post)
|
||||
created_posts.append(post)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Guardar primer post en memoria (representa el hilo)
|
||||
if created_posts:
|
||||
_save_to_memory(
|
||||
post_id=created_posts[0].id,
|
||||
content="\n\n".join(posts),
|
||||
content_type="hilo_educativo",
|
||||
platform="x",
|
||||
template_used="thread"
|
||||
)
|
||||
|
||||
logger.info(f"Hilo generado: {len(created_posts)} posts sobre '{topic}'")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"post_ids": [p.id for p in created_posts],
|
||||
"topic": topic
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generando hilo: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
Reference in New Issue
Block a user