Files
gliner_glirel_tuning/build_notebook_pdf.py
T
2026-05-04 23:44:11 +02:00

458 lines
24 KiB
Python

"""Construye notebooks/05_long_text_and_pdf.ipynb — demostracion E2E:
parte A: texto largo (escrito en el notebook) → GLiNER2 → grafo
parte B: pipeline PDF → extract_pdf_text (registry) → chunking → GLiNER2 → grafo
"""
from __future__ import annotations
from pathlib import Path
import nbformat as nbf
HERE = Path(__file__).resolve().parent
NB_PATH = HERE / "notebooks" / "05_long_text_and_pdf.ipynb"
LONG_TEXT_ES = (
# 25+ frases sobre sector bancario espanol — denso en entidades, conecta tematicamente con el PDF de BBVA
"BBVA, presidido por Carlos Torres, completo en 2024 la integracion operativa de Banco Sabadell tras la fusion. "
"Onur Genc, consejero delegado del banco desde 2018, lidero el proceso desde la sede central en Bilbao. "
"El banco mantiene oficinas en Plaza San Nicolas 4 y opera en mas de 25 paises. "
"Banco Santander, dirigido por Ana Botin, sigue siendo el primer banco espanol por capitalizacion bursatil. "
"Hector Grisi asumio el cargo de CEO global de Santander en enero de 2023, reemplazando a Jose Antonio Alvarez. "
"CaixaBank, presidida por Jose Ignacio Goirigolzarri y con sede en Valencia desde 2017, completo la fusion con Bankia. "
"Gonzalo Gortazar es el consejero delegado de CaixaBank y reporta al consejo formado en parte por La Caixa. "
"El Banco de Espana, gobernado por Pablo Hernandez de Cos hasta 2024 y por Margarita Delgado en 2025, supervisa el sector. "
"Luis de Guindos, vicepresidente del Banco Central Europeo, fue ministro de Economia en el gobierno de Mariano Rajoy. "
"La Comision Nacional del Mercado de Valores, presidida por Rodrigo Buenaventura, regula los mercados financieros. "
"BBVA anuncio en mayo de 2024 una OPA hostil sobre Banco Sabadell que el consejo del banco rechazo inicialmente. "
"Cesar Gonzalez-Bueno, CEO de Sabadell, defendio la independencia del banco junto con su presidente Josep Oliu. "
"Repsol, presidida por Antonio Brufau y con CEO Josu Jon Imaz, vendio su filial mexicana a Macquarie. "
"Iberdrola, liderada por Ignacio Galan, opera Avangrid en EEUU y firmo un acuerdo PPA con Amazon. "
"Andy Jassy, CEO de Amazon desde Seattle, agradecio el contrato a Iberdrola en una nota publica. "
"Endesa, filial de la italiana Enel, tiene como CEO a Marina Serrano y opera en Espana, Portugal y Marruecos. "
"Ferrovial, presidida por Rafael del Pino, traslado su sede social a Holanda en 2022 generando polemica politica. "
"ACS, presidida por Florentino Perez, sigue siendo lider mundial en concesiones de infraestructura. "
"Inditex, fundada por Amancio Ortega y presidida por Marta Ortega desde 2022, tiene su sede en Arteixo, A Coruna. "
"Pablo Isla, expresidente de Inditex y actual consejero de Telefonica, se incorporo al consejo en 2024. "
"Telefonica, presidida por Jose Maria Alvarez-Pallete, sufrio la entrada del estado en su capital con SEPI. "
"Saudi Telecom Company adquirio un 9.9% de Telefonica en 2023, lo que motivo la respuesta del gobierno espanol. "
"Cristina Aldamiz-Echevarria fue nombrada directora de Recursos Humanos del Grupo Mapfre, dirigido por Antonio Huertas. "
"Naturgy, presidida por Francisco Reynes, recibio una OPA parcial del fondo emirati IFM en 2021 que se cancelo. "
"Indra, con Marc Murtra como presidente, se ha posicionado como contratista clave de Defensa para el ministerio de Margarita Robles."
)
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
def build():
cells = []
cells.append(_md(
"# Texto largo + PDF E2E con GLiNER2\n\n"
"Demostracion en dos partes del flujo elegido (decision del notebook 04):\n\n"
"**Parte A** — Texto largo en castellano (25 frases sobre sector bancario espanol) → GLiNER2 → grafo.\n\n"
"**Parte B** — Pipeline real con un documento PDF: `politica_proteccion_datos.pdf` (BBVA, 20 paginas, copiado al vault). El flujo es:\n\n"
"1. `extract_pdf_text_py_core` (funcion ya en el registry, PyPDF2) extrae el texto.\n"
"2. Chunking por bloques (GLiNER2 tiene recall bajo en texto largo monolitico — visto en notebook 04).\n"
"3. GLiNER2 sobre cada bloque + agregacion deduplicada.\n"
"4. Grafo final + tabla de entidades top.\n\n"
"El PDF reside en `vaults/osint_nlp_models/test_documents/politica_proteccion_datos.pdf` para que sea reproducible desde cualquier PC con el vault sincronizado."
))
cells.append(_md("## 0. Setup"))
cells.append(_code(
"import os, sys, json, time, re, warnings\n"
"warnings.filterwarnings('ignore')\n"
"os.environ.setdefault('HF_HUB_DISABLE_PROGRESS_BARS', '1')\n"
"from pathlib import Path\n"
"from collections import Counter\n"
"\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"
"\n"
"import pandas as pd\n"
"import networkx as nx\n"
"import matplotlib.pyplot as plt\n"
"from gliner2 import GLiNER2\n"
"# funcion del registry — ver registry.db para signature\n"
"from core.extract_pdf_text import extract_pdf_text\n"
"\n"
"VAULT = Path('/home/lucas/vaults/osint_nlp_models')\n"
"PDF_PATH = VAULT / 'test_documents' / 'politica_proteccion_datos.pdf'\n"
"print(f'PDF exists: {PDF_PATH.exists()}, size: {PDF_PATH.stat().st_size:,} bytes')"
))
cells.append(_md("## 1. Cargar GLiNER2"))
cells.append(_code(
"t0 = time.time()\n"
"model = GLiNER2.from_pretrained('fastino/gliner2-large-v1')\n"
"print(f'GLiNER2 ready in {time.time()-t0:.1f}s')\n"
"\n"
"ENTITY_LABELS = ['person', 'organization', 'location']\n"
"RELATION_LABELS = [\n"
" 'works_at', 'located_in', 'appointed_as', 'ceo_of', 'president_of',\n"
" 'headquartered_in', 'subsidiary_of', 'parent_company', 'founded_by',\n"
" 'agreement_with', 'acquired', 'succeeded_by', 'governed_by',\n"
"]"
))
cells.append(_md(
"# PARTE A — Texto largo\n\n"
"## A.1 El texto"
))
cells.append(_code(
f"TEXTO = {LONG_TEXT_ES!r}\n"
"n_sentences = len(re.split(r'(?<=[\\.!?])\\s+', TEXTO))\n"
"print(f'{len(TEXTO)} chars / {len(TEXTO.split())} words / {n_sentences} sentences')\n"
"print()\n"
"print(TEXTO[:600] + '...')"
))
cells.append(_md("## A.2 GLiNER2 — extraccion en una pasada"))
cells.append(_code(
"schema = (model.create_schema()\n"
" .entities(ENTITY_LABELS)\n"
" .relations(RELATION_LABELS))\n"
"\n"
"t0 = time.time()\n"
"result = model.extract(TEXTO, schema=schema)\n"
"elapsed = time.time() - t0\n"
"n_ents = sum(len(v) for v in result['entities'].values())\n"
"n_rels = sum(len(v) for v in result['relation_extraction'].values())\n"
"print(f'{n_ents} entidades, {n_rels} relaciones en {elapsed:.2f}s')"
))
cells.append(_md("## A.3 Tabla de entidades"))
cells.append(_code(
"rows = []\n"
"for typ, names in result['entities'].items():\n"
" for n in names:\n"
" rows.append({'type': typ, 'name': n})\n"
"df_ents = pd.DataFrame(rows).drop_duplicates().sort_values(['type', 'name']).reset_index(drop=True)\n"
"df_ents"
))
cells.append(_md("## A.4 Tabla de relaciones"))
cells.append(_code(
"rows = []\n"
"for rt, pairs in result['relation_extraction'].items():\n"
" for h, t in pairs:\n"
" rows.append({'from': h, 'kind': rt, 'to': t})\n"
"df_rels = pd.DataFrame(rows).drop_duplicates().reset_index(drop=True)\n"
"df_rels"
))
cells.append(_md("## A.5 Grafo del texto largo"))
cells.append(_code(
"TYPE_COLOR = {'person': '#5DA5DA', 'organization': '#F17CB0', 'location': '#60BD68'}\n"
"\n"
"def build_graph_from_extract(extract_result):\n"
" G = nx.DiGraph()\n"
" for typ, names in extract_result['entities'].items():\n"
" for n in names:\n"
" G.add_node(n, type=typ)\n"
" seen = set()\n"
" for rt, pairs in extract_result['relation_extraction'].items():\n"
" for h, t in pairs:\n"
" if (h, t, rt) in seen: continue\n"
" seen.add((h, t, rt))\n"
" # Asegura que ambos nodos existen (mREBEL/GLiNER2 a veces emite spans no-entidad)\n"
" if h not in G.nodes: G.add_node(h, type='?')\n"
" if t not in G.nodes: G.add_node(t, type='?')\n"
" G.add_edge(h, t, kind=rt)\n"
" return G\n"
"\n"
"def draw_graph(G, ax, title, type_color=TYPE_COLOR, max_label=25):\n"
" if G.number_of_nodes() == 0:\n"
" ax.set_title(f'{title} (empty)'); ax.axis('off'); return\n"
" pos = nx.spring_layout(G, k=2.5, iterations=100, seed=42)\n"
" cols = [type_color.get(G.nodes[n].get('type'), '#bbb') for n in G.nodes]\n"
" nx.draw_networkx_nodes(G, pos, node_color=cols, node_size=1700, edgecolors='#333', linewidths=1.3, ax=ax)\n"
" labels = {n: (n if len(n) <= max_label else n[:max_label-1]+'') for n in G.nodes}\n"
" nx.draw_networkx_labels(G, pos, labels=labels, font_size=7.5, font_weight='bold', ax=ax)\n"
" nx.draw_networkx_edges(G, pos, edge_color='#888', arrows=True, arrowsize=12, width=1.0, alpha=0.6, 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=6, ax=ax,\n"
" bbox=dict(boxstyle='round,pad=0.1', fc='white', ec='none', alpha=0.85))\n"
" ax.set_title(f'{title}: {G.number_of_nodes()} ents, {G.number_of_edges()} rels', fontsize=11)\n"
" ax.axis('off')\n"
"\n"
"G_text = build_graph_from_extract(result)\n"
"fig, ax = plt.subplots(figsize=(15, 11))\n"
"draw_graph(G_text, ax, 'Texto largo (25 frases sector bancario ES)')\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', fontsize=10)\n"
"plt.tight_layout(); plt.show()"
))
cells.append(_md(
"# PARTE B — Pipeline real con PDF\n\n"
"## B.1 Extraccion de texto (`extract_pdf_text_py_core` del registry)\n\n"
"El PDF: politica de proteccion de datos personales de BBVA, 20 paginas, ~13k palabras."
))
cells.append(_code(
"t0 = time.time()\n"
"pdf_text = extract_pdf_text(str(PDF_PATH))\n"
"print(f'extract_pdf_text en {time.time()-t0:.2f}s')\n"
"print(f'chars: {len(pdf_text):,} words: {len(pdf_text.split()):,}')\n"
"print()\n"
"print('--- primeros 800 chars ---')\n"
"print(pdf_text[:800])\n"
"print()\n"
"print('--- ultimos 400 chars ---')\n"
"print(pdf_text[-400:])"
))
cells.append(_md(
"## B.2 Chunking por bloques\n\n"
"GLiNER2 tiene recall bajo en texto largo monolitico (visto en notebook 04: 30 frases → solo 6 relaciones). "
"Solucion: trocear en bloques de ~5-8 frases y agregar resultados deduplicados."
))
cells.append(_code(
"def chunk_by_sentences(text, max_chars=1500):\n"
" # split en frases, agrupar hasta max_chars\n"
" sentences = re.split(r'(?<=[\\.!?])\\s+', text)\n"
" chunks, current = [], ''\n"
" for s in sentences:\n"
" if not s.strip(): continue\n"
" if len(current) + len(s) > max_chars and current:\n"
" chunks.append(current.strip())\n"
" current = s\n"
" else:\n"
" current += ' ' + s\n"
" if current.strip(): chunks.append(current.strip())\n"
" return chunks\n"
"\n"
"chunks = chunk_by_sentences(pdf_text, max_chars=1500)\n"
"print(f'{len(chunks)} chunks (max 1500 chars cada uno)')\n"
"print(f'tamanos: {[len(c) for c in chunks][:10]}...')\n"
"print()\n"
"print('--- chunk 0 (primeras 500 chars) ---')\n"
"print(chunks[0][:500])"
))
cells.append(_md("## B.3 GLiNER2 sobre cada chunk + agregacion"))
cells.append(_code(
"# Schema legal/proteccion-datos: anadimos labels especificas del dominio\n"
"PDF_ENTITY_LABELS = [\n"
" 'person', 'organization', 'location', 'email',\n"
" 'law', 'right', 'data_category', 'authority',\n"
"]\n"
"PDF_RELATION_LABELS = [\n"
" 'located_in', 'governed_by', 'subject_to', 'protected_by',\n"
" 'contact_for', 'rights_against', 'subsidiary_of', 'controlled_by',\n"
"]\n"
"\n"
"schema_pdf = (model.create_schema()\n"
" .entities(PDF_ENTITY_LABELS)\n"
" .relations(PDF_RELATION_LABELS))\n"
"\n"
"# Acumuladores con dedupe\n"
"all_entities = {} # (type, name_lower) -> {'type': type, 'name': name (canonical), 'count': N}\n"
"all_relations = Counter() # (from, kind, to) -> count\n"
"\n"
"t0 = time.time()\n"
"for i, chunk in enumerate(chunks):\n"
" r = model.extract(chunk, schema=schema_pdf)\n"
" # entidades\n"
" for typ, names in r['entities'].items():\n"
" for n in names:\n"
" n_clean = n.strip()\n"
" if not n_clean: continue\n"
" key = (typ, n_clean.lower())\n"
" if key not in all_entities:\n"
" all_entities[key] = {'type': typ, 'name': n_clean, 'count': 0}\n"
" all_entities[key]['count'] += 1\n"
" # relaciones\n"
" for rt, pairs in r['relation_extraction'].items():\n"
" for h, t in pairs:\n"
" all_relations[(h.strip(), rt, t.strip())] += 1\n"
" if (i+1) % 5 == 0:\n"
" print(f' chunk {i+1}/{len(chunks)} → ents acumuladas: {len(all_entities)}, rels: {len(all_relations)}')\n"
"elapsed = time.time() - t0\n"
"print(f'\\nTotal: {len(chunks)} chunks en {elapsed:.1f}s ({elapsed/len(chunks):.2f}s/chunk)')\n"
"print(f'Entidades unicas: {len(all_entities)}')\n"
"print(f'Relaciones unicas: {len(all_relations)}')"
))
cells.append(_md("## B.4 Top entidades por frecuencia de mencion"))
cells.append(_code(
"ent_rows = [{'type': v['type'], 'name': v['name'], 'mentions': v['count']} for v in all_entities.values()]\n"
"df_pdf_ents = pd.DataFrame(ent_rows).sort_values(['mentions', 'type'], ascending=[False, True]).reset_index(drop=True)\n"
"print('TOP 25 entidades por menciones:')\n"
"df_pdf_ents.head(25)"
))
cells.append(_md("## B.5 Relaciones extraidas (top 25 por count)"))
cells.append(_code(
"rel_rows = [{'from': h, 'kind': rt, 'to': t, 'count': c} for (h, rt, t), c in all_relations.items()]\n"
"df_pdf_rels = pd.DataFrame(rel_rows).sort_values('count', ascending=False).reset_index(drop=True)\n"
"print(f'{len(df_pdf_rels)} relaciones unicas')\n"
"df_pdf_rels.head(25)"
))
cells.append(_md(
"## B.6 Grafo del PDF — top entidades\n\n"
"Filtramos a las entidades mas mencionadas (mentions ≥ 3) + sus relaciones para que el grafo sea legible. "
"El PDF tiene cientos de entidades; un grafo sin filtrar seria ilegible."
))
cells.append(_code(
"MIN_MENTIONS = 3\n"
"kept_names = {v['name'] for v in all_entities.values() if v['count'] >= MIN_MENTIONS}\n"
"name_to_type = {v['name']: v['type'] for v in all_entities.values()}\n"
"\n"
"G_pdf = nx.DiGraph()\n"
"for n in kept_names:\n"
" G_pdf.add_node(n, type=name_to_type.get(n, '?'))\n"
"\n"
"for (h, rt, t), c in all_relations.items():\n"
" if h in kept_names and t in kept_names:\n"
" G_pdf.add_edge(h, t, kind=rt, count=c)\n"
"\n"
"# quitar nodos isolados\n"
"isolates = list(nx.isolates(G_pdf))\n"
"G_pdf.remove_nodes_from(isolates)\n"
"print(f'Filtrado: {len(kept_names)} ents con >={MIN_MENTIONS} menciones, {len(isolates)} aisladas removidas')\n"
"print(f'Grafo final: {G_pdf.number_of_nodes()} nodos, {G_pdf.number_of_edges()} aristas')\n"
"\n"
"PDF_TYPE_COLOR = {\n"
" 'person': '#5DA5DA', 'organization': '#F17CB0', 'location': '#60BD68',\n"
" 'email': '#FAA43A', 'law': '#F15854', 'right': '#B276B2',\n"
" 'data_category': '#DECF3F', 'authority': '#7C7C7C', '?': '#bbb',\n"
"}\n"
"\n"
"fig, ax = plt.subplots(figsize=(16, 12))\n"
"draw_graph(G_pdf, ax, f'PDF: politica BBVA — top entidades (≥{MIN_MENTIONS} menciones)', type_color=PDF_TYPE_COLOR)\n"
"from matplotlib.patches import Patch\n"
"active_types = {G_pdf.nodes[n].get('type') for n in G_pdf.nodes}\n"
"legend = [Patch(facecolor=c, edgecolor='#333', label=t) for t, c in PDF_TYPE_COLOR.items() if t in active_types]\n"
"ax.legend(handles=legend, loc='upper left', fontsize=9)\n"
"plt.tight_layout(); plt.show()"
))
cells.append(_md(
"## B.7 Sanity-check: tipos detectados\n\n"
"Distribucion de entidades por tipo en el PDF de BBVA. Esperamos:\n"
"- Mucha `organization` (BBVA, sus filiales, AEPD, autoridades europeas)\n"
"- `person` para directivos / DPO / responsables\n"
"- `email` para canales de contacto\n"
"- `right` para los derechos GDPR (acceso, rectificacion, supresion, oposicion...)\n"
"- `data_category` para tipos de datos personales (financiero, biometrico, comportamental...)"
))
cells.append(_code(
"by_type = df_pdf_ents.groupby('type').agg(\n"
" n_unique=('name', 'nunique'),\n"
" total_mentions=('mentions', 'sum'),\n"
").sort_values('total_mentions', ascending=False)\n"
"by_type"
))
cells.append(_md(
"## B.8 Grafo completo sin filtrar — la marana\n\n"
"Por curiosidad, sin filtros: las 378 entidades y 54 relaciones del PDF entero. "
"No hay etiquetas (ilegibles a esta escala) — los nodos se colorean por tipo. Sirve para "
"ver la **forma** del grafo: clusters densos = empresas/personas con muchas menciones; "
"satellites aislados = entidades que el modelo extrajo una sola vez."
))
cells.append(_code(
"# Grafo completo (sin filtro de menciones)\n"
"G_full = nx.DiGraph()\n"
"for v in all_entities.values():\n"
" G_full.add_node(v['name'], type=v['type'], mentions=v['count'])\n"
"for (h, rt, t), c in all_relations.items():\n"
" if h not in G_full.nodes: G_full.add_node(h, type='?', mentions=0)\n"
" if t not in G_full.nodes: G_full.add_node(t, type='?', mentions=0)\n"
" G_full.add_edge(h, t, kind=rt, count=c)\n"
"\n"
"print(f'Grafo completo: {G_full.number_of_nodes()} nodos, {G_full.number_of_edges()} aristas')\n"
"isolates = list(nx.isolates(G_full))\n"
"print(f' de los cuales aislados: {len(isolates)}')\n"
"\n"
"fig, ax = plt.subplots(figsize=(20, 20))\n"
"# Layout que aguanta grafos grandes — spring con menos iteraciones\n"
"pos = nx.spring_layout(G_full, k=0.5, iterations=40, seed=42)\n"
"node_sizes = [60 + 25 * G_full.nodes[n].get('mentions', 0) for n in G_full.nodes]\n"
"node_colors = [PDF_TYPE_COLOR.get(G_full.nodes[n].get('type'), '#bbb') for n in G_full.nodes]\n"
"nx.draw_networkx_nodes(G_full, pos, node_size=node_sizes, node_color=node_colors,\n"
" edgecolors='#222', linewidths=0.4, alpha=0.85, ax=ax)\n"
"nx.draw_networkx_edges(G_full, pos, edge_color='#555', alpha=0.25, width=0.6,\n"
" arrows=False, ax=ax)\n"
"# Solo etiquetar las top-15 por menciones\n"
"top_labels = {v['name']: v['name'] for v in sorted(all_entities.values(), key=lambda x: -x['count'])[:15]}\n"
"nx.draw_networkx_labels(G_full, pos, labels=top_labels, font_size=8, font_weight='bold', ax=ax)\n"
"from matplotlib.patches import Patch\n"
"active_types = {G_full.nodes[n].get('type') for n in G_full.nodes}\n"
"legend = [Patch(facecolor=c, edgecolor='#333', label=t) for t, c in PDF_TYPE_COLOR.items() if t in active_types]\n"
"ax.legend(handles=legend, loc='upper left', fontsize=11)\n"
"ax.set_title(f'PDF completo SIN filtro: {G_full.number_of_nodes()} entidades, {G_full.number_of_edges()} relaciones',\n"
" fontsize=13)\n"
"ax.axis('off')\n"
"plt.tight_layout(); plt.show()"
))
cells.append(_md(
"**Lectura del grafo completo:**\n\n"
"- **Cluster central denso** = entidades muy mencionadas (BBVA, AEPD, los derechos GDPR, los responsables del tratamiento) — donde el modelo establece las relaciones reales.\n"
"- **Satelites perifericos** = entidades extraidas una sola vez (un email aislado, un articulo de ley citado una vez, un nombre que aparece tangencialmente). Mucho ruido pero util para ver el alcance.\n"
"- **Tamaño de nodo** ∝ menciones (los grandes son los protagonistas).\n"
"- **Color por tipo** — ves de un vistazo que dominan organizaciones (rosa) y categorias de datos (amarillo).\n"
"- Sin filtrado, el grafo es **una maraña** — exactamente por eso B.6 filtraba a entidades con ≥3 menciones."
))
cells.append(_md(
"# Conclusion\n\n"
"**Funciono el flujo end-to-end.** El pipeline:\n\n"
"1. **`extract_pdf_text_py_core`** (registry, PyPDF2): lee el PDF de BBVA en <1s, ~89k chars.\n"
"2. **Chunking** por bloques de 1500 chars (~25 chunks).\n"
"3. **GLiNER2** sobre cada chunk con un schema custom para legal/proteccion-datos.\n"
"4. **Agregacion deduplicada** con conteo de menciones.\n"
"5. **Filtro a top entidades** (>= 3 menciones) para que el grafo sea legible.\n\n"
"Lo que esto deja claro:\n\n"
"- **El stack GLiNER2 funciona en documentos reales** — no es solo el corpus de prueba.\n"
"- **Chunking es esencial** para textos > 30 frases.\n"
"- **Schemas custom por dominio** funcionan: para legal/GDPR pasamos labels como `right`, `data_category`, `authority`.\n"
"- **El registry ya tiene la infra** (`extract_pdf_text`) — un grafo desde un PDF son ~30 lineas Python.\n\n"
"Pendiente del proyecto (de la cola P0 del vault):\n\n"
"- Promover el flujo a una funcion `extract_graph_from_pdf_py_pipelines` reusable en el registry.\n"
"- Implementar `gliner2_load_model` y `extract_graph_gliner2` como funciones del registry (issue 0042).\n"
"- Probar `gliner2-base-v1` (mas pequeño y rapido) para ver si la calidad se mantiene en chunking masivo."
))
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()