Módulo 2: El patrón supervisor/router

Eligiendo tu router

Descripción

Las lecciones 03 y 04 construyeron los dos mecanismos por separado. La lección 06 mostró el determinista funcionando de punta a punta, con un ahorro real de una llamada frente al modelo. Esta lección los pone a trabajar juntos: un router híbrido que intenta las reglas primero —gratis, instantáneo— y solo cae al modelo cuando las reglas no encuentran nada. Vas a ejecutarlo sobre cuatro peticiones y confirmar el ahorro.

Pero esta lección no se detiene en el caso feliz. La lección 03 ya encontró algo importante: una regla puede matchear con confianza y estar mal, sin devolver nunca un None que active ningún fallback. Vas a ejecutar exactamente ese caso contra el router híbrido de esta lección y confirmar, con la salida real, que el híbrido más simple no lo resuelve — y vas a diseñar, en el Ejercicio 3, una mitigación puntual para ese límite concreto.

Conexión con el módulo

Esta lección es la síntesis de todo el módulo: usa route_deterministic (03) sin cambios, retoma el mecanismo de extracción de decisión de la lección 04 para el caso de fallback, y aplica el mismo vocabulario de costo (ROUTE_CALLS, llamadas al modelo) que sostiene la lección 06. El mini-proyecto de la lección 08 usa el router híbrido completo de esta lección sobre escenarios nuevos.


Comparación con números: determinista vs. decisión del modelo

DeterministaDecisión del modeloHíbrido (esta lección)
Costo, petición clara0 llamadas1 llamada0 llamadas
Costo, petición ambigua sin cobertura0 llamadas (pero falla o devuelve None)1 llamada (acierta)1 llamada (acierta)
Costo, petición ambigua con match falso0 llamadas (pero falla con confianza)1 llamada (acierta)0 llamadas (sigue fallando)
AuditableSí, al 100%ParcialParcial, en la parte que cae al modelo

La tercera fila es la que esta lección desarrolla en detalle — es la diferencia real entre "el híbrido resuelve todo" y "el híbrido resuelve lo que las reglas saben que no saben".


Ejemplo trabajado: route_hybrid, el caso feliz

from dataclasses import dataclass


@dataclass
class RoutingDecision:
    target: str
    reason: str
    method: str  # "rules" o "model"


POLICY_KEYWORDS = ("política", "no-show", "no me presento", "no llego", "cargo por")
PRICING_KEYWORDS = ("compara", " vs ", "más barata", "conviene")
BOOKING_KEYWORDS = ("cotiza", "reserva", "resérva", "agenda", "cancela")


def route_deterministic(text):
    t = text.lower()
    if any(kw in t for kw in POLICY_KEYWORDS):
        return "policy_agent"
    if any(kw in t for kw in PRICING_KEYWORDS):
        return "pricing_agent"
    if any(kw in t for kw in BOOKING_KEYWORDS):
        return "booking_agent"
    return None


# Guion CONCEPTO (claude-sonnet-5): la decisión que tomaría el modelo cuando
# route_deterministic no encuentra NINGUNA coincidencia -- el único caso en
# que este route_hybrid consulta al modelo.
CONCEPT_FALLBACK_DECISIONS = {
    "Hola, ¿tienen wifi en las salas?": (
        "booking_agent",
        "es una pregunta general sobre las salas, más cercana al dominio "
        "de booking_agent que a política o precios -- aunque ninguna tool "
        "actual la responde del todo",
    ),
}


def route_hybrid(text):
    """Ruteo híbrido: primero intenta reglas (gratis, 0 llamadas al modelo).
    Si no matchea nada, cae a la decisión del modelo (concepto, 1 llamada)."""
    target = route_deterministic(text)
    if target is not None:
        return RoutingDecision(target=target, reason="coincidencia de palabra clave", method="rules")
    target, reason = CONCEPT_FALLBACK_DECISIONS[text]
    return RoutingDecision(target=target, reason=reason, method="model")


REQUESTS = [
    "Cotiza Focus pro 3h y resérvala para Ana.",
    "¿Qué pasa si no me presento a mi reserva?",
    "Compara el precio de Focus, Studio y Boardroom, todos pro, 3h.",
    "Hola, ¿tienen wifi en las salas?",
]

print("--- route_hybrid sobre 4 peticiones ---")
total_model_calls_for_routing = 0
for text in REQUESTS:
    decision = route_hybrid(text)
    cost = 0 if decision.method == "rules" else 1
    total_model_calls_for_routing += cost
    print(f"[{decision.method:5} | {cost} llamada(s)] {decision.target:15} <- {text}")

print()
print(f"llamadas al modelo solo para rutear estas 4 peticiones: {total_model_calls_for_routing}")
print("(3 se resolvieron gratis con reglas; 1 no matcheó ninguna y cayó al modelo)")

Qué esperar:

--- route_hybrid sobre 4 peticiones ---
[rules | 0 llamada(s)] booking_agent   <- Cotiza Focus pro 3h y resérvala para Ana.
[rules | 0 llamada(s)] policy_agent    <- ¿Qué pasa si no me presento a mi reserva?
[rules | 0 llamada(s)] pricing_agent   <- Compara el precio de Focus, Studio y Boardroom, todos pro, 3h.
[model | 1 llamada(s)] booking_agent   <- Hola, ¿tienen wifi en las salas?

llamadas al modelo solo para rutear estas 4 peticiones: 1
(3 se resolvieron gratis con reglas; 1 no matcheó ninguna y cayó al modelo)

Tres de cuatro peticiones se resuelven completamente gratis. Solo la que las reglas reconocen que no saben resolver —la que devuelve None— consume una llamada al modelo. Este es el comportamiento ideal del híbrido: paga el costo solo cuando hace falta.


El límite real: una regla confiada nunca cae al fallback

print("--- el límite del fallback: una regla CONFIADA pero incorrecta no cae al modelo ---")
tricky = "Necesito cancelar porque no voy a poder llegar a mi reserva de mañana, ¿me cobran algo?"
decision_tricky = route_hybrid(tricky)
print(f"texto: {tricky!r}")
print(f"route_hybrid -> método={decision_tricky.method!r}, target={decision_tricky.target!r}")

Qué esperar:

--- el límite del fallback: una regla CONFIADA pero incorrecta no cae al modelo ---
texto: 'Necesito cancelar porque no voy a poder llegar a mi reserva de mañana, ¿me cobran algo?'
route_hybrid -> método='rules', target='booking_agent'

route_hybrid responde método='rules', no 'model'. Esto no es un bug del código — es la consecuencia exacta de cómo está escrita la función: route_deterministic devuelve 'booking_agent' con total confianza (matchea "cancela" y "reserva"), así que route_hybrid nunca llega a preguntarse si esa respuesta está bien. El if target is not None de la función solo verifica que haya una respuesta — no que esa respuesta sea la correcta. La lección 04 ya mostró que la respuesta correcta para esta petición es policy_agent; el router híbrido, tal como está escrito acá, sigue sin poder verlo.

Esta es la distinción central de esta lección. El fallback "si es None, preguntale al modelo" solo repara el modo de falla de cobertura (una palabra que nadie anticipó, como "wifi"). No repara el modo de falla de confianza equivocada (una palabra que sí está en la lista, pero apunta al especialista incorrecto para esta frase en particular). Son dos problemas distintos, y un híbrido ingenuo solo resuelve uno de los dos.


Cuándo cada mecanismo alcanza

  • Determinista solo, cuando el vocabulario es acotado, las categorías no comparten palabras clave ambiguas, y el volumen es alto —el costo cero escala sin límite—.
  • Híbrido con fallback en None, cuando además existe una franja real de peticiones con vocabulario genuinamente no anticipado, pero las reglas que sí tienes son confiables cuando matchean.
  • Decisión del modelo siempre, cuando el vocabulario es tan variado, o el costo de una mala ruta es tan alto, que ni siquiera confías en las reglas que sí matchean — el Ejercicio 3 de esta lección construye una variante que fuerza esto para verbos puntuales de alto riesgo.

Ninguna de las tres es "la correcta" en abstracto — depende de qué proporción de tu tráfico real se parece al caso feliz de esta lección, y qué proporción se parece al caso ambiguo.


Errores comunes

  1. Pensar que "híbrido" significa "cubre todos los casos". Esta lección demostró, con salida real, que no es así — un híbrido con fallback en None solo cubre el modo de falla de cobertura, no el de confianza equivocada.

  2. Confundir route_hybrid que devuelve method='rules' con "la decisión es correcta". El campo method describe qué mecanismo decidió, no si acertó. Verificar que algo se decidió por reglas no es lo mismo que verificar que la decisión estuvo bien.

  3. Pensar que agregar más palabras clave a las listas de la lección 03 resuelve este límite. No lo resuelve — el problema no es de cobertura de vocabulario, es que "cancela" y "reserva" son palabras genuinamente ambiguas en ciertos contextos. Ninguna cantidad de keywords adicionales elimina esa ambigüedad de raíz; hace falta un mecanismo distinto (Ejercicio 3).

  4. Olvidar medir cuánto CUESTA el fallback, no solo cuánto ayuda. El híbrido de esta lección gastó 1 llamada de las 4 peticiones — un ahorro del 75% frente a rutear todo por decisión del modelo. Ese número solo tiene sentido si también cuentas cuántas peticiones reales, en producción, terminan cayendo al fallback.

  5. Tratar el límite de esta lección como un fracaso del ruteo determinista, no como información útil. Saber exactamente cuándo un mecanismo falla —y por qué— es lo que permite diseñar la mitigación correcta (Ejercicio 3), en vez de descartar todo el enfoque determinista por un caso puntual.


Ejercicios

Ejercicio 1: Agrega un nuevo caso de fallback (Fácil)

Agrega "¿Aceptan mascotas en el Boardroom?" a CONCEPT_FALLBACK_DECISIONS con una decisión razonable (concepto), y confirma que route_hybrid lo resuelve por el método 'model'.

Ver solución
CONCEPT_FALLBACK_DECISIONS["¿Aceptan mascotas en el Boardroom?"] = (
    "booking_agent",
    "pregunta general sobre una sala específica -- no es transacción, "
    "política ni comparación de precio, pero booking_agent es el "
    "especialista más cercano al dominio de la pregunta",
)

decision = route_hybrid("¿Aceptan mascotas en el Boardroom?")
print(f"método={decision.method!r}, target={decision.target!r}")

Salida esperada:

método='model', target='booking_agent'

Explicación: ninguna de las tres listas de palabras clave de route_deterministic reconoce "mascotas" ni "aceptan", así que la función devuelve None y route_hybrid cae correctamente al fallback — el comportamiento ideal del híbrido, para el modo de falla de cobertura.

Ejercicio 2: Tally de método sobre 6 peticiones (Medio)

Ejecuta route_hybrid sobre estas 6 peticiones y cuenta cuántas se resolvieron por reglas y cuántas por el modelo: las 3 del ejemplo trabajado del caso feliz, más "Hola, ¿tienen wifi en las salas?", "Cancela mi reserva número 4." y "¿Aceptan mascotas en el Boardroom?" (del Ejercicio 1).

Ver solución
REQUESTS_6 = [
    "Cotiza Focus pro 3h y resérvala para Ana.",
    "¿Qué pasa si no me presento a mi reserva?",
    "Compara el precio de Focus, Studio y Boardroom, todos pro, 3h.",
    "Hola, ¿tienen wifi en las salas?",
    "Cancela mi reserva número 4.",
    "¿Aceptan mascotas en el Boardroom?",
]
tally = {"rules": 0, "model": 0}
for text in REQUESTS_6:
    d = route_hybrid(text)
    tally[d.method] += 1
    print(f"[{d.method:5}] {d.target:15} <- {text}")
print(f"tally: {tally}  (costo total de ruteo: {tally['model']} llamadas al modelo)")

Salida esperada:

[rules] booking_agent   <- Cotiza Focus pro 3h y resérvala para Ana.
[rules] policy_agent    <- ¿Qué pasa si no me presento a mi reserva?
[rules] pricing_agent   <- Compara el precio de Focus, Studio y Boardroom, todos pro, 3h.
[model] booking_agent   <- Hola, ¿tienen wifi en las salas?
[rules] booking_agent   <- Cancela mi reserva número 4.
[model] booking_agent   <- ¿Aceptan mascotas en el Boardroom?
tally: {'rules': 4, 'model': 2}  (costo total de ruteo: 2 llamadas al modelo)

Explicación: "Cancela mi reserva número 4." matchea "cancela" de BOOKING_KEYWORDS y se resuelve por reglas, correctamente esta vez —a diferencia del caso ambiguo de esta lección, acá la intención SÍ es cancelar una reserva puntual, así que booking_agent es la respuesta correcta—. De 6 peticiones, solo 2 costaron una llamada al modelo: 33% del tráfico, no el 100% que costaría rutear todo por decisión del modelo.

Ejercicio 3: Fuerza el fallback para verbos riesgosos (Difícil)

route_hybrid_v2 no confía en las reglas para peticiones que contienen "cancela"/"cancelar" —las manda siempre al modelo, sin importar si route_deterministic matchearía algo—. Implementala y confirma que resuelve el caso ambiguo de esta lección sin afectar una petición limpia.

Ver solución
RISKY_KEYWORDS = ("cancela", "cancelar")


def route_hybrid_v2(text):
    t = text.lower()
    if any(kw in t for kw in RISKY_KEYWORDS):
        # No confiamos en la regla sola para verbos ambiguos -- siempre
        # consultamos al modelo, aunque route_deterministic SÍ matchee.
        return RoutingDecision(target="NEEDS_MODEL_DECISION", reason="verbo riesgoso -- ambiguo entre acción y consulta", method="model")
    target = route_deterministic(text)
    if target is not None:
        return RoutingDecision(target=target, reason="coincidencia de palabra clave", method="rules")
    return RoutingDecision(target="NEEDS_MODEL_DECISION", reason="ninguna regla matcheó", method="model")


tricky = "Necesito cancelar porque no voy a poder llegar a mi reserva de mañana, ¿me cobran algo?"
clean = "Cotiza Focus pro 3h y resérvala para Ana."
print(f"route_hybrid_v2(tricky) -> {route_hybrid_v2(tricky)}")
print(f"route_hybrid_v2(clean)  -> {route_hybrid_v2(clean)}")

Salida esperada:

route_hybrid_v2(tricky) -> RoutingDecision(target='NEEDS_MODEL_DECISION', reason='verbo riesgoso -- ambiguo entre acción y consulta', method='model')
route_hybrid_v2(clean)  -> RoutingDecision(target='booking_agent', reason='coincidencia de palabra clave', method='rules')

Explicación: route_hybrid_v2 corrige el límite de esta lección con un principio simple: no todas las palabras clave merecen la misma confianza. "Cancela"/"cancelar" son verbos que aparecen tanto en acciones ("cancela mi reserva ya") como en preguntas sobre condiciones ("¿qué pasa si cancelo?"), así que la función los trata como una señal de riesgo, no como una regla confiable — fuerza el fallback aunque route_deterministic sí hubiera matchado algo. La petición limpia, sin ninguna palabra riesgosa, sigue resolviéndose gratis por reglas, sin ningún costo adicional. Esta es exactamente la clase de mitigación puntual que el error común 3 pedía: no eliminar el ruteo determinista, identificar con precisión qué palabras son confiables y cuáles no.


Resumen y siguiente paso

  • route_hybrid combina reglas (gratis) y decisión del modelo (concepto, 1 llamada): intenta primero route_deterministic, y solo cae al modelo cuando devuelve None.
  • Sobre 4 peticiones, el híbrido resolvió 3 gratis y 1 con una llamada al modelo — el comportamiento ideal, cuando el modo de falla es de cobertura.
  • El límite real, confirmado con salida ejecutada: sobre la petición ambigua de las lecciones 03/04, route_hybrid devolvió método='rules', no 'model' — el fallback "si es None" nunca se activa quando la regla matchea con confianza equivocada, no con silencio.
  • El Ejercicio 3 mostró una mitigación puntual: tratar ciertos verbos como "riesgosos" y forzar el fallback para ellos, sin sacrificar el ahorro en el resto del tráfico.

Con esto termina el Módulo 2. Construiste el primer patrón completo —supervisor/router— con sus dos mecanismos de decisión, medidos y comparados con números reales, y con sus límites reales, no idealizados.

Siguiente lección: 08 — Mini-proyecto: el supervisor de Reservo. Aplicas el router híbrido completo a cinco escenarios nuevos, con el dispatcher de punta a punta.


Recursos adicionales

  1. Anthropic — Building effective agents — El principio de combinar mecanismos simples y complejos según la señal, la base conceptual de un router híbrido.
  2. Anthropic — Multi-agent research system — Un caso real donde el costo de coordinar —medido, no asumido— determinó qué parte del sistema se automatizó con reglas y qué parte necesitó al modelo.
  3. Python — Diccionarios y funciones — La estructura detrás de CONCEPT_FALLBACK_DECISIONS y la lógica condicional de route_hybrid.
  4. Anthropic — Tool use (function calling) overview — El protocolo que sostiene la parte de "decisión del modelo" del híbrido, sin cambios desde la lección 04.