diff --git a/app/Http/Livewire/Chat/ShowAiLogs.php b/app/Http/Livewire/Chat/ShowAiLogs.php index a57b7ac..b070efd 100755 --- a/app/Http/Livewire/Chat/ShowAiLogs.php +++ b/app/Http/Livewire/Chat/ShowAiLogs.php @@ -86,6 +86,8 @@ class ShowAiLogs extends Component SUM(output_tokens) as total_output_tokens, SUM(audio_segundos) as total_segundos_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, AVG(tiempo_ms) as avg_tiempo_ms ")->first(); diff --git a/app/Jobs/ValidarPagoWebJob.php b/app/Jobs/ValidarPagoWebJob.php index 2340998..c2771e5 100644 --- a/app/Jobs/ValidarPagoWebJob.php +++ b/app/Jobs/ValidarPagoWebJob.php @@ -231,7 +231,7 @@ class ValidarPagoWebJob implements ShouldQueue // de la cuota/estabilidad de Gemini Vision, que es lo que fallaba antes. $ocr = app(OcrExtractionService::class); if ($ocr->habilitado()) { - $texto = $ocr->extraerTexto($base64, $mime); + $texto = $ocr->extraerTexto($base64, $mime, $this->usuarioId, 'web'); if ($texto) { $datos = $gemini->extraerPagoDeTexto($texto, $this->usuarioId, 'web'); } else { diff --git a/app/Services/GeminiIntentService.php b/app/Services/GeminiIntentService.php index 9b16301..b54d730 100755 --- a/app/Services/GeminiIntentService.php +++ b/app/Services/GeminiIntentService.php @@ -72,8 +72,20 @@ class GeminiIntentService $inputTokens = $response->json('usageMetadata.promptTokenCount', 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([ 'servicio' => 'gemini_intent', @@ -82,8 +94,13 @@ class GeminiIntentService 'input_tokens' => $inputTokens, 'output_tokens'=> $outputTokens, 'tiempo_ms' => $tiempoMs, - 'resultado' => 'ok', - 'detalle' => ['texto' => $texto, 'accion' => $resultado['action'] ?? null], + 'resultado' => $resultado ? 'ok' : 'error', + 'detalle' => [ + 'texto' => $texto, + 'accion' => $resultado['action'] ?? null, + 'tokens' => "{$inputTokens}/{$outputTokens}", + 'raw' => $resultado ? null : substr($text, 0, 200), + ], ]); return $resultado; diff --git a/app/Services/GeminiVisionService.php b/app/Services/GeminiVisionService.php index baec410..084bdf3 100755 --- a/app/Services/GeminiVisionService.php +++ b/app/Services/GeminiVisionService.php @@ -58,6 +58,7 @@ class GeminiVisionService 'generationConfig' => [ 'temperature' => 0.1, 'maxOutputTokens' => 2048, + 'thinkingConfig' => ['thinkingBudget' => 0], ], ]; @@ -85,7 +86,7 @@ class GeminiVisionService $tiempoMs = (int) ((microtime(true) - $inicio) * 1000); if (! $response) { - Log::warning('[Gemini Vision] API failed: ' . $lastError); + Log::warning("[Gemini {$servicio}] API failed: " . $lastError); AiUsageLog::registrar([ 'servicio' => $servicio, 'canal' => $canal, @@ -108,7 +109,7 @@ class GeminiVisionService '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); diff --git a/app/Services/OcrExtractionService.php b/app/Services/OcrExtractionService.php index 22ff500..c0a0eb3 100644 --- a/app/Services/OcrExtractionService.php +++ b/app/Services/OcrExtractionService.php @@ -2,12 +2,15 @@ namespace App\Services; +use App\Models\AiUsageLog; use App\Models\ChatConfig; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class OcrExtractionService { + private const COSTO_POR_FOTO = 5; // pesos COP, tarifa plana del servicio OCR + private string $url; 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. */ - 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) { return null; } + $inicio = microtime(true); + try { $response = Http::withToken($this->token) ->timeout(20) @@ -44,23 +49,40 @@ class OcrExtractionService 'mime_type' => $mimeType, ]); - if (! $response->successful()) { - Log::warning('[OCR] HTTP ' . $response->status() . ': ' . substr($response->body(), 0, 300)); + $tiempoMs = (int) ((microtime(true) - $inicio) * 1000); + $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; } - $data = $response->json(); - - if (! ($data['success'] ?? false) || empty($data['text'])) { - Log::warning('[OCR] respuesta sin texto util: ' . substr($response->body(), 0, 300)); - return null; - } + Log::info("[OCR] ok ({$tiempoMs}ms): " . substr($data['text'], 0, 200)); + $this->registrarUso($usuarioId, $canal, $tiempoMs, 'ok', ['caracteres' => mb_strlen($data['text'])]); return $data['text']; } catch (\Throwable $e) { + $tiempoMs = (int) ((microtime(true) - $inicio) * 1000); Log::warning('[OCR] excepcion: ' . $e->getMessage()); + $this->registrarUso($usuarioId, $canal, $tiempoMs, 'error', ['error' => $e->getMessage()]); 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, + ]); + } } diff --git a/resources/views/livewire/chat/show-ai-logs.blade.php b/resources/views/livewire/chat/show-ai-logs.blade.php index 559d353..348d742 100755 --- a/resources/views/livewire/chat/show-ai-logs.blade.php +++ b/resources/views/livewire/chat/show-ai-logs.blade.php @@ -9,9 +9,10 @@ @php $costoGemini = $resumen->costo_gemini ?? 0; $costoWhisper = $resumen->costo_whisper ?? 0; - $costoTotal = $costoGemini + $costoWhisper; + $costoOcr = $resumen->costo_ocr ?? 0; + $costoTotal = $costoGemini + $costoWhisper + $costoOcr; @endphp -
+

Total llamadas

{{ number_format($resumen->total_llamadas ?? 0) }}

@@ -38,6 +39,11 @@

${{ number_format($costoGemini, 0, ',', '.') }}

$20.000 / millón tokens

+
+

Costo OCR

+

${{ number_format($costoOcr, 0, ',', '.') }}

+

{{ number_format($resumen->total_fotos_ocr ?? 0) }} fotos · $5/foto

+

Costo Total

${{ number_format($costoTotal, 0, ',', '.') }}

@@ -75,6 +81,8 @@ + +
@@ -118,16 +126,20 @@ $badgeServicio = match($log->servicio) { 'gemini_intent' => 'bg-blue-100 text-blue-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', default => 'bg-gray-100 text-gray-600', }; $labelServicio = match($log->servicio) { 'gemini_intent' => 'Gemini Intent', 'gemini_vision' => 'Gemini Vision', + 'gemini_text' => 'Gemini Texto', + 'ocr' => 'OCR', 'whisper' => 'Whisper', 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->costo ?? 0); @endphp