487 lines
17 KiB
PHP
487 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Controllers\Setting;
|
|
|
|
use App\Controllers\BaseController;
|
|
|
|
class AiSettings extends BaseController
|
|
{
|
|
public function index()
|
|
{
|
|
if (! $this->canManageAiSettings()) {
|
|
return $this->response->setStatusCode(403)->setBody('Access denied');
|
|
}
|
|
|
|
return view('Setting/Admin/ai_settings', [
|
|
'diagnosis_ai_server_url' => $this->readSettingValue('DIAGNOSIS_AI_SERVER_URL'),
|
|
'diagnosis_ai_ocr_endpoint' => $this->readSettingValue('DIAGNOSIS_AI_OCR_ENDPOINT'),
|
|
'diagnosis_ai_parse_endpoint' => $this->readSettingValue('DIAGNOSIS_AI_PARSE_ENDPOINT'),
|
|
'diagnosis_ai_imaging_prompt' => $this->readSettingValue('DIAGNOSIS_AI_IMAGING_PROMPT') ?: $this->defaultImagingPrompt(),
|
|
'diagnosis_ai_timeout_seconds' => $this->readSettingValue('DIAGNOSIS_AI_TIMEOUT_SECONDS') ?: '45',
|
|
'diagnosis_ai_retry_attempts' => $this->readSettingValue('DIAGNOSIS_AI_RETRY_ATTEMPTS') ?: '2',
|
|
'diagnosis_ai_daily_limit' => $this->readSettingValue('DIAGNOSIS_AI_DAILY_LIMIT') ?: '20',
|
|
'diagnosis_ai_token_masked' => $this->maskKey($this->readSettingValue('DIAGNOSIS_AI_PARSE_TOKEN')),
|
|
'diagnosis_ai_token_exists' => $this->readSettingValue('DIAGNOSIS_AI_PARSE_TOKEN') !== '',
|
|
]);
|
|
}
|
|
|
|
public function save()
|
|
{
|
|
if (! $this->request->isAJAX()) {
|
|
return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
|
|
}
|
|
|
|
if (! $this->canManageAiSettings()) {
|
|
return $this->response->setStatusCode(403)->setJSON(['update' => 0, 'error_text' => 'Access denied']);
|
|
}
|
|
|
|
if (! $this->db->tableExists('hospital_setting')) {
|
|
return $this->response->setJSON(['update' => 0, 'error_text' => 'hospital_setting table not found']);
|
|
}
|
|
|
|
$fields = [
|
|
'DIAGNOSIS_AI_SERVER_URL' => trim((string) $this->request->getPost('diagnosis_ai_server_url')),
|
|
'DIAGNOSIS_AI_OCR_ENDPOINT' => trim((string) $this->request->getPost('diagnosis_ai_ocr_endpoint')),
|
|
'DIAGNOSIS_AI_PARSE_ENDPOINT' => trim((string) $this->request->getPost('diagnosis_ai_parse_endpoint')),
|
|
'DIAGNOSIS_AI_IMAGING_PROMPT' => trim((string) $this->request->getPost('diagnosis_ai_imaging_prompt')),
|
|
'DIAGNOSIS_AI_PARSE_TOKEN' => trim((string) $this->request->getPost('diagnosis_ai_parse_token')),
|
|
'DIAGNOSIS_AI_TIMEOUT_SECONDS' => trim((string) $this->request->getPost('diagnosis_ai_timeout_seconds')),
|
|
'DIAGNOSIS_AI_RETRY_ATTEMPTS' => trim((string) $this->request->getPost('diagnosis_ai_retry_attempts')),
|
|
'DIAGNOSIS_AI_DAILY_LIMIT' => trim((string) $this->request->getPost('diagnosis_ai_daily_limit')),
|
|
];
|
|
|
|
$savedCount = 0;
|
|
foreach ($fields as $name => $value) {
|
|
if ($value === '') {
|
|
continue;
|
|
}
|
|
|
|
if ($this->upsertSettingValue($name, $value)) {
|
|
$savedCount++;
|
|
}
|
|
}
|
|
|
|
if ($savedCount === 0) {
|
|
return $this->response->setJSON([
|
|
'update' => 0,
|
|
'error_text' => 'Provide at least one setting value to save',
|
|
'csrfName' => csrf_token(),
|
|
'csrfHash' => csrf_hash(),
|
|
]);
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'update' => 1,
|
|
'error_text' => 'Saved ' . $savedCount . ' setting(s)',
|
|
'csrfName' => csrf_token(),
|
|
'csrfHash' => csrf_hash(),
|
|
]);
|
|
}
|
|
|
|
public function test()
|
|
{
|
|
if (! $this->request->isAJAX()) {
|
|
return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
|
|
}
|
|
|
|
if (! $this->canManageAiSettings()) {
|
|
return $this->response->setStatusCode(403)->setJSON(['update' => 0, 'error_text' => 'Access denied']);
|
|
}
|
|
|
|
$provider = strtolower(trim((string) $this->request->getPost('provider')));
|
|
if ($provider === '') {
|
|
$provider = 'ai-server';
|
|
}
|
|
|
|
if ($provider === 'diagnosis-external') {
|
|
return $this->testDiagnosisExternalAi();
|
|
}
|
|
|
|
if ($provider === 'ai-server') {
|
|
return $this->testAiServer();
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'update' => 0,
|
|
'error_text' => 'Unsupported provider',
|
|
'csrfName' => csrf_token(),
|
|
'csrfHash' => csrf_hash(),
|
|
]);
|
|
}
|
|
|
|
public function usage()
|
|
{
|
|
if (! $this->request->isAJAX()) {
|
|
return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
|
|
}
|
|
|
|
if (! $this->canManageAiSettings()) {
|
|
return $this->response->setStatusCode(403)->setJSON(['update' => 0, 'error_text' => 'Access denied']);
|
|
}
|
|
|
|
if (! $this->db->tableExists('lab_ai_extraction_batches')) {
|
|
return $this->response->setJSON([
|
|
'update' => 1,
|
|
'error_text' => 'Usage table unavailable',
|
|
'usage' => [
|
|
'total_today' => 0,
|
|
'gemini_today' => 0,
|
|
'fallback_today' => 0,
|
|
'last_hour' => 0,
|
|
'daily_limit' => 20,
|
|
'level' => 'ok',
|
|
'ratio' => 0,
|
|
'providers' => [],
|
|
],
|
|
'csrfName' => csrf_token(),
|
|
'csrfHash' => csrf_hash(),
|
|
]);
|
|
}
|
|
|
|
$dailyLimit = (int) ($this->readSettingValue('DIAGNOSIS_AI_DAILY_LIMIT') ?: '20');
|
|
if ($dailyLimit <= 0) {
|
|
$dailyLimit = 20;
|
|
}
|
|
|
|
$start = date('Y-m-d 00:00:00');
|
|
$end = date('Y-m-d 23:59:59');
|
|
$hourStart = date('Y-m-d H:i:s', time() - 3600);
|
|
|
|
$table = $this->db->table('lab_ai_extraction_batches');
|
|
|
|
$totalToday = (int) $this->db->table('lab_ai_extraction_batches')
|
|
->where('created_at >=', $start)
|
|
->where('created_at <=', $end)
|
|
->countAllResults();
|
|
|
|
$geminiToday = (int) $this->db->table('lab_ai_extraction_batches')
|
|
->where('created_at >=', $start)
|
|
->where('created_at <=', $end)
|
|
->where("LOWER(ai_provider) = 'gemini'", null, false)
|
|
->countAllResults();
|
|
|
|
$fallbackToday = (int) $this->db->table('lab_ai_extraction_batches')
|
|
->where('created_at >=', $start)
|
|
->where('created_at <=', $end)
|
|
->where("LOWER(ai_provider) IN ('local-xray-fallback','regex-fallback')", null, false)
|
|
->countAllResults();
|
|
|
|
$lastHour = (int) $this->db->table('lab_ai_extraction_batches')
|
|
->where('created_at >=', $hourStart)
|
|
->where('created_at <=', $end)
|
|
->countAllResults();
|
|
|
|
$providerRows = $table
|
|
->select('ai_provider, COUNT(*) AS total', false)
|
|
->where('created_at >=', $start)
|
|
->where('created_at <=', $end)
|
|
->groupBy('ai_provider')
|
|
->orderBy('total', 'DESC')
|
|
->get()
|
|
->getResultArray();
|
|
|
|
$providers = [];
|
|
foreach ($providerRows as $row) {
|
|
$key = trim((string) ($row['ai_provider'] ?? ''));
|
|
if ($key === '') {
|
|
$key = 'unknown';
|
|
}
|
|
$providers[] = [
|
|
'provider' => $key,
|
|
'count' => (int) ($row['total'] ?? 0),
|
|
];
|
|
}
|
|
|
|
$ratio = (int) round(($geminiToday / max(1, $dailyLimit)) * 100);
|
|
$level = 'ok';
|
|
if ($ratio >= 100) {
|
|
$level = 'critical';
|
|
} elseif ($ratio >= 90) {
|
|
$level = 'danger';
|
|
} elseif ($ratio >= 70) {
|
|
$level = 'warn';
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'update' => 1,
|
|
'usage' => [
|
|
'total_today' => $totalToday,
|
|
'gemini_today' => $geminiToday,
|
|
'fallback_today' => $fallbackToday,
|
|
'last_hour' => $lastHour,
|
|
'daily_limit' => $dailyLimit,
|
|
'level' => $level,
|
|
'ratio' => $ratio,
|
|
'providers' => $providers,
|
|
],
|
|
'csrfName' => csrf_token(),
|
|
'csrfHash' => csrf_hash(),
|
|
]);
|
|
}
|
|
|
|
private function testDiagnosisExternalAi()
|
|
{
|
|
$endpoint = trim((string) $this->request->getPost('diagnosis_ai_parse_endpoint'));
|
|
$token = trim((string) $this->request->getPost('diagnosis_ai_parse_token'));
|
|
|
|
if ($endpoint === '') {
|
|
$endpoint = $this->readSettingValue('DIAGNOSIS_AI_PARSE_ENDPOINT');
|
|
}
|
|
if ($token === '') {
|
|
$token = $this->readSettingValue('DIAGNOSIS_AI_PARSE_TOKEN');
|
|
}
|
|
|
|
if ($endpoint === '') {
|
|
return $this->response->setJSON([
|
|
'update' => 0,
|
|
'error_text' => 'Diagnosis AI endpoint is required',
|
|
'csrfName' => csrf_token(),
|
|
'csrfHash' => csrf_hash(),
|
|
]);
|
|
}
|
|
|
|
try {
|
|
$headers = [
|
|
'Content-Type' => 'application/json',
|
|
'Accept' => 'application/json',
|
|
];
|
|
if ($token !== '') {
|
|
$headers['Authorization'] = 'Bearer ' . $token;
|
|
}
|
|
|
|
$client = service('curlrequest', $this->geminiHttpOptions());
|
|
$response = $client->post(rtrim($endpoint, '/'), [
|
|
'headers' => $headers,
|
|
'json' => [
|
|
'ocr_text' => 'Hemoglobin 13.5 g/dL; Total Bilirubin 1.2 mg/dL',
|
|
'panel_name' => 'LFT Test',
|
|
],
|
|
]);
|
|
|
|
$status = $response->getStatusCode();
|
|
$body = (string) $response->getBody();
|
|
$decoded = json_decode($body, true);
|
|
|
|
if ($status < 200 || $status >= 300) {
|
|
return $this->response->setJSON([
|
|
'update' => 0,
|
|
'error_text' => 'Diagnosis AI endpoint test failed (HTTP ' . $status . ')',
|
|
'detail' => mb_substr($body, 0, 300),
|
|
'csrfName' => csrf_token(),
|
|
'csrfHash' => csrf_hash(),
|
|
]);
|
|
}
|
|
|
|
$values = $decoded['values'] ?? null;
|
|
if (! is_array($values)) {
|
|
return $this->response->setJSON([
|
|
'update' => 0,
|
|
'error_text' => 'Diagnosis AI endpoint responded without values[]',
|
|
'detail' => mb_substr($body, 0, 300),
|
|
'csrfName' => csrf_token(),
|
|
'csrfHash' => csrf_hash(),
|
|
]);
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'update' => 1,
|
|
'error_text' => 'Diagnosis AI endpoint connection successful',
|
|
'csrfName' => csrf_token(),
|
|
'csrfHash' => csrf_hash(),
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
return $this->response->setJSON([
|
|
'update' => 0,
|
|
'error_text' => 'Diagnosis AI endpoint test failed: ' . $e->getMessage(),
|
|
'csrfName' => csrf_token(),
|
|
'csrfHash' => csrf_hash(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
private function testAiServer()
|
|
{
|
|
$baseUrl = trim((string) $this->request->getPost('diagnosis_ai_server_url'));
|
|
if ($baseUrl === '') {
|
|
$baseUrl = $this->readSettingValue('DIAGNOSIS_AI_SERVER_URL');
|
|
}
|
|
|
|
if ($baseUrl === '') {
|
|
return $this->response->setJSON([
|
|
'update' => 0,
|
|
'error_text' => 'AI Server URL is required',
|
|
'csrfName' => csrf_token(),
|
|
'csrfHash' => csrf_hash(),
|
|
]);
|
|
}
|
|
|
|
$token = trim((string) $this->request->getPost('diagnosis_ai_parse_token'));
|
|
if ($token === '') {
|
|
$token = $this->readSettingValue('DIAGNOSIS_AI_PARSE_TOKEN');
|
|
}
|
|
|
|
try {
|
|
$headers = ['Accept' => 'application/json'];
|
|
if ($token !== '') {
|
|
$headers['Authorization'] = 'Bearer ' . $token;
|
|
}
|
|
|
|
$client = service('curlrequest', $this->geminiHttpOptions());
|
|
$url = rtrim($baseUrl, '/') . '/translate?text=test&target_lang=en';
|
|
$response = $client->get($url, [
|
|
'headers' => $headers,
|
|
]);
|
|
|
|
$status = $response->getStatusCode();
|
|
$body = (string) $response->getBody();
|
|
|
|
if ($status < 200 || $status >= 300) {
|
|
return $this->response->setJSON([
|
|
'update' => 0,
|
|
'error_text' => 'AI Server test failed (HTTP ' . $status . ')',
|
|
'detail' => mb_substr($body, 0, 300),
|
|
'csrfName' => csrf_token(),
|
|
'csrfHash' => csrf_hash(),
|
|
]);
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'update' => 1,
|
|
'error_text' => 'AI Server connection successful',
|
|
'csrfName' => csrf_token(),
|
|
'csrfHash' => csrf_hash(),
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
return $this->response->setJSON([
|
|
'update' => 0,
|
|
'error_text' => 'AI Server test failed: ' . $e->getMessage(),
|
|
'csrfName' => csrf_token(),
|
|
'csrfHash' => csrf_hash(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function geminiHttpOptions(): array
|
|
{
|
|
$options = [
|
|
'timeout' => 20,
|
|
'http_errors' => false,
|
|
];
|
|
|
|
if (defined('ENVIRONMENT') && in_array((string) ENVIRONMENT, ['development', 'testing'], true)) {
|
|
$options['verify'] = false;
|
|
}
|
|
|
|
return $options;
|
|
}
|
|
|
|
private function canManageAiSettings(): bool
|
|
{
|
|
if (! function_exists('auth') || ! auth()->loggedIn()) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function defaultImagingPrompt(): string
|
|
{
|
|
return "Generate a radiology report in concise clinical style.\n"
|
|
. "Use this structure exactly:\n"
|
|
. "1) Findings Draft\n"
|
|
. "2) Technique\n"
|
|
. "3) Impression Draft\n\n"
|
|
. "Include checks for lungs/pleura, mediastinum/heart size, diaphragm/costophrenic angles, bones/soft tissue, and lines/tubes/devices if present.\n"
|
|
. "If a structure is normal, explicitly state it as normal.\n"
|
|
. "Do not mention AI. Keep output clinically readable for doctors.\n"
|
|
. "Study Name: {study_name}";
|
|
}
|
|
|
|
private function readSettingValue(string $name): string
|
|
{
|
|
if (defined($name)) {
|
|
$definedValue = trim((string) constant($name));
|
|
if ($definedValue !== '') {
|
|
return $definedValue;
|
|
}
|
|
}
|
|
|
|
if (! $this->db->tableExists('hospital_setting')) {
|
|
return '';
|
|
}
|
|
|
|
$row = $this->db->table('hospital_setting')
|
|
->select('s_value')
|
|
->where('s_name', $name)
|
|
->get(1)
|
|
->getRowArray();
|
|
|
|
$value = trim((string) ($row['s_value'] ?? ''));
|
|
if ($value !== '') {
|
|
return $value;
|
|
}
|
|
|
|
$rows = $this->db->table('hospital_setting')
|
|
->select('s_name, s_value')
|
|
->get()
|
|
->getResultArray();
|
|
|
|
$target = strtoupper(trim($name));
|
|
foreach ($rows as $item) {
|
|
$itemName = strtoupper(trim((string) ($item['s_name'] ?? '')));
|
|
if ($itemName === $target) {
|
|
$itemValue = trim((string) ($item['s_value'] ?? ''));
|
|
if ($itemValue !== '') {
|
|
return $itemValue;
|
|
}
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
private function upsertSettingValue(string $name, string $value): bool
|
|
{
|
|
$existing = $this->db->table('hospital_setting')
|
|
->select('id, s_value')
|
|
->where('s_name', $name)
|
|
->get(1)
|
|
->getRowArray();
|
|
|
|
$oldValue = trim((string) ($existing['s_value'] ?? ''));
|
|
if ($existing) {
|
|
$ok = $this->db->table('hospital_setting')
|
|
->where('id', (int) ($existing['id'] ?? 0))
|
|
->update(['s_value' => $value]);
|
|
} else {
|
|
$ok = $this->db->table('hospital_setting')->insert([
|
|
's_name' => $name,
|
|
's_value' => $value,
|
|
]);
|
|
}
|
|
|
|
if ($ok) {
|
|
$this->auditClinicalUpdate('hospital_setting', $name, $name, $oldValue, $value);
|
|
}
|
|
|
|
return (bool) $ok;
|
|
}
|
|
|
|
private function maskKey(string $key): string
|
|
{
|
|
$key = trim($key);
|
|
if ($key === '') {
|
|
return '';
|
|
}
|
|
|
|
if (mb_strlen($key) <= 8) {
|
|
return str_repeat('*', mb_strlen($key));
|
|
}
|
|
|
|
return mb_substr($key, 0, 4) . str_repeat('*', mb_strlen($key) - 8) . mb_substr($key, -4);
|
|
}
|
|
}
|