feat(imaging-ai): add DICOM preview and AI diagnosis support

This commit is contained in:
DevSoftTech Server 2026-03-13 05:22:50 +05:30
parent 01156a1ee2
commit 68dfbaf006
7 changed files with 961 additions and 457 deletions

View File

@ -645,6 +645,9 @@ $routes->post('diagnosis/final-update/(:num)', 'Diagnosis::finalUpdate/$1');
$routes->post('diagnosis/final-update-xray/(:num)', 'Diagnosis::finalUpdateXray/$1'); $routes->post('diagnosis/final-update-xray/(:num)', 'Diagnosis::finalUpdateXray/$1');
$routes->post('diagnosis/confirm-report/(:num)', 'Diagnosis::confirmReport/$1'); $routes->post('diagnosis/confirm-report/(:num)', 'Diagnosis::confirmReport/$1');
$routes->post('diagnosis/confirm-report-xray/(:num)', 'Diagnosis::confirmReportXray/$1'); $routes->post('diagnosis/confirm-report-xray/(:num)', 'Diagnosis::confirmReportXray/$1');
// Editor JS compatibility aliases
$routes->post('diagnosis/update-report/(:num)', 'Diagnosis::finalUpdateXray/$1');
$routes->post('diagnosis/report-verify/(:num)', 'Diagnosis::confirmReportXray/$1');
$routes->match(['get', 'post'], 'diagnosis/get-template-xray/(:num)', 'Diagnosis::getTemplateXray/$1'); $routes->match(['get', 'post'], 'diagnosis/get-template-xray/(:num)', 'Diagnosis::getTemplateXray/$1');
$routes->post('diagnosis/remove-test/(:num)', 'Diagnosis::removeTest/$1'); $routes->post('diagnosis/remove-test/(:num)', 'Diagnosis::removeTest/$1');
$routes->post('diagnosis/update-combine-report', 'Diagnosis::updateCombineReport'); $routes->post('diagnosis/update-combine-report', 'Diagnosis::updateCombineReport');
@ -653,6 +656,7 @@ $routes->post('diagnosis/upload-report-file', 'Diagnosis::uploadReportFile');
$routes->get('diagnosis/report-file-list/(:num)/(:num)', 'Diagnosis::reportFileList/$1/$2'); $routes->get('diagnosis/report-file-list/(:num)/(:num)', 'Diagnosis::reportFileList/$1/$2');
$routes->get('diagnosis/report-file-list/(:num)/(:num)/(:num)', 'Diagnosis::reportFileList/$1/$2/$3'); $routes->get('diagnosis/report-file-list/(:num)/(:num)/(:num)', 'Diagnosis::reportFileList/$1/$2/$3');
$routes->get('diagnosis/imaging-upload-gallery/(:num)/(:num)/(:num)', 'Diagnosis::imagingUploadGallery/$1/$2/$3'); $routes->get('diagnosis/imaging-upload-gallery/(:num)/(:num)/(:num)', 'Diagnosis::imagingUploadGallery/$1/$2/$3');
$routes->get('diagnosis/imaging-dicom-preview/(:num)', 'Diagnosis::imagingDicomPreview/$1');
$routes->post('diagnosis/imaging-ai-diagnosis/(:num)', 'Diagnosis::imagingAiDiagnosis/$1'); $routes->post('diagnosis/imaging-ai-diagnosis/(:num)', 'Diagnosis::imagingAiDiagnosis/$1');
$routes->get('diagnosis/select-lab-radiology/(:num)/(:num)', 'Diagnosis::selectLabRadiology/$1/$2'); $routes->get('diagnosis/select-lab-radiology/(:num)/(:num)', 'Diagnosis::selectLabRadiology/$1/$2');
$routes->get('diagnosis/print-pdf-create/(:num)/(:num)/(:num)', 'Diagnosis::printPdfCreate/$1/$2/$3'); $routes->get('diagnosis/print-pdf-create/(:num)/(:num)/(:num)', 'Diagnosis::printPdfCreate/$1/$2/$3');
@ -746,6 +750,7 @@ $routes->group('setting', static function($routes) {
$routes->get('admin/ai-settings', 'Setting\\AiSettings::index'); $routes->get('admin/ai-settings', 'Setting\\AiSettings::index');
$routes->post('admin/ai-settings/save', 'Setting\\AiSettings::save'); $routes->post('admin/ai-settings/save', 'Setting\\AiSettings::save');
$routes->post('admin/ai-settings/test', 'Setting\\AiSettings::test'); $routes->post('admin/ai-settings/test', 'Setting\\AiSettings::test');
$routes->post('admin/ai-settings/usage', 'Setting\\AiSettings::usage');
$routes->get('admin/doctor', 'Setting\\Doctor::index'); $routes->get('admin/doctor', 'Setting\\Doctor::index');
$routes->get('admin/doctor/new', 'Setting\\Doctor::create'); $routes->get('admin/doctor/new', 'Setting\\Doctor::create');
$routes->post('admin/doctor/new', 'Setting\\Doctor::store'); $routes->post('admin/doctor/new', 'Setting\\Doctor::store');

View File

@ -1548,6 +1548,7 @@ class Diagnosis extends BaseController
$ext = strtolower(pathinfo((string) ($row['file_name'] ?? ''), PATHINFO_EXTENSION)); $ext = strtolower(pathinfo((string) ($row['file_name'] ?? ''), PATHINFO_EXTENSION));
$isImage = (int) ($row['is_image'] ?? 0) === 1 || str_starts_with($mime, 'image/'); $isImage = (int) ($row['is_image'] ?? 0) === 1 || str_starts_with($mime, 'image/');
$isPdf = $ext === 'pdf' || $mime === 'application/pdf'; $isPdf = $ext === 'pdf' || $mime === 'application/pdf';
$isDicom = $this->isDicomMimeOrExtension($mime, $ext);
$files[] = [ $files[] = [
'id' => (int) ($row['id'] ?? 0), 'id' => (int) ($row['id'] ?? 0),
@ -1556,6 +1557,8 @@ class Diagnosis extends BaseController
'url' => $url, 'url' => $url,
'is_image' => $isImage, 'is_image' => $isImage,
'is_pdf' => $isPdf, 'is_pdf' => $isPdf,
'is_dicom' => $isDicom,
'dicom_preview_url' => $isDicom ? base_url('diagnosis/imaging-dicom-preview/' . (int) ($row['id'] ?? 0)) : '',
'insert_time' => (string) ($row['insert_time'] ?? $row['insert_date'] ?? ''), 'insert_time' => (string) ($row['insert_time'] ?? $row['insert_date'] ?? ''),
'ai_status' => (string) ($row['ai_status'] ?? ''), 'ai_status' => (string) ($row['ai_status'] ?? ''),
'ai_alert_text' => (string) ($row['ai_alert_text'] ?? ''), 'ai_alert_text' => (string) ($row['ai_alert_text'] ?? ''),
@ -1569,6 +1572,50 @@ class Diagnosis extends BaseController
]); ]);
} }
public function imagingDicomPreview($fileId)
{
$fileId = (int) $fileId;
if ($fileId <= 0 || ! $this->db->tableExists('file_upload_data')) {
return $this->response->setStatusCode(404)->setBody('DICOM file not found');
}
$fileRow = $this->db->table('file_upload_data')
->where('id', $fileId)
->where('isdelete', 0)
->get(1)
->getRowArray();
if (empty($fileRow)) {
return $this->response->setStatusCode(404)->setBody('DICOM file not found');
}
$absolutePath = $this->resolveUploadAbsolutePath((string) ($fileRow['full_path'] ?? ''));
if ($absolutePath === '' || ! is_file($absolutePath)) {
return $this->response->setStatusCode(404)->setBody('DICOM file missing on server');
}
$mime = $this->detectMimeType($absolutePath);
$ext = strtolower(pathinfo($absolutePath, PATHINFO_EXTENSION));
if (! $this->isDicomMimeOrExtension($mime, $ext)) {
return $this->response->setStatusCode(415)->setBody('File is not a DICOM');
}
$preview = $this->callAiServerDicomPreview($absolutePath, $mime);
if (($preview['error'] ?? '') !== '') {
return $this->response->setStatusCode(422)->setBody((string) $preview['error']);
}
$bytes = (string) ($preview['bytes'] ?? '');
if ($bytes === '') {
return $this->response->setStatusCode(422)->setBody('Unable to render DICOM preview');
}
return $this->response
->setHeader('Content-Type', (string) ($preview['mime'] ?? 'image/jpeg'))
->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate')
->setBody($bytes);
}
public function uploadReportFile() public function uploadReportFile()
{ {
if (! $this->request->isAJAX()) { if (! $this->request->isAJAX()) {
@ -1595,9 +1642,9 @@ class Diagnosis extends BaseController
} }
$ext = strtolower((string) $upload->getExtension()); $ext = strtolower((string) $upload->getExtension());
$allowed = ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'pdf']; $allowed = ['jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'pdf', 'dcm', 'dicom'];
if (! in_array($ext, $allowed, true)) { if (! in_array($ext, $allowed, true)) {
return $this->response->setJSON(['update' => 0, 'error_text' => 'Unsupported file type']); return $this->response->setJSON(['update' => 0, 'error_text' => 'Unsupported file type. Allowed: image, PDF, DICOM (.dcm)']);
} }
$targetDir = ROOTPATH . 'public' . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . 'diagnosis' . DIRECTORY_SEPARATOR . date('Y') . DIRECTORY_SEPARATOR . date('m'); $targetDir = ROOTPATH . 'public' . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . 'diagnosis' . DIRECTORY_SEPARATOR . date('Y') . DIRECTORY_SEPARATOR . date('m');
@ -1719,7 +1766,7 @@ class Diagnosis extends BaseController
$ocrText = $this->extractTextFromLabFile($absolutePath); $ocrText = $this->extractTextFromLabFile($absolutePath);
if ($ocrText === '') { if ($ocrText === '') {
return $this->response->setJSON(['update' => 0, 'error_text' => 'OCR failed. Configure AZURE_DOCINTEL_ENDPOINT/KEY or use readable image.']); return $this->response->setJSON(['update' => 0, 'error_text' => 'OCR failed. Configure DIAGNOSIS_AI_OCR_ENDPOINT on your AI server.']);
} }
$parsed = $this->parseLabValuesWithAi($ocrText, $panelName); $parsed = $this->parseLabValuesWithAi($ocrText, $panelName);
@ -1741,7 +1788,8 @@ class Diagnosis extends BaseController
$panelName, $panelName,
$modelName, $modelName,
$ocrText, $ocrText,
$rawResponse $rawResponse,
(string) ($parsed['provider'] ?? 'ai-server')
); );
if ($batchId <= 0) { if ($batchId <= 0) {
@ -1786,7 +1834,7 @@ class Diagnosis extends BaseController
return $this->response->setJSON([ return $this->response->setJSON([
'update' => 1, 'update' => 1,
'error_text' => 'AI extracted ' . $storedCount . ' values. Doctor verification pending.', 'error_text' => 'AI extracted ' . $storedCount . ' values. Doctor verification pending.',
'ai_provider' => (string) ($parsed['provider'] ?? 'azure-openai'), 'ai_provider' => (string) ($parsed['provider'] ?? 'ai-server'),
'ai_model' => $modelName, 'ai_model' => $modelName,
'batch_id' => $batchId, 'batch_id' => $batchId,
'stored_values' => $storedCount, 'stored_values' => $storedCount,
@ -1933,23 +1981,56 @@ class Diagnosis extends BaseController
$ocrChunks[] = 'FILE: ' . $fileName . "\n" . $ocrText; $ocrChunks[] = 'FILE: ' . $fileName . "\n" . $ocrText;
} }
if (str_starts_with($mime, 'image/') && count($visionImages) < 4) { $ext = strtolower(pathinfo($absolutePath, PATHINFO_EXTENSION));
$imagePayload = $this->buildVisionImagePayload($absolutePath, $mime); if ((str_starts_with($mime, 'image/') || $this->isDicomMimeOrExtension($mime, $ext)) && count($visionImages) < 4) {
if ($imagePayload !== '') { $visionImages[] = [
$visionImages[] = [ 'abs_path' => $absolutePath,
'type' => 'image_url', 'mime' => $mime,
'image_url' => ['url' => $imagePayload], ];
]; }
}
$patientName = trim((string) ($labRequest['patient_name'] ?? 'Unknown'));
$patientAge = 0;
$patientGender = 'Unknown';
$patientId = (int) ($labRequest['patient_id'] ?? 0);
if ($patientId > 0 && $this->db->tableExists('patient_master')) {
$patient = $this->db->table('patient_master')
->select('gender, age, age_in_month, dob')
->where('id', $patientId)
->get(1)
->getRowArray();
$genderRaw = (int) ($patient['gender'] ?? 0);
$patientGender = $genderRaw === 1 ? 'Male' : ($genderRaw === 2 ? 'Female' : 'Unknown');
$patientAge = (int) ($patient['age'] ?? 0);
if ($patientAge <= 0) {
$months = (int) ($patient['age_in_month'] ?? 0);
if ($months > 0) {
$patientAge = max(1, (int) floor($months / 12));
}
}
if ($patientAge <= 0) {
$dob = trim((string) ($patient['dob'] ?? ''));
if ($dob !== '' && $dob !== '0000-00-00') {
try {
$patientAge = (int) ((new \DateTime($dob))->diff(new \DateTime('today'))->y ?? 0);
} catch (\Throwable $e) {
$patientAge = 0;
}
} }
} }
} }
$analysis = $this->generateImagingDiagnosisDraft($studyName, $ocrChunks, $visionImages); $analysis = $this->generateImagingDiagnosisDraft($studyName, $ocrChunks, $visionImages, $patientName, $patientAge, $patientGender);
if (($analysis['error'] ?? '') !== '') { if (($analysis['error'] ?? '') !== '') {
$this->updateImagingFileAiState($fileIds, 'failed', (string) $analysis['error'], 1); $this->updateImagingFileAiState($fileIds, 'failed', (string) $analysis['error'], 1);
return $this->response->setJSON(['update' => 0, 'error_text' => (string) $analysis['error']]); return $this->response->setJSON(['update' => 0, 'error_text' => (string) $analysis['error']]);
} }
$promptUsed = $this->buildImagingDiagnosisPrompt($studyName);
$rawPayload = [ $rawPayload = [
'type' => 'imaging-ai-diagnosis', 'type' => 'imaging-ai-diagnosis',
'request_id' => $labReqId, 'request_id' => $labReqId,
@ -1960,8 +2041,9 @@ class Diagnosis extends BaseController
'findings_html' => (string) ($analysis['findings_html'] ?? ''), 'findings_html' => (string) ($analysis['findings_html'] ?? ''),
'impression_html' => (string) ($analysis['impression_html'] ?? ''), 'impression_html' => (string) ($analysis['impression_html'] ?? ''),
'summary_text' => (string) ($analysis['summary_text'] ?? ''), 'summary_text' => (string) ($analysis['summary_text'] ?? ''),
'provider' => (string) ($analysis['provider'] ?? 'azure-openai'), 'provider' => (string) ($analysis['provider'] ?? 'ai-server'),
'model' => (string) ($analysis['model'] ?? ''), 'model' => (string) ($analysis['model'] ?? ''),
'prompt_used' => $promptUsed,
'source_files' => $sourceFiles, 'source_files' => $sourceFiles,
'raw' => $analysis['raw'] ?? [], 'raw' => $analysis['raw'] ?? [],
]; ];
@ -1974,7 +2056,8 @@ class Diagnosis extends BaseController
$studyName, $studyName,
(string) ($analysis['model'] ?? ''), (string) ($analysis['model'] ?? ''),
$ocrText, $ocrText,
(string) json_encode($rawPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) (string) json_encode($rawPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
(string) ($analysis['provider'] ?? 'ai-server')
); );
if ($batchId <= 0) { if ($batchId <= 0) {
@ -1999,7 +2082,7 @@ class Diagnosis extends BaseController
'findings_html' => (string) ($analysis['findings_html'] ?? ''), 'findings_html' => (string) ($analysis['findings_html'] ?? ''),
'impression_html' => (string) ($analysis['impression_html'] ?? ''), 'impression_html' => (string) ($analysis['impression_html'] ?? ''),
'summary_text' => (string) ($analysis['summary_text'] ?? ''), 'summary_text' => (string) ($analysis['summary_text'] ?? ''),
'provider' => (string) ($analysis['provider'] ?? 'azure-openai'), 'provider' => (string) ($analysis['provider'] ?? 'ai-server'),
'model' => (string) ($analysis['model'] ?? ''), 'model' => (string) ($analysis['model'] ?? ''),
]); ]);
@ -2023,14 +2106,15 @@ class Diagnosis extends BaseController
string $panelName, string $panelName,
string $modelName, string $modelName,
string $ocrText, string $ocrText,
string $rawResponse string $rawResponse,
string $provider = 'ai-server'
): int { ): int {
$ok = $this->db->table('lab_ai_extraction_batches')->insert([ $ok = $this->db->table('lab_ai_extraction_batches')->insert([
'invoice_id' => $invoiceId, 'invoice_id' => $invoiceId,
'lab_type' => $labType, 'lab_type' => $labType,
'file_upload_id' => $fileId, 'file_upload_id' => $fileId,
'panel_name' => $panelName, 'panel_name' => $panelName,
'ai_provider' => 'azure-openai', 'ai_provider' => $provider,
'model_name' => $modelName, 'model_name' => $modelName,
'ocr_text' => $ocrText, 'ocr_text' => $ocrText,
'raw_response_json' => $rawResponse, 'raw_response_json' => $rawResponse,
@ -2161,129 +2245,210 @@ class Diagnosis extends BaseController
* @param array<int, array<string, mixed>> $visionImages * @param array<int, array<string, mixed>> $visionImages
* @return array<string, mixed> * @return array<string, mixed>
*/ */
private function generateImagingDiagnosisDraft(string $studyName, array $ocrChunks, array $visionImages): array private function generateImagingDiagnosisDraft(string $studyName, array $ocrChunks, array $visionImages, string $patientName = 'Unknown', int $patientAge = 0, string $patientGender = 'Unknown'): array
{ {
$endpoint = rtrim($this->readSetting('AZURE_OPENAI_ENDPOINT'), '/'); if ($visionImages === []) {
$apiKey = trim($this->readSetting('AZURE_OPENAI_API_KEY'));
$deployment = trim($this->readSetting('AZURE_OPENAI_DEPLOYMENT'));
$apiVersion = trim($this->readSetting('AZURE_OPENAI_API_VERSION'));
if ($endpoint === '' || $apiKey === '' || $deployment === '') {
return [ return [
'error' => 'Azure OpenAI settings are missing for imaging AI diagnosis.', 'error' => 'No image available for AI diagnosis.',
]; ];
} }
if ($apiVersion === '') { $firstImagePath = (string) ($visionImages[0]['abs_path'] ?? '');
$apiVersion = '2024-10-21'; $firstImageMime = (string) ($visionImages[0]['mime'] ?? 'image/jpeg');
} if ($firstImagePath === '' || ! is_file($firstImagePath)) {
if ($visionImages === [] && $ocrChunks === []) {
return [ return [
'error' => 'No image or OCR context available for AI diagnosis.', 'error' => 'Image file not found on server for AI diagnosis.',
]; ];
} }
$content = [[ $response = $this->callAiServerDiagnosis($studyName, $firstImagePath, $firstImageMime, $patientName, $patientAge, $patientGender);
'type' => 'text', if (($response['error'] ?? '') !== '') {
'text' => "You are assisting a radiologist in drafting an imaging report for an Indian hospital.\n" . return $response;
"Study name: {$studyName}\n" .
"Review the uploaded imaging files and any OCR text.\n" .
"Return strict JSON only with keys: title, findings_html, impression_html, summary_text.\n" .
"Rules:\n" .
"1. findings_html must be concise HTML paragraphs or bullet list suitable for the report editor.\n" .
"2. impression_html must be concise HTML suitable for the Impression field.\n" .
"3. summary_text must be plain text under 220 characters.\n" .
"4. Do not include markdown fences.\n" .
"5. If image quality is poor, say that clearly in findings_html/impression_html.\n\n" .
($ocrChunks !== [] ? ("OCR_CONTEXT:\n" . mb_substr(implode("\n\n", $ocrChunks), 0, 14000)) : 'OCR_CONTEXT: none'),
]];
foreach ($visionImages as $imagePart) {
$content[] = $imagePart;
} }
$url = $endpoint . '/openai/deployments/' . rawurlencode($deployment) . '/chat/completions?api-version=' . rawurlencode($apiVersion); $report = $response['report'] ?? [];
$attempts = $this->aiMaxRetries(); $technique = $this->normalizeAiTextValue($report['Technique'] ?? $studyName, $studyName);
$lastError = 'AI diagnosis generation failed.'; $impression = $this->normalizeAiTextValue($report['Impression'] ?? 'AI diagnosis draft prepared.', 'AI diagnosis draft prepared.');
for ($attempt = 1; $attempt <= $attempts; $attempt++) { $findings = $report['Findings'] ?? [];
try { $narrative = '';
$client = service('curlrequest', $this->httpOptions()); $labels = [];
$response = $client->post($url, [ $ocrText = [];
'headers' => [ if (is_array($findings)) {
'api-key' => $apiKey, $narrative = $this->normalizeAiTextValue($findings['Narrative'] ?? '', '');
'Content-Type' => 'application/json', $labels = $this->normalizeAiListValue($findings['Labels'] ?? []);
], $ocrText = $this->normalizeAiListValue($findings['OCR Text'] ?? []);
'json' => [ } else {
'messages' => [[ $narrative = $this->normalizeAiTextValue($findings, '');
'role' => 'user', }
'content' => $content,
]],
'temperature' => 0.2,
'response_format' => ['type' => 'json_object'],
'max_tokens' => 2200,
],
]);
if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) { $labelHtml = '';
$lastError = 'Azure OpenAI HTTP ' . $response->getStatusCode(); if ($labels !== []) {
continue; $safeLabels = array_slice(array_map(static fn($x) => trim((string) $x), $labels), 0, 8);
} $safeLabels = array_values(array_filter($safeLabels, static fn($x) => $x !== ''));
if ($safeLabels !== []) {
$decoded = json_decode((string) $response->getBody(), true); $labelHtml = '<p><strong>Detected labels:</strong> ' . esc(implode(', ', $safeLabels)) . '</p>';
$contentText = trim((string) ($decoded['choices'][0]['message']['content'] ?? ''));
if ($contentText === '') {
$lastError = 'Azure OpenAI returned empty content.';
continue;
}
$json = json_decode($contentText, true);
if (! is_array($json)) {
$lastError = 'Azure OpenAI returned invalid JSON content.';
continue;
}
$findingsHtml = trim((string) ($json['findings_html'] ?? ''));
$impressionHtml = trim((string) ($json['impression_html'] ?? ''));
if ($findingsHtml === '' && $impressionHtml === '') {
$lastError = 'AI returned empty report sections.';
continue;
}
return [
'provider' => 'azure-openai',
'model' => (string) ($decoded['model'] ?? $deployment),
'title' => trim((string) ($json['title'] ?? $studyName)),
'findings_html' => $findingsHtml,
'impression_html' => $impressionHtml,
'summary_text' => trim((string) ($json['summary_text'] ?? 'AI diagnosis draft prepared.')),
'raw' => $decoded,
'error' => '',
];
} catch (\Throwable $e) {
$lastError = $e->getMessage() !== '' ? $e->getMessage() : 'Azure OpenAI request timeout/error.';
} }
} }
$ocrHtml = '';
if ($ocrText !== []) {
$ocrFirst = trim((string) ($ocrText[0] ?? ''));
if ($ocrFirst !== '') {
$ocrHtml = '<p><strong>OCR notes:</strong> ' . nl2br(esc(mb_substr($ocrFirst, 0, 1500))) . '</p>';
}
}
$findingsHtml = '<p><strong>Technique:</strong> ' . esc($technique) . '</p>';
if ($narrative !== '') {
$paragraphs = preg_split('/\R{2,}|\n/u', $narrative) ?: [];
$paragraphs = array_values(array_filter(array_map(static fn($item) => trim((string) $item), $paragraphs), static fn($item) => $item !== ''));
if ($paragraphs === []) {
$findingsHtml .= '<p>' . nl2br(esc($narrative)) . '</p>';
} else {
foreach ($paragraphs as $paragraph) {
$findingsHtml .= '<p>' . esc($paragraph) . '</p>';
}
}
} else {
$findingsHtml .= $labelHtml . $ocrHtml;
}
return [ return [
'error' => $lastError, 'provider' => (string) ($response['provider'] ?? 'ai-server'),
'model' => (string) ($response['model'] ?? 'ai-server-diagnosis'),
'title' => trim((string) ($studyName !== '' ? $studyName : ($technique !== '' ? $technique : 'Imaging Study'))),
'findings_html' => $findingsHtml,
'impression_html' => '<p>' . esc($impression) . '</p>',
'summary_text' => mb_substr($impression !== '' ? $impression : 'AI diagnosis draft prepared.', 0, 220),
'raw' => $response['raw'] ?? [],
'error' => '',
]; ];
} }
/**
* @param mixed $value
*/
private function normalizeAiTextValue($value, string $fallback = ''): string
{
if (is_string($value)) {
$text = trim($value);
return $text !== '' ? $text : trim($fallback);
}
if (is_scalar($value)) {
$text = trim((string) $value);
return $text !== '' ? $text : trim($fallback);
}
if (is_array($value)) {
$chunks = [];
array_walk_recursive($value, static function ($item) use (&$chunks): void {
if (is_scalar($item)) {
$text = trim((string) $item);
if ($text !== '') {
$chunks[] = $text;
}
}
});
if ($chunks !== []) {
return trim(implode(' ', array_slice($chunks, 0, 8)));
}
}
return trim($fallback);
}
/**
* @param mixed $value
* @return array<int, string>
*/
private function normalizeAiListValue($value): array
{
if (is_string($value)) {
$text = trim($value);
return $text !== '' ? [$text] : [];
}
if (is_scalar($value)) {
$text = trim((string) $value);
return $text !== '' ? [$text] : [];
}
if (! is_array($value)) {
return [];
}
$items = [];
array_walk_recursive($value, static function ($item) use (&$items): void {
if (is_scalar($item)) {
$text = trim((string) $item);
if ($text !== '') {
$items[] = $text;
}
}
});
return array_slice($items, 0, 20);
}
private function extractTextFromLabFile(string $absolutePath): string private function extractTextFromLabFile(string $absolutePath): string
{ {
$docEndpoint = rtrim($this->readSetting('AZURE_DOCINTEL_ENDPOINT'), '/'); return $this->extractTextWithAiServerOcr($absolutePath);
$docKey = trim($this->readSetting('AZURE_DOCINTEL_KEY')); }
if ($docEndpoint !== '' && $docKey !== '') { private function extractTextWithAiServerOcr(string $absolutePath): string
$text = $this->extractTextWithDocumentIntelligence($absolutePath, $docEndpoint, $docKey); {
if ($text !== '') { $endpoint = rtrim($this->readSetting('DIAGNOSIS_AI_OCR_ENDPOINT'), '/');
return $text; if ($endpoint === '') {
} return '';
} }
return $this->extractTextWithAzureVisionLlm($absolutePath); $mime = $this->detectMimeType($absolutePath);
if (! str_starts_with($mime, 'image/')) {
return '';
}
try {
$client = service('curlrequest', $this->httpOptions());
$headers = [
'Accept' => 'application/json',
];
$token = trim($this->readSetting('DIAGNOSIS_AI_PARSE_TOKEN'));
if ($token !== '') {
$headers['Authorization'] = 'Bearer ' . $token;
}
$curlFile = new \CURLFile($absolutePath, $mime, basename($absolutePath));
$response = $client->post($endpoint, [
'headers' => $headers,
'multipart' => [
'file' => $curlFile,
],
]);
if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) {
return '';
}
$decoded = json_decode((string) $response->getBody(), true);
if (! is_array($decoded)) {
return '';
}
foreach (['ocr_text', 'text', 'extracted_text'] as $key) {
$value = trim((string) ($decoded[$key] ?? ''));
if ($value !== '') {
return $value;
}
}
} catch (\Throwable $e) {
return '';
}
return '';
} }
private function extractTextWithDocumentIntelligence(string $absolutePath, string $endpoint, string $key): string private function extractTextWithDocumentIntelligence(string $absolutePath, string $endpoint, string $key): string
@ -2425,23 +2590,158 @@ class Diagnosis extends BaseController
if (! empty($external['values']) && is_array($external['values'])) { if (! empty($external['values']) && is_array($external['values'])) {
return $external; return $external;
} }
$azure = $this->parseLabValuesWithAzureOpenAi($ocrText, $panelName);
if (! empty($azure['values']) && is_array($azure['values'])) {
return $azure;
}
$externalError = trim((string) ($external['error'] ?? '')); $externalError = trim((string) ($external['error'] ?? ''));
$azureError = trim((string) ($azure['error'] ?? ''));
return [ return [
'provider' => 'none', 'provider' => 'none',
'model' => '', 'model' => '',
'values' => [], 'values' => [],
'raw' => [], 'raw' => [],
'error' => $externalError !== '' ? $externalError : $azureError, 'error' => $externalError !== '' ? $externalError : 'AI parse failed. Configure DIAGNOSIS_AI_PARSE_ENDPOINT on your AI server.',
]; ];
} }
/**
* @return array<string, mixed>
*/
private function callAiServerDiagnosis(string $studyName, string $filePath, string $mime = 'image/jpeg', string $patientName = 'Unknown', int $patientAge = 0, string $patientGender = 'Unknown'): array
{
$base = $this->resolveAiServerBaseUrl();
if ($base === '') {
return [
'error' => 'AI server URL is missing. Configure DIAGNOSIS_AI_SERVER_URL or set parse/OCR endpoint in AI Settings.',
];
}
if (! is_file($filePath)) {
return [
'error' => 'Image file not found: ' . basename($filePath),
];
}
$isDicom = $this->isDicomMimeOrExtension($mime, strtolower(pathinfo($filePath, PATHINFO_EXTENSION)));
if ($isDicom) {
$ext = 'dcm';
} else {
$ext = str_contains($mime, 'png') ? 'png' : (str_contains($mime, 'webp') ? 'webp' : 'jpg');
}
$token = trim($this->readSetting('DIAGNOSIS_AI_PARSE_TOKEN'));
$headers = ['Accept' => 'application/json'];
if ($token !== '') {
$headers['Authorization'] = 'Bearer ' . $token;
}
$url = $base . '/diagnosis';
$prompt = $this->buildImagingDiagnosisPrompt($studyName);
$attempts = $this->aiMaxRetries();
$lastError = 'AI server diagnosis request failed.';
for ($attempt = 1; $attempt <= $attempts; $attempt++) {
try {
$client = service('curlrequest', $this->httpOptions());
$uploadMime = $isDicom ? 'application/dicom' : $mime;
$curlFile = new \CURLFile($filePath, $uploadMime, 'study.' . $ext);
$response = $client->post($url, [
'headers' => $headers,
'multipart' => [
'file' => $curlFile,
'patient_name' => $patientName !== '' ? $patientName : 'Unknown',
'patient_age' => (string) max(0, $patientAge),
'patient_gender' => $patientGender !== '' ? $patientGender : 'Unknown',
'clinical_indication' => $studyName !== '' ? $studyName : 'Imaging study',
'report_prompt' => $prompt,
],
]);
$status = $response->getStatusCode();
$body = (string) $response->getBody();
if ($status < 200 || $status >= 300) {
$lastError = 'AI server HTTP ' . $status . ': ' . mb_substr($body, 0, 180);
continue;
}
$decoded = json_decode($body, true);
if (! is_array($decoded)) {
$lastError = 'AI server returned non-JSON response.';
continue;
}
$report = $decoded['diagnosis_report'] ?? null;
if (! is_array($report)) {
$lastError = trim((string) ($decoded['detail'] ?? 'AI server response missing diagnosis_report.'));
continue;
}
return [
'provider' => 'ai-server',
'model' => 'python-fastapi',
'report' => $report,
'raw' => $decoded,
'error' => '',
];
} catch (\Throwable $e) {
$lastError = $e->getMessage() !== '' ? $e->getMessage() : 'AI server request timeout/error.';
}
}
return [
'error' => $lastError,
];
}
private function buildImagingDiagnosisPrompt(string $studyName): string
{
$template = trim($this->readSetting('DIAGNOSIS_AI_IMAGING_PROMPT'));
if ($template === '') {
$template = "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}";
}
$name = trim($studyName) !== '' ? trim($studyName) : 'Imaging study';
$prompt = str_replace(['{study_name}', '{{study_name}}'], $name, $template);
return trim($prompt);
}
private function resolveAiServerBaseUrl(): string
{
$base = rtrim($this->readSetting('DIAGNOSIS_AI_SERVER_URL'), '/');
if ($base !== '') {
return $base;
}
$candidateUrls = [
trim((string) $this->readSetting('DIAGNOSIS_AI_PARSE_ENDPOINT')),
trim((string) $this->readSetting('DIAGNOSIS_AI_OCR_ENDPOINT')),
];
foreach ($candidateUrls as $url) {
if ($url === '') {
continue;
}
$parts = @parse_url($url);
if (! is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
continue;
}
$base = (string) $parts['scheme'] . '://' . (string) $parts['host'];
if (! empty($parts['port'])) {
$base .= ':' . (int) $parts['port'];
}
return rtrim($base, '/');
}
return '';
}
/** /**
* @return array<string, mixed> * @return array<string, mixed>
*/ */
@ -2696,13 +2996,101 @@ class Diagnosis extends BaseController
'bmp' => 'image/bmp', 'bmp' => 'image/bmp',
'gif' => 'image/gif', 'gif' => 'image/gif',
'pdf' => 'application/pdf', 'pdf' => 'application/pdf',
'dcm' => 'application/dicom',
'dicom' => 'application/dicom',
]; ];
$mime = $map[$ext] ?? 'application/octet-stream'; $mime = $map[$ext] ?? 'application/octet-stream';
} }
if ($mime === 'application/octet-stream') {
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if (in_array($ext, ['dcm', 'dicom'], true)) {
return 'application/dicom';
}
}
return $mime; return $mime;
} }
private function isDicomMimeOrExtension(string $mime, string $ext): bool
{
$mime = strtolower(trim($mime));
$ext = strtolower(trim($ext));
if (in_array($ext, ['dcm', 'dicom'], true)) {
return true;
}
return str_contains($mime, 'dicom');
}
/**
* @return array<string, mixed>
*/
private function callAiServerDicomPreview(string $filePath, string $mime = 'application/dicom'): array
{
$base = $this->resolveAiServerBaseUrl();
if ($base === '') {
return [
'error' => 'AI server URL is missing for DICOM preview.',
];
}
if (! is_file($filePath)) {
return [
'error' => 'DICOM file not found on server.',
];
}
$token = trim($this->readSetting('DIAGNOSIS_AI_PARSE_TOKEN'));
$headers = ['Accept' => 'image/*'];
if ($token !== '') {
$headers['Authorization'] = 'Bearer ' . $token;
}
$url = $base . '/dicom-preview';
$attempts = $this->aiMaxRetries();
$lastError = 'DICOM preview request failed.';
for ($attempt = 1; $attempt <= $attempts; $attempt++) {
try {
$client = service('curlrequest', $this->httpOptions());
$curlFile = new \CURLFile($filePath, 'application/dicom', basename($filePath));
$response = $client->post($url, [
'headers' => $headers,
'multipart' => [
'file' => $curlFile,
],
]);
$status = $response->getStatusCode();
$body = (string) $response->getBody();
if ($status < 200 || $status >= 300) {
$lastError = 'AI server HTTP ' . $status . ': ' . mb_substr($body, 0, 180);
continue;
}
$contentType = strtolower(trim((string) $response->getHeaderLine('Content-Type')));
if (! str_starts_with($contentType, 'image/')) {
$lastError = 'AI server did not return image data for DICOM preview.';
continue;
}
return [
'bytes' => $body,
'mime' => $contentType,
'error' => '',
];
} catch (\Throwable $e) {
$lastError = $e->getMessage() !== '' ? $e->getMessage() : 'DICOM preview service timeout/error.';
}
}
return [
'error' => $lastError,
];
}
public function selectLabRadiology($invoiceId, $labType) public function selectLabRadiology($invoiceId, $labType)
{ {
return $this->selectLabInvoicePath((int) $invoiceId, (int) $labType); return $this->selectLabInvoicePath((int) $invoiceId, (int) $labType);

View File

@ -12,21 +12,16 @@ class AiSettings extends BaseController
return $this->response->setStatusCode(403)->setBody('Access denied'); return $this->response->setStatusCode(403)->setBody('Access denied');
} }
$geminiKey = $this->readSettingValue('GEMINI_API_KEY');
$azureKey = $this->readSettingValue('AZURE_OPENAI_API_KEY');
$docIntelKey = $this->readSettingValue('AZURE_DOCINTEL_KEY');
return view('Setting/Admin/ai_settings', [ return view('Setting/Admin/ai_settings', [
'gemini_key_masked' => $this->maskKey($geminiKey), 'diagnosis_ai_server_url' => $this->readSettingValue('DIAGNOSIS_AI_SERVER_URL'),
'gemini_key_exists' => $geminiKey !== '', 'diagnosis_ai_ocr_endpoint' => $this->readSettingValue('DIAGNOSIS_AI_OCR_ENDPOINT'),
'azure_openai_endpoint' => $this->readSettingValue('AZURE_OPENAI_ENDPOINT'), 'diagnosis_ai_parse_endpoint' => $this->readSettingValue('DIAGNOSIS_AI_PARSE_ENDPOINT'),
'azure_openai_deployment' => $this->readSettingValue('AZURE_OPENAI_DEPLOYMENT'), 'diagnosis_ai_imaging_prompt' => $this->readSettingValue('DIAGNOSIS_AI_IMAGING_PROMPT') ?: $this->defaultImagingPrompt(),
'azure_openai_api_version' => $this->readSettingValue('AZURE_OPENAI_API_VERSION') ?: '2024-10-21', 'diagnosis_ai_timeout_seconds' => $this->readSettingValue('DIAGNOSIS_AI_TIMEOUT_SECONDS') ?: '45',
'azure_openai_key_masked' => $this->maskKey($azureKey), 'diagnosis_ai_retry_attempts' => $this->readSettingValue('DIAGNOSIS_AI_RETRY_ATTEMPTS') ?: '2',
'azure_openai_key_exists' => $azureKey !== '', 'diagnosis_ai_daily_limit' => $this->readSettingValue('DIAGNOSIS_AI_DAILY_LIMIT') ?: '20',
'azure_docintel_endpoint' => $this->readSettingValue('AZURE_DOCINTEL_ENDPOINT'), 'diagnosis_ai_token_masked' => $this->maskKey($this->readSettingValue('DIAGNOSIS_AI_PARSE_TOKEN')),
'azure_docintel_key_masked' => $this->maskKey($docIntelKey), 'diagnosis_ai_token_exists' => $this->readSettingValue('DIAGNOSIS_AI_PARSE_TOKEN') !== '',
'azure_docintel_key_exists' => $docIntelKey !== '',
]); ]);
} }
@ -45,13 +40,14 @@ class AiSettings extends BaseController
} }
$fields = [ $fields = [
'GEMINI_API_KEY' => trim((string) $this->request->getPost('gemini_api_key')), 'DIAGNOSIS_AI_SERVER_URL' => trim((string) $this->request->getPost('diagnosis_ai_server_url')),
'AZURE_OPENAI_ENDPOINT' => trim((string) $this->request->getPost('azure_openai_endpoint')), 'DIAGNOSIS_AI_OCR_ENDPOINT' => trim((string) $this->request->getPost('diagnosis_ai_ocr_endpoint')),
'AZURE_OPENAI_API_KEY' => trim((string) $this->request->getPost('azure_openai_api_key')), 'DIAGNOSIS_AI_PARSE_ENDPOINT' => trim((string) $this->request->getPost('diagnosis_ai_parse_endpoint')),
'AZURE_OPENAI_DEPLOYMENT' => trim((string) $this->request->getPost('azure_openai_deployment')), 'DIAGNOSIS_AI_IMAGING_PROMPT' => trim((string) $this->request->getPost('diagnosis_ai_imaging_prompt')),
'AZURE_OPENAI_API_VERSION' => trim((string) $this->request->getPost('azure_openai_api_version')), 'DIAGNOSIS_AI_PARSE_TOKEN' => trim((string) $this->request->getPost('diagnosis_ai_parse_token')),
'AZURE_DOCINTEL_ENDPOINT' => trim((string) $this->request->getPost('azure_docintel_endpoint')), 'DIAGNOSIS_AI_TIMEOUT_SECONDS' => trim((string) $this->request->getPost('diagnosis_ai_timeout_seconds')),
'AZURE_DOCINTEL_KEY' => trim((string) $this->request->getPost('azure_docintel_key')), '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; $savedCount = 0;
@ -94,163 +90,193 @@ class AiSettings extends BaseController
$provider = strtolower(trim((string) $this->request->getPost('provider'))); $provider = strtolower(trim((string) $this->request->getPost('provider')));
if ($provider === '') { if ($provider === '') {
$provider = 'gemini'; $provider = 'ai-server';
} }
if ($provider === 'azure') { if ($provider === 'diagnosis-external') {
return $this->testAzureOpenAi(); return $this->testDiagnosisExternalAi();
} }
$apiKey = trim((string) $this->request->getPost('gemini_api_key')); if ($provider === 'ai-server') {
if ($apiKey === '') { return $this->testAiServer();
$apiKey = $this->readSettingValue('GEMINI_API_KEY');
} }
if ($apiKey === '') { return $this->response->setJSON([
return $this->response->setJSON(['update' => 0, 'error_text' => 'Gemini API key not configured']); 'update' => 0,
} 'error_text' => 'Unsupported provider',
'csrfName' => csrf_token(),
try { 'csrfHash' => csrf_hash(),
$client = service('curlrequest', $this->geminiHttpOptions()); ]);
$payload = [
'contents' => [[
'parts' => [[
'text' => 'Reply with exactly: CONNECTED',
]],
]],
'generationConfig' => [
'temperature' => 0,
'maxOutputTokens' => 20,
],
];
$lastStatus = 0;
$lastBody = '';
foreach ($this->geminiModelCandidates() as $modelName) {
$url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $modelName . ':generateContent?key=' . urlencode($apiKey);
$response = $client->post($url, [
'headers' => ['Content-Type' => 'application/json'],
'json' => $payload,
]);
$status = $response->getStatusCode();
$body = (string) $response->getBody();
if ($status === 429) {
usleep(1200000);
$retry = $client->post($url, [
'headers' => ['Content-Type' => 'application/json'],
'json' => $payload,
]);
$status = $retry->getStatusCode();
$body = (string) $retry->getBody();
}
if ($status >= 200 && $status < 300) {
return $this->response->setJSON([
'update' => 1,
'error_text' => 'Gemini connection successful (' . $modelName . ')',
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
$lastStatus = $status;
$lastBody = $body;
if ($status !== 404) {
break;
}
}
return $this->response->setJSON([
'update' => 0,
'error_text' => $this->geminiHttpFailureMessage($lastStatus, $lastBody),
'detail' => mb_substr($lastBody, 0, 250),
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
} catch (\Throwable $e) {
return $this->response->setJSON([
'update' => 0,
'error_text' => $this->normalizeGeminiError($e->getMessage()),
'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(),
]);
}
} }
private function testAzureOpenAi() public function usage()
{ {
$endpoint = trim((string) $this->request->getPost('azure_openai_endpoint')); if (! $this->request->isAJAX()) {
$apiKey = trim((string) $this->request->getPost('azure_openai_api_key')); return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
$deployment = trim((string) $this->request->getPost('azure_openai_deployment'));
$apiVersion = trim((string) $this->request->getPost('azure_openai_api_version'));
if ($endpoint === '') {
$endpoint = $this->readSettingValue('AZURE_OPENAI_ENDPOINT');
}
if ($apiKey === '') {
$apiKey = $this->readSettingValue('AZURE_OPENAI_API_KEY');
}
if ($deployment === '') {
$deployment = $this->readSettingValue('AZURE_OPENAI_DEPLOYMENT');
}
if ($apiVersion === '') {
$apiVersion = $this->readSettingValue('AZURE_OPENAI_API_VERSION');
}
if ($apiVersion === '') {
$apiVersion = '2024-10-21';
} }
if ($endpoint === '' || $apiKey === '' || $deployment === '') { if (! $this->canManageAiSettings()) {
$missing = []; return $this->response->setStatusCode(403)->setJSON(['update' => 0, 'error_text' => 'Access denied']);
if ($endpoint === '') { }
$missing[] = 'endpoint';
}
if ($apiKey === '') {
$missing[] = 'key';
}
if ($deployment === '') {
$missing[] = 'deployment';
}
if (! $this->db->tableExists('lab_ai_extraction_batches')) {
return $this->response->setJSON([ return $this->response->setJSON([
'update' => 0, 'update' => 1,
'error_text' => 'Azure OpenAI settings incomplete: missing ' . implode(', ', $missing), '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(), 'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(), 'csrfHash' => csrf_hash(),
]); ]);
} }
$endpoint = rtrim($endpoint, '/'); $dailyLimit = (int) ($this->readSettingValue('DIAGNOSIS_AI_DAILY_LIMIT') ?: '20');
$url = $endpoint . '/openai/deployments/' . rawurlencode($deployment) . '/chat/completions?api-version=' . rawurlencode($apiVersion); 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 { try {
$headers = [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
];
if ($token !== '') {
$headers['Authorization'] = 'Bearer ' . $token;
}
$client = service('curlrequest', $this->geminiHttpOptions()); $client = service('curlrequest', $this->geminiHttpOptions());
$response = $client->post($url, [ $response = $client->post(rtrim($endpoint, '/'), [
'headers' => [ 'headers' => $headers,
'api-key' => $apiKey,
'Content-Type' => 'application/json',
],
'json' => [ 'json' => [
'messages' => [[ 'ocr_text' => 'Hemoglobin 13.5 g/dL; Total Bilirubin 1.2 mg/dL',
'role' => 'user', 'panel_name' => 'LFT Test',
'content' => 'Reply with exactly: CONNECTED',
]],
'temperature' => 0,
'max_tokens' => 20,
], ],
]); ]);
$status = $response->getStatusCode(); $status = $response->getStatusCode();
$body = (string) $response->getBody(); $body = (string) $response->getBody();
$decoded = json_decode($body, true);
if ($status < 200 || $status >= 300) { if ($status < 200 || $status >= 300) {
return $this->response->setJSON([ return $this->response->setJSON([
'update' => 0, 'update' => 0,
'error_text' => 'Azure OpenAI test failed (HTTP ' . $status . ')', '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), 'detail' => mb_substr($body, 0, 300),
'csrfName' => csrf_token(), 'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(), 'csrfHash' => csrf_hash(),
@ -259,14 +285,76 @@ class AiSettings extends BaseController
return $this->response->setJSON([ return $this->response->setJSON([
'update' => 1, 'update' => 1,
'error_text' => 'Azure OpenAI connection successful', 'error_text' => 'Diagnosis AI endpoint connection successful',
'csrfName' => csrf_token(), 'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(), 'csrfHash' => csrf_hash(),
]); ]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return $this->response->setJSON([ return $this->response->setJSON([
'update' => 0, 'update' => 0,
'error_text' => 'Azure OpenAI test failed: ' . $e->getMessage(), '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(), 'csrfName' => csrf_token(),
'csrfHash' => csrf_hash(), 'csrfHash' => csrf_hash(),
]); ]);
@ -290,57 +378,6 @@ class AiSettings extends BaseController
return $options; return $options;
} }
private function normalizeGeminiError(string $message): string
{
$message = trim($message);
if (stripos($message, 'SSL certificate problem') !== false) {
return 'Gemini test failed: SSL certificate trust issue in local PHP/cURL. Local use is possible; this build auto-relaxes SSL only in development/testing. If still failing, configure CA bundle in PHP.';
}
return 'Gemini test failed: ' . $message;
}
private function geminiHttpFailureMessage(int $status, string $body): string
{
if ($status === 429) {
return 'Gemini test failed: Rate limit/quota exceeded (HTTP 429). Please wait 1-2 minutes, then retry. If it continues, check API quota/billing in Google AI Studio.';
}
if ($status === 403) {
return 'Gemini test failed: API key not permitted for this request (HTTP 403). Verify key and project permissions.';
}
if ($status === 404) {
return 'Gemini test failed: Model endpoint not found (HTTP 404). Please verify Gemini API availability for this key/project.';
}
if ($status <= 0) {
return 'Gemini test failed: No HTTP response received.';
}
$message = 'Gemini test failed (HTTP ' . $status . ')';
$decoded = json_decode($body, true);
$apiMessage = trim((string) ($decoded['error']['message'] ?? ''));
if ($apiMessage !== '') {
$message .= ': ' . $apiMessage;
}
return $message;
}
/**
* @return array<int, string>
*/
private function geminiModelCandidates(): array
{
return [
'gemini-2.0-flash',
'gemini-1.5-flash',
'gemini-1.5-flash-latest',
];
}
private function canManageAiSettings(): bool private function canManageAiSettings(): bool
{ {
if (! function_exists('auth') || ! auth()->loggedIn()) { if (! function_exists('auth') || ! auth()->loggedIn()) {
@ -350,6 +387,19 @@ class AiSettings extends BaseController
return true; 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 private function readSettingValue(string $name): string
{ {
if (defined($name)) { if (defined($name)) {

View File

@ -5,84 +5,88 @@
<div class="card-body"> <div class="card-body">
<?= csrf_field() ?> <?= csrf_field() ?>
<h6 class="mb-2">Gemini (optional fallback)</h6> <h6 class="mb-2">AI Server (Primary - Python FastAPI)</h6>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Gemini API Key</label> <label class="form-label">AI Server Base URL</label>
<input type="password" class="form-control" id="gemini_api_key" placeholder="Paste Gemini API key"> <input type="text" class="form-control" id="diagnosis_ai_server_url" placeholder="http://127.0.0.1:8000" value="<?= esc($diagnosis_ai_server_url ?? '') ?>">
<div class="form-text"> <div class="form-text">Used for imaging diagnosis endpoint: /diagnosis</div>
<?php if (!empty($gemini_key_exists)) : ?> </div>
Current key: <?= esc($gemini_key_masked ?? '') ?> <div class="mb-3">
<?php else : ?> <label class="form-label">AI OCR Endpoint (optional, for pathology file OCR)</label>
No API key configured yet. <input type="text" class="form-control" id="diagnosis_ai_ocr_endpoint" placeholder="http://127.0.0.1:8000/ocr" value="<?= esc($diagnosis_ai_ocr_endpoint ?? '') ?>">
<?php endif; ?> <div class="form-text">If not configured, pathology AI extraction OCR will be unavailable.</div>
</div>
</div> </div>
<div class="alert alert-info"> <div class="mb-3">
<h6 class="mb-2">How to get Gemini API key</h6> <label class="form-label">Imaging Report Prompt Template <span class="badge bg-secondary">Optional Override</span></label>
<ol class="mb-2 ps-3"> <textarea class="form-control" id="diagnosis_ai_imaging_prompt" rows="6" placeholder="Leave empty to use auto-detected prompts per study type. Use {study_name} as placeholder."><?= esc($diagnosis_ai_imaging_prompt ?? '') ?></textarea>
<li> <div class="form-text">
Open <a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noopener">Google AI Studio API Keys</a> <strong>Auto-detection is active:</strong> the server automatically selects the correct prompt structure for each study type —
<button type="button" class="btn btn-sm btn-outline-secondary ms-2" id="btn_copy_gemini_link" data-url="https://aistudio.google.com/app/apikey">Copy Link</button>. Chest X-ray, Abdomen, Spine, PNS/Sinuses, Skull, Pelvis, Extremities/Joints, Contrast studies, Mammography, MRI, CT, Ultrasound.<br>
</li> Fill this field only to override with a custom global prompt for all studies. Use <code>{study_name}</code> as placeholder for current study name. Leave blank to keep auto-detection.
<li>Sign in and click <strong>Create API key</strong>.</li> </div>
<li>Copy the key and paste it in this field.</li>
<li>Click <strong>Save Key</strong>, then <strong>Test Connection</strong>.</li>
</ol>
<div class="small mb-0">If test fails, check internet access from server, remove extra spaces/newline in key, or regenerate key.</div>
</div> </div>
<hr> <hr>
<h6 class="mb-2">Azure OpenAI (Primary)</h6> <h6 class="mb-2">Diagnosis AI Parse Endpoint</h6>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Azure OpenAI Endpoint</label> <label class="form-label">Diagnosis Parse Endpoint</label>
<input type="text" class="form-control" id="azure_openai_endpoint" placeholder="https://your-resource.openai.azure.com" value="<?= esc($azure_openai_endpoint ?? '') ?>"> <input type="text" class="form-control" id="diagnosis_ai_parse_endpoint" placeholder="http://127.0.0.1:8000/parse-lab-values" value="<?= esc($diagnosis_ai_parse_endpoint ?? '') ?>">
<div class="form-text">Used by pathology value extraction flow.</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Azure OpenAI API Key</label> <label class="form-label">Diagnosis Parse Token (optional)</label>
<input type="password" class="form-control" id="azure_openai_api_key" placeholder="Paste Azure OpenAI key"> <input type="password" class="form-control" id="diagnosis_ai_parse_token" placeholder="Bearer token">
<div class="form-text"> <div class="form-text">
<?php if (!empty($azure_openai_key_exists)) : ?> <?php if (!empty($diagnosis_ai_token_exists)) : ?>
Current key: <?= esc($azure_openai_key_masked ?? '') ?> Current token: <?= esc($diagnosis_ai_token_masked ?? '') ?>
<?php else : ?> <?php else : ?>
No Azure OpenAI key configured yet. No token configured yet.
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label class="form-label">Deployment Name</label> <label class="form-label">Diagnosis AI Timeout (seconds)</label>
<input type="text" class="form-control" id="azure_openai_deployment" placeholder="hms-opd-draft" value="<?= esc($azure_openai_deployment ?? '') ?>"> <input type="number" min="5" max="180" class="form-control" id="diagnosis_ai_timeout_seconds" value="<?= esc($diagnosis_ai_timeout_seconds ?? '45') ?>">
</div> </div>
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label class="form-label">API Version</label> <label class="form-label">Diagnosis AI Retry Attempts</label>
<input type="text" class="form-control" id="azure_openai_api_version" placeholder="2024-10-21" value="<?= esc($azure_openai_api_version ?? '2024-10-21') ?>"> <input type="number" min="1" max="5" class="form-control" id="diagnosis_ai_retry_attempts" value="<?= esc($diagnosis_ai_retry_attempts ?? '2') ?>">
</div> </div>
</div> </div>
<hr> <div class="row">
<h6 class="mb-2">OCR (Azure Document Intelligence)</h6> <div class="col-md-6 mb-3">
<div class="mb-3"> <label class="form-label">Gemini Daily Limit (guardrail)</label>
<label class="form-label">Document Intelligence Endpoint</label> <input type="number" min="1" max="10000" class="form-control" id="diagnosis_ai_daily_limit" value="<?= esc($diagnosis_ai_daily_limit ?? '20') ?>">
<input type="text" class="form-control" id="azure_docintel_endpoint" placeholder="https://your-docintel.cognitiveservices.azure.com" value="<?= esc($azure_docintel_endpoint ?? '') ?>"> <div class="form-text">Used for warning levels at 70% and 90%.</div>
</div>
<div class="mb-3">
<label class="form-label">Document Intelligence Key</label>
<input type="password" class="form-control" id="azure_docintel_key" placeholder="Paste Document Intelligence key">
<div class="form-text">
<?php if (!empty($azure_docintel_key_exists)) : ?>
Current key: <?= esc($azure_docintel_key_masked ?? '') ?>
<?php else : ?>
No Document Intelligence key configured yet.
<?php endif; ?>
</div> </div>
<div class="col-md-6 mb-3 d-flex align-items-end">
<button type="button" class="btn btn-outline-primary" id="btn_refresh_ai_usage">Refresh Usage</button>
</div>
</div>
<div class="border rounded p-3 mb-3 bg-light" id="ai_usage_card">
<div class="d-flex justify-content-between align-items-center mb-2">
<strong>AI Usage Guardrail (Today)</strong>
<span class="badge bg-secondary" id="ai_usage_level">Unknown</span>
</div>
<div class="row g-2">
<div class="col-md-3"><div class="small text-muted">Gemini Calls</div><div id="ai_usage_gemini" class="fw-bold">-</div></div>
<div class="col-md-3"><div class="small text-muted">Fallback Calls</div><div id="ai_usage_fallback" class="fw-bold">-</div></div>
<div class="col-md-3"><div class="small text-muted">Total Calls</div><div id="ai_usage_total" class="fw-bold">-</div></div>
<div class="col-md-3"><div class="small text-muted">Last Hour</div><div id="ai_usage_hour" class="fw-bold">-</div></div>
</div>
<div class="small mt-2 text-muted" id="ai_usage_hint">Press Refresh Usage to load latest usage.</div>
</div> </div>
<div class="d-flex gap-2 flex-wrap"> <div class="d-flex gap-2 flex-wrap">
<button type="button" class="btn btn-primary" id="btn_save_ai_key">Save Settings</button> <button type="button" class="btn btn-primary" id="btn_save_ai_key">Save Settings</button>
<button type="button" class="btn btn-outline-success" id="btn_test_ai_key">Test Gemini</button> <button type="button" class="btn btn-outline-dark" id="btn_test_ai_server">Test AI Server</button>
<button type="button" class="btn btn-outline-primary" id="btn_test_azure_key">Test Azure OpenAI</button> <button type="button" class="btn btn-outline-secondary" id="btn_test_diagnosis_ai">Test Parse Endpoint</button>
</div> </div>
<div id="ai_settings_msg" class="mt-3"></div> <div id="ai_settings_msg" class="mt-3"></div>
@ -129,99 +133,105 @@
} }
$('#btn_save_ai_key').on('click', function() { $('#btn_save_ai_key').on('click', function() {
var key = ($('#gemini_api_key').val() || '').trim(); var aiServerUrl = ($('#diagnosis_ai_server_url').val() || '').trim();
var azureEndpoint = ($('#azure_openai_endpoint').val() || '').trim(); var aiOcrEndpoint = ($('#diagnosis_ai_ocr_endpoint').val() || '').trim();
var azureKey = ($('#azure_openai_api_key').val() || '').trim(); var diagnosisEndpoint = ($('#diagnosis_ai_parse_endpoint').val() || '').trim();
var azureDeployment = ($('#azure_openai_deployment').val() || '').trim(); var diagnosisImagingPrompt = ($('#diagnosis_ai_imaging_prompt').val() || '').trim();
var azureApiVersion = ($('#azure_openai_api_version').val() || '').trim(); var diagnosisToken = ($('#diagnosis_ai_parse_token').val() || '').trim();
var docintelEndpoint = ($('#azure_docintel_endpoint').val() || '').trim(); var diagnosisTimeout = ($('#diagnosis_ai_timeout_seconds').val() || '').trim();
var docintelKey = ($('#azure_docintel_key').val() || '').trim(); var diagnosisRetry = ($('#diagnosis_ai_retry_attempts').val() || '').trim();
postJson('<?= base_url('setting/admin/ai-settings/save') ?>', { postJson('<?= base_url('setting/admin/ai-settings/save') ?>', {
gemini_api_key: key, diagnosis_ai_server_url: aiServerUrl,
azure_openai_endpoint: azureEndpoint, diagnosis_ai_ocr_endpoint: aiOcrEndpoint,
azure_openai_api_key: azureKey, diagnosis_ai_parse_endpoint: diagnosisEndpoint,
azure_openai_deployment: azureDeployment, diagnosis_ai_imaging_prompt: diagnosisImagingPrompt,
azure_openai_api_version: azureApiVersion, diagnosis_ai_parse_token: diagnosisToken,
azure_docintel_endpoint: docintelEndpoint, diagnosis_ai_timeout_seconds: diagnosisTimeout,
azure_docintel_key: docintelKey diagnosis_ai_retry_attempts: diagnosisRetry,
diagnosis_ai_daily_limit: ($('#diagnosis_ai_daily_limit').val() || '').trim()
}, function(data) { }, function(data) {
if ((data.update || 0) !== 1) { if ((data.update || 0) !== 1) {
showMessage('error', data.error_text || 'Unable to save API key'); showMessage('error', data.error_text || 'Unable to save settings');
return; return;
} }
showMessage('success', data.error_text || 'Settings saved'); showMessage('success', data.error_text || 'Settings saved');
$('#gemini_api_key').val(''); $('#diagnosis_ai_parse_token').val('');
$('#azure_openai_api_key').val(''); refreshUsage();
$('#azure_docintel_key').val('');
}); });
}); });
$('#btn_test_ai_key').on('click', function() { $('#btn_test_ai_server').on('click', function() {
var key = ($('#gemini_api_key').val() || '').trim(); var aiServerUrl = ($('#diagnosis_ai_server_url').val() || '').trim();
postJson('<?= base_url('setting/admin/ai-settings/test') ?>', { var diagnosisToken = ($('#diagnosis_ai_parse_token').val() || '').trim();
provider: 'gemini',
gemini_api_key: key
}, function(data) {
if ((data.update || 0) !== 1) {
showMessage('error', data.error_text || 'Test failed');
return;
}
showMessage('success', data.error_text || 'Connection successful');
});
});
$('#btn_test_azure_key').on('click', function() {
var azureEndpoint = ($('#azure_openai_endpoint').val() || '').trim();
var azureKey = ($('#azure_openai_api_key').val() || '').trim();
var azureDeployment = ($('#azure_openai_deployment').val() || '').trim();
var azureApiVersion = ($('#azure_openai_api_version').val() || '').trim();
postJson('<?= base_url('setting/admin/ai-settings/test') ?>', { postJson('<?= base_url('setting/admin/ai-settings/test') ?>', {
provider: 'azure', provider: 'ai-server',
azure_openai_endpoint: azureEndpoint, diagnosis_ai_server_url: aiServerUrl,
azure_openai_api_key: azureKey, diagnosis_ai_parse_token: diagnosisToken
azure_openai_deployment: azureDeployment,
azure_openai_api_version: azureApiVersion
}, function(data) { }, function(data) {
if ((data.update || 0) !== 1) { if ((data.update || 0) !== 1) {
showMessage('error', data.error_text || 'Azure test failed'); showMessage('error', data.error_text || 'AI Server test failed');
return; return;
} }
showMessage('success', data.error_text || 'Azure connection successful'); showMessage('success', data.error_text || 'AI Server connection successful');
}); });
}); });
$('#btn_copy_gemini_link').on('click', function() { $('#btn_test_diagnosis_ai').on('click', function() {
var url = $(this).data('url') || ''; var diagnosisEndpoint = ($('#diagnosis_ai_parse_endpoint').val() || '').trim();
if (!url) { var diagnosisToken = ($('#diagnosis_ai_parse_token').val() || '').trim();
showMessage('error', 'Unable to copy link');
return;
}
if (navigator.clipboard && navigator.clipboard.writeText) { postJson('<?= base_url('setting/admin/ai-settings/test') ?>', {
navigator.clipboard.writeText(url).then(function() { provider: 'diagnosis-external',
showMessage('success', 'Gemini link copied'); diagnosis_ai_parse_endpoint: diagnosisEndpoint,
}).catch(function() { diagnosis_ai_parse_token: diagnosisToken
showMessage('error', 'Copy failed. Please copy manually'); }, function(data) {
}); if ((data.update || 0) !== 1) {
return; showMessage('error', data.error_text || 'Parse endpoint test failed');
} return;
}
var temp = document.createElement('input'); showMessage('success', data.error_text || 'Parse endpoint connection successful');
temp.value = url; });
document.body.appendChild(temp);
temp.select();
try {
document.execCommand('copy');
showMessage('success', 'Gemini link copied');
} catch (e) {
showMessage('error', 'Copy failed. Please copy manually');
}
document.body.removeChild(temp);
}); });
function refreshUsage() {
postJson('<?= base_url('setting/admin/ai-settings/usage') ?>', {}, function(data) {
var usage = data && data.usage ? data.usage : null;
if (!usage) {
$('#ai_usage_hint').text((data && data.error_text) ? data.error_text : 'Unable to load usage');
return;
}
$('#ai_usage_gemini').text((usage.gemini_today || 0) + ' / ' + (usage.daily_limit || 0));
$('#ai_usage_fallback').text(usage.fallback_today || 0);
$('#ai_usage_total').text(usage.total_today || 0);
$('#ai_usage_hour').text(usage.last_hour || 0);
var level = (usage.level || 'ok').toString();
var levelText = 'OK';
var levelClass = 'bg-success';
if (level === 'warn') {
levelText = 'Warning';
levelClass = 'bg-warning text-dark';
} else if (level === 'danger') {
levelText = 'High';
levelClass = 'bg-danger';
} else if (level === 'critical') {
levelText = 'Exceeded';
levelClass = 'bg-dark';
}
$('#ai_usage_level').removeClass().addClass('badge ' + levelClass).text(levelText);
$('#ai_usage_hint').text('Gemini utilization: ' + (usage.ratio || 0) + '%. Thresholds: 70% warning, 90% high.');
});
}
$('#btn_refresh_ai_usage').on('click', function() {
refreshUsage();
});
refreshUsage();
})(); })();
</script> </script>

View File

@ -9,6 +9,23 @@ $provider = trim((string) ($provider ?? 'azure-openai'));
$model = trim((string) ($model ?? '')); $model = trim((string) ($model ?? ''));
$findingsInputId = 'ai_findings_' . $batchId; $findingsInputId = 'ai_findings_' . $batchId;
$impressionInputId = 'ai_impression_' . $batchId; $impressionInputId = 'ai_impression_' . $batchId;
$providerKey = strtolower($provider);
$providerLabel = $provider !== '' ? $provider : 'ai-server';
$providerBadgeClass = 'bg-secondary';
if ($providerKey === 'gemini') {
$providerLabel = 'Gemini';
$providerBadgeClass = 'bg-primary';
} elseif ($providerKey === 'local-xray-fallback') {
$providerLabel = 'Local X-ray Fallback';
$providerBadgeClass = 'bg-warning text-dark';
} elseif ($providerKey === 'regex-fallback') {
$providerLabel = 'Regex Fallback';
$providerBadgeClass = 'bg-warning text-dark';
} elseif ($providerKey === 'azure-openai') {
$providerLabel = 'Azure OpenAI';
$providerBadgeClass = 'bg-info text-dark';
}
$isFallback = in_array($providerKey, ['local-xray-fallback', 'regex-fallback'], true);
?> ?>
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3"> <div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3">
@ -17,7 +34,13 @@ $impressionInputId = 'ai_impression_' . $batchId;
<?php if ($studyName !== ''): ?> <?php if ($studyName !== ''): ?>
<div class="small text-muted">Study: <?= esc($studyName) ?></div> <div class="small text-muted">Study: <?= esc($studyName) ?></div>
<?php endif; ?> <?php endif; ?>
<div class="small text-muted">Stored batch #<?= esc((string) $batchId) ?> | <?= esc($provider) ?><?= $model !== '' ? ' / ' . esc($model) : '' ?></div> <div class="small text-muted d-flex align-items-center gap-2 flex-wrap">
<span>Stored batch #<?= esc((string) $batchId) ?></span>
<span class="badge <?= esc($providerBadgeClass) ?>"><?= esc($providerLabel) ?></span>
<?php if ($model !== ''): ?>
<span class="badge bg-light text-dark border"><?= esc($model) ?></span>
<?php endif; ?>
</div>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button <button
@ -43,6 +66,12 @@ $impressionInputId = 'ai_impression_' . $batchId;
<div class="alert alert-info py-2"><?= esc($summaryText) ?></div> <div class="alert alert-info py-2"><?= esc($summaryText) ?></div>
<?php endif; ?> <?php endif; ?>
<?php if ($isFallback): ?>
<div class="alert alert-warning py-2">
Fallback mode used for this draft. Review carefully before sign-off.
</div>
<?php endif; ?>
<div class="row g-3"> <div class="row g-3">
<div class="col-lg-7"> <div class="col-lg-7">
<div class="card h-100"> <div class="card h-100">

View File

@ -6,7 +6,7 @@ $studyName = trim((string) ($study_name ?? ''));
<div class="mb-3"> <div class="mb-3">
<div class="d-flex justify-content-between align-items-center gap-2 flex-wrap"> <div class="d-flex justify-content-between align-items-center gap-2 flex-wrap">
<div> <div>
<h6 class="mb-1">Uploaded Images</h6> <h6 class="mb-1">Uploaded Imaging Files</h6>
<?php if ($studyName !== ''): ?> <?php if ($studyName !== ''): ?>
<div class="small text-muted">Study: <?= esc($studyName) ?></div> <div class="small text-muted">Study: <?= esc($studyName) ?></div>
<?php endif; ?> <?php endif; ?>
@ -16,7 +16,7 @@ $studyName = trim((string) ($study_name ?? ''));
</div> </div>
<?php if ($fileRows === []): ?> <?php if ($fileRows === []): ?>
<div class="alert alert-warning mb-0">No uploaded images or PDFs found for this study.</div> <div class="alert alert-warning mb-0">No uploaded imaging files found for this study.</div>
<?php else: ?> <?php else: ?>
<div class="row g-3"> <div class="row g-3">
<?php foreach ($fileRows as $row): ?> <?php foreach ($fileRows as $row): ?>
@ -41,11 +41,26 @@ $studyName = trim((string) ($study_name ?? ''));
<a href="<?= esc((string) $row['url']) ?>" target="_blank" rel="noopener"> <a href="<?= esc((string) $row['url']) ?>" target="_blank" rel="noopener">
<img src="<?= esc((string) $row['url']) ?>" alt="Uploaded image" class="img-fluid rounded border" style="width:100%; max-height:220px; object-fit:cover;"> <img src="<?= esc((string) $row['url']) ?>" alt="Uploaded image" class="img-fluid rounded border" style="width:100%; max-height:220px; object-fit:cover;">
</a> </a>
<?php elseif (!empty($row['is_dicom']) && !empty($row['dicom_preview_url'])): ?>
<div class="border rounded p-2 bg-light">
<img src="<?= esc((string) $row['dicom_preview_url']) ?>" alt="DICOM preview" class="img-fluid rounded border" style="width:100%; max-height:220px; object-fit:cover;">
<div class="d-flex justify-content-between align-items-center mt-2">
<span class="badge bg-primary-subtle text-primary-emphasis border">DICOM Preview</span>
<?php if (!empty($row['url'])): ?>
<a href="<?= esc((string) $row['url']) ?>" target="_blank" rel="noopener">Open DICOM</a>
<?php endif; ?>
</div>
</div>
<?php elseif (!empty($row['url']) && !empty($row['is_pdf'])): ?> <?php elseif (!empty($row['url']) && !empty($row['is_pdf'])): ?>
<div class="border rounded p-3 text-center bg-light"> <div class="border rounded p-3 text-center bg-light">
<div class="display-6 text-danger"><i class="bi bi-file-earmark-pdf"></i></div> <div class="display-6 text-danger"><i class="bi bi-file-earmark-pdf"></i></div>
<a href="<?= esc((string) $row['url']) ?>" target="_blank" rel="noopener">Open PDF</a> <a href="<?= esc((string) $row['url']) ?>" target="_blank" rel="noopener">Open PDF</a>
</div> </div>
<?php elseif (!empty($row['url'])): ?>
<div class="border rounded p-3 text-center bg-light">
<div class="display-6 text-primary"><i class="bi bi-file-earmark-medical"></i></div>
<a href="<?= esc((string) $row['url']) ?>" target="_blank" rel="noopener">Open File</a>
</div>
<?php else: ?> <?php else: ?>
<div class="border rounded p-3 text-muted bg-light">Preview unavailable</div> <div class="border rounded p-3 text-muted bg-light">Preview unavailable</div>
<?php endif; ?> <?php endif; ?>

View File

@ -537,6 +537,9 @@ function initializeReportEditor() {
return; return;
} }
// Re-enable notification plugins (may be disabled globally in welcome_message.php)
CKEDITOR.config.removePlugins = '';
if (CKEDITOR.instances.HTMLShow) { if (CKEDITOR.instances.HTMLShow) {
CKEDITOR.instances.HTMLShow.destroy(true); CKEDITOR.instances.HTMLShow.destroy(true);
} }
@ -902,7 +905,7 @@ function diagnosisUploadForReq(reqId, testName, mode) {
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
input.accept = 'image/*,application/pdf'; input.accept = 'image/*,application/pdf,.dcm,application/dicom,application/dicom+json';
if ((mode || '').toLowerCase() === 'camera') { if ((mode || '').toLowerCase() === 'camera') {
input.accept = 'image/*'; input.accept = 'image/*';
input.setAttribute('capture', 'environment'); input.setAttribute('capture', 'environment');
@ -976,6 +979,8 @@ function getEditorImagingContext() {
}; };
} }
window._imagingSupportModalInstance = window._imagingSupportModalInstance || null;
function openImagingSupportModal(title, html) { function openImagingSupportModal(title, html) {
const titleEl = document.getElementById('imagingSupportLabel'); const titleEl = document.getElementById('imagingSupportLabel');
const bodyEl = document.getElementById('imagingSupportBody'); const bodyEl = document.getElementById('imagingSupportBody');
@ -986,8 +991,11 @@ function openImagingSupportModal(title, html) {
bodyEl.innerHTML = html || '<div class="alert alert-warning mb-0">No content found.</div>'; bodyEl.innerHTML = html || '<div class="alert alert-warning mb-0">No content found.</div>';
} }
const modal = new bootstrap.Modal(document.getElementById('imagingSupportModal')); const el = document.getElementById('imagingSupportModal');
modal.show(); if (el) {
window._imagingSupportModalInstance = bootstrap.Modal.getOrCreateInstance(el);
window._imagingSupportModalInstance.show();
}
} }
function showImagingUploads(reqId, testName) { function showImagingUploads(reqId, testName) {
@ -1094,9 +1102,8 @@ function applyAiDiagnosisDraftToEditor(button, autoSave) {
} }
const modalEl = document.getElementById('imagingSupportModal'); const modalEl = document.getElementById('imagingSupportModal');
const modal = modalEl ? bootstrap.Modal.getInstance(modalEl) : null; if (window._imagingSupportModalInstance) {
if (modal) { window._imagingSupportModalInstance.hide();
modal.hide();
} }
if (autoSave === true) { if (autoSave === true) {