Recomendador¶
El recomendador de Camarero IA decide qué platos sugerir mediante un motor determinista de varias capas, y delega en el LLM únicamente la redacción del mensaje. Esta página explica esa separación de responsabilidades, por qué los artefactos se precalculan, cómo se recomputan y cómo se mide el resultado.
En una frase
El motor DECIDE, el LLM REDACTA. El ranking de productos sale de cálculo determinista sobre datos históricos; el modelo de lenguaje solo pone palabras bonitas alrededor de una lista que ya está decidida.
Por qué esta separación¶
Si dejáramos que el LLM eligiera los platos, cada respuesta sería impredecible, difícil de medir y dependiente de la latencia y disponibilidad del proveedor de IA. Al separar decisión de redacción:
- Las recomendaciones son reproducibles y auditables: dado el mismo estado, el motor produce el mismo ranking.
- El sistema degrada con elegancia: si el LLM falla, las tarjetas de producto siguen siendo correctas porque ya estaban decididas.
- Podemos medir la calidad del motor de forma aislada de la calidad de la redacción (ver Medición con behavior events).
El módulo vive en app/modules/recommendation/ y se comunica con el resto del
sistema solo a través de puertos (ADR-004), definidos en
app/modules/recommendation/domain/ports.py (IProductCatalog,
IRecommendationAIProvider, ICustomerDirectory) y
app/modules/recommendation/domain/artifacts.py (IArtifactRepository).
Dos caminos de recomendación¶
El módulo expone dos casos de uso distintos. No compiten: sirven a contextos diferentes.
| Caso de uso | Para qué | Qué usa |
|---|---|---|
GetHybridRecommendationsUseCase |
Recomendaciones deterministas dentro del chat híbrido, a partir de lo que la mesa ya tiene en el carrito. | Capas 0–2 sobre artefactos precalculados. Sin LLM. |
GetRecommendationsUseCase |
Endpoint cliente GET /recommendations: sugerencias por contexto (hora, comensales) y preferencias del cliente. |
Proveedor de IA (IRecommendationAIProvider, NVIDIA) con fallback heurístico. |
Ambos viven en app/modules/recommendation/application/use_cases/. El resto de
esta página se centra en el motor de capas (GetHybridRecommendationsUseCase),
que es el corazón del sistema; el camino con IA se resume en
Redacción por el LLM y
Endpoints cliente.
Las capas de decisión (0–2)¶
El motor combina varias capas. Cada una aporta una señal distinta; las superiores añaden contexto sin sustituir a las inferiores. El selector de capa es el estado de la mesa:
- Mesa vacía (carrito sin productos) → solo capa 0.
- Mesa con pedidos → capas 1 + 2, con fallback a la capa 0 si el histórico es demasiado fino para producir ranking.
| Capa | Qué hace | Dónde vive |
|---|---|---|
| 0 — Reglas/contexto | Puntúa candidatos por franja horaria (desayuno/comida/cena) y por tamaño de grupo (platos para compartir). Es el fallback heurístico cuando no hay señal histórica. | domain/services.py (fallback_suggestions, _score_product) |
| 1 — Co-ocurrencia | "Quien pidió X también pidió Y" sobre tickets históricos, con normalización por lift para que pan y agua no dominen. | domain/engine_v2.py (compute_cooccurrence) |
| 2 — Contenido (embeddings) | Ordena candidatos por similitud coseno entre su embedding y el perfil de la sesión (la media de lo ya pedido en la mesa). | domain/engine_v2.py (session_profile, cosine_rank) |
Capa 0 — heurística por franja y grupo¶
fallback_suggestions() puntúa cada producto con _score_product(): base 1.0,
+0.3 si el producto tiene un tag de la franja horaria actual y +0.2 si el
grupo es grande (num_guests >= GROUP_SHARING_THRESHOLD, que es 4) y el
producto está etiquetado como compartible. Las franjas y sus tags son constantes
de dominio:
- Desayuno
06:00–10:59→BREAKFAST_TAGS(breakfast,light,sweet). - Comida
11:00–14:59→LUNCH_TAGS(main,lunch,savory). - Cena (resto del día) →
DINNER_TAGS(dinner,hearty,main). - Compartir →
SHARING_TAGS(shareable,family,appetizer).
Los límites horarios (BREAKFAST_START_HOUR=6, LUNCH_START_HOUR=11,
DINNER_START_HOUR=15) viven en domain/constants.py; los tags son el enum
ProductTag (domain/value_objects.py).
Filtro por preferencias (alérgenos/dislikes)
El filtrado por alérgenos e ingredientes no deseados ocurre en
filter_products_by_preferences() (domain/services.py), que se aplica en el
camino con IA (GetRecommendationsUseCase) cuando hay un cliente
autenticado. El camino híbrido del chat parte de los productos
disponibles del catálogo y excluye lo ya pedido.
Capa 1 — co-ocurrencia con lift¶
compute_cooccurrence() recorre los tickets (una lista de product_id por
pedido histórico) y calcula, para cada par, su lift: la frecuencia conjunta
dividida por el producto de las frecuencias individuales (> 1 = aparecen juntos
más de lo que predeciría la independencia). Los pares vistos menos de
MIN_SUPPORT veces (3) se descartan como ruido.
Capa 2 — contenido por embeddings¶
session_profile() calcula la media de los embeddings de lo ya pedido en la
mesa, y cosine_rank() ordena los candidatos por similitud coseno a ese perfil.
Capa 2 usa embeddings Granite
Los vectores de la capa 2 los produce el servicio de embeddings Granite
(app/shared/infrastructure/granite_embeddings.py), un singleton compartido
con el clasificador de intents del chat híbrido (get_embedding_service()).
El documento que se vectoriza por producto es nombre + descripción +
ingredientes + categoría (_embedding_text() en recompute_artifacts.py).
El mismo código sirve para una futura capa 3 por usuario: basta pasar el
historial de una persona en vez del de la mesa.
Mezcla de capas¶
combine_layers() mezcla las señales: para cada candidato suma el lift frente
a todo lo ya pedido (capa 1) y añade un peso fijo (CONTENT_WEIGHT = 0.5) por
aparecer en el ranking de contenido (capa 2). Solo se recomiendan productos del
conjunto de candidatos válidos (disponibles y no pedidos aún).
Artefactos precalculados¶
Las capas 1 y 2 no se calculan en cada petición: serían demasiado caras (recorrer
todo el histórico de tickets, generar embeddings). En su lugar se precalculan
y se guardan en la tabla recommendation_artifacts, indexada por
(restaurant_id, kind). Hay dos tipos (app/modules/recommendation/domain/artifacts.py):
cooccurrence(KIND_COOCCURRENCE) — la matriz lift de la capa 1.product_embeddings(KIND_PRODUCT_EMBEDDINGS) — los vectores de la capa 2.
El puerto IArtifactRepository expone solo get_payload(restaurant_id, kind) y
upsert(restaurant_id, kind, payload). En tiempo de petición el motor lee los
artefactos; nunca los genera. Los payloads se serializan con claves de tipo
string (requisito JSON) y el motor las reconvierte a int al leerlas.
Recómputo: sin scheduler in-process (a propósito)¶
Los artefactos se regeneran fuera del ciclo de petición. No existe un scheduler dentro del proceso de la aplicación, y es una decisión de diseño, no un olvido: el recómputo es un trabajo por lotes que debe poder ejecutarse, observarse y reintentarse de forma independiente del servidor web.
Hay dos vías para disparar el recómputo, y ambas terminan en el mismo
RecomputeArtifactsUseCase:
1. Cron del host¶
El runner (app/worker/runner.py) recomputa co-ocurrencia y embeddings para un
restaurante (--restaurant-id) o para todos los no borrados (is_deleted=False).
Pensado para un cron del host o un sidecar de compose. Por cada restaurante hace
commit y registra un dict de estadísticas (tickets, cooccurrence_items,
products_embedded).
2. Endpoint admin on-demand¶
Definido en app/modules/recommendation/interface/admin_router.py. Verifica
acceso al restaurante (check_access) y devuelve las mismas estadísticas
(RecomputeResponse). Útil para forzar un recálculo tras cambios de carta sin
esperar al cron.
Embeddings best-effort
El cálculo de embeddings es best-effort: si el servicio Granite no está
disponible (EmbeddingsUnavailable), el recómputo no aborta — la co-ocurrencia
ya se ha guardado y la capa 2 simplemente no aporta hasta el siguiente run.
En tests siempre EMBEDDINGS_ENABLED=false (lo fija tests/conftest.py);
nunca dependas del modelo real en un test.
Redacción por el LLM¶
Una vez el motor ha decidido el ranking, el LLM redacta el mensaje. El proveedor
de IA se abstrae tras el puerto IRecommendationAIProvider.generate(...), que
recibe los productos ya seleccionados y devuelve la redacción, o lanza para forzar
fallback. El LLM no reordena ni reemplaza la lista decidida por el motor.
En el chat híbrido, el nodo de recomendación emite una intro corta y el detalle de
los platos viaja como tarjetas estructuradas (frame SSE recommendation), no
enumerado en el texto. Ver Tiempo real (SSE) para el contrato
de eventos.
Endpoints cliente¶
El router (app/modules/recommendation/interface/router.py) expone tres
endpoints orientados al cliente:
| Método y ruta | Uso |
|---|---|
GET /recommendations |
Sugerencias por contexto (num_guests, hour, max_recommendations) vía GetRecommendationsUseCase. Anónimo → tipo ai; autenticado → personalized; si el proveedor falla → fallback. |
GET /recommendations/conversation/{conversation_id} |
Recomendaciones almacenadas de una conversación. |
POST /recommendations/{recommendation_id}/action |
Acepta o descarta una recomendación almacenada (RecommendationActionType.ACCEPT / DISMISS). |
El tipo de respuesta lo define el enum RecommendationType
(ai / personalized / fallback, en domain/value_objects.py).
Medición con behavior events¶
Para evaluar el motor de forma aislada de la redacción, cada interacción relevante
se registra como behavior event en la tabla behavior_events
(app/modules/analytics/). El enum BehaviorEventType
(app/shared/domain/enums.py) define tres tipos:
| Evento | Significado |
|---|---|
pedido |
Se añadió un producto al carrito/comanda. |
rec_mostrada |
El motor mostró un producto como recomendación. |
rec_aceptada |
Un producto mostrado se pidió dentro de la ventana de atribución. |
El recorder (IBehaviorEventRecorder, vía get_behavior_event_recorder(db),
implementado por SqlBehaviorEventRecorder) se invoca de forma best-effort
desde el flujo del chat híbrido (infrastructure/flows/): la acción de
recomendar emite un rec_mostrada por ítem, y al añadir al carrito
(record_cart_add) se emite pedido y, si el producto fue mostrado
recientemente, rec_aceptada. El recorder nunca propaga errores al flujo de
negocio que lo llama.
Ventana de atribución: ~15 minutos
Un pedido cuenta como rec_aceptada solo si hay un rec_mostrada para esa
sesión y producto dentro de REC_ACCEPTANCE_WINDOW = timedelta(minutes=15)
(behavior_event_recorder.py). El campo user_id de la fila es nullable
desde el día 0 para poder enlazar historial por usuario de forma retroactiva
cuando exista identidad. Con estos eventos se calcula la tasa de
aceptación (cuántas recomendaciones acaban en pedido), la métrica directa de
calidad del motor.
Páginas relacionadas¶
- Tiempo real (SSE) — contrato del frame
recommendationy demás eventos del chat. - Chat híbrido — el árbol de decisión que invoca al motor.
- Referencia API — endpoints de recomendaciones.