Compare commits
4
Commits
978c7c1492
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e0c0367d9 | ||
|
|
7b21cd9773 | ||
|
|
f5e746abcd | ||
|
|
38f0986aad |
@@ -545,6 +545,14 @@ class PublicChat extends Component
|
||||
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 {
|
||||
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.");
|
||||
@@ -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/',
|
||||
// Promociones
|
||||
'promo.listar' => '/promo(ci[oó]n)?|oferta|descuento|especial/',
|
||||
// Historial de compras
|
||||
'historial.ver' => '/historial|compra(s)?|pedido(s)?|lo\s*que\s*(compr[eé]|he\s*comprado)|mis\s*compras/',
|
||||
// Historial de compras. \b es clave: sin el limite de palabra,
|
||||
// "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.ver' => '/perfil|mis\s*datos|mi\s*informaci[oó]n|mis\s*datos\s*personales/',
|
||||
// Asesor humano
|
||||
@@ -1645,6 +1655,16 @@ class PublicChat extends Component
|
||||
|
||||
foreach ($patrones as $action => $pattern) {
|
||||
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 = [];
|
||||
if ($action === 'recarga.iniciar') {
|
||||
if (preg_match('/\$?\s*(\d{1,3}(?:[.,]\d{3})+|\d{4,})\b/', $t, $m)) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -190,26 +190,35 @@ class ShowConfiguracionChat extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
// 1x1 png transparente, solo para verificar que el servicio responde.
|
||||
$pixel = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=');
|
||||
// PNG 240x80 con el texto "TEST OCR 123" renderizado, para que la prueba
|
||||
// sea real: un pixel en blanco siempre da "sin_texto_detectado" aunque
|
||||
// todo funcione bien.
|
||||
$imagenPrueba = 'iVBORw0KGgoAAAANSUhEUgAAAPAAAABQCAIAAACoK28rAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAB6ElEQVR4nO3c3W6iUBhA0WHS939l5sLENPxpPRCd3bXuWgSPZVc/MXGa5/kPVPx99wLgTIImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSfka2Xmaps3fz/O8uen+5errrbdNBwf80WIWt18c9uC+nv/699vu69vvLWNzDZxuOuUvuz67e+f7yRsf7P5wAYt9934cudN7nZv/OetlHC+JE33EyDHP88jZXfTxvaF1OuMZ7b2MHCxj8QCPX44YMTRy/KcGm97LcXxcYdy1QW8OlLeX+w+ZKa9bxma1e7MKZ7k26L3Ttj7N03TONP+yc+997zn4/uz+9sdb9REz9Bvdp9sTJ9qHE4UZ+jq/MeifXjR84eCefd/lDUGvB+hBixy/J7Uu9fhS9+DCDi5Or9cg+itcex164eEHKwcHfHINB0dbbH35OvRrnwp9yJvgPG9NSPmNMzRhgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImpR/3UrzkDoP+sMAAAAASUVORK5CYII=';
|
||||
|
||||
try {
|
||||
$inicio = microtime(true);
|
||||
$response = Http::withToken($token)
|
||||
->timeout(15)
|
||||
->post("{$url}/extract", [
|
||||
'image_base64' => base64_encode($pixel),
|
||||
'image_base64' => $imagenPrueba,
|
||||
'mime_type' => 'image/png',
|
||||
]);
|
||||
|
||||
$ms = (int) ((microtime(true) - $inicio) * 1000);
|
||||
$ms = (int) ((microtime(true) - $inicio) * 1000);
|
||||
$data = $response->json();
|
||||
|
||||
if ($response->successful()) {
|
||||
$this->ocrResult = "✅ Servicio OCR responde OK ({$ms}ms). " .
|
||||
"Respuesta: " . substr($response->body(), 0, 200);
|
||||
} else {
|
||||
$this->ocrResult = "❌ El servicio respondió con error " . $response->status() . ": " .
|
||||
if ($data === null) {
|
||||
// No respondio JSON valido: la conexion en si fallo (ruta mal, host caido, etc).
|
||||
$this->ocrResult = "❌ El servicio respondió HTTP " . $response->status() . " sin JSON válido: " .
|
||||
substr($response->body(), 0, 200);
|
||||
} elseif ($data['success'] ?? false) {
|
||||
$texto = $data['text'] ?? '';
|
||||
$this->ocrResult = "✅ Servicio OCR responde OK ({$ms}ms). Texto reconocido: \"" . substr($texto, 0, 150) . "\"";
|
||||
} else {
|
||||
// Respondio con JSON estructurado pero success=false: SI esta conectado,
|
||||
// solo que no reconocio el texto de la imagen de prueba.
|
||||
$this->ocrResult = "⚠️ El servicio conectó pero no reconoció el texto de prueba (HTTP {$response->status()}): " .
|
||||
($data['error'] ?? 'sin detalle');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->ocrResult = '❌ No se pudo conectar con el servicio OCR: ' . $e->getMessage();
|
||||
|
||||
@@ -67,6 +67,7 @@ class ValidarPagoWebJob implements ShouldQueue
|
||||
|
||||
if (! $solicitud || $solicitud->id !== $this->solicitudId) {
|
||||
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');
|
||||
return;
|
||||
}
|
||||
@@ -230,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,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;
|
||||
|
||||
@@ -57,7 +57,8 @@ class GeminiVisionService
|
||||
'contents' => $contents,
|
||||
'generationConfig' => [
|
||||
'temperature' => 0.1,
|
||||
'maxOutputTokens' => 512,
|
||||
'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));
|
||||
$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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -182,7 +182,7 @@
|
||||
<span wire:loading wire:target="probarOcr">Probando...</span>
|
||||
</button>
|
||||
@if($ocrResult)
|
||||
<p class="mt-2 text-sm {{ str_contains($ocrResult, '✅') ? 'text-emerald-600' : 'text-red-600' }}">
|
||||
<p class="mt-2 text-sm {{ str_contains($ocrResult, '✅') ? 'text-emerald-600' : (str_contains($ocrResult, '⚠️') ? 'text-amber-600' : 'text-red-600') }}">
|
||||
{{ $ocrResult }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
Reference in New Issue
Block a user