# Capability: nlp

Pipeline NLP para extraccion de entities/relations sobre documentos en castellano (proyecto OSINT principalmente). Cubre: lectura de PDFs (`extract_pdf_text`, `clean_pdf_text`), OCR fallback, chunking (`chunk_with_overlap`), inferencia GLiNER (NER) + GLiREL (relation extraction), dedup (`dedup_entities`, `dedup_relations`), agregacion (`aggregate_extraction_results`), extraccion de elementos especificos (URLs, crypto wallets, IPs, dominios).

## Funciones

| ID | Firma | Que hace |
|---|---|---|
| `aggregate_extraction_results_py_core` | `def aggregate_extraction_results(extract_results: list[dict]) -> dict` | Agrega entidades y relaciones de N resultados de extraccion por chunk. Deduplica entidades por (type, name_lowercased) acumulando counts. Deduplica relaciones por (head, rel_type, tail) con Counter. |
| `align_relations_to_entities_py_datascience` | `def align_relations_to_entities(triplets: list[dict], entity_names: list[str]) -> list[dict]` | Filtra y alinea triplets REBEL/mREBEL a nombres canonicos de entidades. Para cada triplet, resuelve head y tail contra entity_names con match exacto case-insensitive o substring (gana el nombre mas largo). Descarta triplets donde algun lado no resuelve o head==tail. |
| `chunk_with_overlap_py_core` | `def chunk_with_overlap(text: str, max_chars: int = 1500, overlap_sentences: int = 2) -> list[dict]` | Divide texto en chunks por sentence boundaries con sliding window overlap. Garantiza avance forzado si una frase supera max_chars (evita bucle infinito). Cada chunk retorna dict con 'text' y 'sentences'. |
| `clean_pdf_text_py_core` | `def clean_pdf_text(text: str) -> str` | Limpieza de artefactos PyPDF2/pdfplumber: elimina marcas de pagina (1/20), tabs, guiones de dehyphenation, saltos de linea en medio de oraciones y espacios duplicados. |
| `deduplicate_entities_py_datascience` | `def deduplicate_entities(candidates: list[EntityCandidate], name_threshold: float = 0.85, same_type_only: bool = True) -> DeduplicationResult` | Agrupa entidades candidatas que refieren a la misma entidad real usando fuzzy matching de nombres (Levenshtein + Jaccard) y Union-Find para clusters transitivos. Retorna entidades mergeadas con mapas de resolucion de IDs y log de merges. |
| `deduplicate_relations_py_datascience` | `def deduplicate_relations(relations: list[RelationCandidate], entity_id_map: dict[str, str]) -> list[RelationCandidate]` | Deduplica relaciones candidatas resolviendo from_name/to_name a entity IDs finales via entity_id_map. Descarta self-loops y relaciones sin match. Mergea duplicados (mismo from_id, to_id, relation_type) concatenando descripciones unicas y tomando max confidence. |
| `estimate_token_count_py_core` | `def estimate_token_count(content: str) -> int` | Estimacion rapida de tokens sin tokenizer. CJK chars cuentan ~0.7 token/char, otros non-whitespace ~0.3 token/char. |
| `extract_entities_gliner_py_datascience` | `def extract_entities_gliner(text: str, entity_schema: list[dict], model: Any, threshold: float = 0.5, flat_ner: bool = True) -> list[EntityCandidate]` | Extrae entidades zero-shot con GLiNER. Drop-in del contrato de extract_entities_llm pero 50-200x mas rapido y sin coste por token. El caller inyecta el modelo cargado con gliner_load_model. Anota offsets start/end en attributes para reconciliar con extract_iocs. |
| `extract_entities_llm_py_datascience` | `def extract_entities_llm(text: str, entity_schema: list[dict], llm_chat_json: Callable[[list[dict]], dict], language_instruction: str = 'Respond in English.') -> list[EntityCandidate]` | Extrae entidades de un chunk de texto usando un LLM inyectado. Construye el system prompt con el schema, llama al LLM y valida la respuesta retornando EntityCandidate. JSON invalido o type_ref fuera del schema se descartan con warning. |
| `extract_graph_from_text_py_pipelines` | `def extract_graph_from_text(text: str, entity_labels: list[str], relation_labels: list \| dict, allowed: dict, model: Any, threshold: float = 0.3, max_chars_per_chunk: int = 1500, overlap_sentences: int = 2) -> dict` | Pipeline E2E: texto -> grafo de entidades y relaciones. Orquesta chunking, extraccion con GLiNER2 por chunk, agregacion, filtrado tipado y resolucion de alias. Refactorizacion del playground del analisis gliner_glirel_tuning. |
| `extract_graph_gliner2_py_datascience` | `def extract_graph_gliner2(text: str, entity_labels: list[str], relation_labels: list \| dict, model: Any, threshold: float = 0.3, include_confidence: bool = False) -> dict` | Extrae entidades + relaciones en una sola pasada con GLiNER2. Wrapper de alto nivel: construye schema, ejecuta extraccion, normaliza a dict plano. No aplica post-filtrado ni coreference. |
| `extract_graph_hybrid_py_pipelines` | `def extract_graph_hybrid(chunks: list[str], entity_schema: list[dict], relation_types: list[str], gliner_model: Any, glirel_model: Any, llm_chat_json: Callable[[list[dict]], dict] \| None = None, ioc_types: list[str] \| None = None, confidence_threshold: float = 0.6, languages: str = 'Respond in Spanish.', min_entities_per_chunk: int = 2) -> tuple[list[EntityCandidate], list[RelationCandidate]]` | Pipeline hibrido en cascada que combina extract_iocs (regex, coste 0), GLiNER (zero-shot NER, coste bajo), GLiREL (zero-shot RE) y un LLM fallback opcional para chunks complejos o de baja confianza. Devuelve listas concatenadas listas para deduplicate_entities/deduplicate_relations. |
| `extract_relations_glirel_py_datascience` | `def extract_relations_glirel(text: str, entities: list[EntityCandidate], relation_types: list[str], model: Any, threshold: float = 0.5, max_pairs: int = 0) -> list[RelationCandidate]` | Extrae relaciones zero-shot con GLiREL. Drop-in del contrato de extract_relations_llm pero sin coste por token y mas rapido para corpus grandes. Tokeniza por whitespace, mapea spans de entidades (de attributes['start'/'end'] o fallback text.find) a indices de tokens, y devuelve RelationCandidate cuyos from_name/to_name siempre coinciden con entidades del input. |
| `extract_relations_llm_py_datascience` | `def extract_relations_llm(text: str, entities: list, relation_types: list[str], llm_chat_json: Callable[[list[dict]], dict], language_instruction: str = 'Respond in English.') -> list` | Extrae relaciones entre entidades de un chunk de texto usando un LLM inyectado. Valida que from_name y to_name correspondan a entidades existentes, y usa 'related_to' como fallback para tipos de relacion no permitidos. |
| `extract_relations_mrebel_py_datascience` | `def extract_relations_mrebel(text: str, entities: list[EntityCandidate], tokenizer: Any, model: Any, src_lang: str = 'es_XX', sentence_split_re: str = r'(?<=[.!?])\s+', min_sentence_chars: int = 20, num_beams: int = 4, max_length: int = 256) -> list[RelationCandidate]` | Extrae relaciones entre entidades usando mREBEL (seq2seq multilingue). Divide el texto por oraciones, genera triplets con mREBEL, parsea con parse_rebel_output y alinea a entidades conocidas con align_relations_to_entities. Drop-in con extract_relations_glirel para benchmarks. |
| `extract_triples_spacy_es_py_datascience` | `def extract_triples_spacy_es(text: str, nlp: Any) -> dict` | Extraccion OpenIE schema-less en castellano via reglas de dependencia spaCy. Detecta patrones sujeto-verbo-objeto con el lemma del verbo como relacion (sin vocabulario fijo). Tambien extrae entidades NER (PER, ORG, LOC, MISC). |
| `extraction_pipeline_py_pipelines` | `def extraction_pipeline(file_path: str, entity_presets: list[dict], relation_types: list[str], llm_chat_json: Callable[[list[dict]], dict], chunk_size: int = 500, chunk_overlap: int = 50, confidence_threshold: float = 0.5, dedup_threshold: float = 0.85, on_progress: Callable[[str, float], None] \| None = None) -> ExtractionResult` | Pipeline completa de extraccion de entidades y relaciones desde un documento. Orquesta extract_text_from_file -> preprocess_text -> split_text_into_chunks -> extract_entities_llm por chunk -> deduplicate_entities -> extract_relations_llm por chunk -> deduplicate_relations. |
| `filter_relations_by_entity_types_py_core` | `def filter_relations_by_entity_types(relations: dict, name_to_type: dict, allowed: dict) -> tuple[list, list]` | Post-filtrado tipado de relaciones NER+RE: descarta pares donde los tipos de entidad (head_type, tail_type) no coinciden con los permitidos por relation kind. Ej: descarta 'Madrid president_of Persona' porque Madrid es location no person. |
| `gliner2_load_model_py_datascience` | `def gliner2_load_model(model_name: str = 'fastino/gliner2-large-v1', device: str = 'auto') -> Any` | Carga (y cachea por (model_name, device)) un modelo GLiNER2 (NER+RE joint). GLiNER2 extrae entidades y relaciones en una sola pasada con schema unificado. ~2x mas rapido que GLiNER + GLiREL separados. LICENSE: Apache 2.0. |
| `gliner_load_model_py_datascience` | `def gliner_load_model(model_name: str = 'urchade/gliner_multi-v2.1', device: str = 'auto') -> Any` | Carga (y cachea por (model_name, device)) un modelo GLiNER zero-shot NER. La primera llamada descarga ~200 MB desde HuggingFace; sucesivas devuelven la instancia cacheada. device='auto' usa CUDA si esta disponible, o CPU. |
| `glirel_load_model_py_datascience` | `def glirel_load_model(model_name: str = 'jackboyla/glirel-large-v0', device: str = 'auto') -> Any` | Carga (y cachea por (model_name, device)) un modelo GLiREL zero-shot relation extraction. La primera llamada descarga ~500 MB desde HuggingFace; sucesivas devuelven la instancia cacheada. device='auto' usa CUDA si esta disponible, o CPU. |
| `marianmt_es_en_load_model_py_datascience` | `def marianmt_es_en_load_model(model_name: str = 'Helsinki-NLP/opus-mt-es-en') -> tuple[Any, Any]` | Carga (y cachea) el tokenizer y modelo MarianMT para traduccion ES->EN (Helsinki-NLP, ~300 MB). Licencia Apache 2.0. Cache por model_name. |
| `merge_entity_aliases_py_core` | `def merge_entity_aliases(entity_names: list[str]) -> dict[str, str]` | Coreference simple por normalizacion + substring: mapea cada nombre de entidad a su forma canonica. 'BBVA' y 'bbva' -> mismo canonical. Nombres cortos absorbidos por nombres largos que los contienen como palabra completa (min 4 chars normalizados). |
| `mrebel_base_load_model_py_datascience` | `def mrebel_base_load_model(model_name: str = 'Babelscape/mrebel-base', src_lang: str = 'es_XX', tgt_lang: str = 'tp_XX') -> tuple[Any, Any]` | Variante rapida de mrebel_load_model con checkpoint base (250M params, ~900 MB). Delega completamente en mrebel_load_model. Misma licencia CC BY-NC-SA 4.0 — solo uso no comercial. |
| `mrebel_load_model_py_datascience` | `def mrebel_load_model(model_name: str = 'Babelscape/mrebel-large', src_lang: str = 'es_XX', tgt_lang: str = 'tp_XX') -> tuple[Any, Any]` | Carga (y cachea) el tokenizer y modelo mREBEL (mBART-based, ~600M params, ~2.4 GB). Multilingue 30+ idiomas. Cache por (model_name, src_lang). Primera llamada descarga de HuggingFace. LICENCIA CC BY-NC-SA 4.0 — solo uso no comercial. |
| `normalize_entity_name_py_core` | `def normalize_entity_name(name: str, entity_type: str = "") -> str` | Normaliza el nombre de una entidad para comparacion y deduplicacion. Aplica reglas distintas segun el tipo: ip/email/domain/crypto_wallet/phone usan normalizacion tecnica; person remueve titulos y convierte formato Apellido-Nombre; organization elimina sufijos legales; default aplica lower+strip+colapso de espacios. |
| `parse_rebel_output_py_datascience` | `def parse_rebel_output(decoded_text: str) -> list[dict]` | Parser puro del wire format de REBEL / mREBEL. Convierte la cadena decoded por el tokenizer (con skip_special_tokens=False) a una lista de triplets tipados {head, head_type, type, tail, tail_type}. Nunca lanza excepcion. |
| `rebel_load_model_py_datascience` | `def rebel_load_model(model_name: str = 'Babelscape/rebel-large') -> tuple[Any, Any]` | Carga (y cachea) el tokenizer y modelo REBEL (BART-based, ~1.5 GB). Solo ingles. Licencia Apache 2.0 — uso comercial permitido. Cache por model_name. |
| `remove_words_from_column_py_datascience` | `def remove_words_from_column(values: Iterable[str \| None], words: list[str]) -> list[str]` | Elimina palabras especificas de un iterable de strings usando regex de palabra completa (\b). Case-insensitive. Colapsa espacios multiples y hace strip. None se convierte en cadena vacia. Sin pandas. |
| `spacy_es_load_model_py_datascience` | `def spacy_es_load_model(model_name: str = 'es_core_news_md') -> Any` | Carga (y cachea) un modelo spaCy en castellano. Provee POS, dependencias y NER (PER, ORG, LOC, MISC). Usado por extract_triples_spacy_es para OpenIE schema-less. LICENSE: spaCy MIT + es_core_news_md CC BY-SA 4.0. |
| `split_text_into_chunks_py_core` | `def split_text_into_chunks(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]` | Divide texto en chunks de tamaño fijo con overlap, intentando cortar en límites de oración para no romper frases a mitad. Soporta separadores CJK (。！？) y occidentales (. ! ?). |
| `translate_es_to_en_py_datascience` | `def translate_es_to_en(text: str, tokenizer: Any, model: Any, max_length: int = 512, num_beams: int = 4) -> str` | Traduce texto espanol a ingles frase a frase usando MarianMT. Divide por boundaries de oracion, traduce cada una independientemente y une con espacio. Preserva nombres propios mejor que pasar el parrafo entero. |
| `words_to_dataset_py_datascience` | `def words_to_dataset(texts: Iterable[str \| None], min_ocurrencias: int = 1, eliminar_stopwords: bool = False) -> list[dict]` | Extrae palabras y sus ocurrencias de un iterable de textos. Tokeniza con \b\w+\b, convierte a mayusculas, cuenta con Counter, filtra por minimo de ocurrencias y opcionalmente elimina stopwords en espanol. Sin pandas. |


## Ejemplo canonico

### Pipeline completo PDF -> entities + relations

```python
import os, sys
sys.path.insert(0, os.path.join(os.environ["FN_REGISTRY_ROOT"], "python", "functions"))

from core import extract_pdf_text, clean_pdf_text, chunk_with_overlap
from datascience import (
    gliner_extract_entities, glirel_extract_relations,
    dedup_entities, dedup_relations, aggregate_extraction_results,
)

raw = extract_pdf_text("/path/to/doc.pdf")
text = clean_pdf_text(raw)
chunks = chunk_with_overlap(text, size=512, overlap=64)

entities = []
relations = []
for ch in chunks:
    entities.extend(gliner_extract_entities(ch, labels=["PERSON","ORG","ACCOUNT"]))
    relations.extend(glirel_extract_relations(ch, entity_pairs=entities))

result = aggregate_extraction_results(
    entities=dedup_entities(entities),
    relations=dedup_relations(relations),
)
```

## Fronteras

- **NO entrena modelos**. Solo inferencia con GLiNER/GLiREL pre-entrenados (HuggingFace).
- **NO maneja embeddings densos** (sentence-transformers / e5). Para vectores, usa funciones del grupo `ml`.
- **NO hace traduccion ni summarization LLM**. Solo NER + RE. Para LLM, ver tag `llm`.
- **NO escribe a BD** automaticamente. La persistencia (vault, sqlite, parquet) la maneja el caller via funciones de `infra`/`datascience`.
