Arquitecto de automatización con IA: Día 24
Índice de contenido
Resumen:
Un mensaje de queja recibió la respuesta “gracias por su pedido”, y no fue culpa de ningún modelo de lenguaje: fue un filtro mal escrito. En esta entrada, Valeria convierte el escenario de mensajes de WhatsApp de Trigo Lento en TL Pedidos WhatsApp v2, con un router de seis rutas ordenadas más una ruta de respaldo que termina en una persona. Escribe un evaluador de filtros de Make en Python, lo enfrenta a $200$ mensajes simulados y encuentra $45$ solapes, $25$ huecos y $12$ rutas equivocadas en la primera versión, que la corrección lleva a cero. Al terminar, usted sabrá escribir filtros con grupos AND y OR, hacer excluyentes las rutas de un router, probarlas sin gastar un crédito y calcular cuánto ahorra filtrar temprano: $396$ créditos al mes, la diferencia entre caber en Make Core y pasarse.
¿Cómo citar el presente artículo?
Romero, J. (24 de febrero de 2026). Arquitecto de automatización con IA: Día 24. python-data-engineer. https://www.python-data-engineer.com/post/aai/dia_24/.
Arquitecto de automatización con IA: Día 24 by Jeshua Romero Guadarrama, available under Attribution 4.0 International (CC BY 4.0) at https://www.python-data-engineer.com/post/aai/dia_24/.
Precios, límites y políticas de Make, n8n, Zapier, WhatsApp (Meta, Twilio, Wati) y de la API de Claude revisados en octubre de 2026. El comportamiento del router (rutas procesadas en orden, ruta de respaldo que solo recibe lo que no cumple ninguna otra) y la regla de que routers y filtros no consumen créditos se contrastaron con el centro de ayuda y la academia de Make en esa fecha. El blueprint del Bloque 6 tiene la estructura verificada contra la documentación, revisado en octubre de 2026; no es la exportación de una cuenta real. Los identificadores internos de los operadores de filtro (text:contain:ci, text:pattern:ci y similares) y la clave con la que se marca la ruta de respaldo (fallback) no aparecen en la documentación pública: siguen el formato documentado y deben contrastarse exportando un escenario propio antes de usarlos.
Día 24. Routers y filtros: decidir sin código
Introducción
Ayer, en el Día 23 (Disparadores, acciones y módulos en Make), Valeria distinguió los tipos de módulo de Make, conectó WhatsApp con un webhook a través de Twilio, verificó la firma de cada mensaje y midió la cola de la hora pico, y Trigo Lento ganó TL Pedidos WhatsApp v1: un escenario que recibe cada mensaje, comprueba que viene de Twilio, lo guarda como borrador en la hoja y responde a tiempo. Ese escenario trata igual todos los mensajes, y hoy toca enseñarle a distinguirlos.
Es martes 24 de febrero, poco antes de las nueve. Anoche, entusiasmada, Valeria añadió a la versión de ayer un router con una ruta por tipo de mensaje y una respuesta automática para los pedidos, lo activó y se fue a dormir. A las 8:12 llegó este mensaje: “El pastel de ayer llegó tarde y mal, quiero hablar con alguien”. A las 8:12, Trigo Lento contestó: “Gracias por su pedido, en breve se lo confirmamos”. La clienta, con razón, escribió una segunda vez, ahora en mayúsculas. Valeria apagó el escenario, llamó a la clienta, se disculpó y abrió el historial de Make para entender qué había pasado. La respuesta era corta y vergonzosa: el mensaje contenía “quiero” y “pastel”, las palabras con las que su filtro reconocía un pedido, y contenía “tarde” y “mal”, las de su filtro de quejas. El router lo mandó por las dos rutas, y la de pedidos, que era la única que contestaba, contestó.
Don Ernesto se entera a media mañana, y su objeción llega con la calma de quien ya se lo esperaba: “Valeria, ¿y cómo sabe usted que no le va a contestar mal a otra clienta mañana? ¿Va a leer todos los mensajes antes de que salgan, para comprobar? Porque entonces el aparato no ahorra nada.” La pregunta es justa y tiene una respuesta mejor que “leerlos todos”: probar los filtros antes de activarlos, con cientos de mensajes que se parezcan a los de verdad, y diseñar las rutas para que un mensaje no pueda tomar dos caminos. Ninguna de las dos cosas exige un modelo de lenguaje; exigen lógica escrita con cuidado.
En la presente entrada, descubrirá cómo decide un router de Make: en qué orden recorre sus rutas, por qué un mismo paquete puede tomar varias y cuándo entra en juego la ruta de respaldo. Verá la estructura de un filtro en el blueprint, con grupos de condiciones que se combinan con AND dentro del grupo y con OR entre grupos, y la lista de operadores que usará la serie con su equivalente en Python. Escribirá un evaluador de filtros que interpreta las condiciones de un blueprint, generará $200$ mensajes de WhatsApp simulados, contará los huecos y los solapes de la primera versión de las rutas de Valeria y los llevará a cero con una regla de prioridad. Después leerá y dibujará TL Pedidos WhatsApp v2, calculará cuántos créditos ahorra filtrar al principio en lugar de al final, medirá cuántos mensajes atiende una persona y cuántos el escenario, y validará el router con reglas que convierten la ética del día en comprobaciones automáticas.
Objetivos de aprendizaje
Al terminar esta entrada, usted podrá:
- Usar el router de Make con rutas ordenadas y una ruta de respaldo (fallback).
- Escribir filtros con grupos de condiciones (AND dentro del grupo, OR entre grupos) y sus operadores.
- Detectar huecos y solapes entre rutas probando los filtros en Python con cientos de mensajes.
- Ahorrar créditos filtrando lo antes posible.
El router
Un escenario sin router es una línea: cada paquete pasa por todos los módulos, uno detrás de otro, salvo que un filtro lo detenga. El router (builtin:BasicRouter en el blueprint) es el módulo que rompe esa línea en varias. No habla con ninguna aplicación ni transforma datos: recibe un paquete y lo reparte entre sus rutas, cada una con su propia fila de módulos. Es la pieza que en el Día 22 se veía como un rombo naranja en el dibujo del borrador del formulario, y la que Valeria usó anoche.
La regla de reparto es la fuente de casi todos los errores con routers, así que conviene leerla despacio. Según el centro de ayuda de Make, revisado en octubre de 2026, las rutas se procesan una detrás de otra, no en paralelo: Make no empieza la segunda ruta hasta terminar la primera, y el orden se cambia desde la opción de ordenar rutas del menú del router. Cada ruta empieza con un filtro, que es la condición que el paquete debe cumplir para entrar en ella. Y el paquete entra en todas las rutas cuyo filtro cumple, no solo en la primera: el router no es un “si, si no, si no”; es un “para cada ruta, si cumple, entra”. Un mensaje que cumple dos filtros se procesa dos veces, con todo lo que eso significa: dos filas, dos respuestas, dos veces los créditos de cada ruta.
La excepción es la ruta de respaldo (en la documentación en inglés, fallback route). Una ruta marcada como respaldo procesa los datos que no cumplen la condición de ninguna otra ruta y se ejecuta al final. En el editor se marca desde la ventana del filtro de la ruta, y el router la distingue con una flecha especial. La documentación añade un detalle útil: la ruta de respaldo también puede tener su propio filtro, de modo que se puede decidir que ni siquiera lo que cae al respaldo pase si no cumple algo mínimo.
Con esa regla, lo que hace un router con un paquete $x$ se escribe en una línea:
$$ E(x) = \begin{cases} R(x) = {, r \in {1, \dots, k} : f_r(x) = 1 ,} & \text{si } R(x) \neq \varnothing \ {, r_{\text{resp}} ,} & \text{si } R(x) = \varnothing \end{cases} $$
La lógica detrás de esta fórmula es múltiple:
- $f_r(x)$ vale $1$ si el paquete cumple el filtro de la ruta normal $r$ y $0$ si no; el router tiene $k$ rutas normales, recorridas en el orden en que están configuradas.
- $R(x)$ es el conjunto de rutas normales que el paquete cumple. Puede tener cero, una o varias rutas: nada en la regla de Make obliga a que tenga una sola.
- $E(x)$ es el conjunto de rutas que se ejecutan. Si $R(x)$ no está vacío, se ejecutan todas sus rutas, en orden; si está vacío, se ejecuta la ruta de respaldo $r_{\text{resp}}$.
- En conclusión, un router bien diseñado es aquel en el que $\lvert E(x) \rvert = 1$ para todo mensaje real: cada mensaje toma exactamente un camino. Cuando $R(x)$ tiene dos o más rutas hay un solape; cuando un mensaje de un tipo conocido termina en el respaldo, hay un hueco.
La Figura 1 traduce la regla a una metáfora ferroviaria que Don Ernesto entendió a la primera: el router es un cambio de agujas con varias vías, cada vía tiene una señal (el filtro) y un vagón (el paquete) entra en todas las vías cuya señal está en verde para su billete; si ninguna se abre, el vagón sigue por la vía de respaldo hasta la estación de Valeria.
Figura 1. Agujas de tren: el router como cambio de vías, con una señal por filtro; el vagón del mensaje de queja tomó la vía de pedidos y la de quejas porque su billete abría las dos, y la vía de respaldo solo recibe vagones que ninguna señal deja pasar.
Hay un último dato que importa para el presupuesto: según la academia de Make, revisada en octubre de 2026, los routers y los filtros no consumen operaciones, y por tanto no consumen créditos. Lo que cuesta es lo que hay detrás: cada módulo de cada ruta que se ejecuta. El Día 22 lo dejó escrito en la fórmula $C = \sum_m p_m$, y hoy tendrá una consecuencia directa: un solape no solo produce una respuesta equivocada, también cobra los créditos de dos rutas.
Filtros
Un filtro es una condición sobre una línea del escenario: deja pasar los paquetes que la cumplen y detiene los demás. En el editor se pone haciendo clic sobre la línea que une dos módulos; en el blueprint, como vio el Día 22 en la Tabla 2, se guarda en la clave filter del módulo al que llega la línea. Por eso el filtro de una ruta vive en el primer módulo de esa ruta, y el filtro de firma del Día 23 vivía en el módulo $3$, el que lee el registro del cliente.
La estructura de filter tiene dos claves: name, el nombre que se ve en el lienzo, y conditions, una lista de grupos. Cada grupo es a su vez una lista de condiciones, y cada condición tiene tres partes: a, el valor que se compara (casi siempre una referencia como {{1.Body}}); o, el operador; y b, el valor con el que se compara, que los operadores de existencia no necesitan. La regla de combinación es la que da nombre a la sección del editor: las condiciones de un mismo grupo se unen con AND (todas deben cumplirse) y los grupos entre sí con OR (basta con uno). En lógica, esa forma tiene nombre propio:
$$ f(x) = \bigvee_{g=1}^{G} ; \bigwedge_{c=1}^{C_g} \varphi_{g,c}(x) $$
La lógica detrás de esta fórmula es múltiple:
- $\varphi_{g,c}(x)$ es la condición $c$ del grupo $g$, evaluada sobre el paquete $x$: un operador aplicado a
ayb, que devuelve verdadero o falso. - $\bigwedge$ (AND) recorre las $C_g$ condiciones del grupo: el grupo se cumple solo si se cumplen todas.
- $\bigvee$ (OR) recorre los $G$ grupos: el filtro se cumple si al menos un grupo se cumple.
- En conclusión, un filtro de Make es una forma normal disyuntiva: un OR de ANDs. Cualquier condición lógica se puede escribir así, pero algunas cuestan muchos grupos. “Contiene alguna de cuatro palabras de pedido y alguna de seis palabras de fecha” exige $4 \cdot 6 = 24$ grupos, uno por cada pareja, porque la forma no permite un OR dentro de un AND.
Esa última viñeta tiene una consecuencia práctica que se verá en la primera versión de Valeria: sus filtros de pedidos sumaban $85$ condiciones, casi todas copiadas a mano en el editor. Un filtro que nadie puede leer de un vistazo es un filtro que nadie revisa.
Los operadores
Make agrupa los operadores en básicos (existe y no existe), de texto, numéricos, de fecha y hora y de arreglos, y varios de los de texto tienen una variante que no distingue mayúsculas y minúsculas. En el blueprint, cada operador se guarda con un identificador corto. La Tabla 1 reúne los diez que usará la serie, con su nombre aproximado en el editor y su equivalente en Python, que es exactamente lo que el evaluador del Bloque 3 implementará. Antes, el bloque de importaciones de siempre, con la semilla global y los dos supuestos fijos del día: los $1,560$ mensajes de WhatsApp al mes y la cuota de Make Core:
# Importaciones base, semilla de la serie y carpeta del día en Python:
import warnings
warnings.filterwarnings('ignore')
import copy
import json
import re
from collections import Counter
from datetime import date
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# Generador aleatorio con la semilla global de la serie:
rng = np.random.default_rng(2026)
pd.set_option('display.width', 120)
pd.set_option('display.max_colwidth', 60)
carpeta = Path('trigo_lento') / 'dia_24'
flujos = carpeta / 'flujos' / 'make'
flujos.mkdir(parents = True, exist_ok = True)
# Supuestos fijos de la serie: mensajes de WhatsApp al mes y cuota de Make Core:
MENSAJES_MES = 1_560 # 60 mensajes al día, 26 días (A.5)
CUOTA_CORE = 10_000 # créditos al mes (revisado en octubre de 2026)
print('numpy', np.__version__, '| pandas', pd.__version__)
print('Mensajes de WhatsApp al mes:', MENSAJES_MES, '| cuota de Make Core:', CUOTA_CORE, 'créditos')
numpy 2.4.6 | pandas 3.0.3
Mensajes de WhatsApp al mes: 1560 | cuota de Make Core: 10000 créditos
El bloque de operadores define, para cada identificador, el nombre en el editor y una función de Python de dos argumentos, a y b, que devuelve verdadero o falso:
# Operadores de filtro de Make usados en la serie y su equivalente en Python:
def _num(v):
return float(str(v).replace(',', '.'))
OPERADORES = { # identificador en el blueprint -> (nombre en el editor, función de Python)
'text:equal': ('Igual a', lambda a, b: str(a) == str(b)),
'text:equal:ci': ('Igual a (sin distinguir mayúsculas)', lambda a, b: str(a).lower() == str(b).lower()),
'text:contain:ci': ('Contiene (sin distinguir mayúsculas)', lambda a, b: str(b).lower() in str(a).lower()),
'text:notcontain:ci': ('No contiene (sin distinguir mayúsculas)', lambda a, b: str(b).lower() not in str(a).lower()),
'text:pattern:ci': ('Coincide con el patrón (sin distinguir)', lambda a, b: re.search(b, str(a), re.I) is not None),
'text:notpattern:ci': ('No coincide con el patrón (sin distinguir)', lambda a, b: re.search(b, str(a), re.I) is None),
'number:greater': ('Mayor que (número)', lambda a, b: _num(a) > _num(b)),
'date:greater': ('Posterior a (fecha)', lambda a, b: date.fromisoformat(str(a)[:10]) > date.fromisoformat(str(b)[:10])),
'exist': ('Existe', lambda a, b: a not in (None, '')),
'notexist': ('No existe', lambda a, b: a in (None, '')),
}
EQUIVALENTE = {'text:equal': 'a == b', 'text:equal:ci': 'a.lower() == b.lower()',
'text:contain:ci': 'b.lower() in a.lower()', 'text:notcontain:ci': 'b.lower() not in a.lower()',
'text:pattern:ci': 're.search(b, a, re.I)', 'text:notpattern:ci': 'not re.search(b, a, re.I)',
'number:greater': 'float(a) > float(b)', 'date:greater': 'date(a) > date(b)',
'exist': "a not in (None, '')", 'notexist': "a in (None, '')"}
tabla1 = pd.DataFrame([{'operador': op, 'en el editor': nombre, 'Python': EQUIVALENTE[op]}
for op, (nombre, _) in OPERADORES.items()])
print(tabla1.to_string(index = False))
print('Operadores:', len(OPERADORES), '| unarios (sin valor b):',
[op for op in OPERADORES if op in ('exist', 'notexist')])
operador en el editor Python
text:equal Igual a a == b
text:equal:ci Igual a (sin distinguir mayúsculas) a.lower() == b.lower()
text:contain:ci Contiene (sin distinguir mayúsculas) b.lower() in a.lower()
text:notcontain:ci No contiene (sin distinguir mayúsculas) b.lower() not in a.lower()
text:pattern:ci Coincide con el patrón (sin distinguir) re.search(b, a, re.I)
text:notpattern:ci No coincide con el patrón (sin distinguir) not re.search(b, a, re.I)
number:greater Mayor que (número) float(a) > float(b)
date:greater Posterior a (fecha) date(a) > date(b)
exist Existe a not in (None, '')
notexist No existe a in (None, '')
Operadores: 10 | unarios (sin valor b): ['exist', 'notexist']
Tabla 1. Operadores de filtro usados en la serie, con su equivalente en Python (identificadores según el formato documentado, a contrastar con una exportación; revisado en octubre de 2026).
| Operador en el blueprint | En el editor | Equivalente en Python | Uso en Trigo Lento |
|---|---|---|---|
text:equal | Igual a | a == b | Comparar la firma calculada con la recibida (Día 23) |
text:equal:ci | Igual a, sin distinguir mayúsculas | a.lower() == b.lower() | Palabras exactas como “BAJA” |
text:contain:ci | Contiene, sin distinguir mayúsculas | b.lower() in a.lower() | Primera versión de las rutas |
text:notcontain:ci | No contiene, sin distinguir mayúsculas | b.lower() not in a.lower() | Excluir palabras de fecha |
text:pattern:ci | Coincide con el patrón | re.search(b, a, re.I) | Rutas corregidas |
text:notpattern:ci | No coincide con el patrón | not re.search(b, a, re.I) | Exclusión por prioridad |
number:greater | Mayor que | float(a) > float(b) | Adjuntos: NumMedia mayor que 0 |
date:greater | Posterior a | date(a) > date(b) | Fecha de entrega posterior a hoy |
exist | Existe | a not in (None, '') | Pedido con fecha de entrega |
notexist | No existe | a in (None, '') | Formulario sin fecha (Día 22) |
Los dos operadores de patrón merecen una nota. “Coincide con el patrón” compara el texto con una expresión regular, la misma herramienta que el Día 4 usó para normalizar teléfonos. Con ella, cu[aá]nto reconoce “cuánto” y “cuanto” en una sola condición, y factur reconoce “factura”, “facturar” y “facturación”. Donde “contiene” necesita un grupo por variante, un patrón necesita una condición.
Un evaluador de filtros en Python
Con los operadores definidos, interpretar un filtro de Make es cuestión de diez líneas. La función resolver sustituye cada referencia {{n.campo}} por el dato que entregó el módulo n (si el valor es solo una referencia, conserva su tipo, para que un campo ausente sea None y no una cadena vacía); evaluar_condicion aplica el operador; y evaluar_filtro aplica la regla de la fórmula anterior: any sobre los grupos y all sobre las condiciones de cada grupo. Un filtro sin condiciones deja pasar todo, como en Make. El bloque lo prueba con diez casos, uno por cada trampa conocida:
# Evaluador de filtros de Make: grupos en OR, condiciones en AND, con pruebas unitarias en Python:
def resolver(valor, paquete):
# Sustituye {{n.campo}} por el dato del módulo n; si el valor es solo una referencia, conserva el tipo:
if not isinstance(valor, str):
return valor
sola = re.fullmatch(r'\{\{(\d+)\.(\w+)\}\}', valor)
if sola:
return paquete.get(int(sola.group(1)), {}).get(sola.group(2))
return re.sub(r'\{\{(\d+)\.(\w+)\}\}',
lambda m: str(paquete.get(int(m.group(1)), {}).get(m.group(2), '')), valor)
def evaluar_condicion(c, paquete):
a = resolver(c['a'], paquete)
b = resolver(c.get('b'), paquete)
return OPERADORES[c['o']][1](a, b)
def evaluar_filtro(filtro, paquete):
# Sin filtro, todo pasa; con filtro: basta un grupo cuyas condiciones se cumplan todas:
if not filtro or not filtro.get('conditions'):
return True
return any(all(evaluar_condicion(c, paquete) for c in grupo) for grupo in filtro['conditions'])
def filtro(nombre, *grupos):
return {'name': nombre, 'conditions': [list(g) for g in grupos]}
def cond(a, o, b = None):
return {'a': a, 'o': o} if b is None else {'a': a, 'b': b, 'o': o}
msg = {1: {'Body': '¿Cuánto cuesta el Pastel de zanahoria?', 'NumMedia': '10', 'Fecha': '2026-02-24'}}
pruebas = [
('igual a distingue mayúsculas', filtro('t', [cond('{{1.Body}}', 'text:equal', '¿cuánto cuesta el pastel de zanahoria?')]), False),
('igual a (ci) no las distingue', filtro('t', [cond('{{1.Body}}', 'text:equal:ci', '¿cuánto cuesta el pastel de zanahoria?')]), True),
('contiene "pastel" (ci)', filtro('t', [cond('{{1.Body}}', 'text:contain:ci', 'pastel')]), True),
('patrón cu[aá]nto (ci)', filtro('t', [cond('{{1.Body}}', 'text:pattern:ci', 'cu[aá]nto')]), True),
('AND: pastel y chocolate', filtro('t', [cond('{{1.Body}}', 'text:contain:ci', 'pastel'),
cond('{{1.Body}}', 'text:contain:ci', 'chocolate')]), False),
('OR: chocolate o zanahoria', filtro('t', [cond('{{1.Body}}', 'text:contain:ci', 'chocolate')],
[cond('{{1.Body}}', 'text:contain:ci', 'zanahoria')]), True),
('número: "10" > "9"', filtro('t', [cond('{{1.NumMedia}}', 'number:greater', '9')]), True),
('fecha: 24-02 > 21-02', filtro('t', [cond('{{1.Fecha}}', 'date:greater', '2026-02-21')]), True),
('existe un campo ausente', filtro('t', [cond('{{1.MediaUrl0}}', 'exist')]), False),
('no existe un campo ausente', filtro('t', [cond('{{1.MediaUrl0}}', 'notexist')]), True),
]
aciertos = 0
for nombre, f, esperado in pruebas:
obtenido = evaluar_filtro(f, msg)
aciertos += obtenido == esperado
print(f"{'ok ' if obtenido == esperado else 'MAL'} {nombre:<32} -> {obtenido}")
print(f'Pruebas superadas: {aciertos} de {len(pruebas)} | como texto, "10" > "9" sería {"10" > "9"}')
ok igual a distingue mayúsculas -> False
ok igual a (ci) no las distingue -> True
ok contiene "pastel" (ci) -> True
ok patrón cu[aá]nto (ci) -> True
ok AND: pastel y chocolate -> False
ok OR: chocolate o zanahoria -> True
ok número: "10" > "9" -> True
ok fecha: 24-02 > 21-02 -> True
ok existe un campo ausente -> False
ok no existe un campo ausente -> True
Pruebas superadas: 10 de 10 | como texto, "10" > "9" sería False
Las $10$ pruebas pasan, y cada una deja una lección. “Igual a” distingue mayúsculas: el mensaje dice “Pastel” y la condición “pastel”, así que no son iguales, y la variante sin distinción sí lo son. Un grupo con dos condiciones exige las dos (el mensaje dice “pastel” pero no “chocolate”, así que el AND falla), y dos grupos con una condición cada uno se conforman con una (el mensaje dice “zanahoria”, así que el OR pasa). La prueba numérica es la que más errores evita en la práctica: con el operador numérico, $10 > 9$; comparados como texto, como muestra la última línea, “10” > “9” es falso, porque el texto se compara carácter a carácter y “1” va antes que “9”. Si un filtro de Make compara números con un operador de texto, o recibe los números como texto sin convertir, el resultado será alfabético. Las dos últimas pruebas muestran que un campo que no llegó (aquí, MediaUrl0, la URL de la primera imagen, que Twilio solo envía si hay adjunto) no existe, y que “no existe” es la forma correcta de preguntar por él.
Atención aquí: los patrones de este día se probaron con el motor de expresiones regulares de Python, y Make evalúa “Coincide con el patrón” con el suyo. Las diferencias aparecen justo en los rincones que más usa un negocio en español: el límite de palabra \b junto a letras con tilde o eñe, las clases como \w con caracteres acentuados y las mayúsculas de letras como “Á”. Antes de activar un filtro con patrones, pruébelo en el editor de Make con los mensajes reales que más le preocupen (los de la Tabla 4 son un buen comienzo) y evite \b pegado a una vocal con tilde. Un patrón que en Python atrapa “decepción” y en Make no, convierte una queja en un hueco.
El árbol de decisión de los mensajes
Para probar filtros hacen falta mensajes, y para que la prueba diga algo, los mensajes tienen que parecerse a los de verdad. Valeria repasó dos semanas de conversaciones de WhatsApp de la libreta y de la hoja, sin copiar ningún dato personal, y anotó qué tipos de mensaje recibe Trigo Lento y en qué proporción. Encontró seis: pedidos (encargos con o sin fecha de entrega), preguntas de horario, preguntas de precio, solicitudes de factura, quejas y un cajón de otros (saludos, agradecimientos, preguntas que no encajan en nada). Cada tipo tiene un destino distinto, y la Figura 2, más abajo, los dibuja como un árbol.
El siguiente bloque convierte esas notas en $200$ mensajes simulados. Para cada tipo hay varias plantillas con las formas de escribir que Valeria ve a diario, incluidas las incómodas: “cuanto” sin tilde, “pal sábado”, la fecha escrita como “28/02”, el “Buenas tardes” de cortesía y el pedido que pregunta el precio en la misma frase. Las plantillas de pedido tienen dos versiones, con fecha y sin fecha, y el $60%$ de los pedidos se genera con fecha. Los productos, los días y los nombres salen al azar con la semilla de la serie. El número de mensajes por tipo es fijo (las proporciones que Valeria midió) y cada mensaje lleva su tipo real, que el escenario no conoce y que servirá de respuesta correcta:
# 200 mensajes simulados de WhatsApp con su tipo real y si traen fecha en Python:
PRODUCTOS = ['hogazas', 'baguettes', 'conchas', 'croissants', 'roles de canela', 'pays de queso']
DIAS = ['sábado', 'viernes', 'lunes', 'martes', 'mañana', '28/02', 'jueves']
NOMBRES = ['Lucía', 'Ramiro', 'Paty', 'Jorge', 'Inés']
PLANTILLAS = {
'pedido': [('Quiero {n} {p} para el {d}', 'Quiero {n} {p}'),
('Me aparta {n} {p} pal {d}? soy {nom}', 'Me aparta {n} {p}? soy {nom}'),
('Hola, quisiera encargar un pastel de chocolate para el {d}', 'Hola, quisiera encargar un pastel de chocolate'),
('Buenas tardes, quiero {n} {p}, ¿a qué hora paso el {d}?', 'Buenas tardes, quiero {n} {p}, ¿a qué hora paso?'),
('Necesito {n} {p} para el {d}', 'Necesito {n} {p} para la oficina'),
('Quiero el pastel de zanahoria para el {d}, ¿cuánto sería?', 'Quiero el pastel de zanahoria, ¿cuánto sería?')],
'horario': ['¿A qué hora abren el {d}?', '¿Abren los domingos?', '¿Cuál es su horario?',
'¿Hasta qué hora están abiertos hoy?', '¿Cierran tarde los sábados?'],
'precio': ['¿Cuánto cuesta el pastel de zanahoria?', 'cuanto cuesta la hogaza', '¿Qué precio tiene el pay de queso?',
'Me pasa sus precios porfa', 'Quiero saber cuánto cuesta la caja de pan dulce'],
'factura': ['Necesito factura del pedido de ayer', '¿Me pueden facturar? Le paso mi RFC', 'Me falta el CFDI de la semana'],
'queja': ['El pastel de ayer llegó tarde y mal, quiero hablar con alguien', 'Las conchas venían crudas, qué decepción',
'Tengo una queja sobre mi pedido', 'Pésimo servicio, nadie me contestó'],
'otro': ['Gracias', 'ok', 'Muchas gracias!', 'Va, gracias', 'Hola', 'Buenas tardes', '¿Tienen estacionamiento?',
'¿Hacen pan sin gluten?', '¿Normalmente tienen baguette?', 'Me encantó el pan, saludos'],
}
ACUSES = {'Gracias', 'ok', 'Muchas gracias!', 'Va, gracias'}
CUENTAS = {'pedido': 60, 'horario': 40, 'precio': 30, 'factura': 10, 'queja': 12, 'otro': 48}
filas = []
for tipo, n in CUENTAS.items():
for k in range(n):
plantilla = PLANTILLAS[tipo][rng.integers(len(PLANTILLAS[tipo]))]
tiene_fecha = tipo == 'pedido' and bool(rng.random() < 0.6)
if tipo == 'pedido':
plantilla = plantilla[0] if tiene_fecha else plantilla[1]
texto = plantilla.format(n = int(rng.integers(1, 7)), p = PRODUCTOS[rng.integers(len(PRODUCTOS))],
d = DIAS[rng.integers(len(DIAS))], nom = NOMBRES[rng.integers(len(NOMBRES))])
filas.append({'tipo': tipo, 'texto': texto, 'tiene_fecha': tiene_fecha, 'acuse': texto in ACUSES})
mensajes = pd.DataFrame(filas).sample(frac = 1, random_state = 2026).reset_index(drop = True)
mensajes.insert(0, 'id', [f'SM{k:04d}' for k in range(1, len(mensajes) + 1)])
tabla2 = mensajes.groupby('tipo', sort = False).agg(mensajes = ('id', 'size'), con_fecha = ('tiene_fecha', 'sum'),
acuses = ('acuse', 'sum'))
tabla2 = tabla2.reindex(CUENTAS)
tabla2['% del total'] = (100 * tabla2['mensajes'] / len(mensajes)).round(1)
tabla2['al mes'] = (tabla2['mensajes'] / len(mensajes) * MENSAJES_MES).round().astype(int)
print(tabla2.to_string())
print('Total:', len(mensajes), '| textos distintos:', mensajes['texto'].nunique())
print(mensajes.head(6)[['id', 'tipo', 'texto']].to_string(index = False))
mensajes con_fecha acuses % del total al mes
tipo
pedido 60 34 0 30.0 468
horario 40 0 0 20.0 312
precio 30 0 0 15.0 234
factura 10 0 0 5.0 78
queja 12 0 0 6.0 94
otro 48 0 20 24.0 374
Total: 200 | textos distintos: 72
id tipo texto
SM0001 precio cuanto cuesta la hogaza
SM0002 horario ¿Cierran tarde los sábados?
SM0003 pedido Hola, quisiera encargar un pastel de chocolate para el sábado
SM0004 pedido Hola, quisiera encargar un pastel de chocolate
SM0005 precio ¿Cuánto cuesta el pastel de zanahoria?
SM0006 pedido Hola, quisiera encargar un pastel de chocolate para el 28/02
Tabla 2. Distribución de los 200 mensajes simulados y su equivalente en un mes de 1 560 mensajes.
| Tipo | Mensajes | Con fecha | Acuses | % del total | Al mes | Destino deseado |
|---|---|---|---|---|---|---|
| Pedido | 60 | 34 | 0 | 30.0 | 468 | Borrador y confirmación de Valeria |
| Horario | 40 | 0 | 0 | 20.0 | 312 | Respuesta automática identificada |
| Precio | 30 | 0 | 0 | 15.0 | 234 | Respuesta automática con la lista |
| Factura | 10 | 0 | 0 | 5.0 | 78 | Hoja de facturas para Valeria |
| Queja | 12 | 0 | 0 | 6.0 | 94 | Siempre una persona |
| Otro | 48 | 0 | 20 | 24.0 | 374 | Ruta de respaldo hacia Valeria |
| Total | 200 | 34 | 20 | 100.0 | 1 560 |
La muestra tiene $60$ pedidos, $34$ de ellos con fecha, y una cifra que no es casualidad: escalados a los $1,560$ mensajes del mes, los pedidos son $468$, los mismos $468$ encargos al mes que la serie usa desde el Día 1. Los otros cinco tipos suman $1,092$ mensajes que no son encargos y que hoy Valeria lee uno por uno. Dentro de “otro” hay $20$ acuses (“Gracias”, “ok”, “Va, gracias”), el $10%$ del total: mensajes que no piden nada y que, como se verá en la sección de créditos, conviene detener antes del router. La muestra tiene $72$ textos distintos, porque las plantillas se repiten con productos y días diferentes; es suficiente para encontrar los errores de diseño de un filtro, aunque no para estimar su tasa de error en producción, una diferencia que las consideraciones finales retomarán.
El árbol de la Figura 2 ordena los seis destinos por prioridad, de arriba abajo: primero las quejas, que nunca deben recibir una respuesta automática; después las facturas, porque Marisol, de la Cafetería Brújula, ya avisó que una factura que se pierde le descuadra el cierre de la semana; después los pedidos, separados por la fecha; después las preguntas de precio y de horario, que el escenario puede contestar solo; y, al final, la ruta de respaldo, que lleva a Valeria todo lo que no encaja. Las cifras de cada hoja son las del mes que calculará el Bloque 9.
Figura 2. El árbol de decisión de Trigo Lento: el mensaje firmado pasa el filtro de acuses y llega al router, que lo envía por prioridad a queja (94 al mes), factura (78), pedido con fecha (265), pedido sin fecha (203), precio (234) u horario (312); lo demás sigue la ruta de respaldo hacia Valeria (218).
Lógica booleana sin sorpresas
El error de anoche cabe en una tabla de verdad. El mensaje de la clienta tenía dos propiedades: contenía palabras de pedido ($P$) y palabras de queja ($Q$). La Tabla 3 enumera las cuatro combinaciones posibles y lo que hacía con cada una el router de anoche, en el que las rutas eran independientes, frente a lo que hace un router con prioridad, en el que la ruta de pedidos solo se toma si el mensaje no es una queja:
Tabla 3. Tabla de verdad de dos filtros: rutas independientes frente a rutas con prioridad.
| $P$ (palabras de pedido) | $Q$ (palabras de queja) | Rutas independientes | Rutas con prioridad | Ejemplo |
|---|---|---|---|---|
| 0 | 0 | Respaldo | Respaldo | “¿Tienen estacionamiento?” |
| 0 | 1 | Queja | Queja | “Tengo una queja sobre mi pedido” |
| 1 | 0 | Pedido | Pedido | “Quiero 2 hogazas para el sábado” |
| 1 | 1 | Pedido y queja | Queja | “El pastel llegó tarde y mal, quiero hablar con alguien” |
La última fila es el solape: con rutas independientes, el mensaje entra en las dos, y la ruta de pedidos, que contesta sola, contesta. Con prioridad, el filtro de pedidos se convierte en $P \wedge \neg Q$, “palabras de pedido y ninguna de queja”, y el mensaje solo entra por la ruta de quejas. La primera fila es la del respaldo, y no es un error: un mensaje que no encaja en nada debe llegar a una persona. El error sería que un pedido, una factura o una queja acabaran ahí, porque sus palabras no estaban en el filtro: eso es un hueco.
La regla de prioridad se generaliza a cualquier número de rutas. Si las rutas se ordenan de la más delicada a la menos delicada, el filtro efectivo de cada ruta es su condición propia más la negación de todas las anteriores:
$$ f’r(x) = f_r(x) \wedge \neg f_1(x) \wedge \neg f_2(x) \wedge \cdots \wedge \neg f{r-1}(x) $$
La lógica detrás de esta fórmula es múltiple:
- $f_r$ es la condición “natural” de la ruta $r$ (por ejemplo, “habla de precios”) y $f’_r$ la que se escribe de verdad en el filtro.
- La negación de las rutas anteriores garantiza que, si un mensaje cumple dos condiciones naturales, solo lo tome la de mayor prioridad: los filtros $f’_r$ son mutuamente excluyentes por construcción.
- La unión de las rutas no cambia: un mensaje que cumplía alguna condición natural sigue cumpliendo exactamente una $f’_r$, así que la prioridad no abre huecos nuevos.
- En conclusión, la exclusión se paga en condiciones: la ruta $r$ lleva $r - 1$ negaciones. En Trigo Lento, la ruta de horario, la última, lleva $4$, y el router entero suma $20$ condiciones, frente a las $85$ de la primera versión, porque los patrones agrupan las variantes que antes necesitaban un grupo cada una.
La exclusión resuelve los solapes, pero no los huecos: un mensaje que no cumple ninguna condición natural tampoco cumplirá ninguna $f’_r$. Para contar los dos problemas a la vez, basta con la identidad de inclusión y exclusión, que aquí se lee como un presupuesto de errores:
$$ \lvert A \cup B \rvert = \lvert A \rvert + \lvert B \rvert - \lvert A \cap B \rvert $$
En un router con dos rutas, $A$ y $B$ son los conjuntos de mensajes que cumplen cada filtro; $|A \cap B|$ son los solapes, que se cuentan dos veces si se suman los mensajes de cada ruta; y lo que queda fuera de $A \cup B$ va al respaldo, sea un “otro” legítimo o un hueco. La Figura 3 dibuja esos conjuntos para tres de las rutas de Valeria, con las zonas problemáticas sombreadas y las cifras de la primera versión que el Bloque 5 está a punto de calcular.
Figura 3. Huecos y solapes: diagrama de Venn de los filtros de pedido, precio y queja de la primera versión, con las intersecciones sombreadas como solapes (45 mensajes) y los mensajes fuera de todos los círculos que no eran otro como huecos (25).
Probar los filtros en Python
Con el evaluador y los mensajes, probar un router es pasar cada mensaje por todos sus filtros y comparar lo que pasó con lo que debía pasar. El siguiente bloque lo hace con dos versiones de las rutas.
La primera versión es la de anoche, reconstruida a partir del historial: palabras clave con el operador “contiene”. Un pedido es un mensaje con “quiero”, “pastel”, “encargo” o “aparta”; tiene fecha si además contiene “hoy”, “mañana”, “lunes”, “martes”, “viernes” o “sábado”; horario es “hora”, “abren” o “cierran”; precio, “cuánto” o “precio”; factura, “factura”; y queja, “queja”, “mal” o “tarde”. Nada impide que un mensaje tome dos rutas.
La versión corregida cambia tres cosas. Usa patrones en lugar de palabras sueltas, para cubrir variantes (“cu[aá]nto”, “factur”, “cfdi”, “rfc”, “p[eé]sim”, “decepci”, “crud”), y patrones más específicos para no atrapar lo que no debe (“llegó tarde” en lugar de “tarde”, para que “Buenas tardes” no sea una queja). Ordena las rutas por prioridad, queja, factura, pedido, precio y horario, y escribe en cada filtro la negación de las anteriores, como en la fórmula $f’_r$. Y separa el pedido con fecha del pedido sin fecha con un mismo patrón de fecha, afirmado en una ruta y negado en la otra, que reconoce también “28/02” y “jueves”.
La función rutas_tomadas aplica la regla del router: todas las rutas normales cuyo filtro se cumple y, solo si no se cumple ninguna, la de respaldo. La función diagnostico clasifica cada mensaje en cuatro casos: correcto (una sola ruta, la esperada), solape (dos o más rutas), hueco (un mensaje de tipo conocido que solo atrapó el respaldo) y ruta equivocada (una sola ruta, pero no la esperada):
# Cobertura de las rutas: huecos, solapes y rutas equivocadas antes y después de corregir en Python:
CUERPO = '{{1.Body}}'
def contiene(t):
return cond(CUERPO, 'text:contain:ci', t)
def no_contiene(t):
return cond(CUERPO, 'text:notcontain:ci', t)
# Primera versión: palabras clave con "contiene" y nada que impida tomar dos rutas:
KW_PEDIDO, KW_FECHA = ['quiero', 'pastel', 'encargo', 'aparta'], ['hoy', 'mañana', 'lunes', 'martes', 'viernes', 'sábado']
RUTAS_V1 = [
{'ruta': 'pedido con fecha', 'filter': filtro('Pedido con fecha', *[[contiene(k), contiene(d)] for k in KW_PEDIDO for d in KW_FECHA])},
{'ruta': 'pedido sin fecha', 'filter': filtro('Pedido sin fecha', *[[contiene(k)] + [no_contiene(d) for d in KW_FECHA] for k in KW_PEDIDO])},
{'ruta': 'horario', 'filter': filtro('Horario', *[[contiene(k)] for k in ['hora', 'abren', 'cierran']])},
{'ruta': 'precio', 'filter': filtro('Precio', *[[contiene(k)] for k in ['cuánto', 'precio']])},
{'ruta': 'factura', 'filter': filtro('Factura', [contiene('factura')])},
{'ruta': 'queja', 'filter': filtro('Queja', *[[contiene(k)] for k in ['queja', 'mal', 'tarde']])},
{'ruta': 'respaldo', 'fallback': True, 'filter': filtro('Respaldo: Valeria')},
]
# Versión corregida: patrones con variantes y exclusión explícita por prioridad (queja > factura > pedido > precio > horario):
P = {'queja': r'queja|reclam|llegó tarde|llego tarde|p[eé]sim|decepci|crud[oa]s?\b|quemad',
'factura': r'factur|\bcfdi\b|\brfc\b',
'pedido': r'\bquiero (?!saber)|quisiera|\baparta|encarg|necesito \d|me manda',
'precio': r'cu[aá]nto|precio|cuesta',
'horario': r'horario|\babren\b|\bcierran\b|abiert',
'fecha': r'\bhoy\b|ma[ñn]ana|lunes|martes|mi[eé]rcoles|jueves|viernes|s[aá]bado|\d{1,2}/\d{1,2}'}
def si(clave):
return cond(CUERPO, 'text:pattern:ci', P[clave])
def no(clave):
return cond(CUERPO, 'text:notpattern:ci', P[clave])
RUTAS_V2 = [
{'ruta': 'queja', 'filter': filtro('Queja', [si('queja')])},
{'ruta': 'factura', 'filter': filtro('Factura', [si('factura'), no('queja')])},
{'ruta': 'pedido con fecha', 'filter': filtro('Pedido con fecha', [si('pedido'), si('fecha'), no('queja'), no('factura')])},
{'ruta': 'pedido sin fecha', 'filter': filtro('Pedido sin fecha', [si('pedido'), no('fecha'), no('queja'), no('factura')])},
{'ruta': 'precio', 'filter': filtro('Precio', [si('precio'), no('pedido'), no('queja'), no('factura')])},
{'ruta': 'horario', 'filter': filtro('Horario', [si('horario'), no('precio'), no('pedido'), no('queja'), no('factura')])},
{'ruta': 'respaldo', 'fallback': True, 'filter': filtro('Respaldo: Valeria')},
]
def rutas_tomadas(rutas, paquete):
# Router de Make: todas las rutas normales cuyo filtro se cumple, en orden; el respaldo solo si ninguna:
tomadas = [r['ruta'] for r in rutas if not r.get('fallback') and evaluar_filtro(r['filter'], paquete)]
return tomadas or [r['ruta'] for r in rutas if r.get('fallback') and evaluar_filtro(r['filter'], paquete)]
def esperada(fila):
if fila['tipo'] == 'pedido':
return 'pedido con fecha' if fila['tiene_fecha'] else 'pedido sin fecha'
return 'respaldo' if fila['tipo'] == 'otro' else fila['tipo']
def diagnostico(tomadas, esperada_):
if len(tomadas) > 1:
return 'solape'
if tomadas == ['respaldo'] and esperada_ != 'respaldo':
return 'hueco'
return 'correcto' if tomadas == [esperada_] else 'ruta equivocada'
for v, rutas in [('v1', RUTAS_V1), ('v2', RUTAS_V2)]:
mensajes[f'rutas_{v}'] = [rutas_tomadas(rutas, {1: {'Body': t}}) for t in mensajes['texto']]
mensajes[f'n_{v}'] = mensajes[f'rutas_{v}'].map(lambda r: 0 if r == ['respaldo'] else len(r))
mensajes[f'diag_{v}'] = [diagnostico(t, esperada(f)) for t, (_, f) in zip(mensajes[f'rutas_{v}'], mensajes.iterrows())]
resumen = pd.DataFrame({v: mensajes[f'diag_{v}'].value_counts() for v in ('v1', 'v2')}).fillna(0).astype(int)
print(resumen.reindex(['correcto', 'solape', 'hueco', 'ruta equivocada']).fillna(0).astype(int).to_string())
problemas = mensajes[mensajes['diag_v1'] != 'correcto']
detalle = (problemas.assign(rutas = problemas['rutas_v1'].map(' + '.join))
.groupby(['diag_v1', 'tipo', 'rutas']).size().rename('mensajes').reset_index()
.sort_values(['diag_v1', 'mensajes'], ascending = [True, False]))
print(detalle.to_string(index = False))
for diag in ['solape', 'hueco', 'ruta equivocada']:
ejemplo = problemas[problemas['diag_v1'] == diag]['texto'].iloc[0]
print(f'Ejemplo de {diag}: "{ejemplo}"')
print('Condiciones v1:', sum(len(g) for r in RUTAS_V1 for g in r['filter']['conditions']),
'| condiciones v2:', sum(len(g) for r in RUTAS_V2 for g in r['filter']['conditions']))
fig, ejes = plt.subplots(1, 2, figsize = (12, 4.8), sharey = True)
for ax, v, titulo in [(ejes[0], 'v1', 'Primera versión'), (ejes[1], 'v2', 'Versión corregida')]:
matriz = pd.crosstab(mensajes['tipo'], mensajes[f'n_{v}']).reindex(index = list(CUENTAS), columns = [0, 1, 2, 3], fill_value = 0)
ax.imshow(matriz.values, cmap = 'Blues', vmin = 0, vmax = 60)
for i in range(matriz.shape[0]):
for j in range(matriz.shape[1]):
valor = matriz.values[i, j]
mal = valor and ((matriz.index[i] == 'otro') != (j != 1)) # "otro" debe quedarse en el respaldo
ax.text(j, i, valor, ha = 'center', va = 'center', fontsize = 11,
color = 'white' if valor > 30 else ('#c8423b' if mal else '#222222'))
ax.set_xticks(range(4))
ax.set_xticklabels(['0 (respaldo)', '1', '2', '3'])
ax.set_yticks(range(len(CUENTAS)))
ax.set_yticklabels(list(CUENTAS))
ax.set_xlabel('Rutas normales que toma el mensaje')
ax.set_title(f"{titulo}: {(mensajes[f'diag_{v}'] != 'correcto').sum()} mensajes con problema", fontsize = 12)
fig.suptitle('Rutas activadas por cada uno de los 200 mensajes simulados', fontsize = 16)
plt.tight_layout()
plt.show()
v1 v2
correcto 118 200
solape 45 0
hueco 25 0
ruta equivocada 12 0
diag_v1 tipo rutas mensajes
hueco precio respaldo 8
hueco queja respaldo 7
hueco pedido respaldo 6
hueco factura respaldo 4
ruta equivocada otro queja 7
ruta equivocada pedido pedido sin fecha 5
solape horario horario + queja 13
solape precio pedido sin fecha + precio 10
solape pedido pedido sin fecha + precio 8
solape pedido pedido con fecha + horario + queja 6
solape pedido pedido sin fecha + horario + queja 4
solape pedido pedido con fecha + precio 2
solape queja pedido sin fecha + queja 2
Ejemplo de solape: "¿Cierran tarde los sábados?"
Ejemplo de hueco: "cuanto cuesta la hogaza"
Ejemplo de ruta equivocada: "Hola, quisiera encargar un pastel de chocolate para el 28/02"
Condiciones v1: 85 | condiciones v2: 20

La primera versión trató bien $118$ de $200$ mensajes, el $59%$. Los otros $82$ se reparten en $45$ solapes, $25$ huecos y $12$ rutas equivocadas, y la versión corregida los lleva a cero: $200$ de $200$ correctos. La Tabla 4 ordena los problemas de la primera versión por causa, con la corrección que los resolvió:
Tabla 4. Huecos, solapes y rutas equivocadas de la primera versión en los 200 mensajes, y su corrección.
| Problema | Tipo real | Rutas tomadas | Mensajes | Causa | Corrección |
|---|---|---|---|---|---|
| Solape | Horario | Horario + queja | 13 | “tarde” en “¿Cierran tarde los sábados?” | Queja exige “llegó tarde” |
| Solape | Precio | Pedido sin fecha + precio | 10 | “pastel” y “quiero saber” parecen pedido | Pedido exige intención; “quiero saber” no cuenta |
| Solape | Pedido | Pedido sin fecha + precio | 8 | “¿cuánto sería?” dentro de un pedido | Prioridad: pedido antes que precio |
| Solape | Pedido | Pedido + horario + queja | 10 | “Buenas tardes” y “¿a qué hora paso?” | Horario solo con “abren”, “cierran”, “horario” |
| Solape | Pedido | Pedido con fecha + precio | 2 | Precio dentro de un pedido con fecha | Prioridad: pedido antes que precio |
| Solape | Queja | Pedido sin fecha + queja | 2 | “quiero hablar con alguien” (el caso de anoche) | Prioridad: queja antes que todo |
| Hueco | Precio | Respaldo | 8 | “cuanto” sin tilde | Patrón cu[aá]nto y “cuesta” |
| Hueco | Queja | Respaldo | 7 | “crudas”, “decepción”, “pésimo” | Patrones de queja ampliados |
| Hueco | Pedido | Respaldo | 6 | “Necesito 3 conchas”, sin “quiero” ni “aparta” | Patrón de intención de pedido |
| Hueco | Factura | Respaldo | 4 | “CFDI” sin la palabra “factura” | Patrón factur, cfdi, rfc |
| Ruta equivocada | Otro | Queja | 7 | “mal” en “Normalmente”, “tarde” en “Buenas tardes” | Patrones de queja específicos |
| Ruta equivocada | Pedido | Pedido sin fecha | 5 | “28/02” y “jueves” no eran fechas | Patrón de fecha con \d{1,2}/\d{1,2} |
La tabla enseña más que el total. Los solapes más numerosos no vienen de mensajes raros, sino de palabras cortas y frecuentes: “tarde” está en “Buenas tardes”, que es como empieza media correspondencia de una panadería, y “mal” está dentro de “normalmente”. El operador “contiene” no sabe de palabras, solo de letras seguidas. Los huecos, en cambio, vienen de lo contrario, de filtros demasiado estrechos: quien escribe “cuanto” sin tilde, “CFDI” en lugar de “factura” o “venían crudas” en lugar de “queja” queda fuera. Y el caso de anoche, “quiero hablar con alguien”, aparece como dos solapes de queja: con los mensajes simulados, la primera versión le habría dado la respuesta equivocada a una de cada seis quejas.
En la figura, cada fila es un tipo de mensaje y cada columna el número de rutas normales que tomó; la columna $0$ es el respaldo. En la primera versión, los números en rojo son mensajes mal encaminados: pedidos que tomaron dos y hasta tres rutas a la vez ($10$ mensajes en la columna $3$), horarios que tomaron dos, y $7$ mensajes de “otro” que tomaron una ruta cuando debían quedarse en el respaldo. En la versión corregida, la matriz es la que se busca: todos los tipos conocidos en la columna $1$ y los $48$ “otros” en el respaldo. Las condiciones bajan de $85$ a $20$, y el filtro de pedidos con fecha, que en la primera versión eran $24$ grupos de dos condiciones, cabe ahora en un grupo de cuatro.
Don Ernesto mira la tabla impresa y pone el dedo sobre la fila de “Buenas tardes”: “O sea, que el aparato pensaba que todo el que saluda por la tarde viene a quejarse.” Exacto. Y añade, con más razón todavía: “Y si mañana alguien escribe de una forma que usted no puso en su lista, ¿qué?” Para eso está la ruta de respaldo, que no contesta nada y lleva el mensaje a Valeria, y para eso sirve revisar cada mes los mensajes que cayeron en ella: son la lista de huecos que los $200$ mensajes simulados no podían conocer.
El blueprint de TL Pedidos WhatsApp v2
Con las rutas corregidas y probadas, Valeria monta la versión nueva en Make. Parte de TL Pedidos WhatsApp v1 y hace cuatro cambios. Mueve la respuesta a Twilio (gateway:WebhookRespond) al módulo $4$, justo después de comprobar la firma, para que Twilio reciba su $200$ antes de que el escenario haga nada lento; el Día 23 explicó por qué la respuesta debe llegar a tiempo. Añade un filtro, “No es solo un acuse”, en la línea que llega al router, para que los “gracias” y los “ok” no recorran ninguna ruta. Añade el router (builtin:BasicRouter, módulo $5$) con las seis rutas de la Tabla 2 en orden de prioridad, más la ruta de respaldo hacia la hoja Revision. Y activa, como en el formulario del Día 22, el procesamiento en orden, las ejecuciones incompletas y los datos confidenciales.
Las rutas tienen entre uno y dos módulos. Las de queja y factura escriben una fila en su hoja (Quejas, Facturas), y la de queja, además, avisa a Valeria por correo (email:ActionSendEmail), para que la respuesta a una clienta molesta la escriba siempre una persona. Las de pedido escriben el borrador en la hoja Borradores y envían un mensaje automático por la API de Twilio con el módulo HTTP (http:ActionSendData): el acuse de recibo, si el pedido trae fecha, o la pregunta por la fecha, si no la trae. Las de precio y horario contestan con un mensaje automático. Todos los mensajes automáticos empiezan con “Mensaje automático de Trigo Lento”, para que el cliente sepa que no le escribió una persona. La credencial de Twilio vive en la conexión (__IMTCONN__), nunca en el mapper. El blueprint completo, guardado en trigo_lento/dia_24/flujos/make/pedidos_whatsapp_v2.blueprint.json, es este:
{
"name": "TL Pedidos WhatsApp v2",
"flow": [
{
"id": 1,
"module": "gateway:CustomWebHook",
"version": 1,
"parameters": {"hook": 0, "maxResults": 1},
"mapper": {},
"metadata": {"designer": {"x": 0, "y": 0}}
},
{
"id": 2,
"module": "datastore:GetRecord",
"version": 1,
"parameters": {"datastore": 0},
"mapper": {"key": "twilio"},
"metadata": {"designer": {"x": 300, "y": 0}}
},
{
"id": 3,
"module": "datastore:GetRecord",
"version": 1,
"parameters": {"datastore": 0},
"filter": {
"name": "Firma de Twilio válida",
"conditions": [[
{"a": "{{get(map(1.__IMTHEADERS__; \"value\"; \"name\"; \"x-twilio-signature\"); 1)}}", "b": "{{sha1(2.data.url_webhook + \"AccountSid\" + 1.AccountSid + \"ApiVersion\" + 1.ApiVersion + \"Body\" + 1.Body + \"From\" + 1.From + \"MessageSid\" + 1.MessageSid + \"MessageType\" + 1.MessageType + \"NumMedia\" + 1.NumMedia + \"NumSegments\" + 1.NumSegments + \"ProfileName\" + 1.ProfileName + \"ReferralNumMedia\" + 1.ReferralNumMedia + \"SmsMessageSid\" + 1.SmsMessageSid + \"SmsSid\" + 1.SmsSid + \"SmsStatus\" + 1.SmsStatus + \"To\" + 1.To + \"WaId\" + 1.WaId; \"base64\"; 2.data.auth_token)}}", "o": "text:equal"}
]]
},
"mapper": {"key": "{{sha256(1.WaId)}}"},
"metadata": {"designer": {"x": 600, "y": 0}}
},
{
"id": 4,
"module": "gateway:WebhookRespond",
"version": 1,
"parameters": {},
"mapper": {"status": "200", "body": "<Response></Response>", "headers": [{"key": "Content-Type", "value": "text/xml"}]},
"metadata": {"designer": {"x": 900, "y": 0}}
},
{
"id": 5,
"module": "builtin:BasicRouter",
"version": 1,
"filter": {
"name": "No es solo un acuse",
"conditions": [[
{"a": "{{1.Body}}", "b": "^\\W*(ok|va|gracias|muchas gracias|va, gracias|listo)\\W*$", "o": "text:notpattern:ci"}
]]
},
"mapper": null,
"metadata": {"designer": {"x": 1200, "y": 0}},
"routes": [
{"flow": [
{
"id": 6,
"module": "google-sheets:addRow",
"version": 2,
"parameters": {"__IMTCONN__": 0},
"filter": {
"name": "Queja",
"conditions": [[
{"a": "{{1.Body}}", "b": "queja|reclam|llegó tarde|llego tarde|p[eé]sim|decepci|crud[oa]s?\\b|quemad", "o": "text:pattern:ci"}
]]
},
"mapper": {"mode": "select", "spreadsheetId": "/ID_DE_EJEMPLO", "sheetId": "Quejas", "includesHeaders": true, "insertDataOption": "INSERT_ROWS", "valueInputOption": "USER_ENTERED", "insertUnformatted": false, "values": {"0": "{{1.MessageSid}}", "1": "{{ifempty(3.data.nombre; 1.ProfileName)}}", "2": "{{replace(1.From; \"whatsapp:\"; emptystring)}}", "3": "{{1.Body}}", "4": "queja", "5": "{{now}}"}},
"metadata": {"designer": {"x": 1800, "y": -450}}
},
{
"id": 7,
"module": "email:ActionSendEmail",
"version": 1,
"parameters": {"account": 0},
"mapper": {"to": ["valeria@ejemplo.com"], "subject": "Queja de un cliente: {{1.MessageSid}}", "html": "Revise la hoja Quejas y responda usted en persona."},
"metadata": {"designer": {"x": 2100, "y": -450}}
}
]},
{"flow": [
{
"id": 8,
"module": "google-sheets:addRow",
"version": 2,
"parameters": {"__IMTCONN__": 0},
"filter": {
"name": "Factura",
"conditions": [[
{"a": "{{1.Body}}", "b": "factur|\\bcfdi\\b|\\brfc\\b", "o": "text:pattern:ci"},
{"a": "{{1.Body}}", "b": "queja|reclam|llegó tarde|llego tarde|p[eé]sim|decepci|crud[oa]s?\\b|quemad", "o": "text:notpattern:ci"}
]]
},
"mapper": {"mode": "select", "spreadsheetId": "/ID_DE_EJEMPLO", "sheetId": "Facturas", "includesHeaders": true, "insertDataOption": "INSERT_ROWS", "valueInputOption": "USER_ENTERED", "insertUnformatted": false, "values": {"0": "{{1.MessageSid}}", "1": "{{ifempty(3.data.nombre; 1.ProfileName)}}", "2": "{{replace(1.From; \"whatsapp:\"; emptystring)}}", "3": "{{1.Body}}", "4": "factura por emitir", "5": "{{now}}"}},
"metadata": {"designer": {"x": 1800, "y": -300}}
}
]},
{"flow": [
{
"id": 9,
"module": "google-sheets:addRow",
"version": 2,
"parameters": {"__IMTCONN__": 0},
"filter": {
"name": "Pedido con fecha",
"conditions": [[
{"a": "{{1.Body}}", "b": "\\bquiero (?!saber)|quisiera|\\baparta|encarg|necesito \\d|me manda", "o": "text:pattern:ci"},
{"a": "{{1.Body}}", "b": "\\bhoy\\b|ma[ñn]ana|lunes|martes|mi[eé]rcoles|jueves|viernes|s[aá]bado|\\d{1,2}/\\d{1,2}", "o": "text:pattern:ci"},
{"a": "{{1.Body}}", "b": "queja|reclam|llegó tarde|llego tarde|p[eé]sim|decepci|crud[oa]s?\\b|quemad", "o": "text:notpattern:ci"},
{"a": "{{1.Body}}", "b": "factur|\\bcfdi\\b|\\brfc\\b", "o": "text:notpattern:ci"}
]]
},
"mapper": {"mode": "select", "spreadsheetId": "/ID_DE_EJEMPLO", "sheetId": "Borradores", "includesHeaders": true, "insertDataOption": "INSERT_ROWS", "valueInputOption": "USER_ENTERED", "insertUnformatted": false, "values": {"0": "{{1.MessageSid}}", "1": "{{ifempty(3.data.nombre; 1.ProfileName)}}", "2": "{{replace(1.From; \"whatsapp:\"; emptystring)}}", "3": "{{1.Body}}", "4": "por confirmar", "5": "{{now}}"}},
"metadata": {"designer": {"x": 1800, "y": -150}}
},
{
"id": 10,
"module": "http:ActionSendData",
"version": 3,
"parameters": {"handleErrors": true, "__IMTCONN__": 0},
"mapper": {"url": "https://api.twilio.com/2010-04-01/Accounts/{{2.data.account_sid}}/Messages.json", "method": "post", "bodyType": "x_www_form_urlencoded", "data": [{"key": "From", "value": "{{1.To}}"}, {"key": "To", "value": "{{1.From}}"}, {"key": "Body", "value": "Mensaje automático de Trigo Lento: recibimos su pedido. Valeria se lo confirma con el total en breve."}]},
"metadata": {"designer": {"x": 2100, "y": -150}}
}
]},
{"flow": [
{
"id": 11,
"module": "http:ActionSendData",
"version": 3,
"parameters": {"handleErrors": true, "__IMTCONN__": 0},
"filter": {
"name": "Pedido sin fecha",
"conditions": [[
{"a": "{{1.Body}}", "b": "\\bquiero (?!saber)|quisiera|\\baparta|encarg|necesito \\d|me manda", "o": "text:pattern:ci"},
{"a": "{{1.Body}}", "b": "\\bhoy\\b|ma[ñn]ana|lunes|martes|mi[eé]rcoles|jueves|viernes|s[aá]bado|\\d{1,2}/\\d{1,2}", "o": "text:notpattern:ci"},
{"a": "{{1.Body}}", "b": "queja|reclam|llegó tarde|llego tarde|p[eé]sim|decepci|crud[oa]s?\\b|quemad", "o": "text:notpattern:ci"},
{"a": "{{1.Body}}", "b": "factur|\\bcfdi\\b|\\brfc\\b", "o": "text:notpattern:ci"}
]]
},
"mapper": {"url": "https://api.twilio.com/2010-04-01/Accounts/{{2.data.account_sid}}/Messages.json", "method": "post", "bodyType": "x_www_form_urlencoded", "data": [{"key": "From", "value": "{{1.To}}"}, {"key": "To", "value": "{{1.From}}"}, {"key": "Body", "value": "Mensaje automático de Trigo Lento: ¿para qué día y a qué hora lo necesita? Con eso Valeria arma su pedido."}]},
"metadata": {"designer": {"x": 1800, "y": 0}}
},
{
"id": 12,
"module": "google-sheets:addRow",
"version": 2,
"parameters": {"__IMTCONN__": 0},
"mapper": {"mode": "select", "spreadsheetId": "/ID_DE_EJEMPLO", "sheetId": "Borradores", "includesHeaders": true, "insertDataOption": "INSERT_ROWS", "valueInputOption": "USER_ENTERED", "insertUnformatted": false, "values": {"0": "{{1.MessageSid}}", "1": "{{ifempty(3.data.nombre; 1.ProfileName)}}", "2": "{{replace(1.From; \"whatsapp:\"; emptystring)}}", "3": "{{1.Body}}", "4": "falta fecha", "5": "{{now}}"}},
"metadata": {"designer": {"x": 2100, "y": 0}}
}
]},
{"flow": [
{
"id": 13,
"module": "http:ActionSendData",
"version": 3,
"parameters": {"handleErrors": true, "__IMTCONN__": 0},
"filter": {
"name": "Precio",
"conditions": [[
{"a": "{{1.Body}}", "b": "cu[aá]nto|precio|cuesta", "o": "text:pattern:ci"},
{"a": "{{1.Body}}", "b": "\\bquiero (?!saber)|quisiera|\\baparta|encarg|necesito \\d|me manda", "o": "text:notpattern:ci"},
{"a": "{{1.Body}}", "b": "queja|reclam|llegó tarde|llego tarde|p[eé]sim|decepci|crud[oa]s?\\b|quemad", "o": "text:notpattern:ci"},
{"a": "{{1.Body}}", "b": "factur|\\bcfdi\\b|\\brfc\\b", "o": "text:notpattern:ci"}
]]
},
"mapper": {"url": "https://api.twilio.com/2010-04-01/Accounts/{{2.data.account_sid}}/Messages.json", "method": "post", "bodyType": "x_www_form_urlencoded", "data": [{"key": "From", "value": "{{1.To}}"}, {"key": "To", "value": "{{1.From}}"}, {"key": "Body", "value": "Mensaje automático de Trigo Lento: nuestros precios están en la lista adjunta: trigolento.ejemplo/precios"}]},
"metadata": {"designer": {"x": 1800, "y": 150}}
}
]},
{"flow": [
{
"id": 14,
"module": "http:ActionSendData",
"version": 3,
"parameters": {"handleErrors": true, "__IMTCONN__": 0},
"filter": {
"name": "Horario",
"conditions": [[
{"a": "{{1.Body}}", "b": "horario|\\babren\\b|\\bcierran\\b|abiert", "o": "text:pattern:ci"},
{"a": "{{1.Body}}", "b": "cu[aá]nto|precio|cuesta", "o": "text:notpattern:ci"},
{"a": "{{1.Body}}", "b": "\\bquiero (?!saber)|quisiera|\\baparta|encarg|necesito \\d|me manda", "o": "text:notpattern:ci"},
{"a": "{{1.Body}}", "b": "queja|reclam|llegó tarde|llego tarde|p[eé]sim|decepci|crud[oa]s?\\b|quemad", "o": "text:notpattern:ci"},
{"a": "{{1.Body}}", "b": "factur|\\bcfdi\\b|\\brfc\\b", "o": "text:notpattern:ci"}
]]
},
"mapper": {"url": "https://api.twilio.com/2010-04-01/Accounts/{{2.data.account_sid}}/Messages.json", "method": "post", "bodyType": "x_www_form_urlencoded", "data": [{"key": "From", "value": "{{1.To}}"}, {"key": "To", "value": "{{1.From}}"}, {"key": "Body", "value": "Mensaje automático de Trigo Lento: abrimos de lunes a sábado de 7:00 a 20:00."}]},
"metadata": {"designer": {"x": 1800, "y": 300}}
}
]},
{"flow": [
{
"id": 15,
"module": "google-sheets:addRow",
"version": 2,
"parameters": {"__IMTCONN__": 0},
"filter": {
"name": "Respaldo: Valeria",
"conditions": [],
"fallback": true
},
"mapper": {"mode": "select", "spreadsheetId": "/ID_DE_EJEMPLO", "sheetId": "Revision", "includesHeaders": true, "insertDataOption": "INSERT_ROWS", "valueInputOption": "USER_ENTERED", "insertUnformatted": false, "values": {"0": "{{1.MessageSid}}", "1": "{{ifempty(3.data.nombre; 1.ProfileName)}}", "2": "{{replace(1.From; \"whatsapp:\"; emptystring)}}", "3": "{{1.Body}}", "4": "sin clasificar", "5": "{{now}}"}},
"metadata": {"designer": {"x": 1800, "y": 450}}
}
]}
]
}
],
"metadata": {
"instant": true,
"version": 1,
"scenario": {"roundtrips": 1, "maxErrors": 3, "autoCommit": true, "autoCommitTriggerLast": true, "sequential": true, "confidential": true, "dataloss": false, "dlq": true, "freshVariables": false},
"designer": {"orphans": []}
}
}
Tres partes del archivo merecen una lectura atenta:
- Cada filtro de ruta vive en el primer módulo de la ruta. El filtro “Queja” está en el módulo $6$, el de la hoja
Quejas; el filtro “Pedido sin fecha” está en el módulo $11$, el mensaje que pregunta la fecha. El módulo $7$, el aviso a Valeria, no tiene filtro: si el paquete entró en la ruta, la recorre entera. - Las condiciones de cada filtro están en un solo grupo. Ninguna ruta corregida necesita OR entre grupos, porque los patrones ya llevan el OR dentro (
queja|reclam|...). Todas las negaciones de la regla de prioridad aparecen como condicionestext:notpattern:ci, en el mismo orden en todas las rutas, para que se puedan revisar de un vistazo. - La ruta de respaldo no tiene condiciones, solo la marca
"fallback": trueen su filtro. Esa clave es la forma en que esta serie representa el respaldo; la documentación pública de Make explica cómo marcarlo en el editor, pero no cómo aparece en la exportación, y por eso el notice de vigencia pide contrastarla con un blueprint propio.
El siguiente bloque lee el archivo, comprueba que los filtros del blueprint son idénticos a los que el Bloque 5 probó (si alguien editara el JSON a mano o en el editor, la prueba dejaría de valer, y el bloque lo diría) y dibuja el escenario con la paleta de la serie, a partir de las posiciones del lienzo, con el nombre de cada filtro en la flecha que llega a su módulo:
# Lectura del blueprint TL Pedidos WhatsApp v2, comprobación de sus filtros y dibujo en Python:
from matplotlib.patches import Ellipse, FancyBboxPatch, Polygon, RegularPolygon, FancyArrowPatch
blueprint = json.loads((flujos / 'pedidos_whatsapp_v2.blueprint.json').read_text(encoding = 'utf-8'))
TIPOS = {'gateway:CustomWebHook': 'disparador', 'builtin:BasicRouter': 'decision',
'email:ActionSendEmail': 'humano'}
FORMAS = { # paleta de formas de la serie: tipo -> (forma, relleno)
'disparador': ('ovalo', '#3a9d5d'), 'accion': ('rectangulo', '#2a78d6'),
'decision': ('rombo', '#eb6834'), 'ia': ('rectangulo', '#7b5ea7'),
'error': ('rectangulo', '#c8423b'), 'humano': ('hexagono', '#8a8986'),
}
CORTO = {'gateway:CustomWebHook': 'Webhook', 'datastore:GetRecord': 'Leer\nregistro',
'gateway:WebhookRespond': 'Responder\n200', 'builtin:BasicRouter': 'Router',
'google-sheets:addRow': 'Fila en\n', 'http:ActionSendData': 'Mensaje\nautomático',
'email:ActionSendEmail': 'Aviso a\nValeria'}
def dibujar_flujo(nodos, aristas, titulo, ax = None, ancho = 1.8, alto = 0.95, recorte = 30):
if ax is None:
fig, ax = plt.subplots(figsize = (12, 5.6))
for n in nodos:
forma, color = FORMAS[n['tipo']]
x, y = n['x'], n['y']
if forma == 'ovalo':
figura = Ellipse((x, y), ancho, alto)
elif forma == 'rombo':
figura = Polygon([(x - ancho / 2, y), (x, y + alto / 1.3), (x + ancho / 2, y), (x, y - alto / 1.3)])
elif forma == 'hexagono':
figura = RegularPolygon((x, y), 6, radius = ancho / 2, orientation = np.pi / 6)
else:
figura = FancyBboxPatch((x - ancho / 2, y - alto / 2), ancho, alto, boxstyle = 'round,pad=0.03')
figura.set_facecolor(color)
figura.set_edgecolor('white')
figura.set_linewidth(2)
ax.add_patch(figura)
ax.text(x, y, n['texto'], ha = 'center', va = 'center', fontsize = 8, color = 'white')
posicion = {n['id']: (n['x'], n['y']) for n in nodos}
for origen, destino, etiqueta in aristas:
(x1, y1), (x2, y2) = posicion[origen], posicion[destino]
ax.add_patch(FancyArrowPatch((x1, y1), (x2, y2), arrowstyle = '-|>', mutation_scale = 12,
shrinkA = recorte, shrinkB = recorte + 2, color = '#52514e', linewidth = 1.2))
if etiqueta:
ax.text(x1 + 0.5 * (x2 - x1), y1 + 0.5 * (y2 - y1) + (0.62 if y1 == y2 and abs(x2 - x1) < 3 else 0.18), etiqueta, ha = 'center', fontsize = 7.5,
color = '#52514e', bbox = dict(facecolor = 'white', edgecolor = 'none', pad = 0.5))
xs, ys = [n['x'] for n in nodos], [n['y'] for n in nodos]
ax.set_xlim(min(xs) - 1.2, max(xs) + 1.2)
ax.set_ylim(min(ys) - 0.8, max(ys) + 0.8)
ax.set_aspect('equal')
ax.axis('off')
ax.set_title(titulo, fontsize = 16)
return ax
def nodos_desde_blueprint(bp, escala = 300 / 2.6):
nodos, aristas = [], []
def recorrer(flujo, previo, profundidad):
for m in flujo:
d = m['metadata']['designer']
texto = CORTO.get(m['module'], m['module'])
if m['module'] == 'google-sheets:addRow':
texto += m['mapper']['sheetId']
nodos.append({'id': m['id'], 'tipo': TIPOS.get(m['module'], 'accion'), 'texto': f"{m['id']}. {texto}",
'x': d['x'] / escala, 'y': -d['y'] / escala, 'profundidad': profundidad})
if previo is not None:
aristas.append((previo, m['id'], m.get('filter', {}).get('name', '')))
for ruta in m.get('routes', []):
recorrer(ruta['flow'], m['id'], profundidad + 1)
previo = m['id']
recorrer(bp['flow'], None, 0)
return nodos, aristas
router = next(m for m in blueprint['flow'] if m['module'] == 'builtin:BasicRouter')
filtros_bp = [r['flow'][0]['filter'] for r in router['routes']]
iguales = all(fb['conditions'] == r['filter']['conditions'] for fb, r in zip(filtros_bp, RUTAS_V2))
print('Rutas del router:', len(router['routes']), '| respaldo:',
[f['name'] for f in filtros_bp if f.get('fallback')], '| filtros idénticos a los probados:', iguales)
for k, (f, ruta) in enumerate(zip(filtros_bp, router['routes']), start = 1):
print(f" {k}. {f['name']:<20} condiciones: {sum(len(g) for g in f['conditions']):>2} | módulos: "
f"{[m['id'] for m in ruta['flow']]}")
print('Filtro antes del router:', router['filter']['name'])
nodos, aristas = nodos_desde_blueprint(blueprint)
print('Nodos:', len(nodos), '| aristas:', len(aristas), '| profundidad máxima:', max(n['profundidad'] for n in nodos))
dibujar_flujo(nodos, aristas, blueprint['name'] + ': router de seis rutas más respaldo')
plt.gcf().set_size_inches(12, 7.2)
plt.tight_layout()
plt.show()
Rutas del router: 7 | respaldo: ['Respaldo: Valeria'] | filtros idénticos a los probados: True
1. Queja condiciones: 1 | módulos: [6, 7]
2. Factura condiciones: 2 | módulos: [8]
3. Pedido con fecha condiciones: 4 | módulos: [9, 10]
4. Pedido sin fecha condiciones: 4 | módulos: [11, 12]
5. Precio condiciones: 4 | módulos: [13]
6. Horario condiciones: 5 | módulos: [14]
7. Respaldo: Valeria condiciones: 0 | módulos: [15]
Filtro antes del router: No es solo un acuse
Nodos: 15 | aristas: 14 | profundidad máxima: 1

El router tiene $7$ rutas, una de ellas de respaldo, y los filtros del blueprint son idénticos a los probados (True). La lista muestra la escalera de la prioridad en el número de condiciones: $1$ en la ruta de quejas, $2$ en la de facturas, $4$ en las de pedidos y precio y $5$ en la de horario. El dibujo tiene $15$ nodos, uno por módulo, y profundidad $1$: un solo nivel de rutas, sin routers anidados.
En el dibujo, la línea principal va del óvalo verde del webhook al rombo naranja del router, pasando por los dos registros y la respuesta; las dos etiquetas de esa línea son los dos filtros que actúan antes de decidir, la firma de Twilio y el filtro de acuses. Del rombo salen siete flechas con el nombre de su filtro. El único hexágono gris, el paso humano de la paleta de la serie, es el aviso a Valeria de la ruta de quejas: la ética del día dibujada en una forma.
Filtrar temprano
El orden de los módulos no solo decide qué pasa con cada mensaje; también decide cuánto cuesta. Un filtro detiene un paquete, y todo lo que habría venido después no se ejecuta ni se cobra. Si el filtro está al principio, el paquete descartado ha costado poco; si está al final, ha costado casi lo mismo que uno útil. Para Trigo Lento hay dos clases de paquetes que conviene descartar:
- Peticiones sin firma válida. La URL de un webhook acaba siendo conocida por robots que prueban direcciones al azar. Con el filtro de firma en el módulo $3$, una petición falsa ejecuta el webhook y la lectura de la configuración de Twilio, $2$ créditos, y se detiene. La serie supone, como cifra de juguete, $120$ peticiones así al mes.
- Acuses. Los “gracias” y los “ok”, el $10%$ de los mensajes. Con el filtro antes del router, ejecutan los cuatro primeros módulos (la respuesta a Twilio incluida, que es obligatoria) y se detienen: $4$ créditos.
El bloque compara la versión $2$ con un diseño tardío que tiene los mismos filtros, pero al final: la firma se exige dentro del filtro de cada ruta y los acuses no se detienen antes del router, así que caen en la ruta de respaldo. Para no calcular a mano, el bloque recorre el propio blueprint con una función, ejecutar, que aplica la regla del router y el evaluador de filtros a cada mensaje y cuenta los módulos ejecutados, sin contar el router. El filtro de firma, cuya fórmula usa funciones de Make que el evaluador no interpreta (sha1 y map, probadas el Día 23), se simula con un indicador. El consumo del mes sigue la fórmula de créditos del Día 22, separada por clase de paquete:
$$ C_{mes} = N_M \Big(k_0 + \sum_{r} \pi_r , k_r\Big) + N_A , k_A + N_F , k_F $$
La lógica detrás de esta fórmula es múltiple:
- $N_M = 1,404$ son los mensajes con acción al mes (los $1,560$ menos los $156$ acuses); $k_0 = 4$ son los módulos comunes (webhook, dos lecturas y respuesta) y $\pi_r$ la proporción de esos mensajes que toma la ruta $r$, que cuesta $k_r$ créditos ($1$ o $2$).
- $N_A = 156$ acuses y $N_F = 120$ peticiones falsas cuestan $k_A$ y $k_F$ créditos cada uno, según dónde esté el filtro que los detiene.
- El ahorro de filtrar temprano solo depende de los paquetes descartados: $\Delta C = N_A (k_A^{tarde} - k_A^{temprano}) + N_F (k_F^{tarde} - k_F^{temprano})$.
- En conclusión, mover un filtro hacia el principio ahorra, por cada paquete descartado, tantos créditos como módulos deja de ejecutar; los mensajes útiles cuestan lo mismo en los dos diseños.
# Créditos al mes con los filtros al principio (v2) frente a los mismos filtros al final en Python:
def pasa(f, paquete):
if not f:
return True
if f['name'].startswith('Firma'): # la firma (sha1 del Día 23) se simula con un indicador
return paquete[1]['firma_ok'] == 'true'
return evaluar_filtro(f, paquete)
def es_respaldo(r):
return r['flow'][0].get('filter', {}).get('fallback', False)
def ejecutar(bp, paquete):
# Recorre el blueprint como Make: filtros, rutas normales en orden y respaldo solo si ninguna pasa:
ejecutados = []
def correr(flujo):
for m in flujo:
f = m.get('filter')
if f and not f.get('fallback') and not pasa(f, paquete):
return
ejecutados.append(m)
if 'routes' in m:
tomadas = [r for r in m['routes'] if not es_respaldo(r) and pasa(r['flow'][0].get('filter'), paquete)]
for r in tomadas or [r for r in m['routes'] if es_respaldo(r) and pasa(r['flow'][0]['filter'], paquete)]:
correr(r['flow'])
correr(bp['flow'])
return sum(m['module'] != 'builtin:BasicRouter' for m in ejecutados), [m['id'] for m in ejecutados]
# Diseño tardío: la firma se exige dentro de cada ruta y no hay filtro de acuses antes del router:
tardio = copy.deepcopy(blueprint)
tardio['name'] = 'TL Pedidos WhatsApp v2 (filtros al final)'
del tardio['flow'][2]['filter'], tardio['flow'][4]['filter']
for r in tardio['flow'][4]['routes']:
f = r['flow'][0]['filter']
f['conditions'] = [g + [cond('{{1.firma_ok}}', 'text:equal', 'true')] for g in (f['conditions'] or [[]])]
FALSAS_MES = 120 # supuesto de juguete: peticiones sin firma válida (robots que prueban la URL)
falsas = [{1: {'Body': cuerpo, 'firma_ok': 'false'}} for cuerpo in ['', 'test', 'hola', 'Gane dinero hoy']]
filas = []
for nombre, bp in [('temprano (v2)', blueprint), ('tardío', tardio)]:
c_msg = [ejecutar(bp, {1: {'Body': t, 'firma_ok': 'true'}})[0] for t in mensajes['texto']]
c_falsa = float(np.mean([ejecutar(bp, p)[0] for p in falsas]))
con_accion = float(np.mean([c for c, a in zip(c_msg, mensajes['acuse']) if not a]))
acuse = float(np.mean([c for c, a in zip(c_msg, mensajes['acuse']) if a]))
n_acuses = MENSAJES_MES * mensajes['acuse'].mean()
filas.append({'diseño': nombre, 'cred_mensaje': round(con_accion, 2), 'cred_acuse': acuse, 'cred_sin_firma': c_falsa,
'con acción': round((MENSAJES_MES - n_acuses) * con_accion), 'acuses': round(n_acuses * acuse),
'sin firma': round(FALSAS_MES * c_falsa)})
creditos_mes = pd.DataFrame(filas).set_index('diseño')
creditos_mes['total'] = creditos_mes[['con acción', 'acuses', 'sin firma']].sum(axis = 1)
OTROS = 962 + 280 # recordatorio de recogida (Día 13) y formulario (Día 22)
creditos_mes['uso de Core %'] = (100 * (creditos_mes['total'] + OTROS) / CUOTA_CORE).round(2)
print(creditos_mes.to_string())
ahorro = creditos_mes.loc['tardío', 'total'] - creditos_mes.loc['temprano (v2)', 'total']
print(f'Ahorro del filtro temprano: {ahorro} créditos al mes | margen para este escenario: {CUOTA_CORE - OTROS}')
print('Módulos que recorre una petición sin firma:', ejecutar(blueprint, falsas[1])[1], 'frente a',
ejecutar(tardio, falsas[1])[1])
fig, (ax1, ax2) = plt.subplots(1, 2, figsize = (12, 4.8))
colores = {'con acción': '#2a78d6', 'acuses': '#eb6834', 'sin firma': '#c8423b'}
base = np.zeros(2)
for col, color in colores.items():
ax1.bar(creditos_mes.index, creditos_mes[col], bottom = base, color = color, label = col, edgecolor = 'white')
base += creditos_mes[col].values
for i, total in enumerate(creditos_mes['total']):
ax1.text(i, total / 2, f'{total:,} créditos'.replace(',', ' '), ha = 'center', fontsize = 11, color = 'white')
ax1.axhline(CUOTA_CORE - OTROS, color = '#52514e', ls = '--', lw = 1.2)
ax1.text(-0.4, CUOTA_CORE - OTROS + 150, 'margen de Make Core', ha = 'left', fontsize = 9, color = '#52514e')
ax1.set_ylim(0, 10_000)
ax1.set_ylabel('Créditos al mes')
ax1.set_title('Dónde se gastan los créditos', fontsize = 12)
ax1.legend(fontsize = 9, loc = 'lower center')
cuota_ruido = np.linspace(0, 0.4, 41)
for nombre, color in [('temprano (v2)', '#3a9d5d'), ('tardío', '#eb6834')]:
fila = creditos_mes.loc[nombre]
ruido = MENSAJES_MES * cuota_ruido
ax2.plot(100 * cuota_ruido, (MENSAJES_MES - ruido) * fila['cred_mensaje'] + ruido * fila['cred_acuse']
+ FALSAS_MES * fila['cred_sin_firma'], color = color, lw = 2, label = f'Filtro {nombre}')
ax2.axhline(CUOTA_CORE - OTROS, color = '#52514e', ls = '--', lw = 1.2)
ax2.axvline(100 * mensajes['acuse'].mean(), color = '#8a8986', ls = ':', lw = 1.2)
ax2.set_xlabel('Mensajes que son solo un acuse (%)')
ax2.set_ylabel('Créditos al mes')
ax2.set_title('Sensibilidad a la proporción de acuses', fontsize = 12)
ax2.legend(fontsize = 9)
fig.suptitle('Filtrar temprano frente a filtrar tarde en TL Pedidos WhatsApp v2', fontsize = 16)
plt.tight_layout()
plt.show()
cred_mensaje cred_acuse cred_sin_firma con acción acuses sin firma total uso de Core %
diseño
temprano (v2) 5.4 4.0 2.0 7582 624 240 8446 96.88
tardío 5.4 5.0 4.0 7582 780 480 8842 100.84
Ahorro del filtro temprano: 396 créditos al mes | margen para este escenario: 8758
Módulos que recorre una petición sin firma: [1, 2] frente a [1, 2, 3, 4, 5]

Tabla 5. Créditos al mes de TL Pedidos WhatsApp v2 con los filtros al principio y al final (1 560 mensajes y 120 peticiones falsas; supuestos de juguete declarados).
| Diseño | Créditos por mensaje con acción | Por acuse | Por petición falsa | Mensajes con acción | Acuses | Sin firma | Total | Uso de Make Core |
|---|---|---|---|---|---|---|---|---|
| Temprano (v2) | 5.4 | 4 | 2 | 7 582 | 624 | 240 | 8 446 | 96.88 % |
| Tardío | 5.4 | 5 | 4 | 7 582 | 780 | 480 | 8 842 | 100.84 % |
Un mensaje con acción cuesta $5.4$ créditos de media en los dos diseños: $4$ de la parte común y $1.4$ de su ruta, porque las rutas de quejas y de pedidos tienen dos módulos y las demás uno. Lo que cambia es el ruido. Con los filtros al principio, un acuse cuesta $4$ y una petición falsa $2$; con los filtros al final, $5$ y $4$, porque recorren el router y, en el caso de los acuses, la ruta de respaldo. La fórmula da el ahorro sin simular: $156 \cdot (5 - 4) + 120 \cdot (4 - 2) = 156 + 240 = 396$, $396$ créditos al mes, que es lo que imprime el bloque. La última línea de la salida muestra el recorrido de una petición falsa: los módulos $1$ y $2$ en la versión $2$, y los módulos $1$ a $5$ en el diseño tardío, que además le contestó $200$ a un robot.
$396$ créditos parecen pocos, el $4%$ de la cuota, pero el contexto los vuelve decisivos. El recordatorio de recogida del Día 13 y el formulario del Día 22 ya consumen $1,242$ créditos al mes, así que a este escenario le quedan $8,758$, la línea discontinua de la figura. Con el filtro temprano, TL Pedidos WhatsApp v2 gasta $8,446$ y la cuenta llega al $96.88%$ de Make Core; con el filtro tardío, gasta $8,842$ y la cuenta se pasa: $100.84%$. La diferencia entre caber en el plan de $10.59$ USD y tener que comprar créditos adicionales es, literalmente, dónde está un filtro.
El panel derecho muestra la sensibilidad a la proporción de acuses, que es el supuesto más incierto. Con el filtro temprano, cada punto de acuses abarata el mes, porque un acuse cuesta $4$ créditos y un mensaje con acción $5.4$. Con el filtro tardío, el abaratamiento es menor y la línea solo cruza por debajo del margen cuando los acuses superan el $23%$ de los mensajes, una proporción que Trigo Lento no tiene. La línea vertical punteada marca el $10%$ de la muestra.
La cifra también corrige una estimación anterior. El Día 12 calculó $7,644$ créditos al mes para el flujo de mensajes de WhatsApp, y con ella el Día 22 dejó la cuenta al $88.86%$. El escenario real, con su router y sus respuestas, gasta $8,446$: unos $800$ créditos más que la estimación, que no contaba ni la respuesta a Twilio ni los dos módulos de las rutas de pedidos. El margen que queda, $312$ créditos, no alcanza para una sincronización como la que el Día 16 programaba cada $10$ minutos, y Valeria lo anota: o se reduce algún módulo en el Día 25, o el presupuesto de Make tendrá que revisarse antes de cerrar el módulo.
TL Pedidos WhatsApp v2 en servicio
Un router no solo reparte mensajes: reparte trabajo. Antes de activarlo, Valeria quiere saber cuántos mensajes atenderá el escenario solo, cuántos le llegarán a ella con el trabajo a medio hacer y cuántos le llegarán enteros, y si las reglas éticas del día se cumplen en el blueprint y no solo en la intención.
¿Quién atiende cada mensaje?
El siguiente bloque toma la ruta que la versión corregida asignó a cada mensaje con acción, escala la muestra a $1,560$ mensajes al mes y asigna a cada ruta un destino final y unos minutos de Valeria, con supuestos declarados: $1.5$ minutos por pedido (el objetivo de atención humana por encargo que la serie fijó en el Día 1), $1.5$ por mensaje del respaldo, $3$ por factura y $5$ por queja, porque una queja se contesta llamando o escribiendo con calma. Las preguntas de precio y de horario no le cuestan nada, porque las contesta el escenario:
# Flujo de mensajes al mes por ruta y destino final, con los minutos de Valeria en Python:
DESTINO = {'queja': 'Solo una persona', 'factura': 'Solo una persona', 'respaldo': 'Solo una persona',
'pedido con fecha': 'Automático y Valeria', 'pedido sin fecha': 'Automático y Valeria',
'precio': 'Solo automático', 'horario': 'Solo automático'}
MINUTOS = {'queja': 5.0, 'factura': 3.0, 'respaldo': 1.5, 'pedido con fecha': 1.5, 'pedido sin fecha': 1.5,
'precio': 0.0, 'horario': 0.0}
con_accion = mensajes[~mensajes['acuse']]
escala = MENSAJES_MES / len(mensajes)
flujo = (con_accion['rutas_v2'].str[0].value_counts().reindex(list(DESTINO)).fillna(0) * escala).round().astype(int)
tabla_flujo = pd.DataFrame({'mensajes_mes': flujo, 'destino': [DESTINO[r] for r in flujo.index],
'min_Valeria': [round(flujo[r] * MINUTOS[r]) for r in flujo.index]})
print(tabla_flujo.to_string())
print(tabla_flujo.groupby('destino')['mensajes_mes'].sum().to_string())
horas_v2 = tabla_flujo['min_Valeria'].sum() / 60
horas_todo = horas_v2 + flujo[['precio', 'horario']].sum() * 1.5 / 60 # sin respuestas automáticas: 1.5 min más por mensaje
print(f"Acuses descartados antes del router: {round(mensajes['acuse'].sum() * escala)} al mes")
print(f'Horas de Valeria al mes: {horas_v2:.1f} con v2 frente a {horas_todo:.1f} si contestara a mano '
f'precios y horarios ({horas_v2 * 75:.0f} MXN frente a {horas_todo * 75:.0f} MXN a 75 MXN la hora)')
fig, ax = plt.subplots(figsize = (11, 4.8))
colores_destino = {'Solo automático': '#3a9d5d', 'Automático y Valeria': '#2a78d6', 'Solo una persona': '#8a8986'}
orden = list(flujo.sort_values().index)
for destino, color in colores_destino.items():
valores = [flujo[r] if DESTINO[r] == destino else 0 for r in orden]
ax.barh(orden, valores, color = color, label = destino, edgecolor = 'white')
for i, r in enumerate(orden):
ax.text(flujo[r] + 6, i, f"{flujo[r]} mensajes | {tabla_flujo.loc[r, 'min_Valeria']} min de Valeria",
va = 'center', fontsize = 9)
ax.set_xlim(0, flujo.max() * 1.75)
ax.set_xlabel('Mensajes al mes (escalados a 1 560)')
ax.set_title('TL Pedidos WhatsApp v2: mensajes por ruta y destino final', fontsize = 16)
ax.legend(fontsize = 9, loc = 'lower right')
plt.tight_layout()
plt.show()
mensajes_mes destino min_Valeria
rutas_v2
queja 94 Solo una persona 470
factura 78 Solo una persona 234
respaldo 218 Solo una persona 327
pedido con fecha 265 Automático y Valeria 398
pedido sin fecha 203 Automático y Valeria 304
precio 234 Solo automático 0
horario 312 Solo automático 0
destino
Automático y Valeria 468
Solo automático 546
Solo una persona 390
Acuses descartados antes del router: 156 al mes
Horas de Valeria al mes: 28.9 con v2 frente a 42.5 si contestara a mano precios y horarios (2166 MXN frente a 3190 MXN a 75 MXN la hora)

El mes se reparte en tres destinos. $546$ mensajes se resuelven solos (las $312$ preguntas de horario y las $234$ de precio, el $35%$), $468$ llegan a Valeria con el trabajo a medio hacer (los pedidos, ya acusados o con la fecha preguntada, y escritos en la hoja de borradores) y $390$ llegan a una persona sin respuesta automática: $94$ quejas, $78$ facturas y $218$ mensajes del respaldo. Los $156$ acuses no llegan a nadie, y está bien así: nadie necesita contestar un “gracias”.
En horas, Valeria dedicará $28.9$ al mes a los mensajes de WhatsApp, frente a $42.5$ si tuviera que contestar a mano también los precios y los horarios: $13.6$ horas ahorradas, unos $1,024$ MXN al mes a $75$ MXN la hora. La figura muestra dónde se van esas horas: las quejas, con solo $94$ mensajes, consumen $470$ minutos, más que cualquier otra ruta; los pedidos, $702$ entre las dos rutas. Las horas de Valeria no están en los mensajes fáciles, sino en los delicados, y eso es exactamente lo que el router debía conseguir: quitarle lo repetitivo para que tenga tiempo para lo que no se debe automatizar.
Validar el router
Las reglas éticas de este día son tres, y las tres se pueden comprobar en el blueprint: la ruta de quejas termina siempre en una persona; la ruta de respaldo nunca responde con texto automático genérico a algo que no entendió; y todo mensaje automático se identifica como tal. El validador del siguiente bloque las comprueba junto con cuatro reglas de estructura: una sola ruta de respaldo, colocada al final; todas las rutas normales con filtro (una ruta sin filtro recibe todos los paquetes); rutas excluyentes en los $200$ mensajes, con la función rutas_tomadas del Bloque 5; y ninguna conexión, cuenta, almacén de datos ni webhook real en los parámetros, como exigía el validador del Día 22.
Para comprobar que el validador detiene lo que debe, el bloque reconstruye el primer borrador de anoche a partir de la versión $2$: los filtros de la primera versión, la ruta de quejas sin el aviso a Valeria, el acuse de pedido sin la marca de mensaje automático (“Gracias por su pedido, en breve se lo confirmamos”, la frase que recibió la clienta) y una ruta de respaldo que, en lugar de llevar el mensaje a la hoja de revisión, contesta lo mismo. Después valida los dos blueprints y escribe la ficha de la versión $2$:
# Validador del router: respaldo, exclusividad, quejas a una persona y mensajes identificados en Python:
def todos_los_modulos(flujo):
for m in flujo:
yield m
yield from todos_los_modulos(m.get('onerror', []))
for ruta in m.get('routes', []):
yield from todos_los_modulos(ruta['flow'])
def rutas_de(bp):
router = next(m for m in bp['flow'] if m['module'] == 'builtin:BasicRouter')
return [{'ruta': r['flow'][0]['filter']['name'], 'filter': r['flow'][0]['filter'],
'fallback': r['flow'][0]['filter'].get('fallback', False), 'flow': r['flow']} for r in router['routes']]
def validar_router(bp):
rutas = rutas_de(bp)
respaldos = [k for k, r in enumerate(rutas) if r['fallback']]
maximo = max(len(rutas_tomadas(rutas, {1: {'Body': t}})) for t in mensajes['texto'])
quejas = [r for r in rutas if r['ruta'].lower().startswith('queja')]
textos = [d['value'] for m in todos_los_modulos(bp['flow']) if m['module'] == 'http:ActionSendData'
for d in m['mapper']['data'] if d['key'] == 'Body']
conexiones = [m['id'] for m in todos_los_modulos(bp['flow'])
for k in ('__IMTCONN__', 'account', 'datastore', 'hook') if (m.get('parameters') or {}).get(k, 0) != 0]
return {
'un solo respaldo, al final': respaldos == [len(rutas) - 1],
'rutas normales con filtro': all(r['filter']['conditions'] for r in rutas if not r['fallback']),
'rutas excluyentes (200 mensajes)': maximo == 1,
'quejas terminan en una persona': bool(quejas) and any(m['module'] == 'email:ActionSendEmail' for m in quejas[0]['flow']),
'respaldo sin texto automático': all(m['module'] != 'http:ActionSendData' for r in rutas if r['fallback'] for m in r['flow']),
'mensajes identificados': all(t.startswith('Mensaje automático') for t in textos),
'sin conexiones reales': not conexiones,
}
# Primer borrador de Valeria: filtros v1, queja sin aviso y respaldo que contesta solo:
borrador = copy.deepcopy(blueprint)
borrador['name'] = 'TL Pedidos WhatsApp v2 (primer borrador)'
del borrador['flow'][4]['filter']
rutas_b = rutas_de(borrador)
for r in rutas_b:
v1 = next(v['filter'] for v in RUTAS_V1 if v['ruta'] == r['ruta'].lower().split(':')[0])
r['flow'][0]['filter']['conditions'] = copy.deepcopy(v1['conditions'])
rutas_b[0]['flow'].pop() # la queja ya no avisa a Valeria
rutas_b[2]['flow'][1]['mapper']['data'][2]['value'] = 'Gracias por su pedido, en breve se lo confirmamos.'
respuesta_sola = copy.deepcopy(rutas_b[2]['flow'][1])
respuesta_sola.update(id = 15, filter = rutas_b[-1]['filter'])
rutas_b[-1]['flow'][0] = respuesta_sola # el respaldo contesta "gracias por su pedido"
for bp in [borrador, blueprint]:
resultado = validar_router(bp)
print(f"{bp['name']}: {'APTO' if all(resultado.values()) else 'RECHAZADO'}")
for regla, ok in resultado.items():
print(f" {'ok ' if ok else 'MAL'} {regla}")
ficha = [f"# {blueprint['name']}",
'Dueña: Valeria Ortiz | revisión de filtros: cada mes, con los mensajes del respaldo',
f"Rutas: {' > '.join(r['ruta'] for r in rutas_de(blueprint))}",
f"Créditos al mes: {creditos_mes.loc['temprano (v2)', 'total']} de {CUOTA_CORE} (más {OTROS} de otros escenarios)",
f'Horas de Valeria al mes: {horas_v2:.1f}',
'Ética: las quejas terminan en una persona; el respaldo no contesta solo; todo mensaje automático se identifica']
(carpeta / 'ficha_pedidos_whatsapp_v2.md').write_text('\n'.join(ficha) + '\n', encoding = 'utf-8')
print('\n'.join(ficha[2:5]))
TL Pedidos WhatsApp v2 (primer borrador): RECHAZADO
ok un solo respaldo, al final
ok rutas normales con filtro
MAL rutas excluyentes (200 mensajes)
MAL quejas terminan en una persona
MAL respaldo sin texto automático
MAL mensajes identificados
ok sin conexiones reales
TL Pedidos WhatsApp v2: APTO
ok un solo respaldo, al final
ok rutas normales con filtro
ok rutas excluyentes (200 mensajes)
ok quejas terminan en una persona
ok respaldo sin texto automático
ok mensajes identificados
ok sin conexiones reales
Rutas: Queja > Factura > Pedido con fecha > Pedido sin fecha > Precio > Horario > Respaldo: Valeria
Créditos al mes: 8446 de 10000 (más 1242 de otros escenarios)
Horas de Valeria al mes: 28.9
El primer borrador sale RECHAZADO con cuatro reglas incumplidas, una por cada decisión que provocó el problema de anoche o que lo habría repetido: las rutas no son excluyentes, la queja no termina en una persona, el respaldo contesta solo y los mensajes no se identifican como automáticos. TL Pedidos WhatsApp v2 sale APTO en las siete reglas. La ficha resume las rutas en su orden de prioridad y los $8,446$ créditos al mes, y queda guardada en trigo_lento/dia_24/ficha_pedidos_whatsapp_v2.md junto a la del formulario.
Que el validador mire la ética no es un adorno. Las tres reglas éticas del día son, en el fondo, reglas de diseño del router: dónde termina una ruta, qué hace el respaldo y cómo se escriben los textos. Si se comprueban con código, cada versión futura del escenario las hereda sin que nadie tenga que acordarse; si solo se escriben en un documento, la primera prisa las borra.
Prueba con mensajes firmados
La última comprobación se hace contra Make, con el escenario activo. El siguiente bloque envía al webhook siete mensajes de prueba, uno por ruta, con los campos que envía Twilio y la cabecera X-Twilio-Signature calculada con el token de una subcuenta de pruebas, como enseñó el Día 23. La URL del webhook y el token viven en variables de entorno, los números son los de prueba de la serie (+52 55 0000 00XX) y los identificadores de mensaje llevan la marca PRUEBA, para que las filas se puedan descartar. Como el escenario responde a Twilio antes del router, la respuesta HTTP es la misma para todas las rutas; la ruta que tomó cada mensaje se comprueba en el historial. Este bloque llama al webhook real y no se ejecutó para escribir la entrada; la salida es un ejemplo comentado:
# Prueba de las siete rutas de TL Pedidos WhatsApp v2 con mensajes firmados como Twilio en Python:
import base64
import hashlib
import hmac
import os
import time
import requests
# La URL del webhook y el token de la subcuenta de pruebas viven en variables de entorno:
URL = os.environ['TL_WEBHOOK_WHATSAPP']
TOKEN = os.environ['TL_TWILIO_TOKEN_PRUEBAS']
def firmar(url, campos, token):
# Firma de Twilio: HMAC-SHA1 de la URL más los parámetros ordenados, en base64 (Día 23):
datos = url + ''.join(k + campos[k] for k in sorted(campos))
return base64.b64encode(hmac.new(token.encode(), datos.encode(), hashlib.sha1).digest()).decode()
casos = {'queja': 'Las conchas venían crudas, qué decepción', 'factura': 'Me falta el CFDI de la semana',
'pedido con fecha': 'Quiero 2 hogazas para el sábado', 'pedido sin fecha': 'Me aparta 3 conchas? soy Inés',
'precio': 'cuanto cuesta la hogaza', 'horario': '¿Abren los domingos?', 'respaldo': '¿Hacen pan sin gluten?'}
for k, (ruta, texto) in enumerate(casos.items(), start = 1):
campos = {'MessageSid': f'SM_PRUEBA_24_{k:02d}', 'From': 'whatsapp:+5255000000' + f'{k:02d}',
'To': 'whatsapp:+525500000001', 'Body': texto, 'NumMedia': '0', 'WaId': '5255000000' + f'{k:02d}'}
r = requests.post(URL, data = campos, headers = {'X-Twilio-Signature': firmar(URL, campos, TOKEN)}, timeout = 30)
print(f'{ruta:<17} -> {r.status_code} {r.text[:24]!r}')
time.sleep(1) # con ejecución secuencial, sin ráfagas artificiales
print('Revise en el historial de Make que cada ejecución tomó una sola ruta y la esperada.')
# Salida de ejemplo (puede variar):
# queja -> 200 '<Response></Response>'
# factura -> 200 '<Response></Response>'
# pedido con fecha -> 200 '<Response></Response>'
# pedido sin fecha -> 200 '<Response></Response>'
# precio -> 200 '<Response></Response>'
# horario -> 200 '<Response></Response>'
# respaldo -> 200 '<Response></Response>'
# Revise en el historial de Make que cada ejecución tomó una sola ruta y la esperada.
Las siete respuestas de ejemplo son un 200 con el TwiML vacío, la señal de que la firma pasó y el escenario respondió a tiempo. Lo que importa está en el historial: siete ejecuciones, cada una con un solo camino iluminado después del router. Si una ejecución mostrara dos rutas, o si el mensaje de queja apareciera en la hoja de borradores, el escenario se desactiva y se vuelve al Bloque 5 con ese mensaje añadido a la muestra. Valeria hace la prueba a las 13:40, revisa el historial, comprueba que la queja de prueba le llegó por correo y vuelve a activar el escenario. Don Ernesto, que ha visto la prueba por encima de su hombro, se limita a decir: “Mientras las quejas me las conteste usted, el aparato puede contestar los horarios.”
Consideraciones finales
El día empezó con una clienta que recibió las gracias por quejarse y termina con un router cuyas rutas no pueden solaparse por construcción, probado con $200$ mensajes antes de activarse y validado con reglas que incluyen la ética. La lección técnica es que un router de Make no elige una ruta: entra en todas las que puede. La lección de diseño es que esa libertad se doma con prioridad, escribiendo en cada filtro la negación de los anteriores, y con una ruta de respaldo que no finge entender. Un filtro no se da por bueno porque funcione con el mensaje que usted tenía en la cabeza, sino porque se comporta bien con los cientos que no tenía.
Antes de activar un router, conviene hacerse algunas preguntas:
- ¿Puede un mismo mensaje cumplir dos filtros? Si la respuesta es “en teoría no”, pruébelo con mensajes; si es “sí”, escriba la prioridad en los filtros.
- ¿Qué hace la ruta de respaldo? Si contesta algo automático, contestará también lo que nadie entendió.
- ¿Qué cuesta cada paquete que el escenario descarta? Si el filtro que lo descarta está al final, casi lo mismo que uno útil.
- ¿Quién revisa los mensajes que caen en el respaldo, y cada cuánto? Son la única fuente de huecos que ninguna prueba puede anticipar.
Las cifras de hoy tienen límites claros. Los $200$ mensajes salen de $72$ textos construidos a partir de plantillas, y la versión corregida se diseñó mirándolos: que acierte el $100%$ demuestra que los errores encontrados se corrigieron, no que el router acierte el $100%$ de los mensajes reales. Una estimación honesta exigiría una muestra de mensajes reales, seudonimizados y separados en dos mitades, una para diseñar y otra para medir, como se hará al evaluar escenarios en el Día 32. Los patrones se probaron con el motor de Python y deben probarse también en Make; los identificadores de los operadores y la marca del respaldo siguen el formato documentado sin una exportación real; y las $120$ peticiones falsas, el $10%$ de acuses y los minutos por tipo de mensaje son supuestos de juguete. Ninguno de esos límites cambia la estructura de la conclusión: las rutas independientes se solapan, la prioridad las vuelve excluyentes, el respaldo debe terminar en una persona y el filtro temprano ahorra exactamente los módulos que evita.
Hay, además, un límite de fondo que conviene decir en voz alta. Las palabras clave y los patrones funcionan con lo previsible, pero los clientes de Trigo Lento no escriben para que los entienda un patrón. “¿El de zanahoria lo tienen para el sábado o mejor lo encargo?” es un pedido, una pregunta y una duda a la vez. El Día 30 llevará al escenario un modelo de lenguaje que clasifique esos mensajes, y lo hará dentro de esta misma arquitectura: el modelo propondrá un tipo y el router seguirá decidiendo con filtros sobre ese tipo, con la misma prioridad y el mismo respaldo. La lógica booleana de hoy no se tira cuando llega la IA; es la que la mantiene bajo control.
Un gran libro que habla de esto es El arte de probar software (o The Art of Software Testing), en el que Glenford Myers, con Corey Sandler y Tom Badgett en la tercera edición, defiende que probar es intentar demostrar que un programa falla, no que funciona, y enseña a diseñar los casos con particiones de equivalencia y valores límite. Las plantillas de este día son particiones de equivalencia (“pedido con fecha”, “pedido sin fecha”, “precio sin tilde”), y los mensajes incómodos, como “Buenas tardes” o “Normalmente”, son sus valores límite. Aunque el libro habla de programas escritos en código, sus ideas valen igual para un escenario armado con burbujas.
Conclusiones clave
Router
- Un router de Make procesa sus rutas en orden, una detrás de otra, y un paquete entra en todas las rutas cuyo filtro cumple; la ruta de respaldo solo recibe lo que no cumple ninguna otra.
- Un router bien diseñado cumple $\lvert E(x) \rvert = 1$: cada mensaje toma un solo camino. Dos o más rutas son un solape; un tipo conocido en el respaldo es un hueco.
- Routers y filtros no consumen créditos; lo que cobra es cada módulo de cada ruta que se ejecuta, así que un solape cuesta los créditos de dos rutas.
Filtros
- Un filtro es un OR de grupos y cada grupo un AND de condiciones (
a,o,b), guardado en el módulo al que llega la línea. - “Contiene” ve letras, no palabras: “tarde” está en “Buenas tardes” y “mal” en “normalmente”. Los patrones (
cu[aá]nto,factur,llegó tarde) cubren variantes en una condición y bajaron el router de $85$ a $20$ condiciones. - La prioridad $f’r = f_r \wedge \neg f_1 \wedge \cdots \wedge \neg f{r-1}$ hace excluyentes las rutas por construcción; en Trigo Lento, queja, factura, pedido, precio y horario.
- Comparar números como texto da resultados alfabéticos: “10” > “9” es falso.
Pruebas y costes
- La primera versión trató bien $118$ de $200$ mensajes, con $45$ solapes, $25$ huecos y $12$ rutas equivocadas; la corregida, $200$ de $200$, en una muestra que sirvió para diseñar y no para medir.
- Con los filtros al principio,
TL Pedidos WhatsApp v2gasta $8,446$ créditos al mes ($96.88%$ de Make Core con los otros escenarios); con los mismos filtros al final, $8,842$ ($100.84%$). El ahorro, $396$ créditos, es $156 \cdot 1 + 120 \cdot 2$. - El escenario resuelve solo $546$ mensajes al mes y lleva a Valeria $858$, que le ocupan $28.9$ horas frente a $42.5$; las quejas, con $94$ mensajes, consumen más minutos que ninguna otra ruta.
- El validador convierte la ética en reglas: quejas a una persona, respaldo sin texto automático y mensajes identificados; rechazó el primer borrador con cuatro fallos y aprobó la versión $2$.
Mañana, en el Día 25: Transformar datos en Make: funciones, iteradores y agregadores. El router ya sabe qué es cada mensaje, pero no qué dice: “pal sábado” llegó como texto y la hoja guardó la fecha de hoy. Valeria aprenderá las funciones de texto, número y fecha de Make, la zona horaria de la Ciudad de México y el par iterador y agregador, con sus créditos contados.
Referencias
- Make (2026). Router: rutas, orden de las rutas y ruta de respaldo. Centro de ayuda de Make. https://help.make.com/router
- Make (2026). Filtrado: filtros, condiciones y operadores. Centro de ayuda de Make. https://help.make.com/filtering
- Make Academy (2026). Make Foundation: Routing and Filters. https://academy-content.make.com/courses/make-foundation-routing-filters/02-adding-a-filter/
- Make Academy (2026). Make Foundation: Operations and Credits. https://academy-content.make.com/courses/make-foundation-operations-credits/01-introduction-to-operations-and-credits
- Twilio (2026). Webhooks Security: validar la firma X-Twilio-Signature. https://www.twilio.com/docs/usage/webhooks/webhooks-security
- Boole, G. (1854). An Investigation of the Laws of Thought. Walton and Maberly.
- Myers, G. J., Sandler, C. y Badgett, T. (2011). The Art of Software Testing (3.ª ed.). Wiley.
- Hohpe, G. y Woolf, B. (2003). Enterprise Integration Patterns: Designing, Building, and Deploying Messaging Solutions. Addison-Wesley.

