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>
122 lines
3.8 KiB
Python
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
|