Files
whatsapp/services/GeminiService.php
Lizandro GuarnizoandClaude Opus 5 81c007c516 LIA responde sobre la documentación, filtrada por el rol de quien pregunta
DocIndex::contextoIA() selecciona los documentos relevantes a la pregunta y
devuelve su texto. Reutiliza el filtrado por rol ya existente: al contexto que
se envía al modelo solo entra lo que ese usuario podría leer por su cuenta en
el módulo Soporte, así el asistente no puede revelar contenido restringido.
Verificado: un recepcionista preguntando por permisos recibe solo el manual
básico, sin el SQL ni los detalles internos que sí recibe un administrador.

- services/GeminiService.php concentra la llamada a la API y la contabilidad
  del presupuesto, que antes vivía dentro de ai_chat.php y ahora comparten los
  dos asistentes.
- La LIA del turnero suma la documentación a su contexto operativo, así responde
  tanto "cuánto facturamos hoy" como "cómo marco un paciente ausente".
- modules/soporte/api/ai_docs.php es el asistente de la documentación: no accede
  a datos de pacientes ni de la operación, solo a los documentos visibles. A
  diferencia del anterior no exige acceso al turnero, así que lo puede usar
  cualquier usuario autenticado desde la página de documentación.
- La respuesta cita de qué documentos salió.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 10:41:40 -05:00

109 lines
4.4 KiB
PHP

<?php
/**
* services/GeminiService.php
* Llamada a Gemini Flash y contabilidad del presupuesto de tokens de LIA.
*
* La usan el asistente del turnero (contexto operativo del día) y el de la
* documentación (base de conocimiento filtrada por rol). Ambos comparten el
* mismo presupuesto, guardado en lab_config.lia_tokens_usados.
*/
final class GeminiService
{
public const TOKENS_MAX = 1_000_000;
private const MODELO = 'gemini-3.5-flash';
private const ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models/';
private PDO $pdo;
private string $apiKey;
private int $tokensUsados;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
$cfg = $pdo->query(
"SELECT clave, valor FROM lab_config WHERE clave IN ('gemini_api_key','lia_tokens_usados')"
)->fetchAll(PDO::FETCH_KEY_PAIR);
$this->apiKey = trim($cfg['gemini_api_key'] ?? '');
$this->tokensUsados = (int)($cfg['lia_tokens_usados'] ?? 0);
}
public function hayClave(): bool { return $this->apiKey !== ''; }
public function presupuestoAgotado(): bool { return $this->tokensUsados >= self::TOKENS_MAX; }
public function tokensUsados(): int { return $this->tokensUsados; }
public function tokensRestantes(): int { return max(0, self::TOKENS_MAX - $this->tokensUsados); }
/**
* Envía una consulta y devuelve la respuesta ya contabilizada.
*
* @param array $historial Turnos previos: [['role'=>'user'|'model','parts'=>[['text'=>...]]], ...]
* @return array{respuesta:string, truncada:bool, tokens_usados:int, tokens_restantes:int, tokens_max:int}
* @throws RuntimeException si la API falla; el mensaje es apto para mostrar
*/
public function preguntar(string $systemPrompt, string $pregunta, array $historial = [], int $maxTokens = 2048): array
{
$payload = json_encode([
'system_instruction' => ['parts' => [['text' => $systemPrompt]]],
'contents' => array_merge($historial, [
['role' => 'user', 'parts' => [['text' => $pregunta]]],
]),
'generationConfig' => [
'temperature' => 0.3,
'maxOutputTokens' => $maxTokens,
],
], JSON_UNESCAPED_UNICODE);
$ch = curl_init(self::ENDPOINT . self::MODELO . ':generateContent?key=' . $this->apiKey);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_TIMEOUT => 25,
CURLOPT_SSL_VERIFYPEER => true,
]);
$raw = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if (!$raw) throw new RuntimeException('Sin respuesta del servicio de IA');
$json = json_decode($raw, true);
if ($code !== 200 || empty($json['candidates'][0]['content']['parts'][0]['text'])) {
throw new RuntimeException('Error IA: ' . ($json['error']['message'] ?? 'Error desconocido'));
}
$respuesta = $json['candidates'][0]['content']['parts'][0]['text'];
// Gemini avisa con finishReason cuando corta; sin esto el usuario recibe
// un texto truncado a media frase sin saber que faltó contenido.
$truncada = (($json['candidates'][0]['finishReason'] ?? null) === 'MAX_TOKENS');
if ($truncada) {
$respuesta .= "\n\n_(respuesta cortada por longitud — pide el detalle por partes)_";
}
$this->contabilizar(
(int)($json['usageMetadata']['totalTokenCount']
?? (mb_strlen($pregunta) + mb_strlen($respuesta)) / 4)
);
return [
'respuesta' => $respuesta,
'truncada' => $truncada,
'tokens_usados' => $this->tokensUsados,
'tokens_restantes' => $this->tokensRestantes(),
'tokens_max' => self::TOKENS_MAX,
];
}
private function contabilizar(int $tokens): void
{
$this->tokensUsados += $tokens;
$this->pdo->prepare(
"INSERT INTO lab_config (clave, valor) VALUES ('lia_tokens_usados', ?)
ON DUPLICATE KEY UPDATE valor = ?"
)->execute([$this->tokensUsados, $this->tokensUsados]);
}
}