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
@@ -161,6 +161,68 @@ final class DocIndex
|
||||
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.
|
||||
* Solo incluye secciones visibles para el usuario actual.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -135,10 +135,47 @@ Layout::open('Soporte · Documentación', 'fas fa-life-ring');
|
||||
.doc-body { padding:20px 18px 60px; }
|
||||
}
|
||||
@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 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>
|
||||
|
||||
<div class="doc-wrap">
|
||||
@@ -232,6 +269,26 @@ Layout::open('Soporte · Documentación', 'fas fa-life-ring');
|
||||
</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>
|
||||
/* ── Buscador: índice completo servido con la página ── */
|
||||
const DOCS = <?= json_encode(DocIndex::indiceBusqueda(), JSON_UNESCAPED_UNICODE) ?>;
|
||||
@@ -281,6 +338,94 @@ function esc(s) {
|
||||
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');
|
||||
let _t = null;
|
||||
inputQ.addEventListener('input', () => {
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────
|
||||
$systemPrompt = <<<PROMPT
|
||||
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.
|
||||
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.
|
||||
|
||||
CONTEXTO DEL DÍA:
|
||||
{$ctx}
|
||||
{$ctx}{$bloqueDocs}
|
||||
PROMPT;
|
||||
|
||||
// ── Llamada a Gemini Flash ────────────────────────────────────
|
||||
|
||||
@@ -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