Files
graph_explorer/tests/test_split_words.py
T
egutierrez 352b27d488 feat: enricher split_words para probar grouping con volumen alto
split_sentences a menudo no llega al umbral de 50 (un texto medio
tiene 5-15 frases). split_words tokeniza el mismo notes en palabras
y trivialmente lo supera con cualquier parrafo decente -> Group
visible y testeable end-to-end sin necesidad de pegar megabytes.

Diferencias respecto a split_sentences:

* Splits por regex de letras (incluye acentos espanyoles + apostrofo
  interno como "don't"). Numeros y puntuacion ignorados.
* Lowercase + filtro por min_length (default 3, filtra a/el/de/y/o).
* Param `dedupe` (default true): vocabulario unico vs cada ocurrencia.
  Con dedupe=false sirve como stress test de volumen.
* Tipo `Word` en types.yaml: amarillo, ti-letter-w, principal_field=word.
* Relacion `WORD_OF` desde cada Word al source.
* Mismo patron de grouping que split_sentences (threshold 50, K=10
  preview, batch_id en metadata, Group con count + enricher).

Tests:

* below threshold no crea Group.
* >=50 tokens unicos -> Group + 10 sueltos + resto agrupados.
* dedupe=true (default) colapsa repeticiones; dedupe=false las
  conserva como nodos separados.
* min_length filtra correctamente.
* notes prioriza sobre node_name.
* texto vacio -> exit 2.
* max_words trunca.

WSL 89 / Windows 78 + 11 skipped.
2026-05-04 00:14:57 +02:00

155 lines
6.7 KiB
Python

"""Tests del enricher split_words (offline, regex puro).
Mismo patron que test_split_sentences.py: nodo text con `notes` largo,
verificamos tokens, fallback a name, threshold/grouping y dedupe.
"""
from __future__ import annotations
from conftest import (
base_ctx, list_entities, list_relations, make_node, run_enricher,
)
# Texto con suficientes palabras unicas para superar threshold (50)
# si dedupe=true: cuento ~85 unicas a ojo. Sin dedupe (count=true)
# todas las ocurrencias dan ~140+ tokens.
LONG_TEXT = (
"Las estrellas brillan suavemente sobre el horizonte mientras "
"la marea retrocede dejando huellas mojadas en la arena fina. "
"Caminamos lentamente conversando sobre proyectos antiguos, "
"ideas frescas, libros leidos durante el invierno pasado, "
"viajes pendientes hacia tierras lejanas con culturas vibrantes. "
"Recordamos infancias compartidas, amigos perdidos, victorias "
"modestas, fracasos instructivos. Cada palabra dibuja un mapa "
"diferente del territorio interno que habitamos. Los nombres de "
"ciudades antiguas resuenan: Estambul, Marrakech, Kioto, Lisboa, "
"Praga, Budapest, Cuzco, Cartagena. Tambien tecnologia: servidores, "
"bases datos, redes neuronales, modelos linguisticos, sistemas "
"distribuidos, criptografia moderna. La conversacion fluye sin "
"esfuerzo aparente entre dominios completamente distintos."
)
def test_split_words_creates_word_nodes(ops_db, app_dir, registry_root):
"""Texto corto < threshold genera Words sueltos sin Group."""
short_text = "uno dos tres cuatro cinco seis siete ocho nueve diez."
make_node(ops_db, node_id="t1", name="short",
type_ref="text", notes=short_text)
ctx = base_ctx(ops_db=ops_db, app_dir=app_dir, registry_root=registry_root,
node_id="t1", node_name="short", node_type="text")
rc, out, err = run_enricher("split_words", ctx)
assert rc == 0, err
assert out["grouped"] is False
assert out["entities_added"] == out["words"]
assert out["words"] >= 5 # filtra <3 chars: dos, tres, cuatro, ...
words = list_entities(ops_db, type_ref="Word")
assert len(words) == out["words"]
def test_split_words_above_threshold_creates_group(ops_db, app_dir,
registry_root):
"""Texto largo (≥50 tokens unicos) → Group + 10 sueltos + resto."""
make_node(ops_db, node_id="t1", name="largo",
type_ref="text", notes=LONG_TEXT)
ctx = base_ctx(ops_db=ops_db, app_dir=app_dir, registry_root=registry_root,
node_id="t1", node_name="largo", node_type="text")
rc, out, err = run_enricher("split_words", ctx, timeout=60)
assert rc == 0, err
assert out["words"] >= 50
assert out["grouped"] is True
assert out["group_id"]
# Group + words = entities_added.
assert out["entities_added"] == out["words"] + 1
# Hay 10 Words sueltos (group_id NULL) y resto agrupados.
import sqlite3
cn = sqlite3.connect(ops_db)
n_loose = cn.execute(
"SELECT count(*) FROM entities WHERE type_ref='Word' "
"AND group_id IS NULL"
).fetchone()[0]
n_grouped = cn.execute(
"SELECT count(*) FROM entities WHERE type_ref='Word' "
"AND group_id = ?", (out["group_id"],)
).fetchone()[0]
cn.close()
assert n_loose == 10
assert n_loose + n_grouped == out["words"]
def test_split_words_dedupe_default_true(ops_db, app_dir, registry_root):
"""Por defecto dedupe=true: 'casa casa casa' produce 1 Word."""
make_node(ops_db, node_id="t1", name="dup",
type_ref="text", notes="casa casa casa perro perro gato.")
ctx = base_ctx(ops_db=ops_db, app_dir=app_dir, registry_root=registry_root,
node_id="t1", node_name="dup", node_type="text")
rc, out, err = run_enricher("split_words", ctx)
assert rc == 0, err
assert out["deduped"] is True
# casa, perro, gato (todas ≥3 chars).
assert out["words"] == 3
def test_split_words_dedupe_false(ops_db, app_dir, registry_root):
"""Con dedupe=false cada ocurrencia es un nodo Word."""
make_node(ops_db, node_id="t1", name="dup",
type_ref="text", notes="casa casa casa perro perro gato.")
ctx = base_ctx(ops_db=ops_db, app_dir=app_dir, registry_root=registry_root,
node_id="t1", node_name="dup", node_type="text",
params={"dedupe": False})
rc, out, err = run_enricher("split_words", ctx)
assert rc == 0, err
assert out["deduped"] is False
# 3 casa + 2 perro + 1 gato = 6 tokens.
assert out["words"] == 6
def test_split_words_min_length_filters(ops_db, app_dir, registry_root):
"""min_length filtra tokens cortos. Default 3."""
make_node(ops_db, node_id="t1", name="cortos",
type_ref="text",
notes="a el de la y o un casa perro elefante.")
ctx = base_ctx(ops_db=ops_db, app_dir=app_dir, registry_root=registry_root,
node_id="t1", node_name="cortos", node_type="text")
rc, out, err = run_enricher("split_words", ctx)
assert rc == 0, err
# >=3 chars: casa, perro, elefante. (un=2, de=2, la=2 quedan fuera).
assert out["words"] == 3
def test_split_words_uses_notes_priority(ops_db, app_dir, registry_root):
"""Lee `entities.notes` por encima de node_name."""
make_node(ops_db, node_id="t1", name="ignorame",
type_ref="text",
notes="estos cinco tokens deberian ganar.")
ctx = base_ctx(ops_db=ops_db, app_dir=app_dir, registry_root=registry_root,
node_id="t1", node_name="ignorame", node_type="text")
rc, out, err = run_enricher("split_words", ctx)
assert rc == 0, err
# estos, cinco, tokens, deberian, ganar (todos ≥3).
assert out["words"] == 5
def test_split_words_no_text_fails(ops_db, app_dir, registry_root):
"""Sin notes y name corto → exit 2."""
make_node(ops_db, node_id="t1", name="x", type_ref="text", metadata={})
ctx = base_ctx(ops_db=ops_db, app_dir=app_dir, registry_root=registry_root,
node_id="t1", node_name="x", node_type="text")
rc, out, err = run_enricher("split_words", ctx)
assert rc == 2
assert out is not None
assert "demasiado corto" in (out.get("error") or "")
def test_split_words_max_words_truncates(ops_db, app_dir, registry_root):
"""max_words limita el output."""
make_node(ops_db, node_id="t1", name="largo",
type_ref="text", notes=LONG_TEXT)
ctx = base_ctx(ops_db=ops_db, app_dir=app_dir, registry_root=registry_root,
node_id="t1", node_name="largo", node_type="text",
params={"max_words": 12})
rc, out, err = run_enricher("split_words", ctx)
assert rc == 0, err
assert out["words"] == 12