File size: 5,229 Bytes
f390b04
 
 
 
2e3f5aa
 
 
 
 
 
caf4ed9
 
 
 
f390b04
 
 
 
 
 
2e3f5aa
 
 
caf4ed9
f390b04
 
 
 
2e3f5aa
 
f390b04
 
 
 
 
 
 
 
 
2e3f5aa
 
f390b04
 
2e3f5aa
f390b04
 
 
 
 
 
2e3f5aa
f390b04
 
 
 
 
2e3f5aa
 
 
 
 
 
 
f390b04
 
 
 
2e3f5aa
 
 
 
 
 
 
f390b04
 
 
 
 
 
 
 
 
caf4ed9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f390b04
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e3f5aa
f390b04
 
 
 
2e3f5aa
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
"""
Named Entity Recognition (NER).

Uses the pre-trained model ``dslim/bert-base-NER`` (fine-tuned on CoNLL-2003,
~92.6% F1) via the Hugging Face ``token-classification`` pipeline by default.
No training is required here - this is direct inference on a pre-trained
model, in line with the methodology. The default model is loaded lazily on
first use so the application starts quickly and only pays the memory cost
when NER is actually needed. Since V3, callers may instead pick any other
token-classification model from the Hugging Face Hub at request time; that
model is loaded and cached via ``processors.model_registry``. Since V6,
``extract_entities_batch`` runs NER over many texts in one pipeline call
instead of one call per text, which is significantly faster for large
``/ingest`` batches.
"""

from __future__ import annotations

from typing import Any

from processors import model_registry

DEFAULT_MODEL_NAME = "dslim/bert-base-NER"
_BATCH_SIZE = 16
_pipeline = None
_load_error: str | None = None


def _get_default_pipeline():
    """Lazy-load the default NER pipeline on first call."""
    global _pipeline, _load_error
    if _pipeline is not None or _load_error is not None:
        return _pipeline

    try:
        from transformers import pipeline

        _pipeline = pipeline(
            "token-classification",
            model=DEFAULT_MODEL_NAME,
            tokenizer=DEFAULT_MODEL_NAME,
            aggregation_strategy="simple",  # merge sub-word tokens into whole entities
        )
        print(f"[ner] Loaded model {DEFAULT_MODEL_NAME}.")
    except Exception as e:  # pragma: no cover
        _load_error = str(e)
        print(f"[ner] Failed to load model: {e}")
    return _pipeline


def extract_entities(text: str, model_id: str | None = None) -> list[dict[str, Any]]:
    """
    Extract named entities from ``text``.

    Returns a list of dicts: {"text", "type", "start", "end", "score"}.
    Entity types follow CoNLL-2003: PER (person), LOC (location),
    ORG (organization), MISC (miscellaneous) for the default model; a custom
    ``model_id`` may use a different label set.

    ``model_id`` optionally selects a different Hugging Face Hub model
    (loaded/cached on demand via ``model_registry``) instead of the default.
    Raises ``RuntimeError`` if that model cannot be loaded, so the API layer
    can turn it into a clean 400 response.
    """
    if not isinstance(text, str) or not text.strip():
        return []

    if model_id and model_id != DEFAULT_MODEL_NAME:
        nlp = model_registry.get_pipeline(
            model_id, task="token-classification", aggregation_strategy="simple"
        )
    else:
        nlp = _get_default_pipeline()

    if nlp is None:
        return []

    try:
        raw = nlp(text)
    except Exception as e:  # pragma: no cover
        print(f"[ner] Inference failed: {e}")
        return []

    return _format_entities(raw)


def extract_entities_batch(
    texts: list[str], model_id: str | None = None
) -> list[list[dict[str, Any]]]:
    """
    Batched version of ``extract_entities``: runs NER once over the whole
    list of texts instead of once per text. The pipeline batches the
    underlying forward passes internally (``batch_size``), which is much
    faster for a large ``/ingest`` batch than calling ``extract_entities``
    in a Python loop. Empty/blank texts are skipped and get an empty list
    back, at their original position.
    """
    results: list[list[dict[str, Any]]] = [[] for _ in texts]
    valid = [(i, t) for i, t in enumerate(texts) if isinstance(t, str) and t.strip()]
    if not valid:
        return results

    if model_id and model_id != DEFAULT_MODEL_NAME:
        nlp = model_registry.get_pipeline(
            model_id, task="token-classification", aggregation_strategy="simple"
        )
    else:
        nlp = _get_default_pipeline()
    if nlp is None:
        return results

    indices, valid_texts = zip(*valid)
    try:
        raw_batch = nlp(list(valid_texts), batch_size=_BATCH_SIZE)
    except Exception as e:  # pragma: no cover
        print(f"[ner] Batch inference failed: {e}")
        return results

    # A single-item input list should still come back as a list-of-one, but
    # be defensive in case a given pipeline/version collapses it.
    if len(valid_texts) == 1 and (not raw_batch or not isinstance(raw_batch[0], list)):
        raw_batch = [raw_batch]

    for idx, raw in zip(indices, raw_batch):
        results[idx] = _format_entities(raw)
    return results


def _format_entities(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
    entities: list[dict[str, Any]] = []
    for ent in raw:
        entities.append(
            {
                "text": ent.get("word", ""),
                "type": ent.get("entity_group", ent.get("entity", "")),
                "start": int(ent.get("start", 0)),
                "end": int(ent.get("end", 0)),
                "score": round(float(ent.get("score", 0.0)), 4),
            }
        )
    return entities


def is_ready() -> bool:
    """True if the default model is loaded or can be loaded (no fatal error)."""
    return _load_error is None


def model_name() -> str:
    return DEFAULT_MODEL_NAME