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
hybridsin árbol activo, se siembradefault_trees/hybrid_v1.json. - Edición: la API admin
/api/v1/admin/conversation-treesexpone 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
fallbackdel nodo (nunca rompe el turno). - Si la confianza es menor que
min_confidencedel nodo, también va afallback. - En otro caso, bifurca por
branches[intent](ofallbacksi 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_llmya 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):
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)¶
- Llega el mensaje; el seam
ModeDispatchingChatAiResponderleechat_mode = hybridy arrancaTreeInterpreter(fail-open a legacy si algo falla). - El intérprete construye el contexto (
_build_ctx) y recorre el árbol desdeentry. - Un
ClassifyNodeclasifica el intent; las ramas deterministas resuelven datos (carrito, alérgenos, recomendaciones) contra la BD. - Un
LlmStreamNoderedacta el texto final (sin thinking); los framescontentse transmiten en vivo. - Las acciones de carrito y coursing se aplican y se reflejan en
cart_actions/course_actions. _finishpersiste el mensaje del asistente (conextra_data), elChatLog(conengine_mode="hybrid",tree_id,node_path) y el estado del árbol; emite el framerecommendation(si lo hay) y el framedone.
Páginas relacionadas: Arquitectura hexagonal.