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>
154 lines
8.4 KiB
Markdown
154 lines
8.4 KiB
Markdown
# 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, deflate` on 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 via `find_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
|
|
hitting `source_contents`.
|
|
- On startup the bot marks orphaned `running` sessions as `interrupted`.
|
|
|
|
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.
|
|
|
|
## YouTube por API: dos candados que no avisan al configurarlo
|
|
|
|
Ninguno de los dos da un error al montarlo. Los dos se descubren tarde.
|
|
|
|
**1. Los vídeos suben restringidos a privado y no se abren desde Studio.** La
|
|
documentación de [`videos.insert`](https://developers.google.com/youtube/v3/docs/videos/insert)
|
|
lo dice: *"All videos uploaded via the videos.insert endpoint from unverified API
|
|
projects created after 28 July 2020 will be restricted to private viewing mode"*.
|
|
El candado es del **proyecto de API**, no del vídeo, y se levanta pasando la
|
|
auditoría de cumplimiento de Google — no cambiando la visibilidad a mano. Por eso
|
|
`YOUTUBE_PRIVACY=public` no publica nada: `UploadedVideo.forced_private` detecta
|
|
que YouTube devolvió `private` cuando se pidió otra cosa, y el aviso de Telegram
|
|
lo dice en vez de dejar creer que salió.
|
|
|
|
**2. El refresh token caduca a los 7 días si la pantalla de consentimiento sigue
|
|
en "Testing".** Google revoca los tokens de las apps sin publicar, así que el bot
|
|
funciona una semana y luego empieza a dar `invalid_grant` sin que nada haya
|
|
cambiado. La cura es pasar la pantalla a "In production" (con el aviso de "app no
|
|
verificada" al dar permiso, que para un único usuario da igual) y volver a sacar
|
|
el token. `_auth_error()` traduce `invalid_grant` a ese texto exacto: es el fallo
|
|
que más cuesta adivinar y el que más probable es encontrarse.
|
|
|
|
Corolario de los dos juntos: `/upload_short` **no publica**, y no es una decisión
|
|
de diseño que se pueda revertir tocando una env var. Deja el vídeo en el canal
|
|
con los metadatos puestos y devuelve el enlace de Studio.
|