La retención iba por la edad de la SESIÓN y cascadeaba
`DELETE FROM outputs WHERE session_id = ?`, así que un output generado ayer
sobre una sesión de julio moría en el siguiente arranque del bot.
Pasó hoy, 2026-09-01, al desplegar 198b0e62:
Startup purge done sessions=12 outputs=27 chunks=2030 sources=3466
api_usage=1118 shorts=6
El pod llevaba 18 días sin reiniciarse, así que tres semanas de material
cumplieron los 30 días de golpe. Y lo que se llevó por delante no fue lo viejo:
los outputs 132-139 tenían 18,8 días —los tres Shorts re-renderizados el día
antes entre ellos— pero colgaban de las sesiones 161-165, de hace 40. Murieron
por la edad de su madre mientras 128-131, más antiguos, sobrevivían.
Ahora la purga va en dos fases:
1. Outputs por SU propia fecha, vivan en la sesión que vivan.
2. Sesiones viejas que ya no sostienen ningún output.
El orden importa: la sesión cuyos outputs eran todos viejos se queda sin
ninguno en la fase 1 y resulta purgable en la fase 2, así que el caso normal
—sesión vieja, material viejo— sigue limpiándose entero en UNA pasada. Hay un
test que lo fija, y es el único de los cuatro nuevos que pasa también con la
lógica anterior: está para probar que no se rompió lo que funcionaba.
Una sesión con un output vivo sobrevive entera, con sus sources y sus chunks.
No es generosidad: los chunks son contra lo que se comprueba el fundamento de
ese output, y conservar el spec tirando aquello con lo que se verifica deja
algo que ya no se puede auditar. El precio es que la retención afloja, y se
paga a sabiendas.
Dos cosas más que salieron al mirarlo:
- `_purge_on_startup` sólo escribía en el log `if result["sessions"] > 0`. Con
la retención por output, una pasada puede borrar 27 outputs y CERO sesiones,
y eso no habría dejado ni una línea. Una purga silenciosa es como se
descubre tres semanas tarde. Ahora informa si borró cualquier cosa.
- El MP4 se llama por sesión, así que cuando la fase 1 se lleva el último
short_en de una sesión que sigue viva, el fichero queda sin nada que lo
nombre. Se borra ahí también, o el PVC acumula vídeos que no aparecen en
ninguna fila.
`purge_old_sessions` pasa a llamarse `purge_old_data`: ya no purga sólo por
sesiones y el nombre viejo describía justo el defecto.
La BD anterior a la purga está a salvo y verificada en
~/rescates/researchowl-purga-2026-09-01 (integrity_check ok, 31 outputs, los
17 short_en). Con esta lógica, un restore conserva 16 de los 17.
Suite: 278 pasan.
Co-Authored-By: Claude Opus 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>
Las tareas de research viven solo en memoria (_active_tasks): un reinicio
del pod las mata sin tocar la DB y sus sesiones quedan en 'running' para
siempre — parecen activas en /status y get_active_session. Nuevo estado
ResearchStatus.INTERRUPTED y barrido en _on_startup antes de la purga.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Engancha el monitor de noticias al scheduler loop EXISTENTE de
watched_topics (sin segunda task asyncio). Cada tick, si NEWS_ENABLED,
y si ha pasado NEWS_POLL_INTERVAL_HOURS desde el último poll (estado en
memoria), pollea los feeds, empuja los pendientes (notified=0) al chat
destino (NEWS_CHAT_ID o 1er TELEGRAM_ALLOWED_USERS) y los marca.
Best-effort: la rama de noticias va en su propio try/except — un fallo
del news-poll NUNCA tumba el scheduler de watched_topics. Deploy inerte:
NEWS_ENABLED sigue False; el flip se hará aparte en k8s-manifests.
- db: get_unnotified(limit=None) — pendientes, recientes primero (NULLS LAST)
- bot: rama news al final del tick del scheduler, reusa poll_feeds /
item_from_row / format_digest de F1; "…y N más" si excede NEWS_MAX_ITEMS
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
news_seen table (CREATE TABLE IF NOT EXISTS, sin migración) + índices.
Config NEWS_* (NEWS_ENABLED=false por defecto) + propiedad news_chat_id
(fallback al 1er TELEGRAM_ALLOWED_USERS). Métodos ResearchDB: source_seeded,
record_news_item, mark_news_notified, get_recent_news. Todo inerte: nada
lo invoca aún. feedparser ya estaba en requirements (6.0.12 >= 6.0).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
get_db() devuelve un proxy sobre una única conexión real reutilizada durante
toda la vida del proceso, en vez de abrir una conexión nueva y re-ejecutar
todo el SCHEMA en cada comando/scoring.
- _SharedConnection: proxy con close() no-op → los handlers conservan el
patrón get_db()/finally close() sin cambios
- aiosqlite serializa las operaciones en el hilo de la conexión: compartirla
entre coroutines es seguro y elimina la contención del lock de escritura a
nivel de fichero (scheduler solapado, /compare con 2 sesiones)
- close_db() vía post_shutdown para checkpoint WAL limpio al apagar
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>
La purga de sesiones >30d fallaba con FOREIGN KEY constraint failed:
api_usage.session_id referencia research_sessions(id) pero nunca se
borraba antes de la sesión padre (con PRAGMA foreign_keys = ON).
Co-Authored-By: Claude Opus 4.8 (1M context) <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>