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:
Lizandro
2026-08-10 21:49:34 +00:00
co-authored by Claude Sonnet 5
parent 7f4053ea62
commit 7afd22d581
6 changed files with 435 additions and 13 deletions
+57 -7
View File
@@ -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