Kansho/backend/gliner_client.py
Lars 011cc9dd81
Some checks failed
Deploy Development / deploy (push) Successful in 1m0s
Test Suite / pytest-backend (push) Failing after 2m58s
Test Suite / smoke-dev (push) Successful in 0s
Test Suite / frontend-build (push) Successful in 23s
Put student-first detect learning ahead of the teacher call.
Identity-only senses no longer lock a new cue. Learning stages, journal-generate review, and additive docs follow the closed section 6.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 08:59:18 +02:00

122 lines
3.8 KiB
Python

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