docs: issues 0041 (split thresholds) + 0042 (GLiNER2), supersedes mREBEL

Cierra el ciclo del analysis gliner_glirel_tuning: documenta en app.md el
pipeline NER+RE disponible en el registry y abre los dos issues que faltan
para cablearlo en extract_graph_hybrid + panel paste_extract. Archiva el
0042 original (mREBEL) tras la decision a favor de GLiNER2 (Apache 2.0,
joint NER+RE, 20-30x mas rapido en CPU).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 10:07:43 +02:00
parent 3c98fee443
commit 5e6023f639
4 changed files with 498 additions and 0 deletions
+31
View File
@@ -98,3 +98,34 @@ cmake --build build/linux --target graph_explorer -j$(nproc)
filesystem propio para evitar lock con otras apps que esten escribiendo.
- El `graph_hash` se calcula a partir del path canonico del input. Mismo path
= mismo grafo a efectos de layout guardado.
### Pipeline NER+RE disponible en el registry (2026-05-04)
Tras la investigacion del analysis `gliner_glirel_tuning` (proyecto `osint_graph`), el stack completo de extraccion de entidades + relaciones desde texto / PDF esta listo como funciones del registry. **Esto desbloquea los issues 0041 y 0042**:
```python
# Pipeline E2E recomendado (texto -> grafo)
from pipelines.extract_graph_from_text import extract_graph_from_text
from datascience.gliner2_load_model import gliner2_load_model
model = gliner2_load_model() # Apache 2.0, NER+RE joint, 340M params
result = extract_graph_from_text(text, ENTITY_LABELS, RELATION_LABELS, ALLOWED, model)
# result = {'nodes': [...], 'edges': [...], 'stats': {...}}
```
Componentes (mira `python/functions/{core,datascience,pipelines}/`):
- **core (puras):** `clean_pdf_text`, `chunk_with_overlap`, `merge_entity_aliases`, `filter_relations_by_entity_types`, `aggregate_extraction_results`.
- **datascience (impuras):** `gliner2_load_model`, `extract_graph_gliner2`, `spacy_es_load_model`, `extract_triples_spacy_es` (OpenIE schema-less ES).
- **pipelines:** `extract_graph_from_text` — composicion E2E.
Recetas validadas en notebooks 04-08 del analysis y vaultadas en `vaults/osint_nlp_models/`:
- `threshold=0.3` (vs default 0.5) para GLiNER2.
- snake_case verbal labels (`works_at`, `ceo_of`...).
- `chunk_with_overlap` para texto > 1500 chars.
- `filter_relations_by_entity_types` para descartar `Madrid president_of Persona`.
- `merge_entity_aliases` para fusionar `BBVA` ⊂ `Banco Bilbao Vizcaya Argentaria, S.A.`.
- spaCy ES dep-rules como capa OpenIE schema-less complementaria (predicado = verbo del texto).
Issues que desbloquea: `issues/0041-split-confidence-thresholds.md` y `issues/0042-gliner2-unified-extractor.md`. El registry tiene todas las funciones necesarias; solo falta cablearlas en `extract_graph_hybrid_py_pipelines` y el panel `paste_extract`.
Playground de referencia: `projects/osint_graph/analysis/gliner_glirel_tuning/playground/` (FastAPI + Sigma.js, sirviendo en `localhost:7878`).
+102
View File
@@ -0,0 +1,102 @@
---
id: 0041
title: Split confidence_threshold en entity_threshold + relation_threshold
status: pending
priority: medium
created: 2026-05-04
parent: 0013
---
## Objetivo
`extract_graph_hybrid` y el panel `paste_extract` actualmente comparten un solo `confidence_threshold`. El analisis empirico (`projects/osint_graph/analysis/gliner_glirel_tuning/`) demuestra que las dos capas tienen distribuciones de score radicalmente distintas:
- **GLiNER** emite scores 0.92-0.99 en narrativa, 0.5-0.95 en OSINT. Threshold sano: **0.50-0.70**.
- **GLiREL** emite scores 0.05-0.34. Threshold sano: **0.10-0.20**.
- **mREBEL** (issue 0042) emite confianzas implicitas via `num_beams` — distinto modelo, distinto rango.
Un solo threshold global o filtra todas las relaciones (`t=0.6` mata GLiREL) o satura el grafo de entidades dudosas (`t=0.15` deja pasar entidades borderline).
## Cambios
### En `python/functions/pipelines/extract_graph_hybrid.py`
Aceptar dos thresholds opcionales con back-compat:
```python
def extract_graph_hybrid(
chunks, entity_schema, relation_types,
gliner_model, glirel_model,
llm_chat_json=None,
confidence_threshold=0.6, # legacy, valor por defecto si los dos siguientes no se pasan
entity_threshold=None, # nuevo — gobierna GLiNER + LLM-fallback de entidades
relation_threshold=None, # nuevo — gobierna GLiREL + LLM-fallback de relaciones
):
if entity_threshold is None:
entity_threshold = confidence_threshold
if relation_threshold is None:
relation_threshold = confidence_threshold
...
```
Pasar cada threshold a su capa respectiva (GLiNER `predict_entities(threshold=entity_threshold)`, GLiREL `predict_relations(threshold=relation_threshold)`).
### En `enrichers/paste_extract/run.py`
Aceptar `entity_threshold` y `relation_threshold` en `params`. Mantener `confidence_threshold` por compatibilidad. Defaults sugeridos en el manifest:
```yaml
params:
text: { type: string, required: true }
use_hybrid: { type: bool, default: true }
entity_threshold: { type: float, default: 0.50 }
relation_threshold: { type: float, default: 0.15 }
```
### En `extract_panel.cpp` / `extract_panel.h`
Reemplazar el slider unico por **dos sliders verticales** (mas el toggle `use_hybrid`):
```
┌─ Extract config ─────────────────────────────┐
│ ☑ Use hybrid (GLiNER + GLiREL) │
│ │
│ Entity threshold 0.50 [==== ] │
│ Relation threshold 0.15 [== ] │
└──────────────────────────────────────────────┘
```
Defaults: `entity_threshold=0.50`, `relation_threshold=0.15`.
### Tests
`tests/test_paste_extract.py`:
- Test que pasar solo `confidence_threshold` mantiene comportamiento legacy.
- Test que pasar `entity_threshold=0.5, relation_threshold=0.1` aplica thresholds distintos a cada capa.
- Test que la UI envia los dos parametros correctamente al subprocess.
## Definicion de hecho
- Pego un texto y veo entidades con confianza 0.5+ Y relaciones con confianza 0.15+ por defecto.
- Mover el slider de relaciones a 0.05 me muestra mas relaciones (potencialmente espurias) sin afectar las entidades.
- Mover el slider de entidades a 0.9 reduce las entidades sin tocar las relaciones.
- El test legacy (`confidence_threshold` solo) sigue pasando.
## Datos que respaldan los defaults
Notebook `01_gliner_glirel_tuning.ipynb`, tabla "GLiNER thresholds" (corpus es_corporate, en_corporate, es_journalism). Notebook `02_e2e_spanish_graph.ipynb`, comparacion recall (t=0.15) vs precision (t=0.30) — ambos modos producen grafos validos por separado, no hay un punto medio compartido bueno.
## Out of scope
- Optimizacion automatica de thresholds (issue futuro).
- Threshold por tipo de entidad (`person` mas estricto que `location`) — issue futuro.
- Calibracion automatica con feedback del usuario (al hacer Apply, ajustar thresholds aprendiendo).
## Reversibilidad
Si los nuevos defaults producen mas ruido del esperado, basta con cambiarlos en el manifest. El esquema de dos thresholds es estrictamente mas expresivo que el legacy.
## Relacionado
- Issue 0042 — sustituir GLiREL por mREBEL. Si 0042 va primero, este issue cambia: `relation_threshold` se vuelve menos relevante (mREBEL no usa threshold continuo del mismo modo) pero `entity_threshold` sigue siendo necesario.
+191
View File
@@ -0,0 +1,191 @@
---
id: 0042
title: GLiNER2 — extractor unificado NER+RE (Apache 2.0) sustituye GLiREL en extract_graph_hybrid
status: pending
priority: high
created: 2026-05-04
parent: 0013
depends_on: [0041]
supersedes: 0042-mrebel-relation-extractor (archivado, ver decisions/2026-05-04-gliner2-over-mrebel.md)
---
## Contexto
El issue 0042 inicial (sustituir GLiREL por mREBEL) queda superado por el descubrimiento de **GLiNER2** (`fastino/gliner2-large-v1`):
- **Apache 2.0** — sin restriccion comercial.
- **NER + RE en un solo modelo** — un solo forward pass.
- **20-30× mas rapido** que mREBEL en CPU.
- **Funciona en OSINT castellano** — captura IPs, dominios defanged, CVEs, malware.
Datos en `vaults/osint_nlp_models/models/gliner2.md`, decision en `vaults/osint_nlp_models/decisions/2026-05-04-gliner2-over-mrebel.md`, validacion empirica en `analysis/gliner_glirel_tuning/notebooks/04_gliner2_winner.ipynb`.
## Objetivo
Anadir GLiNER2 como motor principal de NER+RE en el pipeline `extract_graph_hybrid`. GLiREL queda como engine secundario en el panel `paste_extract`. mREBEL queda disponible via las funciones del registry pero no por defecto.
## Diseño
### 1. Funciones nuevas en el registry
```
python/functions/datascience/
├── gliner2_load_model.py # carga + cache GLiNER2 (Apache 2.0)
└── extract_graph_gliner2.py # ejecuta schema entity+relation, normaliza a EntityCandidate/RelationCandidate
```
Plantilla `gliner2_load_model.py`:
```python
"""Carga (y cachea) un modelo GLiNER2 que hace NER+RE conjunto.
LICENCIA: Apache 2.0 — uso libre incluido comercial.
"""
from typing import Any
_MODEL_CACHE: dict[tuple[str, str], Any] = {}
def gliner2_load_model(
model_name: str = "fastino/gliner2-large-v1",
device: str = "auto",
) -> Any:
"""Returns GLiNER2 instance ready for .extract() with schemas."""
resolved = _resolve_device(device)
key = (model_name, resolved)
if key in _MODEL_CACHE: return _MODEL_CACHE[key]
from gliner2 import GLiNER2
m = GLiNER2.from_pretrained(model_name)
if hasattr(m, "to"): m.to(resolved)
_MODEL_CACHE[key] = m
return m
```
Plantilla `extract_graph_gliner2.py`:
```python
def extract_graph_gliner2(
text: str,
entity_labels: list[str],
relation_labels: list[str],
model,
chunk_by_sentences: bool = False,
) -> tuple[list, list]:
"""Returns (entities, relations) as list[EntityCandidate], list[RelationCandidate].
If chunk_by_sentences=True, splits text by sentence and aggregates.
Recommended for texts > 20 sentences (recall improves).
"""
```
### 2. Cambio en `extract_graph_hybrid.py`
Anadir parametro `relation_engine` con cuatro valores:
```python
def extract_graph_hybrid(
chunks, entity_schema, relation_types,
relation_engine: str = "gliner2", # 'gliner2' | 'glirel' | 'mrebel' | 'none'
gliner_model=None, # capa 1 NER (legacy stack)
glirel_model=None, # capa 4 RE (legacy)
mrebel_models=None, # tuple (tok, mdl) si engine=mrebel
gliner2_model=None, # NUEVO — sustituye gliner+glirel
llm_chat_json=None,
entity_threshold=0.5,
relation_threshold=0.15,
chunk_by_sentences=False,
):
if relation_engine == "gliner2":
ents, rels = extract_graph_gliner2(text=..., entity_labels=..., relation_labels=...,
model=gliner2_model, chunk_by_sentences=chunk_by_sentences)
# NO se llama a GLiNER ni GLiREL, GLiNER2 hace ambos.
elif relation_engine == "glirel":
# legacy: GLiNER + GLiREL como antes
...
elif relation_engine == "mrebel":
# GLiNER + mREBEL via las funciones del registry (extract_relations_mrebel)
...
elif relation_engine == "none":
rels = []
```
### 3. `enrichers/paste_extract/run.py`
Manifest:
```yaml
params:
text: { type: string, required: true }
use_hybrid: { type: bool, default: true }
relation_engine: { type: enum, values: [gliner2, glirel, mrebel, none], default: gliner2 }
entity_threshold: { type: float, default: 0.5 }
relation_threshold: { type: float, default: 0.15 }
chunk_by_sentences: { type: bool, default: false }
```
### 4. Panel C++ (`extract_panel.cpp`)
- Combo: `Engine: [GLiNER2 (recomendado) | GLiNER+GLiREL (rapido EN) | GLiNER+mREBEL (no comercial) | None]`
- Banner amarillo solo si `mREBEL` seleccionado (recordatorio de licencia CC BY-NC-SA).
- Checkbox `chunk_by_sentences` (recomendado para texto largo).
- Sliders del issue 0041 (entity_threshold, relation_threshold).
### 5. `app.md`
```yaml
notes: |
Motor de extraccion por defecto: GLiNER2 (Apache 2.0, NER+RE conjunto, ES/EN/FR).
Engines alternativos: GLiNER+GLiREL (rapido EN), GLiNER+mREBEL (no comercial,
18 idiomas). Ver vaults/osint_nlp_models/ para benchmarks.
dependencies:
- "gliner2 >= 1.3.0 (Apache 2.0)"
optional_dependencies:
- "gliner + glirel (Apache 2.0, legacy)"
- "transformers + sentencepiece (mREBEL/REBEL — non-commercial mREBEL)"
```
### 6. Tests pytest
`tests/test_extract_graph_gliner2.py`:
- Test smoke con stub de modelo (mock que retorna shape oficial).
- Test que normaliza output a `EntityCandidate` / `RelationCandidate` correctamente.
- Test que `chunk_by_sentences=True` agrega correctamente.
`tests/test_paste_extract.py`: anadir test del path `relation_engine=gliner2` (default).
## Definicion de hecho
- Pego el corpus es_corporate_short, dejo engine=GLiNER2, pulso Extract.
- Tras ~1.5s veo 14 entidades y 8 relaciones, mayoria correctas.
- Cambio engine a GLiREL → resultado pobre conocido (validacion contraria).
- Cambio engine a mREBEL → veo banner de licencia + 5 relaciones lentas pero limpias.
- Pego corpus es_osint con label set OSINT → veo IPs, CVEs, malware, hashes correctamente.
- Apply solo guarda las marcadas; dedupe sigue funcionando.
- Tests verdes en WSL.
## Latencia / UX
- GLiNER2: ~1-5s para texto medio (4-30 frases). UX **fluida**, no necesita progress bar.
- GLiNER2 chunked: ~150ms/frase. Para 30 frases = 4.5s, sigue fluido.
- mREBEL queda con barra de progreso obligatoria (issue separado dentro de este).
## Out of scope (issues separados)
- Threshold tuning interno de GLiNER2 (si la API lo expone).
- Mapeo de tipos de entidad GLiNER2 a vocabulario controlado custom (ej. `persona``Person`).
- Extension a Frances (declarado pero sin probar).
- Comparativa con `gliner2-base-v1` (mas pequeño) — issue de optimizacion.
## Riesgos
- **Recall bajo en texto largo** sin chunking. Mitigacion: `chunk_by_sentences=True` por defecto si texto > 20 frases.
- **Errores semanticos puntuales** (e.g. `Inditex acquired Pablo Isla`). El panel los muestra con checkbox — el humano filtra antes de Apply.
- **Spans sucios ocasionales** en OSINT. Mitigacion: aplicar `align_relations_to_entities_py_datascience` (funcion ya en el registry) post-extract para descartar relaciones cuyas puntas no son entidades reales.
## Relacionado
- `vaults/osint_nlp_models/models/gliner2.md`
- `vaults/osint_nlp_models/decisions/2026-05-04-gliner2-over-mrebel.md`
- `analysis/gliner_glirel_tuning/notebooks/04_gliner2_winner.ipynb`
- Issue 0041 (split confidence_threshold) — sigue valido.
- Issue 0042-mrebel-relation-extractor.md.superseded (archivado).
@@ -0,0 +1,174 @@
---
id: 0042
title: Sustituir GLiREL por mREBEL como extractor de relaciones (uso no comercial)
status: pending
priority: high
created: 2026-05-04
parent: 0013
depends_on: [0041]
---
## Contexto
Tras integrar GLiREL en `extract_graph_hybrid` (issue 0013), el analisis empirico (`projects/osint_graph/analysis/gliner_glirel_tuning/`) revelo que GLiREL falla estrepitosamente sobre narrativa empresarial castellana: 51 falsos positivos a `t=0.15`, 1 unica relacion (tambien falsa) a `t=0.30`. **No hay sweet spot**.
Probado el sustituto **`Babelscape/mrebel-large`** (mBART seq2seq que genera tripletas directamente del texto) sobre el mismo corpus: **8 tripletas crudas, 5 alineables con entidades GLiNER, 4 inequivocamente correctas, cero falsos absurdos**.
Decision documentada en `vaults/osint_nlp_models/decisions/2026-05-04-mrebel-over-glirel.md`.
## Restriccion de licencia
mREBEL es **CC BY-NC-SA 4.0 — uso no comercial**. Compatible con `graph_explorer` actual (uso personal de investigacion). Bloqueante si pasa a producto comercial. Plan de contingencia documentado en `vaults/osint_nlp_models/decisions/2026-05-04-license-constraint.md`. **Hay que marcar la restriccion en el codigo y en el `app.md`.**
## Objetivo
Anadir mREBEL como capa 4 alternativa en `extract_graph_hybrid`, configurable por el usuario en el panel `paste_extract`. GLiREL no se borra — queda disponible para textos en ingles donde su comportamiento puede ser razonable.
## Diseño
### 1. Funciones nuevas en el registry
```
python/functions/datascience/
├── mrebel_load_model.py # carga + cache (mBART, src_lang, tgt_lang)
├── extract_relations_mrebel.py # frase a frase, devuelve list[RelationCandidate]
└── _mrebel_parser.py # parser del wire format <triplet>...</s>
```
Plantilla `mrebel_load_model.py`:
```python
"""LICENSE: CC BY-NC-SA 4.0 — non-commercial use only.
Carga (y cachea) Babelscape/mrebel-large para extraccion de relaciones.
"""
from typing import Any
_MODEL_CACHE: dict[tuple[str, str], Any] = {}
def mrebel_load_model(
model_name: str = "Babelscape/mrebel-large",
src_lang: str = "es_XX",
tgt_lang: str = "tp_XX",
) -> tuple[Any, Any]:
key = (model_name, src_lang)
if key in _MODEL_CACHE: return _MODEL_CACHE[key]
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
tok = AutoTokenizer.from_pretrained(model_name, src_lang=src_lang, tgt_lang=tgt_lang)
mdl = AutoModelForSeq2SeqLM.from_pretrained(model_name)
_MODEL_CACHE[key] = (tok, mdl)
return tok, mdl
```
`extract_relations_mrebel.py` toma `text`, `entities` (de GLiNER), trocea por frases con `re.split`, llama a mREBEL frase a frase, parsea tripletas, hace **string-match con `entity.name`**, y devuelve `list[RelationCandidate]` con `relation_type` igual al `type` que emite mREBEL (vocabulario Wikidata). Las tripletas sin match se descartan silenciosamente.
### 2. Cambio en `extract_graph_hybrid.py`
Anadir parametro `relation_engine` con tres valores:
```python
def extract_graph_hybrid(
chunks, entity_schema, relation_types,
gliner_model,
relation_engine: str = "mrebel", # 'glirel' | 'mrebel' | 'none'
glirel_model=None, # solo si engine == 'glirel'
mrebel_models=None, # tuple (tok, mdl) si engine == 'mrebel'
llm_chat_json=None,
entity_threshold=0.5,
relation_threshold=0.15,
):
...
if relation_engine == "glirel":
rels = extract_relations_glirel(chunk, ents, relation_types, glirel_model, threshold=relation_threshold)
elif relation_engine == "mrebel":
rels = extract_relations_mrebel(chunk, ents, mrebel_models)
elif relation_engine == "none":
rels = []
...
```
**`relation_types` es ignorado** cuando `engine == 'mrebel'` (el modelo emite vocab Wikidata cerrado). Quiza convenga dejar un parametro opcional `accept_relation_types` que filtre la salida si el usuario quiere taxonomia controlada.
### 3. Cambio en `enrichers/paste_extract/run.py`
```yaml
# manifest
params:
text: { type: string, required: true }
use_hybrid: { type: bool, default: true }
relation_engine: { type: enum, values: [glirel, mrebel, none], default: mrebel }
entity_threshold: { type: float, default: 0.5 }
relation_threshold: { type: float, default: 0.15 } # solo aplicable si engine=glirel
```
### 4. Cambios en el panel C++ (`extract_panel.cpp`)
- Combo `Relation engine: [GLiREL | mREBEL | None]` debajo del toggle hybrid.
- **Banner de licencia** cuando `engine == mREBEL`: amarillo, texto fijo `"mREBEL: CC BY-NC-SA — non-commercial use only"`.
- **Barra de progreso por frase** cuando engine=mREBEL (latencia ~3s/frase). El subprocess emite `PROGRESS:0.X frase_N` cada frase, el panel lo lee y refresca un `ImGui::ProgressBar`.
### 5. `app.md` — declarar la restriccion
```yaml
---
...
notes: |
El motor de relaciones por defecto es mREBEL (Babelscape/mrebel-large),
bajo licencia CC BY-NC-SA 4.0. NO usar en contextos comerciales sin
cambiar el motor a GLiREL (Apache 2.0) o LLM externo.
optional_dependencies:
- "transformers (mREBEL/GLiREL)"
- "sentencepiece (mBART tokenizer)"
---
```
### 6. Tests pytest
`tests/test_extract_relations_mrebel.py`:
- Test parser de output mREBEL (formato `<triplet> ... </s>`).
- Test string-match con entidades fuzzy (substring + exact).
- Test que tripletas sin match desaparecen.
- Test smoke con stub de modelo (mock que retorna decoded canónico).
- Test integracion: pasar texto ES → ents GLiNER fake → tripletas alineadas.
`tests/test_paste_extract.py`: añadir test del path `relation_engine=mrebel`.
### 7. Documentacion
- `app.md` — seccion de relaciones explica los 3 engines + licencia.
- `python/functions/datascience/mrebel_load_model.md` — frontmatter completo + restriccion de licencia.
## Definicion de hecho
- Pego el texto del corpus es_corporate, selecciono `engine=mREBEL`, pulso Extract.
- Tras ~25s veo las 4 relaciones limpias en la tabla (Pablo Isla→Inditex, Endesa→Marina Serrano, BBVA→Carlos Torres, Arteixo→A Coruna).
- Cambio a `engine=GLiREL` y veo las 51 espurias (validacion contraria).
- Apply solo guarda las marcadas; dedupe sigue funcionando.
- Banner de licencia amarillo es visible cuando engine=mREBEL.
- Tests verdes en WSL.
## Latencia / UX
- mREBEL es ~50× mas lento que GLiREL (3s/frase vs 50ms/grafo entero).
- Para texto largo (20+ frases) son ~60s. **Barra de progreso es obligatoria.**
- Plan: emitir `PROGRESS:f frase=N/M` cada frase; panel C++ refresca ProgressBar.
- Cancelacion: boton "Cancel" mata el subprocess limpiamente (issue futuro si no es trivial).
## Out of scope (issues separados)
- LLM como validator semantico de tripletas mREBEL — futuro.
- mREBEL en GPU (issue de infra, requires CUDA). En CPU es usable.
- Mapeo del vocabulario Wikidata a una taxonomia OSINT custom — issue futuro.
- Cancelacion del subprocess (Cancel button) — micro-issue.
## Riesgos
- **Latencia.** El usuario puede sentir que la app esta colgada sin la barra de progreso. Mitigacion: progress por frase + tip "modelo grande, primer texto descarga 2.4 GB".
- **Cobertura del vocabulario Wikidata** en dominios fuera de business/journalism. Para narrativa funciona; para OSINT, drama tipico, puede no encajar — habra que probar y posiblemente añadir mapeo custom (issue futuro).
- **Licencia.** Reducible solo via diseño claro de UI (banner) + documentacion (app.md, vault).
## Relacionado
- `vaults/osint_nlp_models/models/mrebel.md` — observaciones empiricas
- `vaults/osint_nlp_models/decisions/2026-05-04-mrebel-over-glirel.md` — ADR
- Notebooks 01, 02, 03 en `analysis/gliner_glirel_tuning/`