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>
This commit is contained in:
Lizandro
2026-08-11 01:52:59 +00:00
co-authored by Claude Sonnet 5
parent 7b21cd9773
commit 5e0c0367d9
6 changed files with 73 additions and 19 deletions
+2
View File
@@ -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();
+1 -1
View File
@@ -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 {
+20 -3
View File
@@ -72,9 +72,21 @@ class GeminiIntentService
$inputTokens = $response->json('usageMetadata.promptTokenCount', 0);
$outputTokens = $response->json('usageMetadata.candidatesTokenCount', 0);
$text = $response->json('candidates.0.content.parts.0.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',
'canal' => $canal,
@@ -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;
+3 -2
View File
@@ -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);
+30 -8
View File
@@ -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));
return null;
}
$tiempoMs = (int) ((microtime(true) - $inicio) * 1000);
$data = $response->json();
if (! ($data['success'] ?? false) || empty($data['text'])) {
Log::warning('[OCR] respuesta sin texto util: ' . substr($response->body(), 0, 300));
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;
}
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,
]);
}
}
@@ -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
<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">
<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>
@@ -38,6 +39,11 @@
<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>
</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">
<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>
@@ -75,6 +81,8 @@
<option value="">Todos</option>
<option value="gemini_intent">Gemini Intent</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>
</select>
</div>
@@ -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