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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
dc27184051
commit
81c007c516
@@ -0,0 +1,108 @@
|
||||
<?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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user