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>
El guard anterior vivía en el fallback de autofill y casaba con CUALQUIER
draft del mismo título: un fallo pre-POST (Ollama caído) con un draft viejo
del mismo topic descartaba en silencio el contenido recién generado.
Ahora la recuperación vive dentro de publish_draft y solo se activa cuando
el POST fue aceptado (2xx) y falla la lectura de la respuesta — el escenario
exacto del incidente br del 2026-07-04 — con filtro since=attempt_start en
find_draft_by_title. Cubre a todos los callers: autofill, bare publish y
/publish (antes sin proteger: reintento del usuario = draft duplicado).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Si el autofill falla DESPUÉS de que Ghost aceptara el POST (p.ej. error
leyendo la respuesta), el draft ya existe: el fallback ahora comprueba
por título exacto entre los drafts recientes (find_draft_by_title) y
reutiliza el existente en vez de duplicar. Solo aplica al camino de
fallback-tras-excepción — el modo OFF y la re-generación deliberada
siguen creando draft nuevo como siempre. Best-effort: si el check
falla, se publica bare como antes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Blindaje contra el default de aiohttp: si algún día se reinstala un
backend brotli, las sesiones sin Accept-Encoding volverían a anunciar
br y el decode roto de aiohttp 3.14 rompería la lectura de respuestas
de Ghost (duplicando drafts). Con el header fijado no puede regresar.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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>
_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>
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>
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>
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>
_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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
#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>
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>
- .gitignore para Python, .env, *.db y artefactos de editor/OS
- git rm --cached de todos los __pycache__/*.pyc previamente trackeados
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sección crítica:
- is_blacklisted: match por dominio/subdominio exacto (antes "x.com" como
substring bloqueaba netflix.com, phoenix.com, etc.)
- normalize_url: conserva el query string (rompía YouTube watch?v= y URLs
con ?id=); solo borra el fragment
- get_db: PRAGMA busy_timeout=5000 para evitar "database is locked" en
/compare y watches solapados
- OllamaClient.embed: usa OLLAMA_EMBED_MODEL en vez del modelo de chat
- log_api_call: coste por modelo (opus/sonnet/haiku) en vez de Haiku fijo
Mejoras:
- src/llm.py: cliente Anthropic compartido y cacheado (antes se instanciaba
uno por cada llamada/chunk)
- SEARXNG_URL configurable via env
- get_running_loop() en vez de get_event_loop() (deprecado)
- soup.title.get_text() robusto ante <title> con tags anidados
- limpieza: import muerto, total_words duplicado, w_id no usado
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
Ghost añade el título del post automáticamente en el frontend,
por lo que el <h1> generado desde el markdown aparecía duplicado.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
El campo "html" en Ghost Admin API v5 (Lexical editor) es de solo
lectura. El contenido se debe enviar via mobiledoc con HTML card,
que Ghost acepta en todas las versiones de v5 y renderiza sin
conversión. Añadidos logs de diagnóstico y validación de HTML vacío.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>