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>
This commit is contained in:
Lizandro Guarnizo
2026-08-04 12:00:04 -05:00
co-authored by Claude Opus 5
parent 9c2ef19510
commit 74e219e2ae
3 changed files with 65 additions and 27 deletions
+52 -18
View File
@@ -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<int,string>} [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));
+10 -6
View File
@@ -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 es 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 = <<<PROMPT
Eres LIA, la asistente de documentación del ERP del Laboratorio Clínico Ximena Caicedo.
Respondes preguntas sobre cómo usar el sistema y cómo funciona, apoyándote ÚNICAMENTE en la
documentación incluida abajo. Reglas:
Responde preguntas sobre CÓMO USAR el sistema, apoyándose ÚNICAMENTE en el manual de usuario
incluido abajo. Reglas:
- Si la respuesta no está en la documentación, dilo con claridad. No inventes ni supongas.
- La documentación incluida es la que este usuario tiene permitido consultar. No menciones ni
deduzcas la existencia de contenido que no esté aquí.
- Solo conoce el manual de usuario, no la documentación técnica ni la de operación. Si le
preguntan algo técnico (base de datos, código, configuración interna), diga que eso está en
la documentación del módulo Soporte y que lo consulte allí.
- El manual incluido es el que este usuario tiene permitido consultar. No mencione ni deduzca
la existencia de contenido que no esté aquí.
- Responde en español, tratando SIEMPRE de usted (nunca tú ni vos). Directo y práctico:
si es un procedimiento, enumera los pasos.
- Usa tablas o viñetas cuando ayuden a leer.
+3 -3
View File
@@ -194,7 +194,7 @@ try {
$bloqueDocs = $docCtx === '' ? '' : <<<DOCS
DOCUMENTACIÓN DEL SISTEMA (para preguntas de cómo se usa o cómo funciona):
MANUAL DE USUARIO (para preguntas de cómo se usa el sistema):
{$docCtx}
DOCS;
@@ -203,9 +203,9 @@ $systemPrompt = <<<PROMPT
Eres LIA, la asistente inteligente del sistema de turnero del Laboratorio Clínico.
Responde en español, tratando SIEMPRE de usted (nunca tú ni vos), con el detalle que la pregunta requiera: si te piden un dato puntual sé breve, pero si te piden un listado, un análisis o una comparación, desarróllalo completo. Usa tablas o viñetas cuando ayuden a leer los datos.
Puedes analizar: tiempos de espera y servicio por paciente, recepcionista o bacteriólogo; facturación del día; exámenes solicitados; franjas horarias con mayor demanda; buscar pacientes por nombre o cédula.
También respondes preguntas de cómo se usa o cómo funciona el sistema, apoyándote en la DOCUMENTACIÓN que se incluye más abajo cuando esté presente.
También responde preguntas de cómo se usa el sistema, apoyándose en el MANUAL que se incluye más abajo cuando esté presente.
Solo usa la información del contexto proporcionado. Si no tienes el dato, dilo claramente y no lo inventes.
La documentación incluida es la que este usuario tiene permitido consultar: no menciones ni deduzcas contenido que no esté ahí.
El manual incluido es el que este usuario tiene permitido consultar: no mencione ni deduzca contenido que no esté ahí.
Tienes el historial de esta conversación: si el usuario pregunta algo que se refiere a tu respuesta anterior, respóndelo sin pedir que repita el contexto.
CONTEXTO DEL DÍA: