diff --git a/app.md b/app.md index 89d321c..3b6315e 100644 --- a/app.md +++ b/app.md @@ -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`). diff --git a/issues/0041-split-confidence-thresholds.md b/issues/0041-split-confidence-thresholds.md new file mode 100644 index 0000000..193e3cd --- /dev/null +++ b/issues/0041-split-confidence-thresholds.md @@ -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. diff --git a/issues/0042-gliner2-unified-extractor.md b/issues/0042-gliner2-unified-extractor.md new file mode 100644 index 0000000..21122eb --- /dev/null +++ b/issues/0042-gliner2-unified-extractor.md @@ -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). diff --git a/issues/0042-mrebel-relation-extractor.md.superseded b/issues/0042-mrebel-relation-extractor.md.superseded new file mode 100644 index 0000000..8c32d46 --- /dev/null +++ b/issues/0042-mrebel-relation-extractor.md.superseded @@ -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 ... +``` + +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 ` ... `). +- 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/`