Saltar al contenido
Chatbots de WhatsApp desde cero
Temario Ir al ejercicio →

Entender texto libre

  • ⏱ 60 minutos
  • 📶 Nivel: intermedio
  • 🧩 Incluye ejercicio

💡 🎯 Al terminar esta lección podrás…

  • Normalizar un mensaje (minúsculas, sin tildes ni signos) para poder compararlo de forma fiable.
  • Extraer cantidades escritas con cifras, palabras y docenas.
  • Buscar productos por sinónimos, con plurales y errores de tipeo, y resolver ambigüedades.
  • Detectar la intención de un mensaje con frases clave, orden de prioridad y reglas para mensajes cortos.
  • Reconocer los límites de un bot de reglas y decidir qué hacer cuando no entiende.

1.El problema: la gente no escribe como tú programas

Cuando diseñaste el menú en la lección 2, el cliente tocaba botones: respuestas cerradas y fáciles de manejar. Pero en WhatsApp la gente escribe, y escribe como habla:

Nadie escribe «Seleccionar producto: leche; cantidad: 2». Si tu bot solo entiende frases exactas, el cliente se frustra a la segunda respuesta. Esta lección construye, con reglas simples y sin inteligencia artificial, la capa que convierte texto libre en algo que el motor (lección 5) pueda ejecutar. Todo está en un archivo del código de referencia: src/texto.js, de funciones puras (mismo texto, misma respuesta).

El enfoque general es una tubería de pasos pequeños:

Texto crudo"Quiero DOS Leches!"
→normalizar
Texto limpioquiero dos leches
→extraer
Cantidad y producto2 x leche
→o, si no hay productos
Intenciónsaludo, menú, cancelar...

2.Paso 1: normalizar

Para el computador «Leche», «leche» y «LECHE» son tres cosas distintas, y «Azúcar» no es «azucar». Antes de comparar nada hay que dejar el texto en una forma estándar: minúsculas, sin tildes, sin signos ni emojis y con un solo espacio entre palabras.

src/texto.js
export function normalizar(texto) {
  if (typeof texto !== 'string') return '';
  return texto
    .toLowerCase()
    .normalize('NFD') // separa la letra de su tilde: "é" -> "e" + "´"
    .replace(/[̀-ͯ]/g, '') // borra las tildes sueltas
    .replace(/[^a-z0-9\s]/g, ' ') // signos y emojis -> espacio
    .replace(/\s+/g, ' ')
    .trim();
}

La parte menos obvia es normalize('NFD'). En Unicode una «é» puede guardarse como una sola pieza o como la letra «e» más una tilde suelta. Con NFD (descomposición) siempre quedan separadas, y la segunda línea borra las tildes. Se ve así (ejecutado en Node 24):

ejemplo.js
import { normalizar } from './src/texto.js';

for (const x of ['¡Quiero DOS Leches!', '  Atún   en LATA 😀 ', 'Azúcar', '', null]) {
  console.log(JSON.stringify(x), '->', JSON.stringify(normalizar(x)));
}
Salida esperada
"¡Quiero DOS Leches!" -> "quiero dos leches"
"  Atún   en LATA 😀 " -> "atun en lata"
"Azúcar" -> "azucar"
"" -> ""
null -> ""

Tres detalles de diseño: el emoji desaparece (es un símbolo, no una letra), un valor que no es texto (null) devuelve cadena vacía en lugar de lanzar un error (el bot nunca debe caerse por un mensaje raro), y todas las comparaciones del resto de la lección usan texto normalizado. Por eso en el catálogo los sinónimos están escritos sin tildes: 'azucar', 'tallarin', 'lejia'.

📌 Nota · La «ñ»

Con este método «ñ» se vuelve «n» (descomposición n + tilde). Para un bot de pedidos es una ventaja: quien escribe «dueño» y quien escribe «dueno» obtienen lo mismo (dueno está entre las frases de la intención humano).

3.Paso 2: la cantidad

«2 leches», «dos leches», «una docena de huevos», «media docena». Hay que sacar el número de cualquiera de esas formas. La función extraerCantidad busca el primer número (en cifras o en palabras) y entiende docenas:

src/texto.js
const NUMEROS_EN_PALABRAS = {
  un: 1, uno: 1, una: 1, dos: 2, tres: 3, cuatro: 4, cinco: 5, seis: 6, siete: 7,
  ocho: 8, nueve: 9, diez: 10, once: 11, doce: 12, trece: 13, catorce: 14,
  quince: 15, veinte: 20,
};

export function extraerCantidad(texto) {
  const t = normalizar(texto);
  if (/\bmedia docena\b/.test(t)) return 6;
  let n = null;
  for (const palabra of t.split(' ')) {
    if (/^\d+$/.test(palabra)) { n = Number(palabra); break; }
    if (palabra in NUMEROS_EN_PALABRAS) { n = NUMEROS_EN_PALABRAS[palabra]; break; }
  }
  if (/\bdocenas?\b/.test(t)) return (n ?? 1) * 12;
  return n ?? 1;
}

Observa la decisión de fondo: si el cliente no dice cuántos, es 1 (n ?? 1). Es una suposición razonable en una tienda («quiero leche» = una leche), y el bot siempre muestra «Agregué 1 x Leche entera 1 L», así que el cliente puede corregir. Resultados reales:

ejemplo.js
import { extraerCantidad, cantidadSola } from './src/texto.js';

for (const x of ['2 leches', 'dos leches', 'media docena de huevos', 'una docena',
                 'dos docenas de huevos', 'quiero leche', '1 docena y media']) {
  console.log(JSON.stringify(x), extraerCantidad(x));
}
console.log(cantidadSola('tres'), cantidadSola('3 leches'));
Salida esperada
"2 leches" 2
"dos leches" 2
"media docena de huevos" 6
"una docena" 12
"dos docenas de huevos" 24
"quiero leche" 1
"1 docena y media" 12
3 null

La última línea imprime 3 null. La función hermana cantidadSola devuelve el número solo si el mensaje es únicamente un número ("3" o "tres"). Sirve para el paso en que el bot preguntó «¿Cuántas quieres?» y el cliente responde con una sola palabra. Ya viste cómo la usa el motor en la lección 5.

⚠️ Importante · Los límites son parte del diseño

Fíjate en "1 docena y media": el resultado es 12, no 18. El bot no entiende «docena y media». No pasa nada si lo sabes y lo documentas; el peligro es creer que cubres todo. Cada regla nueva trae más casos raros, y por eso conviene empezar con las frases que tus clientes realmente escriben (las verás cuando midas tu bot en producción; ver la lección 21).

4.Paso 3: encontrar el producto

Ahora el producto. Una idea ingenua sería buscar el nombre exacto del catálogo, «Leche entera 1 L». Nadie lo escribe así. La gente dice «leche», «leches», «mantequila» (con una sola «l»), «papitas» o «tallarines». La solución es darle a cada producto una lista de sinónimos: las formas en que la gente lo nombra. Mira dos entradas de src/catalogo.js:

src/catalogo.js
{ id: 'fideos', nombre: 'Fideos spaghetti 500 g', precio: 3.1, categoria: 'abarrotes', unidad: 'bolsa',
  sinonimos: ['fideos', 'spaghetti', 'tallarin', 'tallarines'] },
{ id: 'papas', nombre: 'Papas fritas 100 g', precio: 2.5, categoria: 'snacks', unidad: 'bolsa',
  sinonimos: ['papas fritas', 'papitas', 'snack'] },

El buscador usa solo los sinónimos (no el nombre largo), así que ahí va el nombre «hablado». Esta es una tabla de datos, no lógica: ampliarla no exige tocar el código. Esa es la forma más barata de mejorar un bot: leer qué escribieron los clientes y agregar sinónimos.

Cómo decide buscarProductos

Tiene tres reglas, y las tres se ven en src/texto.js:

  1. Palabras de relleno fuera. «quiero», «de», «por favor», «hola»... no ayudan a identificar nada. Se descartan (RELLENO).
  2. Una frase coincide solo si están todas sus palabras. «agua con gas» necesita las tres palabras. Cada palabra escrita se usa una sola vez.
  3. Gana la frase más larga. Si el cliente escribe «agua con gas», coinciden tanto «agua» (1 palabra) como «agua con gas» (3 palabras); gana la de 3. Si hay empate, se devuelven todos los empatados.
ejemplo.js
import { buscarProductos } from './src/texto.js';
import { PRODUCTOS } from './src/catalogo.js';

for (const x of ['leche', 'leches', 'agua', 'agua con gas', 'agua sin gas', 'gaseosa',
                 'una gaseosa cola', 'pan', 'pan de molde', 'papitas', 'tallarines',
                 'detergente de platos', 'detergente', 'salsa', 'xyz']) {
  console.log(JSON.stringify(x), '->', JSON.stringify(buscarProductos(x, PRODUCTOS).map((p) => p.id)));
}
Salida esperada
"leche" -> ["leche"]
"leches" -> ["leche"]
"agua" -> ["agua"]
"agua con gas" -> ["agua-gas"]
"agua sin gas" -> ["agua"]
"gaseosa" -> ["gaseosa-cola","gaseosa-naranja"]
"una gaseosa cola" -> ["gaseosa-cola"]
"pan" -> ["pan"]
"pan de molde" -> ["pan-molde"]
"papitas" -> ["papas"]
"tallarines" -> ["fideos"]
"detergente de platos" -> ["lavavajillas"]
"detergente" -> ["detergente"]
"salsa" -> []
"xyz" -> []

Cuatro lecturas útiles. «gaseosa» a secas devuelve dos productos: es ambigua, y el motor responderá con una lista para que el cliente elija. «detergente de platos» gana a «detergente» porque es una frase más específica (así se evita agregar detergente de ropa por error). «salsa» no coincide con nada: lista vacía. Y «leches» coincide con «leche» gracias al plural simple, que se resuelve así:

src/texto.js
function mismaPalabra(escrita, deCatalogo) {
  if (escrita === deCatalogo) return true;
  if (escrita === deCatalogo + 's' || escrita === deCatalogo + 'es') return true; // plural simple
  if (deCatalogo.endsWith('z') && escrita === deCatalogo.slice(0, -1) + 'ces') return true; // arroz -> arroces
  // Un error tipográfico (1 letra) se tolera solo en palabras largas, para no confundir "sal" con "sol".
  return escrita.length >= 5 && deCatalogo.length >= 5 && levenshtein(escrita, deCatalogo) <= 1;
}

Errores de tipeo: la distancia de Levenshtein

La última línea de mismaPalabra tolera errores de tipeo con la distancia de Levenshtein: el mínimo de cambios (cambiar, borrar o añadir una letra) para convertir una palabra en otra. «mantequila» y «mantequilla» están a distancia 1: falta una «l».

ejemplo.js
import { levenshtein } from './src/texto.js';
console.log(levenshtein('mantequila', 'mantequilla'), levenshtein('kitten', 'sitting'), levenshtein('sal', 'sol'));
Salida esperada
1 3 1

Mira la última cifra: «sal» y «sol» también están a distancia 1. Si el bot aceptara un error en cualquier palabra, el cliente que pide «sal» podría recibir algo que no es sal. Por eso la regla es solo en palabras de 5 letras o más. Es una decisión de precisión contra flexibilidad: más tolerancia entiende más, pero se equivoca más. En un negocio, una venta equivocada cuesta más que una pregunta de aclaración. Comprobación (el catálogo tiene «arroz» y «sal»):

ejemplo.js
for (const x of ['mantequila', 'arroces', 'lechee', 'arros', 'sol', 'sal'])
  console.log(JSON.stringify(x), '->', JSON.stringify(buscarProductos(x, PRODUCTOS).map((p) => p.id)));
Salida esperada
"mantequila" -> ["mantequilla"]
"arroces" -> ["arroz"]
"lechee" -> ["leche"]
"arros" -> ["arroz"]
"sol" -> []
"sal" -> ["sal"]

5.Paso 4: varios productos en un solo mensaje

Los clientes juntan cosas: «2 leches y 1 arroz», «pan, leche y atún». separarPedidos parte el mensaje en trozos por comas, «+», « y » o « e », y para cada trozo calcula la cantidad y busca los productos. Descarta los trozos sin producto.

src/texto.js
export function separarPedidos(texto, productos) {
  return String(texto)
    .split(/,|\+|\s+y\s+|\s+e\s+/i)
    .map((trozo) => ({ cantidad: extraerCantidad(trozo), productos: buscarProductos(trozo, productos) }))
    .filter((p) => p.productos.length > 0);
}
ejemplo.js
import { separarPedidos } from './src/texto.js';
import { PRODUCTOS } from './src/catalogo.js';

for (const x of ['2 leches y 1 arroz', '2 leches y media docena de huevos',
                 'quiero pan, leche y atun', '3 gaseosas + 2 aguas', 'hola buenas']) {
  const r = separarPedidos(x, PRODUCTOS).map((p) => [p.cantidad, p.productos.map((q) => q.id)]);
  console.log(JSON.stringify(x), '->', JSON.stringify(r));
}
Salida esperada
"2 leches y 1 arroz" -> [[2,["leche"]],[1,["arroz"]]]
"2 leches y media docena de huevos" -> [[2,["leche"]],[6,["huevo"]]]
"quiero pan, leche y atun" -> [[1,["pan"]],[1,["leche"]],[1,["atun"]]]
"3 gaseosas + 2 aguas" -> [[3,["gaseosa-cola","gaseosa-naranja"]],[2,["agua"]]]
"hola buenas" -> []

El resultado de "3 gaseosas + 2 aguas" es instructivo: una parte es ambigua (dos gaseosas posibles) y la otra clara. El motor agrega lo claro y pregunta solo por lo ambiguo, recordando la cantidad (3) mientras el cliente elige. Lo verás completo en la lección 7.

Probemos de punta a punta, con el motor real, un mensaje largo con un error de tipeo:

ejemplo.js
import { conversacion } from './test/ayudas.js';
const c = conversacion();
c.decir('hola');
c.decir('1 mantequila y media docena de huevos');
console.log(c.texto);
Salida esperada
Agregué 1 x Mantequilla 200 g.
Agregué 6 x Huevo.
Llevas 7 producto(s). Subtotal: S/ 9.80.

Y ahora el caso que no se entiende bien (límite conocido): «1 docena y media de huevos» se parte en « y », y el segundo trozo, «media de huevos», se queda sin docena:

Salida esperada
Agregué 1 x Huevo.
Llevas 1 producto(s). Subtotal: S/ 0.60.

Un bot de reglas tiene fronteras. Lo importante es que el cliente ve lo que el bot entendió («Agregué 1 x Huevo») y puede corregir. Ese eco es la mejor red de seguridad que tienes.

6.Paso 5: la intención

Cuando el mensaje no nombra productos («hola», «a qué hora abren», «quiero hablar con una persona»), hay que averiguar qué quiere hacer el cliente: su intención. El código de referencia las lista en un objeto de frases:

src/texto.js
const FRASES = {
  cancelar: ['cancelar', 'cancela', 'cancelo', 'anular', 'anula', 'salir', 'olvidalo', 'ya no quiero', 'no quiero nada'],
  humano: ['persona', 'humano', 'asesor', 'agente', 'encargado', 'operador', 'dueno', 'atencion al cliente', 'hablar con alguien'],
  horario: ['horario', 'horarios', 'a que hora', 'abren', 'cierran', 'atienden', 'direccion', 'ubicacion', 'donde estan'],
  carrito: ['carrito', 'mi pedido', 'ver pedido', 'que llevo', 'cuanto es', 'mi cuenta'],
  catalogo: ['catalogo', 'productos', 'precios', 'que venden', 'que tienen', 'que hay', 'hacer pedido', 'hacer un pedido', 'pedir', 'comprar'],
  menu: ['menu', 'opciones', 'inicio', 'empezar'],
  ayuda: ['ayuda', 'ayudame', 'help', 'como funciona', 'no entiendo'],
  confirmar: ['confirmar', 'confirmo', 'confirmado', 'si', 'ok', 'dale', 'listo', 'acepto', 'correcto', 'finalizar', 'terminar', 'eso es todo', 'nada mas'],
  saludo: ['hola', 'holaa', 'buenas', 'buenos dias', 'buenas tardes', 'buenas noches', 'hey', 'hi', 'ola'],
};

detectarIntencion recorre las intenciones en un orden fijo y devuelve la primera cuya frase aparezca:

src/texto.js
const SOLO_CORTOS = new Set(['confirmar', 'saludo']);
const ORDEN = ['cancelar', 'humano', 'horario', 'carrito', 'catalogo', 'menu', 'ayuda', 'confirmar', 'saludo'];

function contiene(textoNormalizado, frase) {
  return ` ${textoNormalizado} `.includes(` ${frase} `);
}

Tres ideas clave:

ejemplo.js
import { detectarIntencion } from './src/texto.js';

for (const x of ['hola', 'Buenas tardes!', 'que venden', 'quiero hablar con una persona', 'si',
                 'ok listo gracias por todo amigo', 'a que hora cierran', 'ya no quiero', 'mi pedido',
                 'hacer pedido', 'confirmar mi pedido', 'asdf', '']) {
  console.log(JSON.stringify(x), '->', detectarIntencion(x));
}
Salida esperada
"hola" -> saludo
"Buenas tardes!" -> saludo
"que venden" -> catalogo
"quiero hablar con una persona" -> humano
"si" -> confirmar
"ok listo gracias por todo amigo" -> desconocida
"a que hora cierran" -> horario
"ya no quiero" -> cancelar
"mi pedido" -> carrito
"hacer pedido" -> catalogo
"confirmar mi pedido" -> carrito
"asdf" -> desconocida
"" -> desconocida

Mira dos resultados con cuidado. «ok listo gracias por todo amigo» es desconocida porque tiene más de 4 palabras: es una regla que hay que conocer, no un error. Y «confirmar mi pedido» devuelve carrito, no confirmar, porque carrito (con «mi pedido») va antes en el orden. Ese comportamiento es real del código de referencia y un buen ejemplo de límite: en la práctica, el motor resuelve esto con el estado (en el paso CONFIRMAR los botones son los protagonistas), pero un diseñador atento sabría ajustarlo.

7.La prioridad completa: intención o producto

¿Qué gana cuando un mensaje tiene ambas cosas, como «hola quiero 2 leches» o «si quiero 2 leches»? En el motor (accionDeTexto) el orden es:

  1. Cancelar o hablar con una persona (valen en cualquier estado).
  2. Respuestas propias del paso: la dirección en DIRECCION, «delivery» en TIPO_ENTREGA, «yape» en PAGO, la cantidad suelta si hay un producto pendiente.
  3. Quitar («quita las leches»).
  4. Texto que nombra productos (separarPedidos).
  5. Si no hay productos: la intención (saludo, menú, catálogo, carrito, confirmar, ayuda, horario).

Por eso «si quiero 2 leches» agrega leche (regla 4) aunque detectarIntencion lo etiquete confirmar. Y por eso «hola quiero 2 leches» no muestra un saludo vacío, sino que agrega las leches de una vez:

Salida esperada
ELIGIENDO Agregué 2 x Leche entera 1 L.
Llevas 2 producto(s). Subtotal: S/ 8.60.

Esto responde a una regla de experiencia de usuario: entre dos lecturas posibles, elige la que le ahorra más pasos al cliente.

8.Cuando no se entiende

Algunos mensajes no encajan en nada. «quiero algo rico» no nombra productos ni tiene una intención conocida. El motor suma un fallo, responde «No te entendí bien. Puedes escribir, por ejemplo, "2 leches", o usar los botones.» y repite el paso actual. Con el segundo fallo seguido ofrece hablar con una persona (el contador fallos de la lección 5).

Hay dos buenas prácticas:

Cuando las reglas ya no alcanzan, se puede añadir inteligencia artificial como complemento, no como reemplazo; lo veremos en la lección 22.

9.Errores frecuentes

10.Apuntes para llevar

11.Glosario

Término Significado
Texto libre Mensaje escrito por el cliente con sus propias palabras, sin botones.
Normalizar Dejar el texto en una forma estándar (minúsculas, sin tildes ni signos).
Sinónimo Forma alternativa en que la gente nombra un producto.
Intención Lo que quiere hacer el cliente (saludar, ver el catálogo, cancelar...).
Levenshtein Número mínimo de cambios de letras para pasar de una palabra a otra.
Ambigüedad Mensaje que coincide con varios productos y exige preguntar cuál.
Palabra de relleno Palabra que no ayuda a identificar nada («quiero», «de», «por favor»).
Bot de reglas Bot cuyas respuestas salen de reglas escritas por una persona, sin IA.

🧠 Comprueba lo aprendido

Elige una respuesta en cada pregunta. Verás al instante si acertaste y por qué.

¿Para qué sirve normalizar un mensaje antes de analizarlo?

¿Por qué la tolerancia a errores de tipeo se limita a palabras de 5 letras o más?

El cliente escribe «gaseosa» y el catálogo tiene dos gaseosas. ¿Qué debe hacer el bot?

¿Por qué confirmar y saludo solo se detectan en mensajes de 4 palabras o menos?

Un cliente escribe «hola quiero 2 leches». ¿Qué hace el motor de referencia?

📚 Para profundizar (fuentes oficiales)

Este material lo hace un estudiante con ayuda de IA: ante cualquier duda, manda la documentación oficial.

Tu progreso se guarda en este navegador y se refleja en el índice del curso.

💡 🚀 ¡Ahora te toca!

La práctica fija lo aprendido. Pasa al ejercicio de esta lección.

Ir al ejercicio