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(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();
+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. // 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