"""Construye notebooks/02_e2e_spanish_graph.ipynb — E2E con texto castellano,
extract_graph_hybrid y visualizacion del grafo dentro del propio notebook.
"""
from __future__ import annotations

from pathlib import Path

import nbformat as nbf

HERE = Path(__file__).resolve().parent
NB_PATH = HERE / "notebooks" / "02_e2e_spanish_graph.ipynb"


def _md(text: str):
    return nbf.v4.new_markdown_cell(text)


def _code(src: str):
    cell = nbf.v4.new_code_cell(src)
    cell.outputs = []
    cell.execution_count = None
    return cell


SPANISH_TEXT = (
    "Pablo Isla, expresidente de Inditex, ha sido nombrado consejero de Telefonica. "
    "La operacion fue anunciada por el presidente Jose Maria Alvarez-Pallete en Madrid el pasado lunes. "
    "Inditex factura mas de 30.000 millones anuales y tiene su sede en Arteixo, A Coruna.\n\n"
    "En paralelo, Iberdrola y Endesa firmaron un acuerdo de colaboracion en proyectos eolicos en Galicia. "
    "El presidente de Iberdrola, Ignacio Galan, se reunio con la CEO de Endesa, Marina Serrano, en Bilbao. "
    "El acuerdo movilizara 2.000 millones de euros en cinco anos.\n\n"
    "El BBVA, presidido por Carlos Torres, mostro interes en participar en la financiacion del proyecto. "
    "Su sede central esta en Bilbao."
)


def build():
    cells = []

    cells.append(_md(
        "# E2E — texto castellano → grafo en el notebook\n\n"
        "Validacion end-to-end del flujo del panel _Paste & Extract_ usando los thresholds "
        "calibrados en `01_gliner_glirel_tuning.ipynb`:\n\n"
        "- `entity_threshold = 0.50`\n"
        "- `relation_threshold = 0.15`\n"
        "- `relation_labels` en snake_case corto\n\n"
        "Pegamos un texto en castellano sobre el sector empresarial espanol, corremos el pipeline "
        "`extract_graph_hybrid`, y dibujamos el grafo resultante con `networkx + matplotlib`."
    ))

    cells.append(_md("## 1. Setup"))

    cells.append(_code(
        "import os, sys, json, warnings\n"
        "warnings.filterwarnings('ignore')\n"
        "os.environ.setdefault('HF_HUB_DISABLE_PROGRESS_BARS', '1')\n"
        "from pathlib import Path\n"
        "\n"
        "# Limpiar sys.path: el startup del kernel anade cada subdir de\n"
        "# python/functions/, y bigquery/datasets.py sombrea al paquete\n"
        "# `datasets` de HuggingFace. Dejamos solo el padre para imports\n"
        "# 'from datascience...' / 'from pipelines...' al estilo paquete.\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:\n"
        "    sys.path.insert(0, _pf)\n"
        "\n"
        "import pandas as pd\n"
        "import networkx as nx\n"
        "import matplotlib.pyplot as plt\n"
        "from datascience.gliner_load_model import gliner_load_model\n"
        "from datascience.glirel_load_model import glirel_load_model\n"
        "from pipelines.extract_graph_hybrid import extract_graph_hybrid\n"
        "print('imports OK')"
    ))

    cells.append(_md(
        "## 2. Texto de entrada (castellano)\n\n"
        "Tres parrafos sobre el sector empresarial espanol — directivos, sedes, acuerdos — con "
        "entidades de tres tipos (Person, Organization, Location) y relaciones evidentes "
        "(presidencias, sedes, acuerdos)."
    ))

    cells.append(_code(
        f"TEXTO = {SPANISH_TEXT!r}\n"
        "print(TEXTO)\n"
        "print()\n"
        "print(f'longitud: {len(TEXTO)} chars  ~{len(TEXTO.split())} tokens')"
    ))

    cells.append(_md(
        "## 3. Carga de modelos\n\n"
        "Warm load (~8s cada uno) — modelos cacheados en `~/.cache/huggingface/`."
    ))

    cells.append(_code(
        "import time\n"
        "t0 = time.time(); gliner = gliner_load_model(); print(f'GLiNER {time.time()-t0:.1f}s')\n"
        "t0 = time.time(); glirel = glirel_load_model(); print(f'GLiREL {time.time()-t0:.1f}s')"
    ))

    cells.append(_md(
        "## 4. Pipeline `extract_graph_hybrid` — dos pasadas\n\n"
        "El threshold del notebook 01 (`0.15`) se calibro mirando la _distribucion_ de "
        "scores (max ~0.21 en EN, ~0.17 en ES). Pero **GLiREL evalua TODOS los pares "
        "ordenados de entidades para CADA label**: con 15 entidades y 8 labels son "
        "15×14×8 = 1680 candidatos. Aunque pocos pasan, los que pasan son una mezcla "
        "de aciertos y plausibles-pero-falsos.\n\n"
        "Vamos a hacer **dos pasadas** sobre el mismo texto: `0.15` (recall, ruidoso) y "
        "`0.30` (precision, limpio). El notebook 01 solo midio scores agregados — esta "
        "celda completa la calibracion mirando _calidad semantica_ del output."
    ))

    cells.append(_code(
        "entity_schema = [\n"
        "    {'type_ref': 'Person',       'label': 'person'},\n"
        "    {'type_ref': 'Organization', 'label': 'organization'},\n"
        "    {'type_ref': 'Location',     'label': 'location'},\n"
        "]\n"
        "relation_types = [\n"
        "    'works_at', 'located_in', 'appointed_as', 'headquartered_in',\n"
        "    'ceo_of', 'president_of', 'agreement_with', 'met_with',\n"
        "]\n"
        "\n"
        "def run(threshold):\n"
        "    return extract_graph_hybrid(\n"
        "        chunks=[TEXTO],\n"
        "        entity_schema=entity_schema,\n"
        "        relation_types=relation_types,\n"
        "        gliner_model=gliner,\n"
        "        glirel_model=glirel,\n"
        "        llm_chat_json=None,\n"
        "        confidence_threshold=threshold,\n"
        "    )\n"
        "\n"
        "ents_recall,    rels_recall    = run(0.15)\n"
        "ents_precision, rels_precision = run(0.30)\n"
        "print(f'recall    (t=0.15): {len(ents_recall):2d} ents  {len(rels_recall):2d} rels')\n"
        "print(f'precision (t=0.30): {len(ents_precision):2d} ents  {len(rels_precision):2d} rels')\n"
        "\n"
        "# Trabajamos a partir de aqui con 'precision' como base\n"
        "ents, rels = ents_precision, rels_precision"
    ))

    cells.append(_md("### 4.1 Tabla de entidades"))

    cells.append(_code(
        "df_ents = pd.DataFrame([\n"
        "    {'name': e.name, 'type': e.type_ref, 'confidence': round(e.confidence, 3),\n"
        "     'chunks': e.source_chunk_indices, 'merged_from': e.merged_from}\n"
        "    for e in ents\n"
        "]).sort_values(['type','confidence'], ascending=[True, False])\n"
        "df_ents"
    ))

    cells.append(_md("### 4.2 Tabla de relaciones"))

    cells.append(_code(
        "df_rels = pd.DataFrame([\n"
        "    {'from': r.from_name, 'kind': r.relation_type, 'to': r.to_name,\n"
        "     'confidence': round(r.confidence, 3), 'chunk': r.source_chunk_index}\n"
        "    for r in rels\n"
        "]).sort_values('confidence', ascending=False)\n"
        "df_rels"
    ))

    cells.append(_md(
        "## 5. Visualizacion comparativa — recall vs precision\n\n"
        "Dos grafos, mismo texto, distinto threshold. Nodos coloreados por tipo, aristas "
        "etiquetadas con `relation_type`, layout fuerza-dirigido. Es el mismo render que "
        "el panel del `graph_explorer` haria tras un _Apply selected_, pero aqui en linea "
        "para validar visualmente la calibracion."
    ))

    cells.append(_code(
        "TYPE_COLOR = {'Person': '#5DA5DA', 'Organization': '#F17CB0', 'Location': '#60BD68'}\n"
        "\n"
        "def draw(ax, ents, rels, title):\n"
        "    G = nx.DiGraph()\n"
        "    for e in ents:\n"
        "        G.add_node(e.name, type=e.type_ref, confidence=e.confidence)\n"
        "    for r in rels:\n"
        "        G.add_edge(r.from_name, r.to_name, kind=r.relation_type, confidence=r.confidence)\n"
        "    pos = nx.spring_layout(G, k=2.2, iterations=80, seed=42)\n"
        "    node_colors = [TYPE_COLOR.get(G.nodes[n].get('type'), '#bbb') for n in G.nodes]\n"
        "    nx.draw_networkx_nodes(G, pos, node_color=node_colors, node_size=1900,\n"
        "                           edgecolors='#333', linewidths=1.4, ax=ax)\n"
        "    nx.draw_networkx_labels(G, pos, 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.65, ax=ax,\n"
        "                           connectionstyle='arc3,rad=0.08')\n"
        "    edge_labels = {(u, v): d['kind'] for u, v, d in G.edges(data=True)}\n"
        "    nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels, font_size=6.5,\n"
        "                                 font_color='#333', label_pos=0.5,\n"
        "                                 bbox=dict(boxstyle='round,pad=0.1', fc='white', ec='none', alpha=0.8),\n"
        "                                 ax=ax)\n"
        "    ax.set_title(f'{title}: {G.number_of_nodes()} ents, {G.number_of_edges()} rels', fontsize=11)\n"
        "    ax.axis('off')\n"
        "\n"
        "fig, axes = plt.subplots(1, 2, figsize=(20, 9))\n"
        "draw(axes[0], ents_recall,    rels_recall,    't=0.15 (recall)')\n"
        "draw(axes[1], ents_precision, rels_precision, 't=0.30 (precision)')\n"
        "from matplotlib.patches import Patch\n"
        "legend = [Patch(facecolor=c, edgecolor='#333', label=t) for t, c in TYPE_COLOR.items()]\n"
        "axes[0].legend(handles=legend, loc='upper left', frameon=True, fontsize=10)\n"
        "plt.tight_layout(); plt.show()"
    ))

    cells.append(_md("### 5.1 Solo el grafo de precision (vista limpia)"))

    cells.append(_code(
        "fig, ax = plt.subplots(figsize=(13, 9))\n"
        "draw(ax, ents_precision, rels_precision, 'Grafo final (t=0.30)')\n"
        "from matplotlib.patches import Patch\n"
        "legend = [Patch(facecolor=c, edgecolor='#333', label=t) for t, c in TYPE_COLOR.items()]\n"
        "ax.legend(handles=legend, loc='upper left', frameon=True, fontsize=10)\n"
        "plt.tight_layout(); plt.show()"
    ))

    cells.append(_md(
        "## 6. Lectura empirica — el hallazgo incomodo\n\n"
        "**GLiNER funciona muy bien:** 15 entidades nucleo con confianza 0.92-0.98, en castellano, con labels en ingles. Sin asteriscos.\n\n"
        "**GLiREL no funciona bien en este dominio.** No es un problema de threshold — es de fondo:\n\n"
        "### Falsos positivos con score alto a `t=0.15`\n\n"
        "Con 51 relaciones emitidas, la mayoria son espurias. Ejemplos reales del output:\n\n"
        "| Score | from | kind | to | Realidad |\n"
        "|---|---|---|---|---|\n"
        "| 0.339 | Ignacio Galan | president_of | Jose Maria Alvarez-Pallete | **Falso.** Galan preside Iberdrola; Alvarez-Pallete preside Telefonica. No tienen relacion entre si. |\n"
        "| 0.292 | Carlos Torres | president_of | Jose Maria Alvarez-Pallete | **Falso.** Torres preside BBVA. |\n"
        "| 0.253 | Madrid | president_of | Jose Maria Alvarez-Pallete | **Sin sentido.** Una `Location` no preside a una `Person`. |\n"
        "| 0.218 | Madrid | located_in | Inditex | **Invertido.** Inditex esta en Arteixo, no Madrid esta en Inditex. |\n\n"
        "### Y al subir el threshold no mejora\n\n"
        "A `t=0.30` (precision mode), solo sobrevive **1 relacion**: la primera de la tabla — que **tambien es falsa**. GLiREL ha aprendido que dos `Person` cerca de la palabra _presidente_ disparan `president_of` con confianza alta, sin importar la sintaxis ni la direccion.\n\n"
        "### Por que pasa esto\n\n"
        "1. **GLiREL evalua todos los pares ordenados × cada label.** Con 15 ents y 8 labels son 15×14×8 = **1680 candidatos**. Incluso con error <1% por candidato, el output a threshold permisivo es ruidoso.\n"
        "2. **El modelo es atencional, no logico.** Aprende patrones de coocurrencia, no semantica. Por eso `Madrid president_of Persona` recibe score positivo cuando ambos aparecen cerca del verbo.\n"
        "3. **`jackboyla/glirel-large-v0` esta entrenado mayoritariamente en ingles.** El gap EN/ES del notebook 01 (max 0.23 vs 0.17) es la punta del iceberg — la calidad semantica tambien cae.\n\n"
        "### Que toca cambiar en el pipeline\n\n"
        "1. **No usar GLiREL como decisor final** en castellano. Usarlo como _candidate generator_ y validar con LLM. El pipeline `extract_graph_hybrid` ya admite `llm_chat_json` para fallback de entidades — habria que extender el flujo a las relaciones (issue nuevo).\n"
        "2. **Si no hay LLM disponible**, mejor emitir solo top-N por score (ej: top-3 relaciones globales) que filtrar por threshold global. El panel deja al humano elegir.\n"
        "3. **El issue `0041-split-confidence-thresholds`** sigue siendo valido (separar entity y relation thresholds), pero ahora sabemos que el problema mas grave **NO es el threshold sino la calidad del modelo en este dominio**.\n"
        "4. **Para OSINT/narrativa en EN**, GLiREL podria funcionar mejor (notebook 01 mostro scores ~25% mas altos en EN). No probado aqui.\n\n"
        "### Decision provisional para el panel `paste_extract`\n\n"
        "- **GLiNER (entidades): habilitado por defecto.** Funciona muy bien.\n"
        "- **GLiREL (relaciones): deshabilitado por defecto en castellano** o, alternativamente, mostrar siempre con un banner explicando que las relaciones son sugerencias y deben validarse antes de _Apply_.\n"
        "- **Issue nuevo:** integrar LLM como validator semantico de candidatos GLiREL antes de mostrar al usuario.\n\n"
        "**Para iterar sobre tu propio texto:** edita la celda 5 (`TEXTO = ...`) y re-ejecuta desde la celda 7. Los modelos quedan cacheados en RAM."
    ))

    nb = nbf.v4.new_notebook()
    nb.cells = cells
    nb.metadata = {
        "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
        "language_info": {"name": "python"},
    }
    NB_PATH.parent.mkdir(parents=True, exist_ok=True)
    nbf.write(nb, NB_PATH)
    print(f"[done] {NB_PATH}  cells={len(cells)}")


if __name__ == "__main__":
    build()
