Saltar a contenido

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

python -m app.worker.runner recompute-recs [--restaurant-id N]

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

POST /api/v1/admin/recommendations/recompute?restaurant_id=N

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