docs(issues): plan enrichers asincronos + recoleccion web (0026-0030)

Cinco issues que componen el plan:
- 0026: sistema de jobs (infra, contrato wire)
- 0027: tipo Webpage + cache de documentos
- 0028: enricher fetch_webpage (MVP end-to-end)
- 0028b: enrichers extract_domain / extract_links / extract_text_entities
- 0029: variantes CDP (Chrome headless, screenshot)
- 0030: macro "Deep enrich" + expand_domain

Tambien anade los issues previos 0012-0025 que estaban untracked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 18:24:13 +02:00
parent ce60c55619
commit 9042110ea2
20 changed files with 1193 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
---
id: 0012
title: Endpoint HTTP local de ingesta y consulta
status: pending
priority: medium
created: 2026-05-01
---
## Objetivo
Exponer un servidor HTTP local en `graph_explorer` (o como `ingest_server`
hermano) que sea el punto de entrada unico para todo flujo externo:
extension de navegador, CLI `gx`, watcher de portapapeles, bots, OCR, etc.
Sin este endpoint cada cliente externo tendria que abrir `operations.db`
directamente — colisiona con el lock del proceso vivo y duplica logica de
extraccion.
## Endpoints minimos
- `POST /entity` — crea entidad. Body: `{type, name, metadata}`.
- `POST /relation` — crea relacion. Body: `{from_id, to_id, kind, metadata}`.
- `POST /ingest/text` — texto libre -> `extract_graph_hybrid` -> preview o auto-commit.
- `POST /ingest/url` — URL -> fetch + extract -> preview o auto-commit.
- `POST /ingest/file` — multipart upload (PDF/CSV/JSON/.eml/imagen) -> router por mime -> extract.
- `GET /search?q=` — fuzzy / FTS sobre entidades.
- `GET /entity/:id`, `GET /entity/:id/neighbors`.
## Decisiones
- Bind a `127.0.0.1` por defecto, puerto fijo (ej. 7878) o aleatorio
escrito a `~/.fn_graph_port`.
- Auth: token compartido en `~/.fn_graph_token` (header `X-Token`).
Generado al primer arranque.
- Modo "preview": las rutas de ingesta aceptan `?commit=false` y
devuelven entities/relations propuestas para que el cliente las muestre
antes de persistir. Cuando es `true`, escribe directo.
- Implementacion: httplib o Mongoose embebido en C++ (sin nuevas deps
pesadas). Alternativa: lanzar el servidor en Go/Python aparte si la
integracion C++ se complica.
## Bloquea
Issues 0014, 0017, 0018, 0019, 0020 dependen de este.
## Definicion de hecho
- `curl -H "X-Token: ..." -d '{"type":"person","name":"X"}' localhost:7878/entity`
crea entidad.
- `POST /ingest/text` con texto en castellano devuelve entities/relations
detectadas por el pipeline hibrido.
- El endpoint corre en background mientras la UI sigue interactiva.
- Si `graph_explorer` no esta abierto, un binario `ingest_server`
standalone ofrece el mismo API contra la misma `operations.db`.
+45
View File
@@ -0,0 +1,45 @@
---
id: 0013
title: Panel "Paste & Extract" — texto libre a entidades con extract_graph_hybrid
status: pending
priority: high
created: 2026-05-01
---
## Objetivo
Panel dockeable dentro de `graph_explorer` con un textarea grande. Pegas
texto (articulo, mensaje, transcripcion, documento), pulsas Extract, corre
el pipeline `extract_graph_hybrid` (regex + GLiNER + GLiREL + LLM fallback)
y muestra preview de entidades y relaciones detectadas. El usuario marca
cuales aceptar antes de commit a `operations.db`.
Es el quick-win de mas alto valor: aprovecha el pipeline ya mergeado
(commit 1a353878) y elimina la friccion de tipear datos a mano.
## Alcance
- Panel "Extract" con textarea, combo de proyecto/tipos esperados, boton
"Extract".
- Lanza el pipeline en hilo aparte (es Python — invocar via subprocess
o el endpoint HTTP de 0012 con `commit=false`).
- Tabla de entidades propuestas: checkbox, type, name, source span. Tabla
de relaciones propuestas: from, kind, to, checkbox.
- Edicion inline de tipo/nombre antes de commit.
- "Apply selected" -> escribe a operations.db, refresca grafo, posiciona
los nuevos nodos cerca del centro o vinculados al ultimo seleccionado.
- Dedupe: si una entidad propuesta ya existe (mismo type+name) reusar el
id en lugar de duplicar.
## Decisiones
- Invocacion del pipeline: via 0012 si esta disponible, o subprocess
directo como fallback (para que el panel funcione sin levantar HTTP).
- Resaltado de spans en el textarea (v2 — primera version solo lista).
## Definicion de hecho
- Pego un parrafo en castellano sobre una empresa y un directivo, pulso
Extract, veo entidades correctas tipadas y la relacion entre ambas.
- Apply crea los nodos en el grafo en menos de 1 s tras click.
- Re-extraer el mismo texto no duplica entidades (dedupe funciona).
+49
View File
@@ -0,0 +1,49 @@
---
id: 0014
title: Extension de navegador "Add to graph"
status: pending
priority: high
created: 2026-05-01
depends_on: [0012]
---
## Objetivo
Extension Firefox/Chrome que añade items al grafo desde el navegador con
un click. Cubre el flujo Maltego "estoy leyendo algo en web -> nodo en
mi grafo" sin abandonar el navegador.
## Casos
- Click derecho sobre seleccion de texto -> "Add to graph" (manda texto
via `/ingest/text`).
- Click derecho sobre link -> "Add link" (crea entidad URL + metadata
del href, opcionalmente trigger fetch).
- Boton de toolbar -> "Add this page" (URL + titulo + meta description +
texto principal extraido con Readability).
- Modo "select & relate": dos selecciones consecutivas -> crea relacion
entre las entidades resultantes.
## Alcance
- WebExtension API (compatible Firefox/Chrome, Manifest v3).
- Settings: URL del endpoint (default `http://localhost:7878`), token,
proyecto destino.
- Preview popup tras extraccion: muestra entities propuestas, el usuario
acepta o edita antes de commit (reusa `?commit=false` de 0012).
- Atajo configurable (ej. `Ctrl+Shift+G`) para "add page".
## Decisiones
- Sin auth OAuth — token local compartido es suficiente para localhost.
- Empaquetar en `apps/graph_explorer/extension/` o como sub-repo propio
bajo `dataforge/graph_explorer_extension`.
- Si `graph_explorer` no esta corriendo: la extension muestra error
claro y guarda la accion en cola para reintentar.
## Definicion de hecho
- Selecciono un parrafo en una pagina, click derecho -> Add, en menos de
2 s veo los nodos en `graph_explorer`.
- Funciona en Firefox y Chrome con la misma build.
- Reintento automatico de la cola cuando vuelve a haber endpoint vivo.
+47
View File
@@ -0,0 +1,47 @@
---
id: 0015
title: Drag & drop de archivos sobre el viewport para ingesta
status: pending
priority: medium
created: 2026-05-01
---
## Objetivo
Soltar archivos sobre la ventana de `graph_explorer` lanza el extractor
adecuado segun extension y mete las entidades en el grafo, sin abrir
modales ni navegar menus.
## Tipos soportados
- `.pdf` -> texto + `extract_graph_hybrid`.
- `.eml` / `.msg` -> headers (from/to/cc) como entidades persona/email +
cuerpo via extract.
- `.csv` / `.parquet` -> ingesta como tabla DuckDB (encadena con 0011).
- `.json` / `.jsonl` -> si tiene shape entity/relation, importar; si no,
extract sobre stringify.
- `.png` / `.jpg` -> OCR (issue 0019) y luego extract.
- `.txt` / `.md` -> extract directo.
## Alcance
- Hook de drop de ImGui -> dispatcher por mime/extension -> pipeline
correspondiente -> preview con seleccion antes de commit (igual UX que
0013).
- Indicador visual de zona drop activa cuando hay drag sobre la ventana.
- Multiples archivos en un drop: procesar en cola, mostrar progreso.
## Decisiones
- Dispatcher reutiliza `/ingest/file` del endpoint 0012 si esta vivo, o
resuelve localmente como fallback.
- Limite de tamaño por archivo configurable (default 50 MB) para evitar
bloqueos en PDFs gigantes.
## Definicion de hecho
- Suelto un PDF en castellano sobre el canvas, en menos de 30 s veo
preview con entidades correctas.
- Suelto un .eml y aparecen `from`/`to` como nodos persona conectados
por una relacion `mailed`.
- Cancelar durante el preview no toca operations.db.
+44
View File
@@ -0,0 +1,44 @@
---
id: 0016
title: Watcher de portapapeles con deteccion de patrones
status: pending
priority: low
created: 2026-05-01
---
## Objetivo
Servicio (toggle desde la toolbar) que escucha el portapapeles y, cuando
detecta patrones de interes, ofrece añadir como entidad sin abandonar el
flujo en otra app. Pensado para sesiones de OSINT manual donde el coste
de "abrir la app y tipear" rompe el ritmo.
## Patrones detectados
- URL -> entidad URL (con fetch + extract opcional).
- Email, telefono, IBAN, DNI/NIE/CIF, BIC -> entidad tipada con regex.
- Coordenadas (lat,lon), hash (sha1/sha256), wallet crypto (BTC/ETH).
## Alcance
- Polling del clipboard (ImGui `GetClipboardText` + diff) o API nativa
(X11 selection / Win32 clipboard listener).
- Toast / notificacion no intrusiva con boton "Add". El usuario decide
por defecto.
- Modo "auto-add" para tipos seguros (IBAN/DNI raras veces son ruido).
- Lista de patrones configurable en `graph_explorer.db`.
## Decisiones
- Por defecto OFF — opt-in desde settings, para evitar leer todo lo que
el usuario copia.
- Anonimizar logs: nunca persistir el contenido del clipboard si el
usuario no lo añade.
- Deduplicar: copiar la misma cadena dos veces seguidas no notifica.
## Definicion de hecho
- Activo el watcher, copio un IBAN, recibo notificacion, click en Add y
el nodo aparece en el grafo.
- Apagar el watcher detiene la escucha en menos de 1 s.
- Patrones configurados como lista de regex editable desde settings.
+38
View File
@@ -0,0 +1,38 @@
---
id: 0017
title: CLI `gx` para hablar con el endpoint local
status: pending
priority: medium
created: 2026-05-01
depends_on: [0012]
---
## Objetivo
Cliente CLI fino, instalable en `~/.local/bin/gx`, que habla con el
endpoint HTTP local de `graph_explorer` (issue 0012). Permite ingesta y
consulta desde terminal o scripts sin abrir la app.
## Comandos
- `gx add <type> <name> [--metadata k=v ...]` — crea entidad.
- `gx rel <from_id> <kind> <to_id>` — crea relacion.
- `gx ingest <file>` — manda archivo al endpoint, abre preview en TUI.
- `gx from-url <url>` — fetch + extract.
- `gx search "query"` — devuelve hits del grafo activo (json o tabla).
- `gx neighbors <id> [--depth N]`.
- `gx open <id>` — abre el grafo y enfoca el nodo en `graph_explorer`.
## Decisiones
- Implementar como sub-comando del `fn` CLI existente (`fn gx ...`) o
binario aparte? Probablemente sub-comando para reusar config y auth.
- Output JSON por defecto si stdout no es TTY (componible con jq).
- Tabla legible si stdout es TTY.
## Definicion de hecho
- `gx add person "Juan Perez"` añade el nodo en el grafo en vivo.
- `gx ingest articulo.pdf` lanza preview interactivo en terminal y commit.
- `gx neighbors <id> --depth 2 --format json | jq` funciona en pipeline.
- Errores de conexion al endpoint se reportan claros (no stack traces).
@@ -0,0 +1,54 @@
---
id: 0018
title: Transforms automatizadas tipo Maltego con browser headless
status: pending
priority: low
created: 2026-05-01
depends_on: [0012]
---
## Objetivo
Dada una entidad seleccionada, ejecutar un script (Playwright/Puppeteer)
que enriquece el grafo con datos derivados — equivalente a las
"transforms" de Maltego. Es la pieza que diferencia frente a alternativas
mas estaticas.
## Ejemplos para banking/OSINT espanol
- Persona/empresa -> consulta BORME, registro mercantil, axesor.
- Dominio -> whois, DNS records, certificados (crt.sh).
- Email -> haveibeenpwned, hunter.io.
- Telefono -> truecaller-like.
- Empresa -> LinkedIn search publico, opencorporates.
## Alcance
- Cada transform es un pipeline del registry con tag `transform` y un
contrato fijo: input = `{id, type, metadata}`, output = `{entities,
relations}`.
- Registro de transforms aplicables por entity_type.
- UI: context menu sobre nodo -> "Run transform..." -> lista filtrada
por type aplicable -> ejecuta async -> notifica al terminar -> preview
antes de commit.
- Sandbox: cada transform en proceso aparte, timeout configurable.
## Riesgos y mitigaciones
- Los scrapers se rompen cuando los sitios cambian -> mantener una suite
de "transform health checks" automaticos (cron ligero) que avisa de
fallos antes de que el usuario los descubra en vivo.
- Cumplimiento legal y robots.txt -> documentar en cada transform su
fuente, politica y ToS.
- Rate limiting -> cooldown por host configurable.
## Definicion de hecho
- Selecciono un dominio en el grafo, lanzo "whois", aparecen registrant,
registrar y nameservers como nodos vinculados con relaciones tipadas.
- Un transform que falla loguea el error y no afecta a otros que
esten corriendo en paralelo.
- La lista de transforms aplicables a una entidad se computa segun su
type (no se ofrecen los inaplicables).
- Health check cron escribe a `proposals` cuando un transform empieza a
fallar repetidamente.
+39
View File
@@ -0,0 +1,39 @@
---
id: 0019
title: OCR de region de pantalla y archivos imagen
status: pending
priority: low
created: 2026-05-01
depends_on: [0012]
---
## Objetivo
Capturar una region de pantalla (atajo global) o soltar imagen sobre la
app (issue 0015) -> Tesseract / PaddleOCR -> texto -> `extract_graph_hybrid`.
Util cuando la fuente solo esta como captura, PDF escaneado, o pantalla
de un sistema sin copy/paste.
## Alcance
- Captura: usar herramienta del SO (gnome-screenshot, flameshot, snipping
tool) con flag de region. Linux primero, Windows con Snip & Sketch.
- OCR: Tesseract con datos de espanol (`spa.traineddata`). PaddleOCR
como alternativa para texto manuscrito o calidades bajas.
- Pipeline: imagen -> OCR -> texto -> panel preview de 0013.
## Decisiones
- Atajo global configurable (default `Ctrl+Alt+G`).
- Idiomas OCR como lista en settings (default `[spa, eng]`).
- Persistir la imagen original como `metadata.source_image_path` en la
entidad creada para trazabilidad.
## Definicion de hecho
- Atajo abre selector de region, capturo un parrafo en pantalla, en
menos de 5 s veo entidades extraidas.
- Suelto un PNG con texto sobre el canvas, mismo flujo (encadena con 0015).
- Calidad de OCR para espanol > 90% en capturas estandar 1080p de texto
impreso.
+46
View File
@@ -0,0 +1,46 @@
---
id: 0020
title: Ingesta via email forwarding y bot Telegram/Signal
status: pending
priority: low
created: 2026-05-01
depends_on: [0012]
---
## Objetivo
Ingerir entidades sin estar delante del PC. Util para capturar cosas
sobre la marcha (movil, lectura en otra pantalla, conversaciones).
## Canales
- **Email**: direccion dedicada (mailbox o alias) que se chequea via
IMAP cada N minutos. Adjuntos -> ingesta como en 0015. Cuerpo ->
extract.
- **Bot Telegram/Signal**: forwardear un mensaje, una imagen, o escribir
comandos (`/add empresa Acme`, `/relate Acme owns Bravo`). El bot
habla con el endpoint 0012.
## Alcance
- Cliente IMAP minimo o uso de un MTA local (postfix+dovecot) que
redirija a un script.
- Bot Telegram: BotFather + python-telegram-bot o equivalente Go (vive
en `apps/<bot_name>/` con tag `service`).
- Auth: solo procesar mensajes de chat IDs / direcciones whitelisted en
config.
- Confirmacion: el bot responde con preview ("¿añado estas 3 entidades?
responde 'si' o 'no'") antes de commit.
## Decisiones
- Bot self-hosted (no SaaS) — corre como service en VPS o en el PC.
- Multiples grafos: el bot puede targetear distintos `operations.db`
segun el chat de origen (mapping en config).
## Definicion de hecho
- Reenvio un PDF a la direccion dedicada y, en menos de 2 minutos, veo
las entidades en el grafo con notificacion del bot.
- El bot rechaza mensajes de chat IDs no autorizados sin responder.
- Comando `/search Acme` desde el bot devuelve hits del grafo.
+42
View File
@@ -0,0 +1,42 @@
---
id: 0021
title: Command palette Ctrl+K — busqueda y acciones globales
status: pending
priority: high
created: 2026-05-01
---
## Objetivo
Atajo `Ctrl+K` (configurable) abre overlay flotante con input de busqueda
fuzzy global. Lo que mas acelera el dia a dia: cero navegacion por menus
para encontrar un nodo o disparar una accion.
## Alcance
Indexa y matchea sobre:
- Entidades del grafo (por name, type, metadata).
- Acciones de la app ("Toggle inspector", "Save layout", "Run transform",
"Export subgraph", "Switch project", "Open settings").
- Comandos recientes (MRU al tope sin escribir).
Selecciono con flechas + Enter -> ejecuta accion o enfoca nodo en
el viewport.
## Implementacion
- Overlay modal centrado, input de texto + lista virtualizada
(`ImGuiListClipper`).
- Indexador en memoria sobre entidades; refresh al cambiar grafo.
- Fuzzy matcher (fzf-like, p.ej. `fts_fuzzy_match` de Forrest the woods,
o algo equivalente).
- Acciones registrables desde cualquier panel — registro central tipo
`cmd_palette_register("name", lambda)`.
## Definicion de hecho
- Ctrl+K, escribo 3 letras del nombre de un nodo, lo enfoca en el grafo.
- Ctrl+K, "exp", veo accion "Export subgraph as Markdown" disponible.
- Latencia de matching imperceptible con 50k entidades.
- MRU pone arriba lo usado recientemente.
+41
View File
@@ -0,0 +1,41 @@
---
id: 0022
title: Consulta del grafo en lenguaje natural via LLM
status: pending
priority: medium
created: 2026-05-01
depends_on: [0001]
---
## Objetivo
Input de texto ("personas relacionadas con BancoX que aparecen en mas de
3 documentos") -> LLM traduce a SQL sobre `operations.db` o a un set de
filtros sobre el grafo en memoria -> resalta el subgrafo resultante.
Complementa el chat de 0001 con un modo "consulta puntual" sin
conversacion: input -> resultado destacado, sin chat history.
## Alcance
- Tool-use ya disponible en 0001 (`query_entities`, `query_relations`).
- Modo "highlight": en lugar de devolver texto, el LLM emite un set de
ids -> la UI dibuja el subgrafo con resaltado y oscurece el resto.
- Boton "save as filter" -> persiste como vista nombrada (issue 0023).
- Historial de queries recientes en un dropdown.
- Indicador de query en curso (puede tardar varios segundos).
## Decisiones
- ¿Mismo cliente HTTP/Anthropic que 0001 o duplicado? Reusar.
- Modelo por defecto el mismo que 0001 (`claude-sonnet-4-6`).
- Query schema (que campos ve el LLM) dado por `types_registry` para
que aprenda los nombres de campos del proyecto.
## Definicion de hecho
- "personas con mas de 5 conexiones" devuelve subgrafo correcto.
- "documentos publicados en 2025" funciona si la metadata tiene fechas.
- Fallo silencioso (LLM mal interpreta) -> mensaje claro y opcion de
reintentar refinando.
- Query guardada como filtro reutilizable.
+39
View File
@@ -0,0 +1,39 @@
---
id: 0023
title: Vistas guardadas y filtros nombrados
status: pending
priority: medium
created: 2026-05-01
---
## Objetivo
Guardar combinaciones de filtros (tipo, tag, FTS, layout, zoom, nodos
fijados) bajo un nombre y reaplicarlas con un click o atajo.
Util para volver siempre al "mapa de la red de empresa X" o "vista de
emails sospechosos" sin reconfigurar todo cada vez.
## Alcance
- Tabla `saved_views(graph_hash, name, payload_json, hotkey, created_at)`
en `graph_explorer.db`.
- Panel/menu "Views" con lista, atajos asignables (Ctrl+1..9).
- Payload incluye: filtros activos, expanded nodes, viewport rect, layout
mode, theme overrides, nodos pinned.
- Boton "Save current as view..." en toolbar.
- Boton "Update view" cuando una view esta activa y el usuario cambia algo.
## Decisiones
- Las views son por `graph_hash` (no globales) — cada `operations.db`
tiene su set propio.
- Compartir view entre PCs: export/import JSON manual (v2 podria sync via
`fn sync`).
## Definicion de hecho
- Configuro filtros, "Save view as 'Banca'", la veo en el menu.
- Reload de la app -> "Banca" aplica todo lo guardado.
- Un atajo (Ctrl+1..9) salta a la vista correspondiente al instante.
- "Update view" persiste cambios sin crear duplicados.
+45
View File
@@ -0,0 +1,45 @@
---
id: 0024
title: Exportar subgrafo seleccionado a Markdown / Mermaid / CSV / PNG
status: pending
priority: medium
created: 2026-05-01
---
## Objetivo
Seleccion de nodos (rect drag o filtro activo) -> menu "Export as..." con
varios formatos de salida segun el destino.
## Formatos
- **Markdown**: una pagina por entidad con sus campos y links a vecinos.
Encaja con 0025 (sync con vault).
- **Mermaid `graph TD`**: para pegar en notas o issues.
- **CSV**: dos archivos `nodes.csv` + `edges.csv` para Gephi/Cytoscape.
- **PNG / SVG**: render del subgrafo con layout actual.
- **JSON**: shape `{nodes:[], edges:[]}` para reimportar o procesar.
## Alcance
- Menu "Export selected" en context menu del canvas y en menu superior.
- Cada exportador es una funcion del registry reutilizable
(`export_subgraph_md_cpp_viz`, `export_subgraph_mermaid_cpp_viz`, etc).
- Para PNG/SVG: reusar el render actual a un framebuffer offscreen, con
factor de escalado configurable (1x / 2x / 4x).
- Diccionario de plantillas configurable para Markdown (por entity_type).
## Decisiones
- Mermaid copiado al portapapeles automaticamente; otros formatos
abren dialogo de guardado.
- Limite suave a 500 nodos para Mermaid (ilegible mas alla).
## Definicion de hecho
- Selecciono 20 nodos, exporto Markdown -> directorio con 20 .md y
enlaces cruzados validos.
- Exporto Mermaid -> string copiado al portapapeles, valido en
mermaid.live.
- Exporto PNG con layout fijo, calidad 2x, fidelidad pixel a la vista.
- CSV importable directo en Gephi sin transformaciones.
+44
View File
@@ -0,0 +1,44 @@
---
id: 0025
title: Sync bidireccional con vault Obsidian / markdown
status: pending
priority: low
created: 2026-05-01
depends_on: [0024]
---
## Objetivo
Espejar el grafo activo a un vault de markdown (estilo Obsidian) en
`projects/osint_graph/vaults/<name>/`. Cada entidad = una nota; cada
relacion = un wikilink. El usuario puede navegar el grafo desde Obsidian
y editar campos alli; los cambios vuelven al grafo.
Encaja con `vaults/` ya conceptualizados en el registry.
## Alcance
- Watcher de filesystem + serializer/parser de notas con frontmatter
YAML para los campos del entity_type.
- Plantilla por entity_type configurable (apoyandose en el exporter
Markdown de 0024).
- Resolucion de conflictos: timestamp + merge campo a campo; preferencia
configurable (vault wins / db wins / prompt).
- Modo unidireccional inicial (graph -> vault) si la ida y vuelta es
mucho trabajo. v2 anade sync de vuelta.
## Decisiones
- Sync continuo o on-demand (boton "Sync now")? Empezar on-demand. El
watcher se anade en una segunda fase.
- Detectar cambios externos via `mtime` + checksum.
- Wikilinks usan ids del registry, no nombres (estables ante renames).
## Definicion de hecho
- Boton "Sync to vault" genera N notas con frontmatter correcto y
wikilinks navegables en Obsidian.
- Editar un campo en la nota y "Sync from vault" actualiza la entidad
en operations.db.
- No se pierden datos cuando hay edicion concurrente en ambos lados
(resolucion de conflicto explicita).
+209
View File
@@ -0,0 +1,209 @@
---
id: 0026
title: Sistema de jobs — enrichers asincronos en background
status: in_progress
priority: high
created: 2026-05-01
blocks: [0027, 0028, 0029, 0030]
supersedes: [0001, 0002, 0003]
---
## Objetivo
Convertir el menu "Run enricher" (hoy placeholder en main.cpp:485) en un
sistema real de jobs asincronos: el usuario lanza un enricher sobre un
nodo, vuelve a la app y sigue trabajando mientras el enricher procesa
en background. Al terminar, el grafo se recarga automaticamente con las
entidades/relaciones nuevas.
Este issue solo cubre la **infra**. Los enrichers concretos se escriben
en 0028, 0029, 0030.
## Decisiones tomadas
1. **Workers concurrentes**: 2 por defecto, configurable via Settings.
2. **Cache de documentos**: `<app_dir>/cache/<sha256[0:2]>/<sha256>.{html,md,png}`. Carpeta gitignored en el sub-repo.
3. **Webpage vs Url**: tipos separados (issue 0027). Url = link suelto, Webpage = documento descargado con cuerpo.
4. **Subprocess Python por job** (no daemon residente): cold start ~200 ms aceptable. Si molesta, issue futura.
5. **Estado en `graph_explorer.db`** (NO en `operations.db`): jobs son especificos de la app, no del grafo.
## Tabla `jobs` (graph_explorer.db)
```sql
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY, -- ULID
enricher_id TEXT NOT NULL, -- ej: "fetch_webpage"
node_id TEXT, -- nodo objetivo (NULL si batch)
node_name TEXT DEFAULT '', -- snapshot para mostrar en UI
params_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL, -- queued|running|done|error|cancelled
progress REAL NOT NULL DEFAULT 0, -- 0..1
stage TEXT NOT NULL DEFAULT '', -- mensaje corto: "fetching", "extracting"
result_json TEXT, -- {entities_added: N, relations_added: M, ...}
error TEXT,
pid INTEGER, -- subprocess pid para cancelar
created_at INTEGER NOT NULL,
started_at INTEGER,
finished_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status, created_at);
```
## Runtime C++
### `jobs.{h,cpp}` (nuevo)
- `JobRunner`: pool de N std::thread workers (default 2).
- Cola en memoria `std::queue<JobId>` + persistencia en BD.
- API publico:
- `bool jobs_init(const char* db_path, int n_workers);`
- `bool jobs_submit(const char* enricher_id, const char* node_id, const char* params_json, char* out_id);`
- `bool jobs_cancel(const char* job_id);`
- `void jobs_shutdown();`
- `int jobs_dirty_counter();` // incrementa al completar un job; render lo lee
- `bool jobs_list(std::vector<JobRow>* out);`
- Worker hace:
1. Pop de cola → mark running, started_at = now, pid = subprocess pid.
2. Spawn `python/.venv/bin/python3 enrichers/<id>/run.py` con stdin = JSON.
3. Lee stderr line-by-line buscando `PROGRESS:<float> <stage>` para actualizar fila.
4. Lee stdout completo al cerrar — JSON final con entities/relations/node_updates.
5. Aplica al `operations.db` desde el worker (entity_insert/relation_insert/entity_update).
6. Marca done o error con result_json/error, finished_at, increment dirty_counter.
- Al arrancar: marca jobs `running` huerfanos como `error: "process died"`.
### `enrichers.{h,cpp}` (nuevo)
- Escanea `enrichers/*/manifest.yaml` al arrancar.
- Estructura `EnricherSpec`:
```
std::string id, name, description;
std::vector<std::string> applies_to; // tipos validos
std::vector<EnricherParam> params;
std::string run_path; // enrichers/<id>/run.py absoluto
```
- API:
- `void enrichers_load(const char* enrichers_dir);`
- `std::vector<EnricherSpec> enrichers_for_type(const char* type_ref);`
## Contrato enricher (wire protocol)
Cada enricher vive en `apps/graph_explorer/enrichers/<id>/`:
```
enrichers/
fetch_webpage/
manifest.yaml
run.py
```
### `manifest.yaml`
```yaml
id: fetch_webpage
name: "Fetch web page"
description: "Descarga HTML, extrae markdown y guarda en cache."
applies_to: [Webpage, Url]
params:
- { name: timeout_s, type: int, default: 15 }
- { name: use_browser, type: bool, default: false }
```
### `run.py` — stdin/stdout/stderr
**stdin** (una linea JSON):
```json
{"node_id":"webpage_123","node_type":"Webpage","node_name":"...",
"metadata":{"url":"https://..."},"params":{"timeout_s":15},
"db_path":"/path/operations.db","cache_dir":"/path/cache",
"registry_root":"/home/lucas/fn_registry"}
```
**stderr** (lineas de progreso, opcional):
```
PROGRESS:0.10 connecting
PROGRESS:0.50 parsing
PROGRESS:0.90 writing
```
**stdout** (una linea JSON al final):
```json
{"node_updates":[
{"id":"webpage_123","metadata_patch":{"title":"...","status_code":200}}
],
"entities":[
{"type":"Domain","name":"example.com","metadata":{}}
],
"relations":[
{"from_id":"webpage_123","to_name":"example.com","to_type":"Domain","kind":"BELONGS_TO"}
],
"notes":""}
```
Resolucion de relaciones:
- Si `to_id` esta presente, se usa directamente.
- Si no, se busca por `(to_name, to_type)` en operations.db; si no existe, se crea primero.
## UI
### Panel "Jobs"
- Nuevo panel dockeable (entry en `g_panels[]`).
- Tabla con columnas: enricher, target node (clicable → centra viewport), status (badge), progress bar, stage, duracion, error tooltip.
- Botones inline por fila: cancelar (running), reintentar (error/cancelled), borrar (terminal).
- Filtro: all | active | done | error.
### Toolbar
- Badge en la toolbar superior con contador de `running + queued`. Click abre el panel Jobs.
### Context menu (main.cpp:485)
Reemplazar el `TextDisabled("coming soon")` por:
```cpp
auto specs = ge::enrichers_for_type(node->type_ref);
if (specs.empty()) {
ImGui::TextDisabled("(no hay enrichers para este tipo)");
} else {
for (const auto& s : specs) {
if (ImGui::MenuItem(s.name.c_str())) {
char job_id[64];
ge::jobs_submit(s.id.c_str(), node->id.c_str(), "{}", job_id);
}
}
}
```
## Fases del bucle reactivo en BD
- **CONSTRUIR**: enricher escrito en `enrichers/<id>/`.
- **EJECUTAR**: subprocess corre, escribe progress en `jobs`.
- **RECOPILAR**: stdout JSON parseado, entities/relations aplicadas a operations.db.
- **ANALIZAR**: jobs.result_json contiene metricas (entities_added, duration_ms).
- **MEJORAR**: si un enricher falla repetidamente, futura issue de health checks.
## Cancelacion
- Boton "Cancel" en panel Jobs:
- Lee `pid` de la fila.
- `kill(pid, SIGTERM)` (Linux/WSL) o `TerminateProcess` (Windows).
- El worker captura el exit code y marca `cancelled`.
- Si el job aun no salio de la cola (status = queued), el worker simplemente no lo coge — al pop chequea status y skipea cancelled.
## Definicion de hecho
- Tabla `jobs` se crea al arrancar la app.
- `JobRunner` con 2 workers acepta `jobs_submit` y procesa.
- Panel Jobs muestra estado en tiempo real (progress bar avanza visiblemente).
- Cancelar mata subprocess y marca `cancelled`.
- Al completar, el grafo se recarga (dirty_counter detectado en render).
- Subir el contador de jobs en la toolbar.
- Test manual: enricher dummy `noop` (incluido en este issue) que duerme 3 s emitiendo PROGRESS y termina sin entidades. Lanzarlo y comprobar UI.
## Trabajo posterior
- 0027: tipo Webpage + cache.
- 0028: primer enricher real (`fetch_webpage`) end-to-end.
- 0028b: enrichers extract_domain, extract_links, extract_text_entities.
- 0029: enrichers via CDP (browser headless).
- 0030: macro "Deep enrich" + expand_domain.
+82
View File
@@ -0,0 +1,82 @@
---
id: 0027
title: Tipo Webpage + cache de documentos descargados
status: pending
priority: high
created: 2026-05-01
depends_on: [0026]
blocks: [0028, 0029, 0030]
---
## Objetivo
Anadir un tipo `Webpage` al `examples/types.yaml` y un layout
estandarizado de cache donde los enrichers guardan HTML, markdown y
screenshots descargados. El tipo `Url` existente queda como link suelto;
`Webpage` es un documento descargado con cuerpo.
## Cambios en `examples/types.yaml`
Anadir tras el bloque `Url`:
```yaml
- name: Webpage
color: "#89E0FC"
icon: ti-file-text
principal_field: url
fields:
- { name: url, type: url, required: true }
- { name: title, type: string }
- { name: status_code, type: int }
- { name: content_type, type: string }
- { name: fetched_at, type: date }
- { name: html_path, type: string } # cache/<sha256[0:2]>/<sha256>.html
- { name: markdown_path, type: string } # cache/<sha256[0:2]>/<sha256>.md
- { name: screenshot_path,type: string } # cache/<sha256[0:2]>/<sha256>.png
- { name: text_length, type: int }
- { name: lang, type: string }
```
## Layout del cache
```
<app_dir>/cache/
ab/
abcd1234...ef.html
abcd1234...ef.md
abcd1234...ef.png
cd/
cdef5678...01.html
...
```
- `sha256[0:2]` para evitar miles de archivos en un solo dir.
- Path absoluto desde `<app_dir>` para que sea portable entre PCs (paths relativos en metadata).
- `cache/` se anade al `.gitignore` del sub-repo.
## Helper C++
Funcion en `data.{h,cpp}` (o nuevo `cache_paths.{h,cpp}`):
```cpp
namespace ge {
// Resuelve el path absoluto donde un enricher debe escribir el blob.
// Crea el dir si no existe. ext sin punto: "html", "md", "png".
// hash_input: tipicamente la URL canonica (normalizada).
std::string cache_path(const char* app_dir,
const char* hash_input,
const char* ext);
}
```
- SHA256 calculado en C++ (usar implementacion existente en cpp/functions/core/ si la hay; si no, vendor/sqlite3 trae uno o se anade simple).
- O: el enricher Python calcula el sha256 (mas simple) y lo devuelve como parte de `node_updates`. Decidido: Python calcula el sha256, C++ solo expone `app_dir/cache/` como path absoluto al enricher.
## Definicion de hecho
- `Webpage` aparece en types.yaml con icono `ti-file-text`.
- El icono se renderiza correctamente (existe en tabler_codepoint_by_name).
- `cache/` esta en `.gitignore` del sub-repo del app.
- C++ pasa `cache_dir` al enricher en el JSON de stdin.
- Test manual: crear nodo `Webpage` desde el inspector, comprobar que
aparece con el color/icono correctos.
+78
View File
@@ -0,0 +1,78 @@
---
id: 0028
title: Enricher fetch_webpage (MVP end-to-end)
status: pending
priority: high
created: 2026-05-01
depends_on: [0026, 0027]
---
## Objetivo
Primer enricher real sobre el sistema de jobs (0026). Right-click sobre
un nodo `Url` o `Webpage` → "Fetch web page". Descarga el HTML, lo
convierte a markdown, guarda los blobs en cache, actualiza el nodo
(o lo convierte a Webpage si era Url) y crea el nodo `Domain` con
relacion `BELONGS_TO`.
Este enricher valida el contrato entero. Los siguientes (0028b) reusan
exactamente el mismo wire protocol.
## Archivos
```
apps/graph_explorer/enrichers/fetch_webpage/
manifest.yaml
run.py
```
## `manifest.yaml`
```yaml
id: fetch_webpage
name: "Fetch web page"
description: "Descarga HTML, extrae markdown limpio y guarda en cache."
applies_to: [Webpage, Url]
emits: [Domain]
relations: [BELONGS_TO]
params:
- { name: timeout_s, type: int, default: 15 }
```
## `run.py`
Logica:
1. Lee JSON de stdin.
2. Saca `url` de `metadata.url` (o `metadata.address` si es Url legacy).
3. `PROGRESS:0.05 normalize``normalize_url_py_cybersecurity`.
4. `PROGRESS:0.20 fetching` — descarga via `requests.get(url, timeout=N)`.
5. `PROGRESS:0.60 parsing``html_to_markdown_py_core` con readabilipy.
6. `PROGRESS:0.85 writing` — calcula sha256(url), escribe `cache/<sha[0:2]>/<sha>.html` y `.md`.
7. Emite stdout JSON:
- `node_updates`: cambia type a Webpage si era Url, anade title/status_code/content_type/fetched_at/html_path/markdown_path/text_length.
- `entities`: `{type: Domain, name: <dominio>, metadata: {}}`.
- `relations`: `from_id: <node_id>, to_name: <dominio>, to_type: Domain, kind: BELONGS_TO`.
## Funciones del registry usadas
- `normalize_url_py_cybersecurity` — limpia tracking params.
- `html_to_markdown_py_core` — readabilipy + markdownify.
- `extract_domain` se hace inline en el enricher (regex trivial sobre la URL parseada).
## Manejo de errores
- HTTP error (4xx/5xx) → escribe status_code en metadata pero NO marca el job como error (el nodo guarda evidencia del fallo).
- Timeout / DNS error / etc → exit con error JSON en stdout: `{"error": "...", "node_updates": [], "entities": [], "relations": []}`.
- Si el enricher levanta excepcion, sale con codigo != 0 y stderr capturado va a `jobs.error`.
## Definicion de hecho
- Crear nodo Url con `https://example.com` → click derecho → "Fetch web page".
- En segundos aparece en panel Jobs como `running` con progress.
- Al terminar:
- El nodo cambia a tipo `Webpage` con icono `ti-file-text`.
- El inspector muestra title, status_code, html_path, markdown_path.
- Aparece nodo `Domain` "example.com" conectado por `BELONGS_TO`.
- El archivo `cache/<sha>.md` existe en disco.
- El job aparece en panel Jobs como `done` con `entities_added=1, relations_added=1`.
- Tirar la red (sin internet) → el job acaba en `error` con mensaje claro.
+72
View File
@@ -0,0 +1,72 @@
---
id: 0028b
title: Enrichers extract_domain, extract_links, extract_text_entities
status: pending
priority: high
created: 2026-05-01
depends_on: [0028]
---
## Objetivo
Tres enrichers Python adicionales que reusan el contrato validado por
`fetch_webpage`. Cada uno cubre un eje de extraccion distinto.
## 1. `extract_domain`
```
applies_to: [Url, Webpage, Email]
emits: [Domain]
relations: [BELONGS_TO]
```
- Saca el dominio de `metadata.url` o `metadata.address`.
- Crea nodo `Domain` si no existe + relacion `BELONGS_TO`.
- Util cuando el usuario tiene un Url/Email que aun no ha sido fetched
pero quiere ver el dominio en el grafo.
## 2. `extract_links`
```
applies_to: [Webpage]
emits: [Url]
relations: [LINKS_TO]
```
- Lee `metadata.markdown_path`. Si vacio → exit con error "run fetch_webpage first".
- `extract_urls_py_cybersecurity` sobre el contenido.
- Para cada URL distinta encontrada:
- Crea nodo `Url` con `metadata.url` (si no existe).
- Relacion `LINKS_TO` desde la Webpage origen.
- Param: `max_links` (default 50) para no saturar el grafo.
## 3. `extract_text_entities`
```
applies_to: [Webpage]
emits: [Person, Org, Email, Phone, Domain, Location, IPAddress, CVE, ...]
relations: [EXTRACTED_FROM, ...relaciones que GLiREL detecte]
```
- Lee `metadata.markdown_path`.
- Llama `extract_graph_hybrid_py_pipelines` (regex IoCs + GLiNER + GLiREL + LLM fallback).
- Para cada entidad detectada:
- Resuelve por `(name, type)` en operations.db. Si no existe la crea.
- Relacion `EXTRACTED_FROM` desde la entidad nueva al nodo Webpage.
- Para cada relacion detectada por GLiREL:
- Relacion entre las dos entidades con el `kind` predicho.
- Params:
- `chunk_size` (default 2000)
- `use_llm_fallback` (default false — evitar coste; el usuario lo activa en jobs concretos)
## Definicion de hecho
- Los tres enrichers aparecen en el menu "Run enricher" segun el tipo
del nodo right-clickado.
- En un nodo Webpage el menu muestra los 3 + fetch_webpage.
- Test integracion:
- Crear Url → fetch_webpage → run extract_links sobre el resultado
→ run extract_text_entities → grafo se llena con persons/orgs/etc.
- Cada paso es un job independiente visible en panel Jobs.
- `extract_text_entities` con LLM off termina sin coste y produce
entidades de IoC + entidades GLiNER (gratis).
+63
View File
@@ -0,0 +1,63 @@
---
id: 0029
title: Enrichers via Chrome headless (CDP) — fetch_webpage_browser, fetch_screenshot
status: pending
priority: medium
created: 2026-05-01
depends_on: [0028]
---
## Objetivo
Variantes de los enrichers basicos que usan Chrome headless via CDP,
para sitios con contenido renderizado por JavaScript (SPA, paginas con
auth visual, etc.) o cuando se quiere capturar evidencia visual.
## 1. `fetch_webpage_browser`
```
applies_to: [Url, Webpage]
emits: [Domain]
relations: [BELONGS_TO]
params:
- { name: chrome_port, type: int, default: 9222 }
- { name: wait_after_load_ms, type: int, default: 1500 }
```
- Usa funciones del registry:
- `chrome_launch_go_browser` — lanza Chrome en port (reusa si ya esta).
- `cdp_connect_go_browser`
- `cdp_navigate_go_browser`
- `cdp_wait_load_go_browser`
- `cdp_get_html_go_browser` — DOM post-JS.
- El run.py shell-out a un binario Go pequeno o llama estas funciones via
un wrapper Python que invoca el Go function como subprocess.
- Decision pendiente: empaquetar las funciones Go en un binario CLI
`cdp-fetcher` que el run.py invoque, o reescribir la logica en Python
con `pychrome` / `playwright`. Preferencia: binario Go para reusar las
funciones del registry.
## 2. `fetch_screenshot`
```
applies_to: [Webpage, Url]
params:
- { name: full_page, type: bool, default: true }
```
- `cdp_screenshot_go_browser` → guarda `cache/<sha>.png`.
- `node_updates`: anade `screenshot_path` a metadata del Webpage.
- No emite entidades nuevas.
## Definicion de hecho
- `fetch_webpage_browser` extrae correctamente DOM de una SPA (test:
twitter.com, linkedin.com publico).
- `fetch_screenshot` produce PNG legible en el cache.
- Inspector del nodo Webpage muestra una preview del screenshot
cuando `screenshot_path` existe (mejora UI opcional).
## Out of scope
- Login flows / auth via CDP — fuera de v1.
- Adblock / fingerprint evasion — el user-agent default es suficiente.
+62
View File
@@ -0,0 +1,62 @@
---
id: 0030
title: Macro "Deep enrich" + enricher expand_domain
status: pending
priority: medium
created: 2026-05-01
depends_on: [0028, 0028b]
---
## Objetivo
Encadenar varios enrichers con un solo click. Cubre dos flujos:
1. **Deep enrich Webpage**: sobre un nodo Webpage, ejecuta en orden
`fetch_webpage` (si no fetched aun) → `extract_domain``extract_links`
`extract_text_entities`. Cuatro jobs separados, en cadena.
2. **Expand domain**: sobre un nodo Domain, fetch homepage + 1 nivel de
links + extraccion de entidades sobre cada pagina. Util para "dame
todo lo que sepas de este dominio en un click".
## Implementacion
### Macro Deep enrich (no es un enricher Python — es UI + orquestacion en C++)
- Boton/menu item "Deep enrich" en el context menu del nodo Webpage.
- Encolar 4 jobs con dependencias: cada job tiene `depends_on_job_id`.
- Worker pool respeta dependencias: si el job tiene depends_on y el
predecesor no esta `done`, lo deja en cola.
- Anadir columna a tabla `jobs`: `depends_on_job_id TEXT`.
### Enricher `expand_domain`
```
applies_to: [Domain]
params:
- { name: max_pages, type: int, default: 5 }
- { name: deep, type: bool, default: false } # si true, deep enrich cada pagina
```
- run.py:
1. Fetch `https://<domain>/` y `http://<domain>/` (probando ambos esquemas).
2. Crea Webpage homepage + relacion `HOMEPAGE_OF` desde Domain.
3. Si `deep`, encola un job `extract_text_entities` por pagina via
un endpoint local de control (out of scope v1) o emite un campo
especial `chained_jobs: [...]` que el worker C++ encola.
4. Decision: v1 solo crea las paginas. La cadena con extract_*
se puede hacer manualmente desde la UI o esperar a un sistema
de chained jobs decente.
## Definicion de hecho
- Click derecho en Webpage → "Deep enrich" → 4 jobs en cadena visibles
en panel Jobs. Al terminar el ultimo, el grafo tiene domain + links +
persons/orgs/etc.
- Click derecho en Domain → "Expand domain" → Webpage homepage aparece
conectada al Domain.
- Cancelar el job intermedio cancela en cascada los que dependen.
## Out of scope v1
- Cron / repeat schedule de enrichers.
- Progress agregado de la cadena (cada job mantiene su progress propio).