Plumbing para issue 0035 — agrupacion de resultados de enrichers cuando exceden umbral. Sin cambios visibles para el usuario todavia. - Migracion idempotente: ALTER TABLE entities ADD COLUMN group_id si no existe (detectado via PRAGMA table_info). Se ejecuta al abrir el proyecto en switch_to_project y en el bootstrap inicial. - Tipo Group en examples/types.yaml (template) y en el types.yaml del proyecto default activo en Windows. - shape=square (regla en types_registry.cpp extendida a Group), color=#94A3B8, icon=ti-stack-2. - Fields: name (req), count (int), enricher (string), batch_id (string). Refs: issues/0035a-group-type-and-schema.md
9.0 KiB
id, title, status, priority, created, depends_on
| id | title | status | priority | created | depends_on | |
|---|---|---|---|---|---|---|
| 0035 | Agrupar resultados de enrichers cuando exceden un umbral — nodos `Group` cuadrados expandibles | pending | high | 2026-05-03 |
|
Contexto
Hoy un enricher como web_search con limit=200, extract_links sobre una pagina densa, o un futuro gliner_extract sobre un texto largo, vomitan N nodos sueltos en el viewport. Con N grande (cientos, miles) el grafo se vuelve ilegible: cluster apretado de bolitas anonimas alrededor del source, layouts saturados, panel Inspector inservible.
Ademas, queremos poder llegar a millones de nodos sin que la app se ahogue al renderizar — no porque los necesitemos todos en pantalla a la vez, sino porque la fase de investigacion OSINT genuinamente produce esos volumenes (todos los enlaces de un sitio, todos los IoCs de un dump de logs, todos los followers de una cuenta...).
Solucion: cuando un enricher produce >= N hijos (default 50, override en manifest), creamos un nodo cuadrado Group que los contiene. El renderer dibuja solo el cuadrado por defecto; los hijos se materializan en pantalla cuando el usuario "entra" al grupo.
Decisiones tomadas
1. Storage — opcion C (hibrida)
Los hijos son entidades reales en operations.db, no un blob serializado. Cada uno lleva un campo group_id que apunta a la entidad Group contenedora. El renderer filtra al cargar:
mostrar nodo si nodo.group_id IS NULL OR group(nodo.group_id).expanded
Beneficios:
- FTS5 sigue encontrando el hijo aunque este colapsado.
- Las relaciones cruzadas (un hijo de un grupo conectado a un nodo fuera del grupo) son aristas reales.
- La expansion es perezosa: al colapsar, los hijos se descargan del grafo en RAM (siguen en BD).
2. Umbral
- Default global (50) configurable en settings.
- Override por enricher: el manifest puede declarar
auto_group_threshold: 100(o el valor que tenga sentido para ese flujo concreto). - Sin override por ejecucion (de momento) — el modal de configuracion del enricher no incluye un checkbox "agrupar". Lo añadimos despues si hay friccion.
3. Modo "Twitter / Reddit"
Cuando el resultado excede el umbral, NO metemos los N hijos en el grupo de golpe. Estilo timeline:
- Los primeros K hijos (K = 10-20 configurable, default 10) van sueltos colgando del source con relacion normal — el usuario los ve como preview directo.
- Los N - K restantes van dentro de un nodo
Groupque cuelga del source con la misma relacion (ej:SEARCH_RESULT_OF), etiquetado+ {N-K} mas.
Por que: muchas veces los primeros resultados son los mas relevantes (DDG ranking, anchors prioritarios). El usuario ve lo importante sin friccion; el resto queda accesible pero compactado. Si el usuario quiere todo, abre el grupo.
4. Doble click en el grupo → vista de tabla
El grupo NO comparte el comportamiento del doble-click de un nodo normal (que abre Notes). En su lugar:
- Doble click en un
Groupcuadrado → abre la vista de tabla (tableview) listando todos los hijos con sus columnas (id, name, type_ref, status, updated_at, ...) + filtros y busqueda. - Desde esa tabla el usuario puede:
- Seleccionar filas y promover los nodos seleccionados (sacarlos del grupo y plantarlos como nodos sueltos en el canvas —
group_id = NULL). - Re-agrupar un subconjunto por tipo: seleccionar varios
Urly "agrupar por type_ref" crea un sub-grupoUrl (X)dentro del actual.
- Seleccionar filas y promover los nodos seleccionados (sacarlos del grupo y plantarlos como nodos sueltos en el canvas —
- Las relaciones nunca se pierden: agrupar/promover solo toca
group_id, no las filas derelations. Un hijo dentro de un grupo sigue teniendo sus aristas reales en BD; lo unico que cambia es como las dibujamos (ver decision 5).
Asi el grupo es un contenedor de organizacion, no una nueva identidad — promover y reagrupar son baratos.
5. Aristas — v1 simple, sin agregacion
Mientras el grupo este colapsado:
- Aristas internas (entre dos hijos del mismo grupo): se ocultan.
- Aristas cruzando el limite (hijo del grupo ↔ nodo fuera del grupo): se dibujan tal cual, conectando el nodo externo al grupo cuadrado (la coordenada del extremo "interno" se reemplaza por la del cuadrado). Una linea por arista, sin sumar pesos ni colapsar duplicados.
Cuando el grupo esta expandido, las aristas se dibujan a sus extremos reales (entre hijos individuales).
Implicacion visual: si 87 hijos del grupo tienen cada uno una relacion BELONGS_TO con un mismo Domain externo, ves 87 lineas dibujadas sobre el cuadrado → maraña. Lo aceptamos para v1; la agregacion con peso (1 sola linea con grosor proporcional a 87) queda para una v2.
6. Cascada y multi-tipo — un enricher, un (o varios) grupos planos
Caso: tengo un grupo "web_search: tomate" con 87 Urls. Lanzo extract_links sobre todos. Cada Url devuelve hasta 50 enlaces. Resultado bruto: hasta 4350 nodos nuevos. ¿Como los organizamos?
Decision: A flat con procedencia, partido por tipo cuando el enricher emite varios.
Reglas:
- Una ejecucion produce 1 grupo por tipo de salida que exceda el umbral.
- Single-type (
extract_links→ todo Url): 1 grupo. - Multi-type (
extract_text_entities→ Email + IP + Domain + ...): hasta N grupos, uno por tipo. Los tipos que individualmente NO superan el umbral quedan sueltos.
- Single-type (
- Cada grupo cuelga del trigger con la misma relacion que tendrian sus hijos individuales (
EXTRACTED_FROM,LINKS_TO,SEARCH_RESULT_OF, etc.). Si el trigger es a su vez un grupo (cascada masiva), el nuevo grupo cuelga del grupo origen. - Procedencia: la relacion ya la lleva. El hijo tiene su
EXTRACTED_FROM <source_specific>real enrelations— no se duplica comometadata.source_node_id. El filtro "muestrame los hijos que vienen de X" en tableview es una query arelations. batch_iden metadata del grupo y de cada hijo: UUID compartido por todos los nodos de la misma ejecucion (incluso si la salida se parte en varios grupos + nodos sueltos). Sirve para deshacer/filtrar/exportar la operacion completa.- Iconografia del grupo: hereda color e icono del tipo que contiene (si es homogeneo). Cuadrado con shape de grupo + glyph del tipo. El usuario lee el canvas sin expandir.
- Preview Twitter/Reddit aplica por tipo: para cada tipo que excede umbral, los primeros K hijos quedan sueltos colgando del trigger; el resto va al grupo de ese tipo.
- Excepcion a la regla 5 de aristas: cuando ambos extremos caen en grupos colapsados (posiblemente el mismo), dibujamos UNA linea por par de grupos. Sin peso visual ni grosor — solo deduplicacion para evitar maranias topologicas.
Por que A con esta forma:
- Procedencia via filtro (queryable por relacion existente), no via estructura forzada del grafo.
- No hay problema de umbral marginal: la cascada en bloque siempre evalua el total contra el umbral, no source-por-source.
- Particion por tipo recobra significado visual del cuadrado (color/icono del contenido).
- "Extraer subset por source X" se queda como feature de fase 2 cuando madure la seleccion en tableview — el dato ya esta en BD desde el principio.
Caveat aceptado: con tableview filtrando por source_node_id, el usuario tiene que pedirlo explicitamente para ver subsets. La estructura por defecto es plana por tipo.
Plan por fases
Fase 1 — MVP, un solo enricher
- Tipo
Groupenexamples/types.yaml(square, iconoti-stack-2oti-folder, color distintivo). - Columna
group_id TEXTañadida al schema deentities(nullable). - Aplicar solo a
web_search: silen(results) >= threshold, crearGroup+ insertar losN - Krestantes congroup_idapuntando al grupo. - Renderer ignora hijos con
group_id != NULLsalvo que el grupo este expandido (flag en RAM, no persistido aun). - Doble click en
Group→ abre tableview filtrada porgroup_id = <id>. - Sin promote/regroup todavia. Sin cascada. Sin agregacion de aristas.
Goal: validar end-to-end con un caso real antes de generalizar.
Fase 2 — generalizar
- Aplicar a los 5 enrichers de sistema. Manifests declaran
auto_group_threshold. - Promote desde tableview (mover hijos fuera del grupo).
- Re-agrupar por tipo desde tableview.
- Persistir el flag
expandedpor grupo (enlocal_files/<project>/<app>.db, no en operations.db). - Cascada (decision 6): definir e implementar.
Fase 3 — escala y pulido
- Layouts internos del grupo cuando esta expandido (los hijos se posicionan en el "area" del cuadrado, no fuera).
- Agregacion de aristas con peso visual.
- Drill-in con breadcrumb (
tomate / search results / domain X) como vista alternativa al tableview. - Exportar subgrafos respetando jerarquia de grupos (issue 0024).
Notas tecnicas (sin codigo)
- El renderer carga TODA
entitieshoy. Con millones de hijos, eso se rompe — fase 2/3 requiere cambiar el load para que filtre conWHERE group_id IS NULL OR group_id IN (lista expandida)desde la query. - El layout
place_orphans_near_neighborsya cluster bien los hermanos de un mismo anchor (cambio reciente). Eso sigue valiendo: unGroupcolgando del source es un orphan mas que se posiciona cerca del padre. - El
tableviewya existe (issue 0010-0011), solo hay que poder abrirlo filtrado porgroup_id.