- OcrExtractionService: registra cada llamada en ai_usage_logs (servicio=ocr, costo=5 COP/foto) y ahora tambien loggea cuando SI funciona, no solo cuando falla (antes era imposible confirmar por log que el OCR corrio). - GeminiVisionService: el log decia siempre "[Gemini Vision]" sin importar si la llamada fue de imagen o de texto post-OCR, haciendo imposible distinguir cual camino se uso realmente. Ahora usa el servicio real. - GeminiVisionService: agrega thinkingConfig.thinkingBudget=0 (igual que GeminiIntentService) para que el modelo no gaste tokens narrando su razonamiento en texto plano antes del JSON final -- se estaba colando ese razonamiento en la respuesta y comiendose el presupuesto de tokens. - GeminiIntentService: resultado ya no queda forzado a 'ok'; ahora filtra bloques 'thought' igual que vision, y guarda tokens/raw en el detalle para poder diagnosticar sin acceso al servidor. - ShowAiLogs: card y filtro de costo OCR en el dashboard de consumo. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
89 lines
3.0 KiB
PHP
89 lines
3.0 KiB
PHP
<?php
|
|
|
|
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;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->url = self::normalizarUrl(ChatConfig::get('ocr_url', ''));
|
|
$this->token = ChatConfig::get('ocr_token', '');
|
|
}
|
|
|
|
/** Acepta tanto "https://host" como "https://host/extract" (error comun de configuracion). */
|
|
public static function normalizarUrl(string $url): string
|
|
{
|
|
$url = rtrim(trim($url), '/');
|
|
return preg_replace('#/extract$#', '', $url);
|
|
}
|
|
|
|
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, ?int $usuarioId = null, string $canal = 'web'): ?string
|
|
{
|
|
if (! $this->url) {
|
|
return null;
|
|
}
|
|
|
|
$inicio = microtime(true);
|
|
|
|
try {
|
|
$response = Http::withToken($this->token)
|
|
->timeout(20)
|
|
->post("{$this->url}/extract", [
|
|
'image_base64' => $base64,
|
|
'mime_type' => $mimeType,
|
|
]);
|
|
|
|
$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;
|
|
}
|
|
|
|
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,
|
|
]);
|
|
}
|
|
}
|