Saltar a contenido

Chat híbrido

Esta página explica por qué existen dos motores de chat en Camarero IA, cómo conviven sin romper el contrato hacia el frontend y qué decide cada pieza. Es una explicación de arquitectura (Diátaxis = explanation): no es un tutorial paso a paso ni una referencia exhaustiva de la API.

Términos clave

  • Motor legacy: el chatbot original, que envía cada turno al LLM con todo el contexto y deja que el modelo decida e improvise.
  • Motor híbrido: un árbol de decisión determinista (datos en BD) más un clasificador de intents; el LLM solo redacta.
  • Seam (costura): el punto único donde se conmuta entre ambos motores.
  • Frame SSE: un evento del flujo Server-Sent Events que el backend emite mientras genera la respuesta (content, : thinking, done, recommendation, error).

Por qué dos motores

El motor legacy manda el historial completo al LLM en cada mensaje. Esto encarece cada conversación y produce respuestas no deterministas justo en los datos que más importan: alérgenos, precios y disponibilidad. El motor híbrido mueve esas decisiones a lógica determinista alimentada por la BD y reserva el LLM para humanizar el texto.

La migración es de bajo riesgo por diseño: ambos motores coexisten en producción y se conmutan por restaurante con un único flag. Si el híbrido falla, el sistema cae al legacy de forma automática.

Ver también: Arquitectura hexagonal.


Conmutación por restaurants.chat_mode

Cada restaurante tiene una columna restaurants.chat_mode con dos valores:

Valor Motor Comportamiento
legacy (por defecto) Legacy 100% LLM: el modelo lee tags y decide cada turno.
hybrid Híbrido Árbol de decisión + clasificador de intents; el LLM solo redacta.

El rollback es instantáneo: basta devolver el flag a legacy. No requiere despliegue.

El seam: ModeDispatchingChatAiResponder

El punto único de conmutación es ModeDispatchingChatAiResponder, en app/modules/chat/infrastructure/external/mode_dispatcher.py. Lee chat_mode y enruta el turno al motor correspondiente.

Fail-open a legacy

El seam es fail-open: ante cualquier error al resolver o arrancar el motor híbrido, cae al motor legacy. La política es no dejar nunca a un cliente sin respuesta por un fallo del stack nuevo.

engine.py legacy está CONGELADO

El motor legacy vive en app/modules/chat/infrastructure/ai/engine.py y está congelado a propósito: no se refactoriza ni se extrae lógica de él. El stack híbrido (chat/infrastructure/flows/ + chat/domain/flows/) es una reescritura limpia; la duplicación temporal de la lógica de IO es deliberada. Cuando el legacy se retire, engine.py se borra entero.

Reuso permitido del legacy

El intérprete híbrido sí reutiliza, en modo solo lectura, helpers del legacy para construir prompts con paridad exacta: ContextBuilder y PromptBuilder (chat/infrastructure/ai/context_builder.py) y las funciones _build_state_snapshot_message y _history_message_to_api de engine.py. Reusar no es lo mismo que modificar.


El árbol es DATA, no código

El árbol de decisión del motor híbrido no está en el código: es un documento JSON almacenado en la tabla conversation_trees. Hay un árbol activo por restaurante (índice parcial único). Esto permite editar el flujo conversacional sin tocar Python.

  • Interpretación: lo recorre TreeInterpreter (app/modules/chat/infrastructure/flows/interpreter.py).
  • Siembra automática: al primer mensaje en modo hybrid sin árbol activo, se siembra default_trees/hybrid_v1.json.
  • Edición: la API admin /api/v1/admin/conversation-trees expone el árbol para un editor visual (React Flow).

Qué hace TreeInterpreter

TreeInterpreter recorre el árbol para un mensaje de usuario y emite frames SSE como async generator (método run). El árbol orquesta; las acciones, los checks registrados y el cliente LLM ejecutan.

Tipos de nodo que el intérprete sabe recorrer (ver el match/case en _walk):

Nodo Función
EntryNode Punto de entrada del árbol.
StateCheckNode Bifurca según un check registrado (get_check).
PhaseSwitchNode Bifurca según la fase de la conversación.
ClassifyNode Invoca al clasificador de intents y bifurca por intent.
ConditionNode Evalúa una expresión contra la raíz de contexto.
ActionNode Ejecuta una acción registrada (get_action), con timeout.
LlmStreamNode Llama al LLM para redactar/narrar (puede re-enrutar).
ExecuteTagsNode Aplica las operaciones de carrito y coursing parseadas de tags.
PhaseTransitionNode Transiciona la fase de la conversación (con guardas).
PublishEventNode Publica un evento de dominio por el bus.
SetStateNode Escribe estado persistente del árbol.

El intérprete tiene un tope de pasos (settings.interpreter_max_steps): si lo supera, lanza FlowExecutionError para evitar bucles.

El estado del árbol (ctx["state"]) se persiste entre turnos en conversations.metadata_json["tree_state"], y solo se reescribe cuando cambia.


El clasificador de intents (kNN)

El nodo ClassifyNode invoca a un clasificador que devuelve (intent, confidence). Internamente es un kNN sobre embeddings (ver Embeddings Granite).

Comportamiento del enrutado (_exec_classify):

  • Si el clasificador no está disponible o falla, se va al fallback del nodo (nunca rompe el turno).
  • Si la confianza es menor que min_confidence del nodo, también va a fallback.
  • En otro caso, bifurca por branches[intent] (o fallback si el intent no tiene rama).

Intent único pedir_producto; drink-vs-food por course_type

Existe un solo intent para pedir cosas: pedir_producto. La distinción entre bebida y comida no la hace el clasificador, sino la lógica determinista a partir del course_type del producto. Otros intents reconocidos incluyen consulta_carta, consulta_plato, consulta_alergeno, consulta_local, consultar_pedido, recomendacion, modificar_pedido, cancelar_item, confirmar_pedido, pedir_cuenta y marchar_curso (lista usada también por el re-enrutado del fallback).

Umbral (0.75) sin calibrar

El umbral del clasificador (~0.75) aún no está calibrado. El shadow mode escribe intent_predicted e intent_confidence en chat_logs en ambos motores. Hay que calibrar con esos datos antes de promover pedir_producto a una rama determinista dura (hoy va por fallback).

Re-enrutado desde el fallback LLM

El nodo de chat libre (llm_actual) puede emitir una etiqueta <reroute intent="X"/>. Cuando lo hace, el intérprete bufferea la salida (no la transmite token a token, para que la etiqueta no parpadee en la UI) y, si el intent es válido, cede el turno a la rama determinista correspondiente reutilizando el mapa intent → nodo del propio ClassifyNode (_reroute_target). Los tokens de esa llamada de enrutado se atribuyen para contabilidad de coste (router_tokens).


El recomendador: el motor decide, el LLM redacta

El recomendador separa decisión de redacción:

  • El motor decide qué platos recomendar (capas 0-2, con artefactos precalculados en recommendation_artifacts).
  • El LLM solo escribe una breve introducción. El nodo rec_llm ya no enumera platos: el detalle viaja en tarjetas estructuradas.

El recómputo de artefactos es fuera de proceso (no hay scheduler in-process, a propósito):

python -m app.worker.runner recompute-recs

o vía POST /api/v1/admin/recommendations/recompute.


El contrato SSE del intérprete

El intérprete híbrido emite el mismo contrato SSE que el legacy, más un frame propio aditivo. Esto es lo que permite que el frontend funcione igual con ambos motores.

Frame Forma Cuándo
content data: {"type":"content","data":"<token>"} Por cada token de texto del LLM.
: thinking comentario SSE : thinking\n\n Keepalive mientras el modelo razona.
recommendation data: {"type":"recommendation","data":[...]} Solo motor híbrido, justo antes de done, si hay recomendaciones.
done data: {"type":"done","data":{...}} Cierre del turno con metadatos y acciones.
error data: {"type":"error","data":"<msg>"} Ante fallo del intérprete.

El frame done lleva, entre otros campos: content, latency_ms, model, phase, message_id, prompt_tokens/completion_tokens, cart_actions, course_actions, confirm_order, suggestions y reply_source.

model=\"deterministic\"

En los turnos resueltos sin LLM (rama 100% determinista o LLM no configurado), done.model vale deterministic. Cuando narra el LLM, vale el modelo real (o fallback).

El frame recommendation es aditivo: se construye desde ctx["recs"] y se recorta a los campos que necesita la tarjeta del frontend (product_id, name, price en céntimos, reason, description, image_url, is_available).

Persistir las tarjetas en extra_data (no solo en done)

El frame done lo consume quien hace el stream, pero el evento chat_message que se publica por el bus post-stream suele ganar la carrera y ser quien crea el mensaje en el store del front. Por eso suggestions y recommendations se persisten también en chat_messages.extra_data (en interpreter._finish), y de ahí los mapea el frontend. Si un dato estructurado por mensaje viajara solo en done, el front lo perdería en la mayoría de los turnos.


Fase 5: señales de "control de sala" en el prompt

La Fase 5 inyecta dos señales del dashboard de manager ("control de sala") en el prompt del motor, calibrando lo que el camarero virtual recomienda y cómo habla. Ambas son best-effort: si los gateways del manager no están disponibles, se cae a un estado neutro y el chat nunca se rompe.

Cocina saturada (kitchen_overloaded)

ContextBuilder._read_kitchen_load lee el flag de saturación de cocina por restaurante. Cuando está activo, PromptBuilder._build_kitchen_overload_section añade una sección ## KITCHEN STATUS al prompt que instruye al modelo a priorizar platos rápidos o fríos, evitar recomendar platos largos y avisar con tacto de posibles demoras. Puede incluir una nota libre del manager (kitchen_overload_note).

Perfil de mesa (table_profile)

ContextBuilder._read_table_profile lee el perfil conductual de la mesa a partir del session_token. Los valores son executive_lunch, social_leisure o None. PromptBuilder._build_personality_section añade una guía de tono según el perfil (_TABLE_PROFILE_TONE):

  • executive_lunch: clientes con prisa → directo y decisivo, sin charla, recomendaciones rápidas, respuestas cortas.
  • social_leisure: clientes relajados → cálido y sin prisa, sugiere maridajes, deja respirar la conversación.

Estas señales solo aplican a los nodos que usan el prompt de paridad completo (los nodos que parsean tags operativos o el centinela por fase). Los nodos puramente humanizadores usan un prompt mínimo y no las reciben.


Proxy LLM local y embeddings

Proxy LLM (DeepSeek, thinking off)

El motor híbrido habla con un proxy LLM local (DeepSeek) a través de NvidiaLlmClient (app/modules/chat/infrastructure/flows/llm_client.py). El reasoning (thinking) está desactivado en todos los nodos: el árbol determinista ya decidió, así que el modelo solo narra. Esto reduce latencia y evita filtrar bloques <think>. Los nodos humanizadores limitan además el presupuesto de salida (max_tokens).

Seams de monkeypatch en tests

Los dos motores se mockean en puntos distintos a propósito: legacy → ChatService._stream_nvidia_completion; híbrido → NvidiaLlmClient.stream.

Embeddings Granite

Los embeddings los provee un singleton Granite (app/shared/infrastructure/granite_embeddings.py), compartido por el clasificador de intents y por la capa 2 del recomendador.

En tests, EMBEDDINGS_ENABLED=false

En tests siempre EMBEDDINGS_ENABLED=false (lo fija tests/conftest.py). Los tests stubean IntentClassifier.classify o usan embeddings falsos. Nunca dependas del modelo real en un test.


Resumen del flujo de un turno (modo hybrid)

  1. Llega el mensaje; el seam ModeDispatchingChatAiResponder lee chat_mode = hybrid y arranca TreeInterpreter (fail-open a legacy si algo falla).
  2. El intérprete construye el contexto (_build_ctx) y recorre el árbol desde entry.
  3. Un ClassifyNode clasifica el intent; las ramas deterministas resuelven datos (carrito, alérgenos, recomendaciones) contra la BD.
  4. Un LlmStreamNode redacta el texto final (sin thinking); los frames content se transmiten en vivo.
  5. Las acciones de carrito y coursing se aplican y se reflejan en cart_actions/course_actions.
  6. _finish persiste el mensaje del asistente (con extra_data), el ChatLog (con engine_mode="hybrid", tree_id, node_path) y el estado del árbol; emite el frame recommendation (si lo hay) y el frame done.

Páginas relacionadas: Arquitectura hexagonal.