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,449 +1,156 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Posts - Social Media Automation</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<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; }
.btn-secondary:hover { background-color: #4b5563; }
.btn-danger { background-color: #dc2626; }
.btn-danger:hover { background-color: #b91c1c; }
.status-published { background-color: #065f46; color: #6ee7b7; }
.status-scheduled { background-color: #1e40af; color: #93c5fd; }
.status-pending { background-color: #92400e; color: #fcd34d; }
.status-draft { background-color: #374151; color: #9ca3af; }
.status-failed { background-color: #991b1b; color: #fca5a5; }
.platform-x { background-color: #1d1d1d; }
.platform-threads { background-color: #1a1a1a; }
.platform-instagram { background: linear-gradient(45deg, #f09433, #e6683c, #dc2743, #cc2366, #bc1888); }
.platform-facebook { background-color: #1877f2; }
</style>
</head>
<body class="min-h-screen">
{% extends "base.html" %}
{% block title %}Posts{% 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> - Social Media
</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">+ Crear</a>
<a href="/dashboard/posts" class="px-4 py-2 rounded bg-gray-800 accent">Posts</a>
<a href="/dashboard/calendar" class="px-4 py-2 rounded hover:bg-gray-800">Calendario</a>
<a href="/dashboard/interactions" class="px-4 py-2 rounded hover:bg-gray-800">Interacciones</a>
<a href="/dashboard/settings" class="px-4 py-2 rounded hover:bg-gray-800">Config</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">Posts</h1>
<p class="text-gray-400 mt-1">Gestiona todas tus publicaciones</p>
</div>
</header>
<a href="/compose" class="btn-primary px-6 py-3 rounded-xl font-medium flex items-center gap-2 transition-all">
<span>✍️</span>
<span>Crear Post</span>
</a>
</div>
<main class="container mx-auto px-6 py-8">
<!-- Filters -->
<div class="card p-4 mb-6">
<div class="flex flex-wrap gap-4 items-center">
<div>
<label class="text-sm text-gray-400 block mb-1">Estado</label>
<select id="filter-status" onchange="filterPosts()" class="bg-gray-800 border border-gray-700 rounded px-3 py-2">
<option value="">Todos</option>
<option value="published">Publicados</option>
<option value="scheduled">Programados</option>
<option value="pending_approval">Pendientes</option>
<option value="draft">Borradores</option>
<option value="failed">Fallidos</option>
</select>
</div>
<div>
<label class="text-sm text-gray-400 block mb-1">Plataforma</label>
<select id="filter-platform" onchange="filterPosts()" class="bg-gray-800 border border-gray-700 rounded px-3 py-2">
<option value="">Todas</option>
<option value="x">X (Twitter)</option>
<option value="threads">Threads</option>
<option value="instagram">Instagram</option>
<option value="facebook">Facebook</option>
</select>
</div>
<div>
<label class="text-sm text-gray-400 block mb-1">Tipo</label>
<select id="filter-type" onchange="filterPosts()" class="bg-gray-800 border border-gray-700 rounded px-3 py-2">
<option value="">Todos</option>
<option value="tip">Tips</option>
<option value="product">Productos</option>
<option value="service">Servicios</option>
<option value="engagement">Engagement</option>
</select>
</div>
<div class="flex-1"></div>
<div>
<a href="/dashboard/compose" class="btn-primary px-4 py-2 rounded inline-block">
+ Nuevo Post
</a>
</div>
</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-5 gap-4 mb-6">
<div class="card p-4 text-center">
<div class="text-2xl font-bold text-green-400" id="stat-published">0</div>
<div class="text-gray-400 text-sm">Publicados</div>
</div>
<div class="card p-4 text-center">
<div class="text-2xl font-bold text-blue-400" id="stat-scheduled">0</div>
<div class="text-gray-400 text-sm">Programados</div>
</div>
<div class="card p-4 text-center">
<div class="text-2xl font-bold text-yellow-400" id="stat-pending">0</div>
<div class="text-gray-400 text-sm">Pendientes</div>
</div>
<div class="card p-4 text-center">
<div class="text-2xl font-bold text-gray-400" id="stat-draft">0</div>
<div class="text-gray-400 text-sm">Borradores</div>
</div>
<div class="card p-4 text-center">
<div class="text-2xl font-bold text-red-400" id="stat-failed">0</div>
<div class="text-gray-400 text-sm">Fallidos</div>
</div>
</div>
<!-- Posts List -->
<div class="card">
<div class="p-4 border-b border-gray-700 flex justify-between items-center">
<h2 class="text-xl font-semibold">Posts</h2>
<span class="text-gray-400 text-sm" id="posts-count">0 posts</span>
</div>
<div id="posts-list" class="divide-y divide-gray-700">
<!-- Posts loaded dynamically -->
<div class="p-8 text-center text-gray-500">
Cargando posts...
</div>
</div>
</div>
<!-- Pagination -->
<div class="flex justify-center gap-2 mt-6" id="pagination">
<!-- Pagination buttons -->
</div>
</main>
<!-- Edit Modal -->
<div id="edit-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="card p-6 max-w-2xl w-full mx-4">
<div class="flex justify-between items-center mb-4">
<h3 class="font-semibold text-lg">Editar Post</h3>
<button onclick="closeEditModal()" class="text-gray-400 hover:text-white"></button>
</div>
<form id="edit-form">
<input type="hidden" id="edit-post-id">
<div class="mb-4">
<label class="block text-sm text-gray-400 mb-1">Contenido</label>
<textarea id="edit-content" rows="4" class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2"></textarea>
<div class="text-right text-sm text-gray-500 mt-1">
<span id="edit-char-count">0</span> caracteres
</div>
</div>
<div class="mb-4">
<label class="block text-sm text-gray-400 mb-1">Programar para</label>
<input type="datetime-local" id="edit-scheduled" class="bg-gray-800 border border-gray-700 rounded px-3 py-2">
</div>
<div class="flex gap-2 justify-end">
<button type="button" onclick="closeEditModal()" class="btn-secondary px-4 py-2 rounded">Cancelar</button>
<button type="submit" class="btn-primary px-4 py-2 rounded">Guardar</button>
</div>
</form>
<!-- Filters -->
<div class="card rounded-2xl p-4 mb-6">
<div class="flex flex-wrap gap-4 items-center">
<select id="filter-status" onchange="filterPosts()" class="bg-dark-800 border border-dark-600 rounded-xl px-4 py-2 text-white focus:border-primary focus:outline-none">
<option value="">Todos los estados</option>
<option value="published">Publicados</option>
<option value="scheduled">Programados</option>
<option value="pending_approval">Pendientes</option>
<option value="draft">Borradores</option>
<option value="failed">Fallidos</option>
</select>
<select id="filter-platform" onchange="filterPosts()" 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>
<input type="text" id="filter-search" placeholder="Buscar..." onkeyup="filterPosts()" class="bg-dark-800 border border-dark-600 rounded-xl px-4 py-2 text-white placeholder-gray-500 focus:border-primary focus:outline-none flex-1 min-w-48">
</div>
</div>
<script>
let allPosts = [];
let currentPage = 1;
const postsPerPage = 20;
// Load posts on page load
window.addEventListener('load', loadPosts);
async function loadPosts() {
try {
const response = await fetch('/api/posts/');
allPosts = await response.json();
updateStats();
filterPosts();
} catch (error) {
console.error('Error loading posts:', error);
document.getElementById('posts-list').innerHTML =
'<div class="p-8 text-center text-red-400">Error cargando posts</div>';
}
}
function updateStats() {
const stats = {
published: 0,
scheduled: 0,
pending_approval: 0,
draft: 0,
failed: 0
};
allPosts.forEach(post => {
if (stats[post.status] !== undefined) {
stats[post.status]++;
}
});
document.getElementById('stat-published').textContent = stats.published;
document.getElementById('stat-scheduled').textContent = stats.scheduled;
document.getElementById('stat-pending').textContent = stats.pending_approval;
document.getElementById('stat-draft').textContent = stats.draft;
document.getElementById('stat-failed').textContent = stats.failed;
}
function filterPosts() {
const status = document.getElementById('filter-status').value;
const platform = document.getElementById('filter-platform').value;
const type = document.getElementById('filter-type').value;
let filtered = allPosts;
if (status) {
filtered = filtered.filter(p => p.status === status);
}
if (platform) {
filtered = filtered.filter(p => p.platforms && p.platforms.includes(platform));
}
if (type) {
filtered = filtered.filter(p => p.content_type === type);
}
renderPosts(filtered);
}
function renderPosts(posts) {
const container = document.getElementById('posts-list');
document.getElementById('posts-count').textContent = `${posts.length} posts`;
if (posts.length === 0) {
container.innerHTML = '<div class="p-8 text-center text-gray-500">No hay posts</div>';
return;
}
// Pagination
const start = (currentPage - 1) * postsPerPage;
const pagePosts = posts.slice(start, start + postsPerPage);
container.innerHTML = pagePosts.map(post => `
<div class="p-4 hover:bg-gray-800 transition-colors" data-post-id="${post.id}">
<div class="flex gap-4">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<span class="status-${post.status} px-2 py-1 rounded text-xs font-medium">
${getStatusLabel(post.status)}
</span>
${post.platforms ? post.platforms.map(p => `
<span class="platform-${p} px-2 py-1 rounded text-xs">${p}</span>
`).join('') : ''}
<span class="text-gray-500 text-xs">${post.content_type || 'general'}</span>
</div>
<p class="text-gray-300 mb-2">${escapeHtml(post.content || '').substring(0, 200)}${post.content && post.content.length > 200 ? '...' : ''}</p>
<div class="text-gray-500 text-xs">
${post.scheduled_at ? `Programado: ${formatDate(post.scheduled_at)}` : ''}
${post.published_at ? `Publicado: ${formatDate(post.published_at)}` : ''}
</div>
</div>
<div class="flex flex-col gap-2">
${post.status === 'pending_approval' ? `
<button onclick="approvePost(${post.id})" class="btn-primary px-3 py-1 rounded text-sm">Aprobar</button>
<button onclick="rejectPost(${post.id})" class="btn-danger px-3 py-1 rounded text-sm">Rechazar</button>
` : ''}
${post.status === 'scheduled' ? `
<button onclick="publishNow(${post.id})" class="btn-primary px-3 py-1 rounded text-sm">Publicar Ya</button>
<button onclick="cancelPost(${post.id})" class="btn-secondary px-3 py-1 rounded text-sm">Cancelar</button>
` : ''}
${post.status === 'draft' ? `
<button onclick="schedulePost(${post.id})" class="btn-primary px-3 py-1 rounded text-sm">Programar</button>
` : ''}
<button onclick="editPost(${post.id})" class="btn-secondary px-3 py-1 rounded text-sm">Editar</button>
<button onclick="deletePost(${post.id})" class="text-red-400 hover:text-red-300 text-sm">Eliminar</button>
</div>
<!-- Posts List -->
<div id="posts-container" class="space-y-4">
{% for post in posts %}
<div class="card rounded-2xl p-6 hover:border-primary/30 transition-all post-item"
data-status="{{ post.status }}"
data-platforms="{{ post.platforms|join(',') if post.platforms else '' }}"
data-content="{{ post.content|lower }}">
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<p class="text-white mb-3">{{ post.content[:200] }}{% if post.content|length > 200 %}...{% endif %}</p>
<div class="flex flex-wrap items-center gap-2">
{% for platform in post.platforms %}
<span class="text-xs px-2 py-1 rounded-full
{% if platform == 'x' %}bg-gray-700{% endif %}
{% if platform == 'facebook' %}bg-blue-600/30 text-blue-400{% endif %}
{% if platform == 'instagram' %}bg-pink-600/30 text-pink-400{% endif %}
{% if platform == 'threads' %}bg-gray-600{% endif %}
">{{ platform }}</span>
{% endfor %}
<span class="text-xs px-2 py-1 rounded-full
{% if post.status == 'published' %}bg-green-500/20 text-green-400{% endif %}
{% if post.status == 'scheduled' %}bg-blue-500/20 text-blue-400{% endif %}
{% if post.status == 'pending_approval' %}bg-yellow-500/20 text-yellow-400{% endif %}
{% if post.status == 'draft' %}bg-gray-500/20 text-gray-400{% endif %}
{% if post.status == 'failed' %}bg-red-500/20 text-red-400{% endif %}
">{{ post.status }}</span>
</div>
</div>
`).join('');
<div class="flex flex-col items-end gap-2">
<span class="text-xs text-gray-500">{{ post.created_at[:16] if post.created_at else '-' }}</span>
<div class="flex gap-2">
{% if post.status == 'draft' or post.status == 'pending_approval' %}
<button onclick="publishPost({{ post.id }})" class="text-xs bg-green-500/20 text-green-400 px-3 py-1 rounded-lg hover:bg-green-500/30 transition-colors">
Publicar
</button>
{% endif %}
<button onclick="deletePost({{ post.id }})" class="text-xs bg-red-500/20 text-red-400 px-3 py-1 rounded-lg hover:bg-red-500/30 transition-colors">
Eliminar
</button>
</div>
</div>
</div>
</div>
{% else %}
<div class="text-center py-16 text-gray-500">
<span class="text-6xl mb-4 block">📭</span>
<p class="text-xl">No hay posts</p>
<p class="mt-2">Crea tu primera publicación</p>
<a href="/compose" class="btn-primary px-6 py-3 rounded-xl font-medium inline-flex items-center gap-2 mt-4">
<span>✍️</span>
<span>Crear Post</span>
</a>
</div>
{% endfor %}
</div>
</div>
{% endblock %}
// Render pagination
renderPagination(posts.length);
}
{% block extra_scripts %}
<script>
function filterPosts() {
const status = document.getElementById('filter-status').value;
const platform = document.getElementById('filter-platform').value;
const search = document.getElementById('filter-search').value.toLowerCase();
function renderPagination(totalPosts) {
const totalPages = Math.ceil(totalPosts / postsPerPage);
const container = document.getElementById('pagination');
document.querySelectorAll('.post-item').forEach(post => {
const postStatus = post.dataset.status;
const postPlatforms = post.dataset.platforms;
const postContent = post.dataset.content;
if (totalPages <= 1) {
container.innerHTML = '';
return;
}
const matchesStatus = !status || postStatus === status;
const matchesPlatform = !platform || postPlatforms.includes(platform);
const matchesSearch = !search || postContent.includes(search);
let html = '';
for (let i = 1; i <= totalPages; i++) {
html += `<button onclick="goToPage(${i})" class="${i === currentPage ? 'btn-primary' : 'btn-secondary'} px-3 py-1 rounded">${i}</button>`;
}
container.innerHTML = html;
}
function goToPage(page) {
currentPage = page;
filterPosts();
}
function getStatusLabel(status) {
const labels = {
'published': 'Publicado',
'scheduled': 'Programado',
'pending_approval': 'Pendiente',
'draft': 'Borrador',
'failed': 'Fallido'
};
return labels[status] || status;
}
function formatDate(dateStr) {
const date = new Date(dateStr);
return date.toLocaleString('es-MX', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Actions
async function approvePost(id) {
if (!confirm('¿Aprobar este post para publicación?')) return;
try {
await fetch(`/api/posts/${id}/approve`, { method: 'POST' });
loadPosts();
} catch (error) {
alert('Error al aprobar: ' + error.message);
}
}
async function rejectPost(id) {
if (!confirm('¿Rechazar este post?')) return;
try {
await fetch(`/api/posts/${id}/reject`, { method: 'POST' });
loadPosts();
} catch (error) {
alert('Error al rechazar: ' + error.message);
}
}
async function publishNow(id) {
if (!confirm('¿Publicar inmediatamente?')) return;
try {
await fetch(`/api/calendar/posts/${id}/publish-now`, { method: 'POST' });
alert('Post enviado a publicación');
loadPosts();
} catch (error) {
alert('Error: ' + error.message);
}
}
async function cancelPost(id) {
if (!confirm('¿Cancelar programación?')) return;
try {
await fetch(`/api/calendar/posts/${id}/cancel`, { method: 'POST' });
loadPosts();
} catch (error) {
alert('Error: ' + error.message);
}
}
async function schedulePost(id) {
try {
const response = await fetch(`/api/calendar/posts/${id}/schedule?auto=true`, { method: 'POST' });
const data = await response.json();
alert(`Programado para: ${formatDate(data.scheduled_at)}`);
loadPosts();
} catch (error) {
alert('Error: ' + error.message);
}
}
function editPost(id) {
const post = allPosts.find(p => p.id === id);
if (!post) return;
document.getElementById('edit-post-id').value = id;
document.getElementById('edit-content').value = post.content || '';
document.getElementById('edit-char-count').textContent = (post.content || '').length;
if (post.scheduled_at) {
const date = new Date(post.scheduled_at);
document.getElementById('edit-scheduled').value = date.toISOString().slice(0, 16);
}
document.getElementById('edit-modal').classList.remove('hidden');
document.getElementById('edit-modal').classList.add('flex');
}
function closeEditModal() {
document.getElementById('edit-modal').classList.add('hidden');
document.getElementById('edit-modal').classList.remove('flex');
}
document.getElementById('edit-content').addEventListener('input', function() {
document.getElementById('edit-char-count').textContent = this.value.length;
post.style.display = matchesStatus && matchesPlatform && matchesSearch ? 'block' : 'none';
});
}
document.getElementById('edit-form').addEventListener('submit', async function(e) {
e.preventDefault();
async function publishPost(id) {
if (!confirm('¿Publicar este post ahora?')) return;
const id = document.getElementById('edit-post-id').value;
const content = document.getElementById('edit-content').value;
const scheduled = document.getElementById('edit-scheduled').value;
showModal('Publicando...', true);
try {
const response = await fetch(`/api/posts/${id}/publish`, { method: 'POST' });
const data = await response.json();
try {
await fetch(`/api/posts/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content,
scheduled_at: scheduled || null
})
});
closeEditModal();
loadPosts();
} catch (error) {
alert('Error al guardar: ' + error.message);
}
});
async function deletePost(id) {
if (!confirm('¿Eliminar este post permanentemente?')) return;
try {
await fetch(`/api/posts/${id}`, { method: 'DELETE' });
loadPosts();
} catch (error) {
alert('Error al eliminar: ' + error.message);
if (data.success) {
showModal('<div class="text-center"><span class="text-4xl mb-4 block">🎉</span><p>¡Publicado!</p></div>');
setTimeout(() => location.reload(), 1500);
} else {
showModal(`<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>${data.error || 'Error'}</p></div>`);
}
} catch (error) {
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error de conexión</p></div>');
}
</script>
</body>
</html>
}
async function deletePost(id) {
if (!confirm('¿Eliminar este post?')) return;
showModal('Eliminando...', true);
try {
const response = await fetch(`/api/posts/${id}`, { method: 'DELETE' });
if (response.ok) {
showModal('<div class="text-center"><span class="text-4xl mb-4 block">🗑️</span><p>Eliminado</p></div>');
setTimeout(() => location.reload(), 1000);
} else {
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error eliminando</p></div>');
}
} catch (error) {
showModal('<div class="text-center"><span class="text-4xl mb-4 block">❌</span><p>Error de conexión</p></div>');
}
}
</script>
{% endblock %}