Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ __pycache__/
*.pyc
.env
.claude
.vscode/settings.json
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -511,4 +511,23 @@ Tests (`respx`): NYT deriva `DAILY_LIMIT − call_count`; Guardian lee la cuota

> **Aislamiento de tests:** emitir el log `api.call` destapó un bug latente — `test_logging.py` configuraba structlog **global** apuntando al `stderr` temporal de `capsys`; al cerrarse ese buffer, cualquier test posterior que logueara petaba con `ValueError: I/O operation on closed file`. Se añadió `tests/conftest.py` con un fixture `autouse` que **resetea structlog tras cada test** (un fallo de logging quedaba además enmascarado por el `except Exception` de `make_request` como "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.)*
**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.)*

## Épica 5 — Núcleo NLP: backend local, incoherencia y explicabilidad

> 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.

### E5-01 · Backend NLP seleccionable (remoto / local)

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 con `classify` y `zero_shot`, ambos devolviendo `ToolResult.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; `NLPBackend` actúa de interfaz (sin lógica), `BaseAPI` aporta el transporte.
- `LocalNLPClient(NLPBackend)` — backend **local** con `transformers.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`): elige `remote`/`local` según el setting `nlp_backend` (`Literal`, default `"remote"`). Las tools llaman a la factoría, no a una clase concreta.
- **Las tools no cambian:** `detect_clickbait` / `analyze_sentiment` siguen llamando `api.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.
3 changes: 3 additions & 0 deletions backend/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ class Settings(BaseSettings):
guardian_api_key: str # PS mapea automáticamente
nyt_api_key: str
hf_token: str
nlp_backend: Literal["remote", "local"] = (
"remote" # Añadimos dos opciones de backend NLP, así mantenemos remoto sin cambiar mucho.
)


settings = Settings() # type: ignore #Activa la validación al importar
21 changes: 21 additions & 0 deletions backend/integrations/nlp/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Esta clase abstracta sirve como base para las distintas manera de implementar el NLP (En otro caso, BaseAPI si llama a sus propios métodos para hacer peticiones)


from abc import ABC, abstractmethod

from backend.core.models import ToolResult


class NLPBackend(ABC):
"""Contrato común de los backends NLP (remoto HF / local transformers).

Ambos métodos DEBEN devolver ToolResult.ok({"label": ..., "score": ...}).
"""

@abstractmethod
async def classify(self, text: str, model: str) -> ToolResult:
"""Clasificación de texto (p.ej. sentiment)."""

@abstractmethod
async def zero_shot(self, text: str, model: str, labels: list[str]) -> ToolResult:
"""Clasificación zero-shot (p.ej. clickbait con bart-mnli)."""
4 changes: 3 additions & 1 deletion backend/integrations/nlp/client.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from backend.config.settings import settings
from backend.core.base_api import BaseAPI
from backend.core.models import ToolResult
from backend.integrations.nlp.base import NLPBackend


class HFClient(BaseAPI):
class HFClient(BaseAPI, NLPBackend):
# Importante
BASE_URL = "https://router.huggingface.co/hf-inference/models/"
API_KEY = settings.hf_token
MAX_RETRIES = 3
Expand Down
16 changes: 16 additions & 0 deletions backend/integrations/nlp/factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# factory.py

from backend.config.settings import settings
from backend.integrations.nlp.base import NLPBackend
from backend.integrations.nlp.client import HFClient # client.py
from backend.integrations.nlp.local import LocalNLPClient # local.py


def get_nlp_backend() -> NLPBackend:
match settings.nlp_backend:
case "local":
return LocalNLPClient()
case "remote":
return HFClient()
case _:
return LocalNLPClient() # Default
53 changes: 53 additions & 0 deletions backend/integrations/nlp/local.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from backend.core.models import ToolResult
from backend.integrations.nlp.base import NLPBackend

import asyncio


class LocalNLPClient(NLPBackend):

# Evitamos cargar en cada llamada añadiendo permanencia
def __init__(self) -> None:

self._pipelines: dict[tuple[str, str], object] = {}

# Para devolver

def _get_pipeline(self, task: str, model: str) -> object:
key = (task, model)
if key not in self._pipelines:
from transformers import pipeline

pipe = pipeline(task, model=model)
self._pipelines[key] = pipe

return self._pipelines[key]

# Usamos tupla con la clave (modelo, task), para evitar que se usen modelos para tasks no especificadas

# Tasks que nos interesan "text-classification" y "zero-shot-classification"

async def classify(self, text: str, model: str) -> ToolResult:
try:
pipe = self._get_pipeline("text-classification", model)
result = await asyncio.to_thread(
pipe, text
) # Usamos thread ya que es una accion bloqueante
# (func, *args)

# El modelo termina

return ToolResult.ok(result[0])
except Exception as e:
return ToolResult.fail(f"Error inesperado usando el modelo {model}: {e}")

async def zero_shot(self, text: str, model: str, labels: list[str]) -> ToolResult:
try:
pipe = self._get_pipeline("zero-shot-classification", model)
output = await asyncio.to_thread(
pipe, text, candidate_labels=labels
) # candidate_labels NO es posicional, tiene que declararse
result = {"label": output["labels"][0], "score": output["scores"][0]}
return ToolResult.ok(result) # Etiqueta, valor (ganadores)
except Exception as e:
return ToolResult.fail(f"Error inesperado usando el modelo {model}: {e}")
4 changes: 2 additions & 2 deletions backend/integrations/nlp/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@

from mcp.server.fastmcp import FastMCP
from backend.core.observability import log_tool_invocation
from backend.integrations.nlp.client import HFClient
from backend.integrations.nlp.factory import get_nlp_backend


def register(mcp: FastMCP):

api = HFClient()
api = get_nlp_backend()

@mcp.tool()
@log_tool_invocation
Expand Down
4 changes: 3 additions & 1 deletion requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ respx

structlog

aiolimiter
aiolimiter

transformers
39 changes: 39 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ aiofile==3.9.0
# via py-key-value-aio
aiolimiter==1.2.1
# via -r requirements.in
annotated-doc==0.0.4
# via typer
annotated-types==0.7.0
# via pydantic
anyio==4.12.1
Expand Down Expand Up @@ -60,20 +62,31 @@ exceptiongroup==1.3.1
# via fastmcp
fastmcp==3.1.0
# via -r requirements.in
filelock==3.29.4
# via huggingface-hub
fsspec==2026.4.0
# via huggingface-hub
h11==0.16.0
# via
# httpcore
# uvicorn
hf-xet==1.5.1
# via huggingface-hub
httpcore==1.0.9
# via httpx
httpx==0.28.1
# via
# -r requirements.in
# fastmcp
# huggingface-hub
# mcp
# respx
httpx-sse==0.4.3
# via mcp
huggingface-hub==1.16.1
# via
# tokenizers
# transformers
idna==3.11
# via
# anyio
Expand Down Expand Up @@ -115,14 +128,18 @@ more-itertools==10.8.0
# via
# jaraco-classes
# jaraco-functools
numpy==2.4.6
# via transformers
openapi-pydantic==0.5.1
# via fastmcp
opentelemetry-api==1.40.0
# via fastmcp
packaging==26.0
# via
# fastmcp
# huggingface-hub
# pytest
# transformers
pathable==0.5.0
# via jsonschema-path
platformdirs==4.9.4
Expand Down Expand Up @@ -173,27 +190,36 @@ python-multipart==0.0.22
pyyaml==6.0.3
# via
# fastmcp
# huggingface-hub
# jsonschema-path
# transformers
referencing==0.37.0
# via
# jsonschema
# jsonschema-path
# jsonschema-specifications
regex==2026.5.9
# via transformers
respx==0.23.1
# via -r requirements.in
rich==14.3.3
# via
# cyclopts
# fastmcp
# rich-rst
# typer
rich-rst==1.3.2
# via cyclopts
rpds-py==0.30.0
# via
# jsonschema
# referencing
safetensors==0.8.0
# via transformers
secretstorage==3.5.0
# via keyring
shellingham==1.5.4
# via typer
sse-starlette==3.3.2
# via mcp
starlette==0.52.1
Expand All @@ -202,10 +228,23 @@ starlette==0.52.1
# sse-starlette
structlog==25.5.0
# via -r requirements.in
tokenizers==0.22.2
# via transformers
tqdm==4.68.2
# via
# huggingface-hub
# transformers
transformers==5.12.0
# via -r requirements.in
typer==0.26.7
# via
# huggingface-hub
# transformers
typing-extensions==4.15.0
# via
# anyio
# exceptiongroup
# huggingface-hub
# mcp
# opentelemetry-api
# py-key-value-aio
Expand Down
Loading
Loading