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>
6.8 KiB
ResearchOwl — Known Issues & Operational Gotchas
Brotli / aiohttp incompatibility
Do NOT install any brotli backend (Brotli, brotlicffi) while on aiohttp 3.14.x.
aiohttp's incremental brotli decompressor is broken and fails intermittently
depending on stream chunking ("Can not decode content-encoding: br" on VALID
streams — the same bytes decompress fine offline; httpx is unaffected).
Extra trap: merely installing a backend makes aiohttp advertise br in the
default Accept-Encoding of every session that doesn't set one explicitly.
On 2026-07-04 a scraper fix installed brotlicffi and inadvertently enabled br
project-wide: Ghost/Cloudflare responded in brotli, the decode blew up AFTER
Ghost had already accepted the POST, and the publish fallback re-published →
duplicate drafts + silently lost SEO notices.
Fix applied (commits 397546a + 76c927f + 7d07375, guard relocated
2026-07-05):
- no brotli backend in requirements.txt (comment there explains why),
- explicit
Accept-Encoding: gzip, deflateon all Ghost/autofill aiohttp sessions and on the scraper HEADERS (57f341f), - duplicate guard INSIDE
GhostPublisher.publish_draft(covers every caller, including/publish): if the POST is accepted (2xx) but reading the response fails, it recovers the just-created draft viafind_draft_by_title(title, since=attempt_start)instead of raising. The guard only fires after an accepted POST — a pre-POST failure (Ollama down, menu fetch, links) never triggers it, so a stale same-title draft from a previous run can no longer swallow freshly generated content.
Note: with no backend installed there is NO brotli fallback at all — a server
that responds Content-Encoding: br without it being advertised (misbehaving
CDN) fails undecodable and that source is lost. Accepted trade-off.
The header value lives in one place: SAFE_ACCEPT_ENCODING in src/config.py.
Every aiohttp session/request must use it explicitly — never rely on aiohttp's
default Accept-Encoding, which silently grows br if a backend appears.
Re-test with disclosure.org before ever re-enabling br (e.g. after an aiohttp upgrade).
SQLite WAL mode + read-only mounts
The database runs in WAL mode, so even read-only access needs the -shm file
writable, or must open with sqlite3 "file:...?immutable=1". Backups (daily
CronJob researchowl-db-backup, 03:00 Europe/Madrid, PVC researchowl-backups,
7-day retention) inherit WAL mode: to inspect one from a read-only mount use
?immutable=1; to restore, copy it to a writable location first.
DDG (duckduckgo_search) blocks the event loop
DDGS() is synchronous (blocking requests inside). Never call it directly from
async code — always wrap in loop.run_in_executor() (see _ddg_text_sync /
_ddg_videos_sync in src/scraper/exhaustive.py). Direct calls froze the
entire Telegram bot during searches until fixed on 2026-07-04 (8dfd011).
Google News RSS is a dead end from this infrastructure
news.google.com/rss entry links point to /rss/articles/CBMi… redirects that
hit a consent wall from EU IPs, and the inner AU_yqL id is only resolvable via
Google's private batchexecute API. Do not retry. The news seed uses Bing News
RSS instead (ENABLE_NEWS_SEED, real publisher URL in the ?url= param of
apiclick.aspx — unwrapped by _unwrap_news_link).
Large sources can OOM-kill the pod
On 2026-07-10 the pod was OOMKilled (memory limit was 1Gi) mid-research: a
batch of 20 concurrent sources hit a 98k-word document plus several large PDFs
at once, and pdfplumber's parse spiked RAM past the limit. The in-memory
research task died with the pod and its session sat in running forever.
Mitigations now in place:
- Memory limit raised to 2Gi (
k8s-manifests/researchowl/deployment.yaml). - PDFs capped at 15MB (was 50MB), checked both via Content-Length and actual
body size; pdfplumber runs in
run_in_executor(it is sync + CPU-heavy and also froze the event loop, same class of bug as DDGS) and flushes its page cache per page. - Extracted content is truncated to
max_content_length(300k chars) before hittingsource_contents. - On startup the bot marks orphaned
runningsessions asinterrupted.
If a research still dies, the scraped sources survive in the DB: /process
re-chunks and scores them without re-scraping.
Un shot spec escrito por Haiku falla de dos maneras concretas
Medido el 2026-08-01 generando Shorts de verdad contra sesiones reales (JAL 1628 #153, Bélgica #158). Las dos están mitigadas, pero conviene saber que existen porque las dos son silenciosas si nadie mira.
1. Comillas rectas dentro de una cadena JSON. El modelo escribe
"quote_a": ""CREDIBLE PEOPLE. THEY TOLD CLEARLY WHAT THEY SAW."" y la cadena
se cierra en la segunda comilla: el JSON entero deja de parsear. Se repitió en
los tres intentos aunque el prompt lo prohíbe explícitamente y aunque el error
se le devolvía con el fragmento exacto. No se arregla insistiendo: lo
arregla _typographic_inner_quotes() en src/generator/shortspec.py, que
convierte esas comillas en “ ” recorriendo el texto con estado de cadena.
Sólo se ejecuta después de un fallo de parseo, así que un JSON correcto no pasa
por ahí. Además es lo que se quiere dibujar: las citas del canal van con
tipográficas.
2. Se copian cifras del ejemplo del prompt. El examples/jal1628.json que
va en el prompt como referencia de formato es también una fuente de datos muy
tentadora. En la primera eval dorada, tres claims del spec generado venían del
ejemplo y no de las fuentes: 232 FT (largo de un 747) y 40 YEARS no
aparecían en NINGUNO de los 126 chunks de la sesión, y RARELY, IF EVER estaba
en 1 chunk que no entró en el top-40 que vio el modelo. La sección 5 del prompt
lo dice ahora en mayúsculas ("FORMAT ONLY … a number copied from here is a
fabrication") y eso bajó de 3 a 1.
El ejemplo se queda con cifras reales a propósito — uno sintético enseña peor
la forma —, así que la defensa es estructural: check_grounding() contrasta lo
que no encuentra en los chunks contra el propio ejemplo, y lo que casa ahí
sale en el informe como 🧪 copiado del EJEMPLO del prompt (fuga, no invención). Son dos diagnósticos y piden dos acciones: una invención hay que
verificarla, una fuga hay que borrarla.
Corolario: el informe de claims no es decorativo. Si algún día se manda el vídeo sin él, se estará publicando lo que el modelo recuerde del ejemplo.
El comprobador de fundamento sólo mira los chunks que vio el modelo
check_grounding() compara contra los mismos ~40 chunks que se metieron en el
prompt, no contra los 126 de la sesión. Es deliberado — la pregunta es "¿lo
sacó de lo que le dimos?" — pero produce falsos positivos cuando el dato existe
en la sesión y no entró en el top-k. Un falso positivo cuesta un vistazo; un
falso negativo cuesta la credibilidad del canal.