Version de Python: 3.12.3
Documentación: Requisitos · Arquitectura (UML + cierre del MVP)
Estado actual (agosto 2026): v0.2.0. El núcleo NLP está completo y validado: cuatro señales de clickbait contrastables, un modelo lineal interpretable propio, divulgación de modelos y una evaluación metodológicamente cerrada (split train/dev/test + validación externa). Lo que resta es la capa web (R4–R8) y la memoria. Entrega: febrero 2027.
| Hito | Fecha | Contenido | Requisitos |
|---|---|---|---|
| H1 · Diseño de interfaz | ago–sep 2026 | Wireframes de pantallas y navegación, definición de funcionalidades, diseño de los endpoints REST | — |
H2 · v0.3 API REST |
octubre | FastAPI: exposición de las tools, catálogo con metadatos, historial de ejecución, OpenAPI, CORS, tests | R4, R5 |
H3 · v0.4 SPA funcional |
noviembre | Angular: análisis de un titular → resultados con explicabilidad visual (cues resaltados, contraste de señales), catálogo de tools | R6 |
H4 · v0.5 Docker + persistencia |
diciembre | Docker Compose (MCP + API / web), historial persistente, despliegue continuo | R7, R8 |
H5 · v1.0 Pulido y despliegue |
enero 2027 | Responsive, gestión de errores, pruebas E2E, despliegue | R6 |
| H6 · Memoria y defensa | ene–feb 2027 | Redacción de la memoria y preparación de la defensa | — |
Criterios de priorización:
- El backlog de NLP queda congelado como opcional (multi-dominio #78, featurización alternativa #75, fine-tuning neural E5-05, meta-tool de contraste, post-hoc LIME/SHAP). El límite de generalización ya está medido y documentado (#76), que es lo que exige el rigor; resolverlo no es condición para la entrega.
- La memoria arranca en diciembre, en paralelo con H4. Esta sección de épicas actúa como borrador y diario de desarrollo desde el inicio del proyecto.
- El stack está fijado en requisitos.md: FastAPI (R4) + Angular/TypeScript (R6) + Docker Compose (R7).
Tres niveles, y solo uno tiene rama:
| Nivel | Qué es | ¿Rama? | ¿Versión? |
|---|---|---|---|
| Hito (H1–H6) | Checkpoint con fecha y meta; agrupa issues | no | sí, al cerrarlo |
| Issue | Unidad de trabajo | sí — 1 rama → 1 PR → squash a dev |
no |
Un hito no es trabajo, es un punto de control: no se ramifica ni se mergea. La unidad de trabajo es —y ha sido siempre— el issue.
Épicas (histórico). Hasta la v0.2.0 el trabajo se agrupaba en épicas (E1–E5): dominio sin fecha, de ahí los labels epic:*. En Fase B los hitos las sustituyen, porque cada hito ya trae dominio y fecha («H2 · API REST · octubre»); mantener los dos ejes duplicaría la misma información. Los labels epic:* se conservan como registro de los issues de Fase A.
Cada rama de trabajo parte de dev (recién actualizada: tras un squash la rama de origen queda inservible como base) y sigue el patrón <tipo>/<nº issue>-<descripción-corta>:
| Prefijo | Uso |
|---|---|
feat/ |
Nueva funcionalidad |
fix/ |
Corrección de bug |
chore/ |
Setup, estructura, mantenimiento |
docs/ |
Documentación |
test/ |
Tests nuevos o mejoras de cobertura |
Ejemplos: feat/72-splits-train-dev-test, feat/86-app-fastapi, fix/lexico-cue-una-letra. El número se omite cuando el trabajo no tiene issue (típico en docs/).
ruff hace de linter y formateador; las reglas activas y las exclusiones están en ruff.toml, y el CI lo comprueba en cada PR.
ruff check . --fix # corrige lo automatizable
ruff format . # reformateaFormato: tipo(scope): descripción
Tipos: feat, fix, chore, docs, test, refactor
Scopes: core, integrations, settings, tests, docs
Ejemplos:
feat(integrations): añadir tool get_news_this_week para Guardian API
chore(settings): configurar pydantic-settings con validación al inicio
fix(core): corregir serialización de ToolResult en tools MCP
test(integrations): añadir tests unitarios para weather tools
Dos ramas permanentes con papeles distintos:
dev— integración (rama por defecto): las features llegan por PR (squash merge), cada PR vinculado a una issue conCloses #N. Es donde se desarrolla.main— producción/estable: solo recibe merges desdedev(PR de release, merge normal — sin squash, conserva la historia). Un workflow (protect-main.yml) rechaza cualquier PR amaincuyo origen no seadev.
Releases: al promocionar dev → main se crea un tag (vX.Y.Z). Una versión es tageable solo si cumple los cuatro criterios:
- Bloque funcional completo — en Fase B, hito cerrado; en Fase A era la épica. Un conjunto coherente de issues DEBERÁ, sin features a medias.
- CI verde en
dev(suite completa). - Verificación E2E del servidor MCP pasada (tools respondiendo en vivo).
- Documentación al día (README y requisitos reflejan lo incluido).
Esquema de versiones (semver adaptado al TFG): minor (v0.X.0) = bloque funcional — un hito en Fase B (v0.3 = H2, v0.4 = H3…), una épica en Fase A (v0.1.0 = MVP, v0.2.0 = Épica 5 NLP explicable); patch (v0.X.Y) = hotfix sobre lo shipeado; major (v1.0.0) = entrega final del TFG.
Principio: las versiones son cortes en el tiempo, no contenedores temáticos. Una mejora posterior va a la siguiente versión aunque pertenezca por dominio a una épica ya taggeada (la trazabilidad temática la dan los labels de épica en los issues, no los tags). Los tags son inmutables: nunca se "reabre" una versión.
(Histórico: hasta la v0.2.0 el flujo era feature → PR → main directo; el esquema dev→main se adoptó al cerrar la Épica 5.)
Aquí se documentan las épicas y las iteraciones realizadas durante el desarrollo del proyecto. No se trata de un documento final sino simplemente un histórico de progreso para la memoria final.
Objetivo: validar la viabilidad de construir un servidor MCP en Python que exponga herramientas (tools) capaces de consultar APIs externas reales, estableciendo las bases de arquitectura sobre las que se apoyará el resto del proyecto.
- Creación de
weather.pycomo script monolítico de prueba. - Integración con la API pública de NOAA Weather.gov para obtener alertas meteorológicas por estado y previsiones a partir de coordenadas.
- Añadidos
requirements.inyrequirements.txtcon las dependencias base:mcp,fastmcp,httpx.
- Extracción del prototipo a una arquitectura en capas:
backend/api/,backend/tools/,backend/main.py. main.pyinstancia el servidor FastMCP y registra las tools disponibles.- Separación de responsabilidades: la capa API gestiona las llamadas HTTP; la capa de tools expone las funciones al servidor MCP.
- Introducción de
ToolResult(PydanticBaseModel) enbackend/models.pypara unificar el contrato de respuesta entre la capa API y las tools. - Campos:
success: bool,data: Any | None,error: str | None. - Métodos de fábrica:
ToolResult.ok(data),ToolResult.fail(error_message)y predicadohas_content().
- Añadida
GuardianAPIenbackend/api/the_guardian_api.pypara consultar artículos de la última semana por tema (get_news_this_week_call). - Registrada la tool
get_news_this_weeken el servidor MCP. - Primer test exploratorio en
tests/simple_test.py.
- Creada la clase abstracta
BaseAPIenbackend/api/base_api.pycon el método genéricomake_request(endpoint, method, params). WeatherAPIyGuardianAPIheredan deBaseAPI, eliminando duplicación de lógica HTTP (timeout, errores HTTP, manejo de excepciones).make_requestinyecta automáticamente la API key si está configurada.
- Las tools instancian y usan correctamente los objetos API en lugar de llamar funciones sueltas.
- Los tipos de retorno de las tools se limitan a tipos serializables (
str | dict) compatibles con el protocolo MCP.
| Decisión | Motivo |
|---|---|
| FastMCP como framework MCP | Reduce el boilerplate del protocolo y permite registrar tools con un simple decorador @mcp.tool() |
httpx.AsyncClient para HTTP |
API totalmente asíncrona, coherente con el modelo async/await del servidor |
ToolResult como capa de abstracción |
Desacopla el manejo de errores HTTP del código de las tools; facilita los tests unitarios |
Herencia BaseAPI |
Centraliza timeout, inyección de API key y manejo de excepciones HTTP en un único lugar |
Debido a un descuido y una experimentación algo apresurada, la clave API fue filtrada en github y tuve que cambiarla. Este error fue un toque de conciencia para empezar a definir una estructura completa del proyecto en épicas y priorizar las más relevantes.
Antes de crear nuevos archivos y carpetas, decidí definir la estructura del proyecto primero, pero con un enfasis en YAGNI, la estrucutra podría ser modificada SOLO cuando sea necesario, sin intentar predecir como será a futuro:
Por el momento dentro de backend tenemos:
-
Core: Con una clase API para hacer peticiones básicas y un archivo de modelos personalizados para las tools.
-
Integration: que contiene varias subcarpetas, cada una relacionada con un API y que a su vez contiene las peticiones (client) y las tools que expone al MCP (tool)
-
Test: Carpeta que contendrá tests en el futuro, por ahora un placeholder
Ya que en la próxima épica se tratarán las variables de configuracion, se creó la carpeta config.
Para evitar nuevas filtraciones accidentales de la API key en el código, doy prioridad a establecer las variables de configuración del proyecto. Decidí usar pydantic-settings y no os.getenv por el fail fast y escalabilidad de variables a futuro.
La clase settings.py maneja ahora todas las variables de configuración y seguridad, conectandose con el archivo .env (el cual nunca está en el git ignore)
Siguiente paso: Manejo de tests para depurar llamadas a API, pues algo falla.
Aunque se trate de un paso pequeño, antes de seguir trabajando en las épicas pensé que sería necesario formalizar la creación de ramas y commits para mejorar la organización del proyecto a largo plazo
Se usará Conventional Commits por simplicidad, empezando cada rama por el tipo de épica al que hace referencia (chore, fix, fet, docs) y con commits usando "scopes" que vienen a indicar sobre que parte del proyecto se trabajó. Esto es también útil para no subir macro-commits que afecten a demasiados archivos y poder hacer "rollback" de ser necesario.
Ejemplos:
feat(integrations): añadir tool get_news_this_week para Guardian API chore(settings): configurar pydantic-settings con validación al inicio
Se instalaron pytest, pytest-asyncio y pytest-cov como dependencias de desarrollo. Se creó pytest.ini en la raíz del proyecto con configuración mínima (testpaths y pythonpath). Se añadió un smoke test (tests/test_smoke.py) que verifica que el servidor MCP importa sin errores.
Se añadió respx para mockear llamadas HTTP en tests. Tests implementados para get_alerts_API (respuesta válida, vacía y error HTTP) y para get_forecast (respuesta válida con mockeo de dos llamadas encadenadas, error HTTP en get_zone_by_points y error HTTP en get_forecast_API). Durante el testing se descubrió y corrigió un bug en get_zone_by_points donde faltaba / en la URL construida. Se añadió también validación de periods vacíos en tool.py; el test correspondiente queda fuera del scope de esta iteración por depender de la capa tool y no del client.
El criterio de aceptación del issue (logger.info("test", key="value") debe producir salida estructurada) descartó el logging estándar de Python, que no acepta kwargs arbitrarios — se eligió structlog por soportar esa sintaxis de forma nativa. (Básicamente, el logging básico fija las salidas a ciertos argumentos. Utilizar structlog nos permite más libertad al configurarlos)
La configuración vive en backend/core/logging.py (función configure_logging()) y se controla desde dos nuevas variables en Settings (backend/config/settings.py):
LOG_LEVEL:DEBUG/INFO/WARNING/ERROR(defaultINFO).LOG_FORMAT:console(renderer legible y coloreado, default para desarrollo) ojson(renderer estructurado para producción).
Ambas se declaran como Literal[...] en pydantic-settings, de modo que un valor inválido falla al arrancar (fail-fast, coherente con la decisión de E1-04). La pipeline de processors aplica merge_contextvars, add_log_level y un TimeStamper ISO en UTC antes del renderer final.
En backend/main.py se invoca configure_logging() al inicio de main() y se reemplaza el logging.info("Starting server...") por log.info("server.start", transport="stdio"), ya con campos estructurados. El test tests/test_logging.py valida el DoD del issue forzando LOG_FORMAT=json con monkeypatch y verificando que la salida JSON contiene event, key, level y timestamp.
Se añade backend/core/observability.py con el decorador log_tool_invocation, que envuelve cada tool MCP para registrar cada invocación con tool (nombre), params (kwargs recibidos, ya que MCP transmite los parámetros como JSON object), duration_ms medido con time.perf_counter() y un booleano success.
El decorador se aplica en cascada con @mcp.tool():
@mcp.tool()
@log_tool_invocation
async def get_alerts(state: str) -> str | dict: ...El orden importa — Python aplica los decoradores de abajo a arriba, así que @mcp.tool() registra ya la versión envuelta con logging. Aplicado a las tres tools existentes (get_alerts, get_forecast, get_news_this_week).
En caso de éxito se emite el evento tool.invoke. Si la tool lanza una excepción, se emite tool.invoke.failed con el traceback completo (traceback.format_exc() en el campo exception) y la tool devuelve al cliente MCP un mensaje genérico ("Internal error while executing tool") — el detalle interno solo aparece en el log.
Limitación conocida: cuando una tool retorna un string de error sin lanzar excepción (patrón actual de get_alerts y get_news_this_week: return response.error or "Error fetching..."), el decorador no puede distinguir éxito real de error suave y lo loguea como success=True. Estabilizar ese contrato queda fuera del scope de E1-07 y será cubierto por los issues #13 ([E1-11]) y #14 ([E1-12]).
El test tests/test_observability.py cubre dos casos con pytest-asyncio: invocación correcta (assert sobre event, tool, params, success=True, duration_ms) y excepción (assert sobre event=tool.invoke.failed, success=False, presencia del traceback en exception, valor devuelto = mensaje genérico).
backend/integrations/news/client.py:get_news_this_week_call rompía con TypeError: 'NoneType' object is not iterable cuando la respuesta de The Guardian no incluía la clave results (o llegaba como null). El list comprehension iteraba directamente sobre response.data.get("response", {}).get("results") sin validar.
Con el decorador log_tool_invocation (E1-07) la excepción se capturaba ahora como tool.invoke.failed y el cliente MCP recibía "Internal error while executing tool" — funcional, pero "no hay artículos" no debería ser un error técnico, sino un resultado legible.
Fix: un guard antes del list comprehension:
results = response.data.get("response", {}).get("results")
if not results:
return ToolResult.fail("No articles found")not results cubre con una sola línea tanto None (clave ausente o null explícito) como [] (lista vacía).
Contrato: se eligió ToolResult.fail("No articles found") sobre la alternativa ToolResult.ok("...") por dos razones:
- Tipo único de
data: en éxito,datasiempre eslist[dict]; un string vacío comodatarompería esa consistencia. - Output limpio al cliente: la tool serializa
response.dataconjson.dumps(...)solo cuandohas_content(); sidatafuera un string, saldrían comillas dobles literales ('"No articles found."'). Por el caminofail, la tool devuelveresponse.errordirectamente — string limpio.
El test tests/test_news_guardian.py cubre tres casos con respx: respuesta válida con artículos (verifica success=True y campos title/url/date), results=[] (verifica el guard sobre lista vacía) y respuesta sin la clave results (verifica el guard sobre None, que era el escenario del bug original).
Se añade backend/core/health.py con la tool MCP health_check, que reporta el estado del servidor y de sus dependencias externas haciendo una petición real a cada API. Devuelve un dict con:
status:Literal["ok", "degraded", "down"]— agregado por la función pura_aggregate_statussegún cuántas integraciones respondan.timestamp: ISO 8601 en UTC (datetime.now(timezone.utc).isoformat()).integrations: dict por integración (weather,guardian) conreachable: boolyerror: str | None.
Los probes se ejecutan en paralelo con asyncio.gather, así la latencia total es max(probes) y no sum(probes) — relevante con timeout corto (5s) y dos integraciones. Cada probe usa httpx.AsyncClient directo (sin pasar por BaseAPI) porque la lógica es trivial y la inyección automática de API key no aporta para una petición one-shot. Para Guardian la key se pasa explícita en params={"api-key": settings.guardian_api_key}; raise_for_status() convierte 4xx/5xx en excepción para que el except httpx.HTTPError los trate uniformemente como reachable=False.
Sub-fix incluido: backend/core/logging.py redirige structlog a sys.stderr mediante logger_factory=structlog.PrintLoggerFactory(file=sys.stderr). El protocolo MCP por stdio reserva stdout para JSON-RPC y stderr para logs del servidor; sin este fix, structlog escribía sobre stdout y mezclaba con el protocolo, lo que hacía que Claude Desktop no pudiera parsear las respuestas y el servidor pareciese "muerto" desde el cliente. Deuda heredada de E1-06 que se descubrió al integrar en Claude Desktop por primera vez.
Los tests viven en tests/test_health.py y combinan dos niveles deliberadamente:
- Unit tests (3) sobre
_aggregate_status— función pura, sin red ni mocks. Cubren los tres estados con dicts dummy. Mantienen valor real porque la lógica de agregación tiene 3 ramas no triviales. - Integration tests (2) marcados con
@pytest.mark.integrationque invocan_probe_weathery_probe_guardiancontra las APIs reales. Verifican comportamiento end-to-end: conectividad de red + validez de la API key + contrato HTTP.
Se descartó la opción de mockear las APIs con respx (patrón que sí se usa en test_weather.py y test_news_guardian.py): un mock de la integración que el health debe detectar no aporta en un proyecto de un solo programador frente a un test real automatizado que sirve también como pre-deploy check. El marker integration está declarado en pytest.ini para separar ejecuciones (pytest -m "not integration" cuando no haya red o API key).
| Decisión | Motivo |
|---|---|
| Uso de pydantic-settings | Evitar filtraciones de variables críticas y valores hardcodeados |
structlog sobre logging stdlib |
Soporta kwargs estructurados (log.info("evt", key=value)) sin recurrir a extra={...}; pipeline de processors configurable con renderer condicional console/json |
Decorador log_tool_invocation sobre middleware |
FastMCP no expone un punto claro de middleware; un decorador propio es portable, fácil de testear de forma aislada y se compone explícitamente con @mcp.tool() |
| Mensaje genérico al cliente en error | Evita filtrar detalles internos (paths, librerías, stack) en la respuesta MCP; el detalle solo vive en el log interno (Req 1.5) |
asyncio.gather para probes en paralelo |
Latencia total = max(probes) en lugar de sum; con dos integraciones y timeout 5s, evita esperas innecesarias |
Logs por sys.stderr en transporte stdio |
El protocolo MCP por stdio reserva stdout para JSON-RPC; sin redirigir, structlog corrompe el canal y Claude Desktop pierde el servidor |
| Integration tests sobre mocks para health | Para un solo programador, automatizar la detección real de fallos vale más que mocks que reproducen exactamente la funcionalidad bajo test |
Objetivo: añadir una segunda fuente de noticias al servidor MCP para alimentar el análisis de clickbait con titulares de procedencia distinta a The Guardian. La épica se descompone en cliente (E2-01), tool MCP (E2-02) y tests (E2-03), siguiendo el mismo patrón de integraciones ya consolidado.
Se añade backend/integrations/nyt/ con NYTAPI heredando de BaseAPI, replicando el patrón de GuardianAPI: constantes de clase (BASE_URL, API_KEY, API_KEY_PARAM) y make_request ya inyecta la api-key como query string sin tocar el método.
Settings extendido con nyt_api_key: str (obligatoria, fail-fast vía pydantic-settings). En .env se carga como NYT_API_KEY (mayúsculas, convención 12-factor; pydantic-settings mapea automáticamente).
El único método público es search_articles(topic: str), que llama a Article Search API con q=<topic>, begin_date=YYYYMMDD (hoy menos 7 días) y sort=newest. Devuelve ToolResult.ok(list[dict]) con el mismo schema común que Guardian — {title, url, date} — para que el consumidor (la tool MCP de E2-02) no tenga que distinguir el origen.
Se añade backend/integrations/nyt/tool.py con la función register(mcp), siguiendo el mismo patrón de news/tool.py, y maneja el ToolResult con el mismo contrato que Guardian — if not response.has_content(): return response.error or "Error fetching news" y json.dumps(response.data) en éxito.
backend/main.py registra la nueva tool junto a las anteriores (nyt_tool.register(mcp)), elevando a 5 las tools expuestas al cliente MCP: get_alerts, get_forecast, get_news_this_week, health_check y get_nyt_news.
tests/test_news_nyt.py combina dos niveles siguiendo el patrón establecido en E1-08 (health check):
Unit tests (4 con respx), deterministas, cubren todas las ramas de parseo:
test_search_articles_valid_response— payload OK con artículos, assertasuccess=Truey mapeo de camposheadline.main/web_url/pub_dateal schema común{title, url, date}.test_search_articles_no_results—docs=[], assertasuccess=Falsecon"No articles found"enerror. Cubre el guardif not docs.test_search_articles_missing_docs_key— payload sin la clavedocs, cubre el mismo guard sobreNone.test_search_articles_http_error— respuesta HTTP 500, asserta queBaseAPIpropaga el fallo ysearch_articlesretornaToolResult.fail.
Integration tests (2 con @pytest.mark.integration), hacen petición real a NYT:
test_search_articles_valid_use— drift detection del schema: con la API key real, asserta que cada artículo de la respuesta contienetitle,urlydate. Si NYT renombra o anida distinto algún campo, el test falla y se entera el dev.test_search_articles_invalid_topic— topic improbable ("nonexistingtopicabcde") que actualmente no devuelve resultados; assertanot result.successy"No articles found" in result.error, conectando el contrato del cliente con el string que finalmente recibe el LLM. Aceptado como potencialmente flaky (algún día puede aparecer un artículo con ese topic); documentado en el propio archivo como warning.
Como follow-up de E2-03 se replicó el patrón mixto unit + integration en tests/test_news_guardian.py, que hasta entonces solo tenía cobertura con mocks. Cierra la asimetría: ahora las dos integraciones de noticias (Guardian y NYT) tienen el mismo nivel de cobertura, incluyendo drift detection del contrato real con la API.
| Decisión | Motivo |
|---|---|
Schema común {title, url, date} entre Guardian y NYT |
Permite que la futura tool MCP (E2-02) y el analizador NLP (E3) consuman datos sin ramificar lógica por fuente |
Hereda de BaseAPI igual que Guardian/Weather |
Reutiliza la inyección automática de api-key, manejo uniforme de timeout y errores HTTP; cualquier mejora futura en BaseAPI aplica a las tres integraciones |
Tras cerrar E2, un repaso del proyecto destapó deuda e inconsistencias acumuladas entre épicas. Se abordaron en un PR de refactor (sin nueva funcionalidad de producto), agrupadas por tema.
backend/core/health.py tenía una función _probe_* por integración (_probe_weather, _probe_guardian) con estructura try/except/raise_for_status idéntica — repetición real. Se extrajo un único _probe(url, params) genérico y las integraciones se declaran como datos en un diccionario PROBES. Beneficio doble: añadir una API = una entrada (no más funciones), y NYT entró de paso (el health check ignoraba NYT pese a ser la tercera integración — incoherencia heredada de cuando se diseñó E1-08, antes de que NYT existiera). Los integration tests pasaron a @pytest.mark.parametrize sobre PROBES, generándose uno por integración automáticamente.
Se distinguió esta repetición real de la aparente del registro de tools en main.py: ahí cada *.register(mcp) invoca módulos distintos sin patrón que extraer, así que el "register dinámico" se descartó (YAGNI) — explícito es más legible que un loop o autodescubrimiento mágico con importlib.
Los clientes de noticias tenían topic obligatorio y rango temporal fijo (7 días hardcodeado). Se hizo topic: str | None = None (si se omite, devuelve lo más reciente sin filtrar) y days: int = 7 configurable. Los params se construyen condicionalmente: la clave q solo se envía si hay topic.
Decisión clave de frontera de confianza: al exponer days también en las tools (para que el usuario final pueda pedir rangos vía el LLM), days pasó de input interno a input no confiable del LLM. Por eso se validó en la tool con pydantic.Field(ge=1, le=30) — el rango aparece en el schema que ve el LLM y FastMCP rechaza valores fuera de rango antes de ejecutar. El cliente no revalida: la frontera ya filtró.
get_news_this_week_call (cliente Guardian) pasó a search_articles, idéntico a NYT — ambos clientes exponen ahora la misma interfaz, reforzando el schema común. La tool get_news_this_week pasó a get_guardian_news, simétrica con get_nyt_news y nombrando la fuente para que el LLM desambigüe mejor. El nombre viejo (this_week) además se había vuelto engañoso al hacer days configurable.
- Eliminado
tests/simple_test.py(código muerto de E0: importaba debackend.api.the_guardian_api, ruta que desapareció en la migración E1-03; pytest no lo recogía por no matcheartest_*.py). tests/test_logging.pyytests/test_observability.pyleíancapsysdesde stdout, pero desde el fix de stderr de E1-08 los logs salen por stderr. Tests desincronizados (latentes hasta correrlos juntos): se corrigieron acaptured.err. Ahora además verifican que los logs van a stderr, justo lo que el protocolo MCP stdio exige.- Start del servidor más descriptivo: el log
server.startincluye ahoralog_levelylog_format(QoL para diagnóstico al arrancar). - Imports muertos (
Optional,ToolResultsin usar),f""sin interpolación y TODOs vagos eliminados.
| Decisión | Motivo |
|---|---|
_probe genérico + dict PROBES (health) |
Repetición real entre probes; añadir integración = una entrada de datos. Distinto del register de main.py, donde la repetición es aparente y explícito gana |
Validar days con Field en la tool, no en el cliente |
La frontera de confianza está en la tool (input del LLM); validar una vez en el borde, el cliente confía en lo que recibe |
topic opcional construyendo params condicionalmente |
Evita enviar q=None a la API; permite el caso "titulares recientes sin tema" |
Rename a search_articles / get_guardian_news |
Interfaz idéntica entre clientes y tools simétricas que nombran la fuente; el nombre viejo era engañoso con days configurable |
Se añadió .github/workflows/ci.yml, un workflow que ejecuta la suite de tests en cada pull_request hacia main (y en push a main). El job configura Python 3.12, instala requirements.txt y corre pytest -m "not integration". Es la red de seguridad que da sentido al esfuerzo de testing acumulado: un cambio que rompa el código se detecta en el PR, antes del merge.
El paquete backend/integrations/news/ contenía en realidad la integración de The Guardian, mientras NYT vivía en backend/integrations/nyt/: asimetría news=Guardian vs nyt=NYT. Se renombró a backend/integrations/guardian/ para que el nombre del paquete refleje la fuente, igual que NYT. Cambios asociados: imports en main.py (incluido el alias news_tool → guardian_tool), el import interno de guardian/tool.py, y el test tests/test_news_guardian.py renombrado a tests/test_guardian.py. El movimiento se hizo preservando el historial git de los archivos.
Fue el primer PR validado por el CI de E1-14 antes del merge — estreno de la red de seguridad sobre un cambio mecánico pero con riesgo real de romper imports.
Estado: E3-01 (cliente
HFClient) ✅ mergeado (PR #40). E3-02 (detección de clickbait, zero-shot) en curso. E3-03 (sentimiento) y E3-04 (tests) pendientes.
1. Clasificar texto = ponerle una etiqueta. Un clasificador recibe un texto (un titular) y le asigna una etiqueta de un conjunto, con un score de confianza (0–1). Ej.: clickbait → {clickbait, no-clickbait}.
2. Modelo fine-tuned específico (lo que son elozano o distilbert-sst2). Se entrena con miles de ejemplos ya etiquetados de una tarea. La aprende muy bien, pero: sus etiquetas son fijas, necesitas un modelo por tarea, y —clave aquí— alguien tiene que tenerlo desplegado para usarlo por API. Los de clickbait no lo están en el serverless de HF.
3. Zero-shot classification = clasificar SIN haber entrenado para esas etiquetas. Le pasas el texto y las etiquetas candidatas en el momento de la consulta (["clickbait", "factual"]) y el modelo puntúa cuánto encaja cada una. No fue entrenado para "clickbait"; razona sobre la marcha. Por eso puedes cambiar las etiquetas sin reentrenar nada.
4. ¿Cómo hace esa "magia"? Con NLI (Natural Language Inference). NLI es una tarea clásica: dadas dos frases —una premisa y una hipótesis— decidir si la premisa implica (entailment), contradice o es neutral respecto a la hipótesis. Ej.: premisa "El gato duerme en el sofá" → hipótesis "Hay un animal en el sofá" = entailment.
El truco del zero-shot es reformular la clasificación como NLI:
- Premisa = el titular.
- Hipótesis = una plantilla por etiqueta: "Este texto es clickbait", "Este texto es una noticia factual".
- La probabilidad de entailment de cada hipótesis se usa como score de esa etiqueta; la de mayor entailment gana.
Por eso los modelos zero-shot son en realidad modelos entrenados en NLI, como facebook/bart-large-mnli (entrenado en el dataset MNLI).
5. Cross-encoder vs bi-encoder. Es cómo el modelo compara las dos frases:
- Cross-encoder: mete premisa + hipótesis juntas en la misma pasada; las "lee" a la vez y da un score de relación. Muy preciso, pero lento: hay que reprocesar por cada par → con N etiquetas, N pasadas. Los
cross-encoder/nli-deberta-...son esto. - Bi-encoder: codifica cada frase por separado en un vector y compara vectores. Rápido y reutilizable, pero menos preciso en juicios par-a-par.
Para clasificar pocos titulares con pocas etiquetas, el coste de las N pasadas del cross-encoder es asumible y ganas precisión.
6. Ejemplo real (zero-shot con facebook/bart-large-mnli, etiquetas ["clickbait", "factual news"]):
| Titular | clickbait |
factual news |
|---|---|---|
| "You will not believe what happened next" | 0.79 | 0.21 |
| "Federal Reserve raises interest rates by a quarter point" | 0.17 | 0.83 |
Sin entrenar nada específico de clickbait, el modelo discrimina correctamente.
Estado de diseño: decisiones tomadas antes de codificar. La elección final de modelo de cada tarea se documenta en su iteración (E3-02 clickbait, E3-03 sentimiento).
Para el análisis de los titulares en el servidor MCP se han evaluado las siguientes opciones de modelos ligeros de Hugging Face, priorizando un balance entre baja latencia y precisión. Son candidatos: el modelo definitivo de cada tarea se fija en su issue (E3-02 clickbait, E3-03 sentimiento).
| Categoría | Modelo (Hugging Face) | Ventajas (Pros) | Desventajas (Contras) |
|---|---|---|---|
| Sentimiento (Inglés) | cardiffnlp/twitter-roberta-base-sentiment-latest |
• Excelente precisión con texto corto. • Entiende matices periodísticos y sarcasmo. • Clasificación en 3 vías (Positivo, Negativo, Neutral). |
• Ligeramente más pesado en RAM/VRAM. • Inferencia marginalmente más lenta que modelos destilados. |
| Sentimiento (Inglés) | distilbert/distilbert-base-uncased-finetuned-sst-2-english |
• Inferencia ultra-rápida (ideal para latencia crítica). • Consumo mínimo de recursos (modelo distilled). |
• Solo clasificación binaria (Positivo/Negativo, omite neutralidad). • Menor capacidad para captar ironías complejas. |
| Clickbait (Específico) | elozano/bert-base-cased-clickbait-news |
• Solución "Plug & Play" (enchufar y listo). • Entrenado específicamente con titulares de noticias. |
• Difícil de ajustar (no puedes redefinir qué es "clickbait"). • Puede fallar con el clickbait sutil o "elegante" (ej. NYT). |
| Clickbait (Zero-Shot) | cross-encoder/nli-deberta-v3-small |
• Control total: permite definir tus propias etiquetas (ej. ["factual", "sensationalism"]).• Excelente capacidad de razonamiento lógico e inferencia. |
• Requiere afinar empíricamente las etiquetas de entrada. • Inferencia ligeramente más computacional al evaluar múltiples etiquetas. |
| Traducción (EN ➔ ES) | Helsinki-NLP/opus-mt-en-es |
• Ejecución 100% local y privada (sin costes de API externa). • Extremadamente ligero (~300MB). • Traducciones rápidas de oraciones cortas. |
• Calidad ligeramente inferior a APIs comerciales (DeepL/OpenAI) en textos muy literarios. • Añade un paso extra de procesamiento al pipeline del MCP. |
Para el MVP se ejecutan en remoto; el salto a local queda supeditado a si hay infraestructura de cómputo disponible (p. ej. de la universidad). Comparativa:
- Remoto — HF Inference API: disponible ya, sin GPU propia. HF asume el cómputo; a cambio se paga en latencia de red, rate limits y dependencia de un token (
HF_TOKEN). Encaja conBaseAPI(HTTP) extendiéndola aPOST+ headerAuthorization: Bearer. - Local —
transformers: descarga los pesos y usa RAM/VRAM propias, pero da privacidad total y sin rate limits. No usaBaseAPI.
Decisión para no bloquear la épica: el cliente NLP se programa contra una interfaz estable (classify / zero_shot → {label, score}); las tools (detect_clickbait, analyze_sentiment) y sus tests dependen del contrato, no de la implementación: pasar de remoto a local más adelante es escribir otra implementación detrás de la misma interfaz, sin tocar las tools. Se empieza por remoto, que no depende de infraestructura externa. Un futuro selector nlp_backend (remoto/local) se añadirá junto con el backend local (hoy no lo leería nadie → aplazado, YAGNI).
Sobre la tabla: las ventajas/inconvenientes de RAM/VRAM, "100% local" y tamaño en disco solo aplican al backend local; en remoto ese coste lo absorbe HF. Los modelos son los mismos en ambos casos.
Alcance: la traducción EN→ES (
Helsinki-NLP/opus-mt-en-es) es una capacidad candidata adicional, aún no comprometida en el MVP (las 2 tools núcleo son clickbait y sentimiento).
Primer cliente que rompe el patrón GET + api-key en query: HF exige POST con body JSON y auth por header Authorization: Bearer. Cambios:
Settings: nueva variable obligatoriahf_token(HF_TOKENen.env), fail-fast.BaseAPI: soporte dePOSTcon body JSON, y auth delegada en un método sobreescribible_apply_auth(por defecto, api-key en query → Guardian/NYT intactos). La subclase decide dónde va la auth sin un soloifpor tipo (polimorfismo).HFClient(BaseAPI)(backend/integrations/nlp/):classify(text, model)hace POST y normaliza a{label, score}(clase ganadora) con parseo defensivo.- CI:
HF_TOKEN: dummyañadido al workflow (la nueva key obligatoria rompería el fail-fast deSettingsen CI).
Motivo del cambio de endpoint: el endpoint clásico api-inference.huggingface.co está deprecado (ni resuelve DNS). Se migró al router del provider serverless: https://router.huggingface.co/hf-inference/models/{model}. (De paso se detectó que WSL2 no tiene salida IPv6 — gotcha latente del entorno.)
Restricción descubierta probando: el serverless hf-inference no sirve ningún modelo de clickbait específico — sondeados elozano/bert-base-cased-clickbait-news, valurank/distilroberta-clickbait y Stremie/bert-base-uncased-clickbait-detection, todos devuelven 400 "Model not supported by provider". El cross-encoder/nli-deberta-v3-small de la tabla tampoco está servido.
Lo único viable para clickbait en remoto es zero-shot vía facebook/bart-large-mnli (confirmado servido; discrimina bien, ver ejemplo arriba).
Decisión: zero-shot remoto con bart-large-mnli para el MVP. Definimos nosotros las etiquetas (["clickbait", "factual"]) y dejamos elozano (modelo dedicado, más preciso) como mejora futura en backend local, si llega la infra.
Implicación de código: la respuesta zero-shot del router es una lista plana [{label, score}, ...] (ordenada), distinta del text-classification [[...]]. Por eso classify (que normaliza data[0][0]) no sirve tal cual: E3-02 añade una variante zero_shot(text, labels) que envía parameters.candidate_labels y normaliza data[0].
Tool analyze_sentiment(text) que reutiliza classify (es text-classification, no necesita variante nueva) sobre cardiffnlp/twitter-roberta-base-sentiment-latest.
Por qué cardiffnlp (3 vías) y no distilbert (binario): en titulares de noticias el neutral es frecuente (enunciados factuales); forzarlos a positive/negative distorsiona. cardiffnlp clasifica en positive / neutral / negative. Verificado: "The committee will meet on Tuesday" → neutral (0.94). Ambos están servidos en remoto; distilbert quedaría como opción si se priorizara latencia.
Fiabilidad: la inferencia remota da timeouts puntuales (HF); el cliente ya los reporta como
ToolResult.fail("Request timed out."). El reintento queda como mejora futura.
tests/test_nlp.py con respx (mockeando el POST al router de HF), cubriendo las dos formas de respuesta:
classify: etiqueta ganadoradata[0][0]; forma inesperada →fail(no excepción); HTTP503→ propaga el error.zero_shot: etiqueta ganadoradata[0], y que la petición incluyeparameters.candidate_labels.- Que la petición lleva el header
Authorization: Bearer(cubre_apply_auth). - Integration (marcados
@pytest.mark.integration): llamadas reales aclassifyyzero_shotque validan el contrato{label, score}, fuera de la CI.
Cierra la Épica 3 (las 2 tools núcleo —clickbait y sentimiento— sobre el cliente HF, con tests).
Smoke test E2E del MVP OK (2026-06-03):
health_check→get_nyt_news→detect_clickbait/analyze_sentiment, encadenado por el protocolo MCP real. Titulares del NYT salenfactual(sin sobre-marcar; el listicle fue el de menor confianza factual); sentiment 3-vías discrimina bien. Dos hallazgos → issues #44 (escenarios/evidencias) y #45 (fiabilidad).
Desde un cliente MCP real, el LLM orquesta las tools para resolver la petición del usuario. Escenarios que soporta el MVP:
| # | Escenario | Tools que encadena el LLM |
|---|---|---|
| 1 | Flujo estrella: "titulares del NYT sobre <tema> → ¿cuáles son clickbait?" |
get_nyt_news / get_guardian_news → detect_clickbait |
| 2 | "¿qué tono tienen esos titulares?" | analyze_sentiment |
| 3 | Estado del servidor y sus integraciones | health_check |
| 4 | Tema inexistente → mensaje claro, sin excepción | get_*_news (rama de error) |
Evidencia — smoke test E2E (2026-06-03), ejecutado por el protocolo MCP real (health_check → get_nyt_news → NLP):
| Titular (NYT real) | detect_clickbait |
analyze_sentiment |
|---|---|---|
| 5 Things to Know About Nithya Raman | factual 0.74 | — |
| Scientists Find Way to Supercharge Dangerous Computer Worms With A.I. | factual 0.80 | — |
| Political Newcomer Beats Trump-Backed Candidate in Iowa Governor Primary | factual 0.83 | neutral 0.86 |
| U.S. Treasury Imposes Sanctions on Iran's Biggest Crypto Exchange | factual 0.88 | — |
| Trump Has Failed as Commander in Chief | — | negative 0.91 |
Conclusiones:
- Sin sobre-marcar: los titulares del NYT (fuente reputada) salen
factual; el listicle "5 Things to Know…" es el de menor confianza factual (0.74) — el modelo capta el estilo. Que sí marca clickbait se validó aparte ("You will not believe…" →clickbait0.79). - Sentiment de 3 vías discrimina bien (opinión →
negative0.91; noticia →neutral0.86). - Fiabilidad: 1 de 5 llamadas NLP dio timeout → motivó E4-02 (reintento).
- Limitación: con 2 fuentes reputadas (NYT/Guardian) apenas aparece clickbait; evaluarlo de verdad pide una fuente/dataset sensacionalista (E4-03, aparcado).
Las capturas desde Claude Desktop se adjuntan en la memoria del TFG; esta tabla es la transcripción de la validación in-session.
El smoke test confirmó que la inferencia remota de HF falla de forma transitoria (~1 de cada 5 llamadas dio timeout; recurrente durante E3). Se añadió un reintento en BaseAPI.make_request:
- Opt-in por clase:
MAX_RETRIES(default0→ Guardian/NYT no reintentan) yRETRY_BACKOFF.HFClientlo activa conMAX_RETRIES = 3. - Solo transitorios:
httpx.TimeoutExceptiony HTTP503; cualquier otro error falla al instante (reintentar no lo arregla). - Tests (
respx):503→200ytimeout→200(recupera al reintentar), y503perpetuo → agota reintentos (MAX_RETRIES + 1intentos). UsanRETRY_BACKOFF = 0para no esperar de verdad.
Motivo: mejorar la fiabilidad del MVP frente a la flakiness del backend remoto, sin romper el contrato (sigue devolviendo ToolResult.fail si se agotan los reintentos).
La validación destapó tres problemas al buscar por tema (p.ej. "artificial intelligence"), todos corregidos:
- NYT — relevancia: el cliente forzaba
sort=newest, que hacía queqno filtrara (devolvía lo más nuevo sin relación). Ahorasort = "relevance" if topic else "newest". (Verificado: devuelve IA limpia.) - Guardian — precisión: el
qlibre matchea palabras sueltas ("intelligence" arrastraba espías/música). Ahora filtra por tag curado:/tags?q=<topic>→ top tag →/search?tag=<id>, con fallback aqsi no hay tag. - Usabilidad del LLM:
topicno teníadescriptionen el schema (solo en el docstring), así que el LLM a veces inventaba parámetros (query). Ahora usaField(description=…)en ambas tools.
Lección de la validación: fueron un bug de comportamiento de API externa (NYT sort) y uno de precisión de búsqueda (Guardian) que un test mockeado no destapa — solo la llamada real. La validación garantiza forma, no corrección; por eso aquí pesa la verificación empírica/integración.
Tests (respx): NYT manda sort=relevance/newest según haya topic; Guardian usa tag o cae a q.
Endurecimiento del API_Consumer en BaseAPI, heredado por los tres clientes (NYT, Guardian, HF):
- R2.4 · Rate limiting —
AsyncLimiter(token bucket) por instancia, configurable por clase conRATE_CALLS/RATE_PERIOD. Límites reales: NYT5/60s, Guardian60/60s. Es por instancia (no atributo de clase compartido) para que cada cliente tenga su propio cupo y no se pisen entre ellos. - R2.6 · Tracking —
call_countcuenta cada intento real a la API (incluidos los reintentos de E4-02). Property de solo lectura. - R2.7 · Cuota restante —
remaining_quota, resuelta de forma híbrida según lo que cada API expone (polimorfismo con el hook_read_quota):- Guardian lee el header real
x-ratelimit-remaining-day. - NYT no manda headers → la deriva:
DAILY_LIMIT − call_count(conDAILY_LIMIT = 500).
- Guardian lee el header real
Decisión de diseño — observabilidad, no payload. R2.6/R2.7 se redactaron como "devolver/mostrar al usuario", pero meter el uso de API en la salida de cada tool ensucia la respuesta que lee el consumidor/LLM. Se expone como observabilidad interna: un evento estructurado api.call (structlog → stderr) con api, endpoint, call_count y remaining_quota en cada llamada exitosa. El requisito se ajustó en consecuencia (registrado en la memoria de cambios del TFG).
Tests (respx): NYT deriva DAILY_LIMIT − call_count; Guardian lee la cuota del header; ambos cuentan llamadas (Guardian: 2 por búsqueda con topic, por el /tags + /search).
Aislamiento de tests: emitir el log
api.calldestapó un bug latente —test_logging.pyconfiguraba structlog global apuntando alstderrtemporal decapsys; al cerrarse ese buffer, cualquier test posterior que logueara petaba conValueError: I/O operation on closed file. Se añadiótests/conftest.pycon un fixtureautouseque resetea structlog tras cada test (un fallo de logging quedaba además enmascarado por elexcept Exceptiondemake_requestcomo "No articles found" — doble disfraz).
Selección de tag de Guardian (afinada en este PR): _find_tag ya no coge tags[0] a ciegas. Para temas que son una sección ("technology"), Guardian lista antes tags de nicho (sustainable-business/technology) que, con el filtro from-date, daban 0 resultados recientes, dejando el canónico más abajo. Ahora _find_tag prefiere el tag canónico de sección (id con forma X/X, p.ej. technology/technology) y cae a tags[0] para temas multi-palabra (p.ej. technology/artificialintelligenceai, que no es X/X). (Verificado contra la API real.)
Primer harness de evaluación offline (backend/evaluation/eval_lexical.py) para medir el detector léxico con datos reales en vez de umbrales "a ojo". Issue #63.
- Dataset: Chakraborty et al. 16k (vendorizado en
data/) — 15 999 titulares clickbait + 16 001 no-clickbait, de noticias. Licencia MIT + cita obligatoria (verdata/ATTRIBUTION.md). - Tubería reproducible (
python -m backend.evaluation.eval_lexical):load_dataset(lee los.gz, etiqueta por fichero) →score_headlines(correlexical.detectuna vez por titular, guarda elscore) →confusion/metrics(sklearn: matriz + P/R/F1) →sweep(barre umbrales reutilizando los scores guardados → barato).
Baseline 1 — lexicón mínimo (~12 pistas hardcodeadas):
| t | Precision | Recall | F1 |
|---|---|---|---|
| 1 | 0.887 | 0.506 | 0.644 |
| 2 | 0.985 | 0.183 | 0.309 |
| 3 | 0.997 | 0.019 | 0.037 |
Baseline 2 — lexicón dinámico (~384 pistas, listas completas de Chakraborty):
| t | Precision | Recall | F1 |
|---|---|---|---|
| 1 (default) | 0.847 | 0.850 | 0.849 |
| 2 (modo conservador) | 0.969 | 0.543 | 0.696 |
| 3 | 0.994 | 0.263 | 0.416 |
Lectura: ampliar el léxico de ~12 a ~384 pistas (cargadas dinámicamente desde cues/: hyperbolic palabra-por-línea, subjects literal Python vía ast.literal_eval) dispara el recall 0.506 → 0.850 a cambio de 4 puntos de precisión → F1 0.644 → 0.849. Confirma la hipótesis: el cuello de botella era la cobertura del lexicón, no el umbral. common_phrases se deja fuera del léxico de reglas a propósito (n-gramas genéricos tipo "for the" → hundirían la precisión; son material de features para el modelo lineal, no reglas booleanas).
Punto de operación: THRESHOLD = 1 por defecto (mejor F1, P≈R≈0.85); t=2 documentado como modo conservador (precisión 0.97, menos falsos positivos). Sigue marcado TODO: Parametrizar.
Caveat metodológico: el sweep elige el umbral sobre todo el dataset → ligeramente optimista. Para una regla con 1 hiperparámetro apenas sobreajusta (vale como techo), pero la comparación justa contra el futuro modelo lineal exigirá un split train/test.
Siguiente: modelo de pesos lineales (LogisticRegression/LinearSVC sobre estas mismas pistas como features) — sigue siendo interpretable (los pesos son la explicación, R3.8) y deja que el modelo aprenda el peso de cada señal (incl. ~0 para las genéricas). Es el "modelo propio" del peldaño 2, alineado con Rudin (interpretable-primero, medir el hueco).
- Dependencias:
numpy+scikit-learnvan enrequirements-dev.txt(tooling offline), no enrequirements.txt(CI ligero). - Incoherencia — fuera de alcance: Chakraborty son solo titulares (sin cuerpo) → calibrarla necesita Webis-17 (follow-up).
Fase B. Surge del dilema HF: la Inference API alojada de HuggingFace resultó poco fiable (timeouts ~1/5, caídas del proveedor, modelos de clickbait específicos no servidos). Issues #54–#58.
Desacopla el NLP del proveedor concreto para poder ejecutarlo en local (con transformers), eliminando la dependencia de la API alojada de HF.
- Interfaz
NLPBackend(ABC,nlp/base.py): contrato conclassifyyzero_shot, ambos devolviendoToolResult.ok({"label", "score"}). Al ser clase abstracta, las implementaciones están obligadas a cumplir las dos firmas (enforcement en runtime al instanciar). - Dos implementaciones, un contrato (polimorfismo):
HFClient(BaseAPI, NLPBackend)— backend remoto (HTTP a HF). Herencia múltiple: es a la vez cliente HTTP y backend NLP;NLPBackendactúa de interfaz (sin lógica),BaseAPIaporta el transporte.LocalNLPClient(NLPBackend)— backend local contransformers.pipeline. Carga perezosa + cache por clave(task, model)(cargar un modelo es caro → se crea una vez y se reutiliza), e inferencia en hilo (asyncio.to_thread) para no bloquear el event loop.
- Factoría
get_nlp_backend()(nlp/factory.py): eligeremote/localsegún el settingnlp_backend(Literal, default"remote"). Las tools llaman a la factoría, no a una clase concreta. - Las tools no cambian:
detect_clickbait/analyze_sentimentsiguen llamandoapi.zero_shot/api.classify; como ambos backends cumplen el contrato, cambiar de backend es una línea. Ese es el premio del ABC + factoría.
Motivo: mitigar el riesgo de fiabilidad/disponibilidad del backend remoto (ver Épicas 3 y 4) y ganar control total del modelo — precondición de R3.7 (incoherencia) y del fine-tuning local. La inferencia local es viable en el hardware de desarrollo (GTX 1650 SUPER 4 GB / CPU Ryzen 5).
Dependencias y tests: transformers (con sus dependencias) está en requirements.txt. torch es dependiente del hardware (CPU o CUDA), así que no se fija en requirements.txt — se instala aparte para usar el backend local (CI y los tests no lo necesitan, porque mockean el pipeline). Cobertura: LocalNLPClient (normalización de classify/zero_shot, manejo de errores, cache por (task, model)) y la factoría — todo sin descargar modelos ni tocar la red.
get_guardian_news y get_nyt_news ahora devuelven un campo content (el teaser/resumen del artículo) además de title/url/date — prerequisito de la detección por incoherencia (E5-03), que compara titular ↔ contenido.
- Guardian: el teaser no viene por defecto → se pide con el param
show-fields=trailTexty se extrae defields.trailText. - NYT: el
abstractya viene en la respuesta → solo se extrae. Bonus: también se devuelveprint_headline(titular impreso), que habilita la variante titular web vs. impreso de R3.7. - Acceso defensivo: el teaser está anidado (
fields.trailText,headline.print_headline) → patrón.get("...", {}).get(...)para no petar si falta. - Tests (
respx): losfake_payloadincluyen los campos fuente y se verifica que el cliente los mapea acontent(yprint_headlineen NYT).
Motivo: sin el contenido no hay con qué comparar el titular; E5-02 es el habilitador de E5-03.
Segunda señal de clickbait, complementaria a detect_clickbait (que juzga el titular de forma aislada con zero-shot): mide si el titular se corresponde con el contenido. La esencia del clickbait es prometer algo que el cuerpo no cumple → un titular incoherente con su contenido es sospechoso.
- Componente
IncoherenceDetector(nlp/incoherence.py): usasentence-transformersdirectamente, sin pasar por el contratoNLPBackend— no es clasificación, sino embeddings + similitud. Mismo patrón queLocalNLPClient: carga perezosa + cache del modeloall-MiniLM-L6-v2(instancia única creada enregister→ se carga una sola vez por proceso) e inferencia en hilo (asyncio.to_thread) para no bloquear el event loop. - Técnica: se generan los embeddings del titular y del contenido y se calcula su similitud del coseno. Similitud baja = el titular no encaja con lo que cuenta la noticia = señal de incoherencia. El umbral (
THRESHOLD) decide el flagincoherent. Coseno porque es la métrica con la que se entrena SBERT (elección documentada, no arbitraria). - Tool nueva
detect_clickbait_incoherence(headline, content)— separada dedetect_clickbait(R3.7 dice "además de"): el LLM puede usar una, otra, o contrastar ambas. - Explicable por diseño: la salida es un dict auto-descriptivo
{"similarity", "incoherent", "headline", "content"}— el score es la explicación, a diferencia de la etiqueta opaca del zero-shot. Refuerza el eje de explicabilidad del TFG. - Tests: se mockea
_get_modelcon un modelo falso que controla la similitud (FakeModel+FakeSim, que imita el tensor de.similarity()con su.item()); casos coherente / incoherente / error. Sin descargar modelos ni tocar la red.
Dependencias: sentence-transformers no se fija en requirements.txt — arrastra torch + CUDA (varios GB, dependientes del hardware), así que sigue la misma política que torch en E5-01: se instala aparte para usar la incoherencia en local. CI y los tests no lo necesitan (mockean _get_model).
Motivo: R3.7 — segunda señal de clickbait complementaria al zero-shot. La incoherencia captura el desajuste titular↔cuerpo (la promesa incumplida) y es intrínsecamente explicable (la similitud es el motivo), reforzando el eje de explicabilidad del TFG.
Formaliza la explicabilidad —eje del TFG— y entrega la primera señal genuinamente white-box.
Requisitos: se añaden a R3 cuatro criterios (ver docs/requisitos.md): R3.8 explicar veredictos priorizando lo intrínsecamente interpretable; R3.9 divulgar e intercambiar modelos; R3.10 exponer ≥2 señales contrastables; R3.11 post-hoc opcional (LIME/SHAP).
- Detector léxico
lexical.detect(headline)(nlp/lexical.py): busca pistas de clickbait y devuelve cuáles dispararon y dónde. A diferencia de la incoherencia (decisión transparente pero feature opaca), esta señal es íntegramente interpretable: las pistas son la explicación.- Pistas categorizadas + regex (palabras y frases separadas a propósito):
WORD_CUES(hipérbole, forward-reference) porset,PHRASE_CUES(curiosity-gap) por subcadena conre.escape, yPATTERNSestructurales (número inicial,?final, MAYÚSCULAS, elipsis) por regex. Sembradas de las listas de Chakraborty et al. 2016 (citadas). - Salida auto-descriptiva:
{score, is_clickbait (score ≥ THRESHOLD), matches:[{category, cue, span}], headline}— losspandejan preparado el resaltado en el frontend futuro. - Función pura y síncrona (no hay modelo ni red) → sin
async/to_thread; guard de entrada vacía (R3.5).
- Pistas categorizadas + regex (palabras y frases separadas a propósito):
- Tool nueva
detect_clickbait_lexical(headline)— tercera señal independiente → habilita el contraste (R3.10): el LLM puede cruzar zero-shot + incoherencia + léxico. - Sin dependencias nuevas (solo
rede la stdlib). Tests sin mocks (determinista): positivo / negativo / guard de entrada.
Postura (Rudin): se aplica lo intrínseco donde se puede (léxico = white-box; incoherencia = a medias, el modelo de embeddings es opaco) y se reserva lo post-hoc (LIME/SHAP) solo para el zero-shot, que no se puede abrir de otro modo.
Backlog (en memoria, fuera de este PR): fichas de modelos (R3.9 divulgación), meta-tool de contraste con cascada, post-hoc (R3.11). La combinación calibrada de señales depende de un dataset etiquetado (Webis-17 CC0 / Chakraborty 16k) → sube E4-03 de prioridad.
Motivo: R3.8 — la explicabilidad es el eje del TFG; el explicador léxico es la pieza que responde "qué palabras", la única señal plenamente interpretable, y completa las tres señales contrastables.
Issue #65. Primer modelo entrenado del proyecto: una regresión logística que aprende un peso por señal en vez de contar pistas con un umbral fijo. Sigue siendo interpretable (los pesos son la explicación, R3.8) — peldaño 2 alineado con Rudin (interpretable-primero, medir el hueco antes de plantear una caja negra).
- Featurización — dos granularidades:
- Opción A (por categoría):
featurizecuenta losmatchespor categoría → vector de 7 enteros en el orden delexical.CATEGORIES. - Opción B (por cue):
featurize_cuescuenta cada cue individual (clavematch["cue"]) → vector de ~390 en el orden delexical.ALL_CUES; losPATTERNSse quedan por categoría (su texto casado varía → híbrido). Es un bag-of-words restringido al vocabulario de pistas.
- Opción A (por categoría):
- Tubería (
backend/evaluation/linear_model.py):load_dataset(reusa E4-03) →featurize→ split train/test estratificado (test_size=0.2, semilla fija → corrige el sesgo optimista) →LogisticRegression.fit(minimiza log-loss) →predict→ métricas + pesos.
Resultado (todos sobre el mismo held-out test, random_state=24):
| Reglas (t=1) | Lineal A (categoría) | Lineal B (por cue) | |
|---|---|---|---|
| Precision | 0.841 | 0.863 | 0.927 |
| Recall | 0.845 | 0.842 | 0.811 |
| F1 | 0.843 | 0.852 | 0.865 |
Sobre todo el dataset las reglas daban F1=0.849 (optimista); en el test honesto bajan a 0.843 → el caveat era real, pequeño (parte sesgo, parte muestreo).
Veredicto: el lineal gana, modesto pero limpio (F1 0.852 vs 0.843). Toda la ventaja es precisión (+0.022); el recall queda empatado. Y la explicación predice la métrica: las reglas contaban all_caps/question como +clickbait → falsos positivos → precisión 0.841; el lineal aprendió que son negativos → menos falsos positivos → precisión 0.863. La tabla de pesos (R3.8) anticipó dónde estaría la ganancia. Aun así, con 7 features gruesas el margen es pequeño → la granularidad de las features es el cuello de botella, no el modelo.
Explicabilidad (R3.8) — pesos aprendidos:
| Categoría | Peso | Lectura |
|---|---|---|
| forward_reference | +2.79 | señal de clickbait más fuerte ("this/these/you") |
| leading_number | +2.61 | listicles ("10 things…") |
| hyperbole | +2.05 | "amazing/shocking" |
| curiosity_gap | 0.00 | feature muerta (PHRASE_CUES mínimo → casi nunca dispara) |
| ellipsis | ≈0 | no informativa (signo inestable entre semillas) |
| all_caps | −0.76 | empuja a no-clickbait (siglas de prensa seria: NASA, NATO…) |
| question | −3.80 | empuja a no-clickbait (el ? final sale más en noticias reales aquí) |
Los tres positivos = clickbait de manual → valida el white-box. Hallazgo clave: all_caps y question salen negativos — el modelo corrige solo suposiciones que las reglas tenían al revés (las contaban como +clickbait). Ejemplo de medir > intuir. Los pesos grandes son estables entre semillas (la explicación es fiable, no un artefacto del split). (Caveat: pesos condicionales y dataset específico de titulares EN — no sobre-generalizar.)
Opción B — features por cue (F1 0.865): una feature por palabra/frase (~390) en vez de por categoría. La granularidad revela heterogeneidad dentro de las categorías hechas a mano:
- TOP (clickbait):
you(+5.9, el nº1 → dirigirse al lector),we,what,this,everyone,guys(sujetos vagos) +adorable,hilarious,funniest,amazing,literally(hipérbole afectiva). - BOTTOM (no-clickbait):
question(−4.5, estable con A) +extraordinary,legendary,striking,memorable,grand— todas de la categoríahyperbole, pero léxico formal de prensa seria.
Ese contraste explica el salto de precisión (0.863 → 0.927): A daba hyperbole = +2.0 (el promedio); B distingue adorable (clickbait) de extraordinary (serio) y deja de dispararse con los formales. La categoría a mano era heterogénea y el modelo lo destapa — hallazgo lingüístico, no solo métrico. (Caveat: algunos pesos del bottom — ethnic, psychological, charged — son artefacto temático del corpus, no "no-clickbait" universal.)
Siguiente (opcional): B2 — añadir common_phrases como features (arrastra el acoplamiento de detect(), que es a la vez la tool de reglas → decisión pendiente); o el peldaño neural (E5-05). El modelo lineal interpretable ya bate al baseline con explicación legible (R3.8).
Issue #66. Convierte el modelo lineal de E5-06 (script de investigación) en una señal usable del servidor MCP, sin engordar el runtime.
- Modelo persistido en JSON (
backend/integrations/nlp/linear_clickbait.json):weights,interceptyfeature_names(ordenPATTERNS+ALL_CUES). Lo exporta el script de entrenamiento (linear_model.py); es un asset versionado (sin él, el import de la tool falla). - Inferencia en Python puro (
backend/integrations/nlp/linear.py, sinsklearn/torch): un modelo lineal solo necesitasigmoid(w·x + b)→ un producto escalar.featurize_cuesvive aquí (fuente única; el entrenamiento la importa de aquí). - Tool
detect_clickbait_linear(headline)→{is_clickbait, probability, top_cues, headline}.top_cues= los cues que más contribuyeron al veredicto (peso × frecuencia, ordenados) = explicación intrínseca (R3.8). - 4ª señal contrastable (R3.10): zero-shot + incoherencia + léxico + lineal.
Ejemplo: "10 AMAZING things that happened!" → is_clickbait=True, p≈0.9998, top_cues = [things +3.24, amazing +2.99, leading_number +2.76, that +1.78, all_caps −0.79].
Nota de diseño: entrenar (sklearn, en evaluation/, deps pesadas) y servir (pesos JSON + Python puro, en integrations/nlp/) quedan separados → CI y runtime siguen ligeros. Tests deterministas (sin mocks, el JSON está versionado).
Issue #71. Cierra la mitad pendiente de R3.9 (DEBERÁ): divulgar los modelos que emplea el sistema (la otra mitad —intercambiarlos por configuración— ya la cubría la factoría nlp_backend).
- Fuente única
backend/integrations/nlp/model_cards.py:MODEL_CARDS, una ficha por señal conname,task,type(interpretable / híbrido / opaco),limitationsybackend. - Tool
describe_models()(sin argumentos) → devuelve las fichas en JSON: divulgación consultable en runtime por la interfaz MCP, forward-compatible con un futuro frontend. - El campo
typeenlaza R3.9 con R3.8: marca de un vistazo qué señal es white-box (léxico, lineal) frente a caja negra (zero-shot, sentimiento) o híbrida (incoherencia: decisión transparente, feature opaca). - Limitaciones honestas: el zero-shot es genérico y opaco; el sentimiento está entrenado en tuits; la incoherencia tiene el umbral sin calibrar; el léxico solo capta clickbait de forma, no de engaño.
Es transparencia de sistema (qué modelos, con qué límites), no solo de modelo. Tests deterministas (estructura + serialización). Con esto, el alcance obligatorio (DEBERÁ) de la Épica 5 queda completo.
Issue #72 (sugerencia del tutor). Cierra el caveat metodológico arrastrado desde E4-03: el THRESHOLD se eligió barriendo todo el dataset, y comparar modelos sobre el mismo test que decide el ganador infla el número (sesgo optimista).
- Tres conjuntos, tres trabajos (
backend/evaluation/splits.py, 60/20/20 estratificado, semilla fija):train(19 200) — el modelo aprende;dev(6 400) — banco de pruebas: aquí se afinan umbrales/hiperparámetros y se comparan modelos (absorbe el optimismo del afinado);test(6 400) — congelado; se corta PRIMERO y se abre una sola vez al final para el número honesto.
- Persistencia física (
data/splits/*.jsonl, versionados): los pares crudos(headline, label)— los datos son el contrato, cada modelo featuriza aguas abajo → mismo split para reglas, lineal y futuros modelos, aunque cambie la featurización (p. ej. el fix #69 no invalida los ficheros).create_splits()rehúsa sobrescribir (regenerar rompería la comparabilidad);load_split(name)es la única puerta de entrada. - Decisiones re-tomadas en dev: el sweep del léxico sobre
devre-confirmaTHRESHOLD=1(F1 0.845); el lineal se re-entrena solo contrain(60 %) y se re-exporta el JSON servido.
Resultado final (test, abierto una única vez, evaluado por la vía shipeada — lexical.detect / linear.predict con el JSON):
| dev | test (final) | |
|---|---|---|
| Reglas (t=1) | 0.845 | 0.843 |
| Lineal | 0.868 | 0.865 (P=0.928, R=0.810) |
Lectura: brecha dev→test mínima (~0.003) → sin sobreajuste al dev. Los números coinciden con los de E5-06 → aquellas conclusiones no estaban infladas, y ahora son defendibles: nadie eligió nada mirando el test. Entrenar con el 60 % (antes 80 %) apenas costó rendimiento (32k muestras dan margen).
Límite honesto (validez externa): el test es held-out pero no es dato ajeno — todo sale de Chakraborty (misma distribución). La generalización real a otro dominio se medirá con datasets externos (#76, p. ej. Webis-17).
Issue #76. Mide la generalización real evaluando la vía shipeada sobre un dataset ajeno: extracto del Webis Clickbait Corpus 2017 (2 459 tuits de 27 medios USA, anotados 0–1 por 5 personas; CC BY 4.0, cita en data/external/ATTRIBUTION.md). Cero adaptación: sin reentrenar ni re-afinar nada. Reproducir: python -m backend.evaluation.eval_external.
| F1 | Chakraborty (test #72) | Webis-17 (externo) |
|---|---|---|
| Reglas (t=1) | 0.843 | 0.498 (P=0.40, R=0.68) |
| Lineal | 0.865 | 0.476 (P=0.47, R=0.48) |
Lectura (el hallazgo ES el desplome):
- Distribution shift severo: −0.35–0.39 de F1 al cambiar de dominio. Los detectores léxicos NO generalizan de titulares de noticias a tuits sin adaptación.
- El lineal pierde su ventaja (0.476 vs 0.498 de las reglas): sus pesos por-cue estaban ajustados al estilo de Chakraborty (BuzzFeed vs NYT) — la especialización que ganaba en-dominio es justo lo que pierde fuera.
- Causas visibles en los errores: convenciones de tuit (
RT @user:,via @WSJ, hashtags) y el...de truncado de tuit que disparaellipsissin ser clickbait; prevalencia distinta (31 % vs 50 %). - No es artefacto de binarización: el
truthMeanmedio de los falsos positivos (0.28) apenas supera al de los verdaderos negativos (0.23) → los FP no son mayormente casos "slightly clickbaiting" mal binarizados.
Valor para la memoria: los números en-dominio (0.84–0.87) son válidos para ese dominio; la transferencia requiere adaptación (re-entrenar con datos del dominio destino, limpiar convenciones de tuit, o señales semánticas). El extracto conserva truthMean → futuro: calibración con scores continuos.
Cierra la Épica 5 (
v0.2.0, núcleo NLP completo) y abre la capa web. Issue #73.
El prototipo se diseña como wireframes, no maquetando en HTML/CSS: iterar sobre un boceto cuesta minutos y sobre código, horas — lo que permite validar la interacción antes de comprometer implementación.
Se combinan deliberadamente los dos ejes clásicos de cobertura (Nielsen, Usability Engineering):
- Horizontal, fidelidad media-baja — las cinco pantallas completas, para fijar alcance y navegación.
- Vertical, alta fidelidad — solo en Resultados, porque es lo arriesgado y lo diferenciador: el lienzo de explicabilidad. El resto (un formulario, una tabla, un listado) es patrón conocido y no necesita profundidad.
Es gestión de riesgo, no reparto uniforme: se invierte fidelidad donde el diseño puede fracasar. El prototipo es además desechable — su entregable no es código, sino conocimiento y requisitos de interfaz mejor definidos.
Herramienta: draw.io (fuente versionada en docs/prototipo-ui.drawio, navegable: los botones enlazan entre páginas), por coherencia con los diagramas UML del proyecto.
| # | Pantalla | Justificación |
|---|---|---|
| 1 | Chat | R13 (agente conversacional), R6.10, R6.12 |
| 2 | Analizar | R6.3, R6.4 — camino determinista |
| 3 | Resultados | R3.8 (explicación), R3.10 (contraste), R6.6 |
| 4 | Sistema | R3.9 (divulgación), R5 (catálogo), R6.11 (servidores) |
| 5 | Historial | R4.4, R6.5 |
1 · Chat — el agente decide qué herramientas invocar; la respuesta trae la traza de tools y la tarjeta estructurada.
2 · Analizar — camino determinista (sin agente): titular + cuerpo opcional, o selección de una noticia real.
3 · Resultados — el lienzo de explicabilidad: cues resaltados sobre el propio titular, las cuatro señales contrastadas y el badge de naturaleza de cada modelo.
4 · Sistema — servidores MCP conectados, catálogo de herramientas y fichas de modelos con sus límites medidos.
5 · Historial — análisis previos, con el origen (chat o formulario) y acceso al resultado.
Dos puertas de entrada, no una. El formulario sirve a quien sabe lo que busca; el chat, a quien no conoce el catálogo. Se mantienen ambas porque cubren perfiles distintos y porque el camino determinista es el que se puede probar de forma fiable (E2E) y demostrar sin depender de un modelo generativo.
El veredicto no lo emite el LLM. Riesgo detectado al incorporar el chat: si el resultado se entrega como prosa del modelo, se evapora la explicabilidad —los span de los cues, las contribuciones con su peso, los badges de naturaleza— que es el eje del TFG. La solución no es elegir entre chat y vista estructurada, sino combinarlos: la interfaz renderiza las tarjetas con el JSON real de cada herramienta y el modelo solo narra y contrasta. Si el LLM se equivoca al redactar, la tarjeta lo desmiente. El chat así refuerza la explicabilidad en vez de disolverla (R13.3, R13.4).
LLM local (Ollama), con degradación prevista. Sin acceso a APIs de pago, el agente se sirve en local. El riesgo no es la potencia sino la fiabilidad del tool calling en modelos pequeños (inventan llamadas, ignoran el esquema) y la latencia en CPU. Por eso se valida antes de construir nada encima (spike #82) y se define de antemano un modo guiado (R13.8): si el tool calling no es fiable, el backend decide las herramientas de forma determinista y el modelo solo narra — sigue habiendo conversación y las tarjetas son idénticas.
El prompt de sistema es configuración, no código escondido. Vive como fichero versionado y consultable desde la propia interfaz (R13.5). Codifica la postura del sistema: qué es cada señal y de qué naturaleza es, contrastar en vez de obedecer a una sola, la distinción forma vs engaño, y no emitir veredictos propios. Es coherente con R3.9: si se divulgan los modelos, también debe divulgarse la instrucción que los gobierna. El propio LLM pasa a tener su ficha de modelo (tipo opaco).
MCP multi-servidor: nada cableado. El agente actúa como cliente MCP frente a varios servidores especialistas (NLP, noticias, utilidades) declarados por configuración, cada uno en su contenedor. Añadir un especialista es levantar un contenedor y añadir una línea; ni el agente ni el frontend se tocan. La pantalla Sistema refleja esa lista dinámicamente (nombre, transporte, estado, herramientas que aporta) y los filtros del catálogo se derivan de ella. Esto obliga a un cambio técnico: el servidor MCP usa hoy transporte stdio, que exige lanzar el servidor como subproceso y no cruza contenedores → hay que añadir transporte HTTP (R1.6).
Persistencia del historial: abierta. R4.4 solo exige el endpoint; queda por decidir si persiste en el navegador o en base de datos.
El diseño del prototipo destapó que los requisitos describían una interfaz de ejecutar herramientas con formularios, sin agente — pese a que el título del TFG es "agente inteligente basado en MCP" y el propósito del protocolo es precisamente alimentar a un LLM con herramientas. Se corrige:
- R13 (nuevo) — Agente conversacional: tool calling sobre herramientas descubiertas por MCP; traza y resultado estructurado además de la narración; el veredicto procede de las tools; prompt como configuración versionada;
LLM_Backendintercambiable; ficha de modelo propia; modo guiado como degradación. - R1 ampliado — transporte HTTP además de
stdio; varios servidores declarados por configuración; degradación si uno no responde. - R5 ampliado — el catálogo agrega herramientas de todos los servidores indicando su procedencia, y se construye por descubrimiento, sin listas cableadas.
- R6 ampliado — dos vías de entrada; estado de los servidores conectados; renderizado del resultado estructurado y la traza.
- Glosario —
Agent_Orchestrator,LLM_Backend.
Todo el diseño del asistente descansaba sobre un supuesto sin verificar: que un modelo pequeño servido en local decide bien qué herramienta invocar. Antes de construir nada encima se comprueba, porque el resultado determina la arquitectura: si el modelo no elige bien, el chat pasa a modo guiado (R13.8) y el backend decide las herramientas. Scripts reproducibles en spikes/.
Modelo: qwen3.5:2b (2.3B, Q8_0) con Ollama. Se prueban las descripciones reales de los docstrings y el catálogo completo, incluidas las cuatro herramientas de clickbait con nombres casi idénticos.
Diseño de la medición: las consultas se separan por tipo, porque promediarlas daría una cifra sin sentido — en las genéricas («¿es clickbait?») vale cualquier detector, mientras que en las específicas solo una es correcta. Son estas últimas las que revelan si el modelo confunde herramientas parecidas.
| Categoría | Acierto |
|---|---|
| Genérica (cualquier detector vale) | 4/4 |
| Específica (solo una correcta) | 8/8 |
| Otro dominio (noticias, modelos) | 3/3 |
| Sin herramienta (no debe llamar) | 5/5 |
| Global | 20/20 (100 %) · parámetros válidos 15/15 |
Lectura: el modelo discrimina entre las homónimas por el matiz de la petición — «qué pistas léxicas y dónde» → detect_clickbait_lexical; «dame la probabilidad» → detect_clickbait_linear. Esa distinción solo puede venir de las descripciones, lo que confirma la premisa de R13.2: los docstrings son la interfaz con el modelo, y añadir una herramienta no obliga a tocar el agente.
Modo de fallo detectado (Fase 1): ante una consulta que debía invocar la herramienta, el modelo redactó él mismo el análisis en lugar de llamarla. No admitió no poder: fingió el resultado. Es la justificación empírica de R13.4 — el veredicto debe proceder de las tools, nunca del modelo.
La latencia fue el criterio conflictivo, y reveló un problema de infraestructura. La primera tanda dio 71,7 s de media: Ollama nunca llegaba a usar la GPU, por dos causas encadenadas —el directorio cuda_v12 con permisos 700 de root, y por debajo librerías CUDA corruptas (ldd → SIGBUS)—, probablemente por una instalación que agotó el espacio en disco. El modelo se cargaba con offloaded 0/N layers to GPU independientemente de su tamaño: reducirlo de 4B a 2B no cambiaba nada. Reinstalando Ollama se recuperó la GPU (library=CUDA compute=7.5, reparto parcial de 17/26 capas por los 3,2 GiB disponibles).
| CPU | GPU | |
|---|---|---|
| Acierto global | 20/20 | 20/20 |
| Parámetros válidos | 15/15 | 15/15 |
| Latencia media | 71,7 s | 20,6 s |
El acierto es idéntico en ambas tandas — buen control experimental: la calidad de la decisión depende del modelo, no del hardware, que solo mueve la latencia; y el resultado se reproduce en dos ejecuciones independientes.
La media, además, engañaba. Excluyendo la primera consulta —150,6 s de carga en frío, coste único al montar 2,7 GB de pesos—, la mediana es de 8,8 s. Los picos de 30-50 s corresponden a respuestas donde el modelo redacta párrafos explicativos, no a la selección de herramienta: decidir qué tool llamar cuesta ~8-9 s. El bucle completo del agente (decidir → ejecutar → narrar) rondará los 20-25 s, aceptable con streaming y un indicador de progreso.
Decisión: se adopta el tool calling real (R13.1) — se cumplen los tres criterios: acierto (100 % ≫ 80 % exigido), parámetros válidos (100 %) y latencia. El modo guiado (R13.8) pasa de plan B probable a degradación reservada para entornos sin GPU: en CPU pura el mismo modelo tardaba 71,7 s de media y el chat resultaba inviable. La distinción importa porque el despliegue podría acabar siendo en CPU.
Limitaciones: 20 consultas, un modelo, y redactadas en un registro limpio — los usuarios reales escriben peor. Es una señal sólida, no una medida definitiva.
Las fases anteriores sólo medían la selección. Ejecutando las herramientas de verdad y devolviendo el resultado al modelo (role="tool"), el bucle encadena correctamente: ante «busca una noticia del NYT y dime si su titular es clickbait» llama a get_nyt_news, toma el titular del resultado y se lo pasa a los detectores.
El hallazgo relevante es otro: transcribe bien las cifras, fabrica lo que las rodea. Los valores se reproducen con exactitud (0.9375 → «93 %»; spans [0,4] y [29,32] correctos), pero alrededor aparecen invenciones: una escala «2/3 niveles» que no existe, explicaciones de qué significa cada cue que ninguna herramienta ha dado, o «una empresa llamada Researcher's Lab» cuando el texto decía «researchers at a small lab».
Como el prompt es configuración versionada (R13.5), comparar variantes es el experimento natural. Se probaron cuatro —cada una escrita contra los fallos observados en la anterior— más la ausencia de prompt como control (spikes/prompts/).
Lo que quedó establecido: un prompt elimina las fabricaciones obvias (sin él, siempre tablas, emojis y escalas inventadas, ~1000 caracteres por respuesta), y las reglas dirigidas funcionan — cada norma escrita contra un fallo concreto lo corrigió: la escala inventada, la traducción de las categorías, la fusión de posiciones, la auto-atribución («he detectado» → «el detector léxico señala»). La mejor salida obtenida es exacta punto por punto, con cada cue en su propia posición.
Lo que NO se puede afirmar: cuál prompt es mejor. La varianza entre ejecuciones domina — una variante fue la mejor en una tanda y peor que el control en la siguiente, con el mismo prompt y las mismas consultas. Con dos consultas por variante y sin repeticiones, no hay ranking defendible; harían falta ~5 repeticiones por par (prompt, consulta). Los hallazgos cualitativos sí son fiables, porque son errores verificables contra la salida de las herramientas.
Modo de fallo nuevo: en dos ejecuciones el modelo invocó las tres herramientas correctamente y no generó texto final (respuesta vacía). Ambas fueron la consulta encadenada más larga. Ningún prompt lo previene.
Ningún prompt alcanza fidelidad total, y el mejor sólo desplaza el error hacia formas más sutiles: de inventar una escala (obvio, el usuario sospecha) a fundir dos posiciones en un rango (discreto, suena preciso y es falso). Un error sutil es más peligroso que uno llamativo.
Esto convierte las tarjetas renderizadas desde el JSON de la herramienta de buena práctica en necesidad demostrada, por tres vías independientes: el modelo inventa contexto alrededor de datos correctos; a veces no responde —y la tarjeta sigue mostrando el análisis aunque falle la narración—; y un detector automático de alucinaciones siempre va por detrás (el escrito aquí buscaba escalas «/3» y «/5» y no vio un «2/1» posterior, y no puede detectar un dato correcto mal atribuido, porque el número sí está en la salida). R13.3 y R13.4 quedan demostrados, no supuestos.
De aquí sale también un requisito nuevo, R6.13: si la narración llega vacía o ilegible, la interfaz debe mostrar igualmente los resultados estructurados —con un aviso discreto de que no hubo resumen— y no condicionar la visualización del análisis a que esa narración exista. No es una precaución hipotética: el análisis se había completado con éxito y sólo faltaba la prosa; mostrar «la respuesta del asistente» habría dejado una pantalla en blanco y tirado un resultado válido.
Cierre: se adopta 04-preciso como prompt de partida —por el razonamiento de sus reglas y su mejor salida, no como «ganador medido»—, y el bucle de tool_calling_fase3.py queda como esqueleto del agente real: acepta el prompt de sistema como parámetro, mantiene el historial, ejecuta herramientas y corta a las seis vueltas.
Cierre del tercer bloque de H1 («diseño de los endpoints REST»). Se fija el contrato antes de escribir la app porque en el prototipo lo consumen tres sitios distintos —el formulario de análisis, las tarjetas embebidas en el chat y el historial—: se diseña una vez y sirve para los tres.
Tres principios lo gobiernan (backend/api/schemas.py):
- Envoltorio uniforme por señal. Las señales son una lista de objetos con la misma forma, no un objeto con un campo por señal. El frontend itera y pinta tarjetas sin conocerlas de antemano: añadir una sexta señal no obliga a tocar Angular. Es el mismo desacople que el catálogo (R5.8).
- El estado va por señal, no global. Un único campo
statuscubre dos situaciones que, desde el punto de vista de la respuesta, son la misma —esa señal no tiene resultado, las demás sí—: faltan datos de entrada (no_aplicable, la incoherencia necesita el cuerpo) o la ejecución falló (error; ~1 de cada 5 llamadas a HF da timeout, medido en la Épica 4)./analyzeno devuelve error global mientras alguna señal funcione: perder tres análisis correctos porque el cuarto falló sería el mismo error que evita R6.13. - Veredicto por dimensiones, no por mayoría. Cada señal se etiqueta como forma (sensacionalismo en la redacción), engaño (el titular promete lo que el cuerpo no cumple) o tono. La dimensión se lee de
MODEL_CARDS(R3.9), no se cablea en el orquestador.
Por qué la mayoría no vale. Promediar señales que miden cosas distintas produce un veredicto falso. El caso decisivo es un titular sobrio cuyo cuerpo no corresponde:
| Señal | Veredicto |
|---|---|
| Zero-shot | no es clickbait |
| Léxico | no es clickbait |
| Lineal | no es clickbait |
| Incoherencia | sí es clickbait |
Tres a uno, y la correcta es la cuarta: por mayoría saldría «factual». La jerarquía es explícita —el engaño pesa más que la forma— y las discrepancias dentro de una dimensión se declaran (null → ambiguo) en lugar de resolverse por votación.
El tono se muestra pero no vota. Una narrativa marcadamente positiva o negativa aleja de la objetividad, pero eso no es hacer clickbait, y cuánto pesa es juicio de quien lee. No necesita ningún caso especial en el código: la señal devuelve is_clickbait: null y el mismo filtro que ignora las señales caídas la ignora a ella.
La orquestación (backend/api/analyze.py) lanza las señales con asyncio.gather(..., return_exceptions=True), que en vez de propagar la primera excepción la devuelve dentro de la lista de resultados. Cada excepción se traduce a una señal en estado error y la respuesta sigue siendo un 200 con lo que sí se pudo calcular. Las señales se declaran en una tabla (nombre de tool + cómo ejecutarla + cómo leer su veredicto) para que el bucle tenga una sola forma: añadir una señal es añadir una fila y su ficha.
Un desajuste que destapó el diseño. La ficha del zero-shot tenía "signal": "detect_clickbait (zero-shot)" — un campo pensado para leer, usado como clave de búsqueda. En cuanto /analyze busca la dimensión por ese valor, cualquier mejora de la etiqueta rompe la búsqueda en silencio: no lanza excepción, simplemente no encuentra la ficha. Renombrado al nombre exacto de la tool (la anotación «zero-shot» ya estaba en name y task, no se pierde nada) y añadido test_model_cards_signals_match_registered_tools, que comprueba que los cinco signal resuelven contra tools realmente registradas. El renombrado arregla hoy; el test arregla las próximas veces.
Dos correcciones de paso:
- Validación:
headline=" "pasabamin_length=1—un espacio mide un carácter— y llegaba hasta las señales, que fallaban una a una: la respuesta era un 200 consin_datosen vez del 422 que corresponde. Ahora recorta antes de medir. - CI: el filtro
branchesde GitHub Actions es por rama destino. Con el flujodev→mainadoptado en la Épica 5, las PR de feature apuntan adevy no disparaban los tests; el CI no saltaba hasta promocionardev→main, cuando ya es tarde. Añadidodevapull_requestypush.
Límites de lo entregado. No hay app FastAPI todavía, así que analyze() no es alcanzable por HTTP y no existe prueba extremo a extremo: los 29 tests de la orquestación usan dobles, y el camino nunca se ha ejecutado contra HuggingFace ni contra el modelo de embeddings. Quedan también sin diseñar los contratos de /tools, /history y /chat. La app y su router son H2 (issue #86).
Al escribir la app REST había que decidir la topología, y la respuesta obvia —replicar la arquitectura del entorno profesional del autor: un contenedor por MCP especialista, orquestados desde un punto central— resultó apoyarse en una premisa que aquí no se cumple.
En esa arquitectura los especialistas (Azure, AKS, Rundeck) ya existen y son de otros: la federación resuelve un problema de propiedad del código y de ritmos de despliegue distintos. Aquí no hay ningún MCP de The Guardian que ensamblar — se escribe en este repo, contra su API REST, y comparte BaseAPI, ToolResult, observability y settings con las demás. Partirlas obligaría a duplicar esa base o a publicar un paquete compartido.
Tres niveles que se confunden con facilidad, y cuya confusión es justo lo que produjo la redacción original de R1.7:
| Nivel | Qué es | Ejemplo | Cuántos hay |
|---|---|---|---|
| API externa | servicio de un tercero, ajeno a MCP | api.nytimes.com |
varios |
| MCP_Tool | función propia que la envuelve | get_nyt_news |
11 |
| MCP_Server | proceso que expone tools por el protocolo | tfg-mcp-server |
uno |
NYT y Guardian no son servidores: son hojas dentro del único servidor.
Evaluación contra los criterios que importaban (desacoplamiento, memoria, latencia y el eje de explicabilidad):
- Memoria: ya estaba resuelta sin contenedores.
incoherence.pyimportasentence_transformersdentro de_get_model, ylocal.pyimportatransformersdentro de_get_pipeline. Torch no entra en memoria si nadie usa esas señales. Separarnlppor RAM sería pagar dos veces por lo que ya da la carga perezosa; el argumento que sobrevive es el tamaño de imagen, que es asunto de H4. - Latencia: medida, no supuesta. Un spike levantó el servidor real con
streamable-httpy lo consumió como cliente MCP: handshake 0,212 s,list_tools(11 tools) 0,036 s,call_tool0,033 s y 0,006 s la segunda en la misma sesión. Barato — pero un/analyzedistribuido sumaría el handshake y cuatro saltos a los 0,4 s que hoy cuesta importando el núcleo. El spike confirmó además algo que hasta entonces era razonamiento:call_tooldevuelveTextContentcuyo.textes un JSON string, así que pasar/analyzepor MCP significadict → json.dumps → TextContent → json.loads → dict. - Explicabilidad: el criterio decisivo. La transparencia de sistema —qué herramienta se invocó, con qué fuentes— vive hoy en una traza única (
log_tool_invocation). Repartirla entre contenedores la fragmenta sin aportar nada al eje del trabajo.
Decisión: tres contenedores — servidor MCP, API REST y web. R1.6 se cumple (el servidor puede exponerse por HTTP y desplegarse aparte), R7 también, y /analyze conserva su latencia importando el núcleo. La separación de nlp se reconsiderará en H4 si el tamaño de imagen o el arranque en frío duelen de verdad, con la medición delante; decidirlo ahora sería pagar un coste sin saber si hace falta.
Cambios en los requisitos (docs/requisitos.md):
- R1.8 acotado. Su redacción («si un MCP_Server declarado no responde») y la intención del autor («si una API declarada no está, el sistema sigue funcionando») parecían contradecirse. No lo hacían: hablan de listas distintas. R1.8 se refiere a la lista de servidores MCP —nivel 3, sin sentido hasta que exista el cliente— y queda anotado como tal.
- R2.8 nuevo. La intención del autor se convierte en criterio propio donde le corresponde: degradación ante APIs externas caídas. Ya está satisfecho desde la Épica 1 —
_aggregate_statusdevuelvedegradedcuando alguna sonda falla—, pero no estaba escrito. - R1.9 nuevo. El requisito de extensibilidad que sí sirve a este proyecto: añadir una fuente o una señal no debe obligar a tocar las existentes ni la interfaz. Está alineado con la tesis —añadir una señal es añadir una perspectiva contrastable— y medio construido ya: el envoltorio uniforme de
schemas.pyhace que una sexta señal no obligue a tocar Angular,MODEL_CARDSdeclara su naturaleza yregister()la enchufa. Queda un fleco:main.pytodavía lista losregister()a mano.
R1.7 se mantiene sin cambios. No porque haga falta hoy, sino porque el coste de dejar la puerta abierta es nulo: cuando llegue el cliente MCP en /tools, su configuración será una lista de endpoints en vez de una URL —cinco líneas— y el requisito seguirá siendo satisfacible el día que se quiera enchufar un servidor MCP ajeno, sin haber construido hoy una federación que no resuelve ningún problema real.
Decidida la topología, conviene fijar en qué salto aplica cada mecanismo, porque es fuente habitual de confusión:
Navegador ──(1)──→ nginx ──(2)──→ FastAPI ──(3)──→ servidor MCP
──(4)──→ APIs externas (NYT, HuggingFace…)
| Salto | ¿CORS? | Autenticación |
|---|---|---|
| (1) navegador → nginx | el único donde existe | ninguna |
| (2) nginx → FastAPI | no | ninguna, red interna |
| (3) FastAPI → servidor MCP | no | bearer, si algún día se expone fuera |
| (4) FastAPI → APIs externas | no | API keys (ya implementadas) |
CORS es un concepto exclusivamente de navegador: nace de la política del mismo origen, que solo aplican los navegadores. Los saltos 2-4 son servidor a servidor, así que HF_TOKEN o NYT_API_KEY viajan sin que CORS pinte nada.
Topología del frontend: nginx como proxy inverso. Sirve el build de Angular en / y reenvía /api/* a uvicorn por la red interna. Para el navegador todo es el mismo origen: desaparecen CORS y el preflight —que hoy convierte cada POST /analyze en dos viajes—, pero se mantienen dos contenedores con trabajos separados. En desarrollo el equivalente es el proxy.conf.json de Angular, de modo que desarrollo y producción se comporten igual; ese desajuste es el fallo clásico de servir el front en un origen distinto.
Se descartó que FastAPI sirviera los estáticos: no es su trabajo, acopla el despliegue del frontend al de la API, y el catch-all que exige el enrutado de cliente de Angular puede tragarse /docs y /openapi.json si se registra en mal orden.
CORSMiddleware se mantiene igualmente, aunque la topología lo vuelva inerte en producción: R4.7 lo exige, cuesta seis líneas y es la salida si en algún momento se desarrolla sin proxy.
(Nota sobre mcp-proxy: en el entorno profesional del autor los MCP de terceros solo hablan stdio, y se exponen por HTTP envolviéndolos con mcp-proxy tras un nginx con bearer. Aquí no hace falta —el SDK de Python habla streamable-http de forma nativa, medido arriba— pero sería la herramienta correcta el día que se enchufe un servidor MCP ajeno que solo soporte stdio.)
Con el contrato y la orquestación ya cerrados en H1, esta parte es delgada a propósito: backend/api/app.py monta la aplicación, expone POST /analyze y GET /health, y delega. Se arranca aparte del servidor MCP:
uvicorn backend.api.app:app --reloadcheck_health() sale de register() en core/health.py. Antes vivía dentro de la tool MCP; ahora es una función de módulo que consumen las dos fachadas, porque dos sondeos independientes acabarían respondiendo cosas distintas sobre el mismo sistema.
configure_logging() va en el lifespan, no a nivel de módulo. structlog.configure() muta estado global del proceso, y a nivel de módulo eso ocurriría al importar — que en pytest es durante la colección, antes de que exista ninguna fixture. El repo ya tiene una fixture autouse (_reset_structlog) puesta tras sufrir justo ese problema, pero no protegería de un efecto en el import. El lifespan solo corre cuando la app sirve de verdad: TestClient(app) no lo dispara, with TestClient(app) sí.
Lo que aporta esta parte no es el código, es la primera ejecución real. Hasta aquí los 29 tests de la orquestación usaban dobles: el camino nunca se había recorrido contra HuggingFace ni contra el modelo de embeddings.
| Escenario | Latencia |
|---|---|
| En caliente | 0,4–0,7 s (pico observado de 3,8 s) |
| Arranque de proceso en frío | ~20 s |
| Primerísima ejecución | ~60 s (descarga de all-MiniLM-L6-v2) |
Los 20 s no son una limitación, son una consecuencia: IncoherenceDetector carga el modelo de forma perezosa, en la primera petición, en vez de al arrancar. Precalentarlo en el lifespan los trasladaría del primer usuario a uvicorn — decisión de H4, con el healthcheck del contenedor delante, porque un arranque de 20 s cambia el start_period.
Y el sistema discrimina:
| Titular (con cuerpo coherente) | Veredicto | Señales |
|---|---|---|
| Federal Reserve Holds Interest Rates Steady in March Meeting | factual |
las cuatro de acuerdo; lineal p=0.163, similitud 0.579 |
| 17 Things Nobody Tells You About Moving Abroad | ambiguo |
zero-shot lo lee como factual news; léxico y lineal lo marcan (p=1.000) |
El segundo caso merece atención: es el escenario de discrepancia que se había trazado sobre el papel al diseñar el contrato, apareciendo por sí solo en la primera prueba real. Un listicle dispara las pistas de superficie y la lectura semántica no lo acompaña. El sistema lo declara ambiguo en vez de resolverlo por mayoría — que es exactamente lo que el principio 3 pretendía.
Límites. Sigue sin haber /tools, /history ni /chat. Los 12 tests nuevos cubren la capa HTTP (validación, delegación, CORS, OpenAPI) y no repiten la orquestación, que ya cubre test_analyze.py. Y analyze.py mantiene un efecto de importación conocido —_api = get_nlp_backend() a nivel de módulo— que congela el backend NLP al importar, igual que hace tool.py.
El catálogo necesita saber, de cada herramienta, qué tipo de trabajo hace y de dónde viene. El objeto Tool del protocolo MCP trae nombre, descripción y esquema, pero ninguna de esas dos cosas. MCP sí permite adjuntar un meta libre por herramienta, y se comprobó que viaja intacto hasta el cliente, así que la información se declara en el origen en vez de en un mapa cableado en la API — que obligaría a editarla cada vez que se añade una fuente, justo lo que R1.9 prohíbe.
Los dos ejes se tratan distinto a propósito. La categoría es un juicio —qué tipo de trabajo hace— y no se puede derivar de dónde vive el fichero: describe_models está en nlp/ pero es una utilidad, no una señal. Así que se declara, y declararla obliga a pensarla al añadir la siguiente. La integración es un hecho de ubicación, se deriva del módulo, y por eso no puede mentir: declararla a mano permitiría que el paquete dijera una cosa y el meta otra, en silencio — el mismo fallo que costó el renombrado del campo signal en H1.
Las categorías de R5.3 se renombraron. Los ejemplos originales eran «Integración de API» y «Análisis de NLP», y ambos nombraban mal lo que separan:
| Original | Problema | Ahora |
|---|---|---|
| Integración de API | Describe la implementación, y es falso como distinción: detect_clickbait es una llamada a la API de HuggingFace tanto como get_nyt_news lo es a la de NYT |
Fuentes de contenido |
| Análisis de NLP | Nombra una tecnología, no un propósito — y el proyecto ya tiene su palabra, «señal», usada en SignalResult, en la orquestación y en las fichas |
Señales de análisis |
Lo que de verdad separa a los cuatro primeros del resto no es que llamen a una API: es que traen contenido en vez de analizarlo.
El renombrado tiene además una propiedad que lo confirma: «Señales de análisis» son exactamente las cinco que llevan ficha de modelo. Con los nombres anteriores esa correspondencia parecía casualidad; ahora la categoría predice si model_card viene o no, y R5.9 deja de ser un añadido suelto para encajar con R5.3.
Y el índice de fichas se centraliza. cards_by_signal() vive junto a MODEL_CARDS porque lo necesitan dos consumidores —la orquestación de /analyze, para leer la dimensión de cada señal, y el catálogo, para adjuntar la ficha—. Dos copias del mismo índice acabarían divergiendo.
(Nota para la memoria: get_alerts y get_forecast son andamiaje del MVP y no pertenecen al dominio del clickbait. Se conservan porque son herramientas reales del sistema y ocultarlas sería deshonesto, pero su función es demostrar el mecanismo MCP con una API pública sin clave.)
Es el primer sitio donde la API habla MCP de verdad. /analyze importa el núcleo directamente —dos fachadas sobre el mismo código— y para él es correcto; aquí no vale, y R5.8 lo dice explícitamente: el catálogo debe construirse por descubrimiento. La razón no es purismo, es que importar módulos daría siempre la misma respuesta aunque el servidor estuviera caído, que es justo lo contrario de lo que un catálogo debe mostrar.
Sesión por petición, no persistente. Al planificar el issue se había propuesto mantener la sesión viva en el lifespan para ahorrar los 0,212 s del handshake. Al implementarlo no se sostiene: /tools se consulta cuando alguien abre la pantalla de Sistema, no en bucle, así que esa latencia es imperceptible. A cambio, la sesión persistente obliga a gestionar reconexión, guardar estado mutable compartido y responder si ClientSession aguanta uso concurrente. Se pagará esa complejidad cuando haya un consumidor caliente que la justifique —el agente, con muchas invocaciones por turno— y con una medición delante.
La decisión tiene un efecto que la confirma: hace desaparecer otra pregunta abierta. «¿Arranca la API si no hay servidor MCP?» sólo existía porque el catálogo se construía al arrancar. Sin sesión persistente no hay nada que conectar en el arranque: la API arranca siempre, y /tools informa del estado real en el momento de la llamada.
Un servidor caído no rompe la respuesta, igual que una señal caída no rompe /analyze: sale en servers con estado unreachable y su motivo, degraded queda a true y las herramientas de los demás se sirven igual. Con la configuración como lista, R1.8 deja de ser un requisito vacío aunque la lista tenga un solo elemento.
Y sin servidores configurados, el catálogo sale vacío pero NO degradado. Parece un descuido y es deliberado: degraded significa «algo declarado no responde», y sin nada declarado no ha fallado nada — eso es una mala configuración, no una degradación. La distinción se conserva porque el contrato permite separarlas: servers vacío significa que no hay nada configurado; servers con entradas unreachable significa que está declarado y no contesta. Si la lista vacía marcara degraded, ambas situaciones colapsarían en una y la interfaz no podría decir cuál está ocurriendo.
El resultado, con el servidor real:
servidores: [('tfg-mcp-server', 'ok', 11)] degradado: False
detect_clickbait_lexical Señales de análisis nlp interpretable/forma
detect_clickbait_incoherence Señales de análisis nlp híbrido/engano
analyze_sentiment Señales de análisis nlp opaco/tono
get_nyt_news Fuentes de contenido nyt -
health_check Utilidades None -
El nombre del servidor no sale de la configuración: lo declara él mismo en el handshake (serverInfo.name), lo que demuestra que hubo conversación real y no una lista leída de un fichero.
Los tests hablan el protocolo completo contra la app en el mismo proceso, con un httpx.AsyncClient montado sobre ASGITransport. Aquí no es una optimización sino una necesidad: el servidor arranca por defecto en stdio, así que durante los tests no hay nada escuchando en ningún puerto.
El lifespan no admite una fixture asíncrona. El primer intento falló con «attempted to exit cancel scope in a different task»: un cancel scope de anyio —la región cancelable que abre el lifespan— exige abrirse y cerrarse en la misma tarea, y pytest-asyncio puede ejecutar la fixture y el cuerpo del test en tareas distintas. Se resuelve con un @asynccontextmanager abierto dentro del propio test.
El gestor de sesiones es de un solo uso, y eso rompió un test que ya funcionaba. StreamableHTTPSessionManager.run() sólo puede llamarse una vez por instancia, y backend.main.mcp es un singleton de módulo: el primer test que levantara su app HTTP dejaba el gestor gastado para los demás. Lo grave no es el fallo sino su forma — dependía del orden de ejecución, así que test_main.py pasaba aislado y fallaba en conjunto. Es el patrón que se acaba etiquetando de flaky sin llegar a entenderlo. La solución es una fixture que construye un servidor nuevo por test, montado igual que main.py; que eso sean tres líneas es rédito directo del descubrimiento automático de #91.
Y un tercero que hizo lo que debía: el test que fija las rutas de OpenAPI falló al añadir /tools, porque afirmaba que sólo había dos. Un contrato que avisa cuando cambia.
Límites. El filtro por categoría (R5.4) y la búsqueda (R5.6) quedan fuera: bajaron a PODRÁ al revisar los requisitos, y once herramientas caben en una pantalla sin desplazarse. Sigue sin existir /tools/{name}/execute (R4.3) ni /history (R4.4).
R1.9 —escrito al ordenar la extensibilidad en #86— dice que añadir una fuente de datos o una señal de análisis no debe obligar a modificar las herramientas existentes. La mitad de interfaz ya estaba cumplida por el envoltorio uniforme de señales; la de servidor no: main.py listaba los register() a mano, así que añadir una integración obligaba a editarlo.
Ahora backend/integrations/discovery.py recorre el paquete, importa el tool de cada uno y llama a su register(mcp). Añadir una integración pasa a ser crear su paquete.
El chequeo de salud queda fuera, y no como excepción. Al plantearlo apareció la pregunta de si health debía moverse a integrations/ para que el descubrimiento lo encontrara. La respuesta es que no: no envuelve ninguna API externa, es infraestructura básica —del mismo tipo que el healthcheck de un contenedor—. Y eso lo deja fuera del alcance de R1.9 por definición, porque el requisito habla de «una fuente de datos o una señal de análisis». Que main.py lo registre explícitamente no es un caso especial que disculpar: es la separación correcta, y así queda escrita en el módulo.
El fichero pasa de cinco líneas de registro a dos, y esas dos significan algo:
discover_integrations(mcp) # todo lo que haya en integrations/
health.register(mcp) # núcleo, no integraciónUn paquete roto no tumba el servidor. Si una integración falla al importarse o su register lanza, se anota y se sigue con las demás — misma postura que con las señales en /analyze, y lo que piden R1.8 y R2.8. El arranque registra qué se descubrió y qué falló, porque una integración caída deja al sistema con menos herramientas en silencio: sin ese log, la única pista sería una tool que ya no aparece.
El test que importa es el que demuestra el requisito: crea una integración de mentira en un directorio temporal y comprueba que aparece sola. El truco para no ensuciar el repositorio es extender el __path__ del paquete backend.integrations —la lista donde Python busca submódulos—, de modo que el import funcione de verdad sin copiar ficheros dentro del proyecto. Sin ese test, R1.9 sería una afirmación; con él, es comprobable.
Al preparar /tools se leyó R5 entero por primera vez desde que se escribió, y aparecieron tres problemas —dos de redacción y uno de concepto.
Una contradicción interna. R5.1 decía «mantener un registro» y R5.2 «CUANDO se registre una nueva herramienta… almacenar». Eso describe un catálogo con estado: una tabla que se rellena en un evento de alta. Pero R5.8 exige construirlo dinámicamente por handshake MCP, que es una vista calculada en cada consulta. No pueden ser las dos cosas — y en el modelo dinámico el evento «se registra una tool» nunca ocurre: las herramientas simplemente aparecen o dejan de aparecer en list_tools. Corregido a exponer. La entrada del glosario arrastraba el mismo error («sistema de registro») y se reescribió igual.
Un requisito desproporcionado. R5.6 exigía búsqueda por nombre o palabras clave sobre un catálogo de 11 herramientas, que caben en una pantalla sin desplazarse. Es un criterio pensado para catálogos de cientos de entradas. Baja a PODRÁ, junto con el filtro por categoría (R5.4), por el mismo motivo. El autor añade una razón de uso: invocar una herramienta concreta en vez de dejar que el sistema elija es una operación avanzada, no el camino del usuario medio.
Y el problema de fondo: el catálogo no es un lanzador. La historia de usuario original —«descubrir qué herramientas hay y cómo usarlas»— venía de concebirlo como un menú desde el que invocar herramientas sueltas. Pero el usuario medio no entra por ahí: entra por Analizar o por el chat. Lo que sí necesita es saber qué compone este sistema y con qué límites, que es exactamente lo que hace la pantalla Sistema del prototipo y lo que piden R3.8 y R3.9.
De ahí sale R5.9, nuevo: donde una herramienta sea una señal de análisis, el catálogo debe exponer su ficha de modelo. Sin él, el catálogo mostraría
detect_clickbait_linear → «Análisis de NLP»
y escondería lo que ya está escrito en model_cards.py: que es interpretable (no una caja negra), que mide forma (no engaño), y que su F1 cae de 0.865 en dominio a 0.476 fuera. Un catálogo que tira esa metadata desperdicia justamente el eje del trabajo.
R5.7 reinterpretado. Decía «agregar las herramientas de todos los MCP_Server conectados». Con un solo servidor eso se cumple trivialmente y no demuestra nada — el mismo problema que R1.7. Pero tiene una lectura que sí aporta: de qué integración procede cada herramienta (NYT, Guardian, meteorología, NLP). Esa es información real y útil hoy; la agregación multi-servidor se mantiene como capacidad para cuando haya varios.
R4.3 se queda, con sus consumidores anotados. Ese endpoint —ejecutar una tool concreta— existía para que el catálogo lanzara herramientas, así que al dejar de ser lanzador parecía quedarse sin uso. No es el caso: le quedan dos reales, ejecutar una señal suelta (sólo el sentimiento, sin lanzar las cuatro) y traer una noticia desde la pantalla de análisis. Lo que estaba mal era su justificación, no su forma.
El nombre se mantiene. Se valoró renombrar Tool_Catalog, ya que no aparece en el código y el cambio saldría barato. Se descarta: un catálogo es descriptivo por naturaleza —el de un museo describe obras que no te llevas— y lo que empujaba hacia el lanzador era la historia de usuario, ya corregida. Además la historia de R13 depende del término: «que el sistema decida por mí qué herramientas usar sin necesidad de conocer el catálogo» sólo tiene sentido si existe un catálogo que uno podría conocer.
R1.6 llevaba escrito desde la ampliación de requisitos de Fase B y era un DEBERÁ sin cumplir: main.py cableaba mcp.run(transport="stdio"). El problema no es de forma — stdio exige que el cliente arranque el servidor como subproceso y hable con él por tuberías, cosa que no cruza contenedores. Sin transporte HTTP, H4 no puede separar el servidor MCP de la API.
Ahora sale de configuración (mcp_transport, mcp_host, mcp_port):
MCP_TRANSPORT=streamable-http MCP_PORT=8765 python -m backend.mainEl valor por defecto sigue siendo stdio, y eso es deliberado. Es lo que espera un cliente que lanza el servidor como subproceso —así está conectado el entorno de desarrollo del autor— y cambiar el defecto habría roto esa conexión sin que ningún test fallara. Hay un test que fija ese defecto precisamente para que nadie lo cambie por descuido.
Verificado por los dos caminos, arrancando el entry point real y conectando un cliente MCP de verdad: por HTTP expone las 11 tools y responde a call_tool; por stdio sigue haciendo exactamente lo mismo.
Y la verificación de HTTP quedó automatizada, que era el hueco evidente: los tests que espían mcp.run comprueban el cableado pero no que el servidor sirva, porque run() bloquea el proceso. La salida no es levantar un servidor en un puerto —lento y frágil en CI— sino pasarle al cliente MCP un httpx.AsyncClient montado sobre ASGITransport: el protocolo completo corre contra la app en el mismo proceso, sin red. Cuesta 0,03 s, así que va en cada CI en vez de quedarse como comprobación manual.
Dos obstáculos que costaron encontrar y conviene dejar escritos. El primero, que el gestor de sesiones de FastMCP arranca en el lifespan de la app y ASGITransport no lo ejecuta, así que hay que entrarlo a mano o toda petición falla. El segundo, un 421 Misdirected Request que resultó ser la protección anti DNS rebinding del propio servidor: acepta 127.0.0.1:* y ASGITransport enviaba Host: 127.0.0.1 sin puerto. Se resuelve poniendo puerto en la URL base — dejando la protección activa, que era la tentación fácil de desactivar.
Un detalle de diseño: host y port se asignan siempre, aunque stdio los ignore. Meter un if para no tocar dos campos inertes añade una rama que hay que leer y mantener a cambio de nada.
El proyecto no había tenido nunca linter. Se adopta ruff —linter y formateador en un binario, sustituto de la pila flake8 + isort + pyupgrade + black— y el CI lo comprueba en cada PR.
El conjunto de reglas se declara explícitamente en ruff.toml en vez de heredar el de por defecto. La razón no es purismo: ruff amplía sus defaults entre versiones, y confiando en ellos una actualización de la herramienta rompería el CI sin que cambiara una línea de código. Por lo mismo, la versión va pineada.
Tres reglas se desactivan a conciencia:
BLE001(capturarException) — chocaría con la arquitectura, no con un descuido. Capturar excepciones amplias en las fronteras de integración es lo que sostiene el aislamiento de fallos de todo el sistema:ToolResult.fail,gather(return_exceptions=True), R6.13. Una señal caída no puede tumbar a las demás, y para eso hay que capturar lo que sea que lance el proveedor.E501(línea larga) — el formateador ya mantiene el código dentro del ancho; lo que no puede partir son literales y prosa. Sus 62 avisos caían casi todos en payloads simulados de los tests (hasta 196 caracteres).RUF001-003(caracteres Unicode ambiguos) — existen para detectar homoglifos (cirílico disfrazado de latino). Aquí sólo saltaban por comillas tipográficas en texto español legítimo.
Dos hallazgos reales, que es lo que justifica el ejercicio:
DTZ011— zona horaria implícita. Los clientes de Guardian y NYT calculaban la ventana de «noticias de los últimos N días» condate.today(), que usa la zona de la máquina. En Docker el contenedor va en UTC y el equipo de desarrollo no, así que la misma consulta habría devuelto rangos distintos según dónde se ejecutara, con un día de desfase cerca de medianoche. Corregido adatetime.now(timezone.utc).date(). Es exactamente el tipo de fallo que H4 habría destapado en el peor momento.B905—zip()sinstrict. Ocho sitios.ziptrunca en silencio al iterable más corto, y el más delicado eslinear.py, que empareja pesos, nombres de features y vector de entrada: si esos tres dejaran de cuadrar, cada peso se atribuiría al cue equivocado y la explicación sería falsa sin que nada avisara. En un trabajo cuyo eje es la explicabilidad, eso es el peor fallo posible. Los ocho pasan astrict=True—ruff sólo proponestrict=False, que hace explícito el truncado pero no lo arregla—, convirtiendo un resultado silenciosamente incorrecto en un error ruidoso. Verificado sobre datos reales: las invariantes se cumplían, ahora quedan vigiladas.
Balance: 43 avisos iniciales, 26 corregidos automáticamente, 12 con criterio y 5 desactivados por regla. 26 ficheros tocados, 98 tests en verde.
"Aplico Rudin donde puedo —incoherencia(A MEDIAS, YA QUE EL MODELO NO) y léxico son intrínsecamente interpretables— y reservo lo post-hoc (LIME/SHAP), con sus límites de fidelidad, solo para la parte que depende de un transformer preentrenado que no puedo abrir de otro modo." !!!IMPORTANTE (NO MODIFICAR, RECORDAR POSTURA DEFINIDA)
Omitir contenido decisivo = curiosity / information gap (Loewenstein) — el clásico teórico del clickbait. Catáfora / forward-reference ("this", "these", "here's why") — Blom & Hansen (2015), marcador lingüístico de clickbait. Léxico afectivo vs neutral = sensacionalismo. Activa/pasiva según el foco ("Police shoot man" vs "Man dies after police encounter") = framing de agencia (quién es agente/responsable). Tu intuición de la voz es teoría del framing pura. Perspectiva (dos personas: de quién es el punto de vista).
(Nuevos conceptos para E4-03): Harness de evaluación: Compara resultados de mi código con el del dataset Precisión: Mide falsas alarmas (TP/(TP+FP)) Recall: Mide lo que "escapa" (TP/(TP+FN))
F1: Media de las anteriores
Sweep: Barrido, probar rango de valores para ver que Threshold consigue mejor F1.
Baseline: Resultado a mejorar (+50% para batir azar)