Hub

electrosystems

amadeus-validacion-fotos

done high work
Creado
2026-05-25
Actualizado
2026-05-29

Actividad en bitácora 1 día

jun
jul
ago
sep
oct
nov
dic
ene
feb
mar
abr
may
L
X
V
Menos
Más

✅ CERRADO 2026-05-29 (#239 HECHO). Las 3 fases del scope están en main: modelo ConsumoAnalisis, 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 de amadeus.md (2026-05-29). El contenido de abajo es el diseño histórico.

Decisiones cerradas 2026-05-25 (responden D1-D5 + apelación)

IDPreguntaDecisión
D1Acción ante foto inválidaC — Permitir + auto-rechazar + notificar al usuario y al admin
D2Política de coberturaB — Heurística IA libre (modelo decide si parece viático típico; sin lista blanca de tokens)
D3Sync vs asyncB — Async con Job + estado analizando + push al terminar
D4Proveedor visiónA — Antigravity Sonnet 4.6 (claude-sonnet-4-6) vía Anthropic SDK
D5Scope MVPB — Fase 2 directo: detección de foto inválida + OCR + comparación de monto declarado vs detectado
ApelaciónQué hace el usuario tras rechazoRe-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:

  1. Si la foto es un ticket/comprobante real (no negra, no random, no oscura).
  2. Concepto y monto extraídos del ticket.
  3. 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): campo ticket string (un solo archivo), campos monto/concepto/categoria_id/fecha capturados por el usuario; coordenadas/fecha_ticket/marca_dispositivo extraídos de EXIF; estatus enum pendiente|rechazado|autorizado.
  • ConsumosController::store/actualizar: recibe ticket como single file, store('tickets') en disk local, extrae EXIF en el mismo request.
  • GuardarConsumo Form Request: hoy required|image en create, nullable|image en 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 EnviarNotificacionConsumoAutorizacion activa estado pendiente cuando categoria.requiere_autorizacion=true; controller tiene autorizar()/rechazar() gates.
  • Categorías (categorias): nombre + flags requiere_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 (memoria reference_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_KEY en 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

  1. Usuario sube consumo en ConsumosController::store.
  2. Tras crear el Consumo, también se crea ConsumosAnalisis con estado=pendiente_analisis.
  3. Dispatch AnalizarFotoConsumoJob(consumo_id) (queue analisis).
  4. Worker llama Antigravity Sonnet 4.6 con la imagen + tool analizar_ticket que retorna JSON estructurado (es_ticket, monto, concepto, texto_completo, categoria_sugerida, confianza).
  5. Lógica en PHP compara con lo declarado:
    • Si es_ticket=falseestado=rechazado, consumos.estatus=pendiente (forzar revisión).
    • Si abs(monto - consumos.monto) > 10%estado=sospechoso, consumos.estatus=pendiente.
    • Si todo OK → estado=ok, consumos.estatus queda como venía.
  6. Notificación push al usuario + email al admin cuando hay flag.
  7. UI: badge en Show.vue con 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=5 que pausa job y notifica admin.

Riesgos

  1. Falsos positivos — ticket válido borroso o foto en exterior con sol. Mitigación: estado sospechosorechazado; siempre pasa por humano antes de bloquear.
  2. 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.
  3. Privacidad — la API ve tickets que pueden contener nombres/RFCs de comercios. Para Electrosystems interno, riesgo bajo; documentar en política de uso.
  4. Modo offline — si el job falla por API down, el consumo queda en pendiente_analisis indefinidamente; retry con backoff exponencial + fallback a “revisión manual obligatoria” tras N reintentos.
  5. 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_KEY desde .env).
  • Método analizar(string $rutaImagen): array con 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_ticket con 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_tokens reportados por la API.
  • Cap de costo diario vía settings: si suma diaria > ANALISIS_COSTO_MAX_DIARIO_USD (default 5), el servicio lanza CapDiarioExcedidoException.

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=falseestado=rechazado, marca consumos.estatus=rechazado + dispara ConsumoRechazadoPorIA event.
      • parece_viatico_legitimo=false → idem.
      • |monto_detectado - consumos.monto| / consumos.monto > 0.10estado=rechazado + razón “monto declarado no coincide con el ticket”.
      • Todo OK → estado=ok, no toca consumos.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 ConsumoRechazadoPorIA con listeners:
    • Push notification al usuario (reusa PushNotificationService existente).
    • Email al usuario con la razón humana.
    • Email al admin (configurable: ANALISIS_ADMIN_EMAIL).
  • 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, crear ConsumoAnalisis con estado=pendiente.
  • Dispatch AnalizarFotoConsumoJob($consumo->id).
  • En actualizar con nueva foto: invalidar análisis previo (estado=pendiente, limpiar campos), re-dispatch. Si el consumo estaba rechazado por IA, también vuelve a pendiente esperando el nuevo veredicto. Este es el canal de apelación: re-subir foto = reset del análisis.

Paso 6 — UI (~3-4 h)

  • Show.vue del 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-consumos con:
    • 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

PasoHoras
1 Schema/modelo2
2 Service Anthropic3
3 Job async2
4 Eventos/notif2
5 Hooks controller1
6 UI Vue3-4
7 Tests3-4
8 Dashboard admin2
Total18-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_clave si 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:

  1. Command php artisan analisis:batch-historico --categoria="Misceláneos" --desde="2026-02-25".
  2. Para cada consumo, dispatch del job al modelo (queue baja prioridad para no chocar con tráfico nuevo).
  3. Resultado: tabla agregada por concepto_detectado con conteo + monto total + lista de consumos. Es lo que el OCR realmente vio.
  4. 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-fotos creada en ~/code/amadeus. Commit cb415d5.
  • Migración 2026_05_25_211027_create_consumos_analisis_table.php corrida 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\ConsumoAnalisis con casts y belongsTo Consumo + belongsTo Usuario as overrideAdmin. Relación Consumo::analisis() hasOne agregada.
  • db-investigator corrió en paralelo: 5,530 consumos históricos, 764 en últimos 3 meses, 7 categorías. Hallazgo importante: consumos.estatus es 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.