- 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>
193 lines
7.6 KiB
PHP
Executable File
193 lines
7.6 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 GeminiIntentService
|
|
{
|
|
private string $apiKey;
|
|
private string $apiUrl;
|
|
|
|
private string $model;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->apiKey = ChatConfig::get('gemini_api_key', '');
|
|
$this->model = ChatConfig::get('gemini_model', 'gemini-3.5-flash');
|
|
// v1 for newer models, v1beta as fallback
|
|
$ver = preg_match('/gemini-(2\.5|3[\.\d]*)/', $this->model) ? 'v1' : 'v1beta';
|
|
$this->apiUrl = "https://generativelanguage.googleapis.com/{$ver}/models/{$this->model}:generateContent";
|
|
}
|
|
|
|
private function buildRequest(array $contents, array $genConfig = []): array
|
|
{
|
|
return [
|
|
'contents' => $contents,
|
|
'generationConfig' => array_merge([
|
|
'temperature' => 0.1,
|
|
'maxOutputTokens' => 2048,
|
|
'thinkingConfig' => ['thinkingBudget' => 0],
|
|
], $genConfig),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Detecta la intención del texto del usuario y la mapea a una acción del sistema.
|
|
* Retorna ['action' => string, 'data' => array] o null si no se puede detectar.
|
|
*/
|
|
public function detectar(string $texto, ?int $usuarioId = null, string $canal = 'web'): ?array
|
|
{
|
|
if (! $this->apiKey || ChatConfig::get('gemini_habilitado', '0') !== '1') {
|
|
return null;
|
|
}
|
|
|
|
$prompt = $this->buildPrompt($texto);
|
|
$inicio = microtime(true);
|
|
|
|
try {
|
|
$response = Http::withHeaders(['Content-Type' => 'application/json'])
|
|
->timeout(8)
|
|
->post("{$this->apiUrl}?key={$this->apiKey}",
|
|
$this->buildRequest([['parts' => [['text' => $prompt]]]])
|
|
);
|
|
|
|
$tiempoMs = (int) ((microtime(true) - $inicio) * 1000);
|
|
|
|
if (! $response->successful()) {
|
|
Log::warning('[Gemini Intent] API error: ' . $response->body());
|
|
AiUsageLog::registrar([
|
|
'servicio' => 'gemini_intent',
|
|
'canal' => $canal,
|
|
'usuario_id'=> $usuarioId,
|
|
'tiempo_ms' => $tiempoMs,
|
|
'resultado' => 'error',
|
|
'detalle' => ['error' => substr($response->body(), 0, 300), 'texto' => $texto],
|
|
]);
|
|
return null;
|
|
}
|
|
|
|
$inputTokens = $response->json('usageMetadata.promptTokenCount', 0);
|
|
$outputTokens = $response->json('usageMetadata.candidatesTokenCount', 0);
|
|
|
|
// Igual que GeminiVisionService: si el modelo manda bloques de
|
|
// "pensamiento" (thought=true), el JSON real puede no venir en la
|
|
// parte 0. Sin este filtro, parts.0.text agarraba el pensamiento
|
|
// y el parseo fallaba en silencio (quedaba marcado 'ok' igual).
|
|
$parts = $response->json('candidates.0.content.parts', []);
|
|
$text = implode("\n", array_column(
|
|
array_filter($parts, fn ($p) => isset($p['text']) && empty($p['thought'])),
|
|
'text'
|
|
));
|
|
|
|
$resultado = $this->parseRespuesta($text);
|
|
|
|
Log::info('[Gemini Intent] raw response: ' . substr($text, 0, 300));
|
|
|
|
AiUsageLog::registrar([
|
|
'servicio' => 'gemini_intent',
|
|
'canal' => $canal,
|
|
'usuario_id' => $usuarioId,
|
|
'input_tokens' => $inputTokens,
|
|
'output_tokens'=> $outputTokens,
|
|
'tiempo_ms' => $tiempoMs,
|
|
'resultado' => $resultado ? 'ok' : 'error',
|
|
'detalle' => [
|
|
'texto' => $texto,
|
|
'accion' => $resultado['action'] ?? null,
|
|
'tokens' => "{$inputTokens}/{$outputTokens}",
|
|
'raw' => $resultado ? null : substr($text, 0, 200),
|
|
],
|
|
]);
|
|
|
|
return $resultado;
|
|
|
|
} catch (\Throwable $e) {
|
|
$tiempoMs = (int) ((microtime(true) - $inicio) * 1000);
|
|
Log::warning('[Gemini Intent] Excepcion: ' . $e->getMessage());
|
|
AiUsageLog::registrar([
|
|
'servicio' => 'gemini_intent',
|
|
'canal' => $canal,
|
|
'usuario_id'=> $usuarioId,
|
|
'tiempo_ms' => $tiempoMs,
|
|
'resultado' => 'error',
|
|
'detalle' => ['error' => $e->getMessage(), 'texto' => $texto],
|
|
]);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function buildPrompt(string $texto): string
|
|
{
|
|
$promptExtra = ChatConfig::get('gemini_prompt_extra', '');
|
|
|
|
return <<<PROMPT
|
|
Eres un clasificador de intenciones para SirPremium, una tienda colombiana de streaming (Netflix, Disney+, HBO, Spotify, etc.).
|
|
Los usuarios escriben en español latinoamericano informal, con jerga colombiana y errores ortográficos.
|
|
{$promptExtra}
|
|
|
|
Acciones válidas — devuelve EXACTAMENTE una de estas:
|
|
- menu.principal → saludo, "hola", "inicio", "volver", "menú", pedir ayuda general
|
|
- servicios.listar → ver planes, catálogo, qué servicios hay, "quiero Netflix", "qué tienen", "cuánto vale", preguntar por precios
|
|
Si el usuario nombra un servicio concreto (Netflix, HBO, Disney, Spotify…),
|
|
devuélvelo en data.servicio para abrir ese servicio directamente.
|
|
- promo.listar → ver promociones, ofertas, combos, descuentos, "hay algo especial"
|
|
- recarga.iniciar → recargar saldo, cargar dinero, "quiero poner saldo", "cómo recargo", "agregar plata", "depositar"
|
|
- credenciales.listar → ver contraseña, cómo entrar, "no puedo acceder", "dame el usuario", "mis datos de Netflix", "cómo me conecto"
|
|
- historial.ver → historial de compras, "qué he comprado", "mis pedidos", "mis servicios activos"
|
|
- perfil.ver → ver perfil, mis datos personales, cambiar información
|
|
|
|
Ejemplos de clasificación:
|
|
"hacer recarga" → recarga.iniciar
|
|
"quiero recargar" → recarga.iniciar
|
|
"cargar saldo" → recarga.iniciar
|
|
"poner plata" → recarga.iniciar
|
|
"ver planes" → servicios.listar, data {}
|
|
"qué tienen de Netflix" → servicios.listar, data {"servicio": "Netflix"}
|
|
"cuenta de hbo" → servicios.listar, data {"servicio": "HBO"}
|
|
"cuánto vale disney" → servicios.listar, data {"servicio": "Disney"}
|
|
"hay promociones" → promo.listar
|
|
"mis contraseñas" → credenciales.listar
|
|
"no puedo entrar a mi cuenta" → credenciales.listar
|
|
Texto del usuario: "{$texto}"
|
|
|
|
Responde ÚNICAMENTE con JSON válido, sin markdown, sin explicación:
|
|
{"action": "la_accion", "data": {}}
|
|
|
|
Si el texto es completamente ambiguo o irrelevante, usa: {"action": "menu.principal", "data": {}}
|
|
PROMPT;
|
|
}
|
|
|
|
private function parseRespuesta(string $text): ?array
|
|
{
|
|
$text = trim($text);
|
|
// Limpiar markdown si Gemini lo incluye
|
|
$text = preg_replace('/```json\s*|\s*```/', '', $text);
|
|
$text = trim($text);
|
|
|
|
$json = json_decode($text, true);
|
|
|
|
if (! is_array($json) || empty($json['action'])) {
|
|
return null;
|
|
}
|
|
|
|
$accionesValidas = [
|
|
'menu.principal', 'servicios.listar', 'promo.listar',
|
|
'recarga.iniciar', 'credenciales.listar', 'historial.ver',
|
|
'perfil.ver', 'asesor.solicitar',
|
|
];
|
|
|
|
if (! in_array($json['action'], $accionesValidas)) {
|
|
return ['action' => 'menu.principal', 'data' => []];
|
|
}
|
|
|
|
return [
|
|
'action' => $json['action'],
|
|
'data' => is_array($json['data'] ?? null) ? $json['data'] : [],
|
|
];
|
|
}
|
|
}
|