Añade /generate short_en y /short_spec. El pipeline genera un shot spec
con Haiku, verifica cada cifra, fecha y cita contra los chunks de la
sesión, lo renderiza en shortsmith y entrega el MP4 por Telegram junto
a un informe de claims.
- ShortsmithClient con sondeo y fallback al spec JSON si el render falla
- Contrato de plantillas obtenido de GET /templates, no codificado
- Comprobación de fundamento determinista, sin LLM
- outputs.published_url para enlazar el artículo de Ghost
- Normalización de comillas rectas a tipográficas (ver KNOWN-ISSUES.md)
Lo que no aparece en los chunks se contrasta contra el ejemplo del
prompt: si casa ahí es fuga, no invención, y se informa como tal. El
purgado de sesiones se lleva también su MP4.
La subida a YouTube queda fuera a propósito: fase 3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ALLOWED_TAGS son SLUGS y Ghost casa los tags de un post por NOMBRE. Mandarlos
como {"name": slug} funcionaba en EN por casualidad — allí los tags se llaman
igual que su slug ("military-cases") — y en ES rompía: no hay ningún tag
llamado "casos-militares" (se llama "Casos Militares"), así que Ghost creaba
uno nuevo con ese nombre y, con el slug ya pillado, lo dejaba en
`casos-militares-2`.
Encontrado el 2026-07-29 al preparar el hub: 5 tags duplicados, 7 posts
repartidos entre dos archivos flacos cada uno, y los cinco `-2` ofrecidos a
Google en sitemap-tags.xml. Los datos ya están fusionados en Ghost; esto es
para que no vuelva.
Se resuelve el slug a ID contra la Admin API, que es lo único no ambiguo justo
en el punto donde falla la ambigüedad. Un slug que no exista se DESCARTA con
aviso en vez de crearse: la lista es cerrada, así que no existir significa que
está mal escrita, y crear el tag es exactamente el bug. Si no resuelve nada, o
si Ghost no contesta, se cae al comportamiento anterior antes que publicar un
post sin ninguna categoría.
7 tests nuevos, incluido uno para EN — donde el bug era invisible por la
coincidencia nombre==slug y el resolutor no debe depender de ella.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
El 1.000-1.500 que había no lo cumplía nadie (los dos artículos de prueba del
2026-07-21 salieron a 2.143 y 4.390) y además apuntaba a la franja que PEOR
funciona: en el blog ES los ocho posts por debajo de 1.500 palabras tienen cero
clics los ocho, y en los dos blogs la mediana de los posts con clics ronda las
2.400 palabras frente a las 1.400 de los que no tienen ninguno.
Ojo con la lectura: la correlación está confundida. Los artículos largos son
los casos famosos (Grusch, Rendlesham, Varginha, Roswell), que tienen más
demanda de búsqueda Y más material del que escribir. En el EN, de hecho, los
clics por post son idénticos por encima y por debajo de 1.800 palabras. Lo que
los datos sí descartan es el 1.000-1.500; no demuestran que largo sea mejor.
El tope de 12 encabezados importa más que el recuento: el borrador de 4.390
palabras traía 22 secciones (la mediana del corpus es 9, el máximo 16), y a los
modelos se les da mucho mejor respetar un límite estructural que uno de
palabras. Un número que se incumple siempre no sirve ni de alarma: con el
objetivo puesto donde de verdad está el corpus, una desviación se nota.
El motivo de peso no es SEO sino el tiempo de revisión editorial, que es la
queja original: 4.390 palabras en 22 secciones son el triple de trabajo.
Estaba capado a EN porque las stopwords del motor eran inglesas: en español
«que» o «los» pasaban el filtro de 3 caracteres y contaban como identidad de
caso, así que el aviso habría sido ruido. Con las stopwords ES y el tokenizador
sin acentos (chemavx-seo-tools 31c9d3e) ese motivo ya no existe.
Se destapó generando a propósito un artículo de Canarias 1976 que ya estaba
publicado: el motor lo detectaba con severidad alta («same case + year:
canarias, 1976») y el aviso no llegaba a Telegram. Simulado con el corpus real
antes de tocar nada — con la reja quitada, el mensaje sale correcto.
fetch_collision_corpus y collision_notice ya eran agnósticas del idioma, así
que no hay más cambios. Sigue sin bloquear: el draft se crea igual.
La plantilla daba los encabezados LITERALES («## Contexto», «## Hechos
Clave»), así que el modelo los escupía tal cual o los usaba de prefijo. Se ve
en los dos blogs: 219 encabezados sucios en el ES (limpiados hoy a mano) y 77
de los 340 del EN empezando por la etiqueta de la plantilla — 37 artículos
ingleses terminan con un H2 que dice «Conclusion».
Ahora la estructura va entre corchetes, con la advertencia de que es guía
interna, y una regla prohíbe la etiqueta como encabezado y como prefijo.
Además, solo en el ES: mayúscula de oración. El prompt no decía nada de
capitalización y la única muestra que veía el modelo era «Hechos Clave», en
Title Case; generalizaba ese estilo a todos los encabezados, que en español
es incorrecto. Va con contraejemplo y con el caso de los dos puntos y la raya.
En el EN no se toca: ahí el Title Case es lo correcto.
Sin verificación posible hasta el próximo artículo — un prompt no se prueba en
seco. El check de mayúsculas de seo_watch (chemavx-seo-tools b0d5e1b) cubre la
regresión en el ES a partir de ahora.
Al publicar el draft EN en Ghost, el título propuesto se compara contra
los posts published+scheduled del sitio (corpus vía Admin API — incluye
la cola programada, justo el caso del doble Kecksburg del 2026-07-10) con
el topic_collision vendorizado. Si colisiona, el notice de Telegram lleva
un bloque "🚨 Posible colisión de tema" en las tres rutas (live, dryrun,
bare). Nunca bloquea: el draft se crea igual, el humano decide (fusionar,
retitular o enlazar a propósito). Solo lang=en (stopwords inglesas).
Títulos ajenos saneados de entidades Markdown (regla de _safe_send).
Aislamiento: cualquier fallo del check → notice sin bloque y warning en
logs, jamás rompe la publicación.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
find_draft_by_title y fetch_published_menu duplicaban línea a línea el GET
admin de Ghost (token, URL, ClientSession, dict de headers, status check,
resp.json), y el dict Authorization/Accept-Version/Accept-Encoding estaba
copiado en 3 sitios — el incidente brotli ya demostró que el fix por copia
se deja sitios. Ahora los headers se construyen en un único punto
(_admin_headers, con SAFE_ACCEPT_ENCODING de config) y el GET en _admin_get;
publish_draft usa los mismos headers. aiohttp pasa a import de módulo en
generator.py (era 'import aiohttp as _aio' repetido dentro de dos métodos).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Si Ghost normaliza el título al guardar (trunca a 255 un H1 largo del LLM,
colapsa whitespace), la igualdad exacta no encontraba el draft recién creado
y la recuperación fallaba justo en el escenario que debe cubrir. _norm_title
aplica el mismo colapso+truncado a ambos lados de la comparación.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
El guard anterior vivía en el fallback de autofill y casaba con CUALQUIER
draft del mismo título: un fallo pre-POST (Ollama caído) con un draft viejo
del mismo topic descartaba en silencio el contenido recién generado.
Ahora la recuperación vive dentro de publish_draft y solo se activa cuando
el POST fue aceptado (2xx) y falla la lectura de la respuesta — el escenario
exacto del incidente br del 2026-07-04 — con filtro since=attempt_start en
find_draft_by_title. Cubre a todos los callers: autofill, bare publish y
/publish (antes sin proteger: reintento del usuario = draft duplicado).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Si el autofill falla DESPUÉS de que Ghost aceptara el POST (p.ej. error
leyendo la respuesta), el draft ya existe: el fallback ahora comprueba
por título exacto entre los drafts recientes (find_draft_by_title) y
reutiliza el existente en vez de duplicar. Solo aplica al camino de
fallback-tras-excepción — el modo OFF y la re-generación deliberada
siguen creando draft nuevo como siempre. Best-effort: si el check
falla, se publica bare como antes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Blindaje contra el default de aiohttp: si algún día se reinstala un
backend brotli, las sesiones sin Accept-Encoding volverían a anunciar
br y el decode roto de aiohttp 3.14 rompería la lectura de respuestas
de Ghost (duplicando drafts). Con el header fijado no puede regresar.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sección crítica:
- is_blacklisted: match por dominio/subdominio exacto (antes "x.com" como
substring bloqueaba netflix.com, phoenix.com, etc.)
- normalize_url: conserva el query string (rompía YouTube watch?v= y URLs
con ?id=); solo borra el fragment
- get_db: PRAGMA busy_timeout=5000 para evitar "database is locked" en
/compare y watches solapados
- OllamaClient.embed: usa OLLAMA_EMBED_MODEL en vez del modelo de chat
- log_api_call: coste por modelo (opus/sonnet/haiku) en vez de Haiku fijo
Mejoras:
- src/llm.py: cliente Anthropic compartido y cacheado (antes se instanciaba
uno por cada llamada/chunk)
- SEARXNG_URL configurable via env
- get_running_loop() en vez de get_event_loop() (deprecado)
- soup.title.get_text() robusto ante <title> con tags anidados
- limpieza: import muerto, total_words duplicado, w_id no usado
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ghost añade el título del post automáticamente en el frontend,
por lo que el <h1> generado desde el markdown aparecía duplicado.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
El campo "html" en Ghost Admin API v5 (Lexical editor) es de solo
lectura. El contenido se debe enviar via mobiledoc con HTML card,
que Ghost acepta en todas las versiones de v5 y renderiza sin
conversión. Añadidos logs de diagnóstico y validación de HTML vacío.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Use Claude Haiku (via ANTHROPIC_API_KEY) for all output generation.
Falls back to Ollama qwen2.5:3b if no API key is set.
Also translates all user-turn prompts to Spanish for consistency.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add "Escribe SIEMPRE en español" at the start of all system prompts
(podcast, blog, report, thread) so Ollama generates content in Spanish.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
database.py: enable PRAGMA journal_mode=WAL + synchronous=NORMAL so
/status reads from concurrent connections see committed data without
blocking behind the scraper's writes; add 'skipped' to get_session_stats
bot.py: show skipped count in fmt_progress and cmd_status; use 'or 0'
to guard against NULL from SUM(); label active research in /status
processor.py: raise generate() temperature default to 0.7 + add
repeat_penalty=1.15/repeat_last_n=128 to Ollama options to stop
qwen2.5:3b from looping; scoring prompt keeps temperature=0.1
generator.py: rewrite all prompts with explicit "NEVER repeat"
constraints and distinct-content rules per section; podcast prompt
now asks for spoken-word style (no formal headers); reduce thread
to 12-18 tweets (was 15-25) to fit model context; pass temperature=0.7
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>