Spaces:
Runtime error
Runtime error
| """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 | |
| 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 | |
| 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 | |