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:
Lizandro Guarnizo
2026-08-04 10:41:40 -05:00
co-authored by Claude Opus 5
parent dc27184051
commit 81c007c516
5 changed files with 432 additions and 3 deletions
+62
View File
@@ -161,6 +161,68 @@ final class DocIndex
return ucfirst(str_replace('-', ' ', $slugFallback)); return ucfirst(str_replace('-', ' ', $slugFallback));
} }
/** Palabras que no aportan al puntaje de relevancia. */
private const VACIAS = [
'como','cual','cuales','donde','cuando','porque','para','pero','esta','este','esto',
'con','sin','por','que','del','las','los','una','uno','del','sus','sobre','desde',
'hacer','tengo','puedo','quiero','necesito','ayuda','favor','the','and','not','del',
];
/**
* Selecciona los documentos más relevantes para una pregunta y devuelve su
* texto, listo para dárselo a un modelo de lenguaje.
*
* Respeta la visibilidad por rol: solo entra lo que el usuario podría leer
* por su cuenta, así el asistente no puede filtrar contenido restringido.
*
* @return array{0: string, 1: array<int,string>} [contexto, títulos usados]
*/
public static function contextoIA(string $pregunta, int $maxDocs = 4, int $maxChars = 14000): array
{
$palabras = array_values(array_filter(
preg_split('/[^a-záéíóúñü0-9]+/u', mb_strtolower($pregunta)),
fn($p) => mb_strlen($p) > 2 && !in_array($p, self::VACIAS, true)
));
if (!$palabras) return ['', []];
$candidatos = [];
foreach (self::arbol() as $sec => $cfg) {
foreach ($cfg['docs'] as $doc) {
[, $cuerpo] = self::leer($doc['archivo']);
$heno = mb_strtolower($doc['titulo'] . ' ' . $cuerpo);
$puntaje = 0;
foreach ($palabras as $p) {
// El título pesa mucho más que una mención en el cuerpo
$puntaje += substr_count(mb_strtolower($doc['titulo']), $p) * 12;
$puntaje += min(substr_count($heno, $p), 8);
}
if ($puntaje > 0) {
$candidatos[] = [
'puntaje' => $puntaje,
'titulo' => $doc['titulo'],
'seccion' => $cfg['titulo'],
'cuerpo' => $cuerpo,
];
}
}
}
if (!$candidatos) return ['', []];
usort($candidatos, fn($a, $b) => $b['puntaje'] <=> $a['puntaje']);
$candidatos = array_slice($candidatos, 0, $maxDocs);
$porDoc = (int)floor($maxChars / count($candidatos));
$ctx = '';
$titulos = [];
foreach ($candidatos as $c) {
$texto = preg_replace('/\{\{\w+\}\}/', '', $c['cuerpo']); // los marcadores no aportan
$texto = mb_substr(trim($texto), 0, $porDoc);
$ctx .= "\n\n===== [{$c['seccion']}] {$c['titulo']} =====\n" . $texto;
$titulos[] = $c['titulo'];
}
return [trim($ctx), $titulos];
}
/** /**
* Índice para el buscador: un registro por documento con su texto plano. * Índice para el buscador: un registro por documento con su texto plano.
* Solo incluye secciones visibles para el usuario actual. * Solo incluye secciones visibles para el usuario actual.
+96
View File
@@ -0,0 +1,96 @@
<?php
/**
* modules/soporte/api/ai_docs.php
* POST { pregunta: string, historial?: [{rol,texto},...] }
*
* LIA respondiendo únicamente sobre la documentación del sistema. El contexto
* lo arma DocIndex, que filtra por rol: solo entra lo que este usuario podría
* leer por su cuenta en el módulo Soporte. No accede a datos de pacientes ni
* de la operación del día.
*/
require_once __DIR__ . '/../../../config/config.php';
require_once __DIR__ . '/../../../services/GeminiService.php';
require_once __DIR__ . '/../DocIndex.php';
header('Content-Type: application/json; charset=utf-8');
function salir(array $datos, int $codigo = 200): void
{
http_response_code($codigo);
echo json_encode($datos, JSON_UNESCAPED_UNICODE);
exit;
}
if (!isUserLoggedIn()) salir(['ok' => false, 'error' => 'No autenticado'], 401);
if ($_SERVER['REQUEST_METHOD'] !== 'POST') salir(['ok' => false, 'error' => 'Método no permitido'], 405);
$body = json_decode(file_get_contents('php://input'), true) ?? [];
$pregunta = trim($body['pregunta'] ?? '');
if ($pregunta === '') salir(['ok' => false, 'error' => 'Pregunta vacía'], 400);
if (mb_strlen($pregunta) > 800) salir(['ok' => false, 'error' => 'Pregunta demasiado larga'], 400);
$gemini = new GeminiService(Database::getInstance()->getConnection());
if (!$gemini->hayClave()) {
salir(['ok' => false, 'error' => 'El asistente no está configurado. Avisa a un administrador.'], 503);
}
if ($gemini->presupuestoAgotado()) {
salir(['ok' => false, 'error' => '⚠️ Se agotaron los tokens de LIA. Solicita tokens a soporte para continuar.'], 402);
}
// ── Documentación visible para este usuario, acotada a la pregunta ──
[$contexto, $titulos] = DocIndex::contextoIA($pregunta);
if ($contexto === '') {
salir([
'ok' => true,
'respuesta' => 'No encontré nada sobre eso en la documentación a la que tenés acceso. '
. 'Probá con otras palabras, o revisá el índice de la izquierda.',
'fuentes' => [],
]);
}
// ── Historial de la conversación ───────────────────────────────
$historial = [];
foreach (array_slice(is_array($body['historial'] ?? null) ? $body['historial'] : [], -6) as $h) {
$texto = trim((string)($h['texto'] ?? ''));
if ($texto === '') continue;
$historial[] = [
'role' => (($h['rol'] ?? '') === 'model') ? 'model' : 'user',
'parts' => [['text' => mb_substr($texto, 0, 1500)]],
];
}
$prompt = <<<PROMPT
Eres LIA, la asistente de documentación del ERP del Laboratorio Clínico Ximena Caicedo.
Respondes preguntas sobre cómo usar el sistema y cómo funciona, apoyándote ÚNICAMENTE en la
documentación incluida abajo. Reglas:
- Si la respuesta no está en la documentación, dilo con claridad. No inventes ni supongas.
- La documentación incluida es la que este usuario tiene permitido consultar. No menciones ni
deduzcas la existencia de contenido que no esté aquí.
- Responde en español, directo y práctico. Si es un procedimiento, enumera los pasos.
- Usa tablas o viñetas cuando ayuden a leer.
- No inventes rutas, nombres de botones ni consultas SQL que no aparezcan en la documentación.
DOCUMENTACIÓN DISPONIBLE:
{$contexto}
PROMPT;
try {
$r = $gemini->preguntar($prompt, $pregunta, $historial);
salir([
'ok' => true,
'respuesta' => $r['respuesta'],
'truncada' => $r['truncada'],
'fuentes' => $titulos,
'tokens_usados' => $r['tokens_usados'],
'tokens_restantes' => $r['tokens_restantes'],
'tokens_max' => $r['tokens_max'],
]);
} catch (\Throwable $e) {
salir(['ok' => false, 'error' => $e->getMessage()], 502);
}
+146 -1
View File
@@ -135,10 +135,47 @@ Layout::open('Soporte · Documentación', 'fas fa-life-ring');
.doc-body { padding:20px 18px 60px; } .doc-body { padding:20px 18px 60px; }
} }
@media print { @media print {
.doc-nav, .doc-toc, .doc-ruta { display:none !important; } .doc-nav, .doc-toc, .doc-ruta, .lia-fab, .lia-panel { display:none !important; }
.doc-body { max-width:none; padding:0; } .doc-body { max-width:none; padding:0; }
.doc-body pre { background:#f8fafc; color:#0f172a; border:1px solid #cbd5e1; } .doc-body pre { background:#f8fafc; color:#0f172a; border:1px solid #cbd5e1; }
} }
/* ── Asistente LIA ── */
.lia-fab { position:fixed; right:22px; bottom:22px; z-index:1040; width:54px; height:54px;
border-radius:50%; border:none; cursor:pointer; color:#fff; font-size:1.25rem;
background:linear-gradient(135deg,#2563eb,#7c3aed); box-shadow:0 8px 24px rgba(37,99,235,.4); }
.lia-fab:hover { transform:scale(1.06); }
.lia-panel { position:fixed; right:22px; bottom:88px; z-index:1041; width:390px; max-width:calc(100vw - 44px);
height:520px; max-height:calc(100vh - 130px); background:#fff; border:1px solid #e2e8f0;
border-radius:16px; box-shadow:0 20px 60px rgba(0,0,0,.18); display:none;
flex-direction:column; overflow:hidden; }
.lia-panel.abierto { display:flex; }
.lia-head { padding:12px 15px; background:linear-gradient(135deg,#2563eb,#7c3aed); color:#fff;
display:flex; align-items:center; gap:9px; }
.lia-head .t { font-weight:700; font-size:.92rem; flex:1; }
.lia-head .s { font-size:.7rem; opacity:.85; display:block; font-weight:400; }
.lia-head button { background:none; border:none; color:#fff; cursor:pointer; font-size:1rem; opacity:.85; }
.lia-msgs { flex:1; overflow-y:auto; padding:14px; background:#f8fafc; }
.lia-msg { max-width:88%; padding:9px 12px; border-radius:12px; font-size:.84rem; line-height:1.55;
margin-bottom:9px; word-wrap:break-word; }
.lia-msg.user { background:#2563eb; color:#fff; margin-left:auto; border-bottom-right-radius:3px; }
.lia-msg.bot { background:#fff; color:#1e293b; border:1px solid #e2e8f0; border-bottom-left-radius:3px; }
.lia-msg.bot table { width:100%; border-collapse:collapse; font-size:.76rem; margin:6px 0; }
.lia-msg.bot th, .lia-msg.bot td { border:1px solid #e2e8f0; padding:4px 6px; text-align:left; }
.lia-msg.bot code { background:#f1f5f9; color:#be185d; padding:1px 4px; border-radius:3px; font-size:.9em; }
.lia-msg.bot ul, .lia-msg.bot ol { padding-left:18px; margin:6px 0; }
.lia-fuentes { font-size:.68rem; color:#64748b; margin:-4px 0 10px 2px; }
.lia-fuentes a { color:#2563eb; text-decoration:none; }
.lia-in { display:flex; gap:7px; padding:11px; border-top:1px solid #e2e8f0; background:#fff; }
.lia-in input { flex:1; border:1px solid #cbd5e1; border-radius:9px; padding:8px 11px; font-size:.84rem; }
.lia-in input:focus { outline:none; border-color:#2563eb; }
.lia-in button { border:none; background:#2563eb; color:#fff; border-radius:9px; width:38px; cursor:pointer; }
.lia-in button:disabled { opacity:.5; cursor:default; }
.lia-sug { padding:0 14px 12px; }
.lia-sug button { display:block; width:100%; text-align:left; background:#fff; border:1px solid #e2e8f0;
border-radius:9px; padding:7px 11px; font-size:.79rem; color:#334155; cursor:pointer;
margin-bottom:6px; }
.lia-sug button:hover { border-color:#2563eb; color:#1d4ed8; }
</style> </style>
<div class="doc-wrap"> <div class="doc-wrap">
@@ -232,6 +269,26 @@ Layout::open('Soporte · Documentación', 'fas fa-life-ring');
</div> </div>
</div> </div>
<!-- ── Asistente LIA sobre la documentación ── -->
<button class="lia-fab" id="liaFab" title="Preguntar a LIA"><i class="fas fa-robot"></i></button>
<div class="lia-panel" id="liaPanel">
<div class="lia-head">
<i class="fas fa-robot"></i>
<div class="t">LIA
<span class="s">Responde sobre la documentación que podés ver</span>
</div>
<button onclick="liaToggle()" title="Cerrar"><i class="fas fa-times"></i></button>
</div>
<div class="lia-msgs" id="liaMsgs">
<div class="lia-msg bot">Hola. Preguntame cómo hacer algo en el sistema y te respondo con la documentación.</div>
<div class="lia-sug" id="liaSug"></div>
</div>
<div class="lia-in">
<input type="text" id="liaInput" placeholder="¿Cómo…?" autocomplete="off">
<button id="liaSend" onclick="liaEnviar()"><i class="fas fa-paper-plane"></i></button>
</div>
</div>
<script> <script>
/* ── Buscador: índice completo servido con la página ── */ /* ── Buscador: índice completo servido con la página ── */
const DOCS = <?= json_encode(DocIndex::indiceBusqueda(), JSON_UNESCAPED_UNICODE) ?>; const DOCS = <?= json_encode(DocIndex::indiceBusqueda(), JSON_UNESCAPED_UNICODE) ?>;
@@ -281,6 +338,94 @@ function esc(s) {
return d.innerHTML; return d.innerHTML;
} }
/* ── Asistente LIA sobre la documentación ── */
const LIA_API = '<?= BASE_URL ?>modules/soporte/api/ai_docs.php';
let _liaHist = [];
function liaToggle() {
const p = document.getElementById('liaPanel');
p.classList.toggle('abierto');
if (p.classList.contains('abierto')) document.getElementById('liaInput').focus();
}
document.getElementById('liaFab').addEventListener('click', liaToggle);
// Sugerencias tomadas de los documentos que este usuario realmente puede ver
(function () {
const sug = document.getElementById('liaSug');
if (!DOCS.length) return;
sug.innerHTML = DOCS.slice(0, 3)
.map(d => `<button onclick="liaPreguntar('¿Qué explica ${esc(d.t)}?')">${esc(d.t)}</button>`)
.join('');
})();
function liaPreguntar(texto) {
document.getElementById('liaInput').value = texto;
liaEnviar();
}
/* Formato mínimo de la respuesta: negritas, código, listas y saltos. */
function liaFmt(md) {
let h = esc(md);
h = h.replace(/```[\s\S]*?```/g, m => '<pre style="white-space:pre-wrap;font-size:.75rem">'
+ m.replace(/```\w*\n?/g, '') + '</pre>');
h = h.replace(/`([^`]+)`/g, '<code>$1</code>');
h = h.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
h = h.replace(/^\s*[-*]\s+(.*)$/gm, '<li>$1</li>');
h = h.replace(/(<li>[\s\S]*?<\/li>)/g, '<ul>$1</ul>');
return h.replace(/\n{2,}/g, '<br><br>').replace(/\n/g, '<br>');
}
async function liaEnviar() {
const input = document.getElementById('liaInput');
const btn = document.getElementById('liaSend');
const msgs = document.getElementById('liaMsgs');
const q = input.value.trim();
if (!q) return;
document.getElementById('liaSug')?.remove();
msgs.insertAdjacentHTML('beforeend', `<div class="lia-msg user">${esc(q)}</div>`);
const pensando = document.createElement('div');
pensando.className = 'lia-msg bot';
pensando.textContent = 'Buscando en la documentación…';
msgs.appendChild(pensando);
msgs.scrollTop = msgs.scrollHeight;
input.value = '';
input.disabled = btn.disabled = true;
try {
const r = await fetch(LIA_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pregunta: q, historial: _liaHist }),
});
const d = await r.json();
if (d.ok) {
pensando.innerHTML = liaFmt(d.respuesta);
if (d.fuentes && d.fuentes.length) {
msgs.insertAdjacentHTML('beforeend',
`<div class="lia-fuentes">Según: ${d.fuentes.map(esc).join(' · ')}</div>`);
}
_liaHist.push({ rol: 'user', texto: q }, { rol: 'model', texto: d.respuesta });
if (_liaHist.length > 6) _liaHist = _liaHist.slice(-6);
} else {
pensando.textContent = d.error || 'No pude responder.';
}
} catch (_) {
pensando.textContent = 'Error de conexión.';
} finally {
input.disabled = btn.disabled = false;
input.focus();
msgs.scrollTop = msgs.scrollHeight;
}
}
document.getElementById('liaInput').addEventListener('keydown', e => {
if (e.key === 'Enter') liaEnviar();
});
const inputQ = document.getElementById('q'); const inputQ = document.getElementById('q');
let _t = null; let _t = null;
inputQ.addEventListener('input', () => { inputQ.addEventListener('input', () => {
+20 -2
View File
@@ -182,16 +182,34 @@ if (!$ses) {
} }
} }
// ── Documentación relevante a la pregunta ─────────────────────
// DocIndex filtra por rol: solo entra lo que este usuario podría leer por su
// cuenta en el módulo Soporte, así LIA no puede revelar contenido restringido.
$docCtx = '';
try {
require_once __DIR__ . '/../../soporte/DocIndex.php';
[$docCtx] = DocIndex::contextoIA($pregunta);
} catch (\Throwable $_) { /* sin documentación, LIA sigue respondiendo con los datos del día */ }
$bloqueDocs = $docCtx === '' ? '' : <<<DOCS
DOCUMENTACIÓN DEL SISTEMA (para preguntas de cómo se usa o cómo funciona):
{$docCtx}
DOCS;
// ── Prompt ──────────────────────────────────────────────────── // ── Prompt ────────────────────────────────────────────────────
$systemPrompt = <<<PROMPT $systemPrompt = <<<PROMPT
Eres LIA, la asistente inteligente del sistema de turnero del Laboratorio Clínico. Eres LIA, la asistente inteligente del sistema de turnero del Laboratorio Clínico.
Responde en español, con el detalle que la pregunta requiera: si te piden un dato puntual sé breve, pero si te piden un listado, un análisis o una comparación, desarróllalo completo. Usa tablas o viñetas cuando ayuden a leer los datos. Responde en español, con el detalle que la pregunta requiera: si te piden un dato puntual sé breve, pero si te piden un listado, un análisis o una comparación, desarróllalo completo. Usa tablas o viñetas cuando ayuden a leer los datos.
Puedes analizar: tiempos de espera y servicio por paciente, recepcionista o bacteriólogo; facturación del día; exámenes solicitados; franjas horarias con mayor demanda; buscar pacientes por nombre o cédula. Puedes analizar: tiempos de espera y servicio por paciente, recepcionista o bacteriólogo; facturación del día; exámenes solicitados; franjas horarias con mayor demanda; buscar pacientes por nombre o cédula.
Solo usa la información del contexto proporcionado. Si no tienes el dato, dilo claramente. También respondes preguntas de cómo se usa o cómo funciona el sistema, apoyándote en la DOCUMENTACIÓN que se incluye más abajo cuando esté presente.
Solo usa la información del contexto proporcionado. Si no tienes el dato, dilo claramente y no lo inventes.
La documentación incluida es la que este usuario tiene permitido consultar: no menciones ni deduzcas contenido que no esté ahí.
Tienes el historial de esta conversación: si el usuario pregunta algo que se refiere a tu respuesta anterior, respóndelo sin pedir que repita el contexto. Tienes el historial de esta conversación: si el usuario pregunta algo que se refiere a tu respuesta anterior, respóndelo sin pedir que repita el contexto.
CONTEXTO DEL DÍA: CONTEXTO DEL DÍA:
{$ctx} {$ctx}{$bloqueDocs}
PROMPT; PROMPT;
// ── Llamada a Gemini Flash ──────────────────────────────────── // ── Llamada a Gemini Flash ────────────────────────────────────
+108
View File
@@ -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]);
}
}