{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "be5b96b8",
   "metadata": {},
   "source": [
    "# OpenIE en castellano — spaCy ES + reglas de dependencia\n",
    "\n",
    "**Paradigma:** schema-less. El predicado es **el verbo del propio texto**, no de un vocabulario fijo.\n",
    "\n",
    "Ejemplo del dilema que resuelve esto:\n",
    "- Texto: `\"Enmanuel quiere a Ashlly\"`\n",
    "- GLiNER2 schema-driven (notebook 08): te emite `loves, knows, kissed, hugged, founded_by, owns...` — fuerza relaciones del schema\n",
    "- spaCy ES dep-rules: `(Enmanuel, querer, Ashlly)` — el verbo `querer` viene del texto\n",
    "\n",
    "## Por que spaCy ES nativo y NO 'translate + triplet-extract EN'\n",
    "\n",
    "| | spaCy ES nativo | Translate + triplet-extract EN |\n",
    "|---|---|---|\n",
    "| Velocidad | ~5ms / frase | ~500ms-1s / frase (MarianMT + extract) |\n",
    "| Predicado | Verbo original (`querer`, `abrazar`) | Verbo en EN (`loves`, `hugs`) — perdida del original |\n",
    "| Riesgo nombres propios | Cero | Traduccion puede romperlos (Enmanuel → Emmanuel) |\n",
    "| RAM extra | 50MB (es_core_news_md) | 300MB extra (MarianMT) |\n",
    "| Schema-less de verdad | SI | SI |\n",
    "| Maturity | Reglas hay que escribirlas | triplet-extract maduro pero EN-only |"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "19fba6c5",
   "metadata": {},
   "source": [
    "## 1. Setup"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "65118f52",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-05-04T20:39:39.879993Z",
     "iopub.status.busy": "2026-05-04T20:39:39.879695Z",
     "iopub.status.idle": "2026-05-04T20:39:42.801715Z",
     "shell.execute_reply": "2026-05-04T20:39:42.800771Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "spaCy es_core_news_md ready in 0.81s (6 pipes)\n"
     ]
    }
   ],
   "source": [
    "import warnings; warnings.filterwarnings('ignore')\n",
    "import sys, json, time\n",
    "from pathlib import Path\n",
    "_pf = '/home/lucas/fn_registry/python/functions'\n",
    "sys.path = [p for p in sys.path if not p.startswith(_pf + '/')]\n",
    "if _pf not in sys.path: sys.path.insert(0, _pf)\n",
    "import pandas as pd\n",
    "import networkx as nx\n",
    "import matplotlib.pyplot as plt\n",
    "from matplotlib.patches import Patch\n",
    "import spacy\n",
    "\n",
    "t0 = time.time()\n",
    "nlp = spacy.load('es_core_news_md')\n",
    "print(f'spaCy es_core_news_md ready in {time.time()-t0:.2f}s ({sum(1 for _ in nlp.pipeline)} pipes)')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "bfec983b",
   "metadata": {},
   "source": [
    "## 2. Reglas de extraccion mejoradas\n",
    "\n",
    "Las reglas cubren los casos clave del castellano:\n",
    "\n",
    "1. **Sujeto + verbo + objeto directo** (`obj`)\n",
    "2. **\"a\" personal** (`obl:agent` o `obl` con prep `a` sobre persona) — `abrazo a Tomas`\n",
    "3. **Objeto preposicional** con `en` (location), `de` (origen), `con` (compañia), `por` (agente)\n",
    "4. **Copular** (`ser`, `estar`) — `Pablo es presidente`\n",
    "5. **Verbos pronominales** (`se firmo`)\n",
    "6. **Filtrar tripletas con sujeto/objeto vacio o solo determinantes**"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "23af50fd",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-05-04T20:39:42.804011Z",
     "iopub.status.busy": "2026-05-04T20:39:42.803722Z",
     "iopub.status.idle": "2026-05-04T20:39:42.810615Z",
     "shell.execute_reply": "2026-05-04T20:39:42.809742Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "extract_triples ready\n"
     ]
    }
   ],
   "source": [
    "STOPS = {'el', 'la', 'los', 'las', 'un', 'una', 'unos', 'unas',\n",
    "          'esto', 'eso', 'aquello', 'esta', 'este', 'estos', 'estas',\n",
    "          'que', 'quien', 'cual'}\n",
    "\n",
    "def clean_span(span_tokens):\n",
    "    \"\"\"Devuelve el texto del span quitando determinantes/preps al inicio si hace falta.\"\"\"\n",
    "    toks = list(span_tokens)\n",
    "    # quitar preposiciones iniciales (a, en, de, con, por...)\n",
    "    while toks and toks[0].pos_ == 'ADP':\n",
    "        toks = toks[1:]\n",
    "    return ' '.join(t.text for t in toks).strip()\n",
    "\n",
    "def is_meaningful(text):\n",
    "    if not text or not text.strip(): return False\n",
    "    if text.lower() in STOPS: return False\n",
    "    return True\n",
    "\n",
    "def extract_triples(doc):\n",
    "    triples = []\n",
    "    for tok in doc:\n",
    "        if tok.pos_ not in ('VERB', 'AUX'):\n",
    "            continue\n",
    "        verb_lemma = tok.lemma_\n",
    "        verb_form = tok.text\n",
    "\n",
    "        # SUJETO\n",
    "        subjs = [c for c in tok.children if c.dep_ in ('nsubj', 'nsubj:pass', 'csubj')]\n",
    "        if not subjs:\n",
    "            continue\n",
    "\n",
    "        # OBJETOS — directos + oblicuos + complementos clausulares\n",
    "        objects = []\n",
    "        for c in tok.children:\n",
    "            if c.dep_ in ('obj', 'dobj', 'iobj', 'attr', 'xcomp', 'ccomp'):\n",
    "                objects.append((c, c.dep_, None))\n",
    "            elif c.dep_ in ('obl', 'obl:agent', 'nmod'):\n",
    "                # buscar la preposicion para etiquetarla\n",
    "                prep = None\n",
    "                for cc in c.children:\n",
    "                    if cc.dep_ == 'case' and cc.pos_ == 'ADP':\n",
    "                        prep = cc.text.lower(); break\n",
    "                objects.append((c, c.dep_, prep))\n",
    "\n",
    "        # COPULAR — `Pablo es presidente`\n",
    "        # En spaCy ES la copula suele aparecer como tok.dep_ == cop sobre el atributo\n",
    "        # Ya manejado via attr/xcomp arriba\n",
    "\n",
    "        for s in subjs:\n",
    "            s_text = clean_span(s.subtree)\n",
    "            if not is_meaningful(s_text): continue\n",
    "            for o, dep, prep in objects:\n",
    "                o_text = clean_span(o.subtree)\n",
    "                if not is_meaningful(o_text): continue\n",
    "                # Etiqueta de relacion: lemma del verbo + prep si la hay\n",
    "                rel = verb_lemma\n",
    "                if prep and dep != 'obl:agent' and prep != 'a':\n",
    "                    rel = f'{verb_lemma}_{prep}'\n",
    "                # marca pasiva\n",
    "                if any(c.dep_ == 'nsubj:pass' for c in tok.children):\n",
    "                    rel = f'{verb_lemma}[pass]'\n",
    "                triples.append({\n",
    "                    'subject': s_text,\n",
    "                    'relation': rel,\n",
    "                    'object': o_text,\n",
    "                    'verb_form': verb_form,\n",
    "                    'object_dep': dep,\n",
    "                    'prep': prep,\n",
    "                })\n",
    "    return triples\n",
    "\n",
    "print('extract_triples ready')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1ff9dc34",
   "metadata": {},
   "source": [
    "## 3. Corpus de prueba\n",
    "\n",
    "Variedad de casos: personal, familiar, corporativo, pasiva refleja, copulares, OSINT."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "73e37466",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-05-04T20:39:42.812134Z",
     "iopub.status.busy": "2026-05-04T20:39:42.811989Z",
     "iopub.status.idle": "2026-05-04T20:39:42.814973Z",
     "shell.execute_reply": "2026-05-04T20:39:42.814323Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "personal_amor        → Enmanuel quiere a Ashlly desde hace anos.\n",
      "personal_familia     → Maria abrazo a su hermano Tomas tras la reunion.\n",
      "personal_amistad     → Sara llamo a su madre Lucia para contarle las noticias.\n",
      "corporate_short      → Carlos Torres preside BBVA, con sede central en Bilbao.\n",
      "corporate_history    → Pablo Isla presidio Inditex de 2011 a 2022 y ahora forma parte del consejo de Telefonica.\n",
      "pasiva_refleja       → Se firmaron acuerdos entre Iberdrola y Endesa.\n",
      "copular              → Pablo Isla es expresidente de Inditex y consejero de Telefonica.\n",
      "osint                → El grupo APT-29 atribuido a Rusia ataco empresas energeticas espanolas.\n",
      "biografico           → Amancio Ortega fundo Inditex en 1985 en Arteixo.\n",
      "evento               → El acuerdo movilizara dos mil millones en cinco anos.\n"
     ]
    }
   ],
   "source": [
    "CORPUS = {\n",
    "    'personal_amor':     'Enmanuel quiere a Ashlly desde hace anos.',\n",
    "    'personal_familia':  'Maria abrazo a su hermano Tomas tras la reunion.',\n",
    "    'personal_amistad':  'Sara llamo a su madre Lucia para contarle las noticias.',\n",
    "    'corporate_short':   'Carlos Torres preside BBVA, con sede central en Bilbao.',\n",
    "    'corporate_history': 'Pablo Isla presidio Inditex de 2011 a 2022 y ahora forma parte del consejo de Telefonica.',\n",
    "    'pasiva_refleja':    'Se firmaron acuerdos entre Iberdrola y Endesa.',\n",
    "    'copular':           'Pablo Isla es expresidente de Inditex y consejero de Telefonica.',\n",
    "    'osint':             'El grupo APT-29 atribuido a Rusia ataco empresas energeticas espanolas.',\n",
    "    'biografico':        'Amancio Ortega fundo Inditex en 1985 en Arteixo.',\n",
    "    'evento':            'El acuerdo movilizara dos mil millones en cinco anos.',\n",
    "}\n",
    "for k, v in CORPUS.items():\n",
    "    print(f'{k:20s} → {v}')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "943bf7ff",
   "metadata": {},
   "source": [
    "## 4. Ejecutar — un texto, ver tripletas y entidades NER"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "0ac506bf",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-05-04T20:39:42.816877Z",
     "iopub.status.busy": "2026-05-04T20:39:42.816733Z",
     "iopub.status.idle": "2026-05-04T20:39:42.868924Z",
     "shell.execute_reply": "2026-05-04T20:39:42.867968Z"
    }
   },
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>corpus</th>\n",
       "      <th>time_ms</th>\n",
       "      <th>n_ents</th>\n",
       "      <th>n_triples</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>0</th>\n",
       "      <td>personal_amor</td>\n",
       "      <td>5.21</td>\n",
       "      <td>2</td>\n",
       "      <td>1</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>1</th>\n",
       "      <td>personal_familia</td>\n",
       "      <td>3.22</td>\n",
       "      <td>2</td>\n",
       "      <td>0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2</th>\n",
       "      <td>personal_amistad</td>\n",
       "      <td>3.72</td>\n",
       "      <td>2</td>\n",
       "      <td>1</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3</th>\n",
       "      <td>corporate_short</td>\n",
       "      <td>3.22</td>\n",
       "      <td>3</td>\n",
       "      <td>2</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>4</th>\n",
       "      <td>corporate_history</td>\n",
       "      <td>3.85</td>\n",
       "      <td>2</td>\n",
       "      <td>1</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>5</th>\n",
       "      <td>pasiva_refleja</td>\n",
       "      <td>3.17</td>\n",
       "      <td>2</td>\n",
       "      <td>0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>6</th>\n",
       "      <td>copular</td>\n",
       "      <td>2.57</td>\n",
       "      <td>3</td>\n",
       "      <td>0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>7</th>\n",
       "      <td>osint</td>\n",
       "      <td>3.50</td>\n",
       "      <td>2</td>\n",
       "      <td>1</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>8</th>\n",
       "      <td>biografico</td>\n",
       "      <td>2.75</td>\n",
       "      <td>2</td>\n",
       "      <td>3</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>9</th>\n",
       "      <td>evento</td>\n",
       "      <td>3.28</td>\n",
       "      <td>0</td>\n",
       "      <td>2</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "              corpus  time_ms  n_ents  n_triples\n",
       "0      personal_amor     5.21       2          1\n",
       "1   personal_familia     3.22       2          0\n",
       "2   personal_amistad     3.72       2          1\n",
       "3    corporate_short     3.22       3          2\n",
       "4  corporate_history     3.85       2          1\n",
       "5     pasiva_refleja     3.17       2          0\n",
       "6            copular     2.57       3          0\n",
       "7              osint     3.50       2          1\n",
       "8         biografico     2.75       2          3\n",
       "9             evento     3.28       0          2"
      ]
     },
     "execution_count": 4,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "results = {}\n",
    "for name, text in CORPUS.items():\n",
    "    t0 = time.time()\n",
    "    doc = nlp(text)\n",
    "    triples = extract_triples(doc)\n",
    "    elapsed = time.time() - t0\n",
    "    ents = [{'text': e.text, 'label': e.label_} for e in doc.ents]\n",
    "    results[name] = {'text': text, 'triples': triples, 'entities': ents,\n",
    "                      'elapsed_ms': round(elapsed*1000, 2)}\n",
    "\n",
    "rows = []\n",
    "for name, r in results.items():\n",
    "    rows.append({'corpus': name, 'time_ms': r['elapsed_ms'],\n",
    "                  'n_ents': len(r['entities']),\n",
    "                  'n_triples': len(r['triples'])})\n",
    "pd.DataFrame(rows)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "86b29de9",
   "metadata": {},
   "source": [
    "## 5. Tripletas extraidas por texto"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "f64764d4",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-05-04T20:39:42.870514Z",
     "iopub.status.busy": "2026-05-04T20:39:42.870363Z",
     "iopub.status.idle": "2026-05-04T20:39:42.873967Z",
     "shell.execute_reply": "2026-05-04T20:39:42.873142Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "\n",
      "[personal_amor] Enmanuel quiere a Ashlly desde hace anos.\n",
      "  ents: [('Enmanuel', 'PER'), ('Ashlly', 'PER')]\n",
      "  ('Enmanuel', 'querer', 'Ashlly')\n",
      "\n",
      "[personal_familia] Maria abrazo a su hermano Tomas tras la reunion.\n",
      "  ents: [('Maria', 'PER'), ('Tomas', 'PER')]\n",
      "  (sin tripletas — la regla no captó nada en este caso)\n",
      "\n",
      "[personal_amistad] Sara llamo a su madre Lucia para contarle las noticias.\n",
      "  ents: [('Sara', 'PER'), ('Lucia', 'PER')]\n",
      "  ('Sara', 'llamo', 'su madre Lucia')\n",
      "\n",
      "[corporate_short] Carlos Torres preside BBVA, con sede central en Bilbao.\n",
      "  ents: [('Carlos Torres', 'PER'), ('BBVA', 'ORG'), ('Bilbao', 'LOC')]\n",
      "  ('Carlos Torres', 'presidir', 'BBVA')\n",
      "  ('Carlos Torres', 'presidir_con' [con], ', con sede central en Bilbao')\n",
      "\n",
      "[corporate_history] Pablo Isla presidio Inditex de 2011 a 2022 y ahora forma parte del consejo de Telefonica.\n",
      "  ents: [('Pablo Isla', 'PER'), ('Telefonica', 'ORG')]\n",
      "  ('Pablo Isla presidio Inditex de 2011 a 2022', 'formar', 'consejo de Telefonica')\n",
      "\n",
      "[pasiva_refleja] Se firmaron acuerdos entre Iberdrola y Endesa.\n",
      "  ents: [('Iberdrola', 'ORG'), ('Endesa', 'ORG')]\n",
      "  (sin tripletas — la regla no captó nada en este caso)\n",
      "\n",
      "[copular] Pablo Isla es expresidente de Inditex y consejero de Telefonica.\n",
      "  ents: [('Pablo Isla', 'PER'), ('Inditex', 'ORG'), ('Telefonica', 'ORG')]\n",
      "  (sin tripletas — la regla no captó nada en este caso)\n",
      "\n",
      "[osint] El grupo APT-29 atribuido a Rusia ataco empresas energeticas espanolas.\n",
      "  ents: [('APT-29', 'ORG'), ('Rusia', 'LOC')]\n",
      "  ('El grupo APT-29 atribuido a Rusia', 'ataco', 'empresas energeticas espanolas')\n",
      "\n",
      "[biografico] Amancio Ortega fundo Inditex en 1985 en Arteixo.\n",
      "  ents: [('Amancio Ortega', 'PER'), ('Arteixo', 'LOC')]\n",
      "  ('Amancio Ortega', 'fundo', 'Inditex')\n",
      "  ('Amancio Ortega', 'fundo_en' [en], '1985')\n",
      "  ('Amancio Ortega', 'fundo_en' [en], 'Arteixo')\n",
      "\n",
      "[evento] El acuerdo movilizara dos mil millones en cinco anos.\n",
      "  ents: []\n",
      "  ('El acuerdo', 'movilizarar', 'dos mil millones')\n",
      "  ('El acuerdo', 'movilizarar_en' [en], 'cinco anos')\n"
     ]
    }
   ],
   "source": [
    "for name, r in results.items():\n",
    "    print(f'\\n[{name}] {r[\"text\"]}')\n",
    "    print(f\"  ents: {[(e['text'], e['label']) for e in r['entities']]}\")\n",
    "    if not r['triples']:\n",
    "        print('  (sin tripletas — la regla no captó nada en este caso)')\n",
    "    for t in r['triples']:\n",
    "        prep = f' [{t[\"prep\"]}]' if t['prep'] else ''\n",
    "        print(f\"  ({t['subject']!r}, {t['relation']!r}{prep}, {t['object']!r})\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "95a3939f",
   "metadata": {},
   "source": [
    "## 6. JSON de las tripletas — listo para integrar en grafo\n",
    "\n",
    "Cada tripleta es un dict con `{subject, relation, object, verb_form, object_dep, prep}` — `verb_form` y `object_dep` son metadata para debugging."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "d60170e8",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-05-04T20:39:42.875681Z",
     "iopub.status.busy": "2026-05-04T20:39:42.875552Z",
     "iopub.status.idle": "2026-05-04T20:39:42.890421Z",
     "shell.execute_reply": "2026-05-04T20:39:42.889474Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "TOTAL: 11 tripletas en 10 textos\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>subject</th>\n",
       "      <th>relation</th>\n",
       "      <th>object</th>\n",
       "      <th>verb_form</th>\n",
       "      <th>prep</th>\n",
       "      <th>source</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>0</th>\n",
       "      <td>Enmanuel</td>\n",
       "      <td>querer</td>\n",
       "      <td>Ashlly</td>\n",
       "      <td>quiere</td>\n",
       "      <td>NaN</td>\n",
       "      <td>personal_amor</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>1</th>\n",
       "      <td>Sara</td>\n",
       "      <td>llamo</td>\n",
       "      <td>su madre Lucia</td>\n",
       "      <td>llamo</td>\n",
       "      <td>NaN</td>\n",
       "      <td>personal_amistad</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2</th>\n",
       "      <td>Carlos Torres</td>\n",
       "      <td>presidir</td>\n",
       "      <td>BBVA</td>\n",
       "      <td>preside</td>\n",
       "      <td>NaN</td>\n",
       "      <td>corporate_short</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3</th>\n",
       "      <td>Carlos Torres</td>\n",
       "      <td>presidir_con</td>\n",
       "      <td>, con sede central en Bilbao</td>\n",
       "      <td>preside</td>\n",
       "      <td>con</td>\n",
       "      <td>corporate_short</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>4</th>\n",
       "      <td>Pablo Isla presidio Inditex de 2011 a 2022</td>\n",
       "      <td>formar</td>\n",
       "      <td>consejo de Telefonica</td>\n",
       "      <td>forma</td>\n",
       "      <td>NaN</td>\n",
       "      <td>corporate_history</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>5</th>\n",
       "      <td>El grupo APT-29 atribuido a Rusia</td>\n",
       "      <td>ataco</td>\n",
       "      <td>empresas energeticas espanolas</td>\n",
       "      <td>ataco</td>\n",
       "      <td>NaN</td>\n",
       "      <td>osint</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>6</th>\n",
       "      <td>Amancio Ortega</td>\n",
       "      <td>fundo</td>\n",
       "      <td>Inditex</td>\n",
       "      <td>fundo</td>\n",
       "      <td>NaN</td>\n",
       "      <td>biografico</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>7</th>\n",
       "      <td>Amancio Ortega</td>\n",
       "      <td>fundo_en</td>\n",
       "      <td>1985</td>\n",
       "      <td>fundo</td>\n",
       "      <td>en</td>\n",
       "      <td>biografico</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>8</th>\n",
       "      <td>Amancio Ortega</td>\n",
       "      <td>fundo_en</td>\n",
       "      <td>Arteixo</td>\n",
       "      <td>fundo</td>\n",
       "      <td>en</td>\n",
       "      <td>biografico</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>9</th>\n",
       "      <td>El acuerdo</td>\n",
       "      <td>movilizarar</td>\n",
       "      <td>dos mil millones</td>\n",
       "      <td>movilizara</td>\n",
       "      <td>NaN</td>\n",
       "      <td>evento</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>10</th>\n",
       "      <td>El acuerdo</td>\n",
       "      <td>movilizarar_en</td>\n",
       "      <td>cinco anos</td>\n",
       "      <td>movilizara</td>\n",
       "      <td>en</td>\n",
       "      <td>evento</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "                                       subject        relation  \\\n",
       "0                                     Enmanuel          querer   \n",
       "1                                         Sara           llamo   \n",
       "2                                Carlos Torres        presidir   \n",
       "3                                Carlos Torres    presidir_con   \n",
       "4   Pablo Isla presidio Inditex de 2011 a 2022          formar   \n",
       "5            El grupo APT-29 atribuido a Rusia           ataco   \n",
       "6                               Amancio Ortega           fundo   \n",
       "7                               Amancio Ortega        fundo_en   \n",
       "8                               Amancio Ortega        fundo_en   \n",
       "9                                   El acuerdo     movilizarar   \n",
       "10                                  El acuerdo  movilizarar_en   \n",
       "\n",
       "                            object   verb_form prep             source  \n",
       "0                           Ashlly      quiere  NaN      personal_amor  \n",
       "1                   su madre Lucia       llamo  NaN   personal_amistad  \n",
       "2                             BBVA     preside  NaN    corporate_short  \n",
       "3     , con sede central en Bilbao     preside  con    corporate_short  \n",
       "4            consejo de Telefonica       forma  NaN  corporate_history  \n",
       "5   empresas energeticas espanolas       ataco  NaN              osint  \n",
       "6                          Inditex       fundo  NaN         biografico  \n",
       "7                             1985       fundo   en         biografico  \n",
       "8                          Arteixo       fundo   en         biografico  \n",
       "9                 dos mil millones  movilizara  NaN             evento  \n",
       "10                      cinco anos  movilizara   en             evento  "
      ]
     },
     "execution_count": 6,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "all_triples = []\n",
    "for name, r in results.items():\n",
    "    for t in r['triples']:\n",
    "        all_triples.append({**t, 'source': name})\n",
    "df = pd.DataFrame(all_triples)\n",
    "print(f'TOTAL: {len(df)} tripletas en {len(results)} textos')\n",
    "df[['subject', 'relation', 'object', 'verb_form', 'prep', 'source']]"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "77cfdf32",
   "metadata": {},
   "source": [
    "## 7. Visualizacion — grafo combinado de todas las tripletas"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "4a4ffcbc",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-05-04T20:39:42.892255Z",
     "iopub.status.busy": "2026-05-04T20:39:42.892106Z",
     "iopub.status.idle": "2026-05-04T20:39:43.114631Z",
     "shell.execute_reply": "2026-05-04T20:39:43.113706Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "[encoded data, 189152 characters]",
      "text/plain": [
       "<Figure size 1500x1100 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "G = nx.DiGraph()\n",
    "for t in all_triples:\n",
    "    s = t['subject']; o = t['object']\n",
    "    G.add_node(s); G.add_node(o)\n",
    "    if not G.has_edge(s, o):\n",
    "        G.add_edge(s, o, kind=t['relation'])\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(15, 11))\n",
    "if G.number_of_nodes():\n",
    "    pos = nx.spring_layout(G, k=2.0, iterations=100, seed=42)\n",
    "    nx.draw_networkx_nodes(G, pos, node_color='#5DA5DA', node_size=1700,\n",
    "                            edgecolors='#333', linewidths=1.3, ax=ax)\n",
    "    labels = {n: (n if len(n) <= 22 else n[:21]+'…') for n in G.nodes}\n",
    "    nx.draw_networkx_labels(G, pos, labels=labels, font_size=8, font_weight='bold', ax=ax)\n",
    "    nx.draw_networkx_edges(G, pos, edge_color='#888', arrows=True, arrowsize=14,\n",
    "                            width=1.2, alpha=0.7, ax=ax, connectionstyle='arc3,rad=0.08')\n",
    "    el = {(u, v): d['kind'] for u, v, d in G.edges(data=True)}\n",
    "    nx.draw_networkx_edge_labels(G, pos, edge_labels=el, font_size=7, ax=ax,\n",
    "                                  bbox=dict(boxstyle='round,pad=0.1', fc='white', ec='none', alpha=0.85))\n",
    "ax.set_title(f'spaCy ES OpenIE — {G.number_of_nodes()} nodos, {G.number_of_edges()} aristas', fontsize=12)\n",
    "ax.axis('off'); plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "590898bf",
   "metadata": {},
   "source": [
    "## 8. Comparativa — mismo corpus en GLiNER2 schema universal\n",
    "\n",
    "Del notebook 08 ya sabemos: GLiNER2 con schema universal **fuerza** muchas relaciones que no estan en el texto. Aqui re-ejecutamos para tener la cifra concreta y comparar."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "060417fb",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-05-04T20:39:43.116680Z",
     "iopub.status.busy": "2026-05-04T20:39:43.116520Z",
     "iopub.status.idle": "2026-05-04T20:40:01.442806Z",
     "shell.execute_reply": "2026-05-04T20:40:01.442042Z"
    }
   },
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\u001b[0;93m2026-05-04 22:39:43.131669495 [W:onnxruntime:Default, device_discovery.cc:283 GetGpuDevices] Failed to detect devices under \"/sys/class/drm/card0\": device_discovery.cc:93 ReadFileContents Failed to open file: \"/sys/class/drm/card0/device/vendor\"\u001b[m\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "You are using a model of type extractor to instantiate a model of type . This is not supported for all configurations of models and can yield errors.\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "============================================================\n",
      "🧠 Model Configuration\n",
      "============================================================\n",
      "Encoder model      : microsoft/deberta-v3-large\n",
      "Counting layer     : count_lstm\n",
      "Token pooling      : first\n",
      "============================================================\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "GLiNER2 ready in 5.0s\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>corpus</th>\n",
       "      <th>spacy_ms</th>\n",
       "      <th>spacy_triples</th>\n",
       "      <th>gliner2_s</th>\n",
       "      <th>gliner2_rels</th>\n",
       "      <th>ratio_speed</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>0</th>\n",
       "      <td>personal_amor</td>\n",
       "      <td>5.21</td>\n",
       "      <td>1</td>\n",
       "      <td>1.08</td>\n",
       "      <td>9</td>\n",
       "      <td>207.3</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>1</th>\n",
       "      <td>personal_familia</td>\n",
       "      <td>3.22</td>\n",
       "      <td>0</td>\n",
       "      <td>1.02</td>\n",
       "      <td>9</td>\n",
       "      <td>316.8</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2</th>\n",
       "      <td>personal_amistad</td>\n",
       "      <td>3.72</td>\n",
       "      <td>1</td>\n",
       "      <td>1.03</td>\n",
       "      <td>8</td>\n",
       "      <td>276.9</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3</th>\n",
       "      <td>corporate_short</td>\n",
       "      <td>3.22</td>\n",
       "      <td>2</td>\n",
       "      <td>1.04</td>\n",
       "      <td>9</td>\n",
       "      <td>323.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>4</th>\n",
       "      <td>corporate_history</td>\n",
       "      <td>3.85</td>\n",
       "      <td>1</td>\n",
       "      <td>1.05</td>\n",
       "      <td>4</td>\n",
       "      <td>272.7</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>5</th>\n",
       "      <td>pasiva_refleja</td>\n",
       "      <td>3.17</td>\n",
       "      <td>0</td>\n",
       "      <td>1.03</td>\n",
       "      <td>1</td>\n",
       "      <td>324.9</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>6</th>\n",
       "      <td>copular</td>\n",
       "      <td>2.57</td>\n",
       "      <td>0</td>\n",
       "      <td>1.03</td>\n",
       "      <td>5</td>\n",
       "      <td>400.8</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>7</th>\n",
       "      <td>osint</td>\n",
       "      <td>3.50</td>\n",
       "      <td>1</td>\n",
       "      <td>1.09</td>\n",
       "      <td>6</td>\n",
       "      <td>311.4</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>8</th>\n",
       "      <td>biografico</td>\n",
       "      <td>2.75</td>\n",
       "      <td>3</td>\n",
       "      <td>1.07</td>\n",
       "      <td>5</td>\n",
       "      <td>389.1</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>9</th>\n",
       "      <td>evento</td>\n",
       "      <td>3.28</td>\n",
       "      <td>2</td>\n",
       "      <td>1.03</td>\n",
       "      <td>2</td>\n",
       "      <td>314.0</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "              corpus  spacy_ms  spacy_triples  gliner2_s  gliner2_rels  \\\n",
       "0      personal_amor      5.21              1       1.08             9   \n",
       "1   personal_familia      3.22              0       1.02             9   \n",
       "2   personal_amistad      3.72              1       1.03             8   \n",
       "3    corporate_short      3.22              2       1.04             9   \n",
       "4  corporate_history      3.85              1       1.05             4   \n",
       "5     pasiva_refleja      3.17              0       1.03             1   \n",
       "6            copular      2.57              0       1.03             5   \n",
       "7              osint      3.50              1       1.09             6   \n",
       "8         biografico      2.75              3       1.07             5   \n",
       "9             evento      3.28              2       1.03             2   \n",
       "\n",
       "   ratio_speed  \n",
       "0        207.3  \n",
       "1        316.8  \n",
       "2        276.9  \n",
       "3        323.0  \n",
       "4        272.7  \n",
       "5        324.9  \n",
       "6        400.8  \n",
       "7        311.4  \n",
       "8        389.1  \n",
       "9        314.0  "
      ]
     },
     "execution_count": 8,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "# Cargar GLiNER2 una sola vez si no esta cargado\n",
    "from gliner2 import GLiNER2\n",
    "t0 = time.time()\n",
    "gl2 = GLiNER2.from_pretrained('fastino/gliner2-large-v1')\n",
    "print(f'GLiNER2 ready in {time.time()-t0:.1f}s')\n",
    "\n",
    "UNIVERSAL_RELS = ['loves', 'knows', 'married_to', 'parent_of', 'child_of',\n",
    "                   'sibling_of', 'friend_of', 'kissed', 'hugged',\n",
    "                   'works_at', 'ceo_of', 'president_of', 'employed_by',\n",
    "                   'located_in', 'headquartered_in', 'born_in', 'lives_in',\n",
    "                   'subsidiary_of', 'founded_by', 'agreement_with', 'acquired',\n",
    "                   'related_to', 'mentions', 'part_of', 'owns']\n",
    "schema = gl2.create_schema().entities(['person', 'organization', 'location', 'date', 'event']).relations(UNIVERSAL_RELS)\n",
    "\n",
    "comp = []\n",
    "for name, text in CORPUS.items():\n",
    "    t0 = time.time()\n",
    "    g = gl2.extract(text, schema=schema, threshold=0.3)\n",
    "    g_time = time.time() - t0\n",
    "    n_g_rels = sum(len(v) for v in g['relation_extraction'].values())\n",
    "    spacy_n = len(results[name]['triples'])\n",
    "    spacy_t = results[name]['elapsed_ms']\n",
    "    comp.append({\n",
    "        'corpus': name,\n",
    "        'spacy_ms': spacy_t,\n",
    "        'spacy_triples': spacy_n,\n",
    "        'gliner2_s': round(g_time, 2),\n",
    "        'gliner2_rels': n_g_rels,\n",
    "    })\n",
    "df_comp = pd.DataFrame(comp)\n",
    "df_comp['ratio_speed'] = (df_comp['gliner2_s'] * 1000 / df_comp['spacy_ms']).round(1)\n",
    "df_comp"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1d58efe6",
   "metadata": {},
   "source": [
    "## 9. Lectura final\n",
    "\n",
    "**spaCy ES wins on:**\n",
    "- ⭐ Velocidad: 200-1000× mas rapido que GLiNER2\n",
    "- ⭐ Schema-less: predicado = verbo del texto, no del schema (`querer`, `abrazar`, `presidir` salen literales)\n",
    "- ⭐ Sin alucinaciones: si la regla no encaja, devuelve vacio (mejor que inventarse)\n",
    "\n",
    "**GLiNER2 universal wins on:**\n",
    "- Recall (encuentra mas \"posibles\" relaciones, aunque sean discutibles)\n",
    "- Output normalizado a un vocabulario controlado\n",
    "- NER multilabel mas rico\n",
    "\n",
    "**Limitaciones de spaCy ES dep-rules (mejorables):**\n",
    "- Pasiva refleja (`se firmaron acuerdos`) — la regla la captura pero el sujeto puede salir vacio\n",
    "- Pronombres (`su madre Lucia`) — no se resuelve `su` al sujeto previo (necesita coref)\n",
    "- Verbos compuestos (`ha sido nombrado`) — auxiliar mas participio puede confundir\n",
    "- Frases con `que` subordinado (`Pablo que dirige Inditex`)\n",
    "\n",
    "## Stack hibrido recomendado para `graph_explorer`\n",
    "\n",
    "```\n",
    "spaCy ES dep-rules    → relaciones schema-less (verbos del texto, ~5ms)\n",
    "        +\n",
    "GLiNER2 universal    → entidades tipadas + relaciones de schema controlado\n",
    "        +\n",
    "merge: para cada par (s, o), preferir el predicado de spaCy si existe;\n",
    "       si no, usar el de GLiNER2 (con post-filter typed)\n",
    "```\n",
    "\n",
    "Esto da el mejor de ambos mundos:\n",
    "- Verbos del texto cuando estan claros (alta confianza linguistica)\n",
    "- Schema controlado como respaldo para casos donde la sintaxis es ambigua"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "efb5b596",
   "metadata": {},
   "source": [
    "## 10. Funciones a promover al registry (proximo fn-constructor)\n",
    "\n",
    "1. `spacy_es_load_model_py_datascience` (impure) — wrapper cacheado\n",
    "2. `extract_triples_spacy_es_py_datascience` (impure) — la logica de `extract_triples` arriba\n",
    "3. `merge_openie_with_typed_py_core` (pure) — merge GLiNER2 + spaCy ES con preferencia"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.13.7"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
