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.config import settings
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
|
# Import all models for autogenerate support
|
||||||
from app.models import (
|
from app.models import (
|
||||||
User, Product, Service, TipTemplate,
|
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
|
# 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.staticfiles import StaticFiles
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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.config import settings
|
||||||
from app.core.database import engine
|
from app.core.database import engine
|
||||||
from app.models import Base
|
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(interactions.router, prefix="/api/interactions", tags=["Interactions"])
|
||||||
app.include_router(publish.router, prefix="/api/publish", tags=["Publish"])
|
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.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(notifications.router, prefix="/api/notifications", tags=["Notifications"])
|
||||||
app.include_router(analytics.router, prefix="/api/analytics", tags=["Analytics"])
|
app.include_router(analytics.router, prefix="/api/analytics", tags=["Analytics"])
|
||||||
app.include_router(odoo.router, prefix="/api/odoo", tags=["Odoo"])
|
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.ab_test import ABTest, ABTestVariant
|
||||||
from app.models.recycled_post import RecycledPost
|
from app.models.recycled_post import RecycledPost
|
||||||
from app.models.thread_series import ThreadSeries, ThreadPost
|
from app.models.thread_series import ThreadSeries, ThreadPost
|
||||||
|
from app.models.content_memory import ContentMemory
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
@@ -38,5 +39,6 @@ __all__ = [
|
|||||||
"ABTestVariant",
|
"ABTestVariant",
|
||||||
"RecycledPost",
|
"RecycledPost",
|
||||||
"ThreadSeries",
|
"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)
|
recycled_from_id = Column(Integer, ForeignKey("posts.id"), nullable=True)
|
||||||
recycle_count = Column(Integer, default=0) # Times this post has been recycled
|
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
|
# Timestamps
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=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,
|
"ab_test_id": self.ab_test_id,
|
||||||
"is_recyclable": self.is_recyclable,
|
"is_recyclable": self.is_recyclable,
|
||||||
"recycled_from_id": self.recycled_from_id,
|
"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:
|
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.
|
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
|
import json
|
||||||
from typing import Optional, List, Dict
|
from typing import Optional, List, Dict, Any
|
||||||
from openai import OpenAI
|
from openai import OpenAI
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.config import settings
|
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:
|
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._client = None
|
||||||
self.model = "deepseek-chat"
|
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
|
@property
|
||||||
def client(self):
|
def client(self):
|
||||||
@@ -30,6 +75,15 @@ class ContentGenerator:
|
|||||||
|
|
||||||
def _get_system_prompt(self) -> str:
|
def _get_system_prompt(self) -> str:
|
||||||
"""Obtener el prompt del sistema con la personalidad de la marca."""
|
"""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}.
|
return f"""Eres el Community Manager de {settings.BUSINESS_NAME}, una empresa de tecnología ubicada en {settings.BUSINESS_LOCATION}.
|
||||||
|
|
||||||
SOBRE LA EMPRESA:
|
SOBRE LA EMPRESA:
|
||||||
@@ -54,13 +108,70 @@ REGLAS:
|
|||||||
- Enfócate en ayudar, no en vender directamente
|
- Enfócate en ayudar, no en vender directamente
|
||||||
- Adapta el contenido a cada plataforma"""
|
- Adapta el contenido a cada plataforma"""
|
||||||
|
|
||||||
|
# === Métodos principales con nuevo motor ===
|
||||||
|
|
||||||
async def generate_tip_tech(
|
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,
|
self,
|
||||||
category: str,
|
category: str,
|
||||||
platform: str,
|
platform: str,
|
||||||
template: Optional[str] = None
|
template: Optional[str] = None
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generar un tip tech."""
|
"""Implementación original de generate_tip_tech."""
|
||||||
char_limits = {
|
char_limits = {
|
||||||
"x": 280,
|
"x": 280,
|
||||||
"threads": 500,
|
"threads": 500,
|
||||||
@@ -97,11 +208,55 @@ Responde SOLO con el texto del post, sin explicaciones."""
|
|||||||
return response.choices[0].message.content.strip()
|
return response.choices[0].message.content.strip()
|
||||||
|
|
||||||
async def generate_product_post(
|
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,
|
self,
|
||||||
product: Dict,
|
product: Dict,
|
||||||
platform: str
|
platform: str
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generar post para un producto."""
|
"""Implementación original de generate_product_post."""
|
||||||
char_limits = {
|
char_limits = {
|
||||||
"x": 280,
|
"x": 280,
|
||||||
"threads": 500,
|
"threads": 500,
|
||||||
@@ -144,11 +299,55 @@ Responde SOLO con el texto del post."""
|
|||||||
return response.choices[0].message.content.strip()
|
return response.choices[0].message.content.strip()
|
||||||
|
|
||||||
async def generate_service_post(
|
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,
|
self,
|
||||||
service: Dict,
|
service: Dict,
|
||||||
platform: str
|
platform: str
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generar post para un servicio."""
|
"""Implementación original de generate_service_post."""
|
||||||
char_limits = {
|
char_limits = {
|
||||||
"x": 280,
|
"x": 280,
|
||||||
"threads": 500,
|
"threads": 500,
|
||||||
@@ -191,11 +390,38 @@ Responde SOLO con el texto del post."""
|
|||||||
return response.choices[0].message.content.strip()
|
return response.choices[0].message.content.strip()
|
||||||
|
|
||||||
async def generate_thread(
|
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,
|
self,
|
||||||
topic: str,
|
topic: str,
|
||||||
num_posts: int = 5
|
num_posts: int = 5
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""Generar un hilo educativo."""
|
"""Implementación original de generate_thread."""
|
||||||
prompt = f"""Genera un hilo educativo de {num_posts} posts sobre: {topic}
|
prompt = f"""Genera un hilo educativo de {num_posts} posts sobre: {topic}
|
||||||
|
|
||||||
REQUISITOS:
|
REQUISITOS:
|
||||||
@@ -222,7 +448,6 @@ Responde con cada post en una línea separada, sin explicaciones."""
|
|||||||
)
|
)
|
||||||
|
|
||||||
content = response.choices[0].message.content.strip()
|
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()]
|
posts = [p.strip() for p in content.split('\n') if p.strip()]
|
||||||
|
|
||||||
return posts
|
return posts
|
||||||
@@ -233,7 +458,36 @@ Responde con cada post en una línea separada, sin explicaciones."""
|
|||||||
interaction_type: str,
|
interaction_type: str,
|
||||||
context: Optional[str] = None
|
context: Optional[str] = None
|
||||||
) -> List[str]:
|
) -> 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:
|
prompt = f"""Un usuario escribió esto en redes sociales:
|
||||||
|
|
||||||
"{interaction_content}"
|
"{interaction_content}"
|
||||||
@@ -267,21 +521,46 @@ Responde con las 3 opciones numeradas, una por línea."""
|
|||||||
content = response.choices[0].message.content.strip()
|
content = response.choices[0].message.content.strip()
|
||||||
suggestions = [s.strip() for s in content.split('\n') if s.strip()]
|
suggestions = [s.strip() for s in content.split('\n') if s.strip()]
|
||||||
|
|
||||||
# Limpiar numeración si existe
|
|
||||||
cleaned = []
|
cleaned = []
|
||||||
for s in suggestions:
|
for s in suggestions:
|
||||||
if s[0].isdigit() and (s[1] == '.' or s[1] == ')'):
|
if s[0].isdigit() and (s[1] == '.' or s[1] == ')'):
|
||||||
s = s[2:].strip()
|
s = s[2:].strip()
|
||||||
cleaned.append(s)
|
cleaned.append(s)
|
||||||
|
|
||||||
return cleaned[:3] # Máximo 3 sugerencias
|
return cleaned[:3]
|
||||||
|
|
||||||
async def adapt_content_for_platform(
|
async def adapt_content_for_platform(
|
||||||
self,
|
self,
|
||||||
content: str,
|
content: str,
|
||||||
target_platform: str
|
target_platform: str
|
||||||
) -> 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 = {
|
char_limits = {
|
||||||
"x": 280,
|
"x": 280,
|
||||||
"threads": 500,
|
"threads": 500,
|
||||||
@@ -318,6 +597,119 @@ Responde SOLO con el contenido adaptado."""
|
|||||||
|
|
||||||
return response.choices[0].message.content.strip()
|
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
|
# Instancia global
|
||||||
content_generator = ContentGenerator()
|
content_generator = ContentGenerator()
|
||||||
|
|||||||
@@ -29,10 +29,12 @@ html2image==2.0.4.3
|
|||||||
|
|
||||||
# Utilidades
|
# Utilidades
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
|
PyYAML==6.0.1
|
||||||
pydantic==2.5.3
|
pydantic==2.5.3
|
||||||
pydantic-settings==2.1.0
|
pydantic-settings==2.1.0
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
passlib[bcrypt]==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
|
bcrypt==4.0.1
|
||||||
|
|
||||||
# Estadísticas (A/B Testing)
|
# Estadísticas (A/B Testing)
|
||||||
scipy==1.11.4
|
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.generate_content",
|
||||||
"worker.tasks.publish_post",
|
"worker.tasks.publish_post",
|
||||||
"worker.tasks.fetch_interactions",
|
"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",
|
"task": "worker.tasks.cleanup.daily_cleanup",
|
||||||
"schedule": crontab(hour=3, minute=0),
|
"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.
|
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 asyncio
|
||||||
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
from worker.celery_app import celery_app
|
from worker.celery_app import celery_app
|
||||||
from app.core.database import SessionLocal
|
from app.core.database import SessionLocal
|
||||||
@@ -14,6 +21,8 @@ from app.models.product import Product
|
|||||||
from app.models.service import Service
|
from app.models.service import Service
|
||||||
from app.services.content_generator import content_generator
|
from app.services.content_generator import content_generator
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def run_async(coro):
|
def run_async(coro):
|
||||||
"""Helper para ejecutar coroutines en Celery."""
|
"""Helper para ejecutar coroutines en Celery."""
|
||||||
@@ -21,6 +30,31 @@ def run_async(coro):
|
|||||||
return loop.run_until_complete(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")
|
@celery_app.task(name="worker.tasks.generate_content.generate_scheduled_content")
|
||||||
def generate_scheduled_content():
|
def generate_scheduled_content():
|
||||||
"""
|
"""
|
||||||
@@ -40,6 +74,7 @@ def generate_scheduled_content():
|
|||||||
ContentCalendar.is_active == True
|
ContentCalendar.is_active == True
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
generated = 0
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
# Verificar si es la hora correcta
|
# Verificar si es la hora correcta
|
||||||
if entry.time.hour != current_hour:
|
if entry.time.hour != current_hour:
|
||||||
@@ -52,20 +87,23 @@ def generate_scheduled_content():
|
|||||||
category_filter=entry.category_filter,
|
category_filter=entry.category_filter,
|
||||||
requires_approval=entry.requires_approval
|
requires_approval=entry.requires_approval
|
||||||
)
|
)
|
||||||
|
generated += 1
|
||||||
|
|
||||||
elif entry.content_type == "producto":
|
elif entry.content_type == "producto":
|
||||||
generate_product_post.delay(
|
generate_product_post.delay(
|
||||||
platforms=entry.platforms,
|
platforms=entry.platforms,
|
||||||
requires_approval=entry.requires_approval
|
requires_approval=entry.requires_approval
|
||||||
)
|
)
|
||||||
|
generated += 1
|
||||||
|
|
||||||
elif entry.content_type == "servicio":
|
elif entry.content_type == "servicio":
|
||||||
generate_service_post.delay(
|
generate_service_post.delay(
|
||||||
platforms=entry.platforms,
|
platforms=entry.platforms,
|
||||||
requires_approval=entry.requires_approval
|
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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@@ -75,9 +113,18 @@ def generate_scheduled_content():
|
|||||||
def generate_tip_post(
|
def generate_tip_post(
|
||||||
platforms: list,
|
platforms: list,
|
||||||
category_filter: str = None,
|
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()
|
db = SessionLocal()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -96,23 +143,66 @@ def generate_tip_post(
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not tip:
|
if not tip:
|
||||||
return "No hay tips disponibles"
|
return {"status": "skipped", "reason": "no_tips_available"}
|
||||||
|
|
||||||
# Generar contenido para cada plataforma
|
# Generar contenido para cada plataforma
|
||||||
content_by_platform = {}
|
content_by_platform = {}
|
||||||
|
quality_info = {}
|
||||||
|
|
||||||
for platform in platforms:
|
for platform in platforms:
|
||||||
content = run_async(
|
if use_quality_check:
|
||||||
content_generator.generate_tip_tech(
|
# Usar generación con validación de calidad
|
||||||
category=tip.category,
|
result = run_async(
|
||||||
platform=platform,
|
content_generator.generate_with_quality_check(
|
||||||
template=tip.template
|
template_name="tip_tech",
|
||||||
|
variables={
|
||||||
|
"category": tip.category,
|
||||||
|
"difficulty_level": "principiante",
|
||||||
|
"target_audience": "profesionales tech"
|
||||||
|
},
|
||||||
|
platform=platform,
|
||||||
|
db=db,
|
||||||
|
max_attempts=2
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
content_by_platform[platform] = result["content"]
|
||||||
content_by_platform[platform] = 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
|
# Crear post
|
||||||
|
main_platform = platforms[0]
|
||||||
post = Post(
|
post = Post(
|
||||||
content=content_by_platform.get(platforms[0], ""),
|
content=content_by_platform.get(main_platform, ""),
|
||||||
content_type="tip_tech",
|
content_type="tip_tech",
|
||||||
platforms=platforms,
|
platforms=platforms,
|
||||||
content_x=content_by_platform.get("x"),
|
content_x=content_by_platform.get("x"),
|
||||||
@@ -122,7 +212,10 @@ def generate_tip_post(
|
|||||||
status="pending_approval" if requires_approval else "scheduled",
|
status="pending_approval" if requires_approval else "scheduled",
|
||||||
scheduled_at=datetime.utcnow() + timedelta(minutes=5),
|
scheduled_at=datetime.utcnow() + timedelta(minutes=5),
|
||||||
approval_required=requires_approval,
|
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)
|
db.add(post)
|
||||||
@@ -133,7 +226,33 @@ def generate_tip_post(
|
|||||||
|
|
||||||
db.commit()
|
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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@@ -143,9 +262,18 @@ def generate_tip_post(
|
|||||||
def generate_product_post(
|
def generate_product_post(
|
||||||
platforms: list,
|
platforms: list,
|
||||||
product_id: int = None,
|
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()
|
db = SessionLocal()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -161,22 +289,66 @@ def generate_product_post(
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not product:
|
if not product:
|
||||||
return "No hay productos disponibles"
|
return {"status": "skipped", "reason": "no_products_available"}
|
||||||
|
|
||||||
# Generar contenido
|
# Generar contenido
|
||||||
content_by_platform = {}
|
content_by_platform = {}
|
||||||
|
quality_info = {}
|
||||||
|
|
||||||
for platform in platforms:
|
for platform in platforms:
|
||||||
content = run_async(
|
if use_quality_check:
|
||||||
content_generator.generate_product_post(
|
result = run_async(
|
||||||
product=product.to_dict(),
|
content_generator.generate_with_quality_check(
|
||||||
platform=platform
|
template_name="product_post",
|
||||||
|
variables={
|
||||||
|
"product_name": product.name,
|
||||||
|
"product_description": product.description or "",
|
||||||
|
"price": product.price,
|
||||||
|
"category": product.category,
|
||||||
|
"specs": product.specs or {},
|
||||||
|
"highlights": product.highlights or []
|
||||||
|
},
|
||||||
|
platform=platform,
|
||||||
|
db=db,
|
||||||
|
max_attempts=2
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
content_by_platform[platform] = result["content"]
|
||||||
content_by_platform[platform] = 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
|
# Crear post
|
||||||
|
main_platform = platforms[0]
|
||||||
post = Post(
|
post = Post(
|
||||||
content=content_by_platform.get(platforms[0], ""),
|
content=content_by_platform.get(main_platform, ""),
|
||||||
content_type="producto",
|
content_type="producto",
|
||||||
platforms=platforms,
|
platforms=platforms,
|
||||||
content_x=content_by_platform.get("x"),
|
content_x=content_by_platform.get("x"),
|
||||||
@@ -187,7 +359,10 @@ def generate_product_post(
|
|||||||
scheduled_at=datetime.utcnow() + timedelta(minutes=5),
|
scheduled_at=datetime.utcnow() + timedelta(minutes=5),
|
||||||
approval_required=requires_approval,
|
approval_required=requires_approval,
|
||||||
product_id=product.id,
|
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)
|
db.add(post)
|
||||||
@@ -197,7 +372,33 @@ def generate_product_post(
|
|||||||
|
|
||||||
db.commit()
|
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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
@@ -207,9 +408,18 @@ def generate_product_post(
|
|||||||
def generate_service_post(
|
def generate_service_post(
|
||||||
platforms: list,
|
platforms: list,
|
||||||
service_id: int = None,
|
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()
|
db = SessionLocal()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -224,22 +434,66 @@ def generate_service_post(
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not service:
|
if not service:
|
||||||
return "No hay servicios disponibles"
|
return {"status": "skipped", "reason": "no_services_available"}
|
||||||
|
|
||||||
# Generar contenido
|
# Generar contenido
|
||||||
content_by_platform = {}
|
content_by_platform = {}
|
||||||
|
quality_info = {}
|
||||||
|
|
||||||
for platform in platforms:
|
for platform in platforms:
|
||||||
content = run_async(
|
if use_quality_check:
|
||||||
content_generator.generate_service_post(
|
result = run_async(
|
||||||
service=service.to_dict(),
|
content_generator.generate_with_quality_check(
|
||||||
platform=platform
|
template_name="service_post",
|
||||||
|
variables={
|
||||||
|
"service_name": service.name,
|
||||||
|
"service_description": service.description or "",
|
||||||
|
"category": service.category,
|
||||||
|
"target_sectors": service.target_sectors or [],
|
||||||
|
"benefits": service.benefits or [],
|
||||||
|
"call_to_action": service.call_to_action or "Contáctanos"
|
||||||
|
},
|
||||||
|
platform=platform,
|
||||||
|
db=db,
|
||||||
|
max_attempts=2
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
content_by_platform[platform] = result["content"]
|
||||||
content_by_platform[platform] = 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
|
# Crear post
|
||||||
|
main_platform = platforms[0]
|
||||||
post = Post(
|
post = Post(
|
||||||
content=content_by_platform.get(platforms[0], ""),
|
content=content_by_platform.get(main_platform, ""),
|
||||||
content_type="servicio",
|
content_type="servicio",
|
||||||
platforms=platforms,
|
platforms=platforms,
|
||||||
content_x=content_by_platform.get("x"),
|
content_x=content_by_platform.get("x"),
|
||||||
@@ -250,7 +504,10 @@ def generate_service_post(
|
|||||||
scheduled_at=datetime.utcnow() + timedelta(minutes=5),
|
scheduled_at=datetime.utcnow() + timedelta(minutes=5),
|
||||||
approval_required=requires_approval,
|
approval_required=requires_approval,
|
||||||
service_id=service.id,
|
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)
|
db.add(post)
|
||||||
@@ -260,7 +517,106 @@ def generate_service_post(
|
|||||||
|
|
||||||
db.commit()
|
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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user