Commit Graph
100 Commits
Author SHA1 Message Date
ChemaVXandClaude Opus 5 02e553fffa fix(short): el divisor del prompt iba redondeado y no reproducía su propia cuenta
12 ÷ 2.8 no da 5.1, da 5.0. La cuenta trabajada la hace el modelo con el número
que ve, así que el número que ve tiene que ser el real.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 20:41:02 +00:00
ChemaVXandClaude Opus 5 8000ef2145 fix(short): el prompt pedía tres cosas que no podían cumplirse a la vez
El modelo declaraba menos segundos de los que su propia línea necesitaba en 3 o
4 de cada 5 planos narrados — entre 3,8 y 8,2 s de deriva por Short. El vídeo
salía con la duración correcta porque shortsmith estira, pero el ritmo visual
que el spec escribía no era el que se renderizaba.

No era pereza del modelo: era un juego de reglas insatisfacible. El prompt pedía
a la vez líneas de hasta 18 palabras, planos de 6 s como mucho, y tiempo
declarado suficiente para la propia voz. 18 palabras piden 7,3 s, así que las
tres juntas son imposibles y el modelo rompía la única que nadie comprobaba.

Tres capas, las tres deterministas y sin gastar una generación:

- La regla enseñada llevaba el mismo error de clase que el estimador tenía antes
  del 2026-08-12: palabras por segundo a secas, sin el término de las pausas.
  Medida contra las 28 líneas que el bot ha narrado de verdad, `words/2.75 + 0.5`
  se quedaba corta en 14 y hasta 2,27 s — obedecerla al pie de la letra seguía
  infradeclarando. Ahora es de dos términos, como la voz, y el peor caso baja a
  1,27 s en 5 de 28.
- El tope de palabras por línea se DERIVA del plano más largo (`max_words_in`)
  en vez de escribirse a mano. La contradicción no puede volver.
- El ejemplo de referencia incumplía su propia regla en 2 de sus 6 líneas, y el
  ejemplo es la señal más fuerte del prompt: le estábamos enseñando el fallo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 20:38:51 +00:00
ChemaVXandClaude Opus 5 77029fa894 feat(youtube): la visibilidad se comprueba desde fuera, no se le cree a la API
Build & Deploy ResearchOwl / build-and-push (push) Successful in 10s
`/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>
2026-08-12 22:09:39 +00:00
ChemaVXandClaude Opus 5 91ceb3b1ca fix(short): una nota de duración vale una reescritura, no dos
Build & Deploy ResearchOwl / build-and-push (push) Successful in 39s
`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>
2026-08-12 21:37:17 +00:00
ChemaVXandClaude Opus 5 9bfa0fdac2 fix(short): la voz se midió con una sola frase, y por eso el bot reescribía Shorts que ya cabían
`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>
2026-08-12 21:37:17 +00:00
ChemaVXandClaude Fable 5 366ded1f59 fix(short): una cita partida en líneas es un span, no dos huecos que rellenar
Build & Deploy ResearchOwl / build-and-push (push) Successful in 8s
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>
2026-08-06 21:42:40 +00:00
ChemaVXandClaude Fable 5 cfe4a6d754 feat(short): el ejemplo del prompt habla, y por eso los specs vuelven a hablar
Build & Deploy ResearchOwl / build-and-push (push) Successful in 10s
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>
2026-08-06 21:32:49 +00:00
ChemaVXandClaude Fable 5 129f436fd2 feat(short): el prompt sabe cuánto texto cabe dibujado, y el informe grita si quedó ilegible
Build & Deploy ResearchOwl / build-and-push (push) Successful in 10s
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>
2026-08-06 16:19:48 +00:00
ChemaVXandClaude Fable 5 a13c3062b6 feat(short): narración — el grounding la cubre, el contrato la admite y el prompt la guía
Build & Deploy ResearchOwl / build-and-push (push) Successful in 10s
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>
2026-08-06 15:38:34 +00:00
ChemaVXandClaude Fable 5 93a506b636 feat(short): la paleta de audio viva — GET /audio en el contrato, el prompt y el bucle de edición
Build & Deploy ResearchOwl / build-and-push (push) Successful in 9s
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>
2026-08-06 14:41:27 +00:00
ChemaVXandClaude Fable 5 1fd0c1b3d6 docs(short): roadmap de fases 4-6 — audio, material de archivo, short_es
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 14:12:10 +00:00
ChemaVXandClaude Fable 5 960990327a feat(youtube): modo --paste en el script OAuth para autorizar desde otro dispositivo
Build & Deploy ResearchOwl / build-and-push (push) Successful in 10s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 20:51:13 +00:00
ChemaVXandClaude Fable 5 cc1f0cab93 fix(short): cerrar el bucle de edición y el desfase spec/vídeo en la subida
Build & Deploy ResearchOwl / build-and-push (push) Successful in 1m17s
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>
2026-08-05 15:46:06 +00:00
ChemaVXandClaude Opus 5 ec3e6d05c0 feat(youtube): subir Shorts al canal con /upload_short
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>
2026-08-03 14:43:53 +00:00
ChemaVXandClaude Opus 5 20c8d03aa7 feat(short): generación y render de Shorts vía shortsmith
Build & Deploy ResearchOwl / build-and-push (push) Successful in 9s
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>
2026-08-01 21:55:42 +00:00
ChemaVXandClaude Opus 5 8b81ef87e4 seo: arreglar la capitalización ES en el ORIGEN, y el motor apuntando al blog malo
Build & Deploy ResearchOwl / build-and-push (push) Successful in 9s
Capa 1 del plan del 2026-07-29: lo que se arregla en el generador se arregla
una vez; lo que se arregla con un vigilante es trabajo para siempre.

1) MAYÚSCULA DE ORACIÓN EN ES. El prompt no decía nada de capitalización, solo
   «write in SPANISH». Un modelo entrenado en inglés escribe Title Case en
   cuanto le pides un «SEO title»: por eso hubo que corregir a mano NOVENTA Y
   UN campos del blog ES.

   Va en el prompt y NO en un validador, y esto es una decisión medida, no una
   comodidad. Distinguir «Lo que Revelan» (mal) de «el Roswell de Pennsylvania»
   (bien) exige saber qué palabra es nombre propio. Probé dos detectores
   deterministas contra el corpus real antes de escribir ninguno:
     - por proporción de palabras capitalizadas: 100% de falsos positivos en
       títulos densos en topónimos («Kecksburg 1965: el Roswell de Pennsylvania»
       da 2/2)
     - por vocabulario en minúscula del corpus: se deja LA MITAD de los malos
       («Ocultan» nunca aparece en minúscula) y marca «Proyecto Libro Azul» y
       «Ejército del Aire» como si fueran errores
   Un gate que rechaza borradores válidos es peor que la avería. La red debajo
   sigue siendo seo_watch.check_title_case, que sí tiene el corpus delante y
   desde hoy corre a diario.

2) EL ARTÍCULO, COMO DATO. Va entre <ARTICLE>…</ARTICLE> y los dos prompts
   declaran que lo de dentro no son instrucciones. El cuerpo se redacta a
   partir de fuentes scrapeadas: una página con «ignore previous instructions»
   entraba en el mensaje sin que nadie lo mirara.

3) EL MOTOR APUNTABA AL BLOG EQUIVOCADO. El comentario de autofill decía que
   rules contaba CERO enlaces internos en ES por estar clavado al host del EN,
   y que no se podía arreglar por el vendorizado byte a byte. Ya se puede: el
   canónico ganó usar_sitio() y el vendorizado se resincronizó — llevaba
   desfasado desde el 21-jul, o sea que la CI habría fallado en el próximo
   build. _check_con_sitio() apunta el motor al idioma correcto y RESTAURA el
   global, con la condición de carrera documentada por si algún día se
   paraleliza la generación.

6 tests nuevos (30 en total). Lo que NO se puede verificar aquí: que el prompt
funcione de verdad. Eso solo lo dice el primer borrador ES que genere, y quien
lo va a comprobar es el vigilante diario.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 17:28:56 +00:00
ChemaVXandClaude Opus 5 3d8cba6567 ghost: resolver los tags por ID, que mandarlos por nombre partía el ES en dos
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>
2026-07-29 16:22:14 +00:00
ChemaVXandClaude Opus 5 a43bbc752d avatar del bot: búho con gafas, generado y aplicado por API
Build & Deploy ResearchOwl / build-and-push (push) Successful in 8s
Mismo patrón que hermes-bot y roswell-corpus: la foto de perfil se genera con
PIL (dibujado a 4× y reducido) en vez de ser un PNG opaco en el repo. Telegram
recorta en círculo y lo enseña a 48 px en la lista de chats, así que el dibujo
es una silueta y no una ilustración.

Las gafas son el único guiño a la investigación que sobrevive al tamaño chico
—una lupa o un libro se vuelven una mancha—, y la montura va en tono oscuro:
en ámbar sobre ámbar el aro se fundía con el plumaje y a 48 px desaparecía.

Documentado en el README, con el gotcha de `setMyProfilePhoto`: el parámetro
`photo` no admite el fichero suelto (responde `photo isn't specified`), hay que
pasarle un InputProfilePhoto que apunte al adjunto.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 19:57:18 +00:00
ChemaVXandClaude Opus 4.8 9653898366 docs: alinear modelos a qwen2.5:7b + bge-m3 (iGPU activa)
Build & Deploy ResearchOwl / build-and-push (push) Successful in 14s
Generación 3B→7B y embeddings a bge-m3 en README, CLAUDE.md, .env.example y
k8s/deployment.yaml. Corregido el default buggy de config.py: ollama_embed_model
era qwen2.5:3b (un modelo de chat) → bge-m3. Añadido OLLAMA_EMBED_MODEL donde
faltaba. Nota del host público de ollama ahora tras Authentik.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 09:54:58 +00:00
ChemaVXandClaude Opus 4.8 6029bebae5 fix(seo): el canónico del ES es el APEX, no www
Build & Deploy ResearchOwl / build-and-push (push) Successful in 10s
Cada enlace interno que el generador escribía en un borrador español apuntaba
a www.zonadeexclusion.com y se comía un 301. El canónico de los dos blogs está
INVERTIDO —EN es www, ES es el apex—, la misma inversión que provocó el 522 y
de la que ya avisa el wrapper ghst-es.

No es teórico: el enlace www suelto que apareció en los-villares salió de aquí.
El corpus ES está limpio hoy, pero el próximo /generate con enlaces lo habría
reintroducido. Nada río abajo lo paraba: seo_watch solo ve el enlace DESPUÉS de
publicar, y ningún test cubría el host.

Añado los dos tests que faltaban, uno por idioma, para fijar la inversión. El
del ES falla contra el valor viejo con el href entero en el mensaje.

Sin tocar rules.py, así que el vendor-sync sigue en verde. Queda documentado el
matiz que este cambio NO arregla: rules.internal_links cuenta cero enlaces en
ES bajo cualquiera de los dos hosts, porque ese módulo está clavado al host EN
(se vendoriza byte a byte y la CI lo verifica). Inofensivo hoy —
internal_links.too_few no bloquea en borrador.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:47:39 +00:00
ChemaVX 496212661b prompt: la longitud objetivo pasa a 1.800-2.500 palabras y 12 secciones
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
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.
2026-07-21 08:52:53 +00:00
ChemaVX 1f13513045 seo: el aviso de colisión de tema also en el blog ES
Build & Deploy ResearchOwl / build-and-push (push) Successful in 8s
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.
2026-07-21 08:27:02 +00:00
ChemaVX 7c00b4ed9a prompt: encabezados descriptivos (ES+EN) y mayúscula de oración (ES)
Build & Deploy ResearchOwl / build-and-push (push) Successful in 8s
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.
2026-07-21 07:52:48 +00:00
ChemaVX 2af654d344 vendor: sincroniza el motor SEO (stopwords ES + tokenizador sin acentos)
Build & Deploy ResearchOwl / build-and-push (push) Successful in 1m9s
make sync-seo tras chemavx-seo-tools 31c9d3e. Solo afecta a topic_collision;
las RULES de check_post no usan el tokenizador. Los 9 tests de seo pasan.
2026-07-21 07:15:45 +00:00
ChemaVXandClaude Fable 5 dadc030d16 feat(seo): aviso de colisión de tema en /generate blog en
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
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>
2026-07-10 11:17:33 +00:00
ChemaVXandClaude Fable 5 0e9a2e5b36 chore(seo): sync vendored seo_rules — check de colisión de tema
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
Re-copia del canónico (chemavx-seo-tools bae1cc9) vía make sync-seo:
añade topic_collision(post, corpus) — detección de posts que se
canibalizan (caso+año, hooks ≥70%, slugs ≥50%). Aditivo: el autofill
del bot no lo usa aún; disponible para integrarlo en /generate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 11:10:34 +00:00
ChemaVXandClaude Fable 5 4ee9ad064e docs: gotcha de OOM por fuentes grandes en KNOWN-ISSUES
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:38:43 +00:00
ChemaVXandClaude Fable 5 187d29a372 feat(bot): marcar sesiones 'running' huérfanas como 'interrupted' al arrancar
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>
2026-07-10 09:38:43 +00:00
ChemaVXandClaude Fable 5 ae56227c03 fix(scraper): endurecer memoria — PDF cap 15MB en executor, contenido cap 300k chars
Un batch de 20 fuentes concurrentes con un documento de 98k palabras y
varios PDFs grandes mató el pod (OOMKilled, límite 1Gi) el 2026-07-10 en
plena investigación.

- _extract_pdf: cap bajado de 50MB a 15MB, verificado también sobre el
  body real (Content-Length puede faltar); pdfplumber movido a
  run_in_executor (es síncrono y congelaba el event loop, misma clase de
  bug que DDGS) con flush_cache() por página.
- _mark_scraped: contenido truncado a settings.max_content_length
  (300k chars) antes de guardarlo en source_contents — libros enteros
  inflan RAM y DB sin aportar al RAG.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:38:30 +00:00
ChemaVXandClaude Fable 5 d31badeb5b refactor(ghost): _admin_get/_admin_headers compartidos en GhostPublisher
Build & Deploy ResearchOwl / build-and-push (push) Successful in 8s
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>
2026-07-05 16:00:31 +00:00
ChemaVXandClaude Fable 5 fbe8ae1885 fix(scraper): _unwrap_news_link normaliza //url y descarta apiclick sin URL usable
Un ?url= protocol-relative (//publisher.com/...) caía por el startswith('http')
y devolvía el link apiclick.aspx crudo, que pasaba blacklist y relevancia y
acababa raspado como página de redirect/consent de Bing. Ahora se antepone
https: a los protocol-relative y, si no se puede extraer URL válida de un
apiclick, se devuelve '' para que el caller lo salte.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:58:40 +00:00
ChemaVXandClaude Fable 5 b58a3ce118 fix(ghost): find_draft_by_title compara títulos normalizados, no igualdad exacta
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>
2026-07-05 15:56:50 +00:00
ChemaVXandClaude Fable 5 634f38e016 fix(scraper): SAFE_ACCEPT_ENCODING compartida — cubre la sesión SearXNG olvidada
La sesión aiohttp de _search_searxng (camino primario de búsqueda) construía
sus headers sin Accept-Encoding: heredaba el default de aiohttp, que volvería
a anunciar br si algún día se reinstala un backend brotli. El valor vive ahora
en src/config.py (SAFE_ACCEPT_ENCODING) en vez de copiado literal por sitio.

También corrige el comentario de HEADERS que afirmaba que brotlicffi seguía
instalado como fallback — 397546a lo quitó: un br no anunciado hoy falla sin
recuperación y la fuente se pierde.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:56:07 +00:00
ChemaVXandClaude Fable 5 3d74857d85 fix(ghost): guard anti-duplicados dentro de publish_draft, solo tras POST aceptado
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>
2026-07-05 15:54:49 +00:00
ChemaVXandClaude Fable 5 68ae6b436c docs: KNOWN-ISSUES.md — gotchas operacionales (brotli/aiohttp, WAL, DDGS, Google News) [skip ci]
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:53:34 +00:00
ChemaVXandClaude Fable 5 7d07375dd1 feat(ghost): guard anti-duplicados en el fallback de publish
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
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>
2026-07-04 20:26:23 +00:00
ChemaVXandClaude Fable 5 76c927f0d5 fix(ghost): Accept-Encoding gzip explícito en publish_draft y menú SEO
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
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>
2026-07-04 20:19:50 +00:00
ChemaVXandClaude Fable 5 397546a37a fix(deps): quita brotlicffi — hacía que aiohttp anunciara br por defecto
Build & Deploy ResearchOwl / build-and-push (push) Successful in 48s
Regresión de esta mañana: con un backend brotli instalado, aiohttp mete
'br' en el Accept-Encoding por defecto de toda sesión que no lo fija
(publish_draft, seo/autofill). Ghost/Cloudflare responde br y el decode
incremental roto de aiohttp 3.14.1 revienta al leer la respuesta de un
POST ya aceptado: el fallback re-publicaba → drafts duplicados y aviso
SEO perdido. Sin backend brotli nada anuncia br (el scraper ya lo quitó
de sus HEADERS explícitos en 57f341f).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:17:56 +00:00
ChemaVXandClaude Fable 5 57f341fc22 fix(scraper): no anunciar br en Accept-Encoding — decode incremental roto en aiohttp 3.14
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
Diagnóstico definitivo: los streams br de disclosure.org/todaywhy.com son
VÁLIDOS (los bytes crudos descomprimen offline con brotlicffi), pero el
descompresor incremental de aiohttp 3.14.1 falla según el troceo de
chunks — intermitente entre sitios e intentos, con Brotli y con
brotlicffi. Sin anunciar br los servidores mandan gzip (verificado:
los 4 sitios afectados, 200 + contenido íntegro) y el camino zlib es
sólido. brotlicffi se queda como red por si llega br no anunciado.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 11:25:58 +00:00
ChemaVXandClaude Fable 5 6c807212a0 fix(scraper): brotlicffi en vez de Brotli — aiohttp 3.14 falla en streams br troceados
Build & Deploy ResearchOwl / build-and-push (push) Successful in 54s
Con Brotli puro, aiohttp 3.14.1 seguía sin decodificar algunos streams
br (disclosure.org, todaywhy.com: 'Can not decode content-encoding: br'
en 3/3 intentos, mientras httpx los decodifica bien). Con brotlicffi
(que aiohttp prefiere sobre Brotli) los mismos sitios decodifican al
primer intento — verificado en el pod con install efímero.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 11:20:04 +00:00
ChemaVXandClaude Fable 5 becc43f770 fix(scraper): instala Brotli — se perdían fuentes br en silencio
Build & Deploy ResearchOwl / build-and-push (push) Successful in 55s
HEADERS anuncia 'Accept-Encoding: gzip, deflate, br' pero aiohttp no
tenía backend brotli: cualquier web que respondiera br fallaba con
'Can not decode content-encoding: brotli' tras 3 reintentos (vistos
washingtontimes, defensescoop, disclosure.org en sesión de prueba).
Verificado local: con Brotli==1.1.0 ambos decodifican (200, encoding=br).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 11:16:00 +00:00
ChemaVXandClaude Fable 5 a23810b9cc feat(scraper): seed de noticias Bing News RSS tras ENABLE_NEWS_SEED (off)
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
Nuevo _seed_news: registra el feed de Bing News como fuente rss y
_extract_rss (F3) siembra sus entries — sin lógica de parseo duplicada.
_unwrap_news_link extrae la URL real del publisher del ?url= de
apiclick.aspx.

Bing y no Google News como pedía el plan: verificado en vivo que los
links de news.google.com/rss/articles/ acaban en muro de consent (UE) y
el id AU_yqL solo se resuelve vía batchexecute interno — habría sembrado
URLs muertas. Los de Bing llevan la URL real en texto plano.

Flag off por defecto: se activará deliberadamente vía deployment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 10:59:00 +00:00
ChemaVXandClaude Fable 5 8dfd0116b2 fix(scraper): DDGS síncrono ya no bloquea el event loop
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
Las llamadas DDGS() en _seed_search y _seed_youtube eran síncronas y se
ejecutaban directamente en código async: durante cada búsqueda DDG el bot
entero (comandos de Telegram incluidos) quedaba congelado. Extraídas a
helpers sync (_ddg_text_sync/_ddg_videos_sync) ejecutados vía
run_in_executor. Sin cambios en queries ni procesamiento de resultados.

Verificado con mock de DDG lento (3s): peor parón del loop 52ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 10:49:22 +00:00
ChemaVXandClaude Fable 5 c701628144 feat(scraper): F4 — siembra de Internet Archive (mediatype:texts)
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
_seed_archive() consulta advancedsearch con el topic completo y siembra
las páginas /details/<id> (rows=12); los PDFs de cada item los descubre
la recursión y los extrae _extract_pdf. Verificado contra la API:
'Roswell incident declassified' devuelve informes del Proyecto Mogul y
documentos del CIA Reading Room.

Sin fallback de query a propósito: el AND estricto prima precisión —
relajar términos mete ruido (verificado: 'Manises' a secas devuelve
libros de loza valenciana). Topics sin material en Archive siembran 0
y el resto de seeds cubren. Aislado en try/except propio, además del
return_exceptions del gather.

Cierra el plan F1-F4 de mejora del descubrimiento de fuentes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:49:10 +00:00
ChemaVXandClaude Fable 5 0ce9933aaa fix(scraper): F3 — el cap del RSS aplica a entries añadidas, no escaneadas
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
entries[:20] antes del filtro de relevancia perdía las entries
relevantes que no caen entre las 20 más recientes del feed (verificado
con thedebrief.org: 0 relevantes en las primeras 20, 3 en las 100
totales). Ahora se escanean todas y el cap corta al añadir 20.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:46:09 +00:00
ChemaVXandClaude Fable 5 f210b34e5f feat(scraper): F3 — extractor RSS + detección de feeds con word-boundary
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
detect_source_type ya clasificaba 'rss' pero no había rama en
_process_source: los feeds caían a _extract_web y trafilatura no saca
nada del XML (se descartaban por cortos). Ahora _extract_rss parsea el
feed (feedparser en hilo, como el monitor de noticias) y siembra sus
entries como fuentes nuevas: filtro de relevancia por título/URL,
blacklist, dedupe, cap de 20 y respeto de max_depth. El feed en sí se
marca skipped — es puro descubrimiento, no tiene contenido que trocear.

De paso, la detección pasa de substring puro (clasificaba /feedback y
/atomic como rss) a RSS_RE con límites de palabra. Tests de ambos.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:43:50 +00:00
ChemaVXandClaude Fable 5 9dae61fde8 feat(scraper): F2 — Wikipedia bilingüe siempre y búsqueda full-text
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
Dos arreglos en _seed_wikipedia, motivados por la validación de F1
(found=0 en ambos idiomas para 'incidente ovni de Manises 1979' pese
a existir el artículo en es.wikipedia):

- opensearch es prefix-de-título: topics verbosos devuelven 0. Se pasa
  a action=query&list=search (full-text, srlimit=8), que para el mismo
  topic encuentra 'Manises UFO incident' (en) e 'Incidente OVNI de
  Manises' (es) + relacionados. Verificado contra la API en ambos.
- Se elimina el break tras en: ahora siembra en + es SIEMPRE, cada
  idioma aislado en su try/except.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:40:08 +00:00
ChemaVXandClaude Fable 5 200de018e4 feat(scraper): F1 — lista de motores SearXNG ampliada y configurable
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
_search_searxng hardcodeaba engines=duckduckgo,google,bing,brave, lo
que ignoraba cualquier motor extra de la instancia (y dejaba la
búsqueda coja cuando brave cae en rate limit y DDG en CAPTCHA, ambos
observados en vivo). Ahora la lista vive en settings.searxng_engines
(env SEARXNG_ENGINES, tunable por GitOps sin rebuild).

Default ampliado con motores verificados contra la instancia el
2026-07-03: +startpage +mojeek +presearch +google news +bing news
+internet archive scholar. Medido con queries reales: ~2x resultados
y ~2x dominios únicos por ~+2s/query. Excluidos qwant (denied),
yahoo (roto), wikidata (0 en texto) y wayback machine (duplicados).

Sin cambios en k8s-manifests: use_default_settings:true ya trae todos
estos motores configurados. El fallback a DDG queda intacto.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:36:04 +00:00
ChemaVXandClaude Fable 5 228ea5c601 feat(news): 3 feeds ES nuevos tras retirar Espacio Misterio
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
Verificados desde el pod (HTTP 200, entries y match de keywords):
- MysteryPlanet (mysteryplanet.com.ar) — diario, 3/10 match hoy
- Marcianitos Verdes (marcianitosverdes.haaan.com) — casi diario, 2/5
- El Ojo Crítico (elojocritico.info) — mensual, calidad ES

Descartados: Ufopolis (último post feb 2025), Mundo Esotérico (bozo).
Cold-start por fuente: el primer poll de cada uno siembra sin notificar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:25:51 +00:00
ChemaVXandClaude Fable 5 2cb0cf841c feat(deps): python-telegram-bot 21.5 -> 22.8
Build & Deploy ResearchOwl / build-and-push (push) Successful in 49s
Salto mayor revisado contra el changelog oficial:
- La 22.0 elimina lo deprecado en v20.x. De esa lista el bot solo usaba
  disable_web_page_preview (3 sitios), que ademas resulta seguir vivo
  como shim en 22.8 — se migra igualmente a link_preview_options, que
  es la API canonica desde Bot API 7.0.
- No usamos quote=, proxy_url, ni *_timeout en run_polling (el resto
  de eliminaciones). Python >=3.10 (imagen 3.12) y httpx >=0.27,<0.29
  (tenemos 0.28.1) OK.
- Verificado aislado: import de src.bot.bot sin DeprecationWarnings,
  8 tests en verde.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:17:21 +00:00
ChemaVXandClaude Fable 5 5876361ac7 chore(deps): pdfplumber 0.11.10 y python-dotenv 1.2.2
Build & Deploy ResearchOwl / build-and-push (push) Successful in 49s
Folda las dos ramas nuevas de Renovate (las 11 rancias ya las cerró
tras el bump b872d00).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:12:36 +00:00
ChemaVXandClaude Fable 5 a91e119171 refactor(config): migrar a SettingsConfigDict y quitar Field(env=...)
Elimina las dos deprecaciones de Pydantic V2 (se rompían en V3):
- class Config -> model_config = SettingsConfigDict(env_file='.env')
- Field(env='X') en los 33 campos -> resolución automática por nombre
  de campo (case-insensitive en pydantic-settings); verificado que los
  33 nombres coinciden con su variable, así que el comportamiento es
  idéntico. pytest pasa de 34 warnings a 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:12:36 +00:00
ChemaVXandClaude Fable 5 19efc70539 feat(seo): fijar vocabulario de tags ES en el autofill
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
Cierra el TODO de ALLOWED_TAGS: el allow-list ES son los 6 tags del
núcleo vivo de zonadeexclusion (Admin API, 2026-07-03), espejo de la
lista EN confirmada: uap, desclasificados, casos-militares,
casos-clasicos, investigacion, casos-espana. Quedan fuera la cola de
tags de 1 post que el modelo inventó antes de constreñir y el duplicado
investigacion-2 (colisión de mayúsculas en Ghost).

La regla anti-legacy 'never use "investigacion"' del prompt pasa a ser
solo-EN: en ES ese slug es el tag canónico del allow-list.

Tests de las funciones puras (_coerce, _system_prompt) por idioma.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 07:52:26 +00:00
ChemaVXandClaude Fable 5 b872d00efc chore(deps): bump 9 dependencias y eliminar fastapi/uvicorn sin uso
Build & Deploy ResearchOwl / build-and-push (push) Successful in 47s
Consolida el backlog de Renovate en un solo bump, ignorando las ramas
rancias que proponían downgrades (youtube-transcript-api, trafilatura,
sqlite-vec se quedan como están):

- aiohttp 3.10.0 -> 3.14.1
- httpx 0.27.0 -> 0.28.1 (PTB 21.5 requiere httpx~=0.27, que admite 0.28;
  el código solo usa AsyncClient(timeout=...), sin APIs eliminadas)
- beautifulsoup4 4.12.3 -> 4.15.0
- lxml 5.2.2 -> 5.4.0
- duckduckgo-search 6.2.6 -> 6.4.2
- aiosqlite 0.20.0 -> 0.22.1
- markdown 3.7 -> 3.10.2
- pydantic 2.8.0 -> 2.13.4 + pydantic-settings 2.4.0 -> 2.14.2
- fastapi y uvicorn eliminados: ningún módulo los importa

Verificado con install aislado (--target) + imports completos + pytest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 07:38:27 +00:00
ChemaVXandClaude Fable 5 dcfe674322 fix(news): rotar feeds de Reddit por run + loguear HTTP >=400
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
Reddit sin OAuth ratelimita por IP con ventana larga: la 2ª petición
seguida a reddit.com devuelve 429 siempre (verificado: 30s de pausa no
bastan). Como r/UFOs va antes en FEEDS, r/UFOB recibía 429 en cada poll
desde F1 y nunca llegó a sembrar (0 filas en news_seen).

- Solo se pollea un feed de reddit por run, rotando (_reddit_turn por
  proceso). Con poll de 6h cada subreddit se refresca cada 12h.
- El 429 llegaba con bozo=False y 0 entries, invisible para el guard
  existente: ahora status >= 400 se loguea como warning y se salta.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 07:18:15 +00:00
ChemaVXandClaude Fable 5 480d86e975 fix(news): quitar feed Espacio Misterio (XML malformado)
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
El feed devuelve 'not well-formed (invalid token)' en cada poll desde la
activación; feedparser nunca extrae entries (bozo). Se elimina para no
generar un warning por tick. Quedan 6 feeds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 07:09:38 +00:00
ChemaVXandClaude Opus 4.8 b1c5bc737d feat(news): F2 — scheduler automático del monitor RSS (inerte)
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
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>
2026-06-27 10:16:07 +00:00
ChemaVXandClaude Opus 4.8 c0b0c1ba0a feat(news): F1 — monitor RSS best-effort + comando /news manual
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
src/news/monitor.py: 7 feeds UAP/OVNI, matching normalizado (NFKD sin tildes)
con word-boundary para tokens cortos (uap/ufo/nhi/aaro/ovni) y substring para
frases. poll_feeds aísla cada feed (uno caído nunca tumba el run) y parsea en
hilo aparte para no bloquear el event loop; cold-start por fuente (siembra sin
notificar). format_digest agrupa por fuente y trocea <4000 chars. /news refresca
y muestra novedades 24h. Capa desacoplada: src/news NO importa de src/bot.
No toca el scheduler (F2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:57:26 +00:00
ChemaVXandClaude Opus 4.8 fd814a3ccf feat(news): F0 — schema, config y métodos DB del monitor RSS (inerte)
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>
2026-06-26 20:46:38 +00:00
ChemaVXandClaude Opus 4.8 727662294c docs(seo): clarify alt vs caption in pre-publish checklist
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 06:11:45 +00:00
ChemaVXandClaude Opus 4.8 6d39875fcf fix(seo): sanitize malformed link suggestions + clean link report
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:49:29 +00:00
ChemaVXandClaude Opus 4.8 7a995da88f feat(seo): wire autofill into publish path behind SEO_AUTOFILL (default off, dry-run support)
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:14:39 +00:00
ChemaVXandClaude Opus 4.8 89aa8d6030 Vendor shared SEO rule engine + CI drift guard (no pipeline wiring yet)
src/seo/rules.py is a byte-for-byte copy of the canonical seo_rules.py
(git.chemavx.xyz/chemavx/chemavx-seo-tools). CI step "Verify vendored SEO engine"
clones the canonical and fails the build on any divergence; `make sync-seo` /
`make check-seo-sync` cover the local-canonical workflow. Nothing imports this in
a runtime path yet — generate_seo_fields integration comes next behind SEO_AUTOFILL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 08:17:37 +00:00
ChemaVXandClaude Opus 4.8 b6dc5192b8 Add pre-publish SEO checklist to Ghost draft Telegram notice (deploy pending)
Build & Deploy ResearchOwl / build-and-push (push) Successful in 2m31s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:40:20 +00:00
ChemaVXandClaude Opus 4.8 cd557a2d71 perf(db): conexión SQLite compartida por proceso
Build & Deploy ResearchOwl / build-and-push (push) Successful in 5s
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>
2026-06-15 15:16:24 +00:00
ChemaVXandClaude Opus 4.8 2ffad8aad3 perf: scoring de calidad en lote + fuentes YouTube/Reddit opcionales
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
#1 batch scoring (processor):
- _score_quality_batch puntúa hasta 25 chunks por llamada a Claude en vez
  de una por chunk (una fuente de 19 chunks pasaba de 19 llamadas a 1)
- parser robusto (último número por línea, padding neutro si faltan)
- fallback por-chunk con Ollama si el batch falla

#2 fuentes opcionales (config + scraper):
- ENABLE_YOUTUBE / ENABLE_REDDIT, default False: la IP del homelab está
  bloqueada por Reddit (403) y YouTube (transcripts vacíos), eran peso muerto
- se saltan también las URLs de yt/reddit descubiertas dentro de webs, sin
  gastar petición de red

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:00:47 +00:00
ChemaVXandClaude Opus 4.8 972bd2f883 fix(rag): chunking real por líneas + embedding de chunk completo
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
simple_chunk:
- parte por \n+ (no solo \n\n): Wikipedia/trafilatura usan \n simple, lo
  que colapsaba cada fuente en un único chunk gigante
- subdivide párrafos que superan chunk_size
- el overlap arrastra un tail de N palabras en vez del párrafo completo
  (evita chunks inflados a ~2x cuando los párrafos son grandes)

processor: embedding sobre el chunk completo (antes truncaba a 1000 chars,
el vector solo representaba el principio del chunk → ranking RAG pobre)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 14:53:00 +00:00
ChemaVXandClaude Opus 4.8 94dc0316f9 chore: add .gitignore y dejar de trackear bytecode .pyc
Build & Deploy ResearchOwl / build-and-push (push) Successful in 1m8s
- .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>
2026-06-15 14:38:46 +00:00
ChemaVXandClaude Opus 4.8 bf275b7f82 fix: correcciones de scraping/DB y mejoras de robustez
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>
2026-06-15 14:38:03 +00:00
ChemaVXandClaude Opus 4.8 fd9aaa193b fix: watch diff — envío con fallback a texto plano y task no silencioso
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
El resumen de diff generado por Claude se enviaba con parse_mode=MARKDOWN;
texto libre con entidades desbalanceadas provocaba BadRequest y el mensaje
no llegaba. Además el send_message estaba fuera del try/except de _task y el
task se retenía en _active_tasks sin await, así que la excepción se tragaba
sin log alguno.

- _safe_send(): intenta Markdown y reintenta en texto plano si falla
- _task: except de nivel superior que loguea cualquier fallo

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:39:55 +00:00
ChemaVXandClaude Opus 4.8 ca0eb059e8 fix: purga de inicio borra api_usage antes de research_sessions
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
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>
2026-06-09 14:00:54 +00:00
ChemaVXandClaude Opus 4.8 41e4e3f5d6 feat: /watch --at HH:MM — hora absoluta en Europe/Madrid
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:55:52 +00:00
ChemaVX 425639f423 test: redis fix
Build & Deploy ResearchOwl / build-and-push (push) Successful in 9s
2026-05-20 12:23:43 +00:00
ChemaVX 7f3c2d0b49 test: webhook
Build & Deploy ResearchOwl / build-and-push (push) Successful in 1m12s
2026-05-20 12:21:07 +00:00
ChemaVX f577ac4712 feat: Ghost EN — /generate blog en publica en inglés en theexclusionzone.com
Build & Deploy ResearchOwl / build-and-push (push) Successful in 1m19s
2026-05-18 16:49:09 +00:00
ChemaVXandClaude Sonnet 4.6 747b9605c0 fix: cmd_publish usa _active_sessions para la sesión correcta
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
Mismo patrón que cmd_generate: si hay sesión activa registrada
para el chat, consulta por id; si no, fallback a created_at DESC.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:40:52 +00:00
ChemaVXandClaude Sonnet 4.6 caf763c23e fix: cmd_generate usa _active_sessions para la sesión correcta
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
Si hay una sesión activa registrada para el chat, se consulta
directamente por id en lugar de por created_at DESC, evitando
que /generate use la sesión más reciente en vez de la actual.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:38:06 +00:00
ChemaVXandClaude Sonnet 4.6 bdea12e6f2 fix: nombre de archivo .md usa topic del output, no del session pre-cacheado
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
El session dict se obtiene antes de generator.generate() y puede
contener datos de la sesión anterior. Ahora se extrae el topic
directamente de la línea "Topic:" del header del output generado,
que siempre refleja la sesión actual usada en la generación.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:18:09 +00:00
ChemaVXandClaude Sonnet 4.6 a6a90d3598 fix: eliminar primer <h1> del HTML antes de publicar en Ghost
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
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>
2026-05-08 10:51:52 +00:00
ChemaVXandClaude Sonnet 4.6 36984657a8 fix: Ghost 5.x — usar mobiledoc+HTML card en lugar del campo html
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
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>
2026-05-08 10:44:38 +00:00
ChemaVXandClaude Sonnet 4.6 83eb2359be feat: Ghost CMS integration — auto-publish blog + /publish command
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 10:26:22 +00:00
ChemaVX 94d209dd8a test: webhook sync automático
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
2026-05-06 11:35:14 +00:00
ChemaVX 7a156e2af1 fix: mover alerta de coste a /generate donde está el gasto real
Build & Deploy ResearchOwl / build-and-push (push) Successful in 5s
2026-05-06 07:49:29 +00:00
ChemaVX 279475a175 feat: alerta de coste — aviso si sesión supera COST_ALERT_THRESHOLD
Build & Deploy ResearchOwl / build-and-push (push) Successful in 5s
2026-05-06 07:23:11 +00:00
ChemaVX 82e614e285 feat: caché de contenido de fuentes — reutiliza URLs scrapeadas en últimos 7 días
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
2026-05-06 07:05:41 +00:00
ChemaVX aa83cfacbd fix: truncar contextos en /compare a 3000 palabras para evitar límite de tokens
Build & Deploy ResearchOwl / build-and-push (push) Successful in 5s
2026-05-06 06:51:36 +00:00
ChemaVX e8034f3f37 feat: /compare — análisis comparativo de dos temas en paralelo
Build & Deploy ResearchOwl / build-and-push (push) Successful in 34s
2026-05-06 06:40:31 +00:00
ChemaVX c2bb301103 feat: dedup semántico antes del scoring — hash MD5 + similitud Jaccard
Build & Deploy ResearchOwl / build-and-push (push) Successful in 5s
2026-05-05 08:58:53 +00:00
ChemaVX 53cf7a04a8 feat: modo diff para /watch — notifica solo si hay novedades reales
Build & Deploy ResearchOwl / build-and-push (push) Successful in 7s
2026-05-05 07:43:41 +00:00
ChemaVX f4e167f3b6 feat: SearXNG como motor principal, DDG como fallback
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
2026-05-04 20:00:24 +00:00
ChemaVX ba2b366534 fix: delay DDG 3-8s aleatorio, logging mejorado en query generation
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
2026-05-04 13:28:54 +00:00
ChemaVX 4bef9d2d17 feat: queries DDG generadas por Claude en lugar de plantillas hardcodeadas
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
2026-05-04 13:24:25 +00:00
ChemaVX 7a012c2c28 fix: _remove_duplicate_headings usa ventana de 5 líneas en lugar de break
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
2026-05-04 13:19:08 +00:00
ChemaVX 6aaa85a1f8 fix: eliminar títulos h1 duplicados en export PDF
Build & Deploy ResearchOwl / build-and-push (push) Successful in 5s
2026-05-04 13:12:32 +00:00
ChemaVX e0a42f0b91 ci: retrigger PDF build
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
2026-05-04 13:02:57 +00:00
ChemaVX 4c7f5b521b feat: fase 3 — export PDF con reportlab + /export command
Build & Deploy ResearchOwl / build-and-push (push) Successful in 1m2s
2026-05-04 12:57:21 +00:00
ChemaVX c33bb5337d fix: títulos de sección en español, sin encabezado duplicado en extended
Build & Deploy ResearchOwl / build-and-push (push) Successful in 6s
2026-05-04 11:40:07 +00:00
ChemaVX 566f685578 ci: retrigger tras fix DinD
Build & Deploy ResearchOwl / build-and-push (push) Successful in 1m42s
2026-05-04 11:13:10 +00:00
ChemaVX 8c259b2b2e ci: clean ci-builder before create to prevent stale BuildKit state
Build & Deploy ResearchOwl / build-and-push (push) Successful in 5s
2026-05-04 11:09:56 +00:00
ChemaVX a47d7b26ca feat: fase 2 — generación por secciones report_extended, blog_extended, podcast_extended
Build & Deploy ResearchOwl / build-and-push (push) Successful in 5s
2026-05-04 10:58:06 +00:00