chore(issues): mover 7 issues completadas a issues/completed/
Status sincronizado con master: - 0001 chat con Claude -> shipped como panel Echo - 0003 enricher web -> shipped (0028 + 0028b) - 0026 sistema de jobs -> shipped - 0027 tipo Webpage -> shipped - 0028 fetch_webpage -> shipped - 0028b extract trio -> shipped - 0031 layout estable -> shipped Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
---
|
||||
id: 0001
|
||||
title: Chat con Claude sobre el grafo
|
||||
status: pending
|
||||
priority: high
|
||||
created: 2026-04-30
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Panel "Chat" dentro de graph_explorer que permita conversar con Claude
|
||||
(Anthropic API) usando el grafo activo como contexto. El agente debe poder:
|
||||
|
||||
- Leer el grafo (entidades, relaciones, metadata) via tool-use sobre operations.db.
|
||||
- Responder preguntas tipo: "muestrame los nodos relacionados con X", "que
|
||||
patrones ves en estas conexiones", "que falta investigar".
|
||||
- Proponer mutaciones (crear nodo, etiquetar relacion) que el usuario aprueba
|
||||
con un click antes de aplicarse.
|
||||
|
||||
## Alcance tecnico
|
||||
|
||||
- Cliente HTTP minimo (libcurl o WinHTTP) → POST a `https://api.anthropic.com/v1/messages`.
|
||||
- Modelo por defecto: `claude-sonnet-4-6` (revisar al implementar).
|
||||
- API key desde env var `ANTHROPIC_API_KEY` o `~/.fn_anthropic_key`.
|
||||
- Tool-use: definir tools `query_entities`, `query_relations`, `propose_node`,
|
||||
`propose_relation`. Las "propose_*" no mutan: insertan en una cola que el
|
||||
usuario revisa antes de aplicar.
|
||||
- Estado de conversacion en memoria (lista de messages). Persistencia opcional
|
||||
en `graph_explorer.db` tabla `chat_sessions`.
|
||||
- Streaming SSE para feedback en vivo (puede dejarse para v2 — primer hit
|
||||
bloqueante esta bien).
|
||||
|
||||
## Decisiones a tomar
|
||||
|
||||
- Renderizado de markdown en ImGui (TextWrapped basico vs lib externa).
|
||||
- Threading: bloqueante en hilo aparte → cola de mensajes → main thread lee.
|
||||
|
||||
## Trabajo previo
|
||||
|
||||
Ya existe en el registry `python/functions/agents/anthropic_chat_py_agents.py`
|
||||
para inspiracion (usa el SDK Python). En C++ usaremos HTTP directo — sin SDK.
|
||||
|
||||
## Definicion de hecho
|
||||
|
||||
- Panel "Chat" dockeable.
|
||||
- Conversacion con tool-use sobre operations.db funciona.
|
||||
- Las mutaciones propuestas por el agente se confirman desde la UI antes de
|
||||
llegar a la BD.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
id: 0003
|
||||
title: Enricher web — descargar URL/dominio y extraer texto
|
||||
status: pending
|
||||
priority: medium
|
||||
created: 2026-04-30
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Right-click sobre un nodo `url` o `domain` → "Run enricher → Fetch & extract
|
||||
text". Descarga el HTML, extrae el texto principal, crea un nodo `text`
|
||||
conectado al origen con relacion `FETCHED_FROM`.
|
||||
|
||||
Despues el usuario puede encadenar: sobre ese nodo `text`, ejecutar el enricher
|
||||
GLiNER+GLiREL (issue 0002) para extraer entidades.
|
||||
|
||||
## Alcance
|
||||
|
||||
- HTTP GET con timeout (libcurl o sys WinHTTP).
|
||||
- Extraccion de texto: regex/strip de tags simple en v1; v2 usa una lib
|
||||
(htmlparser2 / lexbor / boost.url + algo de heuristica).
|
||||
- User-agent identificativo, respeto de robots.txt opcional (out-of-scope v1).
|
||||
- Limite de tamaño descargable (1 MB) para evitar bloqueos.
|
||||
|
||||
## Modelo de etiquetado
|
||||
|
||||
- Nodo origen (url/domain) → arista `FETCHED_FROM` → nodo nuevo (text con
|
||||
metadata={fetched_at, status_code, content_type, length}).
|
||||
- Nombre del nodo text: titulo de la pagina (si <title> existe) o primeros
|
||||
120 caracteres del cuerpo.
|
||||
|
||||
## Definicion de hecho
|
||||
|
||||
- Funciona contra una URL real (https con TLS).
|
||||
- Maneja errores (404, timeout, redirects basicos) sin tumbar la app.
|
||||
- El nodo creado es visible y el texto se puede consumir por el enricher
|
||||
GLiNER+GLiREL del issue 0002.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
id: 0031
|
||||
title: Layout estable al recargar — auto-save, halo placement, sin fit, physics off
|
||||
status: in_progress
|
||||
priority: high
|
||||
created: 2026-05-01
|
||||
related_to: [0026]
|
||||
---
|
||||
|
||||
## Problema
|
||||
|
||||
Cuando un enricher (issue 0026) crea entidades nuevas, el `dirty_counter`
|
||||
dispara `want_reload` y el grafo pierde la disposicion que tenia el
|
||||
usuario:
|
||||
|
||||
1. **`graph_viewport_fit()` se llama en cada reload** → recentra y
|
||||
reescala la camara, sensacion de "todo se movio".
|
||||
2. **`layout_store_save` solo se ejecuta al pulsar "Save layout"** →
|
||||
si el usuario no lo pulsa, las posiciones en RAM se pierden y los
|
||||
nodos viejos vuelven a (0,0) tras el reload.
|
||||
3. **`layout_circular` se aplica si todos los nodos estan en (0,0)**
|
||||
tras reload → si no hay nada guardado en `layout_store`, todo se
|
||||
reorganiza en circulo.
|
||||
4. **Nodos creados por enrichers llegan en (0,0)** → quedan apilados
|
||||
sobre el origen tras `layout_store_load`. Con physics ON se reparten
|
||||
violentamente al chocar entre si.
|
||||
|
||||
## Decisiones (confirmadas por el usuario)
|
||||
|
||||
- **A. Auto-save antes de cada reload**: preservar las posiciones que el
|
||||
usuario tiene en RAM sin que tenga que pulsar "Save layout" jamas.
|
||||
- **B. No `graph_viewport_fit` en reloads**: solo en la primera carga
|
||||
de cada proyecto/archivo. La camara permanece donde la tenia el usuario.
|
||||
- **C. Halo placement para nodos huerfanos**: nodos que tras
|
||||
`layout_store_load` siguen en (0,0) se posicionan junto a su primer
|
||||
vecino con coordenadas conocidas, **garantizando no solapamiento** con
|
||||
nodos existentes ni entre ellos.
|
||||
- **D. No `layout_circular` en reloads**: la condicion `zero_pos == node_count`
|
||||
solo aplica en la primera carga.
|
||||
- **E. Physics siempre pausadas**: `layout_running = false` al cargar y
|
||||
al recargar. El usuario las activa explicitamente con el toggle
|
||||
Physics si quiere ver fuerzas.
|
||||
|
||||
## Implementacion
|
||||
|
||||
### main.cpp:want_reload (sustituye el bloque actual)
|
||||
|
||||
```cpp
|
||||
if (g_app.want_reload) {
|
||||
g_app.want_reload = false;
|
||||
|
||||
// (A) auto-save: persistir posiciones actuales antes de liberar grafo.
|
||||
if (g_loaded) ge::layout_store_save(g_graph_hash, g_graph);
|
||||
|
||||
graph::GraphLoadStats stats{};
|
||||
if (ge::reload_graph(g_input, &g_graph, &stats)) {
|
||||
ge::views_reset_visibility(g_app);
|
||||
ge::views_apply_visibility(g_app);
|
||||
g_graph.update_bounds();
|
||||
// (B) NO graph_viewport_fit aqui.
|
||||
int restored = ge::layout_store_load(g_graph_hash, g_graph);
|
||||
// (C) huerfanos -> halo placement junto a vecinos.
|
||||
place_orphans_near_neighbors(g_graph, /*min_dist=*/60.0f);
|
||||
if (restored > 0 || g_graph.node_count > 0) g_graph.update_bounds();
|
||||
g_atlas_bound = false;
|
||||
g_gpu_dirty = true;
|
||||
// (E) physics siempre pausadas tras reload.
|
||||
g_viewport.layout_running = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### load_input — distinguir primera carga de reload
|
||||
|
||||
Anadir flag `bool first_load`. La condicion `zero_pos == node_count` y
|
||||
el `graph_viewport_fit()` solo se aplican si `first_load == true`.
|
||||
|
||||
```cpp
|
||||
static bool load_input(bool first_load = true);
|
||||
```
|
||||
|
||||
Internamente: `reload_graph()` ya no llama a `load_input`, sino a una
|
||||
version que pasa `first_load=false`. O `want_reload` hace el flujo
|
||||
manualmente como arriba (sin reusar load_input).
|
||||
|
||||
### Nuevo helper `place_orphans_near_neighbors`
|
||||
|
||||
Vive en `main.cpp` (O nuevo `layout_helpers.{h,cpp}` si crece).
|
||||
|
||||
```cpp
|
||||
// Para cada nodo en (0,0) (huerfano tras reload):
|
||||
// 1. Busca su primer vecino (via aristas) con posicion no-cero.
|
||||
// 2. Coloca el huerfano en un anillo a r=80 px alrededor del padre,
|
||||
// eligiendo el primer slot angular (de 12) que no colisione con
|
||||
// ningun otro nodo a min_dist. Si todos ocupados, expande radio
|
||||
// (140, 200, 280, 400). Jitter deterministico por user_data para
|
||||
// que dos huerfanos del mismo padre no caigan en el mismo slot.
|
||||
// 3. Si el huerfano no tiene vecinos colocados (ej. componente
|
||||
// conexa nueva), lo aparca en una columna a la derecha del bbox
|
||||
// del grafo, separados verticalmente min_dist.
|
||||
//
|
||||
// Complejidad O(N * orphans). Suficiente para grafos bajo 5k nodos.
|
||||
static void place_orphans_near_neighbors(GraphData& g, float min_dist);
|
||||
```
|
||||
|
||||
Algoritmo de un huerfano:
|
||||
|
||||
```cpp
|
||||
int parent = first_placed_neighbor(g, i); // O(edges)
|
||||
if (parent < 0) { park_in_free_column(...); continue; }
|
||||
const float radii[] = {80, 140, 200, 280, 400};
|
||||
const int slots = 12; // 30 grados
|
||||
float jitter = ((g.nodes[i].user_data >> 16) & 0xFF) / 255.0f * (2*PI/slots);
|
||||
for (float r : radii) {
|
||||
for (int s = 0; s < slots; ++s) {
|
||||
float a = jitter + s * (2*PI/slots);
|
||||
float cx = g.nodes[parent].x + r * cosf(a);
|
||||
float cy = g.nodes[parent].y + r * sinf(a);
|
||||
if (no_collision(g, i, cx, cy, min_dist)) {
|
||||
g.nodes[i].x = cx; g.nodes[i].y = cy;
|
||||
goto placed;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: ultima posicion del ultimo radio + slot 0 (acepta solape).
|
||||
placed:
|
||||
```
|
||||
|
||||
`no_collision` es O(n) — itera todos los nodos del grafo y rechaza si
|
||||
algun otro esta a < min_dist. Marca el huerfano recien colocado para
|
||||
que el siguiente huerfano sepa de el.
|
||||
|
||||
## Definicion de hecho
|
||||
|
||||
- Reload tras enricher NO mueve la camara.
|
||||
- Reload tras enricher NO cambia las posiciones de los nodos que ya
|
||||
tenian sitio.
|
||||
- Las entidades nuevas creadas por el enricher aparecen visibles, cerca
|
||||
de su nodo padre semantico, sin solaparse con nadie.
|
||||
- Physics permanecen OFF tras el reload (el usuario las activa
|
||||
manualmente si quiere).
|
||||
- Si el usuario nunca pulsa "Save layout", el cierre normal de la app
|
||||
no preserva estado, pero cualquier reload SI preserva (gracias al
|
||||
auto-save antes de reload).
|
||||
Reference in New Issue
Block a user