Files
whatsapp/modules/soporte/DocIndex.php
T
Lizandro GuarnizoandClaude Opus 5 74e219e2ae LIA consulta solo el manual de usuario, y descarta coincidencias irrelevantes
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 <noreply@anthropic.com>
2026-08-04 12:00:04 -05:00

286 lines
11 KiB
PHP

<?php
/**
* modules/soporte/DocIndex.php
* Descubre los documentos en docs/, arma el árbol de navegación y resuelve el
* control de acceso por sección.
*
* Convenciones:
* docs/<seccion>/<NN>-<slug>.md → el prefijo NN solo ordena, no se muestra
* El título sale del primer encabezado `# ` del archivo.
*
* Un documento puede restringir su visibilidad con una cabecera al inicio:
*
* ---
* roles: enfermero, recepcionista
* ---
*
* Sin cabecera, hereda el permiso de su sección. Los administradores ven todo.
*/
final class DocIndex
{
/** Secciones, en orden de aparición. `admin` = solo administradores. */
public const SECCIONES = [
'manual' => ['titulo' => 'Manual de usuario', 'icono' => 'fas fa-book-reader', 'admin' => false,
'resumen' => 'Cómo usar el sistema, paso a paso, según su rol.'],
'tecnica' => ['titulo' => 'Documentación técnica','icono' => 'fas fa-code', 'admin' => true,
'resumen' => 'Cada módulo por dentro: tablas, endpoints y dependencias.'],
'arquitectura'=> ['titulo' => 'Arquitectura', 'icono' => 'fas fa-sitemap', 'admin' => true,
'resumen' => 'Cómo está armado el sistema y por qué.'],
'operacion' => ['titulo' => 'Operación y soporte', 'icono' => 'fas fa-life-ring', 'admin' => true,
'resumen' => 'Qué hacer cuando algo falla. Configuraciones críticas.'],
];
/** Roles que ven todo, sin importar lo que declare cada documento. */
public const ROLES_TOTALES = ['admin', 'superadmin'];
public static function dir(): string
{
return __DIR__ . '/docs';
}
private static function rolActual(): string
{
return $_SESSION['admin_user']['role'] ?? '';
}
private static function esAdmin(): bool
{
return in_array(self::rolActual(), self::ROLES_TOTALES, true);
}
/**
* Separa la cabecera del cuerpo de un documento.
* @return array{0: array<string,string>, 1: string} [metadatos, cuerpo]
*/
public static function leer(string $archivo): array
{
$texto = (string)@file_get_contents($archivo);
if (!preg_match('/^---\R(.*?)\R---\R?(.*)$/s', $texto, $m)) {
return [[], $texto];
}
$meta = [];
foreach (preg_split('/\R/', $m[1]) as $linea) {
if (preg_match('/^\s*([\w-]+)\s*:\s*(.*)$/', $linea, $kv)) {
$meta[strtolower($kv[1])] = trim($kv[2]);
}
}
return [$meta, ltrim($m[2])];
}
/** ¿El usuario actual puede ver este documento concreto? */
public static function puedeVerDoc(string $seccion, string $archivo): bool
{
if (self::esAdmin()) return true;
if (!self::puedeVer($seccion)) return false;
[$meta] = self::leer($archivo);
if (empty($meta['roles'])) return true; // sin cabecera: hereda la sección
$permitidos = array_filter(array_map('trim', explode(',', strtolower($meta['roles']))));
return in_array(strtolower(self::rolActual()), $permitidos, true);
}
/** ¿El usuario actual puede ver esta sección? */
public static function puedeVer(string $seccion): bool
{
$cfg = self::SECCIONES[$seccion] ?? null;
if (!$cfg) return false;
if (!$cfg['admin']) return true;
return self::esAdmin();
}
/** Secciones visibles para el usuario actual. */
public static function seccionesVisibles(): array
{
return array_filter(
self::SECCIONES,
fn($s) => self::puedeVer($s),
ARRAY_FILTER_USE_KEY
);
}
/**
* Árbol completo: [seccion => ['titulo'=>..,'docs'=>[['slug','titulo','archivo'],...]]]
* Solo incluye lo que el usuario puede ver.
*/
public static function arbol(): array
{
$arbol = [];
foreach (self::seccionesVisibles() as $sec => $cfg) {
$ruta = self::dir() . '/' . $sec;
if (!is_dir($ruta)) continue;
$docs = [];
foreach (glob($ruta . '/*.md') ?: [] as $archivo) {
if (!self::puedeVerDoc($sec, $archivo)) continue;
$base = basename($archivo, '.md');
$slug = preg_replace('/^\d+-/', '', $base);
$docs[] = [
'slug' => $slug,
'titulo' => self::titulo($archivo, $slug),
'archivo' => $archivo,
'orden' => $base,
];
}
usort($docs, fn($a, $b) => strcmp($a['orden'], $b['orden']));
// Una sección sin documentos visibles no se muestra
if (!$docs) continue;
$arbol[$sec] = $cfg + ['docs' => $docs];
}
return $arbol;
}
/** Ruta del archivo pedido, o null si no existe o no hay acceso. */
public static function resolver(string $seccion, string $slug): ?string
{
if (!self::puedeVer($seccion)) return null;
// Evitar traversal: los slugs son [a-z0-9-]
if (!preg_match('/^[a-z0-9-]+$/', $seccion) || !preg_match('/^[a-z0-9-]+$/', $slug)) return null;
foreach (glob(self::dir() . '/' . $seccion . '/*.md') ?: [] as $archivo) {
if (preg_replace('/^\d+-/', '', basename($archivo, '.md')) !== $slug) continue;
return self::puedeVerDoc($seccion, $archivo) ? $archivo : null;
}
return null;
}
/** Primer encabezado `# ` del archivo; si no hay, el slug capitalizado. */
private static function titulo(string $archivo, string $slugFallback): string
{
$fh = @fopen($archivo, 'r');
if ($fh) {
$lineas = 0;
while (($l = fgets($fh)) !== false && $lineas++ < 30) {
if (preg_match('/^#\s+(.+)$/', trim($l), $m)) { fclose($fh); return trim($m[1]); }
}
fclose($fh);
}
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',
];
/** 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.
*
* 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<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 ['', []];
// 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']);
$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));
$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.
*/
public static function indiceBusqueda(): array
{
$out = [];
foreach (self::arbol() as $sec => $cfg) {
foreach ($cfg['docs'] as $doc) {
[, $texto] = self::leer($doc['archivo']);
// Aplanar: sin marcas, sin saltos, compacto
$texto = preg_replace('/```.*?```/s', ' ', $texto);
$texto = preg_replace('/[#>*_`|-]+/', ' ', $texto);
$texto = preg_replace('/\s+/', ' ', $texto);
$out[] = [
's' => $sec,
'u' => $doc['slug'],
't' => $doc['titulo'],
'c' => $cfg['titulo'],
'x' => mb_substr(trim($texto), 0, 4000),
];
}
}
return $out;
}
}