fix: Fix YAML syntax errors and validator prompt formatting

- Fix YAML files with unquoted strings containing quotes
- Export singleton instances in ai/__init__.py
- Fix validator scoring prompt to use replace() instead of format()
  to avoid conflicts with JSON curly braces

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-28 21:13:58 +00:00
parent 11b0ba46fa
commit e32885afc5
19 changed files with 3440 additions and 4186 deletions

View File

@@ -1,446 +1,256 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Analytics - Social Media Automation</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body { background-color: #1a1a2e; color: #eee; }
.card { background-color: #16213e; border-radius: 12px; }
.accent { color: #d4a574; }
.btn-primary { background-color: #d4a574; color: #1a1a2e; }
.btn-primary:hover { background-color: #c49564; }
.btn-secondary { background-color: #374151; color: #fff; }
.btn-secondary:hover { background-color: #4b5563; }
.stat-up { color: #10b981; }
.stat-down { color: #ef4444; }
</style>
</head>
<body class="min-h-screen">
{% extends "base.html" %}
{% block title %}Analytics{% endblock %}
{% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
{% endblock %}
{% block content %}
<div class="animate-fade-in">
<!-- Header -->
<header class="bg-gray-900 border-b border-gray-800 px-6 py-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">
<span class="accent">Consultoría AS</span> - Analytics
</h1>
<nav class="flex gap-4">
<a href="/dashboard" class="px-4 py-2 rounded hover:bg-gray-800">Home</a>
<a href="/dashboard/compose" class="px-4 py-2 rounded hover:bg-gray-800 accent">+ Crear Post</a>
<a href="/dashboard/posts" class="px-4 py-2 rounded hover:bg-gray-800">Posts</a>
<a href="/dashboard/analytics" class="px-4 py-2 rounded bg-gray-800">Analytics</a>
<a href="/dashboard/calendar" class="px-4 py-2 rounded hover:bg-gray-800">Calendario</a>
<a href="/logout" class="px-4 py-2 rounded hover:bg-gray-800 text-red-400">Salir</a>
</nav>
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-3xl font-bold">Analytics</h1>
<p class="text-gray-400 mt-1">Métricas y rendimiento de tus publicaciones</p>
</div>
</header>
<main class="container mx-auto px-6 py-8">
<!-- Period Selector -->
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-semibold">Dashboard de Analytics</h2>
<div class="flex gap-2">
<select id="periodSelect" onchange="loadDashboard()"
class="bg-gray-800 border border-gray-700 rounded px-4 py-2">
<option value="7">Últimos 7 días</option>
<option value="30" selected>Últimos 30 días</option>
<option value="90">Últimos 90 días</option>
</select>
<select id="platformSelect" onchange="loadDashboard()"
class="bg-gray-800 border border-gray-700 rounded px-4 py-2">
<option value="">Todas las plataformas</option>
<option value="x">X (Twitter)</option>
<option value="threads">Threads</option>
<option value="instagram">Instagram</option>
<option value="facebook">Facebook</option>
</select>
<button onclick="generateReport()" class="btn-primary px-4 py-2 rounded">
Generar Reporte
</button>
</div>
<div class="flex gap-3">
<select id="periodSelect" onchange="loadDashboard()" class="bg-dark-800 border border-dark-600 rounded-xl px-4 py-2 text-white focus:border-primary focus:outline-none">
<option value="7">Últimos 7 días</option>
<option value="30" selected>Últimos 30 días</option>
<option value="90">Últimos 90 días</option>
</select>
<select id="platformSelect" onchange="loadDashboard()" class="bg-dark-800 border border-dark-600 rounded-xl px-4 py-2 text-white focus:border-primary focus:outline-none">
<option value="">Todas las plataformas</option>
<option value="x">X (Twitter)</option>
<option value="facebook">Facebook</option>
<option value="instagram">Instagram</option>
<option value="threads">Threads</option>
</select>
</div>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-5 gap-4 mb-8">
<div class="card p-4 text-center">
<div class="text-3xl font-bold accent" id="totalPosts">-</div>
<div class="text-gray-400 text-sm">Posts Publicados</div>
</div>
<div class="card p-4 text-center">
<div class="text-3xl font-bold text-blue-400" id="totalImpressions">-</div>
<div class="text-gray-400 text-sm">Impresiones</div>
</div>
<div class="card p-4 text-center">
<div class="text-3xl font-bold text-green-400" id="totalEngagements">-</div>
<div class="text-gray-400 text-sm">Interacciones</div>
</div>
<div class="card p-4 text-center">
<div class="text-3xl font-bold text-purple-400" id="engagementRate">-</div>
<div class="text-gray-400 text-sm">Engagement Rate</div>
</div>
<div class="card p-4 text-center">
<div class="text-3xl font-bold text-yellow-400" id="pendingInteractions">-</div>
<div class="text-gray-400 text-sm">Por Responder</div>
</div>
</div>
<!-- Engagement Breakdown -->
<div class="grid grid-cols-3 gap-4 mb-8">
<div class="card p-4 flex items-center justify-between">
<div>
<div class="text-gray-400 text-sm">Likes</div>
<div class="text-2xl font-bold" id="totalLikes">-</div>
</div>
<div class="text-4xl">❤️</div>
</div>
<div class="card p-4 flex items-center justify-between">
<div>
<div class="text-gray-400 text-sm">Comentarios</div>
<div class="text-2xl font-bold" id="totalComments">-</div>
</div>
<div class="text-4xl">💬</div>
</div>
<div class="card p-4 flex items-center justify-between">
<div>
<div class="text-gray-400 text-sm">Compartidos</div>
<div class="text-2xl font-bold" id="totalShares">-</div>
</div>
<div class="text-4xl">🔄</div>
</div>
</div>
<div class="grid grid-cols-2 gap-6 mb-8">
<!-- Engagement Trend Chart -->
<div class="card p-6">
<h3 class="text-lg font-semibold mb-4">Tendencia de Engagement</h3>
<canvas id="engagementChart" height="200"></canvas>
</div>
<!-- Platform Breakdown -->
<div class="card p-6">
<h3 class="text-lg font-semibold mb-4">Por Plataforma</h3>
<div id="platformBreakdown" class="space-y-3">
<!-- Populated by JS -->
<!-- Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="stat-card card rounded-2xl p-6">
<div class="flex items-center justify-between mb-4">
<div class="w-12 h-12 rounded-xl bg-blue-500/20 flex items-center justify-center">
<span class="text-2xl">📝</span>
</div>
</div>
<p class="text-3xl font-bold" id="stat-posts">-</p>
<p class="text-gray-400 text-sm mt-1">Posts Totales</p>
</div>
<div class="grid grid-cols-2 gap-6 mb-8">
<!-- Top Posts -->
<div class="card p-6">
<h3 class="text-lg font-semibold mb-4">Top Posts por Engagement</h3>
<div id="topPosts" class="space-y-3">
<!-- Populated by JS -->
<div class="stat-card card rounded-2xl p-6">
<div class="flex items-center justify-between mb-4">
<div class="w-12 h-12 rounded-xl bg-green-500/20 flex items-center justify-center">
<span class="text-2xl">👁️</span>
</div>
</div>
<p class="text-3xl font-bold" id="stat-impressions">-</p>
<p class="text-gray-400 text-sm mt-1">Impresiones</p>
</div>
<!-- Optimal Times Heatmap -->
<div class="card p-6">
<h3 class="text-lg font-semibold mb-4">Mejores Horarios</h3>
<div id="optimalTimes" class="space-y-2">
<!-- Populated by JS -->
<div class="stat-card card rounded-2xl p-6">
<div class="flex items-center justify-between mb-4">
<div class="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center">
<span class="text-2xl">❤️</span>
</div>
</div>
<p class="text-3xl font-bold" id="stat-engagements">-</p>
<p class="text-gray-400 text-sm mt-1">Interacciones</p>
</div>
<!-- Content Type Performance -->
<div class="card p-6 mb-8">
<h3 class="text-lg font-semibold mb-4">Rendimiento por Tipo de Contenido</h3>
<div id="contentBreakdown" class="grid grid-cols-4 gap-4">
<!-- Populated by JS -->
<div class="stat-card card rounded-2xl p-6">
<div class="flex items-center justify-between mb-4">
<div class="w-12 h-12 rounded-xl bg-yellow-500/20 flex items-center justify-center">
<span class="text-2xl">📊</span>
</div>
</div>
<p class="text-3xl font-bold" id="stat-rate">-</p>
<p class="text-gray-400 text-sm mt-1">Engagement Rate</p>
</div>
</div>
<!-- Reports History -->
<div class="card p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">Reportes Anteriores</h3>
<button onclick="sendReportTelegram()" class="btn-secondary px-4 py-2 rounded text-sm">
Enviar a Telegram
</button>
</div>
<div id="reportsList" class="space-y-2">
<!-- Populated by JS -->
</div>
<!-- Charts -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<div class="card rounded-2xl p-6">
<h3 class="font-semibold mb-4">Engagement por Plataforma</h3>
<canvas id="platformChart" height="200"></canvas>
</div>
</main>
<div class="card rounded-2xl p-6">
<h3 class="font-semibold mb-4">Rendimiento por Tipo de Contenido</h3>
<canvas id="contentChart" height="200"></canvas>
</div>
</div>
<script>
let engagementChart = null;
<!-- Top Posts -->
<div class="card rounded-2xl p-6 mb-8">
<h3 class="font-semibold mb-4">Mejores Posts</h3>
<div id="top-posts" class="space-y-4">
<div class="text-center py-8 text-gray-500">Cargando...</div>
</div>
</div>
async function loadDashboard() {
const days = document.getElementById('periodSelect').value;
const platform = document.getElementById('platformSelect').value;
<!-- Optimal Times -->
<div class="card rounded-2xl p-6">
<h3 class="font-semibold mb-4">Mejores Horarios para Publicar</h3>
<div id="optimal-times" class="grid grid-cols-7 gap-2">
<div class="text-center py-8 text-gray-500 col-span-7">Cargando...</div>
</div>
</div>
</div>
{% endblock %}
try {
// Load dashboard stats
const params = new URLSearchParams({ days });
if (platform) params.append('platform', platform);
{% block extra_scripts %}
<script>
let platformChart, contentChart;
const statsRes = await fetch(`/api/analytics/dashboard?${params}`);
const stats = await statsRes.json();
async function loadDashboard() {
const period = document.getElementById('periodSelect').value;
const platform = document.getElementById('platformSelect').value;
// Update stat cards
document.getElementById('totalPosts').textContent = stats.total_posts;
document.getElementById('totalImpressions').textContent = formatNumber(stats.total_impressions);
document.getElementById('totalEngagements').textContent = formatNumber(stats.total_engagements);
document.getElementById('engagementRate').textContent = stats.avg_engagement_rate + '%';
document.getElementById('pendingInteractions').textContent = stats.pending_interactions;
document.getElementById('totalLikes').textContent = formatNumber(stats.total_likes);
document.getElementById('totalComments').textContent = formatNumber(stats.total_comments);
document.getElementById('totalShares').textContent = formatNumber(stats.total_shares);
const params = new URLSearchParams({ days: period });
if (platform) params.append('platform', platform);
// Platform breakdown
renderPlatformBreakdown(stats.platform_breakdown);
try {
const statsRes = await fetch(`/api/analytics/dashboard?${params}`);
const stats = await statsRes.json();
// Content breakdown
renderContentBreakdown(stats.content_breakdown);
document.getElementById('stat-posts').textContent = stats.total_posts || 0;
document.getElementById('stat-impressions').textContent = formatNumber(stats.total_impressions || 0);
document.getElementById('stat-engagements').textContent = formatNumber(stats.total_engagements || 0);
document.getElementById('stat-rate').textContent = (stats.avg_engagement_rate || 0).toFixed(2) + '%';
// Load engagement trend
const trendRes = await fetch(`/api/analytics/engagement-trend?${params}`);
const trendData = await trendRes.json();
renderEngagementChart(trendData.trend);
updatePlatformChart(stats.platform_breakdown || {});
updateContentChart(stats.content_breakdown || {});
// Load top posts
const topRes = await fetch(`/api/analytics/top-posts?${params}&limit=5`);
const topData = await topRes.json();
renderTopPosts(topData.posts);
loadTopPosts(period, platform);
loadOptimalTimes(platform);
} catch (error) {
console.error('Error loading dashboard:', error);
}
}
// Load optimal times
const timesRes = await fetch(`/api/analytics/optimal-times?days=${days}${platform ? '&platform=' + platform : ''}`);
const timesData = await timesRes.json();
renderOptimalTimes(timesData.optimal_times);
function formatNumber(num) {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
return num.toString();
}
// Load reports
const reportsRes = await fetch('/api/analytics/reports?limit=5');
const reportsData = await reportsRes.json();
renderReports(reportsData.reports);
function updatePlatformChart(data) {
const ctx = document.getElementById('platformChart').getContext('2d');
const labels = Object.keys(data).length ? Object.keys(data) : ['Sin datos'];
const values = Object.keys(data).length ? Object.values(data) : [0];
} catch (error) {
console.error('Error loading dashboard:', error);
if (platformChart) platformChart.destroy();
platformChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: values,
backgroundColor: ['#6366f1', '#8b5cf6', '#ec4899', '#f59e0b'],
borderWidth: 0
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom', labels: { color: '#9ca3af' } }
}
}
});
}
function updateContentChart(data) {
const ctx = document.getElementById('contentChart').getContext('2d');
const labels = Object.keys(data).length ? Object.keys(data) : ['Sin datos'];
const values = Object.keys(data).length ? Object.values(data) : [0];
if (contentChart) contentChart.destroy();
contentChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Posts',
data: values,
backgroundColor: '#6366f1',
borderRadius: 8
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
y: { grid: { color: '#334155' }, ticks: { color: '#9ca3af' } },
x: { grid: { display: false }, ticks: { color: '#9ca3af' } }
}
}
});
}
async function loadTopPosts(period, platform) {
const container = document.getElementById('top-posts');
try {
const params = new URLSearchParams({ days: period, limit: 5 });
if (platform) params.append('platform', platform);
const response = await fetch(`/api/analytics/top-posts?${params}`);
const data = await response.json();
if (data.posts && data.posts.length) {
container.innerHTML = data.posts.map(post => `
<div class="bg-dark-800/50 rounded-xl p-4">
<p class="text-sm text-gray-300 mb-2">${post.content?.substring(0, 100) || 'Sin contenido'}...</p>
<div class="flex items-center justify-between text-xs text-gray-500">
<span>${post.platforms?.join(', ') || '-'}</span>
<span>Engagement: ${(post.engagement_rate || 0).toFixed(2)}%</span>
</div>
</div>
`).join('');
} else {
container.innerHTML = '<div class="text-center py-8 text-gray-500">No hay datos suficientes</div>';
}
} catch (error) {
container.innerHTML = '<div class="text-center py-8 text-red-400">Error cargando datos</div>';
}
}
function formatNumber(num) {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
return num.toString();
}
async function loadOptimalTimes(platform) {
const container = document.getElementById('optimal-times');
try {
const params = platform ? `?platform=${platform}` : '';
const response = await fetch(`/api/analytics/optimal-times${params}`);
const data = await response.json();
function renderPlatformBreakdown(breakdown) {
const container = document.getElementById('platformBreakdown');
container.innerHTML = '';
const days = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'];
const platformIcons = {
'x': '𝕏',
'threads': '🧵',
'instagram': '📷',
'facebook': '📘'
};
for (const [platform, data] of Object.entries(breakdown)) {
container.innerHTML += `
<div class="flex items-center justify-between bg-gray-800 rounded p-3">
<div class="flex items-center gap-3">
<span class="text-2xl">${platformIcons[platform] || '📱'}</span>
<div>
<div class="font-medium capitalize">${platform}</div>
<div class="text-sm text-gray-400">${data.posts} posts</div>
if (data.optimal_times && Object.keys(data.optimal_times).length) {
container.innerHTML = days.map((day, i) => {
const times = data.optimal_times[i] || [];
return `
<div class="bg-dark-800/50 rounded-xl p-3 text-center">
<p class="font-medium text-sm mb-2">${day}</p>
<div class="space-y-1">
${times.length ? times.slice(0, 3).map(t => `<span class="text-xs bg-primary/20 text-primary px-2 py-1 rounded-full block">${t}:00</span>`).join('') : '<span class="text-xs text-gray-500">-</span>'}
</div>
</div>
<div class="text-right">
<div class="text-green-400 font-bold">${formatNumber(data.engagements)}</div>
<div class="text-sm text-gray-400">interacciones</div>
</div>
</div>
`;
}
if (Object.keys(breakdown).length === 0) {
container.innerHTML = '<p class="text-gray-500">No hay datos disponibles</p>';
`;
}).join('');
} else {
container.innerHTML = '<div class="text-center py-8 text-gray-500 col-span-7">No hay datos suficientes para calcular horarios óptimos</div>';
}
} catch (error) {
container.innerHTML = '<div class="text-center py-8 text-red-400 col-span-7">Error cargando datos</div>';
}
}
function renderContentBreakdown(breakdown) {
const container = document.getElementById('contentBreakdown');
container.innerHTML = '';
for (const [type, data] of Object.entries(breakdown)) {
container.innerHTML += `
<div class="bg-gray-800 rounded p-4 text-center">
<div class="text-lg font-bold accent">${data.posts}</div>
<div class="text-sm text-gray-400 capitalize">${type.replace('_', ' ')}</div>
<div class="text-xs text-green-400 mt-1">${formatNumber(data.engagements)} eng.</div>
</div>
`;
}
if (Object.keys(breakdown).length === 0) {
container.innerHTML = '<p class="text-gray-500 col-span-4">No hay datos disponibles</p>';
}
}
function renderEngagementChart(trend) {
const ctx = document.getElementById('engagementChart').getContext('2d');
if (engagementChart) {
engagementChart.destroy();
}
engagementChart = new Chart(ctx, {
type: 'line',
data: {
labels: trend.map(d => d.date),
datasets: [
{
label: 'Impresiones',
data: trend.map(d => d.impressions),
borderColor: '#60a5fa',
backgroundColor: 'rgba(96, 165, 250, 0.1)',
tension: 0.3,
fill: true
},
{
label: 'Interacciones',
data: trend.map(d => d.engagements),
borderColor: '#34d399',
backgroundColor: 'rgba(52, 211, 153, 0.1)',
tension: 0.3,
fill: true
}
]
},
options: {
responsive: true,
plugins: {
legend: {
labels: { color: '#9ca3af' }
}
},
scales: {
x: {
ticks: { color: '#9ca3af' },
grid: { color: '#374151' }
},
y: {
ticks: { color: '#9ca3af' },
grid: { color: '#374151' }
}
}
}
});
}
function renderTopPosts(posts) {
const container = document.getElementById('topPosts');
container.innerHTML = '';
posts.forEach((post, i) => {
container.innerHTML += `
<div class="bg-gray-800 rounded p-3">
<div class="flex justify-between items-start mb-2">
<span class="text-accent font-bold">#${i + 1}</span>
<span class="text-green-400 text-sm">${post.engagement_rate}% eng.</span>
</div>
<p class="text-sm text-gray-300 mb-2">${post.content}</p>
<div class="flex gap-4 text-xs text-gray-500">
<span>❤️ ${post.likes}</span>
<span>💬 ${post.comments}</span>
<span>🔄 ${post.shares}</span>
<span class="ml-auto">${post.platforms.join(', ')}</span>
</div>
</div>
`;
});
if (posts.length === 0) {
container.innerHTML = '<p class="text-gray-500">No hay posts con métricas</p>';
}
}
function renderOptimalTimes(times) {
const container = document.getElementById('optimalTimes');
container.innerHTML = '';
// Show top 10 times
times.slice(0, 10).forEach(time => {
const barWidth = Math.min(time.avg_engagement_rate * 10, 100);
container.innerHTML += `
<div class="flex items-center gap-3">
<span class="w-20 text-sm text-gray-400">${time.day_name} ${time.hour_formatted}</span>
<div class="flex-1 bg-gray-800 rounded h-4 overflow-hidden">
<div class="bg-accent h-full" style="width: ${barWidth}%"></div>
</div>
<span class="text-sm text-green-400 w-16 text-right">${time.avg_engagement_rate}%</span>
</div>
`;
});
if (times.length === 0) {
container.innerHTML = '<p class="text-gray-500">No hay suficientes datos</p>';
}
}
function renderReports(reports) {
const container = document.getElementById('reportsList');
container.innerHTML = '';
reports.forEach(report => {
container.innerHTML += `
<div class="bg-gray-800 rounded p-3 flex justify-between items-center">
<div>
<span class="font-medium capitalize">${report.report_type}</span>
<span class="text-gray-500 text-sm ml-2">
${report.period_start} - ${report.period_end}
</span>
</div>
<div class="flex gap-4 text-sm">
<span>${report.total_posts} posts</span>
<span class="text-green-400">${report.avg_engagement_rate}% eng.</span>
</div>
</div>
`;
});
if (reports.length === 0) {
container.innerHTML = '<p class="text-gray-500">No hay reportes generados</p>';
}
}
async function generateReport() {
try {
const res = await fetch('/api/analytics/reports/generate?report_type=weekly', {
method: 'POST'
});
const data = await res.json();
if (res.ok) {
alert('Reporte generado exitosamente');
loadDashboard();
} else {
alert('Error: ' + data.detail);
}
} catch (error) {
alert('Error generando reporte');
}
}
async function sendReportTelegram() {
try {
const res = await fetch('/api/analytics/reports/send-telegram', {
method: 'POST'
});
const data = await res.json();
alert(data.message);
} catch (error) {
alert('Error enviando reporte');
}
}
// Load on page load
document.addEventListener('DOMContentLoaded', loadDashboard);
</script>
</body>
</html>
// Load on page load
loadDashboard();
</script>
{% endblock %}