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 $webhookResult = '';
|
||||||
public string $whisperResult = '';
|
public string $whisperResult = '';
|
||||||
public string $geminiResult = '';
|
public string $geminiResult = '';
|
||||||
|
public string $ocrResult = '';
|
||||||
|
|
||||||
// ── Gemini IA ─────────────────────────────────────────────
|
// ── Gemini IA ─────────────────────────────────────────────
|
||||||
public string $gemini_api_key = '';
|
public string $gemini_api_key = '';
|
||||||
@@ -35,6 +36,11 @@ class ShowConfiguracionChat extends Component
|
|||||||
public string $whisper_token = '';
|
public string $whisper_token = '';
|
||||||
public string $whisper_habilitado = '0';
|
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 ─────────────────────────────────────────────────
|
// ── Bre-B ─────────────────────────────────────────────────
|
||||||
public string $recarga_banco_nombre = '';
|
public string $recarga_banco_nombre = '';
|
||||||
public string $recarga_banco_llave = '';
|
public string $recarga_banco_llave = '';
|
||||||
@@ -47,6 +53,7 @@ class ShowConfiguracionChat extends Component
|
|||||||
'gemini_api_key', 'gemini_model', 'gemini_habilitado', 'gemini_prompt_extra',
|
'gemini_api_key', 'gemini_model', 'gemini_habilitado', 'gemini_prompt_extra',
|
||||||
'validacion_foto_habilitada', 'validacion_accion_auto', 'validacion_monto_tolerancia',
|
'validacion_foto_habilitada', 'validacion_accion_auto', 'validacion_monto_tolerancia',
|
||||||
'whisper_url', 'whisper_token', 'whisper_habilitado',
|
'whisper_url', 'whisper_token', 'whisper_habilitado',
|
||||||
|
'ocr_url', 'ocr_token', 'ocr_habilitado',
|
||||||
'recarga_banco_nombre', 'recarga_banco_llave', 'recarga_banco_titular',
|
'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',
|
'gemini_api_key', 'gemini_model', 'gemini_habilitado', 'gemini_prompt_extra',
|
||||||
'validacion_foto_habilitada', 'validacion_accion_auto', 'validacion_monto_tolerancia',
|
'validacion_foto_habilitada', 'validacion_accion_auto', 'validacion_monto_tolerancia',
|
||||||
'whisper_url', 'whisper_token', 'whisper_habilitado',
|
'whisper_url', 'whisper_token', 'whisper_habilitado',
|
||||||
|
'ocr_url', 'ocr_token', 'ocr_habilitado',
|
||||||
'recarga_banco_nombre', 'recarga_banco_llave', 'recarga_banco_titular',
|
'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
|
public function registrarWebhook(): void
|
||||||
{
|
{
|
||||||
$token = trim($this->telegram_token);
|
$token = trim($this->telegram_token);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Models\Saldo;
|
|||||||
use App\Models\SolicitudRecarga;
|
use App\Models\SolicitudRecarga;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\GeminiVisionService;
|
use App\Services\GeminiVisionService;
|
||||||
|
use App\Services\OcrExtractionService;
|
||||||
use App\Services\PagoValidadorService;
|
use App\Services\PagoValidadorService;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
@@ -220,12 +221,28 @@ class ValidarPagoWebJob implements ShouldQueue
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$datos = app(GeminiVisionService::class)->extraerPago(
|
$base64 = base64_encode(file_get_contents($ruta));
|
||||||
base64_encode(file_get_contents($ruta)),
|
$mime = mime_content_type($ruta) ?: 'image/jpeg';
|
||||||
mime_content_type($ruta) ?: 'image/jpeg',
|
$gemini = app(GeminiVisionService::class);
|
||||||
$this->usuarioId,
|
$datos = null;
|
||||||
'web'
|
|
||||||
);
|
// 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'])) {
|
if (! $datos || isset($datos['__error']) || isset($datos['__parse_error'])) {
|
||||||
Log::warning('[ValidarPagoWebJob] Gemini: ' . ($datos['__error'] ?? $datos['__parse_error'] ?? 'sin respuesta'));
|
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
|
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 = [[
|
$contents = [[
|
||||||
'parts' => [
|
'parts' => [
|
||||||
['text' => $this->buildPrompt()],
|
['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 = [
|
$body = [
|
||||||
'contents' => $contents,
|
'contents' => $contents,
|
||||||
'generationConfig' => [
|
'generationConfig' => [
|
||||||
@@ -66,7 +87,7 @@ class GeminiVisionService
|
|||||||
if (! $response) {
|
if (! $response) {
|
||||||
Log::warning('[Gemini Vision] API failed: ' . $lastError);
|
Log::warning('[Gemini Vision] API failed: ' . $lastError);
|
||||||
AiUsageLog::registrar([
|
AiUsageLog::registrar([
|
||||||
'servicio' => 'gemini_vision',
|
'servicio' => $servicio,
|
||||||
'canal' => $canal,
|
'canal' => $canal,
|
||||||
'usuario_id' => $usuarioId,
|
'usuario_id' => $usuarioId,
|
||||||
'tiempo_ms' => $tiempoMs,
|
'tiempo_ms' => $tiempoMs,
|
||||||
@@ -92,7 +113,7 @@ class GeminiVisionService
|
|||||||
$resultado = $this->parseRespuesta($text);
|
$resultado = $this->parseRespuesta($text);
|
||||||
|
|
||||||
AiUsageLog::registrar([
|
AiUsageLog::registrar([
|
||||||
'servicio' => 'gemini_vision',
|
'servicio' => $servicio,
|
||||||
'canal' => $canal,
|
'canal' => $canal,
|
||||||
'usuario_id' => $usuarioId,
|
'usuario_id' => $usuarioId,
|
||||||
'input_tokens' => $inputTokens,
|
'input_tokens' => $inputTokens,
|
||||||
@@ -134,6 +155,35 @@ Si la imagen NO es un comprobante de pago, responde exactamente: {"error": "no_e
|
|||||||
PROMPT;
|
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
|
private function parseRespuesta(string $text): ?array
|
||||||
{
|
{
|
||||||
// Strip markdown fences
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# Servicio OCR para lectura de comprobantes — Especificación
|
||||||
|
|
||||||
|
Este documento describe el microservicio que hay que desplegar (en Coolify u otro
|
||||||
|
host con Docker) para que SirPremium deje de depender únicamente de Gemini Vision
|
||||||
|
para leer comprobantes de pago. El flujo pasa a ser:
|
||||||
|
|
||||||
|
```
|
||||||
|
imagen del comprobante → [ESTE SERVICIO OCR] → texto plano → Gemini (solo texto) → JSON estructurado
|
||||||
|
```
|
||||||
|
|
||||||
|
Gemini deja de "ver" la imagen; solo recibe texto y lo ordena en JSON. Esto evita el
|
||||||
|
punto único de falla que tenías hoy (si Gemini Vision falla, cae el 100% de las
|
||||||
|
lecturas) y reduce el consumo de cuota, porque los modelos de texto son más baratos
|
||||||
|
y menos limitados que los de visión.
|
||||||
|
|
||||||
|
## 1. Qué tiene que exponer el servicio
|
||||||
|
|
||||||
|
Un único endpoint HTTP:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /extract
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <OCR_TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"image_base64": "<imagen en base64, SIN el prefijo data:image/...;base64,>",
|
||||||
|
"mime_type": "image/jpeg"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `mime_type` puede ser `image/jpeg`, `image/png`, `image/webp` o `image/heic`
|
||||||
|
(son los formatos que ya acepta la app hoy).
|
||||||
|
- El token se valida por header `Authorization: Bearer`. Si no coincide, responder
|
||||||
|
`401`.
|
||||||
|
|
||||||
|
### Response — éxito (HTTP 200)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"text": "Transferencia exitosa\nBre-B\nComprobante 24152758409697903...\nValor de la transferencia\n$1.000,00\nEnviaste LEDYS LEON QUINTERO\nA la llave @LEON8909\nEntidad BANCOLOMBIA\n..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `text`: todo el texto detectado en la imagen, tal cual lo lee el OCR (no hace
|
||||||
|
falta que estructure nada — de eso se encarga Gemini después). Si detecta texto
|
||||||
|
en varias líneas/bloques, mejor mantener saltos de línea, ayuda a Gemini a
|
||||||
|
interpretar el layout.
|
||||||
|
|
||||||
|
### Response — error (HTTP 4xx/5xx)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": "descripción corta del error"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Casos que deben devolver error controlado (no un 500 crudo):
|
||||||
|
- Imagen corrupta / no decodificable.
|
||||||
|
- Imagen sin texto detectable (`"error": "sin_texto_detectado"`).
|
||||||
|
- `mime_type` no soportado.
|
||||||
|
|
||||||
|
### Timeouts
|
||||||
|
|
||||||
|
Laravel llama con un timeout de **20 segundos**. El servicio debe responder bien
|
||||||
|
antes de eso — el OCR no debería tardar más de 2-5 segundos por imagen en CPU.
|
||||||
|
|
||||||
|
## 2. Motor de OCR recomendado
|
||||||
|
|
||||||
|
Para desplegar rápido en Coolify, la opción más simple es un contenedor Docker con:
|
||||||
|
|
||||||
|
- **Tesseract OCR** (`tesseract-ocr` + paquete de idioma `spa`) envuelto en una API
|
||||||
|
pequeña (Python + FastAPI, o Node + Express). Es la opción de menor esfuerzo.
|
||||||
|
- Si la precisión con las capturas de pantalla de apps bancarias (fondos oscuros,
|
||||||
|
fuentes pequeñas) resulta insuficiente, migrar el mismo contrato de API a
|
||||||
|
**PaddleOCR** (más preciso, pero imagen Docker más pesada — necesita Python +
|
||||||
|
PaddlePaddle).
|
||||||
|
|
||||||
|
El contrato HTTP (`POST /extract`) es el mismo sin importar cuál motor uses por
|
||||||
|
dentro — así que se puede empezar con Tesseract y cambiar el motor después sin
|
||||||
|
tocar el lado de Laravel.
|
||||||
|
|
||||||
|
### Preprocesamiento recomendado antes de correr OCR
|
||||||
|
|
||||||
|
Mejora mucho la precisión con capturas de pantalla de apps:
|
||||||
|
1. Convertir a escala de grises.
|
||||||
|
2. Aumentar contraste / binarizar (umbral adaptativo).
|
||||||
|
3. Escalar la imagen si es muy pequeña (mínimo ~1000px de ancho).
|
||||||
|
|
||||||
|
## 3. Variables de entorno del servicio (sugeridas)
|
||||||
|
|
||||||
|
```
|
||||||
|
OCR_TOKEN=<token secreto que Laravel debe enviar en el Authorization header>
|
||||||
|
OCR_LANG=spa # idioma para Tesseract
|
||||||
|
PORT=8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Ejemplo mínimo de referencia (Python + FastAPI + Tesseract)
|
||||||
|
|
||||||
|
Esto es solo un punto de partida — no es la implementación final, es para no
|
||||||
|
arrancar de cero.
|
||||||
|
|
||||||
|
**Dockerfile**
|
||||||
|
```dockerfile
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
tesseract-ocr tesseract-ocr-spa libgl1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY app.py .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**requirements.txt**
|
||||||
|
```
|
||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
pytesseract
|
||||||
|
pillow
|
||||||
|
python-multipart
|
||||||
|
```
|
||||||
|
|
||||||
|
**app.py**
|
||||||
|
```python
|
||||||
|
import base64, io, os
|
||||||
|
from fastapi import FastAPI, Header, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
import pytesseract
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
OCR_TOKEN = os.environ.get("OCR_TOKEN", "")
|
||||||
|
LANG = os.environ.get("OCR_LANG", "spa")
|
||||||
|
|
||||||
|
class ExtractRequest(BaseModel):
|
||||||
|
image_base64: str
|
||||||
|
mime_type: str = "image/jpeg"
|
||||||
|
|
||||||
|
def preprocesar(img: Image.Image) -> Image.Image:
|
||||||
|
img = img.convert("L") # escala de grises
|
||||||
|
img = ImageOps.autocontrast(img)
|
||||||
|
if img.width < 1000:
|
||||||
|
ratio = 1000 / img.width
|
||||||
|
img = img.resize((1000, int(img.height * ratio)))
|
||||||
|
return img
|
||||||
|
|
||||||
|
@app.post("/extract")
|
||||||
|
def extract(req: ExtractRequest, authorization: str = Header(default="")):
|
||||||
|
if OCR_TOKEN and authorization != f"Bearer {OCR_TOKEN}":
|
||||||
|
raise HTTPException(status_code=401, detail="token invalido")
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(req.image_base64)
|
||||||
|
img = Image.open(io.BytesIO(raw))
|
||||||
|
except Exception:
|
||||||
|
return {"success": False, "error": "imagen invalida o corrupta"}
|
||||||
|
|
||||||
|
img = preprocesar(img)
|
||||||
|
texto = pytesseract.image_to_string(img, lang=LANG).strip()
|
||||||
|
|
||||||
|
if not texto:
|
||||||
|
return {"success": False, "error": "sin_texto_detectado"}
|
||||||
|
|
||||||
|
return {"success": True, "text": texto}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Despliegue en Coolify
|
||||||
|
|
||||||
|
1. Crear un nuevo recurso tipo "Application" apuntando a un repo/carpeta con el
|
||||||
|
`Dockerfile` de arriba (o el que termines usando).
|
||||||
|
2. Configurar la variable de entorno `OCR_TOKEN` con un valor secreto largo
|
||||||
|
(ej: generado con `openssl rand -hex 32`).
|
||||||
|
3. Publicar el servicio con dominio propio (ej: `https://ocr.tu-dominio.com`) o IP
|
||||||
|
interna si Coolify y el hosting de Laravel comparten red — cualquiera de las
|
||||||
|
dos formas sirve, Laravel solo necesita una URL alcanzable por HTTPS.
|
||||||
|
4. Probar con:
|
||||||
|
```bash
|
||||||
|
curl -X POST https://ocr.tu-dominio.com/extract \
|
||||||
|
-H "Authorization: Bearer <OCR_TOKEN>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"image_base64":"'"$(base64 -w0 comprobante.jpg)"'","mime_type":"image/jpeg"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Qué necesito de ti para conectar Laravel
|
||||||
|
|
||||||
|
Una vez el servicio esté arriba, en el panel de SirPremium (Configuración del
|
||||||
|
Chat → sección "OCR de comprobantes", ya agregada) solo tienes que llenar:
|
||||||
|
|
||||||
|
- **URL del servicio OCR** → ej: `https://ocr.tu-dominio.com`
|
||||||
|
- **Token** → el mismo valor que pusiste en `OCR_TOKEN`
|
||||||
|
- Activar el checkbox "Habilitar OCR antes de enviar a la IA"
|
||||||
|
|
||||||
|
Laravel hace el resto: llama a `{URL}/extract`, si responde con texto se lo pasa a
|
||||||
|
Gemini en modo texto (`estructurarTexto`), y si el OCR falla o no está habilitado,
|
||||||
|
cae automáticamente al método anterior (Gemini leyendo la imagen directamente) —
|
||||||
|
no se rompe nada si el servicio OCR está caído o aún no lo has desplegado.
|
||||||
@@ -150,6 +150,48 @@
|
|||||||
|
|
||||||
<hr class="border-gray-100">
|
<hr class="border-gray-100">
|
||||||
|
|
||||||
|
{{-- OCR de comprobantes --}}
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-bold text-gray-700 mb-1">OCR de comprobantes (antes de la IA)</h3>
|
||||||
|
<p class="text-xs text-gray-400 mb-4">Si está habilitado, la imagen del comprobante se manda primero a este servicio para extraer el texto, y luego ese texto (no la imagen) se le pasa a Gemini para estructurarlo. Si el servicio falla o está deshabilitado, se usa el método anterior (Gemini lee la imagen directamente).</p>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-1">URL del servicio OCR</label>
|
||||||
|
<input wire:model.defer="ocr_url" type="text" placeholder="https://ocr.tu-dominio.com"
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 outline-none font-mono">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-1">Token (Authorization: Bearer)</label>
|
||||||
|
<input wire:model.defer="ocr_token" type="password" placeholder="Token del servicio OCR"
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-emerald-500 outline-none font-mono">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input wire:model.defer="ocr_habilitado" type="checkbox" value="1"
|
||||||
|
id="ocr_hab" class="w-4 h-4 accent-emerald-600">
|
||||||
|
<label for="ocr_hab" class="text-sm text-gray-700">
|
||||||
|
Habilitar OCR antes de enviar a la IA
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button wire:click="probarOcr"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm font-semibold rounded-lg transition">
|
||||||
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||||
|
</svg>
|
||||||
|
<span wire:loading.remove wire:target="probarOcr">Probar conexión OCR</span>
|
||||||
|
<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' }}">
|
||||||
|
{{ $ocrResult }}
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="border-gray-100">
|
||||||
|
|
||||||
{{-- Validacion de pagos --}}
|
{{-- Validacion de pagos --}}
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-bold text-gray-700 mb-1">Validacion de pagos por foto + correo IMAP</h3>
|
<h3 class="text-sm font-bold text-gray-700 mb-1">Validacion de pagos por foto + correo IMAP</h3>
|
||||||
|
|||||||
Reference in New Issue
Block a user