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 = <<