# 0038 — GLiNER entity extractor (zero-shot NER)

## Metadata

| Campo | Valor |
|-------|-------|
| **ID** | 0038 |
| **Estado** | pendiente |
| **Prioridad** | alta |
| **Tipo** | feature — Python (`python/functions/datascience/`) |

## Dependencias

Recomendado leer `extract_entities_llm_py_datascience`, `entity_candidate_py_datascience`, `build_entity_schema_prompt_py_datascience`. El contrato debe ser **drop-in** con la version LLM.

**Desbloquea:** 0039 (GLiREL), 0040 (pipeline hibrido). Permite extraccion masiva sin coste por token.

---

## Objetivo

Wrapper sobre GLiNER (`urchade/gliner_multi-v2.1` por defecto, multilingue ES/EN) que extrae entidades zero-shot a partir del mismo `entity_schema: list[dict]` que ya consume `extract_entities_llm`. Misma firma, mismo retorno (`list[EntityCandidate]`), 50–200x mas rapido y sin coste por token.

## Funciones a crear

| Function ID | Rol |
|---|---|
| `gliner_load_model_py_datascience` | Carga + cachea el modelo. `purity: impure`, `kind: function` |
| `extract_entities_gliner_py_datascience` | Extractor por chunk. `purity: impure`, mismo contrato que la version LLM |

## Contrato

```python
# gliner_load_model.py
def gliner_load_model(
    model_name: str = "urchade/gliner_multi-v2.1",
    device: str = "auto",   # "auto" | "cpu" | "cuda" | "cuda:0"
) -> "GLiNER": ...

# extract_entities_gliner.py
def extract_entities_gliner(
    text: str,
    entity_schema: list[dict],          # mismo formato que extract_entities_llm
    model: "GLiNER",                    # inyectado, cargado una sola vez
    threshold: float = 0.5,
    flat_ner: bool = True,              # False = nested entities
) -> list[EntityCandidate]
```

`entity_schema` se traduce internamente: cada `{"name": "person", "label": "Person", ...}` se convierte al label que GLiNER espera. `EntityCandidate` se rellena con:
- `name` = span text
- `type_ref` = entity name del schema
- `type_label` = label legible del schema
- `confidence` = score de GLiNER
- `attributes["start"]`, `attributes["end"]` = offsets en el chunk
- `source_chunk_indices` lo setea el caller (igual que en la version LLM)

## Pureza

`gliner_load_model`: impure (lee disco/red la primera vez, descarga ~200 MB de HF).
`extract_entities_gliner`: impure (modelo es estado externo) — `error_type: error_go_core` siguiendo la regla del registry.

## Deliverables

- `python/functions/datascience/gliner_load_model.py` + `.md`
- `python/functions/datascience/extract_entities_gliner.py` + `.md`
- Tests en `python/functions/datascience/tests/test_extract_entities_gliner.py` con un corpus minimo (3-5 textos cortos ES/EN, schema con 4-5 tipos).
- `pyproject.toml`: añadir `gliner>=0.2.13` como dep opcional bajo `[project.optional-dependencies]` `nlp = ["gliner", ...]`. NO añadir a deps principales para no inflar `.venv` de quien no use NER.
- Documentar en el `.md`: `pip install gliner` (o `uv pip install gliner`), tamaño del modelo, latencia esperada CPU vs GPU.

## Validacion

1. `./fn run extract_entities_gliner_py_datascience` corre los tests.
2. Bench manual: medir tokens/seg en un texto de 10 KB con 8 labels, CPU y GPU si hay.
3. Compararlo con `extract_entities_llm` sobre el mismo corpus pequeño y registrar precision/recall en el `.md`.

## Notas

- Mantener el contrato **identico** al de la version LLM permite usar `deduplicate_entities`, `merge_entity_attributes` y todo el resto del pipeline sin cambios.
- GLiNER es malo con IoCs tecnicos (IPs, hashes, wallets) — eso lo cubre 0037. Documentar la limitacion.
- El modelo se carga UNA vez por proceso: el caller lo inyecta. No cargarlo dentro de `extract_entities_gliner` — penalty fatal en batch.
- Si la GPU no esta disponible, `device="auto"` debe caer a CPU sin error.
