Toda la documentación pasa de voseo a tratamiento de usted, incluidos los diagramas y los textos de la interfaz. Los prompts de ambos asistentes lo piden explícitamente, para que las respuestas generadas también lo respeten. El panel de LIA en la página de documentación adopta el diseño del dashboard del turnero: mismo botón circular, mismo panel deslizante con encabezado en degradado y logo LIA, mismos chips de atajo, burbujas y campo de entrada. Los atajos se arman con los documentos que ese usuario puede ver. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
252 lines
9.6 KiB
PHP
252 lines
9.6 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',
|
|
];
|
|
|
|
/**
|
|
* 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<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 ['', []];
|
|
|
|
$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.
|
|
*/
|
|
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;
|
|
}
|
|
}
|