"""Stateless client for the LAN GLiNER spike. Fail-soft; never logs request text.""" from __future__ import annotations import logging from typing import Any from urllib.parse import urlparse, urlunparse import httpx from providers import is_local_url log = logging.getLogger("gliner_client") DEFAULT_URL = "http://192.168.2.144:8090" DEFAULT_LABELS = ("person", "place", "organization", "project", "dish", "food") IDENTITY_LABELS = { "person": "PERSON", "place": "PLACE", "organization": "ORG", "project": "PROJECT", } NON_IDENTITY_LABELS = frozenset({"dish", "food"}) EXTRACT_TIMEOUT = 8.0 HEALTH_TIMEOUT = 2.0 def normalize_base_url(url: str | None) -> str: raw = (url or DEFAULT_URL).strip() or DEFAULT_URL parsed = urlparse(raw) if not parsed.scheme or not parsed.netloc: return DEFAULT_URL path = (parsed.path or "").rstrip("/") return urlunparse((parsed.scheme, parsed.netloc, path, "", "", "")) def gliner_identity_type(label: str) -> str | None: return IDENTITY_LABELS.get((label or "").strip().lower()) def is_gliner_non_identity(label: str) -> bool: return (label or "").strip().lower() in NON_IDENTITY_LABELS def health(url: str | None = None) -> dict[str, Any]: """Ping GLiNER. Never include request text. Fail-soft.""" base = normalize_base_url(url) if not is_local_url(base): return {"ok": False, "status": "rejected", "reason": "not_local"} try: response = httpx.get(f"{base}/health", timeout=HEALTH_TIMEOUT) except httpx.HTTPError: return {"ok": False, "status": "unreachable", "url": base} if response.status_code >= 400: return {"ok": False, "status": "http_error", "http_status": response.status_code} try: payload = response.json() except ValueError: return {"ok": False, "status": "invalid"} if not isinstance(payload, dict): return {"ok": False, "status": "invalid"} status = (payload.get("status") or "").strip() or "ok" return { "ok": status == "ok", "status": status, "model": payload.get("model") or "", "offline": bool(payload.get("offline")), } def extract_entities(text: str, url: str | None = None) -> list[dict] | None: """Return spans or None if the service cannot be used. Empty list is a valid miss.""" body = text or "" if not body.strip(): return [] base = normalize_base_url(url) if not is_local_url(base): log.info("gliner skipped: url is not local") return None try: response = httpx.post( f"{base}/v1/extract", json={"text": body, "labels": list(DEFAULT_LABELS)}, timeout=EXTRACT_TIMEOUT, ) except httpx.HTTPError: log.info("gliner extract unreachable chars=%s", len(body)) return None if response.status_code >= 400: log.info("gliner extract http=%s chars=%s", response.status_code, len(body)) return None try: payload = response.json() except ValueError: return None raw = payload.get("spans") if isinstance(payload, dict) else None if not isinstance(raw, list): return None spans = [] for item in raw: if not isinstance(item, dict): continue try: start = int(item["start"]) end = int(item["end"]) except (KeyError, TypeError, ValueError): continue label = str(item.get("text") or "") kind = str(item.get("label") or "") if start < 0 or end <= start or not label or not kind: continue spans.append( { "text": label, "start": start, "end": end, "label": kind, "score": item.get("score"), } ) log.info("gliner extract chars=%s spans=%s", len(body), len(spans)) return spans