atman-linguistic-sensor / lib /gliner2_engine.py
hleserg's picture
Replace 3-model zoo with single gliner2 (HLE-806)
0db8096
Raw
History Blame Contribute Delete
13.4 kB
"""Single-model engine: one `fastino/gliner2-multi-v1` (gliner2) replaces the
3-model zoo (GLiNER NER + MiniLM zero-shot + mREBEL relations).
`Gliner2Analyzer` subclasses `GLiNERPlusMiniLMAnalyzer` and overrides ONLY the
model-touching seams — every heuristic, DTO construction, normalization map,
cache and the public method surface (analyze_user_message / analyze_agent_message
/ analyze_key_moment / clear_session_cache) are inherited unchanged.
Seams swapped:
* `_run_ner` GLiNER.predict_entities → gliner2.extract_entities
* `_run_raw_ner` (same, for psychological discourse spans)
* `_classify_tasks_batch` MiniLM zero-shot argmax → gliner2.classify_text
* `_run_classification` MiniLM multi-label scores → gliner2 binary-contrast scores
* `extract_relations` mREBEL → route-A curated gliner2.extract_relations (@0.5)
Relations use route A (curated 14-type schema + type-constraint/self-loop/dedup
filters); a trained relations adapter (route B) was a measured regression, so the
base model + curated schema is the shipped path. See lib/route_a_relations.py.
"""
from __future__ import annotations
import logging
from typing import Any
from lib.dto import DetectedEntity, EntityType, ExtractedRelation, RawSpan
from lib.linguistic import GLiNERPlusMiniLMAnalyzer
from lib.route_a_relations import RELS, extract_relations_route_a
logger = logging.getLogger(__name__)
GLINER2_MODEL = "fastino/gliner2-multi-v1"
# route-A relation-schema entity types -> atman EntityType, used to synthesize a
# relation endpoint's type when it can't be matched to an already-detected entity.
_ROUTE_A_TO_ATMAN: dict[str, EntityType] = {
"person": EntityType.person,
"organization": EntityType.organization,
"location": EntityType.place,
"project": EntityType.topic,
"product": EntityType.object,
"event": EntityType.event,
"animal": EntityType.object,
"health": EntityType.health_condition,
"emotion_word": EntityType.topic,
"profession": EntityType.skill,
}
def _clamp(x: float) -> float:
return min(1.0, max(0.0, float(x)))
class Gliner2Analyzer(GLiNERPlusMiniLMAnalyzer):
"""GLiNERPlusMiniLMAnalyzer backed entirely by one gliner2 model."""
def __init__(
self,
gliner_model: str = GLINER2_MODEL,
device: str = "cpu",
ner_threshold: float = 0.5,
classification_threshold: float = 0.4,
relation_threshold: float = 0.5,
) -> None:
super().__init__(
gliner_model=gliner_model,
device=device,
ner_threshold=ner_threshold,
classification_threshold=classification_threshold,
)
self._relation_threshold = relation_threshold
# ------------------------------------------------------------------
# Lazy loader — one gliner2 model for everything
# ------------------------------------------------------------------
def _get_gliner(self) -> Any:
if self._gliner is not None:
return self._gliner
try:
from gliner2 import GLiNER2
except Exception:
logger.warning("gliner2 package not installed — analysis disabled.")
return None
logger.info("Loading gliner2 model %s …", self._gliner_model)
try:
self._gliner = GLiNER2.from_pretrained(self._gliner_model)
except Exception:
logger.exception("Failed to load gliner2 model %s", self._gliner_model)
return None
return self._gliner
# gliner2 does classification from the same model — no separate classifier.
def _get_classifier(self) -> Any: # pragma: no cover - defensive
return self._get_gliner()
# ------------------------------------------------------------------
# NER — entity types (-> DetectedEntity)
# ------------------------------------------------------------------
def _run_ner(self, text: str) -> list[DetectedEntity]:
if not text.strip():
return []
model = self._get_gliner()
if model is None:
return []
sample = self._sample_text_for_models(text)
try:
out = model.extract_entities(
sample,
self._entity_type_labels(),
threshold=self._ner_threshold,
include_confidence=True,
include_spans=True,
)
except Exception:
logger.exception("gliner2 NER failed (len=%d)", len(text))
return []
ents = out.get("entities", {}) if isinstance(out, dict) else {}
result: list[DetectedEntity] = []
for label, items in ents.items():
try:
ent_type = EntityType(label)
except (ValueError, KeyError):
continue
for it in items or []:
de = self._mk_detected(it, ent_type)
if de is not None:
result.append(de)
return result
@staticmethod
def _mk_detected(it: Any, ent_type: EntityType) -> DetectedEntity | None:
if isinstance(it, str):
if not it:
return None
return DetectedEntity(text=it, entity_type=ent_type, confidence=1.0, span=None)
text = it.get("text", "")
if not text:
return None
conf = _clamp(it.get("confidence", it.get("score", 1.0)) or 1.0)
span = None
if it.get("start") is not None and it.get("end") is not None:
span = (int(it["start"]), int(it["end"]))
return DetectedEntity(text=text, entity_type=ent_type, confidence=conf, span=span)
# ------------------------------------------------------------------
# NER — raw psychological spans (-> RawSpan, free-form labels)
# ------------------------------------------------------------------
def _run_raw_ner(self, text: str, labels: list[str]) -> list[RawSpan]:
if not text.strip() or not labels:
return []
model = self._get_gliner()
if model is None:
return []
try:
out = model.extract_entities(
text,
list(labels),
threshold=self._ner_threshold,
include_confidence=True,
include_spans=True,
)
except Exception:
logger.exception("gliner2 raw NER failed (labels=%s)", labels[:3])
return []
ents = out.get("entities", {}) if isinstance(out, dict) else {}
spans: list[RawSpan] = []
for label, items in ents.items():
for it in items or []:
if isinstance(it, str):
if it:
spans.append(RawSpan(text=it, label=label, confidence=1.0, span=None))
continue
t = it.get("text", "")
if not t:
continue
span = None
if it.get("start") is not None and it.get("end") is not None:
span = (int(it["start"]), int(it["end"]))
spans.append(
RawSpan(
text=t,
label=label,
confidence=_clamp(it.get("confidence", it.get("score", 0.0)) or 0.0),
span=span,
)
)
return spans
# ------------------------------------------------------------------
# Classification — argmax tasks (-> {task: label|None})
# ------------------------------------------------------------------
def _classify_tasks_batch(
self,
text: str,
tasks: dict[str, list[str]],
norm_maps: dict[str, dict[str, str]] | None = None,
) -> dict[str, str | None]:
norm_maps = norm_maps or {}
if not tasks:
return {}
model = self._get_gliner()
if model is None:
return {name: None for name in tasks}
sample = self._sample_text_for_classification(text)
try:
res = model.classify_text(
sample,
{name: list(labels) for name, labels in tasks.items()},
threshold=0.0,
include_confidence=True,
)
except Exception:
logger.exception("gliner2 classify failed")
return {name: None for name in tasks}
picked: dict[str, str | None] = {}
for name in tasks:
r = res.get(name) if isinstance(res, dict) else None
top: str | None = None
if isinstance(r, dict):
if float(r.get("confidence", 0.0) or 0.0) >= self._classification_threshold:
top = r.get("label")
elif isinstance(r, str):
top = r
elif isinstance(r, (list, tuple)) and r:
# format_results=False shape: [label, score]
if len(r) < 2 or float(r[1] or 0.0) >= self._classification_threshold:
top = r[0]
if top is not None and name in norm_maps:
top = norm_maps[name].get(top, top)
picked[name] = top
return picked
# ------------------------------------------------------------------
# Classification — multi-label scores (legacy Point-K signals)
# gliner2 classify is single-label argmax per task, so reconstruct an
# independent per-label score via a binary contrast task per label.
# ------------------------------------------------------------------
def _run_classification(self, text: str, candidate_labels: list[str]) -> dict[str, float]:
if not text.strip() or not candidate_labels:
return {}
model = self._get_gliner()
if model is None:
return {}
sample = self._sample_text_for_classification(text)
tasks = {f"_c{i}": [lab, "not applicable"] for i, lab in enumerate(candidate_labels)}
try:
res = model.classify_text(sample, tasks, threshold=0.0, include_confidence=True)
except Exception:
logger.exception("gliner2 multi-label classify failed")
return {}
scores: dict[str, float] = {}
for i, lab in enumerate(candidate_labels):
r = res.get(f"_c{i}") if isinstance(res, dict) else None
if isinstance(r, dict):
conf = float(r.get("confidence", 0.0) or 0.0)
scores[lab] = _clamp(conf if r.get("label") == lab else 1.0 - conf)
else:
scores[lab] = 0.0
return scores
# ------------------------------------------------------------------
# Relations — route A curated (replaces MRebelRelationExtractor)
# ------------------------------------------------------------------
def extract_relations(
self, text: str, entities: list[DetectedEntity]
) -> list[ExtractedRelation]:
"""Curated route-A relations from the same gliner2 model.
`entities` (already-detected DetectedEntity list) is used to resolve
relation endpoints to typed entities; route A internally re-extracts its
own schema-typed entities for the type-constraint filter.
"""
model = self._get_gliner()
if model is None or not text.strip():
return []
try:
triplets = extract_relations_route_a(
model,
text,
threshold=self._relation_threshold,
ent_threshold=self._ner_threshold,
curated=True,
)
except Exception:
logger.exception("gliner2 relation extraction failed")
return []
by_text: dict[str, DetectedEntity] = {}
for e in entities or []:
by_text.setdefault(e.text.lower(), e)
out: list[ExtractedRelation] = []
for subj, rel, obj in triplets:
s_ent = self._resolve_endpoint(subj, rel, "subject", by_text)
o_ent = self._resolve_endpoint(obj, rel, "object", by_text)
if s_ent is None or o_ent is None:
continue
if s_ent.text.lower() == o_ent.text.lower():
continue
out.append(
ExtractedRelation(
subject=s_ent,
object=o_ent,
relation_type=rel,
confidence=1.0,
learned_by="gliner2-route-a",
)
)
return out
@staticmethod
def _resolve_endpoint(
surface: str, rel: str, role: str, by_text: dict[str, DetectedEntity]
) -> DetectedEntity | None:
key = surface.lower().strip()
if not key:
return None
if key in by_text:
return by_text[key]
for k, e in by_text.items():
if k and (k in key or key in k):
return e
allow_s, allow_o = RELS.get(rel, ([], []))
types = allow_s if role == "subject" else allow_o
ent_type = _ROUTE_A_TO_ATMAN.get(types[0], EntityType.object) if types else EntityType.object
try:
return DetectedEntity(text=surface, entity_type=ent_type, confidence=1.0, span=None)
except Exception:
return None