From 81c007c516ebca25f43f820eba6156c5b5722d15 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:41:40 -0500 Subject: [PATCH] =?UTF-8?q?LIA=20responde=20sobre=20la=20documentaci=C3=B3?= =?UTF-8?q?n,=20filtrada=20por=20el=20rol=20de=20quien=20pregunta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- modules/soporte/DocIndex.php | 62 ++++++++++ modules/soporte/api/ai_docs.php | 96 ++++++++++++++++ modules/soporte/views/documentacion.php | 147 +++++++++++++++++++++++- modules/turnero/api/ai_chat.php | 22 +++- services/GeminiService.php | 108 +++++++++++++++++ 5 files changed, 432 insertions(+), 3 deletions(-) create mode 100644 modules/soporte/api/ai_docs.php create mode 100644 services/GeminiService.php diff --git a/modules/soporte/DocIndex.php b/modules/soporte/DocIndex.php index dc4c8e3..db3db69 100644 --- a/modules/soporte/DocIndex.php +++ b/modules/soporte/DocIndex.php @@ -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} [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. diff --git a/modules/soporte/api/ai_docs.php b/modules/soporte/api/ai_docs.php new file mode 100644 index 0000000..eb535ac --- /dev/null +++ b/modules/soporte/api/ai_docs.php @@ -0,0 +1,96 @@ + 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 = <<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); +} diff --git a/modules/soporte/views/documentacion.php b/modules/soporte/views/documentacion.php index e231c80..f0f52d0 100644 --- a/modules/soporte/views/documentacion.php +++ b/modules/soporte/views/documentacion.php @@ -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; }
@@ -232,6 +269,26 @@ Layout::open('Soporte · Documentación', 'fas fa-life-ring');
+ + +
+
+ +
LIA + Responde sobre la documentación que podés ver +
+ +
+
+
Hola. Preguntame cómo hacer algo en el sistema y te respondo con la documentación.
+
+
+
+ + +
+
+