"""Optional local Ollama embeddings for semantic knowledge retrieval.""" from __future__ import annotations import math import os from typing import Sequence import httpx class EmbeddingUnavailable(RuntimeError): """The configured local embedding model is missing or cannot embed text.""" def embedding_model_name() -> str | None: return os.getenv("KNOWLEDGE_EMBEDDING_MODEL", "granite-embedding:278m").strip() or None def _ollama_base_url() -> str: configured = os.getenv("LOCAL_LLM_BASE_URL", "http://127.0.0.1:11434/v1").rstrip("/") if configured.endswith("/v1"): configured = configured[:-3] return configured async def embed_texts(texts: Sequence[str], *, model: str | None = None) -> list[list[float]]: """Generate bounded-size embedding batches through the local Ollama API.""" if not texts: return [] configured_model = model if model is not None else embedding_model_name() model_name = configured_model.strip() if configured_model else "" if not model_name: raise EmbeddingUnavailable("لم يُضبط نموذج التضمين المحلي.") endpoint = f"{_ollama_base_url()}/api/embed" results: list[list[float]] = [] try: async with httpx.AsyncClient(timeout=httpx.Timeout(180.0, connect=5.0)) as client: for offset in range(0, len(texts), 32): batch = [str(text)[:4_000] for text in texts[offset : offset + 32]] response = await client.post( endpoint, json={"model": model_name, "input": batch, "keep_alive": "5m"}, ) response.raise_for_status() payload = response.json() vectors = payload.get("embeddings") if isinstance(payload, dict) else None if not isinstance(vectors, list) or len(vectors) != len(batch): raise EmbeddingUnavailable("أعاد نموذج التضمين عدد متجهات غير متوقع.") dimension = None for vector in vectors: if not isinstance(vector, list) or not vector: raise EmbeddingUnavailable("أعاد نموذج التضمين متجهًا فارغًا.") if dimension is None: dimension = len(vector) if len(vector) != dimension: raise EmbeddingUnavailable("أبعاد متجهات التضمين غير متطابقة.") numeric = [float(value) for value in vector] if not all(math.isfinite(value) for value in numeric): raise EmbeddingUnavailable("أعاد نموذج التضمين قيمًا غير صالحة.") if not any(value != 0 for value in numeric): raise EmbeddingUnavailable("أعاد نموذج التضمين متجهًا صفريًا.") results.append(numeric) except EmbeddingUnavailable: raise except (httpx.HTTPError, ValueError, TypeError, KeyError) as exc: raise EmbeddingUnavailable("نموذج التضمين المحلي غير متاح؛ سيبقى البحث النصي مستخدمًا.") from exc return results