El divisor de frases (`/v1/texto/frases`)

*apis/core · 2026-09-16 · Karb & Sil (encargo E-638, TX-638)*

Para qué

Dividir un texto en frases es una herramienta base: la piden el lematizador por frase, el «aclarar», el trailer, cualquier análisis que trabaje oración a oración. Hasta ahora no la teníamos como servicio propio. Esta API la ofrece de forma algorítmica: reglas explícitas que se leen y se explican, sin modelos ni entrenamiento (Eduardo, 16/09/2026: «ideamos el sistema, lo probamos con las frases y sale al mundo»).

El divisor vive en frases.py (raíz de apis/core, junto a aclarar.py): puro, sin dependencias, sin BD. Se puede leer de arriba abajo.

Qué devuelve

dividir(texto) -> [ {inicio, fin, texto}, … ], con offsets sobre el texto original (fin exclusivo). La invariante fuerte: no se pierde ni un carácter. La concatenación de las frases con los separadores (los espacios que quedan entre ellas) reproduce el texto original —lo comprueba reconstruir() y lo prueban los tests—.

Las frases no incluyen el blanco que las separa; ese hueco es el «separador».

Las reglas

ReglaDecisiónEjemplo
Terminadores. ? ! … (y ...) cierran fraseUno. Dos. → 2
; y :No cortan (unen dentro de la frase)Vino; vio; venció. → 1
Espacio despuésUn terminador solo corta si tras los cierres viene espacio o findescarta 3.000, URLs, S.A.
Mayúscula tras puntoSeñal fuerte; minúscula tras punto ⇒ abreviatura (no corta)…a las 5 p.m. y se fue. → 1
AbreviaturasLista propia frases_abreviaturas.txt (ampliable)Sr. EE. UU. núm. art.
Iniciales / siglasUna letra + punto (D.) o token con puntos-de-una-letra (S.A., R.A.E.) no cortanD. Quijote
NúmerosPunto/coma entre dígitos, sin espacio3.000 · 1,5 · 10.30
URLs y correosPuntos sin espacio no cortanwww.web.es · [email protected]
Comillas y paréntesisEl cierre tras el terminador es de la frase«…». · …») · ¿…?»
Rayas de diálogo?/!/. + —+minúscula = inciso del narrador (no corta); —+mayúscula = nueva intervención—¿Qué? —dijo. → 1
Puntos suspensivosCortan ante mayúscula; ante minúscula continúanSe fue… Nunca. → 2 · Era… digo → 1
Saltos de líneaDoble salto (línea en blanco) siempre corta; simple, no (configurable)títulos, listas

cortar_salto_simple=True hace que un salto de línea simple también corte (verso, listas).

La API

  • POST /v1/texto/frases — cuerpo {"texto": "…", "cortar_salto_simple": false} → {"frases": [{inicio, fin, texto}, …], "n": N}. Para textos largos.
  • GET /v1/texto/frases?texto=…&cortar_salto_simple=false — para textos cortos.

Con clave (X-API-Key), telemetría y límites como el resto de la API. Tope de tamaño WK_FRASES_TOPE (defecto 200.000 caracteres) → 413 si se supera.

curl -s -H "X-API-Key: $CLAVE" \
  "https://api.wordkers.es/v1/texto/frases?texto=El%20Sr.%20P%C3%A9rez%20pag%C3%B3%203.000%20euros.%20%C2%BFDe%20veras%3F%20S%C3%AD."
# → {"frases":[{"inicio":0,"fin":30,"texto":"El Sr. Pérez pagó 3.000 euros."}, …],"n":3}

Rendimiento

dividir es O(n) con trabajo mínimo por carácter. En Prepro (Hetzner): 1 MB de texto en ≈0,16 s (medido; el requisito es < 1 s).

Límites conocidos (donde decide una persona)

  • Abreviatura o sigla a final de frase seguida de mayúscula (…en la R.A.E. Ganó el premio., …, etc. Después vino.): no cortamos —es justo el caso ambiguo—. El banco (E-638) mide con qué frecuencia ocurre.
  • ?/! seguido de minúscula que no es inciso de diálogo se considera parte de la misma frase (¿verdad? preguntó).
  • Abreviaturas fuera de la lista: si la siguiente palabra va en minúscula, la regla «minúscula ⇒ abreviatura» las cubre; en mayúscula, habría que añadirlas a frases_abreviaturas.txt.

¿Es práctica una API? (E-638-3)

Dividir en frases es una operación baratísima (nuestro divisor va a ~0,1 s/MB). La pregunta de Eduardo (16/09/2026) es si tiene sentido ofrecerla por HTTP o conviene otra vía. La respuesta corta: sí para textos de tamaño humano (una frase, un párrafo, una página, incluso un libro pequeño), con el matiz de que a esos tamaños la llamada de red cuesta más que la división misma; y no para corpus (millones de artículos), donde la división en sí ya lleva minutos u horas según la herramienta y hay que ir por proceso o por un trabajo asíncrono.

Las cinco preguntas, con datos

Los números salen del benchmark de tools/E-638/benchmark (agentes-common): resultados.md (los cinco divisores sobre 100.000 artículos) y func_vs_api.md (función vs API, tres formas de envío). Ver esas tablas para el detalle.

  1. ¿Para qué tamaños compensa la llamada síncrona? Para todo lo que un humano manda de una vez: una frase, un párrafo, una página, un capítulo, un libro (hasta unos pocos MB). El divisor los parte en milisegundos o décimas; la respuesta síncrona (un POST y su respuesta) llega muy por debajo de los 2 s. Es cómodo y no hay que instalar nada.
  1. ¿A partir de dónde NO compensa, y qué se hace entonces? Cuando el trabajo deja de ser interactivo: un corpus, un archivo entero, un goteo de millones de textos. Ahí mandar millones de peticiones (o un fichero de gigabytes) por HTTP es absurdo: se satura la conexión, se paga latencia por cada ida y vuelta y no hay respuesta síncrona posible. Se hace de dos maneras: (a) en proceso, con el paquete/CLI de aquí abajo (el agente divisor y nuestros programas ya lo usan así); (b) con la puerta de trabajos asíncrona (diseñada abajo) para encargos grandes que no queremos correr nosotros.
  1. ¿Cuánto cuesta una llamada (ms) frente a la división en sí? Es el hallazgo clave: a tamaño pequeño la red domina. Dividir una frase o un párrafo en proceso son microsegundos; la misma división por HTTP añade el coste fijo de una petición (conexión, serialización JSON, viaje). Por eso, si tienes muchos textos cortos, mandarlos de uno en uno por la API es la forma más cara: el sobrecoste es casi todo red. La cura son las puertas de lote y fichero (abajo): amortizan una sola petición entre cientos de textos y bajan el sobrecoste por artículo drásticamente.
  1. ¿Funciona para textos cortos? Sí. Una frase suelta y un párrafo se dividen por debajo de 20 ms (medido; ver tests/test_frases.py::test_textos_cortos_bajo_20ms). No hay arranque ni carga de modelo: las abreviaturas se leen una vez al importar (caché de proceso, frases._ABREVIATURAS), no en cada llamada. Lo que se nota a este tamaño no es la división sino, si vas por HTTP, la red.
  1. ¿Qué tamaño admitimos y cómo se amplía? Topes fijados midiendo para que la respuesta síncrona quede bajo 2 s (ver la tabla de topes). Se amplía con las tres puertas nuevas —lote, fichero, y (a futuro) trabajos— más el uso en proceso. Lo mismo vale para las demás APIs (lematizador, silabeo…): la función pura por dentro, y las mismas puertas por fuera.

Las puertas y sus topes

PuertaEndpointTope síncronoPor qué
Un textoPOST /v1/texto/frases {"texto"}200.000 caracteresdefensa; se divide en ~20 ms
LotePOST /v1/texto/frases {"textos":[…]}1.000 textos y 1.000.000 caracteres sumadosamortiza la red en textos cortos; el lote lleno se divide en décimas de s (< 2 s)
FicheroPOST /v1/texto/frases/fichero (multipart .txt/.md)5 MiBun libro cabe; sale NDJSON en streaming (una frase por línea con offsets) para empezar a recibir sin esperar al final
TrabajoPOST …/frases/trabajo (diseño, no implementado)sin tope síncronocorpus/archivo entero: asíncrono, resultado en fichero

Los topes son configurables por entorno (WK_FRASES_TOPE, WK_FRASES_LOTE_MAX, WK_FRASES_LOTE_CHARS, WK_FRASES_FICH_BYTES). Se fijaron midiendo: con el divisor a ~0,1 s/MB, 1 MB de lote o 5 MiB de fichero se dividen en menos de una décima de segundo de CPU, muy lejos del objetivo de 2 s (el resto del presupuesto es margen para la red y la serialización).

# Lote (varios textos en una petición; respuesta alineada):
curl -s -H "X-API-Key: $CLAVE" -H "Content-Type: application/json" \
  -d '{"textos": ["Uno. Dos.", "¿Sí? No."]}' \
  https://api.wordkers.es/v1/texto/frases
# → {"resultados":[{"frases":[…],"n":2},{"frases":[…],"n":2}],"n":2}

# Fichero → NDJSON en streaming (una frase por línea, con offsets):
curl -s -H "X-API-Key: $CLAVE" -F "[email protected]" \
  https://api.wordkers.es/v1/texto/frases/fichero
# → {"inicio":0,"fin":30,"texto":"El Sr. Pérez pagó 3.000 euros."}
#   {"inicio":31,"fin":41,"texto":"¿De veras?"}
#   …

La puerta de trabajos (diseño, no implementada aquí)

Para lo que no cabe en una respuesta síncrona (un libro grande, un corpus, un archivo entero) el patrón es asíncrono, y aquí queda solo el diseño:

  • POST /v1/texto/frases/trabajo (multipart o URL a un fichero del Storage Box) → responde 202 con {"id": "...", "estado": "encolado"}. No divide en la petición: encola.
  • GET /v1/texto/frases/trabajo/{id} → {"estado": "encolado|en_curso|hecho|error", "progreso": 0..1, "resultado_url": "..."}. El cliente sondea (o webhook).
  • Dónde se guarda el resultado: en el Storage Box de Hetzner (el mismo que usa la telemetría para el volcado diario), un NDJSON comprimido por trabajo, con URL firmada de descarga. No cabe en memoria ni en la respuesta.
  • Cuánto se conserva: el resultado 7 días (configurable) y luego se purga; el registro del trabajo (id, estado, tamaño, coste) se queda para contabilidad.
  • Cómo se cobra: por tamaño procesado (MB o nº de frases), no por llamada —una sola petición puede mover gigabytes—; se anota en la telemetría/presupuestos igual que el resto (E-632), con un tope por agente/concepto.
  • Quién lo ejecuta: un worker aparte (no el proceso del API) que lee del Storage Box, divide en streaming con la MISMA función (frases.dividir) y escribe el resultado. Reutiliza el paralelismo por fichero del banco de E-638-2.

Es deliberadamente lo mismo por dentro (la función pura) con distinta piel por fuera: síncrono para lo pequeño, trabajo para lo grande.

Sin API: el divisor como paquete y como orden de línea

El agente divisor y nuestros programas usan el divisor en proceso, sin HTTP: es más rápido y no depende de la red. Dos formas:

# Como paquete instalable (desde nuestro GitLab):
pip install "git+https://gitlab.com/wordkers/apis/core.git#egg=wk-frases"
# En proceso, desde Python:
from frases import dividir
for f in dividir("El Sr. Pérez pagó 3.000 euros. ¿De veras? Sí."):
    print(f["inicio"], f["fin"], f["texto"])
# Como orden de línea de comandos (una frase por línea, o NDJSON con offsets):
wk-frases documento.txt
wk-frases documento.md --ndjson
cat texto.txt | wk-frases            # también lee de stdin

El paquete (pyproject.toml, módulo frases) es puro y sin dependencias; la lista de abreviaturas va embebida (y frases_abreviaturas.txt, si está al lado, la amplía), así que se instala solo y funciona sin BD ni red.

Alternativas y benchmark

Ver docs/divisores-de-frases.md y tools/E-638/benchmark/resultados.md (agentes-common) para la comparación de los cinco divisores contra Hemero —acuerdo, s/MB, memoria— y para qué gana cada uno.

Qué le falta para ser agente

Lo que hoy decide una persona: resolver los casos ambiguos de arriba con contexto (¿ese etc. cierra la enumeración o sigue?), y aprender abreviaturas y nombres propios nuevos del texto. El divisor es la base; el agente divisor añadirá esa capa de decisión sobre estas reglas.

En esta página