b8c760d004
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
292 lines
13 KiB
HTML
292 lines
13 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>GLiNER2 Playground — graph_explorer</title>
|
|
<script src="/static/graphology.umd.min.js"></script>
|
|
<script src="/static/sigma.min.js"></script>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
html, body { height: 100%; font-family: -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
background: #181a1f; color: #ddd; }
|
|
.app { display: grid; grid-template-columns: 420px 1fr; height: 100%; gap: 0; }
|
|
.left { padding: 16px; border-right: 1px solid #2a2d34; display: flex; flex-direction: column; gap: 12px; overflow-y: auto; }
|
|
h1 { font-size: 14px; font-weight: 600; letter-spacing: 0.02em; color: #fff; }
|
|
h1 .badge { background: #2c2f3a; color: #9aa0ad; padding: 2px 8px; border-radius: 4px;
|
|
font-size: 11px; margin-left: 8px; font-weight: 400; }
|
|
textarea { width: 100%; height: 320px; padding: 10px; font-family: ui-monospace, monospace;
|
|
font-size: 12px; line-height: 1.45; background: #14161b; color: #d8dadf;
|
|
border: 1px solid #2a2d34; border-radius: 6px; resize: vertical; }
|
|
textarea:focus { outline: none; border-color: #3d6cb8; }
|
|
.controls { display: flex; gap: 8px; align-items: center; }
|
|
button { background: #3d6cb8; color: #fff; border: none; padding: 8px 14px;
|
|
border-radius: 6px; font-weight: 600; cursor: pointer; font-size: 13px; }
|
|
button:hover { background: #4d7cc8; }
|
|
button:disabled { background: #555; cursor: not-allowed; }
|
|
label { font-size: 12px; color: #9aa0ad; display: flex; align-items: center; gap: 6px; }
|
|
input[type="number"] { width: 60px; padding: 4px 6px; background: #14161b; color: #d8dadf;
|
|
border: 1px solid #2a2d34; border-radius: 4px; font-size: 12px; }
|
|
.kpis { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 4px; }
|
|
.kpi { background: #14161b; border: 1px solid #2a2d34; border-radius: 6px;
|
|
padding: 10px 12px; }
|
|
.kpi .num { font-size: 28px; font-weight: 700; color: #fff; }
|
|
.kpi .lbl { font-size: 11px; color: #9aa0ad; text-transform: uppercase; letter-spacing: 0.06em; }
|
|
.kpi.full { grid-column: span 2; }
|
|
.legend { display: flex; gap: 12px; flex-wrap: wrap; font-size: 11px; }
|
|
.legend-item { display: flex; align-items: center; gap: 4px; }
|
|
.swatch { width: 10px; height: 10px; border-radius: 50%; border: 1px solid #fff3; }
|
|
.right { background: #0e1015; position: relative; }
|
|
#graph { width: 100%; height: 100%; }
|
|
.empty-msg { position: absolute; inset: 0; display: flex; align-items: center;
|
|
justify-content: center; color: #4c5060; font-size: 14px; pointer-events: none; }
|
|
details { background: #14161b; border: 1px solid #2a2d34; border-radius: 6px; padding: 8px 10px;
|
|
font-size: 11px; color: #9aa0ad; }
|
|
details summary { cursor: pointer; color: #d8dadf; font-weight: 500; }
|
|
details pre { margin-top: 6px; font-size: 10px; line-height: 1.4; max-height: 280px; overflow: auto;
|
|
color: #d8dadf; font-family: ui-monospace, "JetBrains Mono", monospace;
|
|
background: #0e1015; padding: 6px; border-radius: 4px; white-space: pre; }
|
|
details[open] summary { color: #fff; margin-bottom: 4px; }
|
|
.examples { display: flex; flex-direction: column; gap: 4px; }
|
|
.examples a { color: #9aa0ad; font-size: 11px; cursor: pointer; padding: 4px 6px;
|
|
background: #14161b; border: 1px solid #2a2d34; border-radius: 4px; text-decoration: none; }
|
|
.examples a:hover { background: #1e2027; color: #d8dadf; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app">
|
|
<div class="left">
|
|
<h1>GLiNER2 Playground <span class="badge">graph_explorer</span></h1>
|
|
|
|
<textarea id="input" placeholder="Pega aqui un texto en castellano (sector empresarial, OSINT, legal...)"></textarea>
|
|
|
|
<div class="controls">
|
|
<button id="btn">Procesar</button>
|
|
<label>threshold
|
|
<input id="threshold" type="number" value="0.3" step="0.05" min="0.1" max="0.9">
|
|
</label>
|
|
<span id="status" style="font-size: 11px; color: #6c7080;"></span>
|
|
</div>
|
|
|
|
<div class="kpis">
|
|
<div class="kpi"><div class="num" id="kpi-nodes">—</div><div class="lbl">nodos</div></div>
|
|
<div class="kpi"><div class="num" id="kpi-edges">—</div><div class="lbl">relaciones</div></div>
|
|
<div class="kpi full"><div class="num" id="kpi-time">—</div><div class="lbl">tiempo (s)</div></div>
|
|
</div>
|
|
|
|
<div class="legend">
|
|
<div class="legend-item"><div class="swatch" style="background:#5DA5DA"></div>person</div>
|
|
<div class="legend-item"><div class="swatch" style="background:#F17CB0"></div>organization</div>
|
|
<div class="legend-item"><div class="swatch" style="background:#60BD68"></div>location</div>
|
|
</div>
|
|
|
|
<div class="examples">
|
|
<a data-ex="corp">📰 Ej: corporate ES (Pablo Isla / Inditex)</a>
|
|
<a data-ex="osint">🔒 Ej: OSINT ES (APT-29 / CozyBear)</a>
|
|
<a data-ex="banking">🏦 Ej: banca ES (BBVA / Sabadell / OPA)</a>
|
|
</div>
|
|
|
|
<details>
|
|
<summary>Stack aplicado</summary>
|
|
<pre>1. snake_case verbal labels
|
|
2. threshold (configurable)
|
|
3. post-filter typed (head_type, tail_type)
|
|
4. coreferencia normalize+substring
|
|
5. chunking automatico > 1500 chars
|
|
6. layout server-side (networkx spring_layout)
|
|
7. render: sigma.js + graphology</pre>
|
|
</details>
|
|
|
|
<details open>
|
|
<summary>Relaciones extraidas (texto)</summary>
|
|
<pre id="relations-text">(corre una extraccion para verlo)</pre>
|
|
</details>
|
|
|
|
<details>
|
|
<summary>Entidades extraidas por tipo</summary>
|
|
<pre id="entities-text">(corre una extraccion para verlo)</pre>
|
|
</details>
|
|
|
|
<details>
|
|
<summary>JSON completo</summary>
|
|
<pre id="raw-json">(corre una extraccion para verlo)</pre>
|
|
</details>
|
|
|
|
<details>
|
|
<summary>Relaciones descartadas por filtro typed</summary>
|
|
<pre id="dropped">(corre una extraccion para verlo)</pre>
|
|
</details>
|
|
</div>
|
|
<div class="right">
|
|
<div id="graph"></div>
|
|
<div class="empty-msg" id="empty">Pega un texto y pulsa Procesar</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Filtra ResizeObserver warnings benignos (vis-network los disparaba; sigma puede tambien)
|
|
window.addEventListener('error', e => {
|
|
if (e.message && e.message.includes('ResizeObserver')) {
|
|
e.stopImmediatePropagation();
|
|
return false;
|
|
}
|
|
});
|
|
|
|
const TYPE_COLOR = { person:'#5DA5DA', organization:'#F17CB0', location:'#60BD68', '?':'#888' };
|
|
|
|
const EXAMPLES = {
|
|
corp: `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. 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 BBVA, presidido por Carlos Torres, mostro interes en participar en la financiacion del proyecto. Su sede central esta en Bilbao.`,
|
|
osint: `El 15 de agosto de 2024, el grupo APT-29 (atribuido a Rusia) lanzo una campana de phishing contra empresas energeticas espanolas. El servidor de comando y control 185.220.101.45 conectaba con sistemas internos de Iberdrola via TLS. El malware utilizado, identificado como CozyBear, exploto la vulnerabilidad CVE-2024-21412 en Microsoft Defender. El operador @phantomzero reivindico el ataque en un foro de la dark web. El analista Carlos Garcia, del CCN-CERT, publico un informe tecnico. Telefonica Tech alerto a sus clientes sobre indicadores de compromiso adicionales en el dominio cloudfront-cdn[.]net.`,
|
|
banking: `BBVA, presidido por Carlos Torres, anuncio en mayo de 2024 una OPA hostil sobre Banco Sabadell. Onur Genc, consejero delegado del banco desde 2018, lidero el proceso desde la sede central en Bilbao. Cesar Gonzalez-Bueno, CEO de Sabadell, defendio la independencia junto con su presidente Josep Oliu. Banco Santander, dirigido por Ana Botin, sigue siendo el primer banco espanol. CaixaBank, presidida por Jose Ignacio Goirigolzarri y con sede en Valencia, completo la fusion con Bankia. El Banco de Espana, gobernado por Pablo Hernandez de Cos, supervisa el sector. Luis de Guindos, vicepresidente del Banco Central Europeo, fue ministro de Economia en el gobierno de Mariano Rajoy.`
|
|
};
|
|
|
|
document.querySelectorAll('.examples a').forEach(a => {
|
|
a.onclick = () => { document.getElementById('input').value = EXAMPLES[a.dataset.ex] || ''; };
|
|
});
|
|
|
|
let renderer = null;
|
|
|
|
function renderGraph(data) {
|
|
const empty = document.getElementById('empty');
|
|
const container = document.getElementById('graph');
|
|
|
|
if (typeof graphology === 'undefined' || typeof Sigma === 'undefined') {
|
|
empty.textContent = 'Sigma o graphology no cargaron — verifica /static/';
|
|
return;
|
|
}
|
|
|
|
if (!data.nodes || !data.nodes.length) {
|
|
empty.style.display = 'flex';
|
|
empty.textContent = 'Sin nodos extraidos';
|
|
if (renderer) { renderer.kill(); renderer = null; }
|
|
return;
|
|
}
|
|
|
|
empty.style.display = 'none';
|
|
|
|
// Construir el grafo en graphology
|
|
const Graph = graphology.Graph || graphology.default || graphology;
|
|
const g = new Graph({ multi: false, type: 'directed', allowSelfLoops: false });
|
|
|
|
data.nodes.forEach(n => {
|
|
if (!g.hasNode(n.id)) {
|
|
g.addNode(n.id, {
|
|
label: n.label,
|
|
x: n.x || Math.random() * 10,
|
|
y: n.y || Math.random() * 10,
|
|
size: 10,
|
|
color: TYPE_COLOR[n.type] || '#888',
|
|
});
|
|
}
|
|
});
|
|
|
|
data.edges.forEach((e, i) => {
|
|
if (!g.hasNode(e.from) || !g.hasNode(e.to)) return;
|
|
if (e.from === e.to) return;
|
|
const eid = `e${i}`;
|
|
if (!g.hasEdge(e.from, e.to)) {
|
|
g.addEdgeWithKey(eid, e.from, e.to, {
|
|
label: e.label || '',
|
|
size: 1.5,
|
|
color: '#666',
|
|
type: 'arrow',
|
|
});
|
|
}
|
|
});
|
|
|
|
// Re-instanciar el renderer
|
|
if (renderer) { renderer.kill(); renderer = null; }
|
|
container.innerHTML = '';
|
|
renderer = new Sigma(g, container, {
|
|
renderEdgeLabels: true,
|
|
defaultEdgeType: 'arrow',
|
|
edgeLabelSize: 9,
|
|
edgeLabelColor: { color: '#aaa' },
|
|
labelColor: { color: '#fff' },
|
|
labelSize: 12,
|
|
labelDensity: 1.0,
|
|
labelGridCellSize: 80,
|
|
labelRenderedSizeThreshold: 6,
|
|
minCameraRatio: 0.05,
|
|
maxCameraRatio: 6,
|
|
});
|
|
}
|
|
|
|
document.getElementById('btn').onclick = async () => {
|
|
const text = document.getElementById('input').value.trim();
|
|
if (!text) { alert('Pega algo de texto'); return; }
|
|
const threshold = parseFloat(document.getElementById('threshold').value);
|
|
const btn = document.getElementById('btn');
|
|
const status = document.getElementById('status');
|
|
btn.disabled = true;
|
|
const estChunks = Math.max(1, Math.ceil(text.length / 1500));
|
|
status.textContent = estChunks > 1
|
|
? `procesando ${estChunks} chunks (~${(estChunks * 1.5).toFixed(0)}s)…`
|
|
: 'procesando...';
|
|
try {
|
|
const res = await fetch('/extract', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ text, threshold }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'extract failed');
|
|
document.getElementById('kpi-nodes').textContent = data.n_nodes;
|
|
document.getElementById('kpi-edges').textContent = data.n_edges;
|
|
document.getElementById('kpi-time').textContent = data.elapsed_s + 's';
|
|
|
|
// Texto de relaciones — alineado para legibilidad
|
|
const relsText = (data.edges || []).length
|
|
? (() => {
|
|
const padFrom = Math.max(...data.edges.map(e => e.from.length));
|
|
const padKind = Math.max(...data.edges.map(e => (e.label || '').length));
|
|
return data.edges.map(e =>
|
|
`${e.from.padEnd(padFrom)} --[${(e.label || '').padEnd(padKind)}]--> ${e.to}`
|
|
).join('\n');
|
|
})()
|
|
: '(sin relaciones — prueba a bajar threshold o cambiar el texto)';
|
|
document.getElementById('relations-text').textContent = relsText;
|
|
|
|
// Entidades agrupadas por tipo
|
|
const byType = {};
|
|
(data.nodes || []).forEach(n => {
|
|
const t = n.type || '?';
|
|
if (!byType[t]) byType[t] = [];
|
|
byType[t].push(n.id);
|
|
});
|
|
document.getElementById('entities-text').textContent =
|
|
Object.keys(byType).sort().map(t =>
|
|
`${t} (${byType[t].length}):\n ${byType[t].sort().join(', ')}`
|
|
).join('\n\n') || '(sin entidades)';
|
|
|
|
// JSON completo (pretty)
|
|
document.getElementById('raw-json').textContent = JSON.stringify({
|
|
n_nodes: data.n_nodes,
|
|
n_edges: data.n_edges,
|
|
n_chunks: data.n_chunks,
|
|
n_dropped_typed: data.n_dropped_typed,
|
|
elapsed_s: data.elapsed_s,
|
|
nodes: (data.nodes || []).map(n => ({ id: n.id, type: n.type })),
|
|
edges: data.edges,
|
|
}, null, 2);
|
|
|
|
document.getElementById('dropped').textContent = (data.dropped || []).length
|
|
? data.dropped.map(d => `${d.from} (${d.head_type}) -[${d.kind}]-> ${d.to} (${d.tail_type})`).join('\n')
|
|
: '(ninguna — el filtro typed no descarto nada)';
|
|
const chunkInfo = data.n_chunks > 1 ? ` · ${data.n_chunks} chunks` : '';
|
|
status.textContent = `${data.n_nodes} nodos · ${data.n_edges} aristas · ${data.elapsed_s}s${chunkInfo}`;
|
|
renderGraph(data);
|
|
} catch (e) {
|
|
console.error('[playground] extract failed:', e);
|
|
alert('Error: ' + e.message);
|
|
status.textContent = 'error';
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
};
|
|
|
|
document.getElementById('input').addEventListener('keydown', e => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') document.getElementById('btn').click();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|