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>
109 lines
4.4 KiB
PHP
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]);
|
|
}
|
|
}
|