feat(0035c): web_search crea Group cuando excede umbral

Cuando un enricher web_search produce >= 50 resultados, los primeros 10
quedan sueltos colgando del source (preview Twitter/Reddit) y los
restantes entran como hijos de un nuevo nodo Group cuadrado.

Cambios:
- enrichers/web_search/run.py:
  - DEFAULT_GROUP_THRESHOLD=50, GROUP_PREVIEW_K=10 (constantes globales).
  - has_group_id_column(): detecta si el schema soporta agrupacion.
  - insert_group_entity(): crea nodo Group con metadata
    {enricher, query, count, batch_id}.
  - insert_url_entity() acepta batch_id y group_id; los inyecta en
    metadata/columna respectivamente. Nodos existentes mantienen su
    group_id actual (no se machaca).
  - Generacion de batch_id (UUID4 hex) por ejecucion, compartido por
    todos los nodos creados (group + sueltos + agrupados).
  - Cada hijo del grupo conserva su relacion individual SEARCH_RESULT_OF
    al source original — la procedencia es la relacion real, no el
    contenedor.
  - El JSON de salida añade batch_id, group_id, grouped.

- tests/conftest.py: añade columna entities.group_id al SCHEMA_SQL y
  expone group_id en list_entities() para que los tests lo verifiquen.

- tests/test_web_search.py: 3 tests nuevos
  - below_threshold_no_group: 5 resultados → 0 Groups, comportamiento clasico.
  - above_threshold_creates_group_and_preview: 100 resultados → 1 Group +
    10 sueltos + 90 con group_id, todos con SEARCH_RESULT_OF al source.
  - batch_id_shared_across_outputs: group + preview + hijos comparten
    batch_id.
  - _build_lite_html() genera HTML sintetico con N resultados sin
    necesidad de fixture estatico grande.

Tests: 35 passed (32 previos + 3 nuevos) en WSL.
       24 passed + 11 skipped en Windows.

Refs: issues/0035c-web-search-creates-groups.md
This commit is contained in:
2026-05-03 14:52:29 +02:00
parent 784b56ba10
commit 67f10a8afd
3 changed files with 275 additions and 7 deletions
+130 -4
View File
@@ -32,11 +32,24 @@ import re
import sqlite3
import sys
import time
import uuid
from datetime import datetime, timezone
from html.parser import HTMLParser
from urllib.parse import parse_qs, unquote, urlparse
# Issue 0035c — agrupacion automatica de resultados.
#
# Cuando un enricher produce >= GROUP_THRESHOLD resultados, los primeros
# GROUP_PREVIEW_K quedan sueltos colgando del source (estilo
# Twitter/Reddit timeline) y los N-K restantes entran en un nodo Group
# cuadrado. El manifest puede declarar `auto_group_threshold` para
# overridear el default; mas adelante settings UI permitira override
# global. Por ahora esta hardcoded.
DEFAULT_GROUP_THRESHOLD = 50
GROUP_PREVIEW_K = 10
def progress(p: float, stage: str = "") -> None:
sys.stderr.write(f"PROGRESS:{p:.2f} {stage}\n")
sys.stderr.flush()
@@ -350,9 +363,34 @@ def find_url_entity(conn: sqlite3.Connection, url: str) -> str | None:
return None
def has_group_id_column(conn: sqlite3.Connection) -> bool:
"""Detecta si la columna `group_id` existe en `entities`.
El proyecto graph_explorer la añade via migracion (issue 0035a),
pero podriamos correr contra una BD vieja. Si no esta, insertamos
sin esa columna (resultados sueltos pero con `batch_id` en metadata).
"""
try:
cur = conn.execute("PRAGMA table_info(entities)")
for row in cur:
if row[1] == "group_id":
return True
except sqlite3.Error:
pass
return False
def insert_url_entity(conn: sqlite3.Connection, url: str, title: str,
snippet: str, rank: int, query: str) -> str:
"""Crea un nodo Url y devuelve su id. Si ya existe, lo reusa y refresca."""
snippet: str, rank: int, query: str,
batch_id: str = "",
group_id: str | None = None,
has_group_col: bool = False) -> str:
"""Crea un nodo Url y devuelve su id. Si ya existe, lo reusa y refresca.
`batch_id` se inyecta en metadata si no esta vacio. `group_id` se
escribe en la columna homonima cuando existe en el schema y se ha
pasado un valor; si no, queda NULL (nodo suelto).
"""
existing = find_url_entity(conn, url)
ts = now_iso()
meta = {
@@ -364,8 +402,14 @@ def insert_url_entity(conn: sqlite3.Connection, url: str, title: str,
"engine": "duckduckgo",
"found_at": ts,
}
if batch_id:
meta["batch_id"] = batch_id
meta_json = json.dumps(meta, ensure_ascii=False)
if existing:
# Si la entidad ya existia, mantenemos su group_id actual (no
# lo machacamos): un mismo Url puede aparecer en multiples
# busquedas y el primer Group que lo capturo gana. Solo
# actualizamos metadata + updated_at.
conn.execute(
"UPDATE entities SET metadata=?, updated_at=? WHERE id=?",
(meta_json, ts, existing),
@@ -374,10 +418,43 @@ def insert_url_entity(conn: sqlite3.Connection, url: str, title: str,
new_id = f"Url_{now_ms()}_{rank}_{abs(hash(url)) % 100000}"
name = title[:200] if title else url[:200]
if has_group_col:
conn.execute(
"INSERT INTO entities (id, name, type_ref, source, metadata, "
" group_id, created_at, updated_at) "
"VALUES (?, ?, 'Url', 'enricher:web_search', ?, ?, ?, ?)",
(new_id, name, meta_json, group_id, ts, ts),
)
else:
conn.execute(
"INSERT INTO entities (id, name, type_ref, source, metadata, "
" created_at, updated_at) "
"VALUES (?, ?, 'Url', 'enricher:web_search', ?, ?, ?)",
(new_id, name, meta_json, ts, ts),
)
return new_id
def insert_group_entity(conn: sqlite3.Connection, *, query: str,
count: int, batch_id: str) -> str:
"""Crea un nodo Group para los resultados restantes de una busqueda.
Devuelve el id del Group recien creado.
"""
ts = now_iso()
new_id = f"Group_{now_ms()}_{abs(hash(query + batch_id)) % 100000}"
name = f"web_search: {query} ({count})"
meta = {
"enricher": "web_search",
"query": query,
"count": count,
"batch_id": batch_id,
}
meta_json = json.dumps(meta, ensure_ascii=False)
conn.execute(
"INSERT INTO entities (id, name, type_ref, source, metadata, "
" created_at, updated_at) "
"VALUES (?, ?, 'Url', 'enricher:web_search', ?, ?, ?)",
"VALUES (?, ?, 'Group', 'enricher:web_search', ?, ?, ?)",
(new_id, name, meta_json, ts, ts),
)
return new_id
@@ -537,8 +614,31 @@ def main() -> int:
conn.execute("PRAGMA foreign_keys=OFF")
entities_added = 0
relations_added = 0
group_id: str | None = None
batch_id = uuid.uuid4().hex
try:
for r in results:
has_group_col = has_group_id_column(conn)
n_total = len(results)
# Threshold: por ahora hardcoded; la lectura del manifest
# vendra en 0035e (settings UI / overrides por enricher).
threshold = DEFAULT_GROUP_THRESHOLD
if n_total >= threshold and has_group_col:
# Modo Twitter/Reddit: K sueltos + Group con N-K hijos.
group_id = insert_group_entity(
conn, query=query, count=n_total, batch_id=batch_id,
)
entities_added += 1
if insert_relation(conn, group_id, node_id, "SEARCH_RESULT_OF"):
relations_added += 1
preview = results[:GROUP_PREVIEW_K]
grouped = results[GROUP_PREVIEW_K:]
else:
# Comportamiento clasico: todo suelto, sin Group.
preview = results
grouped = []
for r in preview:
existed = find_url_entity(conn, r["url"]) is not None
url_id = insert_url_entity(
conn,
@@ -547,11 +647,34 @@ def main() -> int:
snippet=r["snippet"],
rank=r["rank"],
query=query,
batch_id=batch_id,
group_id=None,
has_group_col=has_group_col,
)
if not existed:
entities_added += 1
if insert_relation(conn, url_id, node_id, "SEARCH_RESULT_OF"):
relations_added += 1
for r in grouped:
existed = find_url_entity(conn, r["url"]) is not None
url_id = insert_url_entity(
conn,
url=r["url"],
title=r["title"],
snippet=r["snippet"],
rank=r["rank"],
query=query,
batch_id=batch_id,
group_id=group_id,
has_group_col=has_group_col,
)
if not existed:
entities_added += 1
# La procedencia es la relacion al source original, no al
# grupo — el grupo es solo un contenedor visual.
if insert_relation(conn, url_id, node_id, "SEARCH_RESULT_OF"):
relations_added += 1
conn.commit()
finally:
conn.close()
@@ -563,6 +686,9 @@ def main() -> int:
"results": len(results),
"entities_added": entities_added,
"relations_added": relations_added,
"batch_id": batch_id,
"group_id": group_id or "",
"grouped": bool(group_id),
}, ensure_ascii=False))
return 0