tenant_id
y el before_send de Sentry depura PII/credenciales/instrumento de pago conservando el
tenant_id.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.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).
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
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).Ninguna lectura de OCR es 100% confiable. Dos señales determinísticas que solo fuerzan revisión humana; jamás cambian el monto.
| Señal | Qué hace |
|---|---|
| Cross-check de palabras | El 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 outlier | Tras 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
price_raw y (si existe) el price_words quedan visibles lado a lado en la UI de revisión para que el humano cruce.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).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
restaurant_id intacto: solo se rankean ítems del propio restaurante (testeado: una búsqueda no trae ítems de otro local).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)
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
[scrubbed]; el tenant_id se conserva para correlación.VITE_SENTRY_DSN.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.Ejercitar la observabilidad de punta a punta (algo que el B6 cableó pero nunca corrió con un span real) destapó dos bugs latentes:
| # | Hallazgo | Fix |
|---|---|---|
| 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. |
| 2 | ip_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. |
# 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
build_completion(prefer=) (routing por-puerto) + setting menu_extractor_provider.parse_spanish_number_words + words_match_minor (cross-check).price_words (migración 0008) + cross-check + heurística de outlier en run_extraction (solo fuerzan revisión).search_menu semántico (embedder inyectable) con fallback substring; aislamiento por restaurante._on_ending + ip_address en scrubbing. UI: columna «En palabras» en la revisión.sk-ant-api…): comparación Claude-vs-Gemini del extractor y fallback del chat en vivo. La capacidad está construida y testeada; la decisión de routing espera los números.OTEL_EXPORTER_OTLP_ENDPOINT, SENTRY_DSN, VITE_SENTRY_DSN): el código está activo y demostrado contra transporte local; falta el collector/DSN reales.price_raw lo expone).