From 74e219e2aedabccfe6b4af01b95ef4577d5bbeef Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:00:04 -0500 Subject: [PATCH] LIA consulta solo el manual de usuario, y descarta coincidencias irrelevantes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El asistente está para ayudar a usar el sistema, no a mantenerlo: la documentación técnica, de arquitectura y de operación queda fuera de su contexto incluso para administradores, que pueden leerla directamente en el módulo. El filtrado por rol sigue aplicándose dentro del manual. Al restringirlo apareció que la relevancia era débil: una pregunta sobre respaldos devolvía Recepción y Kiosko porque palabras comunes como "paciente" suman igual en todos los documentos. Ahora cada palabra pesa según en cuántos documentos aparece, y se descartan los resultados que no superen un umbral absoluto ni queden cerca del mejor. Una pregunta fuera del manual devuelve cero fuentes y LIA lo dice, en vez de responder con lo que tenga a mano. Co-Authored-By: Claude Opus 5 --- modules/soporte/DocIndex.php | 70 ++++++++++++++++++++++++--------- modules/soporte/api/ai_docs.php | 16 +++++--- modules/turnero/api/ai_chat.php | 6 +-- 3 files changed, 65 insertions(+), 27 deletions(-) diff --git a/modules/soporte/DocIndex.php b/modules/soporte/DocIndex.php index 5451d1b..ffa79e2 100644 --- a/modules/soporte/DocIndex.php +++ b/modules/soporte/DocIndex.php @@ -168,12 +168,21 @@ final class DocIndex 'hacer','tengo','puedo','quiero','necesito','ayuda','favor','the','and','not','del', ]; + /** Secciones que puede consultar el asistente. Ver contextoIA(). */ + public const SECCIONES_IA = ['manual']; + /** * 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. + * Dos restricciones se aplican a la vez: + * + * 1. Por sección: el asistente solo consulta el manual de usuario. Está + * para ayudar a usar el sistema, no a mantenerlo — la documentación + * técnica, de arquitectura y de operación queda fuera incluso para + * administradores, que pueden leerla directamente en el módulo. + * 2. Por rol: dentro del manual solo entra lo que ese usuario podría leer + * por su cuenta, así el asistente no puede revelar contenido ajeno. * * @return array{0: string, 1: array} [contexto, títulos usados] */ @@ -185,30 +194,55 @@ final class DocIndex )); if (!$palabras) return ['', []]; - $candidatos = []; + // Textos de los documentos consultables + $docs = []; foreach (self::arbol() as $sec => $cfg) { + if (!in_array($sec, self::SECCIONES_IA, true)) continue; 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, - ]; - } + $docs[] = [ + 'titulo' => $doc['titulo'], + 'seccion' => $cfg['titulo'], + 'cuerpo' => $cuerpo, + 'heno' => mb_strtolower($doc['titulo'] . ' ' . $cuerpo), + ]; } } + if (!$docs) return ['', []]; + + // Peso de cada palabra según en cuántos documentos aparece: una que está + // en todos ("paciente") no distingue nada; una que está en pocos sí. + $total = count($docs); + $peso = []; + foreach ($palabras as $p) { + $enCuantos = 0; + foreach ($docs as $d) if (str_contains($d['heno'], $p)) $enCuantos++; + $peso[$p] = $enCuantos ? log(1 + $total / $enCuantos) : 0; + } + + $candidatos = []; + foreach ($docs as $d) { + $puntaje = 0.0; + foreach ($palabras as $p) { + if (!$peso[$p]) continue; + // El título pesa mucho más que una mención en el cuerpo + $puntaje += substr_count(mb_strtolower($d['titulo']), $p) * 12 * $peso[$p]; + $puntaje += min(substr_count($d['heno'], $p), 6) * $peso[$p]; + } + if ($puntaje > 0) $candidatos[] = ['puntaje' => $puntaje] + $d; + } if (!$candidatos) return ['', []]; usort($candidatos, fn($a, $b) => $b['puntaje'] <=> $a['puntaje']); + + // Umbral absoluto: por debajo son coincidencias sueltas de palabras + // sin relación real con la pregunta, y darle eso al modelo lo lleva a + // responder con lo que tenga a mano en vez de admitir que no sabe. + if ($candidatos[0]['puntaje'] < 6) return ['', []]; + + // Y relativo: descartar lo que quede muy por debajo del mejor resultado + $corte = $candidatos[0]['puntaje'] * 0.45; + $candidatos = array_values(array_filter($candidatos, fn($c) => $c['puntaje'] >= $corte)); $candidatos = array_slice($candidatos, 0, $maxDocs); $porDoc = (int)floor($maxChars / count($candidatos)); diff --git a/modules/soporte/api/ai_docs.php b/modules/soporte/api/ai_docs.php index 31f7b6d..2d0d16e 100644 --- a/modules/soporte/api/ai_docs.php +++ b/modules/soporte/api/ai_docs.php @@ -46,8 +46,9 @@ if ($gemini->presupuestoAgotado()) { if ($contexto === '') { salir([ 'ok' => true, - 'respuesta' => 'No encontré nada sobre eso en la documentación a la que usted tiene acceso. ' - . 'Pruebe con otras palabras, o revise el índice de la izquierda.', + 'respuesta' => 'Eso no está en el manual de usuario. Pruebe con otras palabras, ' + . 'o revise el índice de la izquierda: puede estar en otra sección ' + . 'de la documentación.', 'fuentes' => [], ]); } @@ -66,12 +67,15 @@ foreach (array_slice(is_array($body['historial'] ?? null) ? $body['historial'] : $prompt = <<