Spaces:
Running
Running
| """ | |
| 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 | |