Files
whatsapp/modules/turnero/api/ai_chat.php
T
Lizandro GuarnizoandClaude Sonnet 4.6 d0152e37ce feat: asistente IA Gemini Flash en dashboard turnero + config token
- ai_chat.php: contexto completo (tiempos, facturación, franjas, turnos detallados)
- Preguntas soportadas: demoras por recepcionista, total facturado, horas pico,
  tiempo total por paciente, exámenes, búsqueda por cédula
- Panel flotante de chat en dashboard (botón robot azul)
- Búsqueda de tabla ahora filtra por cédula/documento
- Ícono de fila gira al expandir detalle (flecha → rotada)
- lab_configuracion.php: nueva sección IA con campo token Gemini
- save_config.php: permite guardar gemini_api_key

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 20:28:43 -05:00

212 lines
9.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* modules/turnero/api/ai_chat.php
* POST { pregunta: 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);
$pdo = db();
// ── Clave Gemini ──────────────────────────────────────────────
$row = $pdo->query("SELECT valor FROM lab_config WHERE clave = 'gemini_api_key' LIMIT 1")->fetch();
$apiKey = trim($row['valor'] ?? '');
if (!$apiKey) jsonError('Token de IA no configurado. Ve a Configuración del laboratorio.', 503);
// ── 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";
}
}
}
// ── Prompt ────────────────────────────────────────────────────
$systemPrompt = <<<PROMPT
Eres el asistente inteligente del sistema de turnero del Laboratorio Clínico.
Responde de forma concisa y útil en español. 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.
Solo usa la información del contexto proporcionado. Si no tienes el dato, dilo claramente.
CONTEXTO DEL DÍA:
{$ctx}
PROMPT;
// ── Llamada a Gemini Flash ────────────────────────────────────
$payload = json_encode([
'system_instruction' => ['parts' => [['text' => $systemPrompt]]],
'contents' => [
['role' => 'user', 'parts' => [['text' => $pregunta]]]
],
'generationConfig' => [
'temperature' => 0.3,
'maxOutputTokens' => 600,
],
], JSON_UNESCAPED_UNICODE);
$ch = curl_init("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-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'];
jsonOk(['respuesta' => $respuesta]);