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]); } }