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
+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', () => {