/-.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, 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} [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; } }