✅ CERRADO 2026-05-29 (#239 HECHO). Las 3 fases del scope están en
main: modeloConsumoAnalisis,AnalizarFotoConsumoJob,AnalizadorTicketService(Claude vision + JSON estructurado vía tool use); detección de foto inválida + OCR/comparación de monto + cobertura por heurística IA libre (D2-B, la tabla de palabras clave se descartó). Más extras: auditoría forense (#247), dashboard, apelación/auto-anclaje (#377), 8 tests. Apagado por flags en prod → prenderlo = #249. Verificación read-only en la bitácora deamadeus.md(2026-05-29). El contenido de abajo es el diseño histórico.
Decisiones cerradas 2026-05-25 (responden D1-D5 + apelación)
| ID | Pregunta | Decisión |
|---|---|---|
| D1 | Acción ante foto inválida | C — Permitir + auto-rechazar + notificar al usuario y al admin |
| D2 | Política de cobertura | B — Heurística IA libre (modelo decide si parece viático típico; sin lista blanca de tokens) |
| D3 | Sync vs async | B — Async con Job + estado analizando + push al terminar |
| D4 | Proveedor visión | A — Antigravity Sonnet 4.6 (claude-sonnet-4-6) vía Anthropic SDK |
| D5 | Scope MVP | B — Fase 2 directo: detección de foto inválida + OCR + comparación de monto declarado vs detectado |
| Apelación | Qué hace el usuario tras rechazo | Re-subir foto invalida el rechazo y dispara nuevo análisis; sin botón de revisión humana inicial |
Consecuencia arquitectónica: la heurística IA libre + auto-rechazo elimina la tabla categoria_palabras_clave y el flujo admin de revisión humana del plan original. El doc se simplifica.
Amadeus — Validación inteligente de fotos en consumos
Contexto
Algunos compañeros suben fotos completamente negras (o sin contenido legible) al capturar consumos en amadeus, con la intención de tapar gastos no autorizados o quedarse con dinero de la empresa. El módulo de consumos hoy acepta cualquier imagen sin validación de contenido — solo extrae EXIF (GPS, fecha, dispositivo).
Objetivo: que cada foto subida sea analizada por un modelo de visión que detecte:
- Si la foto es un ticket/comprobante real (no negra, no random, no oscura).
- Concepto y monto extraídos del ticket.
- Si el concepto cae dentro de lo que cubre la empresa (gasolina, comida, peaje, hospedaje, etc.) o no (alcohol, regalos personales, items no autorizados).
La validación entra en el flujo de aprobación existente — los consumos que no pasen quedan en estado de revisión obligatoria; los que claramente vienen mal pueden bloquearse de raíz.
Estado actual del código (HEAD 6b878f3)
Consumo(app/Models/Consumo.php): campoticketstring (un solo archivo), camposmonto/concepto/categoria_id/fechacapturados por el usuario;coordenadas/fecha_ticket/marca_dispositivoextraídos de EXIF;estatusenumpendiente|rechazado|autorizado.ConsumosController::store/actualizar: recibeticketcomo single file,store('tickets')en disk local, extrae EXIF en el mismo request.GuardarConsumoForm Request: hoyrequired|imageen create,nullable|imageen edit. Sin validación de contenido.- Vista
FormaConsumo.vue: comprime client-side con Compressor.js (max 800×800, quality 0.6, retainExif). Single file. - Flujo de aprobación: listener
EnviarNotificacionConsumoAutorizacionactiva estadopendientecuandocategoria.requiere_autorizacion=true; controller tieneautorizar()/rechazar()gates. - Categorías (
categorias):nombre+ flagsrequiere_ticket/transporte/requiere_autorizacion. Sin lista hardcodeada en código; admin las gestiona en BD. - Cero dependencias de IA hoy (composer.json sin SDK Anthropic/OpenAI/tesseract).
Lo importante: la base es suficientemente buena para inyectar el análisis sin migrar schema mayor; el cambio se aterriza en (a) un nuevo modelo ConsumoAnalisis 1:1 con Consumo, (b) un Job dispatched al store, (c) UI que muestra resultado.
Decisiones de producto que abren el diseño
Estas son las decisiones que cambian la arquitectura — antes de elegirlas no tiene sentido codificar.
D1 — ¿Qué hace el sistema cuando la foto es claramente inválida (negra/no-ticket)?
- A) Bloquear el submit en el browser — el cliente analiza antes de enviar y no deja guardar. Pro: feedback instantáneo. Con: análisis IA fuera de browser implica subir 2 veces o exponer la API; alternativa: detección barata client-side de “foto muy oscura” (luminancia promedio) y solo IA en server.
- B) Permitir guardar, pero forzar estado
pendiente+ alerta visible al admin. Pro: el rastro queda registrado (auditoría); el operador no puede “intentar otra vez con otra negra” silenciosamente. Con: requiere flujo de revisión activo. - C) Permitir guardar + auto-
rechazado+ notificación al usuario + log a admin. Pro: dispara presión social; el usuario sabe que el sistema lo detectó. Con: agresivo, puede ofender falsos positivos.
Recomendación tentativa: B + capa client-side liviana (luminancia muy baja → warning en UI antes de submit, no bloquea; el ground truth lo decide el servidor). Auditoría preservada.
D2 — ¿Qué tan estricto con el concepto/categoría?
- A) Política explícita — admin define lista de conceptos cubiertos por categoría (gasolina/diésel ✓, alcohol ✗, etc.). La IA solo extrae texto del ticket; reglas son explícitas en BD. Pro: explicable, defendible ante el usuario, evoluciona con la empresa. Con: requiere mantenimiento de la lista.
- B) Heurística IA libre — el modelo decide si “este gasto parece un viático típico”. Pro: cero mantenimiento. Con: caja negra; difícil defender un rechazo (“la IA dijo que no”).
- C) Solo detectar foto inválida — no enjuiciar concepto; el modelo solo confirma “es un ticket real con contenido legible”. El admin sigue revisando manualmente cuando aplique.
Recomendación tentativa: A — categorías ya existen en BD; agregar tabla categoria_palabras_clave (lista blanca de tokens por categoría). IA extrae texto; matcher en PHP decide cobertura. Auditable.
D3 — Síncrono o asíncrono
- A) Síncrono — al hacer submit, espera el análisis (5-15s) y muestra resultado inmediato. Pro: UX clara. Con: tiempo de espera notorio; si la IA tarda, el operador siente la app lenta; nginx timeout 60s aplica.
- B) Asíncrono con Job + estado
analizando— submit responde 200 inmediato, badge “Analizando…” en el detalle, job procesa en background (segundos a minutos), notifica push cuando termina. Pro: UI rápida; aprovecha el patrón ya validado en otros proyectos del hub (memoriareference_async_jobs_for_slow_apis). Con: el usuario podría salir antes de ver el resultado; requiere indicador visual de estado.
Recomendación tentativa: B — patrón ya probado en el hub; consistente con el flujo de notificaciones ya implementado.
D4 — ¿Proveedor de visión?
- A) Antigravity API (multimodal nativo, ya con
ANTHROPIC_API_KEYen electro-ia). Sonnet 4.6 es excelente para OCR + razonamiento estructurado en una sola llamada. Costo ~$0.003-0.015 por imagen. Sergio ya conoce el proveedor. - B) OpenAI Vision — similar capacidad y costo, requiere clave nueva.
- C) Tesseract OCR + reglas — open source, sin costo recurrente, pero peor en tickets arrugados/mal iluminados y NO razona sobre concepto vs cobertura.
Recomendación tentativa: A — proveedor ya en el stack del hub; un sola llamada hace OCR + clasificación + valoración (JSON estructurado vía tool use). El costo a 100 consumos/día es $0.30-1.50 USD/día ($10-45/mes), trivial vs el riesgo de fraude que mitiga.
D5 — Scope mínimo de la primera entrega
- A) MVP estrecho — solo detectar foto-no-ticket (negra/borrosa/random). Marca
pendiente. Sin extracción de concepto/monto. Sale en 1-2 días. - B) MVP medio — detección + OCR (extrae monto/concepto crudo del ticket) + comparación con lo que capturó el usuario (¿coincide el monto?). Falgs en
pendiente. 3-4 días. - C) Completo — todo lo anterior + validación de cobertura por categoría (D2-A). 5-7 días con la tabla de palabras clave.
Recomendación tentativa: B primero (mata el 80% del fraude obvio sin requerir definir política de cobertura) → C después una vez que Sergio decida las listas blancas.
Propuesta de arquitectura (asume B+A+B+A+B de las recomendaciones)
Schema nuevo
consumos_analisis
- id
- consumo_id (FK unique)
- estado: pendiente_analisis | analizando | ok | sospechoso | rechazado | error
- razon: text nullable -- explicación humana del resultado
- es_imagen_valida: boolean -- false si negra/borrosa/no-ticket
- texto_extraido: text nullable -- OCR crudo
- monto_detectado: decimal nullable -- comparación con consumos.monto
- concepto_detectado: string nullable
- categoria_sugerida_id: FK nullable -- la IA infiere categoría
- coincide_monto: boolean nullable -- diff < umbral entre detectado y declarado
- modelo_usado: string -- claude-sonnet-4-6 etc
- tokens_in, tokens_out, costo_usd: para auditoría
- analizado_at: timestamp nullable
- timestamps
Flujo
- Usuario sube consumo en
ConsumosController::store. - Tras crear el
Consumo, también se creaConsumosAnalisisconestado=pendiente_analisis. - Dispatch
AnalizarFotoConsumoJob(consumo_id)(queueanalisis). - Worker llama Antigravity Sonnet 4.6 con la imagen + tool
analizar_ticketque retorna JSON estructurado (es_ticket,monto,concepto,texto_completo,categoria_sugerida,confianza). - Lógica en PHP compara con lo declarado:
- Si
es_ticket=false→estado=rechazado,consumos.estatus=pendiente(forzar revisión). - Si
abs(monto - consumos.monto) > 10%→estado=sospechoso,consumos.estatus=pendiente. - Si todo OK →
estado=ok,consumos.estatusqueda como venía.
- Si
- Notificación push al usuario + email al admin cuando hay flag.
- UI: badge en
Show.vuecon resultado; tabla admin filtrable por estado de análisis.
Costo estimado
- Sonnet 4.6 visión: input ~$3/1M tokens + ~$0.0008/imagen base64. Output JSON ~300 tokens.
- Por consumo: ~$0.005-0.015 USD.
- 50-100 consumos/día → $0.25-1.50 USD/día → $8-45/mes.
- Cap protector en
.env:ANALISIS_COSTO_MAX_DIARIO_USD=5que pausa job y notifica admin.
Riesgos
- Falsos positivos — ticket válido borroso o foto en exterior con sol. Mitigación: estado
sospechoso≠rechazado; siempre pasa por humano antes de bloquear. - Compresión client-side — Compressor.js a quality 0.6 max 800×800 puede degradar OCR. Considerar subir un ajuste a 1200×1200 quality 0.75 para tickets, o detectar y no comprimir si la foto es <1MB.
- Privacidad — la API ve tickets que pueden contener nombres/RFCs de comercios. Para Electrosystems interno, riesgo bajo; documentar en política de uso.
- Modo offline — si el job falla por API down, el consumo queda en
pendiente_analisisindefinidamente; retry con backoff exponencial + fallback a “revisión manual obligatoria” tras N reintentos. - Operadores que aprenden a “engañar al modelo” — fotos de tickets de combustible al subir consumo de regalos. El monto declarado vs detectado mitiga; complementar con auditoría periódica de muestra aleatoria.
Plan de implementación (post-decisiones)
Una sola entrega, ~3-4 días. La heurística libre + auto-rechazo simplifica todo (no hay tablas de palabras clave ni reviewer flow nuevo).
Paso 1 — Schema y modelo (~2 h)
Migración:
Schema::create('consumos_analisis', function (Blueprint $t) {
$t->id();
$t->foreignId('consumo_id')->constrained()->cascadeOnDelete()->unique();
$t->enum('estado', ['pendiente','analizando','ok','rechazado','error']);
$t->text('razon')->nullable(); // texto humano del veredicto
$t->boolean('es_ticket_valido')->nullable();
$t->boolean('parece_viatico_legitimo')->nullable();
$t->decimal('monto_detectado', 10, 2)->nullable();
$t->string('concepto_detectado')->nullable();
$t->text('texto_extraido')->nullable();
$t->boolean('coincide_monto')->nullable(); // |detectado - declarado| <= 10%
$t->json('respuesta_cruda')->nullable(); // JSON completo de la API
$t->string('modelo_usado')->nullable();
$t->unsignedInteger('tokens_in')->nullable();
$t->unsignedInteger('tokens_out')->nullable();
$t->decimal('costo_usd', 8, 4)->nullable();
$t->unsignedTinyInteger('intentos')->default(0);
$t->timestamp('analizado_at')->nullable();
$t->timestamps();
});
Modelo ConsumoAnalisis con belongsTo Consumo. En Consumo: hasOne ConsumoAnalisis as analisis.
Paso 2 — Servicio cliente Anthropic (~3 h)
app/Services/Vision/AnalizadorTicketService.php:
- Constructor recibe
Anthropic\Client(inyección DI;ANTHROPIC_API_KEYdesde.env). - Método
analizar(string $rutaImagen): arraycon tool use:- Prompt system: “Eres un auditor de viáticos. Vas a recibir la foto de un ticket que un empleado subió como evidencia de un gasto reembolsable. Tienes que evaluar si es un ticket válido y si el gasto parece legítimo (combustible, comida, peaje, hospedaje, herramientas, traslado terrestre). NO son legítimos: alcohol, regalos personales, items para uso personal claramente no laboral.”
- User content: imagen base64 + “Analiza este ticket y devuelve el JSON estructurado vía la tool
dictaminar_ticket.” - Tool
dictaminar_ticketcon schema:{ "es_ticket_valido": "bool — true si la foto muestra un ticket/recibo/factura con contenido legible; false si está negra, borrosa, en blanco, o no es un comprobante", "razon_invalidez": "string — explicación si es_ticket_valido=false, null si true", "monto_total": "number — total del ticket en MXN, null si ilegible", "concepto_principal": "string — qué se compró (ej. 'gasolina magna', 'comida en restaurante', 'peaje cuota'), null si ilegible", "texto_completo": "string — OCR crudo del ticket (líneas relevantes), null si ilegible", "parece_viatico_legitimo": "bool — true si el concepto es razonablemente un viático de trabajo de campo (combustible, comida, peaje, hospedaje, traslado, herramientas); false si claramente personal (alcohol, regalos, items domésticos)", "razon_no_legitimo": "string — explicación si parece_viatico_legitimo=false, null si true", "confianza": "number entre 0 y 1" }
- Costo se calcula con price-per-token y
image_tokensreportados por la API. - Cap de costo diario vía settings: si suma diaria >
ANALISIS_COSTO_MAX_DIARIO_USD(default 5), el servicio lanzaCapDiarioExcedidoException.
Paso 3 — Job async (~2 h)
app/Jobs/AnalizarFotoConsumoJob:
tries=3,backoff=[60, 300, 900]segundos.- Marca
consumos_analisis.estado='analizando'. - Llama al service. Si éxito:
- Persiste todos los campos extraídos.
- Lógica de veredicto:
es_ticket_valido=false→estado=rechazado, marcaconsumos.estatus=rechazado+ disparaConsumoRechazadoPorIAevent.parece_viatico_legitimo=false→ idem.|monto_detectado - consumos.monto| / consumos.monto > 0.10→estado=rechazado+ razón “monto declarado no coincide con el ticket”.- Todo OK →
estado=ok, no tocaconsumos.estatus.
- Si error retryable (timeout, 5xx) → exception bubbles, retry policy se encarga.
- Si error final tras 3 intentos →
estado=error, razón “no se pudo analizar — re-sube la foto”; NO marca rechazado (proteger contra outages del proveedor).
Paso 4 — Eventos + notificaciones (~2 h)
- Event
ConsumoRechazadoPorIAcon listeners:- Push notification al usuario (reusa
PushNotificationServiceexistente). - Email al usuario con la razón humana.
- Email al admin (configurable:
ANALISIS_ADMIN_EMAIL).
- Push notification al usuario (reusa
- Notif body incluye link al consumo para que el usuario re-suba foto.
Paso 5 — Hooks en el controller (~1 h)
ConsumosController::store y actualizar:
- Después de persistir el
Consumo, crearConsumoAnalisisconestado=pendiente. - Dispatch
AnalizarFotoConsumoJob($consumo->id). - En
actualizarcon nueva foto: invalidar análisis previo (estado=pendiente, limpiar campos), re-dispatch. Si el consumo estabarechazadopor IA, también vuelve apendienteesperando el nuevo veredicto. Este es el canal de apelación: re-subir foto = reset del análisis.
Paso 6 — UI (~3-4 h)
Show.vuedel consumo: panel “Análisis IA” con:- Badge de estado (gris/amarillo/verde/rojo).
- Si rechazado: razón + comparación monto declarado vs detectado + botón “Re-subir foto” que lleva a
Editar.vue. - Si ok: resumen “Ticket validado: X MXN por concepto Y” (modesto, no domina la UI).
- Si analizando: spinner + “Analizando foto… (~10s)”.
Editar.vue: aviso si el consumo está rechazado por IA — “Tu foto fue rechazada por: {razón}. Sube una foto clara del ticket completo”.- Index/lista del admin: filtro nuevo “Estado de análisis IA” + chip por fila.
Paso 7 — Tests Pest (~3-4 h)
Fixtures: imágenes en tests/Fixtures/tickets/ con tickets reales, negros, borrosos. El service se mockea en tests con respuestas predefinidas (NO se llama a la API real en CI). Un test integration opcional @group integration con ANTHROPIC_API_KEY real para correr manualmente.
AnalizadorTicketServiceTest: dado payload, valida JSON output schema.AnalizarFotoConsumoJobTest: foto inválida → rechazado + evento; monto no coincide → rechazado; ok → estatus consumo intacto.ConsumosControllerAnalisisTest: store dispatcha job; actualizar con nueva foto resetea análisis.CapDiarioCostoTest: si sumatoria supera cap, job marca error y no llama API.
Paso 8 — Telemetría + dashboard admin (~2 h)
- Página Nova / Inertia
/admin/analisis-consumoscon:- KPIs: % rechazados últimos 7d, costo acumulado USD del mes, top razones de rechazo.
- Tabla de últimos 50 análisis con filtros por usuario/estado/razón.
- Permite al admin ver el patrón de rechazos por persona (cuántos usuarios sostienen rechazos repetidos → señal de fraude estructural, no error aislado).
Total estimado
| Paso | Horas |
|---|---|
| 1 Schema/modelo | 2 |
| 2 Service Anthropic | 3 |
| 3 Job async | 2 |
| 4 Eventos/notif | 2 |
| 5 Hooks controller | 1 |
| 6 UI Vue | 3-4 |
| 7 Tests | 3-4 |
| 8 Dashboard admin | 2 |
| Total | 18-20 h (~3-4 días de trabajo) |
Refinamientos diferidos (post-MVP)
Si la primera semana en prod muestra falsos positivos altos:
- Detección de luminancia client-side (warn pre-submit, no bloquea).
- Subir compresión a 1200×1200 quality 0.75 (mejor OCR a costo de upload más pesado).
- Botón “solicitar revisión humana” como segunda capa de apelación tras 2 re-subidas fallidas.
- Tabla
categoria_palabras_clavesi la heurística IA libre demuestra ser inconsistente.
Paso 9 — Análisis batch de la categoría Misceláneos (post-MVP, ~2 h)
Por qué: la investigación read-only del 2026-05-25 reveló que la categoría Misceláneos (164 consumos / 3 meses / $69,661 / stddev $1,333 / max $8,000) es el catch-all donde caen los Sueros, Comisión retiro, Cable eléctrico, etc. — donde más se camufla el potencial fraude. El concepto declarado por el usuario es texto libre, así que lo que dice no necesariamente es lo que el ticket muestra.
Plan: una vez que el AnalizadorTicketService esté maduro en prod, correr una pasada single-shot sobre los 164 consumos históricos en Misceláneos:
- Command
php artisan analisis:batch-historico --categoria="Misceláneos" --desde="2026-02-25". - Para cada consumo, dispatch del job al modelo (queue baja prioridad para no chocar con tráfico nuevo).
- Resultado: tabla agregada por
concepto_detectadocon conteo + monto total + lista de consumos. Es lo que el OCR realmente vio. - Comparar concepto declarado vs concepto detectado — el diff es la señal: si la persona escribió “Misceláneos” pero la IA leyó “Cerveza Tecate”, ahí está el caso.
Salida esperada: un reporte que entrega a Sergio una lista limpia de qué conceptos reales fluyen por Misceláneos, sin que el operador defina la categoría. Decisiones posteriores (¿se rechaza?, ¿se reasigna a otra categoría?, ¿se sub-clasifica?) las toma con datos en mano, no por anécdotas.
Costo proyectado: 164 imágenes × ~$0.01 = ~$1.64 USD one-shot.
Bitácora
2026-05-25 — captura inicial + diseño
- Sergio pidió subir esto como pendiente más importante, antes de cualquier sprint 15-21 ya documentado.
- Mapeo del módulo Consumos completado (subagente Explore). Estado: foto single field
ticket, EXIF mining ya sofisticado, flujo de aprobación ya existe — base ideal. - Propuesta de arquitectura escrita; recomendaciones tentativas anotadas.
2026-05-25 — Paso 1 ejecutado + decisión de diferir clasificación de conceptos
- Branch
feature/validacion-fotoscreada en~/code/amadeus. Commitcb415d5. - Migración
2026_05_25_211027_create_consumos_analisis_table.phpcorrida en local (Sail). 23 columnas: estado + veredictos + datos extraídos + override admin (admin_id+motivo+timestamp) + auditoría de costo (modelo/tokens/USD/intentos). - Modelo
App\Models\ConsumoAnalisiscon casts ybelongsTo Consumo+belongsTo Usuario as overrideAdmin. RelaciónConsumo::analisis()hasOne agregada. - db-investigator corrió en paralelo: 5,530 consumos históricos, 764 en últimos 3 meses, 7 categorías. Hallazgo importante:
consumos.estatuses NULL en los 5,530 registros — el control de aprobación/rechazo nunca se usó. La validación IA será la primera capa real de control. - Sergio decidió diferir la clasificación manual de conceptos: en vez de marcar cada concepto histórico OK/NO, prefiere que después de que la IA esté en prod, corramos un análisis batch sobre los 164 consumos de “Misceláneos” para ver los conceptos REALES que vinieron en los tickets (no el texto libre del usuario). Más útil porque ahí se camufla el fraude. Agregado como Paso 9.
2026-05-25 — decisiones de producto cerradas + plan concreto
- 6 decisiones tomadas vía AskUserQuestion (D1-D5 + apelación). Sergio eligió el camino más agresivo y menos costoso de mantener: auto-rechazo + IA libre (sin lista blanca de tokens) + async + Antigravity Sonnet 4.6 + Fase 2 directo + re-subida invalida rechazo.
- Plan concreto en 8 pasos, ~18-20 h totales (~3-4 días). Doc reescrito con schema final, prompt del modelo, lógica de veredicto y mock plan de tests.
- Falta: autorización explícita de Sergio para arrancar implementación en
~/code/amadeus. Push y commit en amadeus siguen requiriendo auth per-sesión (no está en la lista de “autorizado por default”). Cotización no aplica — amadeus es infra interna Electrosystems.