Cargando
Miniatura del artículo

Arquitecto de automatización con IA: Día 74

Índice de contenido

Resumen:
Un borrador de publicación de Trigo Lento decía “el mejor pan de la ciudad” y quedó programado sin que nadie lo leyera, porque cada flujo tenía su propia forma de pedir permiso y uno de ellos tenía un atajo. Esta entrada convierte la aprobación humana en una pieza de arquitectura: una matriz de riesgo e impacto decide qué acciones necesitan a una persona, una tabla en SQLite guarda cada aprobación como una máquina de estados con caducidad y escalado, Valeria decide con botones firmados en Slack y Don Ernesto con botones de WhatsApp, y un solo sub-workflow de n8n atiende a todos los flujos. Al terminar, usted sabrá decidir qué se aprueba, implementar la aprobación como estado persistente, medir la carga y los tiempos de quienes aprueban y usar los rechazos para mejorar los prompts.
¿Cómo citar el presente artículo?
Romero, J. (15 de abril de 2026). Arquitecto de automatización con IA: Día 74. python-data-engineer. https://www.python-data-engineer.com/post/aai/dia_74/.
Arquitecto de automatización con IA: Día 74 by Jeshua Romero Guadarrama, available under Attribution 4.0 International (CC BY 4.0) at https://www.python-data-engineer.com/post/aai/dia_74/.

Precios, límites y políticas revisados en octubre de 2026: planes, precios y límites de Make, n8n, Zapier, WhatsApp (Meta, Twilio, Wati) y de la API de Claude. Lo específico de hoy se revisó en la misma fecha: los mensajes interactivos de WhatsApp con botones de respuesta admiten hasta 3 botones con títulos de hasta 20 caracteres y un cuerpo de hasta 1 024 caracteres (documentación de Meta para desarrolladores); el límite de 60 caracteres del encabezado y del pie procede de fuentes secundarias que coinciden entre sí. La firma v0 de Slack (HMAC-SHA256 sobre v0:marca:cuerpo) se contrastó con la documentación oficial de verificación de peticiones de Slack. Las referencias de Parasuraman, Sheridan y Wickens (2000) y de Bainbridge (1983) se comprobaron en sus registros bibliográficos. No se pudo levantar un contenedor de n8n al escribir la entrada: el JSON de TL Aprobaciones v1 tiene la estructura verificada contra la documentación, revisado en octubre de 2026, con identificadores de ejemplo. Ocho de los diez bloques son Python que se ejecuta sin red y sus salidas son las reales; los bloques 5 y 9 son JSON.

Día 74. Humano en el bucle: aprobaciones con Slack y WhatsApp

Introducción


Ayer, en el Día 73 (Caso de otro negocio VI: descripciones de producto accesibles para la tienda en línea), Valeria llevó a Café Altura el manual de voz, la salida estructurada y el cotejo de afirmaciones de esta semana, bloqueó diez “orgánicos” sin certificado vigente y organizó una cola en la que Inés aprueba las afirmaciones y Lucía el tono, y Trigo Lento ganó la prueba de que su método funciona fuera de casa. Esa cola con registro era, en pequeño, lo que hoy le falta a la propia panadería.

Es miércoles 15 de abril, a las 7:40 de la mañana. Valeria revisa la cola de publicaciones del día con el primer café y se detiene en una pieza de Instagram programada para las 13:00: “Hoy salió la hogaza de masa madre con 18 horas de fermentación: el mejor pan de la ciudad. 95 pesos la pieza de 800 g.” Nadie la aprobó. Entró el martes a las 22:10 por la vía rápida que Valeria abrió la semana pasada en TL Publicación multired v1 para los avisos del mismo día (“hoy hay pan de centeno”, “cerramos a las 14:00 por lluvia”): si la lista de comprobación automática del Día 71 no encontraba nada, la pieza pasaba sola. La lista busca precios distintos del catálogo, alérgenos, “sin gluten” y la leyenda de IA; no busca superlativos. Valeria la retira a las 7:42 y la publicación nunca sale, pero la pregunta queda en la mesa: ¿cuántos atajos así existen en los flujos de Trigo Lento?

La respuesta, cuando lo revisa, es incómoda. Cada flujo pide permiso a su manera. El agente de pedidos del Día 38 espera en un nodo Wait con un enlace por WhatsApp; los correos a mayoristas del Día 47 se quedan como borradores con una etiqueta; las pulsaciones de Slack del Día 50 reanudan ese mismo Wait; la publicación multired del Día 72 compara la huella del texto aprobado; la cola de Café Altura de ayer vive en otra tabla. Son cinco mecanismos con cinco registros, y uno de ellos tenía una puerta trasera.

Don Ernesto llega a las 8:15 con la segunda hornada. Lee la pieza en la pantalla, se quita el gorro y no levanta la voz: “El mejor pan de la ciudad lo dirá la gente, no nosotros. Y se lo digo una vez, Valeria: lo que lleve el nombre de la panadería, lo veo yo.” Es la objeción más clara de toda la serie y tiene dos lecturas. La literal es imposible: en Trigo Lento salen al mes $600$ respuestas a preguntas frecuentes, $300$ recordatorios y $120$ correos con el nombre de la panadería, y Don Ernesto no puede leerlos todos sin dejar de hornear. La de arquitecto es exacta: toda pieza nueva que hable en nombre de Trigo Lento ante el público necesita una firma humana, y esa firma no puede depender de qué flujo la generó. Hoy esa regla se escribe en una matriz, en una tabla y en un solo sub-workflow.

En la presente entrada, descubrirá cómo decidir qué acciones requieren aprobación con una matriz de riesgo e impacto y la escalera de autonomía del Día 38, y cómo implementar la aprobación como un estado persistente (pendiente, aprobada, rechazada o caducada) con transiciones que la base de datos hace cumplir. Dibujará la máquina de estados, armará el mensaje de botones de WhatsApp para Don Ernesto, simulará un mes de $222$ aprobaciones con escalado a los $30$ y a los $60$ minutos y comprobará la simulación con una fórmula. Después verá cómo evitar que aprobar se vuelva un sello automático, con diferencias resaltadas y muestras aleatorias, etiquetará los motivos de rechazo para corregir el manual de voz, reunirá las métricas de la supervisión y terminará con TL Aprobaciones v1, el sub-workflow que todos los flujos llaman.

Objetivos de aprendizaje


Al terminar esta entrada, usted podrá:

  • Decidir qué acciones requieren aprobación con una matriz de riesgo e impacto, y cuáles pueden actuar y avisar.
  • Implementar una aprobación como estado persistente (pendiente, aprobada, rechazada, caducada) con escalado por tiempo.
  • Aprobar desde Slack (botones firmados) y desde WhatsApp para Don Ernesto (botones interactivos dentro de la ventana o plantilla de utilidad interna).
  • Medir la carga humana, los tiempos de aprobación y la tasa de rechazo, y usar los rechazos para mejorar los prompts.

Qué aprueba una persona


El humano en el bucle (o HITL, por sus siglas en inglés: “Human in the Loop”) es un diseño en el que una persona interviene en un punto fijo de un proceso automático para decidir, corregir o detener lo que la máquina propone. La definición tiene una trampa: no dice en qué puntos. Poner a una persona en todos convierte la automatización en un formulario con pasos extra; no ponerla en ninguno es apostar la reputación del negocio a que el modelo nunca escriba “el mejor pan de la ciudad”. El trabajo del arquitecto es elegir los puntos, y elegirlos con un criterio que se pueda escribir y revisar.

Parasuraman, Sheridan y Wickens (2000) propusieron ese criterio hace un cuarto de siglo para la aviación y las plantas industriales: la automatización no es un interruptor de todo o nada, sino un nivel que se elige por función, según lo que cuesta un error y lo fácil que es deshacerlo. Valeria ya lo aplicó el Día 38 (Agentes autónomos con supervisión humana: el asistente de pedidos) con una escalera de cinco peldaños: la máquina sugiere, redacta, actúa con aprobación, actúa y avisa, o actúa sola. Hoy la escalera deja de ser una decisión del agente de pedidos y pasa a ser una política de toda la panadería.

Para cada acción que hacen los flujos, Valeria y Don Ernesto puntúan dos cosas de $1$ a $5$: la probabilidad de que la máquina se equivoque y el impacto del error si llega a un cliente o al dinero. La puntuación de riesgo es el producto:

$$ S = P \cdot I $$

La lógica de esta puntuación es la siguiente:

  • $P$ es la probabilidad de error en una escala ordinal de $1$ (casi nunca, como leer el catálogo) a $5$ (frecuente); $I$ es el impacto, de $1$ (nadie lo nota) a $5$ (dinero que sale o una clienta con alergia).
  • Multiplicar hace que una acción solo sea grave si las dos cosas lo son: una consulta con impacto $1$ nunca pasa de $5$, aunque falle mucho, y un reembolso con $P = 2$ e $I = 5$ llega a $10$.
  • La escala es ordinal, así que $S$ sirve para ordenar acciones, no para decir que una es “el doble de riesgosa” que otra. Por eso la decisión no sale solo de $S$, sino de reglas en un orden fijo.

Las reglas, en ese orden, son cuatro. Lo irreversible y caro ($I \ge 4$ y sin forma de deshacerlo) lo ejecuta una persona, aunque la máquina lo redacte: peldaño 2. Lo que tiene $S \ge 6$ o habla en nombre de Trigo Lento con un texto nuevo se ejecuta solo después de un sí explícito: peldaño 3, el bucle de hoy. Lo que llega al cliente pero repite un texto ya aprobado actúa y avisa: peldaño 4. Lo demás es autónomo. La segunda regla es la traducción exacta de la frase de Don Ernesto, con un matiz que él aceptó después de ver los números: una respuesta a una pregunta frecuente lleva el nombre de la panadería, pero repite una página de Notion que él ya aprobó en el Día 68, así que lo que se firma es la fuente, no cada respuesta.

Empecemos con el bloque de preparación habitual: importaciones, la semilla global de la serie y la carpeta del día:

python
# Importaciones base, semilla de la serie y carpeta del día en Python:
import warnings
warnings.filterwarnings('ignore')
import difflib
import hashlib
import json
import math
import re
import sqlite3
import textwrap
from datetime import datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import FancyArrowPatch, FancyBboxPatch, Polygon, RegularPolygon
from scipy import stats

# Generador aleatorio con la semilla global de la serie:
rng = np.random.default_rng(2026)
CDMX = ZoneInfo('America/Mexico_City')
pd.set_option('display.width', 160)
carpeta = Path('trigo_lento') / 'dia_74'
carpeta.mkdir(parents = True, exist_ok = True)
print('numpy', np.__version__, '| pandas', pd.__version__, '| carpeta:', carpeta.as_posix())
numpy 2.4.6 | pandas 3.0.3 | carpeta: trigo_lento/dia_74

El entorno queda listo con numpy $2.4.6$ y pandas $3.0.3$, y todo lo que escriba el día irá a trigo_lento/dia_74/, sin tocar la base de producción.

El siguiente bloque escribe la matriz con las diez acciones que hoy ejecutan los flujos de Trigo Lento, su volumen mensual y sus dos puntuaciones, aplica las cuatro reglas para asignar el peldaño y el aprobador, y calcula cuánto trabajo humano supone el bucle:

python
# Matriz de riesgo e impacto y asignación del peldaño de autonomía (escalera del Día 38) en Python:
# (acción, veces al mes, probabilidad de error 1-5, impacto 1-5, reversible, texto nuevo con el nombre de la panadería, llega al cliente)
ACCIONES = [
    ('Consultar catálogo o existencias', 900, 1, 1, True, False, False),
    ('Responder pregunta frecuente con fuente', 600, 2, 2, True, False, True),
    ('Recordatorio de recogida (plantilla)', 300, 1, 2, True, False, True),
    ('Enviar correo a mayorista', 120, 2, 3, False, True, True),
    ('Confirmar pastel personalizado', 40, 2, 4, True, True, True),
    ('Publicar en redes', 36, 3, 4, True, True, True),
    ('Responder queja', 26, 3, 4, True, True, True),
    ('Cancelar pedido', 9, 2, 4, False, True, True),
    ('Proponer compra de insumos', 8, 2, 4, False, False, False),
    ('Reembolsar anticipo', 4, 2, 5, False, True, True),
]
PELDANOS = {1: 'Sugiere', 2: 'Redacta', 3: 'Actúa con aprobación', 4: 'Actúa y avisa', 5: 'Autónomo'}

def peldano(p, i, reversible, con_nombre, al_cliente):
    # Reglas en orden: lo irreversible y caro lo ejecuta una persona; lo que lleva el nombre o pesa, se aprueba.
    if not reversible and i >= 4:
        return 2
    if p * i >= 6 or con_nombre:
        return 3
    if al_cliente or p * i >= 3:
        return 4
    return 5

def aprobador(accion, nivel):
    if nivel == 2:
        return 'Valeria ejecuta'
    if nivel != 3:
        return 'nadie antes; muestra semanal' if nivel == 4 else 'nadie'
    return 'Don Ernesto (firma)' if accion == 'Publicar en redes' else 'Valeria; escala a Don Ernesto'

matriz = pd.DataFrame(ACCIONES, columns = ['accion', 'al_mes', 'p', 'i', 'reversible', 'con_nombre', 'al_cliente'])
matriz['puntuacion'] = matriz.p * matriz.i
matriz['peldano'] = [peldano(*f) for f in matriz[['p', 'i', 'reversible', 'con_nombre', 'al_cliente']].itertuples(index = False)]
matriz['nombre_peldano'] = matriz.peldano.map(PELDANOS)
matriz['aprobador'] = [aprobador(a, n) for a, n in zip(matriz.accion, matriz.peldano)]
print(matriz[['accion', 'al_mes', 'p', 'i', 'puntuacion', 'reversible', 'con_nombre', 'peldano', 'aprobador']].to_string(index = False))
en_bucle = matriz[matriz.peldano == 3]
print('Aprobaciones al mes (peldaño 3):', en_bucle.al_mes.sum(), '|', dict(zip(en_bucle.accion, en_bucle.al_mes)))
print('Ejecuciones humanas (peldaño 2):', matriz[matriz.peldano == 2].al_mes.sum(),
      '| sin aprobación previa (peldaños 4 y 5):', matriz[matriz.peldano >= 4].al_mes.sum())

# Carga humana: un minuto por aprobación (ficha) y minutos por tipo (supuestos de Valeria):
MINUTOS = {'Enviar correo a mayorista': 1.0, 'Confirmar pastel personalizado': 1.5,
           'Publicar en redes': 1.0, 'Responder queja': 3.0}
horas_1min = en_bucle.al_mes.sum() / 60
horas_tipo = sum(n * MINUTOS[a] for a, n in zip(en_bucle.accion, en_bucle.al_mes)) / 60
print(f'Carga humana: {horas_1min:.1f} h a 1 min por aprobación | {horas_tipo:.2f} h con minutos por tipo')
                                 accion  al_mes  p  i  puntuacion  reversible  con_nombre  peldano                     aprobador
       Consultar catálogo o existencias     900  1  1           1        True       False        5                         nadie
Responder pregunta frecuente con fuente     600  2  2           4        True       False        4  nadie antes; muestra semanal
   Recordatorio de recogida (plantilla)     300  1  2           2        True       False        4  nadie antes; muestra semanal
              Enviar correo a mayorista     120  2  3           6       False        True        3 Valeria; escala a Don Ernesto
         Confirmar pastel personalizado      40  2  4           8        True        True        3 Valeria; escala a Don Ernesto
                      Publicar en redes      36  3  4          12        True        True        3           Don Ernesto (firma)
                        Responder queja      26  3  4          12        True        True        3 Valeria; escala a Don Ernesto
                        Cancelar pedido       9  2  4           8       False        True        2               Valeria ejecuta
             Proponer compra de insumos       8  2  4           8       False       False        2               Valeria ejecuta
                    Reembolsar anticipo       4  2  5          10       False        True        2               Valeria ejecuta
Aprobaciones al mes (peldaño 3): 222 | {'Enviar correo a mayorista': 120, 'Confirmar pastel personalizado': 40, 'Publicar en redes': 36, 'Responder queja': 26}
Ejecuciones humanas (peldaño 2): 21 | sin aprobación previa (peldaños 4 y 5): 1800
Carga humana: 3.7 h a 1 min por aprobación | 4.90 h con minutos por tipo

De $2,043$ acciones al mes, solo $222$ entran en el bucle de aprobación: $120$ correos a mayoristas, $40$ pasteles personalizados, $36$ publicaciones y $26$ respuestas a quejas, las mismas cifras que el Día 50 (Slack como centro de operaciones: webhooks, Block Kit y aprobaciones) usó para dimensionar el canal #aprobaciones. Otras $21$ (cancelaciones, reembolsos y compras) las ejecuta Valeria con su propia mano, y $1,800$ actúan sin aprobación previa, con aviso o sin él. La Tabla 1 resume la matriz.

Tabla 1. Matriz de acciones de Trigo Lento con riesgo, impacto, peldaño de la escalera del Día 38 y aprobador.

AcciónAl mes$P$$I$$S$¿Reversible?¿Texto nuevo con el nombre?PeldañoAprobador
Consultar catálogo o existencias900111SíNo5. AutónomoNadie
Responder pregunta frecuente con fuente600224SíNo4. Actúa y avisaMuestra semanal
Recordatorio de recogida (plantilla)300122SíNo4. Actúa y avisaMuestra semanal
Enviar correo a mayorista120236NoSí3. Actúa con aprobaciónValeria; escala a Don Ernesto
Confirmar pastel personalizado40248SíSí3. Actúa con aprobaciónValeria; escala a Don Ernesto
Publicar en redes363412SíSí3. Actúa con aprobaciónDon Ernesto (firma)
Responder queja263412SíSí3. Actúa con aprobaciónValeria; escala a Don Ernesto
Cancelar pedido9248NoSí2. RedactaValeria ejecuta
Proponer compra de insumos8248NoNo2. RedactaValeria ejecuta
Reembolsar anticipo42510NoSí2. RedactaValeria ejecuta

Hay dos decisiones en la Tabla 1 que conviene mirar dos veces. La primera es que el correo a mayorista es irreversible (un correo enviado no se recoge) y aun así no baja al peldaño 2, porque su impacto es $3$: un error con Marisol se corrige con otro correo y una disculpa. La segunda es que publicar sí es reversible (una publicación se borra) y aun así tiene la puntuación más alta del bucle, $S = 12$, porque una captura de pantalla dura más que la publicación. Las reglas producen estas diferencias solas; nadie tuvo que discutir caso por caso.

La carga humana del bucle

La carga humana del bucle se calcula con una suma ponderada:

$$ H = \frac{1}{60} \sum_{k} N_k , t_k $$

La lógica de esta fórmula es la siguiente:

  • $N_k$ es el número de aprobaciones al mes del tipo $k$ y $t_k$ los minutos que una persona tarda en decidir una; dividir entre $60$ da horas.
  • Con un minuto por aprobación, la cifra de la ficha, $H = 222/60 = 3.7$ horas al mes.
  • Con los minutos que Valeria midió por tipo ($1$ para correos y publicaciones, $1.5$ para pasteles y $3$ para quejas, que exigen pensar la respuesta), $H = 4.90$ horas al mes.
  • $H$ mide el tiempo de decidir, no el costo de la interrupción: $222$ decisiones son $222$ veces que alguien deja lo que estaba haciendo. Por eso el resto del día trata de que cada interrupción llegue a la persona correcta, con lo necesario para decidir en un minuto.

La Figura 1 dibuja la idea del día como Don Ernesto la entiende: un sello que solo una mano puede poner, y una puerta que no se abre sin él.

La firma del maestro: los cinco flujos de Trigo Lento pasan por una sola puerta, TL Aprobaciones v1, donde Valeria firma 186 aprobaciones operativas al mes y Don Ernesto las 36 publicaciones; la vía rápida del martes queda cerrada.

Figura 1. La firma del maestro: los cinco flujos de Trigo Lento pasan por una sola puerta, TL Aprobaciones v1, donde Valeria firma 186 aprobaciones operativas al mes y Don Ernesto las 36 publicaciones; la vía rápida del martes queda cerrada.

La aprobación como máquina de estados


Lo que falló el martes no fue una persona, sino un diseño en el que “aprobado” era una condición que cada flujo evaluaba a su manera. En la vía rápida, “aprobado” significaba “la lista de comprobación no encontró nada”; en el agente de pedidos, “alguien llamó a la dirección de reanudación”; en Gmail, “tiene la etiqueta revisado”. Ninguna de esas condiciones decía quién aprobó, qué texto aprobó ni cuándo dejó de valer el permiso.

La solución es que una aprobación sea un objeto con vida propia, guardado en una base de datos, con un número finito de estados y reglas que dicen de cuál a cuál se puede pasar: una máquina de estados. La de Trigo Lento tiene cuatro estados. Pendiente es el estado inicial: el borrador espera una decisión. Aprobada, rechazada y caducada son estados finales: una vez ahí, la aprobación no vuelve a cambiar, y si alguien quiere otra cosa, crea una aprobación nueva. El escalado no es un estado, sino un contador (nivel) dentro de pendiente: a los $30$ minutos sin decisión, Valeria recibe un recordatorio por WhatsApp; a los $60$, la solicitud llega a Don Ernesto; a los $120$, la aprobación caduca y el flujo que la pidió hace lo seguro (el correo se queda como borrador, el pastel recibe un “una persona le escribirá en breve”, la queja pasa a una llamada).

Tres reglas convierten la tabla en algo más que un registro:

  1. Decide el primero. Cada decisión es un UPDATE ... WHERE estado = 'pendiente': si Valeria y Don Ernesto pulsan a la vez, la base de datos aplica una sola y la otra no tiene efecto. Es la misma sentencia atómica que el Día 50 usó para el relevo de los pasteles.
  2. Los estados finales no se tocan, ni siquiera con un UPDATE escrito a mano: lo impide un disparador (trigger) de SQLite que aborta cualquier cambio en una fila que ya no está pendiente.
  3. El historial solo crece. Cada evento (creada, escalada, aprobada, aprobada con edición, rechazada, caducada) se inserta en una tabla historial con la persona, la hora y la huella SHA-256 del texto, y otros dos disparadores prohíben modificar o borrar sus filas. Ese historial es la respuesta a la pregunta “¿quién aprobó qué y con qué texto?”.

Además, un rechazo exige un motivo de una lista cerrada de cinco (“superlativo o no comprobable”, “tono publicitario”, “dato incorrecto”, “alérgeno o salud”, “foto o texto alternativo”). Sin motivo, la sección de aprender de los rechazos no tendría datos.

El siguiente bloque crea las dos tablas y los tres disparadores, escribe la función transicion, prueba cada estado de origen contra cada acción para construir la tabla de transiciones y, después, reproduce los dos casos de hoy: la pieza de la hogaza, que Don Ernesto rechaza por WhatsApp, y un correo a Brújula que nadie decide:

python
# Tabla aprobaciones en SQLite con máquina de estados, historial inmutable y transiciones validadas en Python:
ruta_bd = carpeta / 'aprobaciones_dia74.sqlite'
ruta_bd.unlink(missing_ok = True)
con = sqlite3.connect(ruta_bd)
con.executescript('''
CREATE TABLE aprobaciones (
    id TEXT PRIMARY KEY, tipo TEXT NOT NULL, origen TEXT NOT NULL,
    texto_propuesto TEXT NOT NULL, texto_anterior TEXT, huella TEXT NOT NULL,
    aprobador TEXT NOT NULL, nivel INTEGER NOT NULL DEFAULT 0,
    estado TEXT NOT NULL DEFAULT 'pendiente'
        CHECK (estado IN ('pendiente', 'aprobada', 'rechazada', 'caducada')),
    creada_en TEXT NOT NULL, caduca_en TEXT NOT NULL,
    decidida_por TEXT, decidida_en TEXT, texto_final TEXT, motivo TEXT);
CREATE TABLE historial (n INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT, evento TEXT,
    persona TEXT, momento TEXT, huella TEXT);
CREATE TRIGGER estados_finales BEFORE UPDATE ON aprobaciones WHEN OLD.estado <> 'pendiente'
BEGIN SELECT RAISE(ABORT, 'estado final: no admite cambios'); END;
CREATE TRIGGER historial_sin_cambios BEFORE UPDATE ON historial
BEGIN SELECT RAISE(ABORT, 'el historial solo admite inserciones'); END;
CREATE TRIGGER historial_sin_borrar BEFORE DELETE ON historial
BEGIN SELECT RAISE(ABORT, 'el historial solo admite inserciones'); END;
''')
MOTIVOS = ['superlativo o no comprobable', 'tono publicitario', 'dato incorrecto', 'alérgeno o salud',
           'foto o texto alternativo']

def huella(texto):
    return hashlib.sha256(texto.encode('utf-8')).hexdigest()[:16]

def iso(t):
    return t.isoformat(timespec = 'minutes')

def registrar(id_, evento, persona, t, texto = None):
    con.execute('INSERT INTO historial (id, evento, persona, momento, huella) VALUES (?, ?, ?, ?, ?)',
                (id_, evento, persona, iso(t), huella(texto) if texto else None))

def crear(id_, tipo, origen, texto, anterior, quien, t, caduca_min):
    con.execute('''INSERT INTO aprobaciones (id, tipo, origen, texto_propuesto, texto_anterior, huella,
                   aprobador, creada_en, caduca_en) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)''',
                (id_, tipo, origen, texto, anterior, huella(texto), quien, iso(t), iso(t + timedelta(minutes = caduca_min))))
    registrar(id_, 'creada', origen, t, texto)

def transicion(id_, accion, persona, t, texto_final = None, motivo = None):
    # Decide el primero: cada UPDATE exige estado pendiente y la hora correcta respecto de la caducidad.
    if accion == 'aprobar':
        cur = con.execute('''UPDATE aprobaciones SET estado = 'aprobada', decidida_por = ?, decidida_en = ?,
                             texto_final = COALESCE(?, texto_propuesto)
                             WHERE id = ? AND estado = 'pendiente' AND caduca_en > ?''',
                          (persona, iso(t), texto_final, id_, iso(t)))
    elif accion == 'rechazar':
        assert motivo in MOTIVOS, 'Un rechazo sin motivo de la lista no se acepta'
        cur = con.execute('''UPDATE aprobaciones SET estado = 'rechazada', decidida_por = ?, decidida_en = ?, motivo = ?
                             WHERE id = ? AND estado = 'pendiente' AND caduca_en > ?''', (persona, iso(t), motivo, id_, iso(t)))
    elif accion == 'escalar':
        cur = con.execute('''UPDATE aprobaciones SET nivel = nivel + 1
                             WHERE id = ? AND estado = 'pendiente' AND caduca_en > ?''', (id_, iso(t)))
    elif accion == 'caducar':
        cur = con.execute('''UPDATE aprobaciones SET estado = 'caducada', decidida_por = 'sistema', decidida_en = ?
                             WHERE id = ? AND estado = 'pendiente' AND caduca_en <= ?''', (iso(t), id_, iso(t)))
    if cur.rowcount == 1:
        texto = texto_final or con.execute('SELECT texto_propuesto FROM aprobaciones WHERE id = ?', (id_,)).fetchone()[0]
        registrar(id_, accion if accion != 'aprobar' or not texto_final else 'aprobar con edición', persona, t, texto)
    return 'aplicada' if cur.rowcount == 1 else 'sin efecto'

# Tabla 2: cada estado de origen contra cada acción, dentro y fuera de plazo:
t0 = datetime(2026, 4, 15, 10, 0, tzinfo = CDMX)
filas = []
for origen, preparar, hora in [('pendiente (en plazo)', None, 30), ('pendiente (vencida)', None, 130),
                               ('aprobada', 'aprobar', 30), ('rechazada', 'rechazar', 30), ('caducada', 'caducar', 130)]:
    fila = {'desde': origen}
    for accion in ['aprobar', 'rechazar', 'escalar', 'caducar']:
        id_ = f'prueba_{len(filas)}_{accion}'
        crear(id_, 'correo_mayorista', 'prueba', 'Texto de prueba', None, 'valeria', t0, 120)
        if preparar:
            transicion(id_, preparar, 'Valeria', t0 + timedelta(minutes = 20 if preparar != 'caducar' else 125),
                       motivo = MOTIVOS[2] if preparar == 'rechazar' else None)
        fila[accion] = transicion(id_, accion, 'Valeria', t0 + timedelta(minutes = hora),
                                  motivo = MOTIVOS[2] if accion == 'rechazar' else None)
    filas.append(fila)
print(pd.DataFrame(filas).to_string(index = False))
con.execute("DELETE FROM aprobaciones WHERE origen = 'prueba'")

# El caso de hoy: la pieza de la hogaza, firmada por Don Ernesto por WhatsApp:
anterior = 'Hoy salió la hogaza de masa madre con 18 horas de fermentación. 95 pesos la pieza de 800 g; se aparta por WhatsApp.'
borrador = 'Hoy salió la hogaza de masa madre con 18 horas de fermentación: el mejor pan de la ciudad. 95 pesos la pieza de 800 g.'
t = datetime(2026, 4, 15, 19, 2, tzinfo = CDMX)
crear('apr_0415_031', 'publicacion', 'TL Publicación multired v1', borrador, anterior, 'don_ernesto', t, 16 * 60)
print('Don Ernesto rechaza:', transicion('apr_0415_031', 'rechazar', 'Don Ernesto', t + timedelta(minutes = 18),
                                         motivo = 'superlativo o no comprobable'))
print('Valeria pulsa Aprobar 5 min después:', transicion('apr_0415_031', 'aprobar', 'Valeria', t + timedelta(minutes = 23)))
try:
    con.execute("UPDATE aprobaciones SET estado = 'aprobada' WHERE id = 'apr_0415_031'")
except sqlite3.DatabaseError as error:
    print('UPDATE directo:', error)
try:
    con.execute("DELETE FROM historial WHERE id = 'apr_0415_031'")
except sqlite3.DatabaseError as error:
    print('Borrar el historial:', error)

# Un correo a Brújula que nadie decide: dos escalones y caducidad a los 120 minutos:
t = datetime(2026, 4, 15, 10, 5, tzinfo = CDMX)
crear('apr_0415_032', 'correo_mayorista', 'TL Gmail mayoristas', 'Confirmamos 40 baguettes para mañana a las 7:30.',
      None, 'valeria', t, 120)
for minutos, accion in [(30, 'escalar'), (60, 'escalar'), (90, 'escalar'), (120, 'caducar')]:
    print(f'+{minutos:>3} min {accion:<8}', transicion('apr_0415_032', accion, 'sistema', t + timedelta(minutes = minutos)))
con.commit()
print(pd.read_sql_query("SELECT id, evento, persona, substr(momento, 12, 5) AS hora, huella FROM historial WHERE id LIKE 'apr%'", con).to_string(index = False))
print(pd.read_sql_query('SELECT id, estado, nivel, decidida_por, motivo FROM aprobaciones', con).to_string(index = False))
               desde    aprobar   rechazar    escalar    caducar
pendiente (en plazo)   aplicada   aplicada   aplicada sin efecto
 pendiente (vencida) sin efecto sin efecto sin efecto   aplicada
            aprobada sin efecto sin efecto sin efecto sin efecto
           rechazada sin efecto sin efecto sin efecto sin efecto
            caducada sin efecto sin efecto sin efecto sin efecto
Don Ernesto rechaza: aplicada
Valeria pulsa Aprobar 5 min después: sin efecto
UPDATE directo: estado final: no admite cambios
Borrar el historial: el historial solo admite inserciones
+ 30 min escalar  aplicada
+ 60 min escalar  aplicada
+ 90 min escalar  aplicada
+120 min caducar  aplicada
          id   evento                    persona  hora           huella
apr_0415_031   creada TL Publicación multired v1 19:02 59921dbe705ea4fb
apr_0415_031 rechazar                Don Ernesto 19:20 59921dbe705ea4fb
apr_0415_032   creada        TL Gmail mayoristas 10:05 cbac5fc6254a6af3
apr_0415_032  escalar                    sistema 10:35 cbac5fc6254a6af3
apr_0415_032  escalar                    sistema 11:05 cbac5fc6254a6af3
apr_0415_032  escalar                    sistema 11:35 cbac5fc6254a6af3
apr_0415_032  caducar                    sistema 12:05 cbac5fc6254a6af3
          id    estado  nivel decidida_por                       motivo
apr_0415_031 rechazada      0  Don Ernesto superlativo o no comprobable
apr_0415_032  caducada      3      sistema                          NaN

La matriz de la salida es la Tabla 2, construida probando, no escribiendo: cada celda es el resultado real de intentar esa acción sobre una aprobación en ese estado. Desde pendiente en plazo se puede aprobar, rechazar y escalar, pero no caducar, porque el plazo no ha vencido. Desde pendiente vencida solo se puede caducar: nadie puede aprobar a las 12:10 un correo cuyo permiso venció a las 12:00, por mucho que pulse el botón. Desde los tres estados finales, nada.

Tabla 2. Estados de la aprobación y transiciones permitidas, comprobadas con el Bloque 3.

Estado de origenAprobarRechazar (con motivo)EscalarCaducarQué hace el flujo que pidió la aprobación
Pendiente, en plazoSíSíSí (nivel + 1)NoEspera; a los 30 min recordatorio y a los 60 Don Ernesto
Pendiente, vencidaNoNoNoSíNada todavía: el sistema la marca como caducada
AprobadaNoNoNoNoEjecuta el texto final aprobado, con su huella
RechazadaNoNoNoNoArchiva el borrador y guarda el motivo
CaducadaNoNoNoNoHace lo seguro: borrador sin enviar, aviso a la clienta o llamada

Los dos casos de hoy muestran las reglas trabajando. La pieza apr_0415_031, con la huella 59921dbe705ea4fb, se creó a las 19:02 y Don Ernesto la rechazó a las 19:20 con el motivo “superlativo o no comprobable”. Cinco minutos después, Valeria pulsó Aprobar en Slack sin saber que su padre ya había decidido, y la base respondió sin efecto: decide el primero. Un UPDATE directo que intenta forzar el estado a aprobada choca con el disparador (“estado final: no admite cambios”), y un intento de borrar el historial también (“el historial solo admite inserciones”). El correo apr_0415_032 subió de nivel a las 10:35, 11:05 y 11:35 y caducó a las 12:05, con nivel $3$ y decidida_por igual a sistema: el historial deja constancia de que nadie lo decidió, que es justo lo que hay que saber para corregir el turno.

Hay una sutileza en el diseño que evita un error clásico. El plazo se compara en la base de datos, dentro de la misma sentencia que decide (caduca_en > ahora), y no en el flujo antes de llamar a la base. Si el flujo comprobara primero el plazo y después escribiera, una pulsación que llega en el último segundo podría pasar la comprobación y escribirse cuando ya venció. La condición en el WHERE cierra esa ventana.

El diagrama de estados

El siguiente bloque dibuja la máquina de estados con matplotlib.patches, con el escalado como bucle sobre pendiente y los plazos en las flechas:

python
# Diagrama de estados de una aprobación dibujado con patches en Python:
fig, ax = plt.subplots(figsize = (10, 5.4))
ESTADOS = {'pendiente': (2.0, 3.0, '#6b6a66'), 'aprobada': (8.0, 5.0, '#1f9d55'),
           'rechazada': (8.0, 3.0, '#d03b3b'), 'caducada': (8.0, 1.0, '#eb6834')}
for nombre, (x, y, color) in ESTADOS.items():
    ax.add_patch(FancyBboxPatch((x - 1.0, y - 0.42), 2.0, 0.84, boxstyle = 'round,pad=0.06,rounding_size=0.3',
                                facecolor = color, edgecolor = 'white', linewidth = 2))
    ax.text(x, y, nombre, ha = 'center', va = 'center', color = 'white', fontsize = 13, weight = 'bold')
flechas = [('aprobada', 'Aprobar (firma verificada;\nla edición guarda el texto final)'),
           ('rechazada', 'Rechazar + motivo de la lista'),
           ('caducada', 'Vence el plazo: 120 min\n(publicaciones: 16 h)')]
for destino, etiqueta in flechas:
    x2, y2, _ = ESTADOS[destino]
    ax.add_patch(FancyArrowPatch((3.05, 3.0 + (y2 - 3.0) * 0.18), (x2 - 1.08, y2), arrowstyle = '-|>',
                                 mutation_scale = 16, color = '#2b2a28', linewidth = 1.6))
    ax.text(5.0, 3.0 + (y2 - 3.0) * 0.62 + (0.28 if y2 >= 3 else -0.05), etiqueta, ha = 'center', va = 'center',
            fontsize = 9.5, color = '#2b2a28', bbox = dict(facecolor = 'white', edgecolor = 'none', pad = 1))
ax.add_patch(FancyArrowPatch((1.45, 3.45), (2.55, 3.45), connectionstyle = 'arc3,rad=-1.6', arrowstyle = '-|>',
                             mutation_scale = 14, color = '#2a78d6', linewidth = 1.6))
ax.text(2.0, 4.75, 'Escalar (sigue pendiente)\n30 min: recordatorio a Valeria por WhatsApp\n60 min: Don Ernesto por WhatsApp',
        ha = 'center', va = 'center', fontsize = 9.5, color = '#2a78d6')
ax.text(8.0, 0.25, 'Estados finales: ningún UPDATE los cambia (disparador de SQLite)', ha = 'center',
        fontsize = 9, color = '#52514e')
ax.text(2.0, 2.2, 'Solo se decide si\nestado = pendiente\ny no ha vencido', ha = 'center', va = 'top', fontsize = 9,
        color = '#52514e')
ax.set_xlim(0, 10)
ax.set_ylim(0, 5.9)
ax.axis('off')
ax.set_title('La aprobación como máquina de estados', fontsize = 14)
plt.tight_layout()
plt.show()

Gráfica: El diagrama de estados. Diagrama de estados de una aprobación dibujado con patches

El diagrama cabe en una servilleta y eso es lo que lo hace útil: Valeria lo pegó en la pared de la oficina. Las tres flechas que salen de pendiente son las únicas salidas posibles; el bucle azul es el escalado, que cambia a quién se le pregunta pero no lo que se pregunta; y la nota de abajo recuerda que los estados finales son finales por una regla de la base, no por buena voluntad. La Figura 2 lo dibuja con relojes en las flechas.

La máquina de estados de una aprobación: pendiente, con el escalado a los 30 y a los 60 minutos, y sus tres salidas finales, aprobada, rechazada con motivo o caducada a los 120 minutos (16 horas en las publicaciones).

Figura 2. La máquina de estados de una aprobación: pendiente, con el escalado a los 30 y a los 60 minutos, y sus tres salidas finales, aprobada, rechazada con motivo o caducada a los 120 minutos (16 horas en las publicaciones).

Aprobar en Slack


Para Valeria, el lugar natural de las aprobaciones es el canal #aprobaciones que el Día 50 montó con Block Kit: un mensaje con el borrador y tres botones. Hoy cambian dos cosas. La primera es que el mensaje ya no lo arma cada flujo, sino TL Aprobaciones v1, que lo construye siempre igual, con el identificador de la aprobación como valor de cada botón y nunca la dirección de reanudación del flujo. La segunda es que el mensaje muestra las diferencias frente a la última versión aprobada del mismo texto, cuando existe, con lo quitado tachado y lo añadido en negrita.

La cadena de confianza es la misma del Día 50 y no admite atajos. Cuando Valeria pulsa un botón, Slack envía un POST a la URL de interactividad de la aplicación con una firma en la cabecera X-Slack-Signature, calculada con el secreto de firma sobre v0:marca:cuerpo crudo. El workflow que recibe la pulsación (TL Slack operaciones v1) responde 200 en menos de tres segundos, verifica la firma con comparación de tiempo constante, rechaza las marcas de más de cinco minutos, comprueba que el usuario de Slack que pulsó esté en la lista de aprobadores de ese tipo y, solo entonces, ejecuta la transición del Bloque 3 y llama a la dirección de reanudación de la ejecución que espera. Si la firma no cuadra, la pulsación no existe.

El tercer botón de Valeria es Editar, y es el que más se usa en las quejas. Editar no es aprobar: abre un cuadro de diálogo (modal) de Slack con el texto propuesto, Valeria lo corrige y, al guardar, la transición registra aprobar con edición con el texto final y su huella, distinta de la del texto propuesto. Esa distinción importa dos veces. Importa para la publicación, porque TL Publicación multired v1 (Publicación multired: Instagram, Facebook y LinkedIn por API o con Make y n8n) solo publica un texto cuya huella coincida con la aprobada, así que lo que sale es lo que Valeria escribió y no lo que propuso el modelo. E importa para aprender, porque la diferencia entre el texto propuesto y el final es la corrección más precisa que el modelo puede recibir: no “esto está mal”, sino “esto es lo que debía decir”.

Una aprobación en Slack, además, deja el mensaje en un estado que todo el equipo entiende. Tras decidir, el workflow reemplaza los botones por una línea (“Rechazada por Don Ernesto a las 19:20: superlativo o no comprobable”) usando la response_url de la pulsación, de modo que nadie más intenta decidir algo ya decidido. El caso de Valeria pulsando Aprobar a las 19:25 solo puede pasar si el mensaje no se actualizó, y aun así la base lo detiene.

Aprobar en WhatsApp


Don Ernesto no usa Slack, y el Día 50 aceptó esa condición con una tableta en el obrador. La tableta sirve cuando él está en el obrador; no sirve cuando está en la central de abasto a las seis de la mañana o en su casa a las siete de la tarde, que es justo cuando se revisan las publicaciones del día siguiente. Para él, el canal es WhatsApp, y la herramienta son los mensajes interactivos con botones de respuesta: un mensaje de la Cloud API con hasta tres botones que el destinatario pulsa sin escribir nada, y cuya pulsación llega al webhook del negocio como un mensaje de tipo interactive con el identificador del botón.

Que Trigo Lento le escriba a Don Ernesto por el número del negocio tiene una base distinta de la de los clientes. No es marketing ni atención: es comunicación interna con una persona que trabaja en la panadería, que aceptó por escrito recibir estas solicitudes en su teléfono, para qué y en qué horario, y que puede pedir que se detengan cuando quiera. El consentimiento se guarda en consentimientos.json con la misma estructura que el de los clientes (quién, cuándo, para qué y por qué canal), y el horario es una regla del flujo, no una cortesía: de 7:00 a 20:00, de lunes a sábado. Lo que se genera de noche, espera a la mañana.

El mensaje interactivo tiene una restricción técnica que decide el diseño: es un mensaje de servicio, y solo se puede enviar dentro de la ventana de atención de 24 horas, es decir, si Don Ernesto le escribió al número del negocio en las últimas 24 horas. Como contesta el resumen de las 7:00 casi todos los días, la ventana suele estar abierta. Cuando no lo está, el flujo envía una plantilla de utilidad aprobada por Meta, tl_aprobacion_pendiente, con dos variables (el tipo y el identificador) y los mismos tres botones como respuestas rápidas. El siguiente bloque es el mensaje que recibió Don Ernesto a las 19:02, tal como lo envía TL Aprobaciones v1 cuando la ventana está abierta:

json
{
  "messaging_product": "whatsapp",
  "recipient_type": "individual",
  "to": "525500000001",
  "type": "interactive",
  "interactive": {
    "type": "button",
    "header": {"type": "text", "text": "Trigo Lento: le toca firmar"},
    "body": {"text": "Publicación de Instagram para el jueves 16 a las 13:00.\n\nTexto propuesto:\nHoy salió la hogaza de masa madre con 18 horas de fermentación: el mejor pan de la ciudad. 95 pesos la pieza de 800 g.\n\nCambios frente a lo que usted aprobó el domingo:\n+ \"el mejor pan de la ciudad\"\n- \"se aparta por WhatsApp\"\n\nSi no decide antes de las 20:00, se lo vuelvo a enviar mañana a las 7:00."},
    "footer": {"text": "Solicitud apr_0415_031 · caduca el jueves 16 a las 11:00"},
    "action": {
      "buttons": [
        {"type": "reply", "reply": {"id": "apr_0415_031|aprobar", "title": "Aprobar"}},
        {"type": "reply", "reply": {"id": "apr_0415_031|rechazar", "title": "Rechazar"}},
        {"type": "reply", "reply": {"id": "apr_0415_031|llamar", "title": "Que me llamen"}}
      ]
    }
  }
}

Cada campo del mensaje responde a una decisión. El cuerpo trae el texto completo propuesto y, debajo, los cambios frente a lo que Don Ernesto aprobó el domingo, porque una persona que lee en el teléfono con las manos enharinadas no va a abrir un enlace. Los tres botones tienen títulos cortos (el límite es de $20$ caracteres) y sus identificadores llevan la aprobación y la acción (apr_0415_031|rechazar), nunca la dirección de reanudación del flujo. El tercer botón no es Editar sino “Que me llamen”: Don Ernesto no va a reescribir un texto en el teléfono, pero sí puede pedir que Valeria lo llame, y esa acción deja la aprobación pendiente y avisa a Valeria. El pie repite el identificador y la hora de caducidad, para que una captura del mensaje se pueda rastrear.

La pulsación llega al webhook de WhatsApp, y ahí se repite la cadena de confianza de Slack con otras piezas. Meta firma cada notificación con el secreto de la aplicación en la cabecera X-Hub-Signature-256; el workflow la verifica con HMAC-SHA256 sobre el cuerpo crudo, como en el Día 51, y después comprueba tres cosas propias de este canal: que el número que pulsó (from) sea el de Don Ernesto registrado como aprobador de ese tipo, que el mensaje al que responde (context.id) sea el que el flujo envió para esa aprobación, y que la aprobación siga pendiente. Una pulsación de un número que no es el suyo, aunque traiga un identificador válido, no decide nada.

Atención aquí: la categoría de una plantilla la asigna Meta al revisarla, no quien la escribe, y Meta puede reclasificar como marketing una plantilla enviada como utilidad si su contenido no corresponde a una transacción o una cuenta en curso. Una solicitud de aprobación interna a un trabajador es un uso poco habitual de la plataforma de mensajería con clientes; antes de depender de ella, envíe la plantilla a revisión, guarde la respuesta de Meta y tenga un plan si la rechaza o la recategoriza (la tableta del obrador y una llamada siguen funcionando). Y nunca use el número de WhatsApp personal de un trabajador para algo que el trabajador no aceptó por escrito: la relación laboral no es consentimiento para recibir mensajes de trabajo a cualquier hora.

La Tabla 3 compara los dos canales, con lo que cada uno hace bien y lo que cuesta.

Tabla 3. Aprobar en Slack frente a aprobar en WhatsApp en Trigo Lento.

AspectoSlack (Valeria)WhatsApp (Don Ernesto y recordatorios)
QuiénValeria, en el canal #aprobacionesDon Ernesto; Valeria solo en el recordatorio de los 30 min
BotonesAprobar, Editar, Rechazar (Block Kit)Aprobar, Rechazar, Que me llamen (hasta 3)
AutenticidadFirma v0 con el secreto de firma; marca de 5 minFirma X-Hub-Signature-256; número from y context.id
EdiciónCuadro de diálogo con el textoNo: “Que me llamen” deja la aprobación pendiente
Cuándo se puede enviarSiempre (es comunicación interna)Interactivo dentro de la ventana de 24 h; si no, plantilla de utilidad
HorarioEl de ValeriaDe 7:00 a 20:00, de lunes a sábado; lo nocturno espera
CostoPlan gratuito de Slack0 dentro de la ventana; 0.0085 USD por plantilla de utilidad (Tabla C)
RegistroLa tabla aprobaciones y su historial, no el canalLa misma tabla: el canal nunca es el registro

Cuánto tarda una decisión y cuántas escalan

Con los dos canales listos, la pregunta de Don Ernesto se vuelve numérica: ¿cuántas veces al mes le va a sonar el teléfono? Depende de cuántas aprobaciones lleguen a los $60$ minutos sin que Valeria decida. Valeria decide con tiempos lognormales por franja horaria (los mismos del Día 50: mediana de $18$ minutos de 7:00 a 10:00, cuando está en el obrador, y de $5$ minutos de 12:00 a 15:00, cuando está en la oficina). Hay además un caso que el Día 50 no modeló y que es el que de verdad dispara el escalado: Valeria no está (el banco, una entrega, una cita médica), lo que se supone que ocurre en el $7%$ de las solicitudes. Si se llama $a$ a esa probabilidad, la fracción de aprobaciones operativas que llegan a Don Ernesto es:

$$ P(\text{escala}) = a + (1 - a) \sum_{f} w_f \left[1 - \Phi!\left(\frac{\ln 60 - \ln m_f}{\sigma_f}\right)\right] e^{-30/m_r} $$

La lógica de esta fórmula es la siguiente:

  • Si Valeria está fuera (probabilidad $a = 0.07$), el recordatorio no la alcanza y la solicitud llega a Don Ernesto a los $60$ minutos con seguridad.
  • Si está, la solicitud escala solo si se cumplen dos cosas: que en su franja $f$ (con peso $w_f$, mediana $m_f$ y dispersión $\sigma_f$) no haya decidido antes de $60$ minutos, lo que mide el corchete con la función de distribución normal $\Phi$, y que tampoco decida en los $30$ minutos que van del recordatorio al escalado, lo que mide $e^{-30/m_r}$ con una media de $m_r = 15$ minutos tras el recordatorio.
  • Las dos condiciones se multiplican porque, en el modelo, son independientes: el recordatorio llega a una persona que ya iba tarde, no la hace más lenta.
  • El primer término domina: sin ausencias, la fórmula daría menos de una solicitud al mes para Don Ernesto. El escalado no existe para la Valeria lenta; existe para la Valeria ausente.

Las publicaciones siguen otro camino, porque su aprobador es Don Ernesto desde el principio: se le envían en lote a las 19:00, y si a las 20:00 no ha decidido, el flujo calla y las reenvía a las 7:00 del día siguiente; caducan $16$ horas después, antes de su hora de publicación. El siguiente bloque simula un mes de $222$ aprobaciones con estas reglas, con las tasas de rechazo y de edición por tipo como supuestos declarados, compara la simulación con la fórmula sobre $9,990$ casos, calcula el percentil $95$ con y sin escalado y cuenta los mensajes de WhatsApp del mes:

python
# Simulación de un mes de 222 aprobaciones con escalado a los 30 y a los 60 minutos en Python:
FRANJAS = pd.DataFrame({
    'franja': ['07-10 obrador', '10-12 hora pico', '12-15 oficina', '15-19 tarde'],
    'peso': [0.25, 0.30, 0.25, 0.20],
    'med_valeria': [18.0, 12.0, 5.0, 7.0], 'sig_valeria': [0.8, 0.9, 0.6, 0.7],   # Día 50
    'med_ernesto': [25.0, 15.0, 10.0, 8.0]})                                       # supuestos de hoy
RECORDATORIO, ESCALADO, CADUCA = 30, 60, 120
M_RECORDATORIO, SIG_ERNESTO = 15.0, 0.8     # media tras el WhatsApp a Valeria; dispersión de Don Ernesto
AUSENCIA, M_REGRESO = 0.07, 90.0            # Valeria fuera (banco, reparto): vuelve a los 60 + Exp(90) min
PUB = {'med': 35.0, 'sig': 1.0, 'corte': 60, 'reenvio': 12 * 60, 'med_manana': 20.0, 'caduca': 16 * 60}
PROB = {'correo_mayorista': (0.05, 0.08), 'pastel': (0.04, 0.10), 'queja': (0.08, 0.25), 'publicacion': (0.22, 0.10)}
PALABRAS = {'correo_mayorista': 120, 'pastel': 45, 'queja': 70, 'publicacion': 40}
MES = {'correo_mayorista': 120, 'pastel': 40, 'queja': 26, 'publicacion': 36}

def simular_aprobaciones(n_por_tipo, rng):
    filas = []
    for tipo, n in n_por_tipo.items():
        for _ in range(n):
            if tipo == 'publicacion':               # lote de las 19:00 para Don Ernesto; a las 20:00 se calla
                franja, t = '19-20 cierre', rng.lognormal(np.log(PUB['med']), PUB['sig'])
                quien, nivel = 'Don Ernesto (firma)', 0
                if t > PUB['corte']:                # se reenvía a las 7:00 del día siguiente
                    t, nivel = PUB['reenvio'] + rng.lognormal(np.log(PUB['med_manana']), 0.7), 1
                caduca = PUB['caduca']
            else:
                f = rng.choice(len(FRANJAS), p = FRANJAS.peso)
                franja = FRANJAS.franja[f]
                if rng.random() < AUSENCIA:         # ausente: el recordatorio no la alcanza
                    valeria, tras_aviso = ESCALADO + rng.exponential(M_REGRESO), np.inf
                else:
                    valeria = rng.lognormal(np.log(FRANJAS.med_valeria[f]), FRANJAS.sig_valeria[f])
                    tras_aviso = RECORDATORIO + rng.exponential(M_RECORDATORIO)
                ernesto = ESCALADO + rng.lognormal(np.log(FRANJAS.med_ernesto[f]), SIG_ERNESTO)
                t = min(valeria, tras_aviso, ernesto)
                quien = ('Valeria' if t <= RECORDATORIO else
                         'Valeria tras recordatorio' if t < ernesto else 'Don Ernesto (escalado)')
                nivel = int(t > RECORDATORIO) + int(t > ESCALADO)
                caduca = CADUCA
            u = rng.random()
            p_rech, p_edit = PROB[tipo]
            decision = 'rechazada' if u < p_rech else 'editada' if u < p_rech + p_edit else 'aprobada'
            if t > caduca:
                t, quien, decision = caduca, 'nadie', 'caducada'
            minimo = PALABRAS[tipo] / 250 * 60          # segundos para leerlo a 250 palabras por minuto
            vistazo = rng.random() < 0.12
            lectura = rng.lognormal(np.log(4.0 if vistazo else minimo * 1.4), 0.5)
            filas.append({'tipo': tipo, 'franja': franja, 'minutos': t, 'quien': quien, 'nivel': nivel,
                          'decision': decision, 'lectura_s': lectura, 'lectura_min_s': minimo})
    return pd.DataFrame(filas)

mes = simular_aprobaciones(MES, rng)
grande = simular_aprobaciones({k: v * 45 for k, v in MES.items()}, rng)   # 9 990 casos para curvas estables

def resumen(d):
    return pd.Series({'n': len(d), 'mediana': d.minutos.median(), 'p95': d.minutos.quantile(0.95),
                      'recordatorio': (d.nivel >= 1).sum(), 'a_ernesto': ((d.nivel == 2) & (d.quien != 'Don Ernesto (firma)')).sum(),
                      'decide_ernesto': (d.quien == 'Don Ernesto (escalado)').sum(),
                      'caducadas': (d.decision == 'caducada').sum(),
                      'rechazo_%': 100 * (d.decision == 'rechazada').mean(), 'edicion_%': 100 * (d.decision == 'editada').mean()})
tabla_3 = mes.groupby('tipo').apply(resumen).reindex(list(MES))
tabla_3.loc['operativas'] = resumen(mes[mes.tipo != 'publicacion'])
tabla_3.loc['total'] = resumen(mes)
print(tabla_3.round(1).to_string())

# Comprobación contra la fórmula: se escala si Valeria está fuera o si calla 60 min y 30 min tras el recordatorio:
p_presente = sum(w * (1 - stats.norm.cdf((np.log(ESCALADO) - np.log(m)) / s))
                 for w, m, s in zip(FRANJAS.peso, FRANJAS.med_valeria, FRANJAS.sig_valeria))
p_formula = AUSENCIA + (1 - AUSENCIA) * p_presente * np.exp(-(ESCALADO - RECORDATORIO) / M_RECORDATORIO)
oper = grande[grande.tipo != 'publicacion']
print(f'Escalan a Don Ernesto: fórmula {p_formula:.4f} | simulación {(oper.nivel == 2).mean():.4f}'
      f' | al mes: {p_formula * 186:.1f} de 186')
# Sin escalado: Valeria sola, con las mismas ausencias y sin recordatorio ni relevo:
solo_valeria = np.concatenate([np.where(rng.random(int(5000 * w)) < AUSENCIA,
                                        ESCALADO + rng.exponential(M_REGRESO, int(5000 * w)),
                                        rng.lognormal(np.log(FRANJAS.med_valeria[f]), FRANJAS.sig_valeria[f], int(5000 * w)))
                               for f, w in enumerate(FRANJAS.peso)])
print(f'p95 de las operativas: con escalado {oper.minutos.quantile(0.95):.1f} min | '
      f'sin escalado {np.quantile(solo_valeria, 0.95):.1f} min | dentro de 60 min: {(oper.minutos <= 60).mean():.1%}'
      f' frente a {(solo_valeria <= 60).mean():.1%} | pasarían de 120 min sin escalado: {(solo_valeria > 120).mean():.1%}')

# Mensajes de WhatsApp al mes y su costo si todos salieran como plantilla de utilidad (Tabla C):
wa_valeria = ((mes.tipo != 'publicacion') & (mes.nivel >= 1)).sum()
wa_ernesto = ((mes.tipo != 'publicacion') & (mes.nivel >= 2)).sum() + 36 + ((mes.tipo == 'publicacion') & (mes.nivel == 1)).sum()
print(f'WhatsApp al mes: {wa_valeria} recordatorios a Valeria y {wa_ernesto} mensajes a Don Ernesto | '
      f'costo máximo como plantillas: {(wa_valeria + wa_ernesto) * 0.0085:.2f} USD')

fig, ax = plt.subplots(figsize = (10, 5))
grupos = [(f, q) for f in list(FRANJAS.franja) + ['19-20 cierre']
          for q in ['Valeria', 'Valeria tras recordatorio', 'Don Ernesto (escalado)', 'Don Ernesto (firma)']]
colores = {'Valeria': '#2a78d6', 'Valeria tras recordatorio': '#7fb2ea', 'Don Ernesto (escalado)': '#eb6834',
           'Don Ernesto (firma)': '#6b6a66'}
datos, posiciones, tonos, x = [], [], [], 0
for i, f in enumerate(list(FRANJAS.franja) + ['19-20 cierre']):
    for q in colores:
        sub = grande[(grande.franja == f) & (grande.quien == q)].minutos
        if len(sub) >= 20:
            datos.append(sub)
            posiciones.append(i * 4 + len([p for p in posiciones if p >= i * 4]))
            tonos.append(colores[q])
cajas = ax.boxplot(datos, positions = posiciones, widths = 0.75, patch_artist = True, showfliers = False)
for caja, color in zip(cajas['boxes'], tonos):
    caja.set_facecolor(color)
    caja.set_edgecolor('#2b2a28')
for med in cajas['medians']:
    med.set_color('white')
ax.set_yscale('log')
for limite, estilo, texto in [(30, ':', 'recordatorio a Valeria (30 min)'), (60, '--', 'escala a Don Ernesto (60 min)'),
                              (120, '-.', 'caduca (120 min)')]:
    ax.axhline(limite, color = '#52514e', ls = estilo, lw = 1.2, label = texto)
centros = [np.mean([p for p in posiciones if i * 4 <= p < i * 4 + 4]) for i in range(5)]
ax.set_xticks(centros, list(FRANJAS.franja) + ['19:00 lote de\npublicaciones'])
ax.set_ylabel('Minutos hasta la decisión (escala logarítmica)')
ax.set_title('Tiempo hasta la decisión por aprobador y franja horaria', fontsize = 14)
for q, color in colores.items():
    ax.bar(0, 0, color = color, label = q)
ax.legend(fontsize = 8.5, loc = 'upper left', ncol = 3, frameon = False)
ax.set_ylim(0.8, 8000)
ax.set_xlim(-1, max(posiciones) + 1.6)
plt.tight_layout()
plt.show()
                      n  mediana    p95  recordatorio  a_ernesto  decide_ernesto  caducadas  rechazo_%  edicion_%
tipo
correo_mayorista  120.0      9.8   62.4          19.0        7.0             5.0        0.0        3.3        8.3
pastel             40.0      7.8   61.4           6.0        3.0             2.0        0.0        2.5       12.5
queja              26.0      7.1   28.0           2.0        1.0             1.0        0.0       15.4       23.1
publicacion        36.0     32.1  742.8           7.0        0.0             0.0        0.0       27.8       11.1
operativas        186.0      8.5   62.0          27.0       11.0             8.0        0.0        4.8       11.3
total             222.0     10.7   69.2          34.0       11.0             8.0        0.0        8.6       11.3
Escalan a Don Ernesto: fórmula 0.0735 | simulación 0.0772 | al mes: 13.7 de 186
p95 de las operativas: con escalado 68.3 min | sin escalado 98.6 min | dentro de 60 min: 92.3% frente a 90.4% | pasarían de 120 min sin escalado: 3.8%
WhatsApp al mes: 27 recordatorios a Valeria y 54 mensajes a Don Ernesto | costo máximo como plantillas: 0.69 USD

Gráfica: Cuánto tarda una decisión y cuántas escalan. Simulación de un mes de 222 aprobaciones con escalado a los 30 y a los 60 minutos

La fórmula y la simulación coinciden: $7.35%$ frente a $7.72%$ de las aprobaciones operativas escalan a Don Ernesto, unas $13.7$ de $186$ al mes; en el mes concreto simulado fueron $11$, y él decidió $8$ (en las otras $3$, Valeria volvió antes de que él contestara). Ninguna aprobación caducó. El costo para el teléfono de Don Ernesto es de $54$ mensajes al mes: $36$ publicaciones, $7$ reenvíos de las 7:00 y $11$ escalados. Todo el WhatsApp de las aprobaciones, si cada mensaje saliera como plantilla de utilidad, costaría $0.69$ USD al mes, un rubro que cabe en la reserva sin moverla.

La comparación con y sin escalado es la cifra que convenció a Valeria. Con escalado, el percentil $95$ de las operativas es de $68.3$ minutos; sin él, de $98.6$, y el $3.8%$ de las aprobaciones pasaría de dos horas sin que nadie las viera, que es exactamente lo que le pasa a un correo de Brújula cuando Valeria está en el banco. El escalado casi no cambia la mediana: cambia la cola, que es donde están los clientes molestos. La gráfica lo muestra en cajas: las de Valeria viven por debajo de la línea de $30$ minutos en las cuatro franjas; las de “Valeria tras recordatorio” se apilan entre $30$ y $60$; las naranjas de Don Ernesto empiezan en $60$ y casi nunca llegan a la caducidad de $120$; y la caja gris de las publicaciones se estira de minutos a más de $12$ horas, porque la regla de no escribir después de las 20:00 manda la mitad de las decisiones a la mañana siguiente. Esa caja larga no es un defecto: es el precio, visible y aceptado, del derecho de Don Ernesto a desconectarse.

No convertir la aprobación en sello automático


Bainbridge (1983) escribió un artículo de cinco páginas que la ingeniería sigue citando cuarenta años después, Ironías de la automatización. Su ironía central es que cuanto mejor funciona la máquina, peor preparada está la persona que debe vigilarla: si el sistema acierta casi siempre, quien supervisa deja de mirar con atención, y el día que falla, la persona está, en el mejor de los casos, desentrenada, y en el peor, aprobando por costumbre. En las aprobaciones esto tiene un nombre práctico, la fatiga de aprobación: después de cuarenta borradores correctos, el cuadragésimo primero se aprueba sin leerlo, y es justo el que dice “el mejor pan de la ciudad”.

La fatiga no se combate pidiendo a las personas que se esfuercen más, sino diseñando el mensaje y el proceso para que leer sea fácil y no leer sea visible. Trigo Lento usa tres herramientas.

La primera son las diferencias resaltadas. Cuando un texto ya tiene una versión aprobada (una publicación recurrente, un correo semanal a Brújula, una respuesta a una queja parecida), el mensaje de aprobación no pide releer todo: muestra qué cambió. El módulo difflib de la biblioteca estándar compara las dos versiones palabra por palabra con SequenceMatcher, que además da un índice de parecido entre $0$ y $1$. Si el parecido baja de $0.85$, el cambio es grande y el mensaje lo dice; si lo añadido contiene una expresión de la lista prohibida del manual de voz, el mensaje lo marca antes de que nadie lo lea.

La segunda son los indicadores de lectura. El sistema sabe cuándo se envió la solicitud, cuándo se abrió y cuándo se decidió, y sabe cuántas palabras tiene el texto. Una aprobación de un correo de $120$ palabras en tres segundos no es una aprobación: es un clic. No se castiga a nadie por eso, pero se mide, y si crece, se cambia el proceso (menos solicitudes, mejores diferencias, otro horario).

La tercera son las muestras aleatorias de lo que no pasa por el bucle. Las $600$ respuestas a preguntas frecuentes y los $300$ recordatorios del peldaño 4 actúan sin aprobación previa; para saber si siguen bien, cada semana se revisa una muestra al azar. La probabilidad de que una muestra de $n$ casos contenga al menos un error, si los errores ocurren en una fracción $p$ de los casos, es:

$$ P(\text{ver al menos un error}) = 1 - (1 - p)^n \qquad\Longrightarrow\qquad n = \left\lceil \frac{\ln(1 - c)}{\ln(1 - p)} \right\rceil $$

La lógica de esta fórmula es la siguiente:

  • $(1-p)^n$ es la probabilidad de que los $n$ casos revisados salgan bien, suponiendo que los errores ocurren al azar e independientes entre sí; su complemento es la de ver al menos uno.
  • Despejando $n$ para una confianza $c$ se obtiene el tamaño de muestra necesario para ver, con esa confianza, un error de frecuencia $p$; el techo $\lceil \cdot \rceil$ redondea hacia arriba.
  • La fórmula es despiadada con los errores raros: cuanto menor es $p$, más crece $n$, aproximadamente como $-\ln(1-c)/p$.
  • Una muestra no mide la tasa de error con precisión; responde a una pregunta más modesta y más útil: ¿hay un problema que valga la pena buscar?

Las tres herramientas en código

El siguiente bloque resalta las diferencias de la pieza de la hogaza y de un correo a Brújula, cuenta las aprobaciones del mes simulado que se decidieron en menos de la mitad del tiempo de lectura y calcula la tabla de muestras:

python
# Diferencias resaltadas entre el borrador y la versión aprobada, indicadores de fatiga y muestras de revisión en Python:
PROHIBIDAS = r'\b(el mejor|la mejor|los mejores|únic[oa]|insuperable|artesanal de origen|sin gluten|saludable)\b'

def resaltar(anterior, nuevo):
    a, b = anterior.split(), nuevo.split()
    sm = difflib.SequenceMatcher(a = a, b = b, autojunk = False)
    partes, cambios = [], []
    for op, i1, i2, j1, j2 in sm.get_opcodes():
        if op == 'equal':
            partes.append(' '.join(a[i1:i2]))
        if op in ('delete', 'replace'):
            partes.append('~' + ' '.join(a[i1:i2]) + '~')
            cambios.append(('quitado', ' '.join(a[i1:i2])))
        if op in ('insert', 'replace'):
            partes.append('*' + ' '.join(b[j1:j2]) + '*')
            cambios.append(('añadido', ' '.join(b[j1:j2])))
    alertas = [t for c, t in cambios if c == 'añadido' and re.search(PROHIBIDAS, t, re.I)]
    return ' '.join(partes), cambios, round(sm.ratio(), 3), alertas

slack, cambios, parecido, alertas = resaltar(anterior, borrador)
print('Slack (mrkdwn):', slack)
for c in cambios:
    print(f'  {c[0]:<8} "{c[1]}"')
print('Parecido:', parecido, '| lectura completa obligatoria:', parecido < 0.85, '| alertas:', alertas)

correo_v1 = 'Buen día, Marisol: confirmamos 40 baguettes para el lunes 20 a las 7:30. La factura semanal sale el sábado.'
correo_v2 = 'Buen día, Marisol: confirmamos 45 baguettes para el lunes 20 a las 7:30. La factura semanal sale el sábado.'
slack2, cambios2, parecido2, alertas2 = resaltar(correo_v1, correo_v2)
print('Correo:', slack2)
print('Parecido:', parecido2, '| cambios:', cambios2)

# Indicadores de fatiga del mes simulado: aprobaciones más rápidas que la propia lectura del texto:
aprobadas = mes[mes.decision.isin(['aprobada', 'editada'])]
rapidas = aprobadas[aprobadas.lectura_s < aprobadas.lectura_min_s * 0.5]
print(f'Aprobadas en menos de la mitad del tiempo de lectura: {len(rapidas)} de {len(aprobadas)} '
      f'({len(rapidas) / len(aprobadas):.1%}) |', rapidas.tipo.value_counts().to_dict())

# Muestra aleatoria de lo que actúa y avisa (peldaño 4): probabilidad de ver al menos un error:
for n in [10, 30, 60]:
    print(f'muestra de {n:>2}:', ' | '.join(f'p = {p:.0%} -> {1 - (1 - p) ** n:.3f}' for p in [0.01, 0.02, 0.05]))
n_necesaria = math.ceil(math.log(1 - 0.9) / math.log(1 - 0.02))
print('Muestra para ver con 90 % un error que ocurre en el 2 % de las respuestas:', n_necesaria,
      f'({n_necesaria / 600:.1%} de las 600 preguntas frecuentes del mes)')
Slack (mrkdwn): Hoy salió la hogaza de masa madre con 18 horas de ~fermentación.~ *fermentación: el mejor pan de la ciudad.* 95 pesos la pieza de 800 ~g; se aparta por WhatsApp.~ *g.*
  quitado  "fermentación."
  añadido  "fermentación: el mejor pan de la ciudad."
  quitado  "g; se aparta por WhatsApp."
  añadido  "g."
Parecido: 0.708 | lectura completa obligatoria: True | alertas: ['fermentación: el mejor pan de la ciudad.']
Correo: Buen día, Marisol: confirmamos ~40~ *45* baguettes para el lunes 20 a las 7:30. La factura semanal sale el sábado.
Parecido: 0.947 | cambios: [('quitado', '40'), ('añadido', '45')]
Aprobadas en menos de la mitad del tiempo de lectura: 23 de 203 (11.3%) | {'correo_mayorista': 12, 'pastel': 6, 'queja': 3, 'publicacion': 2}
muestra de 10: p = 1% -> 0.096 | p = 2% -> 0.183 | p = 5% -> 0.401
muestra de 30: p = 1% -> 0.260 | p = 2% -> 0.455 | p = 5% -> 0.785
muestra de 60: p = 1% -> 0.453 | p = 2% -> 0.702 | p = 5% -> 0.954
Muestra para ver con 90 % un error que ocurre en el 2 % de las respuestas: 114 (19.0% de las 600 preguntas frecuentes del mes)

La pieza de la hogaza tiene un parecido de $0.708$ con la versión aprobada: por debajo de $0.85$, así que el mensaje pide lectura completa, y lo añadido (“el mejor pan de la ciudad”) dispara la alerta de la lista prohibida antes de que Don Ernesto lea una palabra. En el mensaje de Slack, lo quitado aparece tachado (~...~) y lo añadido en negrita (*...*), y salta a la vista que además desapareció “se aparta por WhatsApp”, la única instrucción útil para el cliente. El correo a Brújula, en cambio, tiene un parecido de $0.947$ y un solo cambio, $40$ por $45$ baguettes: justo lo que Valeria tiene que confirmar, sin releer el resto.

En el mes simulado, $23$ de $203$ aprobaciones ($11.3%$) se decidieron en menos de la mitad del tiempo que hace falta para leer el texto a $250$ palabras por minuto, $12$ de ellas correos. Es una cifra de juguete (la fracción de “vistazos” es un supuesto del Bloque 6), pero la medida es real y es la que verá Valeria en su panel. Y la tabla de muestras pone números a la intuición: con $30$ respuestas revisadas por semana, un error que ocurre en el $2%$ de las respuestas aparece en la muestra solo el $45.5%$ de las veces; para verlo con un $90%$ de confianza hacen falta $114$ respuestas, el $19%$ de las $600$ del mes. Valeria decide revisar $30$ por semana ($120$ al mes, algo por encima de las $114$) y lo dice tal cual en el código de honor: la muestra encuentra problemas frecuentes, no garantiza la ausencia de los raros.

La Figura 3 resume la sección: aprobar no es dejar caer un sello, es leer las diferencias.

Aprobar no es sellar: a la izquierda, un sello que cae solo sobre cuarenta borradores; a la derecha, una persona que lee lo quitado y lo añadido, con el parecido de 0.708 y la alerta por superlativo.

Figura 3. Aprobar no es sellar: a la izquierda, un sello que cae solo sobre cuarenta borradores; a la derecha, una persona que lee lo quitado y lo añadido, con el parecido de 0.708 y la alerta por superlativo.

Atención aquí: una aprobación es responsabilidad real, no un trámite, y el diseño debe impedir que se convierta en uno. Nunca agrupe aprobaciones en un botón de “aprobar todas”, no muestre el botón Aprobar antes del texto ni envíe solicitudes de aprobación que la persona no puede evaluar con lo que trae el mensaje. Si la tasa de rechazo de un tipo baja a cero durante semanas y los tiempos de decisión caen por debajo del tiempo de lectura, no concluya que el modelo es perfecto: concluya que nadie está mirando, y suba ese tipo de acción un peldaño (actúa y avisa con muestra) o bájelo (redacta y una persona ejecuta), pero no lo deje en un bucle que ya no supervisa nada.

Aprender de los rechazos


Un rechazo cuesta: Valeria o Don Ernesto leyeron, decidieron que no y alguien tendrá que rehacer la pieza. Desperdiciarlo es no preguntarle por qué. Por eso el botón Rechazar de Slack abre una lista con los cinco motivos y el de WhatsApp, al pulsarse, recibe un segundo mensaje interactivo con los motivos como lista; el motivo se guarda con la transición y no se acepta un rechazo sin él, como comprobó el Bloque 3 con su assert.

Con los motivos etiquetados, el aprendizaje deja de ser una impresión (“el modelo escribe muy de anuncio”) y se vuelve una regla: si un motivo suma el $30%$ o más de los rechazos de las últimas cuatro semanas, se revisa el manual de voz. El manual de voz del Día 71 (De la idea al calendario editorial: textos e imágenes con IA) es el system cacheado que recibe el modelo, así que corregirlo corrige todos los borradores futuros. Lo que no se hace es ajustar el manual por un caso suelto, porque cada frase nueva en el system es una instrucción más que el modelo tiene que equilibrar con las otras.

El siguiente bloque simula doce semanas de borradores de publicación (nueve por semana) con probabilidades de rechazo por motivo declaradas como supuesto, cuenta los motivos antes y después del ajuste, aplica la regla de disparo a las semanas 3 a 6, compara las tasas con el intervalo de Wilson del Día 70 y la prueba exacta de Fisher, y muestra el cambio del manual con difflib.unified_diff:

python
# Motivos de rechazo etiquetados y efecto de ajustar el manual de voz en Python:
P_ANTES = [0.10, 0.06, 0.03, 0.02, 0.03]       # probabilidad por borrador de cada motivo (supuesto)
P_DESPUES = [0.015, 0.03, 0.03, 0.02, 0.03]    # tras añadir superlativos y ejemplos al manual (supuesto)
SEMANAS, POR_SEMANA, AJUSTE = 12, 9, 6
filas = []
for s in range(1, SEMANAS + 1):
    probs = P_ANTES if s <= AJUSTE else P_DESPUES
    for _ in range(POR_SEMANA):
        u, acumulada, motivo = rng.random(), 0.0, None
        for m, p in zip(MOTIVOS, probs):
            acumulada += p
            if u < acumulada:
                motivo = m
                break
        filas.append({'semana': s, 'periodo': 'antes' if s <= AJUSTE else 'después', 'motivo': motivo})
borradores = pd.DataFrame(filas)
borradores['rechazada'] = borradores.motivo.notna()
conteo = borradores[borradores.rechazada].groupby(['motivo', 'periodo']).size().unstack(fill_value = 0)
print(conteo.reindex(MOTIVOS).fillna(0).astype(int)[['antes', 'después']].to_string())

# Regla de disparo: un motivo con 30 % o más de los rechazos de las últimas 4 semanas pide revisar el manual:
ventana = borradores[(borradores.semana > AJUSTE - 4) & (borradores.semana <= AJUSTE) & borradores.rechazada]
cuota = ventana.motivo.value_counts(normalize = True)
print('Semanas 3 a 6:', cuota.round(3).to_dict(), '-> revisar:', list(cuota[cuota >= 0.30].index))

def wilson(x, n, z = 1.96):
    p = x / n
    centro = (p + z**2 / (2 * n)) / (1 + z**2 / n)
    margen = z / (1 + z**2 / n) * math.sqrt(p * (1 - p) / n + z**2 / (4 * n**2))
    return centro - margen, centro + margen

tasas = {}
for periodo in ['antes', 'después']:
    d = borradores[borradores.periodo == periodo]
    x, n = int(d.rechazada.sum()), len(d)
    tasas[periodo] = (x, n)
    bajo, alto = wilson(x, n)
    print(f'{periodo:<8} {x:>2} de {n} = {x / n:.3f}  IC 95 % [{bajo:.3f}, {alto:.3f}]')
(xa, na), (xd, nd) = tasas['antes'], tasas['después']
_, p_fisher = stats.fisher_exact([[xa, na - xa], [xd, nd - xd]], alternative = 'greater')
print(f'Prueba exacta de Fisher (unilateral): p = {p_fisher:.3f}')

manual_v3 = ['Tono: cercano y concreto, de usted.', 'Precios: solo los del catálogo.',
             'Nunca: "sin gluten", promesas de salud.', 'Cierre: cómo apartar por WhatsApp.']
manual_v4 = ['Tono: cercano y concreto, de usted.', 'Precios: solo los del catálogo.',
             'Nunca: "sin gluten", promesas de salud.',
             'Nunca: superlativos ("el mejor", "único") ni comparaciones con otras panaderías.',
             'En su lugar: un hecho comprobable ("18 horas de fermentación", "horneado a las 6:00").',
             'Cierre: cómo apartar por WhatsApp.']
for linea in difflib.unified_diff(manual_v3, manual_v4, 'manual_voz_v3', 'manual_voz_v4', lineterm = '', n = 0):
    print(linea)

semanal = borradores.groupby('semana').rechazada.mean()
fig, ax = plt.subplots(figsize = (9, 4.6))
ax.bar(semanal.index, semanal.values * 100, color = ['#d03b3b' if s <= AJUSTE else '#1f9d55' for s in semanal.index])
for periodo, (x, n) in tasas.items():
    xs = (0.6, AJUSTE + 0.4) if periodo == 'antes' else (AJUSTE + 0.6, SEMANAS + 0.4)
    ax.hlines(100 * x / n, *xs, colors = '#2b2a28', linestyles = '--', lw = 1.5)
    ax.text(xs[0] + 0.1, 100 * x / n + 1.5, f'{periodo}: {100 * x / n:.1f} %', fontsize = 10, color = '#2b2a28')
ax.axvline(AJUSTE + 0.5, color = '#2a78d6', lw = 2)
ax.text(AJUSTE + 0.6, 52, 'manual de voz v4\n(superlativos y ejemplos)', color = '#2a78d6', fontsize = 9.5)
ax.set_xticks(range(1, SEMANAS + 1))
ax.set_xlabel('Semana (9 borradores de publicación por semana)')
ax.set_ylabel('Borradores rechazados (%)')
ax.set_ylim(0, 60)
ax.set_title('Tasa de rechazo semanal de publicaciones', fontsize = 14)
ax.spines[['top', 'right']].set_visible(False)
plt.tight_layout()
plt.show()
periodo                       antes  después
motivo
superlativo o no comprobable      5        0
tono publicitario                 5        1
dato incorrecto                   2        1
alérgeno o salud                  0        1
foto o texto alternativo          1        1
Semanas 3 a 6: {'superlativo o no comprobable': 0.5, 'tono publicitario': 0.333, 'dato incorrecto': 0.167} -> revisar: ['superlativo o no comprobable', 'tono publicitario']
antes    13 de 54 = 0.241  IC 95 % [0.146, 0.369]
después   4 de 54 = 0.074  IC 95 % [0.029, 0.176]
Prueba exacta de Fisher (unilateral): p = 0.016
--- manual_voz_v3
+++ manual_voz_v4
@@ -3,0 +4,2 @@
+Nunca: superlativos ("el mejor", "único") ni comparaciones con otras panaderías.
+En su lugar: un hecho comprobable ("18 horas de fermentación", "horneado a las 6:00").

Gráfica: Aprender de los rechazos. Motivos de rechazo etiquetados y efecto de ajustar el manual de voz

En las seis semanas anteriores al ajuste se rechazaron $13$ de $54$ borradores ($0.241$), y los dos motivos dominantes fueron “superlativo o no comprobable” y “tono publicitario”, con $5$ rechazos cada uno. La regla de disparo, aplicada a las semanas 3 a 6, marca los dos: los superlativos suman el $50%$ de los rechazos y el tono el $33.3%$. El manual de voz v4 añade dos líneas, una prohibición explícita de superlativos y comparaciones y, sobre todo, una alternativa: sustituir el adjetivo por un hecho comprobable (“18 horas de fermentación”). Un modelo al que solo se le prohíbe algo tiende a buscar otro adjetivo; uno al que se le da qué poner en su lugar, lo usa.

En las seis semanas posteriores se rechazaron $4$ de $54$ ($0.074$), sin ningún superlativo. Los intervalos de Wilson, $[0.146, 0.369]$ antes y $[0.029, 0.176]$ después, apenas se tocan, y la prueba exacta de Fisher da $p = 0.016$: con estos supuestos, la mejora no se explica bien por azar. Conviene leer la gráfica con la misma prudencia que las cifras: una semana con $9$ borradores salta de $0%$ a $44%$ con dos rechazos de diferencia, así que ninguna semana suelta dice nada; lo que dice algo es la línea discontinua de cada periodo. Y los rechazos que quedan (un dato incorrecto, un alérgeno, una foto, un tono) son de los que ningún manual elimina: para eso está la persona.

Métricas de la supervisión


Un bucle humano que no se mide se degrada en silencio: las solicitudes crecen, los tiempos se alargan, la tasa de rechazo baja a cero y nadie lo nota hasta que sale una pieza mala. Las métricas de la supervisión son las del propio bucle, no las del modelo, y salen todas de la tabla aprobaciones y de su historial: cuántas aprobaciones hubo, cuánto tardaron (mediana y percentil $95$, porque la media esconde la cola), cuántas necesitaron recordatorio, cuántas llegaron a Don Ernesto, cuántas caducaron, qué fracción se rechazó y cuál se editó. La Tabla 4 las reúne para el mes simulado del Bloque 6.

Tabla 4. Métricas del mes simulado de aprobaciones por tipo (minutos desde la solicitud hasta la decisión).

TipoAprobacionesMediana (min)p95 (min)Con recordatorioEscaladas a Don ErnestoCaducadasRechazoEdición
Correo a mayorista1209.862.419703.3 %8.3 %
Pastel personalizado407.861.46302.5 %12.5 %
Respuesta a queja267.128.021015.4 %23.1 %
Publicación (firma de Don Ernesto)3632.1742.87 (reenvío 7:00)No aplica027.8 %11.1 %
Operativas1868.562.0271104.8 %11.3 %
Total22210.769.2341108.6 %11.3 %

La Tabla 4 se lee por columnas, y cada una tiene una pregunta detrás. La mediana de $8.5$ minutos en las operativas dice que el caso normal es rápido; el p95 de $62.0$ dice que una de cada veinte espera más de una hora, y es el número que hay que contrastar con el acuerdo con Marisol, que pide respuesta a sus correos en menos de $60$ minutos: el p95 de los correos, $62.4$, está justo en el borde. Las escaladas ($11$ al mes) son el costo para Don Ernesto y la cifra que él pidió conocer. Las caducadas ($0$) son el indicador de que el escalado funciona; si suben, falta una persona en la cadena. El rechazo de las quejas ($15.4%$) y su edición ($23.1%$, la más alta) dicen que es el tipo donde el modelo más ayuda a empezar y menos a terminar, lo cual es razonable: una queja se contesta con criterio, no con plantilla. Y el rechazo de las publicaciones, $27.8%$ en este mes simulado, es el del manual de voz v3; con el v4 del Bloque 8 debería acercarse al $7%$.

Tres métricas no están en la tabla y son igual de importantes. La carga humana, $4.90$ horas al mes según el Bloque 2, que se vigila contra el volumen: si las aprobaciones crecen más rápido que las ventas, algo entra al bucle que no debería. La fracción de vistazos, el $11.3%$ del Bloque 7, que es el termómetro de la fatiga. Y la fracción de aprobaciones con texto final distinto del propuesto, que en el mes fue del $11.3%$ y es la cantera de correcciones para el manual. El Día 75 llevará estas cifras al cuadro de mando; hoy basta con que existan y se calculen siempre igual.

TL Aprobaciones v1


Todo lo anterior se junta en un sub-workflow de n8n: un workflow que no tiene disparador propio y que otros llaman con el nodo Execute Workflow, como hace una función en un programa. TL Aprobaciones v1 recibe seis campos (tipo, flujo de origen, texto propuesto, texto anterior, aprobador y minutos hasta la caducidad), registra la aprobación como pendiente, envía el mensaje por el canal que toca, espera, escala y devuelve al flujo que lo llamó la decisión, el texto final y su huella. Desde hoy, ningún flujo de Trigo Lento pide permiso a su manera: el agente de pedidos, los correos de Gmail, la publicación multired y las respuestas a quejas llaman a este sub-workflow, y la vía rápida del martes desapareció. Un aviso del mismo día pasa por el mismo camino, solo que con una caducidad corta.

El JSON exportado, con identificadores de ejemplo y sin credenciales reales, es este:

json
{
  "name": "TL Aprobaciones v1",
  "nodes": [
    {"id": "0b1c7400-0000-4000-8000-000000000001", "name": "Desde otro flujo", "type": "n8n-nodes-base.executeWorkflowTrigger", "typeVersion": 1.1, "position": [0, 0],
      "parameters": {"inputSource": "jsonExample", "jsonExample": "{\n  \"tipo\": \"publicacion\",\n  \"origen\": \"TL Publicación multired v1\",\n  \"texto\": \"Hoy salió la hogaza...\",\n  \"texto_anterior\": \"Hoy salió la hogaza...\",\n  \"aprobador\": \"don_ernesto\",\n  \"caduca_min\": 960\n}"}
    },
    {"id": "0b1c7400-0000-4000-8000-000000000002", "name": "Registrar pendiente", "type": "n8n-nodes-base.postgres", "typeVersion": 2.6, "position": [240, 0], "credentials": {"postgres": {"id": "EJEMPLO-NO-REAL-PG", "name": "Postgres Trigo Lento"}},
      "parameters": {"operation": "executeQuery", "query": "INSERT INTO aprobaciones (id, tipo, origen, texto_propuesto, texto_anterior, huella, aprobador, estado, nivel, creada_en, caduca_en, url_reanudar) VALUES ($1, $2, $3, $4, $5, encode(sha256(convert_to($4, 'UTF8')), 'hex'), $6, 'pendiente', 0, now(), now() + make_interval(mins => $7::int), $8) RETURNING id, tipo, aprobador, nivel, estado, texto_propuesto, texto_anterior, ventana_abierta(CASE WHEN aprobador = 'don_ernesto' THEN 'don_ernesto' ELSE 'valeria' END) AS ventana_abierta;", "options": {"queryReplacement": "={{ ['apr_' + $now.toFormat('MMdd') + '_' + $execution.id, $json.tipo, $json.origen, $json.texto, $json.texto_anterior || '', $json.aprobador, $json.caduca_min, $execution.resumeUrl] }}"}}
    },
    {"id": "0b1c7400-0000-4000-8000-000000000003", "name": "Armar mensaje", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [480, 0],
      "parameters": {"jsCode": "const a = $json;\nconst ahora = new Date(new Date().toLocaleString('en-US', {timeZone: 'America/Mexico_City'}));\nconst enHorario = ahora.getDay() !== 0 && ahora.getHours() >= 7 && ahora.getHours() < 20;\nconst destino = a.aprobador === 'don_ernesto' || a.nivel >= 2 ? 'don_ernesto' : 'valeria';\nconst canal = destino === 'valeria' && a.nivel === 0 ? 'slack' : 'whatsapp';\nconst viejas = new Set((a.texto_anterior || '').split(/\\s+/).filter(Boolean));\nconst nuevas = new Set(a.texto_propuesto.split(/\\s+/).filter(Boolean));\nconst agregadas = [...nuevas].filter(p => !viejas.has(p)).join(' ');\nconst quitadas = [...viejas].filter(p => !nuevas.has(p)).join(' ');\nconst cambios = a.texto_anterior ? '\\n\\nCambios frente a lo aprobado:\\n+ ' + (agregadas || '(nada)') + '\\n- ' + (quitadas || '(nada)') : '\\n\\nPieza nueva: no hay versión anterior.';\nconst botones = ['aprobar', 'rechazar', destino === 'don_ernesto' ? 'llamar' : 'editar'];\nconst titulos = {aprobar: 'Aprobar', rechazar: 'Rechazar', llamar: 'Que me llamen', editar: 'Editar'};\nconst telefono = destino === 'don_ernesto' ? $vars.tl_tel_don_ernesto : $vars.tl_tel_valeria;\nconst interactivo = {messaging_product: 'whatsapp', recipient_type: 'individual', to: telefono, type: 'interactive', interactive: {type: 'button', header: {type: 'text', text: a.nivel === 1 ? 'Recordatorio: aprobación pendiente' : 'Trigo Lento: le toca decidir'}, body: {text: (a.texto_propuesto + cambios).slice(0, 1024)}, footer: {text: 'Solicitud ' + a.id}, action: {buttons: botones.map(b => ({type: 'reply', reply: {id: a.id + '|' + b, title: titulos[b]}}))}}};\nconst plantilla = {messaging_product: 'whatsapp', to: telefono, type: 'template', template: {name: 'tl_aprobacion_pendiente', language: {code: 'es_MX'}, components: [{type: 'body', parameters: [{type: 'text', text: a.tipo}, {type: 'text', text: a.id}]}].concat(botones.map((b, i) => ({type: 'button', sub_type: 'quick_reply', index: String(i), parameters: [{type: 'payload', payload: a.id + '|' + b}]})))}};\nconst slack = {channel: $vars.tl_slack_canal_aprobaciones, text: 'Aprobación pendiente: ' + a.tipo, blocks: [{type: 'section', text: {type: 'mrkdwn', text: '*' + a.tipo + '* (' + a.id + ')\\n' + a.texto_propuesto + cambios}}, {type: 'actions', elements: botones.map(b => ({type: 'button', action_id: b, value: a.id, text: {type: 'plain_text', text: titulos[b]}}))}]};\nconst cuerpo = canal === 'slack' ? slack : (a.ventana_abierta ? interactivo : plantilla);\nreturn [{json: {...a, destino, canal, enviar: enHorario && a.estado === 'pendiente', cuerpo}}];"}
    },
    {"id": "0b1c7400-0000-4000-8000-000000000004", "name": "¿Enviar ahora?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [720, 0],
      "parameters": {"conditions": {"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2}, "conditions": [{"id": "c7400001", "leftValue": "={{ $json.enviar }}", "rightValue": "", "operator": {"type": "boolean", "operation": "true", "singleValue": true}}], "combinator": "and"}, "options": {}}
    },
    {"id": "0b1c7400-0000-4000-8000-000000000005", "name": "¿Por WhatsApp?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [960, -200],
      "parameters": {"conditions": {"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2}, "conditions": [{"id": "c7400002", "leftValue": "={{ $json.canal }}", "rightValue": "whatsapp", "operator": {"type": "string", "operation": "equals"}}], "combinator": "and"}, "options": {}}
    },
    {"id": "0b1c7400-0000-4000-8000-000000000006", "name": "Enviar WhatsApp", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [1200, -320], "credentials": {"httpHeaderAuth": {"id": "EJEMPLO-NO-REAL-WA", "name": "Cloud API TL"}},
      "parameters": {"method": "POST", "url": "https://graph.facebook.com/v26.0/PNID-EJEMPLO-NO-REAL-0074/messages", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify($json.cuerpo) }}", "options": {"timeout": 15000}}
    },
    {"id": "0b1c7400-0000-4000-8000-000000000007", "name": "Publicar en Slack", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [1200, -80], "credentials": {"httpHeaderAuth": {"id": "EJEMPLO-NO-REAL-SL", "name": "Slack bot TL"}},
      "parameters": {"method": "POST", "url": "https://slack.com/api/chat.postMessage", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify($json.cuerpo) }}", "options": {"timeout": 10000}}
    },
    {"id": "0b1c7400-0000-4000-8000-000000000008", "name": "Esperar decisión", "type": "n8n-nodes-base.wait", "typeVersion": 1.1, "position": [1440, 0], "webhookId": "00000000-0000-4000-8000-0000000074aa",
      "parameters": {"resume": "webhook", "httpMethod": "GET", "responseMode": "onReceived", "limitWaitTime": true, "limitType": "afterTimeInterval", "resumeAmount": 30, "resumeUnit": "minutes", "options": {}}
    },
    {"id": "0b1c7400-0000-4000-8000-000000000009", "name": "Leer estado", "type": "n8n-nodes-base.postgres", "typeVersion": 2.6, "position": [1680, 0], "credentials": {"postgres": {"id": "EJEMPLO-NO-REAL-PG", "name": "Postgres Trigo Lento"}},
      "parameters": {"operation": "executeQuery", "query": "SELECT id, tipo, estado, nivel, aprobador, decidida_por, texto_final, huella, now() >= caduca_en AS vencida FROM aprobaciones WHERE id = $1;", "options": {"queryReplacement": "={{ [$('Registrar pendiente').first().json.id] }}"}}
    },
    {"id": "0b1c7400-0000-4000-8000-000000000010", "name": "¿Decidida?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [1920, 0],
      "parameters": {"conditions": {"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2}, "conditions": [{"id": "c7400003", "leftValue": "={{ $json.estado }}", "rightValue": "pendiente", "operator": {"type": "string", "operation": "notEquals"}}], "combinator": "and"}, "options": {}}
    },
    {"id": "0b1c7400-0000-4000-8000-000000000011", "name": "¿Caducó?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [1920, 300],
      "parameters": {"conditions": {"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2}, "conditions": [{"id": "c7400004", "leftValue": "={{ $json.vencida }}", "rightValue": "", "operator": {"type": "boolean", "operation": "true", "singleValue": true}}], "combinator": "and"}, "options": {}}
    },
    {"id": "0b1c7400-0000-4000-8000-000000000012", "name": "Marcar caducada", "type": "n8n-nodes-base.postgres", "typeVersion": 2.6, "position": [2160, 300], "credentials": {"postgres": {"id": "EJEMPLO-NO-REAL-PG", "name": "Postgres Trigo Lento"}},
      "parameters": {"operation": "executeQuery", "query": "UPDATE aprobaciones SET estado = 'caducada', decidida_por = 'sistema', decidida_en = now() WHERE id = $1 AND estado = 'pendiente' RETURNING id, tipo, estado, nivel, decidida_por, texto_final, huella;", "options": {"queryReplacement": "={{ [$json.id] }}"}}
    },
    {"id": "0b1c7400-0000-4000-8000-000000000013", "name": "Subir escalón", "type": "n8n-nodes-base.postgres", "typeVersion": 2.6, "position": [480, 300], "credentials": {"postgres": {"id": "EJEMPLO-NO-REAL-PG", "name": "Postgres Trigo Lento"}},
      "parameters": {"operation": "executeQuery", "query": "UPDATE aprobaciones SET nivel = nivel + (estado = 'pendiente')::int WHERE id = $1 RETURNING id, tipo, aprobador, nivel, estado, texto_propuesto, texto_anterior, ventana_abierta(CASE WHEN aprobador = 'don_ernesto' OR nivel >= 2 THEN 'don_ernesto' ELSE 'valeria' END) AS ventana_abierta;", "options": {"queryReplacement": "={{ [$json.id] }}"}}
    },
    {"id": "0b1c7400-0000-4000-8000-000000000014", "name": "Devolver decisión", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [2400, 0],
      "parameters": {"assignments": {"assignments": [{"id": "d7400001", "name": "id", "value": "={{ $json.id }}", "type": "string"}, {"id": "d7400002", "name": "estado", "value": "={{ $json.estado }}", "type": "string"}, {"id": "d7400003", "name": "decidida_por", "value": "={{ $json.decidida_por }}", "type": "string"}, {"id": "d7400004", "name": "texto_final", "value": "={{ $json.texto_final }}", "type": "string"}, {"id": "d7400005", "name": "huella", "value": "={{ $json.huella }}", "type": "string"}]}, "options": {}}
    }
  ],
  "connections": {
    "Desde otro flujo": {"main": [[{"node": "Registrar pendiente", "type": "main", "index": 0}]]},
    "Registrar pendiente": {"main": [[{"node": "Armar mensaje", "type": "main", "index": 0}]]},
    "Armar mensaje": {"main": [[{"node": "¿Enviar ahora?", "type": "main", "index": 0}]]},
    "¿Enviar ahora?": {"main": [[{"node": "¿Por WhatsApp?", "type": "main", "index": 0}], [{"node": "Esperar decisión", "type": "main", "index": 0}]]},
    "¿Por WhatsApp?": {"main": [[{"node": "Enviar WhatsApp", "type": "main", "index": 0}], [{"node": "Publicar en Slack", "type": "main", "index": 0}]]},
    "Enviar WhatsApp": {"main": [[{"node": "Esperar decisión", "type": "main", "index": 0}]]},
    "Publicar en Slack": {"main": [[{"node": "Esperar decisión", "type": "main", "index": 0}]]},
    "Esperar decisión": {"main": [[{"node": "Leer estado", "type": "main", "index": 0}]]},
    "Leer estado": {"main": [[{"node": "¿Decidida?", "type": "main", "index": 0}]]},
    "¿Decidida?": {"main": [[{"node": "Devolver decisión", "type": "main", "index": 0}], [{"node": "¿Caducó?", "type": "main", "index": 0}]]},
    "¿Caducó?": {"main": [[{"node": "Marcar caducada", "type": "main", "index": 0}], [{"node": "Subir escalón", "type": "main", "index": 0}]]},
    "Marcar caducada": {"main": [[{"node": "Devolver decisión", "type": "main", "index": 0}]]},
    "Subir escalón": {"main": [[{"node": "Armar mensaje", "type": "main", "index": 0}]]}
  },
  "settings": {"executionOrder": "v1", "timezone": "America/Mexico_City", "saveDataSuccessExecution": "all", "errorWorkflow": "Xe7rTb4mKq9wPz2G"},
  "pinData": {},
  "meta": {"templateCredsSetupCompleted": true}
}

Algunas decisiones del flujo merecen nombre. Registrar pendiente guarda en la tabla la dirección de reanudación de la ejecución ($execution.resumeUrl), que nunca viaja en un botón: el workflow que recibe la pulsación la lee de la base después de verificar la firma. Armar mensaje decide el destino con dos reglas (las publicaciones van a Don Ernesto; el resto, a Valeria por Slack, después a Valeria por WhatsApp y desde el nivel $2$ a Don Ernesto), arma el resumen de cambios, elige entre mensaje interactivo y plantilla según ventana_abierta y marca enviar como falso fuera del horario de 7:00 a 20:00, de modo que de noche el flujo espera sin escribir a nadie. Esperar decisión es un nodo Wait con reanudación por webhook y un límite de $30$ minutos: si alguien decide, la reanudación lo despierta al instante; si no, despierta solo a los $30$ minutos y el bucle sube un escalón. ¿Caducó? compara el plazo en la base y Marcar caducada repite la condición estado = 'pendiente', de modo que una decisión que llega en el último segundo gana a la caducidad.

Hay una carrera que el diseño acepta en lugar de resolver con complejidad. Si una persona decide justo mientras el flujo está entre Leer estado y Subir escalón, la reanudación no encuentra al flujo esperando. Subir escalón solo suma un nivel si la aprobación sigue pendiente, y Armar mensaje no envía nada si ya no lo está, así que en el peor caso el flujo espera $30$ minutos más antes de leer la decisión y devolverla. Es una demora rara y acotada a cambio de un flujo que se entiende de un vistazo.

El último bloque lee el JSON del workflow y el del mensaje del Bloque 5 como lo haría una revisión de código: comprueba los límites del mensaje interactivo, cuenta nodos y conexiones, busca destinos inexistentes, nodos sin entrada o sin salida, claves, teléfonos que no sean de prueba y webhooks de Slack, y dibuja el flujo con la paleta de la serie, con el nodo de espera como hexágono gris de paso humano:

python
# Lectura del workflow TL Aprobaciones v1 y del mensaje del Bloque 5, revisión y dibujo en Python:
workflow = json.loads((carpeta / 'flujos' / 'n8n' / 'aprobaciones_v1.json').read_text(encoding = 'utf-8'))
mensaje = json.loads((carpeta / 'mensaje_interactivo_don_ernesto.json').read_text(encoding = 'utf-8'))

botones = mensaje['interactive']['action']['buttons']
print('Mensaje: botones', len(botones), '(máx. 3) | títulos', [len(b['reply']['title']) for b in botones],
      '(máx. 20) | ids únicos:', len({b['reply']['id'] for b in botones}) == len(botones),
      '| cuerpo', len(mensaje['interactive']['body']['text']), '(máx. 1024) | pie',
      len(mensaje['interactive']['footer']['text']), '(máx. 60)')

FORMAS = {'executeWorkflowTrigger': ('disparador', '#1f9d55'), 'postgres': ('accion', '#2a78d6'),
          'code': ('accion', '#2a78d6'), 'httpRequest': ('accion', '#2a78d6'), 'set': ('accion', '#2a78d6'),
          'if': ('decision', '#eb6834'), 'wait': ('humano', '#6b6a66')}

def nodos_desde_n8n(wf):
    nodos = [{'id': n['name'], 'tipo': n['type'].split('.')[-1], 'x': n['position'][0] / 100,
              'y': -n['position'][1] / 100} for n in wf['nodes']]
    aristas = [(o, d['node'], salida) for o, s in wf['connections'].items()
               for tipo, lista in s.items() for salida, destinos in enumerate(lista) for d in destinos]
    return nodos, aristas

def borde(x, y, dx, dy, hw = 0.95, hh = 0.48):
    # Punto del borde de la caja centrada en (x, y) en la dirección (dx, dy):
    t = min(hw / abs(dx) if dx else 1e9, hh / abs(dy) if dy else 1e9)
    return x + dx * t, y + dy * t

def dibujar_flujo(nodos, aristas, titulo):
    fig, ax = plt.subplots(figsize = (15, 5.4))
    pos = {n['id']: (n['x'], n['y']) for n in nodos}
    tipos = {n['id']: n['tipo'] for n in nodos}
    for n in nodos:
        forma, color = FORMAS[n['tipo']]
        x, y = n['x'], n['y']
        if forma == 'decision':
            ax.add_patch(Polygon([(x - 1.0, y), (x, y + 0.6), (x + 1.0, y), (x, y - 0.6)], facecolor = color,
                                 edgecolor = 'white', linewidth = 2))
        elif forma == 'humano':
            ax.add_patch(RegularPolygon((x, y), 6, radius = 0.72, orientation = np.pi / 6, facecolor = color,
                                        edgecolor = 'white', linewidth = 2))
        else:
            estilo = 'round,pad=0.05,rounding_size=0.35' if forma == 'disparador' else 'round,pad=0.05'
            ax.add_patch(FancyBboxPatch((x - 0.9, y - 0.38), 1.8, 0.76, boxstyle = estilo,
                                        facecolor = color, edgecolor = 'white', linewidth = 2))
        etiqueta = textwrap.fill(n['id'], 10 if forma in ('decision', 'humano') else 12, break_long_words = False)
        ax.text(x, y, etiqueta, ha = 'center', va = 'center', color = 'white', fontsize = 8.5)
        ax.text(x, y - (0.72 if forma in ('decision', 'humano') else 0.56), n['tipo'], ha = 'center', va = 'center',
                color = '#52514e', fontsize = 7)
    for o, d, salida in aristas:
        (x1, y1), (x2, y2) = pos[o], pos[d]
        dx, dy = x2 - x1, y2 - y1
        inicio, fin = borde(x1, y1, dx, dy), borde(x2, y2, -dx, -dy)
        atras = dx < -0.01 or (abs(dx) < 0.01 and dy > 0)          # vuelta del bucle de escalado
        ax.add_patch(FancyArrowPatch(inicio, fin, arrowstyle = '-|>', mutation_scale = 13,
                                     color = '#eb6834' if atras else '#52514e', linewidth = 1.4))
        if tipos[o] == 'if':
            ax.text(inicio[0] + 0.12 * np.sign(dx or 1), inicio[1] + (0.18 if dy >= 0 else -0.3),
                    'sí' if salida == 0 else 'no', fontsize = 9, color = '#52514e')
    xs, ys = [p[0] for p in pos.values()], [p[1] for p in pos.values()]
    ax.set_xlim(min(xs) - 1.1, max(xs) + 1.1)
    ax.set_ylim(min(ys) - 1.0, max(ys) + 0.9)
    ax.set_aspect('equal')
    ax.axis('off')
    ax.set_title(titulo, fontsize = 14)
    return ax

nodos, aristas = nodos_desde_n8n(workflow)
nombres = {n['id'] for n in nodos}
print('Nodos:', len(nodos), '| conexiones:', len(aristas))
print('Tipos:', pd.Series([n['tipo'] for n in nodos]).value_counts().to_dict())
print('Destinos inexistentes:', [d for _, d, _ in aristas if d not in nombres] or 'ninguno')
print('Nodos sin entrada:', sorted(nombres - {d for _, d, _ in aristas}),
      '| sin salida:', sorted(nombres - {o for o, _, _ in aristas}))
espera = next(n for n in workflow['nodes'] if n['type'].endswith('.wait'))['parameters']
print('Espera:', espera['resume'], espera['resumeAmount'], espera['resumeUnit'])
texto_json = json.dumps(workflow, ensure_ascii = False) + json.dumps(mensaje)
print('Claves, teléfonos reales o webhooks de Slack:',
      re.findall(r'sk-ant-[\w-]+|xox[bp]-[\w-]+|hooks\.slack\.com/services/\w+|\+?52\d{10}(?<!5255000000\d\d)', texto_json)
      or 'ninguno')
dibujar_flujo(nodos, aristas, f"{workflow['name']}, leído del JSON exportado")
plt.tight_layout()
plt.show()
Mensaje: botones 3 (máx. 3) | títulos [7, 8, 13] (máx. 20) | ids únicos: True | cuerpo 374 (máx. 1024) | pie 56 (máx. 60)
Nodos: 14 | conexiones: 17
Tipos: {'postgres': 4, 'if': 4, 'httpRequest': 2, 'executeWorkflowTrigger': 1, 'code': 1, 'wait': 1, 'set': 1}
Destinos inexistentes: ninguno
Nodos sin entrada: ['Desde otro flujo'] | sin salida: ['Devolver decisión']
Espera: webhook 30 minutes
Claves, teléfonos reales o webhooks de Slack: ninguno

Gráfica: TL Aprobaciones v1. Lectura del workflow TL Aprobaciones v1 y del mensaje del Bloque 5, revisión y dibujo

El mensaje cumple los límites: $3$ botones de $3$, títulos de $7$, $8$ y $13$ caracteres (el máximo es $20$), identificadores únicos, un cuerpo de $374$ caracteres de $1,024$ y un pie de $56$ de $60$. El workflow tiene $14$ nodos y $17$ conexiones, ningún destino inexistente, una sola entrada (Desde otro flujo) y una sola salida (Devolver decisión), que es la forma que debe tener un sub-workflow: se entra por un lado y se sale por el otro, pase lo que pase en medio. No hay claves ni teléfonos reales, y la espera es de $30$ minutos con reanudación por webhook. En el dibujo se ve el bucle naranja del escalado, que vuelve de ¿Caducó? a Subir escalón y de ahí a Armar mensaje, y el hexágono gris donde el flujo hace lo único que no puede hacer solo: esperar a una persona.

Consideraciones finales


El martes, una pieza con “el mejor pan de la ciudad” llegó a la cola de publicación porque “aprobado” significaba cosas distintas en cada flujo. El miércoles, la misma pieza volvió a pasar por TL Aprobaciones v1 como prueba y Don Ernesto la rechazó desde su teléfono en $18$ minutos, con un motivo que acabó corrigiendo el manual de voz. En medio, Trigo Lento ganó una política escrita de qué se aprueba, una tabla que hace cumplir las reglas aunque alguien intente saltárselas, dos canales con la misma cadena de confianza y unas métricas que dicen si el bucle sigue supervisando o ya solo sella. Una aprobación vale lo que vale el registro de quién la dio, con qué texto y antes de qué plazo; todo lo demás es un botón.

Antes de poner a una persona en el bucle de su propio negocio, conviene hacerse algunas preguntas:

  • ¿Está escrito, en reglas que se pueden revisar, qué acciones necesitan aprobación y por qué, o lo decide cada flujo a su manera?
  • ¿La base de datos impide aprobar algo ya decidido o vencido, o lo impide solo la buena voluntad del flujo?
  • ¿La persona que aprueba ve lo que cambió y tiene lo necesario para decidir en el propio mensaje, en su horario y por un canal que aceptó?
  • ¿Sé cuántas aprobaciones se deciden más rápido de lo que se tarda en leerlas, y qué hago cuando esa cifra sube?

Las cifras de hoy tienen límites claros. Los tiempos de decisión, la probabilidad de ausencia de Valeria, las tasas de rechazo y de edición y la fracción de vistazos son supuestos declarados, no mediciones; la simulación de doce semanas de rechazos usa probabilidades inventadas y una prueba de Fisher sobre $54$ borradores por periodo, que es poco. Los volúmenes ($222$ aprobaciones) son los de la serie. Lo que no depende de esos supuestos es el método: puntuar y ordenar las acciones, guardar la aprobación como estado, hacer cumplir las transiciones en la base, medir la cola y no solo la mediana, y etiquetar cada rechazo.

Un gran libro que habla de esto es The Field Guide to Understanding ‘Human Error’, en el que Sidney Dekker explica por qué, cuando algo sale mal en un sistema con personas y máquinas, la pregunta útil no es “¿quién se equivocó?” sino “¿por qué tenía sentido para esa persona hacer lo que hizo?”. La vía rápida del martes es un ejemplo de manual: Valeria la abrió para ahorrar tiempo con los avisos del día y tenía todo el sentido; el error estaba en un diseño que permitía que un atajo local se saltara una regla global. Dekker escribe para la aviación y los hospitales, pero sus capítulos sobre la automatización y la supervisión se leen como si hablaran de una panadería con cinco flujos.

Don Ernesto leyó la Tabla 4 por la noche, en la mesa de la cocina, y se detuvo en la columna de escaladas. “Once veces al mes, y casi siempre cuando usted está en el banco. Eso lo aguanto. Y las publicaciones, que me lleguen a las siete, como hoy, y si no contesto, que esperen a la mañana.” Después pidió ver el mensaje del rechazo una vez más. “Y que conste en su tabla que el mejor pan de la ciudad lo dirá la gente.” Constaba: superlativo o no comprobable, 19:20, Don Ernesto.

Conclusiones clave


Qué aprobar

  • La aprobación se decide por acción con $S = P \cdot I$ y cuatro reglas en orden: lo irreversible y caro lo ejecuta una persona, lo que tiene $S \ge 6$ o un texto nuevo con el nombre de la panadería se aprueba, lo que repite un texto aprobado actúa y avisa, y lo demás es autónomo.
  • De $2,043$ acciones al mes, $222$ entran al bucle (correos, pasteles, publicaciones y quejas), $21$ las ejecuta Valeria y $1,800$ actúan sin aprobación previa; la carga es de $3.7$ horas a un minuto por aprobación y $4.90$ horas con los minutos reales por tipo.
  • “Lo que lleve el nombre de la panadería, lo veo yo” se traduce en firmar las fuentes y los textos nuevos, no cada respuesta repetida: las $600$ respuestas frecuentes se revisan por muestra.

Cómo aprobar

  • La aprobación es un estado persistente en SQLite: pendiente, aprobada, rechazada o caducada, con el escalado como contador; decide el primero porque cada UPDATE exige estado = 'pendiente' y el plazo vigente, y un disparador impide tocar los estados finales y el historial.
  • Valeria decide en Slack con botones firmados (v0) y edición con texto final y huella propia; Don Ernesto, en WhatsApp con tres botones de respuesta dentro de la ventana de 24 horas o una plantilla de utilidad fuera de ella, con su consentimiento escrito y de 7:00 a 20:00.
  • TL Aprobaciones v1 es el único camino: $14$ nodos, una entrada y una salida, la dirección de reanudación guardada en la base y nunca en un botón, y un Wait de $30$ minutos que escala a Valeria y luego a Don Ernesto.

Medir y aprender

  • El escalado existe para la Valeria ausente: la fórmula da un $7.35%$ de escaladas y la simulación un $7.72%$, unas $11$ a $14$ al mes; baja el p95 de $98.6$ a $68.3$ minutos y deja $0$ caducadas.
  • Contra la fatiga de aprobación: diferencias resaltadas con difflib (parecido $0.708$ en la pieza de la hogaza, con alerta por superlativo), vigilancia de las aprobaciones más rápidas que la lectura ($11.3%$) y muestras de $30$ por semana, que ven un error del $2%$ solo el $45.5%$ de las veces.
  • Los rechazos con motivo obligatorio corrigen el manual de voz: con la regla del $30%$, superlativos y tono dispararon el v4, y el rechazo de publicaciones bajó de $13$ de $54$ a $4$ de $54$ ($p = 0.016$ con Fisher).

Mañana, en el Día 75: Cuadros de mando de las automatizaciones: métricas, costes y errores. Don Ernesto preguntará “¿esto está funcionando?” y Valeria tendrá que abrir cinco pestañas para contestar; con las métricas de aprobación de hoy, los registros de Make, n8n y Claude y un presupuesto de errores por flujo, construirá un panel que responde en un minuto si todo funciona, cuánto cuesta y dónde falla.

Referencias


  1. Parasuraman, R., Sheridan, T. B. y Wickens, C. D. (2000). A Model for Types and Levels of Human Interaction with Automation. IEEE Transactions on Systems, Man, and Cybernetics, Part A: Systems and Humans, 30(3), 286-297. https://doi.org/10.1109/3468.844354 (revisado en octubre de 2026).
  2. Bainbridge, L. (1983). Ironies of Automation. Automatica, 19(6), 775-779. https://doi.org/10.1016/0005-1098(83)90046-8 (revisado en octubre de 2026).
  3. Slack (2026). Verificación de peticiones de Slack e interactividad. Documentación para desarrolladores. https://docs.slack.dev/authentication/verifying-requests-from-slack (revisado en octubre de 2026).
  4. Meta (2026). Mensajes interactivos de WhatsApp: botones de respuesta. Documentación de la plataforma de mensajería para empresas. https://developers.facebook.com/documentation/business-messaging/whatsapp (revisado en octubre de 2026).
  5. Dekker, S. (2014). The Field Guide to Understanding ‘Human Error’ (3.ª ed.). Ashgate.
  6. Wilson, E. B. (1927). Probable Inference, the Law of Succession, and Statistical Inference. Journal of the American Statistical Association, 22(158), 209-212.
comments powered by Disqus