Compare commits

...
2 Commits
Author SHA1 Message Date
LizandroandClaude Sonnet 5 5e0c0367d9 feat: costo OCR en dashboard, logs correctos y fix de thinking tokens
- OcrExtractionService: registra cada llamada en ai_usage_logs (servicio=ocr,
  costo=5 COP/foto) y ahora tambien loggea cuando SI funciona, no solo
  cuando falla (antes era imposible confirmar por log que el OCR corrio).
- GeminiVisionService: el log decia siempre "[Gemini Vision]" sin importar
  si la llamada fue de imagen o de texto post-OCR, haciendo imposible
  distinguir cual camino se uso realmente. Ahora usa el servicio real.
- GeminiVisionService: agrega thinkingConfig.thinkingBudget=0 (igual que
  GeminiIntentService) para que el modelo no gaste tokens narrando su
  razonamiento en texto plano antes del JSON final -- se estaba colando
  ese razonamiento en la respuesta y comiendose el presupuesto de tokens.
- GeminiIntentService: resultado ya no queda forzado a 'ok'; ahora filtra
  bloques 'thought' igual que vision, y guarda tokens/raw en el detalle
  para poder diagnosticar sin acceso al servidor.
- ShowAiLogs: card y filtro de costo OCR en el dashboard de consumo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 01:52:59 +00:00
LizandroandClaude Sonnet 5 7b21cd9773 fix: chat web - mensaje al cancelar validacion, doble job, e intencion de compra
- ValidarPagoWebJob: avisa al usuario si la solicitud ya no esta pendiente
  en vez de saltar en silencio a "Menu principal".
- PublicChat: guard para no despachar dos jobs de validacion del mismo
  comprobante si Livewire dispara updatedFotoComprobante() dos veces
  (causaba que apareciera "Menu principal" y luego se corrigiera solo).
- PublicChat: "comprar hbo" caia en historial.ver porque compra(s)? sin \b
  matcheaba dentro de "comprar". Agregado limite de palabra.
- PublicChat: "comprar plan de netflix" caia en el listado generico de
  servicios por la palabra "plan". Ahora, si el mensaje nombra un servicio
  concreto, se abre ese servicio directo en vez del listado.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 01:21:14 +00:00
7 changed files with 96 additions and 21 deletions
+22 -2
View File
@@ -545,6 +545,14 @@ class PublicChat extends Component
return; return;
} }
// Guard contra doble disparo (Livewire a veces llama este hook 2 veces
// en la misma subida): si ya hay una validacion en curso, no lances otro
// job para el mismo comprobante o vas a tener dos jobs pisandose.
if ($this->flujo === 'pago.procesando') {
$this->dispatchBrowserEvent('comprobante-listo');
return;
}
try { try {
if (\App\Models\ChatConfig::get('validacion_foto_habilitada', '0') !== '1') { if (\App\Models\ChatConfig::get('validacion_foto_habilitada', '0') !== '1') {
$this->guardarMensajeBot($this->convId, "El analisis de comprobantes no esta habilitado. Contacta a un asesor."); $this->guardarMensajeBot($this->convId, "El analisis de comprobantes no esta habilitado. Contacta a un asesor.");
@@ -1633,8 +1641,10 @@ class PublicChat extends Component
'servicios.listar' => '/servicio|plan(es)?|cat[aá]logo|qu[eé]\s*(hay|tienen|tienes)|ver\s*plan|streaming|aplicacion|aplicaci[oó]n/', 'servicios.listar' => '/servicio|plan(es)?|cat[aá]logo|qu[eé]\s*(hay|tienen|tienes)|ver\s*plan|streaming|aplicacion|aplicaci[oó]n/',
// Promociones // Promociones
'promo.listar' => '/promo(ci[oó]n)?|oferta|descuento|especial/', 'promo.listar' => '/promo(ci[oó]n)?|oferta|descuento|especial/',
// Historial de compras // Historial de compras. \b es clave: sin el limite de palabra,
'historial.ver' => '/historial|compra(s)?|pedido(s)?|lo\s*que\s*(compr[eé]|he\s*comprado)|mis\s*compras/', // "compra(s)?" hacia match dentro de "comprar" y "no puedo entrar
// a comprar hbo" caia aqui en vez de abrir el servicio.
'historial.ver' => '/historial|\bcompra(s)?\b|pedido(s)?|lo\s*que\s*(compr[eé]|he\s*comprado)|mis\s*compras/',
// Perfil // Perfil
'perfil.ver' => '/perfil|mis\s*datos|mi\s*informaci[oó]n|mis\s*datos\s*personales/', 'perfil.ver' => '/perfil|mis\s*datos|mi\s*informaci[oó]n|mis\s*datos\s*personales/',
// Asesor humano // Asesor humano
@@ -1645,6 +1655,16 @@ class PublicChat extends Component
foreach ($patrones as $action => $pattern) { foreach ($patrones as $action => $pattern) {
if (preg_match($pattern, $t)) { if (preg_match($pattern, $t)) {
// "comprar plan de netflix" matchea aqui por la palabra "plan",
// pero si el mensaje ademas nombra un servicio concreto hay que
// abrir ese servicio directo, no el listado generico.
if ($action === 'servicios.listar') {
$porNombre = $this->detectarServicioPorNombre($texto);
if ($porNombre) {
return $porNombre;
}
}
$data = []; $data = [];
if ($action === 'recarga.iniciar') { if ($action === 'recarga.iniciar') {
if (preg_match('/\$?\s*(\d{1,3}(?:[.,]\d{3})+|\d{4,})\b/', $t, $m)) { if (preg_match('/\$?\s*(\d{1,3}(?:[.,]\d{3})+|\d{4,})\b/', $t, $m)) {
+2
View File
@@ -86,6 +86,8 @@ class ShowAiLogs extends Component
SUM(output_tokens) as total_output_tokens, SUM(output_tokens) as total_output_tokens,
SUM(audio_segundos) as total_segundos_whisper, SUM(audio_segundos) as total_segundos_whisper,
SUM(CASE WHEN servicio = 'whisper' THEN costo ELSE 0 END) as costo_whisper, SUM(CASE WHEN servicio = 'whisper' THEN costo ELSE 0 END) as costo_whisper,
SUM(CASE WHEN servicio = 'ocr' THEN 1 ELSE 0 END) as total_fotos_ocr,
SUM(CASE WHEN servicio = 'ocr' THEN costo ELSE 0 END) as costo_ocr,
SUM((COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) / 1000000.0 * 20000) as costo_gemini, SUM((COALESCE(input_tokens,0) + COALESCE(output_tokens,0)) / 1000000.0 * 20000) as costo_gemini,
AVG(tiempo_ms) as avg_tiempo_ms AVG(tiempo_ms) as avg_tiempo_ms
")->first(); ")->first();
+2 -1
View File
@@ -67,6 +67,7 @@ class ValidarPagoWebJob implements ShouldQueue
if (! $solicitud || $solicitud->id !== $this->solicitudId) { if (! $solicitud || $solicitud->id !== $this->solicitudId) {
Log::info("[ValidarPagoWebJob] conv={$this->convId}: la solicitud ya no está pendiente, abortando."); Log::info("[ValidarPagoWebJob] conv={$this->convId}: la solicitud ya no está pendiente, abortando.");
$this->bot("Tu solicitud de recarga ya no está activa (puede que hayas iniciado una nueva). Si ya transferiste, escribe a un asesor para confirmar tu pago manualmente.");
$this->resolver('cancelado'); $this->resolver('cancelado');
return; return;
} }
@@ -230,7 +231,7 @@ class ValidarPagoWebJob implements ShouldQueue
// de la cuota/estabilidad de Gemini Vision, que es lo que fallaba antes. // de la cuota/estabilidad de Gemini Vision, que es lo que fallaba antes.
$ocr = app(OcrExtractionService::class); $ocr = app(OcrExtractionService::class);
if ($ocr->habilitado()) { if ($ocr->habilitado()) {
$texto = $ocr->extraerTexto($base64, $mime); $texto = $ocr->extraerTexto($base64, $mime, $this->usuarioId, 'web');
if ($texto) { if ($texto) {
$datos = $gemini->extraerPagoDeTexto($texto, $this->usuarioId, 'web'); $datos = $gemini->extraerPagoDeTexto($texto, $this->usuarioId, 'web');
} else { } else {
+21 -4
View File
@@ -72,8 +72,20 @@ class GeminiIntentService
$inputTokens = $response->json('usageMetadata.promptTokenCount', 0); $inputTokens = $response->json('usageMetadata.promptTokenCount', 0);
$outputTokens = $response->json('usageMetadata.candidatesTokenCount', 0); $outputTokens = $response->json('usageMetadata.candidatesTokenCount', 0);
$text = $response->json('candidates.0.content.parts.0.text', '');
$resultado = $this->parseRespuesta($text); // Igual que GeminiVisionService: si el modelo manda bloques de
// "pensamiento" (thought=true), el JSON real puede no venir en la
// parte 0. Sin este filtro, parts.0.text agarraba el pensamiento
// y el parseo fallaba en silencio (quedaba marcado 'ok' igual).
$parts = $response->json('candidates.0.content.parts', []);
$text = implode("\n", array_column(
array_filter($parts, fn ($p) => isset($p['text']) && empty($p['thought'])),
'text'
));
$resultado = $this->parseRespuesta($text);
Log::info('[Gemini Intent] raw response: ' . substr($text, 0, 300));
AiUsageLog::registrar([ AiUsageLog::registrar([
'servicio' => 'gemini_intent', 'servicio' => 'gemini_intent',
@@ -82,8 +94,13 @@ class GeminiIntentService
'input_tokens' => $inputTokens, 'input_tokens' => $inputTokens,
'output_tokens'=> $outputTokens, 'output_tokens'=> $outputTokens,
'tiempo_ms' => $tiempoMs, 'tiempo_ms' => $tiempoMs,
'resultado' => 'ok', 'resultado' => $resultado ? 'ok' : 'error',
'detalle' => ['texto' => $texto, 'accion' => $resultado['action'] ?? null], 'detalle' => [
'texto' => $texto,
'accion' => $resultado['action'] ?? null,
'tokens' => "{$inputTokens}/{$outputTokens}",
'raw' => $resultado ? null : substr($text, 0, 200),
],
]); ]);
return $resultado; return $resultado;
+3 -2
View File
@@ -58,6 +58,7 @@ class GeminiVisionService
'generationConfig' => [ 'generationConfig' => [
'temperature' => 0.1, 'temperature' => 0.1,
'maxOutputTokens' => 2048, 'maxOutputTokens' => 2048,
'thinkingConfig' => ['thinkingBudget' => 0],
], ],
]; ];
@@ -85,7 +86,7 @@ class GeminiVisionService
$tiempoMs = (int) ((microtime(true) - $inicio) * 1000); $tiempoMs = (int) ((microtime(true) - $inicio) * 1000);
if (! $response) { if (! $response) {
Log::warning('[Gemini Vision] API failed: ' . $lastError); Log::warning("[Gemini {$servicio}] API failed: " . $lastError);
AiUsageLog::registrar([ AiUsageLog::registrar([
'servicio' => $servicio, 'servicio' => $servicio,
'canal' => $canal, 'canal' => $canal,
@@ -108,7 +109,7 @@ class GeminiVisionService
'text' 'text'
)); ));
Log::info('[Gemini Vision] raw response: ' . substr($text, 0, 500)); Log::info("[Gemini {$servicio}] raw response: " . substr($text, 0, 500));
$resultado = $this->parseRespuesta($text); $resultado = $this->parseRespuesta($text);
+31 -9
View File
@@ -2,12 +2,15 @@
namespace App\Services; namespace App\Services;
use App\Models\AiUsageLog;
use App\Models\ChatConfig; use App\Models\ChatConfig;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class OcrExtractionService class OcrExtractionService
{ {
private const COSTO_POR_FOTO = 5; // pesos COP, tarifa plana del servicio OCR
private string $url; private string $url;
private string $token; private string $token;
@@ -30,12 +33,14 @@ class OcrExtractionService
} }
/** Envia la imagen al servicio OCR externo y devuelve el texto crudo, o null si falla. */ /** Envia la imagen al servicio OCR externo y devuelve el texto crudo, o null si falla. */
public function extraerTexto(string $base64, string $mimeType): ?string public function extraerTexto(string $base64, string $mimeType, ?int $usuarioId = null, string $canal = 'web'): ?string
{ {
if (! $this->url) { if (! $this->url) {
return null; return null;
} }
$inicio = microtime(true);
try { try {
$response = Http::withToken($this->token) $response = Http::withToken($this->token)
->timeout(20) ->timeout(20)
@@ -44,23 +49,40 @@ class OcrExtractionService
'mime_type' => $mimeType, 'mime_type' => $mimeType,
]); ]);
if (! $response->successful()) { $tiempoMs = (int) ((microtime(true) - $inicio) * 1000);
Log::warning('[OCR] HTTP ' . $response->status() . ': ' . substr($response->body(), 0, 300)); $data = $response->json();
if (! $response->successful() || ! ($data['success'] ?? false) || empty($data['text'])) {
$error = $data['error'] ?? ('HTTP ' . $response->status());
Log::warning('[OCR] ' . $error . ': ' . substr($response->body(), 0, 300));
$this->registrarUso($usuarioId, $canal, $tiempoMs, 'error', ['error' => $error]);
return null; return null;
} }
$data = $response->json(); Log::info("[OCR] ok ({$tiempoMs}ms): " . substr($data['text'], 0, 200));
$this->registrarUso($usuarioId, $canal, $tiempoMs, 'ok', ['caracteres' => mb_strlen($data['text'])]);
if (! ($data['success'] ?? false) || empty($data['text'])) {
Log::warning('[OCR] respuesta sin texto util: ' . substr($response->body(), 0, 300));
return null;
}
return $data['text']; return $data['text'];
} catch (\Throwable $e) { } catch (\Throwable $e) {
$tiempoMs = (int) ((microtime(true) - $inicio) * 1000);
Log::warning('[OCR] excepcion: ' . $e->getMessage()); Log::warning('[OCR] excepcion: ' . $e->getMessage());
$this->registrarUso($usuarioId, $canal, $tiempoMs, 'error', ['error' => $e->getMessage()]);
return null; return null;
} }
} }
/** Cada foto enviada al OCR cuesta lo mismo, se lea bien o no. */
private function registrarUso(?int $usuarioId, string $canal, int $tiempoMs, string $resultado, array $detalle): void
{
AiUsageLog::registrar([
'servicio' => 'ocr',
'canal' => $canal,
'usuario_id' => $usuarioId,
'tiempo_ms' => $tiempoMs,
'costo' => self::COSTO_POR_FOTO,
'resultado' => $resultado,
'detalle' => $detalle,
]);
}
} }
@@ -9,9 +9,10 @@
@php @php
$costoGemini = $resumen->costo_gemini ?? 0; $costoGemini = $resumen->costo_gemini ?? 0;
$costoWhisper = $resumen->costo_whisper ?? 0; $costoWhisper = $resumen->costo_whisper ?? 0;
$costoTotal = $costoGemini + $costoWhisper; $costoOcr = $resumen->costo_ocr ?? 0;
$costoTotal = $costoGemini + $costoWhisper + $costoOcr;
@endphp @endphp
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4"> <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<div class="bg-white rounded-2xl border border-gray-100 shadow p-4"> <div class="bg-white rounded-2xl border border-gray-100 shadow p-4">
<p class="text-xs text-gray-400 mb-1">Total llamadas</p> <p class="text-xs text-gray-400 mb-1">Total llamadas</p>
<p class="text-2xl font-bold text-gray-800">{{ number_format($resumen->total_llamadas ?? 0) }}</p> <p class="text-2xl font-bold text-gray-800">{{ number_format($resumen->total_llamadas ?? 0) }}</p>
@@ -38,6 +39,11 @@
<p class="text-2xl font-bold text-blue-700">${{ number_format($costoGemini, 0, ',', '.') }}</p> <p class="text-2xl font-bold text-blue-700">${{ number_format($costoGemini, 0, ',', '.') }}</p>
<p class="text-xs text-gray-400 mt-1">$20.000 / millón tokens</p> <p class="text-xs text-gray-400 mt-1">$20.000 / millón tokens</p>
</div> </div>
<div class="bg-white rounded-2xl border border-gray-100 shadow p-4">
<p class="text-xs text-gray-400 mb-1">Costo OCR</p>
<p class="text-2xl font-bold text-orange-600">${{ number_format($costoOcr, 0, ',', '.') }}</p>
<p class="text-xs text-gray-400 mt-1">{{ number_format($resumen->total_fotos_ocr ?? 0) }} fotos · $5/foto</p>
</div>
<div class="bg-white rounded-2xl border border-gray-100 shadow p-4"> <div class="bg-white rounded-2xl border border-gray-100 shadow p-4">
<p class="text-xs text-gray-400 mb-1">Costo Total</p> <p class="text-xs text-gray-400 mb-1">Costo Total</p>
<p class="text-2xl font-bold text-emerald-700">${{ number_format($costoTotal, 0, ',', '.') }}</p> <p class="text-2xl font-bold text-emerald-700">${{ number_format($costoTotal, 0, ',', '.') }}</p>
@@ -75,6 +81,8 @@
<option value="">Todos</option> <option value="">Todos</option>
<option value="gemini_intent">Gemini Intent</option> <option value="gemini_intent">Gemini Intent</option>
<option value="gemini_vision">Gemini Vision</option> <option value="gemini_vision">Gemini Vision</option>
<option value="gemini_text">Gemini Texto (post-OCR)</option>
<option value="ocr">OCR</option>
<option value="whisper">Whisper</option> <option value="whisper">Whisper</option>
</select> </select>
</div> </div>
@@ -118,16 +126,20 @@
$badgeServicio = match($log->servicio) { $badgeServicio = match($log->servicio) {
'gemini_intent' => 'bg-blue-100 text-blue-700', 'gemini_intent' => 'bg-blue-100 text-blue-700',
'gemini_vision' => 'bg-indigo-100 text-indigo-700', 'gemini_vision' => 'bg-indigo-100 text-indigo-700',
'gemini_text' => 'bg-sky-100 text-sky-700',
'ocr' => 'bg-orange-100 text-orange-700',
'whisper' => 'bg-purple-100 text-purple-700', 'whisper' => 'bg-purple-100 text-purple-700',
default => 'bg-gray-100 text-gray-600', default => 'bg-gray-100 text-gray-600',
}; };
$labelServicio = match($log->servicio) { $labelServicio = match($log->servicio) {
'gemini_intent' => 'Gemini Intent', 'gemini_intent' => 'Gemini Intent',
'gemini_vision' => 'Gemini Vision', 'gemini_vision' => 'Gemini Vision',
'gemini_text' => 'Gemini Texto',
'ocr' => 'OCR',
'whisper' => 'Whisper', 'whisper' => 'Whisper',
default => $log->servicio, default => $log->servicio,
}; };
$costoFila = in_array($log->servicio, ['gemini_intent', 'gemini_vision']) $costoFila = in_array($log->servicio, ['gemini_intent', 'gemini_vision', 'gemini_text'])
? (($log->input_tokens ?? 0) + ($log->output_tokens ?? 0)) / 1000000 * 20000 ? (($log->input_tokens ?? 0) + ($log->output_tokens ?? 0)) / 1000000 * 20000
: ($log->costo ?? 0); : ($log->costo ?? 0);
@endphp @endphp