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>
217 lines
7.8 KiB
PHP
Executable File
217 lines
7.8 KiB
PHP
Executable File
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\AiUsageLog;
|
|
use App\Models\ChatConfig;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class GeminiVisionService
|
|
{
|
|
private string $apiKey;
|
|
private string $model;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->apiKey = ChatConfig::get('gemini_api_key', '');
|
|
$this->model = ChatConfig::get('gemini_model', 'gemini-2.0-flash');
|
|
}
|
|
|
|
public function extraerPago(string $base64, string $mimeType, ?int $usuarioId = null, string $canal = 'telegram'): ?array
|
|
{
|
|
$contents = [[
|
|
'parts' => [
|
|
['text' => $this->buildPrompt()],
|
|
['inline_data' => ['mime_type' => $mimeType, 'data' => $base64]],
|
|
],
|
|
]];
|
|
|
|
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' => [
|
|
'temperature' => 0.1,
|
|
'maxOutputTokens' => 512,
|
|
],
|
|
];
|
|
|
|
$inicio = microtime(true);
|
|
$response = null;
|
|
$lastError = '';
|
|
|
|
foreach (['v1', 'v1beta'] as $ver) {
|
|
$url = "https://generativelanguage.googleapis.com/{$ver}/models/{$this->model}:generateContent";
|
|
try {
|
|
$resp = Http::withHeaders(['Content-Type' => 'application/json'])
|
|
->timeout(40)
|
|
->post("{$url}?key={$this->apiKey}", $body);
|
|
|
|
if ($resp->successful()) {
|
|
$response = $resp;
|
|
break;
|
|
}
|
|
$lastError = "[{$ver}] " . ($resp->json('error.message') ?? substr($resp->body(), 0, 200));
|
|
} catch (\Throwable $e) {
|
|
$lastError = "[{$ver}] " . $e->getMessage();
|
|
}
|
|
}
|
|
|
|
$tiempoMs = (int) ((microtime(true) - $inicio) * 1000);
|
|
|
|
if (! $response) {
|
|
Log::warning('[Gemini Vision] API failed: ' . $lastError);
|
|
AiUsageLog::registrar([
|
|
'servicio' => $servicio,
|
|
'canal' => $canal,
|
|
'usuario_id' => $usuarioId,
|
|
'tiempo_ms' => $tiempoMs,
|
|
'resultado' => 'error',
|
|
'detalle' => ['error' => $lastError, 'modelo' => $this->model],
|
|
]);
|
|
// Return error info so caller can show it
|
|
return ['__error' => $lastError];
|
|
}
|
|
|
|
$inputTokens = $response->json('usageMetadata.promptTokenCount', 0);
|
|
$outputTokens = $response->json('usageMetadata.candidatesTokenCount', 0);
|
|
|
|
// Skip thought parts (gemini-3.x thinking blocks), only keep actual response text
|
|
$parts = $response->json('candidates.0.content.parts', []);
|
|
$text = implode("\n", array_column(
|
|
array_filter($parts, fn($p) => isset($p['text']) && empty($p['thought'])),
|
|
'text'
|
|
));
|
|
|
|
Log::info('[Gemini Vision] raw response: ' . substr($text, 0, 500));
|
|
|
|
$resultado = $this->parseRespuesta($text);
|
|
|
|
AiUsageLog::registrar([
|
|
'servicio' => $servicio,
|
|
'canal' => $canal,
|
|
'usuario_id' => $usuarioId,
|
|
'input_tokens' => $inputTokens,
|
|
'output_tokens' => $outputTokens,
|
|
'tiempo_ms' => $tiempoMs,
|
|
'resultado' => $resultado ? 'ok' : 'error',
|
|
'detalle' => $resultado
|
|
? ['banco' => $resultado['banco'], 'valor' => $resultado['valor']]
|
|
: ['raw' => substr($text, 0, 400), 'modelo' => $this->model],
|
|
]);
|
|
|
|
// If parse failed, return debug info so caller can show it
|
|
if (! $resultado) {
|
|
return ['__parse_error' => substr($text, 0, 300)];
|
|
}
|
|
|
|
return $resultado;
|
|
}
|
|
|
|
private function buildPrompt(): string
|
|
{
|
|
return <<<PROMPT
|
|
Analiza esta imagen de comprobante de pago bancario o transferencia.
|
|
|
|
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 la imagen NO es un comprobante de pago, responde exactamente: {"error": "no_es_comprobante"}
|
|
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
|
|
$text = preg_replace('/```json\s*|\s*```/', '', $text);
|
|
// Extract the first JSON object found anywhere in the text
|
|
if (preg_match('/\{.*\}/s', $text, $m)) {
|
|
$text = $m[0];
|
|
}
|
|
$text = trim($text);
|
|
$json = json_decode($text, true);
|
|
|
|
if (! is_array($json) || isset($json['error'])) {
|
|
return null;
|
|
}
|
|
|
|
if (isset($json['valor'])) {
|
|
$json['valor'] = (int) preg_replace('/[^0-9]/', '', (string) $json['valor']);
|
|
}
|
|
|
|
return [
|
|
'banco' => $json['banco'] ?? null,
|
|
'valor' => $json['valor'] ?? null,
|
|
'referencia' => $json['referencia'] ?? null,
|
|
'fecha' => $json['fecha'] ?? null,
|
|
'hora' => $json['hora'] ?? null,
|
|
'remitente' => $json['remitente'] ?? null,
|
|
'llave' => $json['llave'] ?? null,
|
|
];
|
|
}
|
|
}
|