Files
sirpremiumv2/app/Services/GeminiIntentService.php
T
LizandroandClaude Sonnet 5 5e0c0367d9 feat: costo OCR en dashboard, logs correctos y fix de thinking tokens
- 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>
2026-08-11 01:52:59 +00:00

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'] : [],
];
}
}