`/upload_short` contaba lo que la respuesta de la subida decía del vídeo. Eso
es la palabra de la API sobre sí misma, y todo el flujo de revisión descansa en
ella: el informe de fundamento se lee ANTES de publicar sólo si subir no
publica. El 2026-08-12, mirando un vídeo recién subido, la respuesta decía
`privacyStatus: private` y el vídeo se veía sin sesión — resultó ser un clic
humano en Studio y no un fallo, pero el episodio dejó claro que no había forma
de distinguir un caso del otro.
Ahora se contrasta: oEmbed contesta 200 a un vídeo que se ve sin sesión y 404 a
uno que no. Sin credenciales, sin tocar el scope — `youtube.upload` no puede
preguntar por el estado de un vídeo, y ampliarlo a uno que sí pueda significa
darle a un token de subida permiso para vaciar el canal.
Dos decisiones que van con esto:
- **Sólo el 200 es una prueba.** Un 404 no demuestra que el vídeo sea privado:
también lo devuelve uno que YouTube aún no ha indexado. Por eso el negativo
se mira dos veces y, si sigue negativo, se cuenta como "no se ve desde
fuera", no como "es privado".
- **No haber podido comprobar no es haber comprobado que no.** Un fallo de red
deja `reachable=None` y el parte lo dice, en vez de heredar la garantía que
no tiene.
Cuando la API dice privado y el vídeo se ve, el aviso va en la PRIMERA línea
del mensaje de Telegram: enterarse tiene que costar cero atención.
Verificado contra la realidad — el mismo vídeo daba True antes de ocultarlo y
False después; un vídeo borrado y uno privado dan False, y uno público True.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`editorial_notes` documentaba "se comenta una vez y, si insiste, se renderiza
igual", pero el bucle reintentaba dos veces: las sesiones 166, 167 y 168
gastaron los tres intentos y las tres acabaron renderizando un spec que seguía
pasándose. Un spec que ya cumple el contrato es renderizable; los intentos que
quedan son para el contrato, que sí es binario.
Y de dos specs válidos se guarda el que menos se sale del objetivo, no el
primero. Obedecer a medias es obedecer: antes, una reescritura que bajaba de
70 s a 50 s se tiraba entera y salía el largo.
El prompt, que era la mitad que faltaba. Decirle "20-45 segundos" no le sirve
de nada: la duración real no está escrita en ninguna parte del spec, sale de
sumar plano a plano el mayor entre lo declarado y lo que tarda la voz, y eso no
lo puede calcular quien no sabe a qué velocidad se le lee. Ahora lleva el
ritmo (2,8 palabras por segundo), la cuenta por plano, un presupuesto de 80
palabras de narración, y dos topes que salen del propio ejemplo en vez de estar
inventados: 12 palabras por línea (tope 18) y 6 s por plano. El anterior — "una
línea de menos de 25 palabras" — no describía nada que el canal hubiera
publicado, y el modelo escribía líneas de 25 y 27 sin saltarse ninguna regla.
Medido con cinco generaciones reales sobre el material de la sesión 168:
antes 3 intentos siempre, con el vídeo fuera del objetivo (47,5 s y 45,4 s).
Ahora 2, y el borrador cae dentro: 39,8 / 37,3 s. En dos de las cinco el
reintento ya no fue por duración sino por un fallo de contrato.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`NARRATION_CHARS_PER_SECOND` era 14,2, sacado de una única línea de 82
caracteres. Sintetizando de verdad las 28 líneas que el bot ha escrito hasta
hoy — mismo Piper, mismo modelo, mismas banderas deterministas — la voz lee a
18,5 car/s y se calla 0,25 s en cada punto. Contar las frases aparte es lo que
arregla el caso raro: "Witness identities. Sensor details. Locations redacted."
son tres cuartos de segundo de silencio que un modelo de caracteres a secas
regala.
El error del modelo viejo era de cuatro a seis segundos sobre un Short entero,
siempre por arriba, y con eso el aviso de duración saltaba en vídeos que
estaban dentro del objetivo. Contrastado ahora contra los tres MP4 que hay
renderizados: 39,42 / 47,19 / 45,81 s estimados contra 39,57 / 47,53 / 45,40
reales.
Dos cosas más, del mismo tirón:
- Un margen de 1,5 s antes de avisar. La estimación acierta dentro de un
segundo por línea, así que medio segundo de exceso puede ser del estimador y
no del spec; la sesión 168 se llevó una generación entera por ochocientas
milésimas. El objetivo sigue siendo 20-45.
- El consejo va en palabras, no en "recorta narración", y señala el plano que
más habla. Las tres veces que saltó, el modelo devolvió un spec que seguía
pasándose: no sabía cuánto.
Los segundos medidos entran en los tests como tabla, no como número redondo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
El Short de Socorro salió con “LIKE ALUMINUM / SMOOTH, NO WINDOWS” atribuido a
Zamora. La primera mitad es su informe literal; la segunda es la compresión de
un divulgador resumiéndolo. Ninguna fuente pone esas palabras juntas y Zamora
nunca dijo la segunda: es una cita fabricada con material auténtico.
El comprobador funcionó — encuentra LIKE ALUMINUM y rechaza la unión — así que
el fallo estaba antes, en el prompt. `scale_bars.quote` es una lista de líneas
por maquetación, pero al modelo le llega como dos huecos, y los llenó de sitios
distintos. Ningún otro campo de cita tiene esa forma: quote_a y quote_b sí son
dos citas a propósito, y esas salieron bien.
El prompt ahora dice que las líneas se leen unidas, que eso es lo que busca la
comprobación y lo que lee el espectador, y que la atribución empeora el pegote
en vez de arreglarlo: nombra a quien acabas de poner esas palabras en la boca.
El test fija la unión como unidad de comprobación con el caso exacto: dos
mitades que existen por separado y una frase que no.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
La primera generación con narración viva narró 5 de 7 planos. La siguiente, ya
con la sección de presupuestos de ancho en el prompt, narró 1 de 8.
La causa no era la sección 3b sino la 5: el ejemplo trabajado — un spec completo
de 8 planos presentado como "el que produjo un buen vídeo" — no llevaba ni una
narración, porque es anterior a la voz. Contra esa señal de formato, una regla
en prosa que además dice "no todos los planos necesitan una" pierde siempre.
Ahora el ejemplo narra 6 de sus 8 y calla en los dos que dibujan una cita, donde
la voz sólo competiría con palabras que ya están en pantalla. Sus duraciones
suben para pagar lo que habla: un plano que se queda corto para su propia voz
enseñaría a infradeclarar, y el render no corta la voz, alarga el plano. La
ventana de silencio se recoloca porque seguía a document_quote por tiempo.
El aviso de ancho ya no cita el hueco de 16 caracteres de quote_a: shortsmith
envuelve esas citas en dos líneas y el presupuesto vivo es 31.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
x-fits llega vivo del contrato y se pone delante del modelo: es el único
límite que nada rechaza, porque el renderizador encoge en vez de fallar. Sin
esto el modelo escribe una cita de 58 caracteres para un hueco de 16 y se
entera cuando ya está renderizada.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
El comprobador va primero, antes que el campo (fase 2 §12): la narración es
prosa que el modelo redacta, no una etiqueta que copia, y es donde se cuela
una cifra sin fuente. De paso, la huella de una cifra pasa a ser número +
unidad canónica: con la voz repitiendo la pantalla, '35,000 FT' y '35,000
feet' son el mismo dato y contarlos dos veces inflaría el informe del que
depende la revisión humana.
editorial_notes estima la duración CON la voz: la declarada es un suelo y sin
esto el modelo escribiría 40 s de shots, les colgaría narración y se enteraría
del Short de 65 s cuando ya está pagado.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
shortsmith ya compone la banda sonora (sonar); ahora publica una paleta
(pulse, static) y este repo la consume en vivo: el prompt la ofrece con sus
notas de mood, validate_spec la usa como fuente de verdad para audio.preset,
y editar el preset en /short_spec es la manera gratis de escucharlas. Sin
/audio (404 o caída) todo cae a la paleta base y nada deja de renderizar.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dos huecos de la revisión:
1. /short_spec prometía re-renderizar el spec editado pero no existía el
camino de vuelta. Ahora un .json adjunto se valida contra el contrato
vivo (errores con su ruta verbatim), se re-comprueba el fundamento (la
edición pudo meter una cifra nueva), se guarda como output nuevo y se
renderiza. Sin LLM: este camino es gratis. La sesión sale del nombre
del fichero (short_{id}_spec.json), que Telegram conserva al reenviar.
2. /upload_short podía subir un vídeo viejo con metadatos nuevos: produce
guarda el spec ANTES de renderizar, así que un re-intento con render
fallido deja en disco el MP4 de la vuelta anterior. Ahora se compara el
mtime del vídeo con el created_at del spec y se niega (force lo salta).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fase 3, con una corrección sobre lo que decía la §11 de la spec de fase 2.
El bloqueo no es OAuth. Los vídeos subidos por videos.insert desde un
proyecto de API sin auditar quedan restringidos a privado, y el candado es
del proyecto, no del vídeo: no se abre desde Studio, se abre pasando la
auditoría de cumplimiento de Google. Así que esto no publica. Deja el vídeo
en el canal con los metadatos puestos y devuelve el enlace de Studio para
que una persona lo revise y le dé a publicar — la misma forma que /publish
con los borradores de Ghost, y por la misma razón: el informe de fundamento
no sirve de nada si el vídeo ya está subido cuando lo lees.
Comando aparte, no un paso de /generate short_en.
- src/generator/youtube.py: refresco de token contra oauth2.googleapis.com,
subida resumable en dos pasos y metadatos derivados del shot spec ya
guardado (título, enlace al artículo, fuentes que el Short cita en
pantalla, etiquetas del tema). Sin google-api-python-client: es síncrono
y bloquearía el loop del bot; son dos peticiones HTTP y el repo ya firma
los JWT de Ghost a mano. aiohttp con SAFE_ACCEPT_ENCODING como todo lo
demás.
- Scope youtube.upload y nada más: un token filtrado no puede leer ni
borrar nada del canal, sólo subir.
- forced_private detecta que YouTube devolvió "private" cuando se pidió
otra cosa, y el aviso lo dice. Es la firma del candado, y tragárselo
haría creer que salió publicado.
- invalid_grant se traduce a su causa real: la pantalla de consentimiento
quedó en "Testing" y Google revoca esos tokens a los siete días. Es el
fallo que menos se adivina y el que más probable es encontrarse.
- get_article_url ahora excluye las filas short_en. Su published_url pasa a
ser la URL de YouTube, y sin el filtro el siguiente Short de la sesión
enlazaría al Short anterior: un bucle silencioso, porque la URL es válida
y nadie la mira dos veces.
- scripts/youtube_oauth.py, sólo stdlib: corre en el portátil, no en el
contenedor, y no debería exigir instalar nada.
- Las tres claves van optional:true en el Deployment. Sin eso, una clave que
aún no está en Infisical deja el pod en CreateContainerConfigError y tira
el bot entero por una función que nadie ha pedido todavía.
30 tests nuevos contra un servidor falso. No hay test en vivo a propósito:
cualquier ejecución real sube un vídeo a un canal de verdad, y eso no es
algo que deba pasar por teclear pytest.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
- .gitignore para Python, .env, *.db y artefactos de editor/OS
- git rm --cached de todos los __pycache__/*.pyc previamente trackeados
Co-Authored-By: Claude Opus 4.8 <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>