Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
212 lines
9.6 KiB
PHP
212 lines
9.6 KiB
PHP
<?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.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'];
|
||
jsonOk(['respuesta' => $respuesta]);
|