feat: leer comprobantes via OCR externo + Gemini solo-texto
Antes, si Gemini Vision fallaba (cuota/API), el 100% de los comprobantes del chat web quedaban sin poder leerse. Ahora el job intenta primero un servicio OCR externo configurable (imagen -> texto) y le pasa el texto a Gemini para que solo lo estructure en JSON, más barato y sin la cuota de visión. Si el OCR no está habilitado o falla, cae al método anterior (Gemini leyendo la imagen directamente) sin romper nada. Agrega configuración en el panel (URL/token/habilitado + probar conexión) y la spec del servicio OCR a desplegar en docs/ocr-service-spec.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
7f4053ea62
commit
7afd22d581
@@ -18,6 +18,7 @@ class ShowConfiguracionChat extends Component
|
||||
public string $webhookResult = '';
|
||||
public string $whisperResult = '';
|
||||
public string $geminiResult = '';
|
||||
public string $ocrResult = '';
|
||||
|
||||
// ── Gemini IA ─────────────────────────────────────────────
|
||||
public string $gemini_api_key = '';
|
||||
@@ -35,6 +36,11 @@ class ShowConfiguracionChat extends Component
|
||||
public string $whisper_token = '';
|
||||
public string $whisper_habilitado = '0';
|
||||
|
||||
// ── OCR de comprobantes (previo a Gemini) ──────────────────
|
||||
public string $ocr_url = '';
|
||||
public string $ocr_token = '';
|
||||
public string $ocr_habilitado = '0';
|
||||
|
||||
// ── Bre-B ─────────────────────────────────────────────────
|
||||
public string $recarga_banco_nombre = '';
|
||||
public string $recarga_banco_llave = '';
|
||||
@@ -47,6 +53,7 @@ class ShowConfiguracionChat extends Component
|
||||
'gemini_api_key', 'gemini_model', 'gemini_habilitado', 'gemini_prompt_extra',
|
||||
'validacion_foto_habilitada', 'validacion_accion_auto', 'validacion_monto_tolerancia',
|
||||
'whisper_url', 'whisper_token', 'whisper_habilitado',
|
||||
'ocr_url', 'ocr_token', 'ocr_habilitado',
|
||||
'recarga_banco_nombre', 'recarga_banco_llave', 'recarga_banco_titular',
|
||||
];
|
||||
|
||||
@@ -79,6 +86,7 @@ class ShowConfiguracionChat extends Component
|
||||
'gemini_api_key', 'gemini_model', 'gemini_habilitado', 'gemini_prompt_extra',
|
||||
'validacion_foto_habilitada', 'validacion_accion_auto', 'validacion_monto_tolerancia',
|
||||
'whisper_url', 'whisper_token', 'whisper_habilitado',
|
||||
'ocr_url', 'ocr_token', 'ocr_habilitado',
|
||||
'recarga_banco_nombre', 'recarga_banco_llave', 'recarga_banco_titular',
|
||||
];
|
||||
|
||||
@@ -172,6 +180,42 @@ class ShowConfiguracionChat extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function probarOcr(): void
|
||||
{
|
||||
$url = rtrim(trim($this->ocr_url ?: ChatConfig::get('ocr_url')), '/');
|
||||
$token = trim($this->ocr_token ?: ChatConfig::get('ocr_token'));
|
||||
|
||||
if (! $url) {
|
||||
$this->ocrResult = '⚠️ La URL del servicio OCR está vacía.';
|
||||
return;
|
||||
}
|
||||
|
||||
// 1x1 png transparente, solo para verificar que el servicio responde.
|
||||
$pixel = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=');
|
||||
|
||||
try {
|
||||
$inicio = microtime(true);
|
||||
$response = Http::withToken($token)
|
||||
->timeout(15)
|
||||
->post("{$url}/extract", [
|
||||
'image_base64' => base64_encode($pixel),
|
||||
'mime_type' => 'image/png',
|
||||
]);
|
||||
|
||||
$ms = (int) ((microtime(true) - $inicio) * 1000);
|
||||
|
||||
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() . ": " .
|
||||
substr($response->body(), 0, 200);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->ocrResult = '❌ No se pudo conectar con el servicio OCR: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
public function registrarWebhook(): void
|
||||
{
|
||||
$token = trim($this->telegram_token);
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\Saldo;
|
||||
use App\Models\SolicitudRecarga;
|
||||
use App\Models\User;
|
||||
use App\Services\GeminiVisionService;
|
||||
use App\Services\OcrExtractionService;
|
||||
use App\Services\PagoValidadorService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -220,12 +221,28 @@ class ValidarPagoWebJob implements ShouldQueue
|
||||
return null;
|
||||
}
|
||||
|
||||
$datos = app(GeminiVisionService::class)->extraerPago(
|
||||
base64_encode(file_get_contents($ruta)),
|
||||
mime_content_type($ruta) ?: 'image/jpeg',
|
||||
$this->usuarioId,
|
||||
'web'
|
||||
);
|
||||
$base64 = base64_encode(file_get_contents($ruta));
|
||||
$mime = mime_content_type($ruta) ?: 'image/jpeg';
|
||||
$gemini = app(GeminiVisionService::class);
|
||||
$datos = null;
|
||||
|
||||
// Camino nuevo: OCR externo (texto) + Gemini solo-texto. Evita depender
|
||||
// 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);
|
||||
if ($texto) {
|
||||
$datos = $gemini->extraerPagoDeTexto($texto, $this->usuarioId, 'web');
|
||||
} else {
|
||||
Log::warning('[ValidarPagoWebJob] OCR no devolvio texto, cae a lectura por vision.');
|
||||
}
|
||||
}
|
||||
|
||||
// Si el OCR no esta habilitado, no respondio, o Gemini no pudo
|
||||
// estructurar su texto: cae al metodo anterior (Gemini lee la imagen).
|
||||
if (! $datos || isset($datos['__error']) || isset($datos['__parse_error'])) {
|
||||
$datos = $gemini->extraerPago($base64, $mime, $this->usuarioId, 'web');
|
||||
}
|
||||
|
||||
if (! $datos || isset($datos['__error']) || isset($datos['__parse_error'])) {
|
||||
Log::warning('[ValidarPagoWebJob] Gemini: ' . ($datos['__error'] ?? $datos['__parse_error'] ?? 'sin respuesta'));
|
||||
|
||||
@@ -20,11 +20,6 @@ class GeminiVisionService
|
||||
|
||||
public function extraerPago(string $base64, string $mimeType, ?int $usuarioId = null, string $canal = 'telegram'): ?array
|
||||
{
|
||||
if (! $this->apiKey) {
|
||||
Log::warning('[Gemini Vision] No hay API key configurada.');
|
||||
return null;
|
||||
}
|
||||
|
||||
$contents = [[
|
||||
'parts' => [
|
||||
['text' => $this->buildPrompt()],
|
||||
@@ -32,6 +27,32 @@ class GeminiVisionService
|
||||
],
|
||||
]];
|
||||
|
||||
return $this->ejecutar($contents, $usuarioId, $canal, 'gemini_vision');
|
||||
}
|
||||
|
||||
/**
|
||||
* Igual que extraerPago(), pero en vez de mandar la imagen manda texto plano
|
||||
* (ya extraido por un servicio de OCR externo) para que Gemini solo lo
|
||||
* estructure en JSON. Mas barato y no depende de la cuota de vision.
|
||||
*/
|
||||
public function extraerPagoDeTexto(string $texto, ?int $usuarioId = null, string $canal = 'web'): ?array
|
||||
{
|
||||
$contents = [[
|
||||
'parts' => [
|
||||
['text' => $this->buildPromptTexto($texto)],
|
||||
],
|
||||
]];
|
||||
|
||||
return $this->ejecutar($contents, $usuarioId, $canal, 'gemini_text');
|
||||
}
|
||||
|
||||
private function ejecutar(array $contents, ?int $usuarioId, string $canal, string $servicio): ?array
|
||||
{
|
||||
if (! $this->apiKey) {
|
||||
Log::warning('[Gemini Vision] No hay API key configurada.');
|
||||
return null;
|
||||
}
|
||||
|
||||
$body = [
|
||||
'contents' => $contents,
|
||||
'generationConfig' => [
|
||||
@@ -66,7 +87,7 @@ class GeminiVisionService
|
||||
if (! $response) {
|
||||
Log::warning('[Gemini Vision] API failed: ' . $lastError);
|
||||
AiUsageLog::registrar([
|
||||
'servicio' => 'gemini_vision',
|
||||
'servicio' => $servicio,
|
||||
'canal' => $canal,
|
||||
'usuario_id' => $usuarioId,
|
||||
'tiempo_ms' => $tiempoMs,
|
||||
@@ -92,7 +113,7 @@ class GeminiVisionService
|
||||
$resultado = $this->parseRespuesta($text);
|
||||
|
||||
AiUsageLog::registrar([
|
||||
'servicio' => 'gemini_vision',
|
||||
'servicio' => $servicio,
|
||||
'canal' => $canal,
|
||||
'usuario_id' => $usuarioId,
|
||||
'input_tokens' => $inputTokens,
|
||||
@@ -134,6 +155,35 @@ Si la imagen NO es un comprobante de pago, responde exactamente: {"error": "no_e
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
private function buildPromptTexto(string $texto): string
|
||||
{
|
||||
return <<<PROMPT
|
||||
El siguiente texto fue extraido por OCR de una imagen de un comprobante de pago
|
||||
bancario o transferencia. Puede tener errores de lectura, palabras cortadas o
|
||||
lineas desordenadas.
|
||||
|
||||
TEXTO OCR:
|
||||
"""
|
||||
{$texto}
|
||||
"""
|
||||
|
||||
Extrae los siguientes campos:
|
||||
- banco: nombre del banco emisor (Bancolombia, Nequi, Davivienda, Banco de Bogota, etc.)
|
||||
- valor: monto en numeros enteros sin simbolos ni puntos (ej: 50000)
|
||||
- referencia: numero de referencia o transaccion (si existe)
|
||||
- fecha: fecha en formato dd/mm/yyyy
|
||||
- hora: hora en formato HH:mm (si existe)
|
||||
- remitente: nombre de quien envia el dinero (si aparece)
|
||||
- llave: llave de pago Bancolombia o Nequi del destinatario, empieza con @ (ej: @leon8909). Solo si aparece en el comprobante.
|
||||
|
||||
Responde UNICAMENTE con un JSON valido, sin markdown, sin explicacion:
|
||||
{"banco": "...", "valor": 50000, "referencia": "...", "fecha": "...", "hora": "...", "remitente": "...", "llave": "..."}
|
||||
|
||||
Si no encuentras un campo, usa null para ese campo.
|
||||
Si el texto NO corresponde a un comprobante de pago, responde exactamente: {"error": "no_es_comprobante"}
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
private function parseRespuesta(string $text): ?array
|
||||
{
|
||||
// Strip markdown fences
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ChatConfig;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class OcrExtractionService
|
||||
{
|
||||
private string $url;
|
||||
private string $token;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->url = rtrim(ChatConfig::get('ocr_url', ''), '/');
|
||||
$this->token = ChatConfig::get('ocr_token', '');
|
||||
}
|
||||
|
||||
public function habilitado(): bool
|
||||
{
|
||||
return ChatConfig::get('ocr_habilitado', '0') === '1' && $this->url !== '';
|
||||
}
|
||||
|
||||
/** Envia la imagen al servicio OCR externo y devuelve el texto crudo, o null si falla. */
|
||||
public function extraerTexto(string $base64, string $mimeType): ?string
|
||||
{
|
||||
if (! $this->url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withToken($this->token)
|
||||
->timeout(20)
|
||||
->post("{$this->url}/extract", [
|
||||
'image_base64' => $base64,
|
||||
'mime_type' => $mimeType,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::warning('[OCR] HTTP ' . $response->status() . ': ' . substr($response->body(), 0, 300));
|
||||
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;
|
||||
}
|
||||
|
||||
return $data['text'];
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('[OCR] excepcion: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user