DocIndex::contextoIA() selecciona los documentos relevantes a la pregunta y devuelve su texto. Reutiliza el filtrado por rol ya existente: al contexto que se envía al modelo solo entra lo que ese usuario podría leer por su cuenta en el módulo Soporte, así el asistente no puede revelar contenido restringido. Verificado: un recepcionista preguntando por permisos recibe solo el manual básico, sin el SQL ni los detalles internos que sí recibe un administrador. - services/GeminiService.php concentra la llamada a la API y la contabilidad del presupuesto, que antes vivía dentro de ai_chat.php y ahora comparten los dos asistentes. - La LIA del turnero suma la documentación a su contexto operativo, así responde tanto "cuánto facturamos hoy" como "cómo marco un paciente ausente". - modules/soporte/api/ai_docs.php es el asistente de la documentación: no accede a datos de pacientes ni de la operación, solo a los documentos visibles. A diferencia del anterior no exige acceso al turnero, así que lo puede usar cualquier usuario autenticado desde la página de documentación. - La respuesta cita de qué documentos salió. 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 tu 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;
|
|
}
|
|
}
|