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));
}
/** 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.
+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; }
}
@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', () => {
+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 ────────────────────────────────────────────────────
$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 ────────────────────────────────────
+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]);
}
}