# Trouve-KA — client du service d'embeddings # Author: Simon-Pierre Boucher # Contact: contact@spboucher.ai """Client HTTP du service d'embeddings — conçu pour la dégradation gracieuse (§13). Ne lève JAMAIS : toute erreur (service absent, timeout, réponse invalide) retourne None et le chemin lexical continue seul. Timeout requête court pour ne pas pénaliser la latence de recherche; timeout passage plus généreux pour l'enrichissement en arrière-plan. """ import httpx class EmbedClient: def __init__( self, base_url: str | None, *, query_timeout: float = 0.25, passage_timeout: float = 30.0, rerank_timeout: float = 0.6, ): self.base_url = (base_url or "").rstrip("/") or None self._query_client = httpx.AsyncClient(timeout=query_timeout) if self.base_url else None self._passage_client = httpx.AsyncClient(timeout=passage_timeout) if self.base_url else None self._rerank_client = httpx.AsyncClient(timeout=rerank_timeout) if self.base_url else None @property def enabled(self) -> bool: return self.base_url is not None async def close(self) -> None: for c in (self._query_client, self._passage_client, self._rerank_client): if c is not None: await c.aclose() async def rerank(self, query: str, texts: list[str]) -> list[float] | None: """Scores cross-encoder pour le top-N, ou None (l'ordre RRF est conservé).""" if not self.base_url or self._rerank_client is None or not texts: return None try: resp = await self._rerank_client.post( f"{self.base_url}/rerank", json={"query": query, "texts": texts} ) resp.raise_for_status() scores = resp.json()["scores"] if not isinstance(scores, list) or len(scores) != len(texts): return None return [float(s) for s in scores] except Exception: return None async def embed_query(self, text: str) -> list[float] | None: """Vecteur de la requête utilisateur, ou None (fallback lexical).""" return await self._embed([text], kind="query", client=self._query_client, single=True) async def embed_passages(self, texts: list[str]) -> list[list[float]] | None: """Vecteurs de passages (enrichissement asynchrone), ou None.""" if not texts: return [] return await self._embed(texts, kind="passage", client=self._passage_client, single=False) async def _embed(self, texts: list[str], *, kind: str, client: httpx.AsyncClient | None, single: bool): if not self.base_url or client is None: return None try: resp = await client.post(f"{self.base_url}/embed", json={"texts": texts, "kind": kind}) resp.raise_for_status() vectors = resp.json()["vectors"] if not isinstance(vectors, list) or len(vectors) != len(texts): return None return vectors[0] if single else vectors except Exception: return None