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:
co-authored by
Claude Opus 5
parent
9c2ef19510
commit
74e219e2ae
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user