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
| Regla | Decisión | Ejemplo |
|---|---|---|
| Terminadores | . ? ! … (y ...) cierran frase | Uno. Dos. → 2 |
; y : | No cortan (unen dentro de la frase) | Vino; vio; venció. → 1 |
| Espacio después | Un terminador solo corta si tras los cierres viene espacio o fin | descarta 3.000, URLs, S.A. |
| Mayúscula tras punto | Señal fuerte; minúscula tras punto ⇒ abreviatura (no corta) | …a las 5 p.m. y se fue. → 1 |
| Abreviaturas | Lista propia frases_abreviaturas.txt (ampliable) | Sr. EE. UU. núm. art. |
| Iniciales / siglas | Una letra + punto (D.) o token con puntos-de-una-letra (S.A., R.A.E.) no cortan | D. Quijote |
| Números | Punto/coma entre dígitos, sin espacio | 3.000 · 1,5 · 10.30 |
| URLs y correos | Puntos sin espacio no cortan | www.web.es · [email protected] |
| Comillas y paréntesis | El 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 suspensivos | Cortan ante mayúscula; ante minúscula continúan | Se fue… Nunca. → 2 · Era… digo → 1 |
| Saltos de línea | Doble 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.
- ¿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
POSTy su respuesta) llega muy por debajo de los 2 s. Es cómodo y no hay que instalar nada.
- ¿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.
- ¿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.
- ¿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.
- ¿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
| Puerta | Endpoint | Tope síncrono | Por qué |
|---|---|---|---|
| Un texto | POST /v1/texto/frases {"texto"} | 200.000 caracteres | defensa; se divide en ~20 ms |
| Lote | POST /v1/texto/frases {"textos":[…]} | 1.000 textos y 1.000.000 caracteres sumados | amortiza la red en textos cortos; el lote lleno se divide en décimas de s (< 2 s) |
| Fichero | POST /v1/texto/frases/fichero (multipart .txt/.md) | 5 MiB | un libro cabe; sale NDJSON en streaming (una frase por línea con offsets) para empezar a recibir sin esperar al final |
| Trabajo | POST …/frases/trabajo (diseño, no implementado) | sin tope síncrono | corpus/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.