Cierre técnico · 4 pendientes de las corridas reales

ComensIA — OCR del colón, fallback, búsqueda semántica y observabilidad

Bloque de pulido sobre B0–B6 + Gate B + precios determinísticos. Sin features de negocio: cierra los cuatro pendientes técnicos detectados al correr en real. Defensa determinística contra la mal-lectura del ₡, routing por-puerto (extractor vs chat), búsqueda semántica de menú y observabilidad encendida y demostrada. Lo que requiere una API key real de Anthropic (comparación Claude y fallback en vivo) queda como pendiente honesto — no se simula.
Fecha: 2026-06-01 Autor: Diego Monge Loría — DM-IA Solutions Servidor: terciario srv1582179 · 2.24.194.226 Estado: semántica + observabilidad verificadas en vivo · 270/270 en contenedor · 2 bugs reales corregidos

Resumen ejecutivo

0.78
coseno del ítem correcto (búsqueda semántica viva)
tenant_id
en span OTel real (exporter en memoria)
2
bugs reales de observabilidad corregidos
270/270
regresión B0–B6 EN CONTENEDOR (hermética)
1
pendiente honesto: API key real de Anthropic
0
barreras / eval / órdenes / dinero tocados
Verificado en vivo (Gemini): la búsqueda semántica encuentra el ítem correcto donde el substring daba 0 — "¿qué platos de arroz o camarones tienen?" rankea «Arroz con Camarones» primero (coseno 0.78). La observabilidad quedó encendida y demostrada: un span OTel real lleva tenant_id y el before_send de Sentry depura PII/credenciales/instrumento de pago conservando el tenant_id.
Pendiente honesto: la única credencial Anthropic en el entorno es el OAuth token de Claude Code (sk-ant-oat…), no una API key de aplicación (sk-ant-api…). No se repurposeó (sería uso indebido de credenciales y el SDK la rechaza por auth distinta). Por eso la comparación Claude-vs-Gemini y el fallback en vivo quedan pendientes de una API key real — la capacidad está construida y testeada con fixtures. Igual de honesto: los backends de OTLP/Sentry reales (DSN/endpoint) tampoco están en el entorno; se demostró contra transporte local.

Parte 1 — OCR del glifo ₡ y fallback a Claude

El Gate B mostró que Gemini mal-lee el ₡ como "1" en PDFs (₡6.800 → "16.800"), produciendo precios válidos-pero-erróneos. Se ataca con (a) routing por-puerto para preferir el modelo que lea mejor el glifo y (b) defensa determinística (pestaña siguiente).

Routing por-puerto (capacidad construida)

build_completion(settings, prefer="gemini"|"claude")  # orden independiente por puerto
chat      → prefer "gemini"  (Gemini primario, Claude fallback)
extractor → settings.menu_extractor_provider  # puede preferir Claude sin afectar el chat
Decisión basada en números = pendiente honesto. No hay API key real de Anthropic en el entorno (solo el OAuth de Claude Code, que no se repurposea). Sin poder correr Claude en vivo, no se justifica con datos enrutar el extractor a Claude, así que queda en gemini. Procedimiento exacto cuando se provea ANTHROPIC_API_KEY (sk-ant-api…): setear la clave + MENU_EXTRACTOR_PROVIDER=claude, re-correr los 2 menús del Gate B y comparar lectura del ₡ / precios exactos / ítems / alérgenos vs Gemini; si Claude gana, fijar el routing. El fallback del chat se valida igual (forzar fallo del primario con ambas claves).

Parte 1b — Defensa determinística (independiente del modelo)

Ninguna lectura de OCR es 100% confiable. Dos señales determinísticas que solo fuerzan revisión humana; jamás cambian el monto.

SeñalQué hace
Cross-check de palabrasEl extractor pide price_words (si el menú escribe el precio en letras, nullable). parse_spanish_number_words("seis mil ochocientos")=6800; si discrepa del número normalizado → price_needs_review. Atrapa el ₡ mal-leído (16.800 ≠ "seis mil ochocientos").
Heurística de outlierTras normalizar todo el menú, marca para revisión los precios que se desvían de forma extrema de la mediana (> 8× o < 1/8). Red coarse, no verdad.
# ambas SOLO levantan la bandera; el precio nunca se corrige solo
if price_words y not words_match_minor(...) → price_needs_review = True
if precio > 8×mediana del menú          → price_needs_review = True
# la barrera de precio del paso previo sigue: nada sin resolver pasa al menú vivo
Límite honesto: en los 2 menús del Gate B el modelo devolvió price_words=null (no traen el precio en letras), y la heurística de outlier no atrapa el caso del PDF porque las mal-lecturas del ₡ dominan la mediana (4 de 7 precios). Las señales son una red, no un blindaje: el caso del ₡ se resuelve de raíz con un mejor extractor (Claude, pendiente de clave) + la revisión humana (el price_raw expone "16.800" para corregir).

Parte 2 — Búsqueda semántica de menú

El Gate B mostró que search_menu hacía match por substring: "arroz o camarones" en lenguaje natural daba 0. Ahora usa embeddings (RAG) para el encuentro, manteniendo "el servidor dispone".

# verificación EN VIVO con gemini-embedding-001@768
query: "¿qué platos de arroz o camarones tienen?"
  Arroz con Camarones   coseno 0.7809   ← top (substring daba 0 resultados)
  Risotto de Hongos     coseno 0.5925
  Ceviche de Pescado    coseno 0.5887
  Limonada Natural      coseno 0.5693
  Tiramisú              coseno 0.5560

Parte 3 — Observabilidad encendida

OTel — span con tenant_id (verificado, exporter en memoria)

span "ai.completion" → attributes: {
  "tenant_id": "c830678e-…-0fb13ad73424",
  "tenant_level": "restaurant"
}   # pipeline OTel real con exporter en memoria (collector local)

Sentry — scrubbing en before_send (verificado)

{"user":{"id":"cust-abc", "email":"[scrubbed]", "ip_address":"[scrubbed]"},
 "extra":{"card_number":"[scrubbed]", "cvv":"[scrubbed]",
   "anthropic_api_key":"[scrubbed]", "customer_token":"[scrubbed]",
   "payload":{"phone":"[scrubbed]", "nota":"texto ok"}},
 "tags":{"tenant_id":"rest-42"}}   # PII/credenciales/instrumento fuera; tenant_id conservado
Pendiente honesto: no hay backends de telemetría en el entorno. Se demostró contra transporte local (exporter en memoria / scrub_event directo). Para encenderlo en vivo: setear OTEL_EXPORTER_OTLP_ENDPOINT (un collector) y SENTRY_DSN/VITE_SENTRY_DSN; el trace request→outbox enlazado a los workers Celery (CeleryInstrumentor ya cableado) se valida con el collector real.

Hallazgos reales corregidos en esta corrida

Ejercitar la observabilidad de punta a punta (algo que el B6 cableó pero nunca corrió con un span real) destapó dos bugs latentes:

#HallazgoFix
1_TenantSpanProcessor sin _on_ending: el SDK de OTel instalado invoca ese hook al cerrar un span → AttributeError en cada cierre. El wiring del B6 nunca se había ejercitado con un span real.Método no-op agregado. Test de pipeline OTel real (in-memory) lo cubre en la regresión.
2ip_address no se depuraba: la IP del comensal (PII) no estaba en las claves de scrubbing del backend.Agregada ip_address/ip a _SCRUB_KEYS; test lo verifica.

Verificación

# dominio + servicios
test_price_words (17) · test_ai_routing (per-port) · test_semantic_search (2) · test_observability (OTel real + ip) → passed
# tipos
mypy app tests → Success: no issues found in 156 source files
# regresión B0–B6 EN CONTENEDOR (hermética, sin claves)
docker compose exec -e GEMINI_API_KEY= -e ANTHROPIC_API_KEY= backend pytest → 270 passed
# migraciones reproducibles desde cero
alembic downgrade base && alembic upgrade head → … 0007 → 0008_price_words OK
# frontend
pnpm typecheck → 12 ok · pnpm test → 47 passed · pnpm build → 4 apps OK
# EN VIVO (Gemini, evidencia separada de la suite hermética)
búsqueda semántica → Arroz con Camarones top (0.78) · OTel span tenant_id · Sentry scrub OK

Transparencia

Qué se tocó

Qué NO se tocó

Pendiente honesto (requiere credenciales/infra, no código)