from __future__ import annotations import asyncio import logging from typing import Any import httpx import numpy as np from app.config import get_settings logger = logging.getLogger(__name__) FAISS_DIM = 384 # match Django's MiniLM output dim (NewsArticle.search_vector) _NLP_MAX_RETRIES = 3 _NLP_TIMEOUT_S = 60.0 # generous — accounts for HF Space cold start def _nlp_service_url() -> str: settings = get_settings() url = settings.nlp_service_url.rstrip("/") if not url: raise RuntimeError("NLP_SERVICE_URL not configured in settings") return url def _nlp_headers() -> dict[str, str]: settings = get_settings() if not settings.nlp_service_token: raise RuntimeError("NLP_SERVICE_TOKEN not configured in settings") return { "Authorization": f"Bearer {settings.nlp_service_token}", "Content-Type": "application/json", } async def _nlp_post(path: str, payload: dict) -> Any: """POST to the shared NLP microservice with retry on timeout/5xx (cold-start safe).""" url = f"{_nlp_service_url()}{path}" headers = _nlp_headers() last_exc: Exception | None = None async with httpx.AsyncClient(timeout=_NLP_TIMEOUT_S) as client: for attempt in range(1, _NLP_MAX_RETRIES + 1): try: resp = await client.post(url, json=payload, headers=headers) resp.raise_for_status() return resp.json() except (httpx.TimeoutException, httpx.ConnectError) as exc: last_exc = exc logger.warning( "NLP service attempt %d/%d timed out: %s", attempt, _NLP_MAX_RETRIES, exc, ) if attempt < _NLP_MAX_RETRIES: await asyncio.sleep(2 ** attempt) # 2s, 4s except httpx.HTTPStatusError as exc: if exc.response.status_code < 500: raise # 4xx — don't retry last_exc = exc logger.warning( "NLP service attempt %d/%d HTTP error: %s", attempt, _NLP_MAX_RETRIES, exc, ) if attempt < _NLP_MAX_RETRIES: await asyncio.sleep(2 ** attempt) raise RuntimeError( f"NLP microservice unavailable after {_NLP_MAX_RETRIES} attempts" ) from last_exc async def embed(texts: list[str]) -> np.ndarray: """ Embed texts via the shared remote MiniLM endpoint (same HF Space Django uses). Returns float32 array of shape (len(texts), FAISS_DIM), L2-normalised. """ result: list[list[float]] = await _nlp_post("/embed", {"texts": texts}) return np.array(result, dtype="float32")