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:
co-authored by
Claude Sonnet 5
parent
7b21cd9773
commit
5e0c0367d9
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user