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>
274 lines
13 KiB
PHP
274 lines
13 KiB
PHP
<?php
|
||
/**
|
||
* modules/turnero/api/ai_chat.php
|
||
* POST { pregunta: string, historial?: [{rol:'user'|'model', texto:string}, ...] }
|
||
* Llama a Gemini Flash con contexto completo del turnero del día.
|
||
*/
|
||
require_once __DIR__ . '/_helpers.php';
|
||
|
||
requireTurnero();
|
||
requireMethod('POST');
|
||
|
||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||
$pregunta = trim($body['pregunta'] ?? '');
|
||
if ($pregunta === '') jsonError('Pregunta vacía', 400);
|
||
if (mb_strlen($pregunta) > 800) jsonError('Pregunta demasiado larga', 400);
|
||
|
||
// Historial de la conversación, para que LIA entienda preguntas de seguimiento
|
||
// ("¿y de esos cuántos fueron en la mañana?"). Se limita a los últimos turnos
|
||
// para no inflar el consumo de tokens en cada llamada.
|
||
$historial = [];
|
||
foreach (array_slice(is_array($body['historial'] ?? null) ? $body['historial'] : [], -8) as $h) {
|
||
$rol = ($h['rol'] ?? '') === 'model' ? 'model' : 'user';
|
||
$texto = trim((string)($h['texto'] ?? ''));
|
||
if ($texto === '') continue;
|
||
$historial[] = ['role' => $rol, 'parts' => [['text' => mb_substr($texto, 0, 2000)]]];
|
||
}
|
||
|
||
define('LIA_TOKENS_MAX', 1_000_000);
|
||
|
||
$pdo = db();
|
||
|
||
// ── Clave Gemini ──────────────────────────────────────────────
|
||
$cfg = $pdo->query("SELECT clave, valor FROM lab_config WHERE clave IN ('gemini_api_key','lia_tokens_usados')")->fetchAll(PDO::FETCH_KEY_PAIR);
|
||
$apiKey = trim($cfg['gemini_api_key'] ?? '');
|
||
$tokensUsados = (int)($cfg['lia_tokens_usados'] ?? 0);
|
||
|
||
if (!$apiKey) jsonError('Token de IA no configurado. Ve a Configuración del laboratorio.', 503);
|
||
if ($tokensUsados >= LIA_TOKENS_MAX) {
|
||
jsonError('⚠️ Se agotaron los tokens de LIA. Solicita tokens a soporte para continuar.', 402);
|
||
}
|
||
|
||
// ── Contexto del día ──────────────────────────────────────────
|
||
$hoy = date('Y-m-d');
|
||
$ctx = "Hoy es {$hoy}.\n";
|
||
|
||
$sesRow = $pdo->prepare("SELECT id, inicio_at, fin_at FROM turnero_sesiones WHERE fecha = ? LIMIT 1");
|
||
$sesRow->execute([$hoy]);
|
||
$ses = $sesRow->fetch(PDO::FETCH_ASSOC);
|
||
|
||
if (!$ses) {
|
||
$ctx .= "No hay sesión de atención abierta hoy.\n";
|
||
} else {
|
||
$sesId = (int)$ses['id'];
|
||
$ctx .= "Sesión " . ($ses['fin_at'] ? 'cerrada' : 'abierta') . ", inicio: " . ($ses['inicio_at'] ?? 'pendiente') . ".\n";
|
||
|
||
// Resumen de estados
|
||
$stmt = $pdo->prepare("SELECT estado, COUNT(*) AS n FROM turnero_turnos WHERE sesion_id = ? GROUP BY estado");
|
||
$stmt->execute([$sesId]);
|
||
$stats = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||
$total = array_sum(array_column($stats, 'n'));
|
||
$ctx .= "Turnos hoy: {$total} total. ";
|
||
foreach ($stats as $s) { $ctx .= "{$s['estado']}: {$s['n']}. "; }
|
||
$ctx .= "\n";
|
||
|
||
// Tiempos promedio globales
|
||
$tRow = $pdo->prepare(
|
||
"SELECT
|
||
ROUND(AVG(TIMESTAMPDIFF(SECOND, creado_at, COALESCE(inicio_recepcion_at, NOW())) / 60), 1) AS espera_prom,
|
||
ROUND(AVG(TIMESTAMPDIFF(SECOND, inicio_recepcion_at, fin_recepcion_at) / 60), 1) AS recepcion_prom,
|
||
ROUND(AVG(TIMESTAMPDIFF(SECOND, inicio_lugar_at, COALESCE(fin_lugar_at, NOW())) / 60), 1) AS servicio_prom,
|
||
ROUND(AVG(TIMESTAMPDIFF(SECOND, creado_at, COALESCE(fin_lugar_at, NOW())) / 60), 1) AS total_prom
|
||
FROM turnero_turnos WHERE sesion_id = ?"
|
||
);
|
||
$tRow->execute([$sesId]);
|
||
$tiempos = $tRow->fetch(PDO::FETCH_ASSOC);
|
||
$ctx .= "Tiempos promedio — espera: " . ($tiempos['espera_prom'] ?? '—') . "min, ";
|
||
$ctx .= "recepción: " . ($tiempos['recepcion_prom'] ?? '—') . "min, ";
|
||
$ctx .= "servicio: " . ($tiempos['servicio_prom'] ?? '—') . "min, ";
|
||
$ctx .= "total: " . ($tiempos['total_prom'] ?? '—') . "min.\n";
|
||
|
||
// Tiempos por recepcionista
|
||
$rRecep = $pdo->prepare(
|
||
"SELECT ur.full_name AS recepcionista,
|
||
COUNT(*) AS atendidos,
|
||
ROUND(AVG(TIMESTAMPDIFF(SECOND, t.inicio_recepcion_at, t.fin_recepcion_at) / 60), 1) AS prom_min
|
||
FROM turnero_turnos t
|
||
JOIN admin_users ur ON ur.id = t.atendido_recepcion_por
|
||
WHERE t.sesion_id = ? AND t.fin_recepcion_at IS NOT NULL
|
||
GROUP BY ur.id, ur.full_name ORDER BY prom_min DESC"
|
||
);
|
||
$rRecep->execute([$sesId]);
|
||
$recepStats = $rRecep->fetchAll(PDO::FETCH_ASSOC);
|
||
if ($recepStats) {
|
||
$ctx .= "\nTiempo promedio por recepcionista:\n";
|
||
foreach ($recepStats as $r) {
|
||
$ctx .= "- {$r['recepcionista']}: {$r['prom_min']} min/paciente ({$r['atendidos']} atendidos)\n";
|
||
}
|
||
}
|
||
|
||
// Facturación del día
|
||
$rFact = $pdo->prepare(
|
||
"SELECT SUM(ts.total_cobrado) AS total, COUNT(*) AS n, ts.metodo_pago
|
||
FROM turnero_solicitudes ts
|
||
JOIN turnero_turnos t ON t.id = ts.turno_id
|
||
WHERE t.sesion_id = ? AND ts.total_cobrado > 0
|
||
GROUP BY ts.metodo_pago"
|
||
);
|
||
$rFact->execute([$sesId]);
|
||
$factRows = $rFact->fetchAll(PDO::FETCH_ASSOC);
|
||
if ($factRows) {
|
||
$ctx .= "\nFacturación del día:\n";
|
||
$totalFact = 0;
|
||
foreach ($factRows as $f) {
|
||
$ctx .= "- {$f['metodo_pago']}: $" . number_format((float)$f['total'], 0, ',', '.') . " ({$f['n']} atenciones)\n";
|
||
$totalFact += (float)$f['total'];
|
||
}
|
||
$ctx .= "Total cobrado hoy: $" . number_format($totalFact, 0, ',', '.') . "\n";
|
||
}
|
||
|
||
// Franjas horarias (cada 2h)
|
||
$rFranjas = $pdo->prepare(
|
||
"SELECT FLOOR(HOUR(creado_at) / 2) * 2 AS franja, COUNT(*) AS n
|
||
FROM turnero_turnos WHERE sesion_id = ?
|
||
GROUP BY franja ORDER BY franja"
|
||
);
|
||
$rFranjas->execute([$sesId]);
|
||
$franjas = $rFranjas->fetchAll(PDO::FETCH_ASSOC);
|
||
if ($franjas) {
|
||
$ctx .= "\nTurnos por franja horaria:\n";
|
||
foreach ($franjas as $f) {
|
||
$h = (int)$f['franja'];
|
||
$ctx .= "- {$h}:00–" . ($h+2) . ":00 → {$f['n']} turnos\n";
|
||
}
|
||
}
|
||
|
||
// Detalle completo de turnos (máx 60)
|
||
$tList = $pdo->prepare(
|
||
"SELECT t.codigo, t.estado, p.codigo AS prioridad,
|
||
COALESCE(sp.nombre_completo, t.paciente_nombre) AS paciente,
|
||
sp.numero_documento AS doc,
|
||
l.nombre AS lugar,
|
||
ur.full_name AS recepcionista,
|
||
ul.full_name AS bacteriologo,
|
||
ts.total_cobrado AS cobrado,
|
||
ts.metodo_pago,
|
||
ROUND(TIMESTAMPDIFF(SECOND, t.inicio_recepcion_at, t.fin_recepcion_at) / 60, 1) AS min_recepcion,
|
||
ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, t.fin_lugar_at) / 60, 1) AS min_servicio,
|
||
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, COALESCE(t.fin_lugar_at, NOW())) / 60, 1) AS min_total,
|
||
GROUP_CONCAT(DISTINCT et.nombre ORDER BY et.nombre SEPARATOR ', ') AS examenes
|
||
FROM turnero_turnos t
|
||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||
LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id
|
||
LEFT JOIN turnero_solicitudes ts ON ts.turno_id = t.id
|
||
LEFT JOIN turnero_examen_items tei ON tei.solicitud_id = ts.id
|
||
LEFT JOIN exam_tipos et ON et.id = tei.exam_tipo_id
|
||
LEFT JOIN lab_pacientes sp ON sp.id = ts.paciente_id
|
||
LEFT JOIN admin_users ur ON ur.id = t.atendido_recepcion_por
|
||
LEFT JOIN admin_users ul ON ul.id = t.atendido_lugar_por
|
||
WHERE t.sesion_id = ?
|
||
GROUP BY t.id
|
||
ORDER BY t.numero ASC LIMIT 60"
|
||
);
|
||
$tList->execute([$sesId]);
|
||
$turnos = $tList->fetchAll(PDO::FETCH_ASSOC);
|
||
|
||
if ($turnos) {
|
||
$ctx .= "\nDetalle de cada turno:\n";
|
||
foreach ($turnos as $t) {
|
||
$ctx .= "- {$t['codigo']} [{$t['prioridad']}] {$t['paciente']}";
|
||
if (!empty($t['doc'])) $ctx .= " (CC: {$t['doc']})";
|
||
if (!empty($t['lugar'])) $ctx .= " → {$t['lugar']}";
|
||
if (!empty($t['recepcionista'])) $ctx .= " | Recep: {$t['recepcionista']}";
|
||
if (!empty($t['bacteriologo'])) $ctx .= " | Bact: {$t['bacteriologo']}";
|
||
if (!empty($t['examenes'])) $ctx .= " | Exámenes: {$t['examenes']}";
|
||
if ($t['min_recepcion'] !== null) $ctx .= " | T.recep: {$t['min_recepcion']}min";
|
||
if ($t['min_servicio'] !== null) $ctx .= " | T.serv: {$t['min_servicio']}min";
|
||
if ($t['min_total'] !== null) $ctx .= " | T.total: {$t['min_total']}min";
|
||
if (!empty($t['cobrado'])) $ctx .= " | $" . number_format((float)$t['cobrado'], 0, ',', '.');
|
||
if (!empty($t['metodo_pago'])) $ctx .= " ({$t['metodo_pago']})";
|
||
$ctx .= " [{$t['estado']}]\n";
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Documentación relevante a la pregunta ─────────────────────
|
||
// DocIndex filtra por rol: solo entra lo que este usuario podría leer por su
|
||
// cuenta en el módulo Soporte, así LIA no puede revelar contenido restringido.
|
||
$docCtx = '';
|
||
try {
|
||
require_once __DIR__ . '/../../soporte/DocIndex.php';
|
||
[$docCtx] = DocIndex::contextoIA($pregunta);
|
||
} catch (\Throwable $_) { /* sin documentación, LIA sigue respondiendo con los datos del día */ }
|
||
|
||
$bloqueDocs = $docCtx === '' ? '' : <<<DOCS
|
||
|
||
|
||
MANUAL DE USUARIO (para preguntas de cómo se usa el sistema):
|
||
{$docCtx}
|
||
DOCS;
|
||
|
||
// ── Prompt ────────────────────────────────────────────────────
|
||
$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 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.
|
||
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:
|
||
{$ctx}{$bloqueDocs}
|
||
PROMPT;
|
||
|
||
// ── Llamada a Gemini Flash ────────────────────────────────────
|
||
$payload = json_encode([
|
||
'system_instruction' => ['parts' => [['text' => $systemPrompt]]],
|
||
'contents' => array_merge($historial, [
|
||
['role' => 'user', 'parts' => [['text' => $pregunta]]]
|
||
]),
|
||
'generationConfig' => [
|
||
'temperature' => 0.3,
|
||
// 600 cortaba a media frase las respuestas largas (listados de turnos,
|
||
// comparativas por bacteriólogo). Gemini Flash admite hasta 8192.
|
||
'maxOutputTokens' => 2048,
|
||
],
|
||
], JSON_UNESCAPED_UNICODE);
|
||
|
||
$ch = curl_init("https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent?key={$apiKey}");
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_POST => true,
|
||
CURLOPT_POSTFIELDS => $payload,
|
||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||
CURLOPT_TIMEOUT => 20,
|
||
CURLOPT_SSL_VERIFYPEER => true,
|
||
]);
|
||
$raw = curl_exec($ch);
|
||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
curl_close($ch);
|
||
|
||
if (!$raw) jsonError('Sin respuesta del servicio de IA', 502);
|
||
|
||
$gemini = json_decode($raw, true);
|
||
if ($code !== 200 || empty($gemini['candidates'][0]['content']['parts'][0]['text'])) {
|
||
$detail = $gemini['error']['message'] ?? 'Error desconocido';
|
||
jsonError("Error IA: {$detail}", 502);
|
||
}
|
||
|
||
$respuesta = $gemini['candidates'][0]['content']['parts'][0]['text'];
|
||
|
||
// Gemini corta la respuesta y avisa con finishReason; sin esto el usuario recibe
|
||
// un texto truncado a media frase sin saber que faltó contenido.
|
||
$finishReason = $gemini['candidates'][0]['finishReason'] ?? null;
|
||
$truncada = ($finishReason === 'MAX_TOKENS');
|
||
if ($truncada) {
|
||
$respuesta .= "\n\n_(respuesta cortada por longitud — pide el detalle por partes)_";
|
||
}
|
||
|
||
$usage = $gemini['usageMetadata'] ?? [];
|
||
$tokensEsta = (int)($usage['totalTokenCount'] ?? (int)((mb_strlen($pregunta) + mb_strlen($respuesta)) / 4));
|
||
$tokensUsados += $tokensEsta;
|
||
|
||
$pdo->prepare("INSERT INTO lab_config (clave, valor) VALUES ('lia_tokens_usados', ?)
|
||
ON DUPLICATE KEY UPDATE valor = ?")->execute([$tokensUsados, $tokensUsados]);
|
||
|
||
jsonOk([
|
||
'respuesta' => $respuesta,
|
||
'truncada' => $truncada,
|
||
'tokens_usados' => $tokensUsados,
|
||
'tokens_restantes' => max(0, LIA_TOKENS_MAX - $tokensUsados),
|
||
'tokens_max' => LIA_TOKENS_MAX,
|
||
]);
|