diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 06a2a25..f81e97e 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -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/confirm-report/(:num)', 'Diagnosis::confirmReport/$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->post('diagnosis/remove-test/(:num)', 'Diagnosis::removeTest/$1'); $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)/(:num)', 'Diagnosis::reportFileList/$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->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'); @@ -746,6 +750,7 @@ $routes->group('setting', static function($routes) { $routes->get('admin/ai-settings', 'Setting\\AiSettings::index'); $routes->post('admin/ai-settings/save', 'Setting\\AiSettings::save'); $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/new', 'Setting\\Doctor::create'); $routes->post('admin/doctor/new', 'Setting\\Doctor::store'); diff --git a/app/Controllers/Diagnosis.php b/app/Controllers/Diagnosis.php index ff8be8f..5917c34 100644 --- a/app/Controllers/Diagnosis.php +++ b/app/Controllers/Diagnosis.php @@ -1548,6 +1548,7 @@ class Diagnosis extends BaseController $ext = strtolower(pathinfo((string) ($row['file_name'] ?? ''), PATHINFO_EXTENSION)); $isImage = (int) ($row['is_image'] ?? 0) === 1 || str_starts_with($mime, 'image/'); $isPdf = $ext === 'pdf' || $mime === 'application/pdf'; + $isDicom = $this->isDicomMimeOrExtension($mime, $ext); $files[] = [ 'id' => (int) ($row['id'] ?? 0), @@ -1556,6 +1557,8 @@ class Diagnosis extends BaseController 'url' => $url, 'is_image' => $isImage, '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'] ?? ''), 'ai_status' => (string) ($row['ai_status'] ?? ''), '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() { if (! $this->request->isAJAX()) { @@ -1595,9 +1642,9 @@ class Diagnosis extends BaseController } $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)) { - 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'); @@ -1719,7 +1766,7 @@ class Diagnosis extends BaseController $ocrText = $this->extractTextFromLabFile($absolutePath); 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); @@ -1741,7 +1788,8 @@ class Diagnosis extends BaseController $panelName, $modelName, $ocrText, - $rawResponse + $rawResponse, + (string) ($parsed['provider'] ?? 'ai-server') ); if ($batchId <= 0) { @@ -1786,7 +1834,7 @@ class Diagnosis extends BaseController return $this->response->setJSON([ 'update' => 1, '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, 'batch_id' => $batchId, 'stored_values' => $storedCount, @@ -1933,23 +1981,56 @@ class Diagnosis extends BaseController $ocrChunks[] = 'FILE: ' . $fileName . "\n" . $ocrText; } - if (str_starts_with($mime, 'image/') && count($visionImages) < 4) { - $imagePayload = $this->buildVisionImagePayload($absolutePath, $mime); - if ($imagePayload !== '') { - $visionImages[] = [ - 'type' => 'image_url', - 'image_url' => ['url' => $imagePayload], - ]; + $ext = strtolower(pathinfo($absolutePath, PATHINFO_EXTENSION)); + if ((str_starts_with($mime, 'image/') || $this->isDicomMimeOrExtension($mime, $ext)) && count($visionImages) < 4) { + $visionImages[] = [ + 'abs_path' => $absolutePath, + 'mime' => $mime, + ]; + } + } + + $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'] ?? '') !== '') { $this->updateImagingFileAiState($fileIds, 'failed', (string) $analysis['error'], 1); return $this->response->setJSON(['update' => 0, 'error_text' => (string) $analysis['error']]); } + $promptUsed = $this->buildImagingDiagnosisPrompt($studyName); + $rawPayload = [ 'type' => 'imaging-ai-diagnosis', 'request_id' => $labReqId, @@ -1960,8 +2041,9 @@ class Diagnosis extends BaseController 'findings_html' => (string) ($analysis['findings_html'] ?? ''), 'impression_html' => (string) ($analysis['impression_html'] ?? ''), 'summary_text' => (string) ($analysis['summary_text'] ?? ''), - 'provider' => (string) ($analysis['provider'] ?? 'azure-openai'), + 'provider' => (string) ($analysis['provider'] ?? 'ai-server'), 'model' => (string) ($analysis['model'] ?? ''), + 'prompt_used' => $promptUsed, 'source_files' => $sourceFiles, 'raw' => $analysis['raw'] ?? [], ]; @@ -1974,7 +2056,8 @@ class Diagnosis extends BaseController $studyName, (string) ($analysis['model'] ?? ''), $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) { @@ -1999,7 +2082,7 @@ class Diagnosis extends BaseController 'findings_html' => (string) ($analysis['findings_html'] ?? ''), 'impression_html' => (string) ($analysis['impression_html'] ?? ''), 'summary_text' => (string) ($analysis['summary_text'] ?? ''), - 'provider' => (string) ($analysis['provider'] ?? 'azure-openai'), + 'provider' => (string) ($analysis['provider'] ?? 'ai-server'), 'model' => (string) ($analysis['model'] ?? ''), ]); @@ -2023,14 +2106,15 @@ class Diagnosis extends BaseController string $panelName, string $modelName, string $ocrText, - string $rawResponse + string $rawResponse, + string $provider = 'ai-server' ): int { $ok = $this->db->table('lab_ai_extraction_batches')->insert([ 'invoice_id' => $invoiceId, 'lab_type' => $labType, 'file_upload_id' => $fileId, 'panel_name' => $panelName, - 'ai_provider' => 'azure-openai', + 'ai_provider' => $provider, 'model_name' => $modelName, 'ocr_text' => $ocrText, 'raw_response_json' => $rawResponse, @@ -2161,129 +2245,210 @@ class Diagnosis extends BaseController * @param array> $visionImages * @return array */ - 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'), '/'); - $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 === '') { + if ($visionImages === []) { return [ - 'error' => 'Azure OpenAI settings are missing for imaging AI diagnosis.', + 'error' => 'No image available for AI diagnosis.', ]; } - if ($apiVersion === '') { - $apiVersion = '2024-10-21'; - } - - if ($visionImages === [] && $ocrChunks === []) { + $firstImagePath = (string) ($visionImages[0]['abs_path'] ?? ''); + $firstImageMime = (string) ($visionImages[0]['mime'] ?? 'image/jpeg'); + if ($firstImagePath === '' || ! is_file($firstImagePath)) { return [ - 'error' => 'No image or OCR context available for AI diagnosis.', + 'error' => 'Image file not found on server for AI diagnosis.', ]; } - $content = [[ - 'type' => 'text', - 'text' => "You are assisting a radiologist in drafting an imaging report for an Indian hospital.\n" . - "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; + $response = $this->callAiServerDiagnosis($studyName, $firstImagePath, $firstImageMime, $patientName, $patientAge, $patientGender); + if (($response['error'] ?? '') !== '') { + return $response; } - $url = $endpoint . '/openai/deployments/' . rawurlencode($deployment) . '/chat/completions?api-version=' . rawurlencode($apiVersion); - $attempts = $this->aiMaxRetries(); - $lastError = 'AI diagnosis generation failed.'; + $report = $response['report'] ?? []; + $technique = $this->normalizeAiTextValue($report['Technique'] ?? $studyName, $studyName); + $impression = $this->normalizeAiTextValue($report['Impression'] ?? 'AI diagnosis draft prepared.', 'AI diagnosis draft prepared.'); - for ($attempt = 1; $attempt <= $attempts; $attempt++) { - try { - $client = service('curlrequest', $this->httpOptions()); - $response = $client->post($url, [ - 'headers' => [ - 'api-key' => $apiKey, - 'Content-Type' => 'application/json', - ], - 'json' => [ - 'messages' => [[ - 'role' => 'user', - 'content' => $content, - ]], - 'temperature' => 0.2, - 'response_format' => ['type' => 'json_object'], - 'max_tokens' => 2200, - ], - ]); + $findings = $report['Findings'] ?? []; + $narrative = ''; + $labels = []; + $ocrText = []; + if (is_array($findings)) { + $narrative = $this->normalizeAiTextValue($findings['Narrative'] ?? '', ''); + $labels = $this->normalizeAiListValue($findings['Labels'] ?? []); + $ocrText = $this->normalizeAiListValue($findings['OCR Text'] ?? []); + } else { + $narrative = $this->normalizeAiTextValue($findings, ''); + } - if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) { - $lastError = 'Azure OpenAI HTTP ' . $response->getStatusCode(); - continue; - } - - $decoded = json_decode((string) $response->getBody(), true); - $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.'; + $labelHtml = ''; + if ($labels !== []) { + $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 !== []) { + $labelHtml = '

Detected labels: ' . esc(implode(', ', $safeLabels)) . '

'; } } + $ocrHtml = ''; + if ($ocrText !== []) { + $ocrFirst = trim((string) ($ocrText[0] ?? '')); + if ($ocrFirst !== '') { + $ocrHtml = '

OCR notes: ' . nl2br(esc(mb_substr($ocrFirst, 0, 1500))) . '

'; + } + } + + $findingsHtml = '

Technique: ' . esc($technique) . '

'; + 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 .= '

' . nl2br(esc($narrative)) . '

'; + } else { + foreach ($paragraphs as $paragraph) { + $findingsHtml .= '

' . esc($paragraph) . '

'; + } + } + } else { + $findingsHtml .= $labelHtml . $ocrHtml; + } + 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' => '

' . esc($impression) . '

', + '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 + */ + 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 { - $docEndpoint = rtrim($this->readSetting('AZURE_DOCINTEL_ENDPOINT'), '/'); - $docKey = trim($this->readSetting('AZURE_DOCINTEL_KEY')); + return $this->extractTextWithAiServerOcr($absolutePath); + } - if ($docEndpoint !== '' && $docKey !== '') { - $text = $this->extractTextWithDocumentIntelligence($absolutePath, $docEndpoint, $docKey); - if ($text !== '') { - return $text; - } + private function extractTextWithAiServerOcr(string $absolutePath): string + { + $endpoint = rtrim($this->readSetting('DIAGNOSIS_AI_OCR_ENDPOINT'), '/'); + 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 @@ -2425,23 +2590,158 @@ class Diagnosis extends BaseController if (! empty($external['values']) && is_array($external['values'])) { return $external; } - - $azure = $this->parseLabValuesWithAzureOpenAi($ocrText, $panelName); - if (! empty($azure['values']) && is_array($azure['values'])) { - return $azure; - } - $externalError = trim((string) ($external['error'] ?? '')); - $azureError = trim((string) ($azure['error'] ?? '')); return [ 'provider' => 'none', 'model' => '', 'values' => [], 'raw' => [], - 'error' => $externalError !== '' ? $externalError : $azureError, + 'error' => $externalError !== '' ? $externalError : 'AI parse failed. Configure DIAGNOSIS_AI_PARSE_ENDPOINT on your AI server.', ]; } + /** + * @return array + */ + 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 */ @@ -2696,13 +2996,101 @@ class Diagnosis extends BaseController 'bmp' => 'image/bmp', 'gif' => 'image/gif', 'pdf' => 'application/pdf', + 'dcm' => 'application/dicom', + 'dicom' => 'application/dicom', ]; $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; } + 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 + */ + 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) { return $this->selectLabInvoicePath((int) $invoiceId, (int) $labType); diff --git a/app/Controllers/Setting/AiSettings.php b/app/Controllers/Setting/AiSettings.php index bfb9fa2..cb1eabb 100644 --- a/app/Controllers/Setting/AiSettings.php +++ b/app/Controllers/Setting/AiSettings.php @@ -12,21 +12,16 @@ class AiSettings extends BaseController 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', [ - 'gemini_key_masked' => $this->maskKey($geminiKey), - 'gemini_key_exists' => $geminiKey !== '', - 'azure_openai_endpoint' => $this->readSettingValue('AZURE_OPENAI_ENDPOINT'), - 'azure_openai_deployment' => $this->readSettingValue('AZURE_OPENAI_DEPLOYMENT'), - 'azure_openai_api_version' => $this->readSettingValue('AZURE_OPENAI_API_VERSION') ?: '2024-10-21', - 'azure_openai_key_masked' => $this->maskKey($azureKey), - 'azure_openai_key_exists' => $azureKey !== '', - 'azure_docintel_endpoint' => $this->readSettingValue('AZURE_DOCINTEL_ENDPOINT'), - 'azure_docintel_key_masked' => $this->maskKey($docIntelKey), - 'azure_docintel_key_exists' => $docIntelKey !== '', + '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') !== '', ]); } @@ -45,13 +40,14 @@ class AiSettings extends BaseController } $fields = [ - 'GEMINI_API_KEY' => trim((string) $this->request->getPost('gemini_api_key')), - 'AZURE_OPENAI_ENDPOINT' => trim((string) $this->request->getPost('azure_openai_endpoint')), - 'AZURE_OPENAI_API_KEY' => trim((string) $this->request->getPost('azure_openai_api_key')), - 'AZURE_OPENAI_DEPLOYMENT' => trim((string) $this->request->getPost('azure_openai_deployment')), - 'AZURE_OPENAI_API_VERSION' => trim((string) $this->request->getPost('azure_openai_api_version')), - 'AZURE_DOCINTEL_ENDPOINT' => trim((string) $this->request->getPost('azure_docintel_endpoint')), - 'AZURE_DOCINTEL_KEY' => trim((string) $this->request->getPost('azure_docintel_key')), + '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; @@ -94,163 +90,193 @@ class AiSettings extends BaseController $provider = strtolower(trim((string) $this->request->getPost('provider'))); if ($provider === '') { - $provider = 'gemini'; + $provider = 'ai-server'; } - if ($provider === 'azure') { - return $this->testAzureOpenAi(); + if ($provider === 'diagnosis-external') { + return $this->testDiagnosisExternalAi(); } - $apiKey = trim((string) $this->request->getPost('gemini_api_key')); - if ($apiKey === '') { - $apiKey = $this->readSettingValue('GEMINI_API_KEY'); + if ($provider === 'ai-server') { + return $this->testAiServer(); } - if ($apiKey === '') { - return $this->response->setJSON(['update' => 0, 'error_text' => 'Gemini API key not configured']); - } - - try { - $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(), - ]); - } + return $this->response->setJSON([ + 'update' => 0, + 'error_text' => 'Unsupported provider', + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); } - private function testAzureOpenAi() + public function usage() { - $endpoint = trim((string) $this->request->getPost('azure_openai_endpoint')); - $apiKey = trim((string) $this->request->getPost('azure_openai_api_key')); - $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 (! $this->request->isAJAX()) { + return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']); } - if ($endpoint === '' || $apiKey === '' || $deployment === '') { - $missing = []; - if ($endpoint === '') { - $missing[] = 'endpoint'; - } - if ($apiKey === '') { - $missing[] = 'key'; - } - if ($deployment === '') { - $missing[] = 'deployment'; - } + 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' => 0, - 'error_text' => 'Azure OpenAI settings incomplete: missing ' . implode(', ', $missing), + '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(), ]); } - $endpoint = rtrim($endpoint, '/'); - $url = $endpoint . '/openai/deployments/' . rawurlencode($deployment) . '/chat/completions?api-version=' . rawurlencode($apiVersion); + $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($url, [ - 'headers' => [ - 'api-key' => $apiKey, - 'Content-Type' => 'application/json', - ], + $response = $client->post(rtrim($endpoint, '/'), [ + 'headers' => $headers, 'json' => [ - 'messages' => [[ - 'role' => 'user', - 'content' => 'Reply with exactly: CONNECTED', - ]], - 'temperature' => 0, - 'max_tokens' => 20, + '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' => '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), 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), @@ -259,14 +285,76 @@ class AiSettings extends BaseController return $this->response->setJSON([ 'update' => 1, - 'error_text' => 'Azure OpenAI connection successful', + 'error_text' => 'Diagnosis AI endpoint connection successful', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), ]); } catch (\Throwable $e) { return $this->response->setJSON([ '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(), 'csrfHash' => csrf_hash(), ]); @@ -290,57 +378,6 @@ class AiSettings extends BaseController 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 - */ - private function geminiModelCandidates(): array - { - return [ - 'gemini-2.0-flash', - 'gemini-1.5-flash', - 'gemini-1.5-flash-latest', - ]; - } - private function canManageAiSettings(): bool { if (! function_exists('auth') || ! auth()->loggedIn()) { @@ -350,6 +387,19 @@ class AiSettings extends BaseController 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)) { diff --git a/app/Views/Setting/Admin/ai_settings.php b/app/Views/Setting/Admin/ai_settings.php index 4ec835a..85bbddd 100644 --- a/app/Views/Setting/Admin/ai_settings.php +++ b/app/Views/Setting/Admin/ai_settings.php @@ -5,84 +5,88 @@
-
Gemini (optional fallback)
- +
AI Server (Primary - Python FastAPI)
- - -
- - Current key: - - No API key configured yet. - -
+ + +
Used for imaging diagnosis endpoint: /diagnosis
+
+
+ + +
If not configured, pathology AI extraction OCR will be unavailable.
-
-
How to get Gemini API key
-
    -
  1. - Open Google AI Studio API Keys - . -
  2. -
  3. Sign in and click Create API key.
  4. -
  5. Copy the key and paste it in this field.
  6. -
  7. Click Save Key, then Test Connection.
  8. -
-
If test fails, check internet access from server, remove extra spaces/newline in key, or regenerate key.
+
+ + +
+ Auto-detection is active: the server automatically selects the correct prompt structure for each study type — + Chest X-ray, Abdomen, Spine, PNS/Sinuses, Skull, Pelvis, Extremities/Joints, Contrast studies, Mammography, MRI, CT, Ultrasound.
+ Fill this field only to override with a custom global prompt for all studies. Use {study_name} as placeholder for current study name. Leave blank to keep auto-detection. +

-
Azure OpenAI (Primary)
+
Diagnosis AI Parse Endpoint
- - + + +
Used by pathology value extraction flow.
+
- - + +
- - Current key: + + Current token: - No Azure OpenAI key configured yet. + No token configured yet.
+
- - + +
- - + +
-
-
OCR (Azure Document Intelligence)
-
- - -
-
- - -
- - Current key: - - No Document Intelligence key configured yet. - +
+
+ + +
Used for warning levels at 70% and 90%.
+
+ +
+
+ +
+
+ AI Usage Guardrail (Today) + Unknown +
+
+
Gemini Calls
-
+
Fallback Calls
-
+
Total Calls
-
+
Last Hour
-
+
+
Press Refresh Usage to load latest usage.
- - + +
@@ -129,99 +133,105 @@ } $('#btn_save_ai_key').on('click', function() { - var key = ($('#gemini_api_key').val() || '').trim(); - 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(); - var docintelEndpoint = ($('#azure_docintel_endpoint').val() || '').trim(); - var docintelKey = ($('#azure_docintel_key').val() || '').trim(); + var aiServerUrl = ($('#diagnosis_ai_server_url').val() || '').trim(); + var aiOcrEndpoint = ($('#diagnosis_ai_ocr_endpoint').val() || '').trim(); + var diagnosisEndpoint = ($('#diagnosis_ai_parse_endpoint').val() || '').trim(); + var diagnosisImagingPrompt = ($('#diagnosis_ai_imaging_prompt').val() || '').trim(); + var diagnosisToken = ($('#diagnosis_ai_parse_token').val() || '').trim(); + var diagnosisTimeout = ($('#diagnosis_ai_timeout_seconds').val() || '').trim(); + var diagnosisRetry = ($('#diagnosis_ai_retry_attempts').val() || '').trim(); postJson('', { - gemini_api_key: key, - azure_openai_endpoint: azureEndpoint, - azure_openai_api_key: azureKey, - azure_openai_deployment: azureDeployment, - azure_openai_api_version: azureApiVersion, - azure_docintel_endpoint: docintelEndpoint, - azure_docintel_key: docintelKey + diagnosis_ai_server_url: aiServerUrl, + diagnosis_ai_ocr_endpoint: aiOcrEndpoint, + diagnosis_ai_parse_endpoint: diagnosisEndpoint, + diagnosis_ai_imaging_prompt: diagnosisImagingPrompt, + diagnosis_ai_parse_token: diagnosisToken, + diagnosis_ai_timeout_seconds: diagnosisTimeout, + diagnosis_ai_retry_attempts: diagnosisRetry, + diagnosis_ai_daily_limit: ($('#diagnosis_ai_daily_limit').val() || '').trim() }, function(data) { 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; } showMessage('success', data.error_text || 'Settings saved'); - $('#gemini_api_key').val(''); - $('#azure_openai_api_key').val(''); - $('#azure_docintel_key').val(''); + $('#diagnosis_ai_parse_token').val(''); + refreshUsage(); }); }); - $('#btn_test_ai_key').on('click', function() { - var key = ($('#gemini_api_key').val() || '').trim(); - postJson('', { - 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(); + $('#btn_test_ai_server').on('click', function() { + var aiServerUrl = ($('#diagnosis_ai_server_url').val() || '').trim(); + var diagnosisToken = ($('#diagnosis_ai_parse_token').val() || '').trim(); postJson('', { - provider: 'azure', - azure_openai_endpoint: azureEndpoint, - azure_openai_api_key: azureKey, - azure_openai_deployment: azureDeployment, - azure_openai_api_version: azureApiVersion + provider: 'ai-server', + diagnosis_ai_server_url: aiServerUrl, + diagnosis_ai_parse_token: diagnosisToken }, function(data) { if ((data.update || 0) !== 1) { - showMessage('error', data.error_text || 'Azure test failed'); + showMessage('error', data.error_text || 'AI Server test failed'); 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() { - var url = $(this).data('url') || ''; - if (!url) { - showMessage('error', 'Unable to copy link'); - return; - } + $('#btn_test_diagnosis_ai').on('click', function() { + var diagnosisEndpoint = ($('#diagnosis_ai_parse_endpoint').val() || '').trim(); + var diagnosisToken = ($('#diagnosis_ai_parse_token').val() || '').trim(); - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(url).then(function() { - showMessage('success', 'Gemini link copied'); - }).catch(function() { - showMessage('error', 'Copy failed. Please copy manually'); - }); - return; - } - - var temp = document.createElement('input'); - 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); + postJson('', { + provider: 'diagnosis-external', + diagnosis_ai_parse_endpoint: diagnosisEndpoint, + diagnosis_ai_parse_token: diagnosisToken + }, function(data) { + if ((data.update || 0) !== 1) { + showMessage('error', data.error_text || 'Parse endpoint test failed'); + return; + } + showMessage('success', data.error_text || 'Parse endpoint connection successful'); + }); }); + + function refreshUsage() { + postJson('', {}, 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(); })(); diff --git a/app/Views/diagnosis/imaging_ai_result_modal.php b/app/Views/diagnosis/imaging_ai_result_modal.php index b99984f..65b6f17 100644 --- a/app/Views/diagnosis/imaging_ai_result_modal.php +++ b/app/Views/diagnosis/imaging_ai_result_modal.php @@ -9,6 +9,23 @@ $provider = trim((string) ($provider ?? 'azure-openai')); $model = trim((string) ($model ?? '')); $findingsInputId = 'ai_findings_' . $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); ?>
@@ -17,7 +34,13 @@ $impressionInputId = 'ai_impression_' . $batchId;
Study:
-
Stored batch # |
+
+ Stored batch # + + + + +
+ +
+ Fallback mode used for this draft. Review carefully before sign-off. +
+ +
diff --git a/app/Views/diagnosis/imaging_upload_gallery.php b/app/Views/diagnosis/imaging_upload_gallery.php index 5d6013c..e2c38ac 100644 --- a/app/Views/diagnosis/imaging_upload_gallery.php +++ b/app/Views/diagnosis/imaging_upload_gallery.php @@ -6,7 +6,7 @@ $studyName = trim((string) ($study_name ?? ''));
-
Uploaded Images
+
Uploaded Imaging Files
Study:
@@ -16,7 +16,7 @@ $studyName = trim((string) ($study_name ?? ''));
-
No uploaded images or PDFs found for this study.
+
No uploaded imaging files found for this study.
@@ -41,11 +41,26 @@ $studyName = trim((string) ($study_name ?? '')); Uploaded image + +
+ DICOM preview +
+ DICOM Preview + + Open DICOM + +
+
+ +
+
+ Open File +
Preview unavailable
diff --git a/app/Views/diagnosis/pathology_detail.php b/app/Views/diagnosis/pathology_detail.php index 97e67c1..50ddbff 100644 --- a/app/Views/diagnosis/pathology_detail.php +++ b/app/Views/diagnosis/pathology_detail.php @@ -537,6 +537,9 @@ function initializeReportEditor() { return; } + // Re-enable notification plugins (may be disabled globally in welcome_message.php) + CKEDITOR.config.removePlugins = ''; + if (CKEDITOR.instances.HTMLShow) { CKEDITOR.instances.HTMLShow.destroy(true); } @@ -902,7 +905,7 @@ function diagnosisUploadForReq(reqId, testName, mode) { const input = document.createElement('input'); input.type = 'file'; - input.accept = 'image/*,application/pdf'; + input.accept = 'image/*,application/pdf,.dcm,application/dicom,application/dicom+json'; if ((mode || '').toLowerCase() === 'camera') { input.accept = 'image/*'; input.setAttribute('capture', 'environment'); @@ -976,6 +979,8 @@ function getEditorImagingContext() { }; } +window._imagingSupportModalInstance = window._imagingSupportModalInstance || null; + function openImagingSupportModal(title, html) { const titleEl = document.getElementById('imagingSupportLabel'); const bodyEl = document.getElementById('imagingSupportBody'); @@ -986,8 +991,11 @@ function openImagingSupportModal(title, html) { bodyEl.innerHTML = html || '
No content found.
'; } - const modal = new bootstrap.Modal(document.getElementById('imagingSupportModal')); - modal.show(); + const el = document.getElementById('imagingSupportModal'); + if (el) { + window._imagingSupportModalInstance = bootstrap.Modal.getOrCreateInstance(el); + window._imagingSupportModalInstance.show(); + } } function showImagingUploads(reqId, testName) { @@ -1094,9 +1102,8 @@ function applyAiDiagnosisDraftToEditor(button, autoSave) { } const modalEl = document.getElementById('imagingSupportModal'); - const modal = modalEl ? bootstrap.Modal.getInstance(modalEl) : null; - if (modal) { - modal.hide(); + if (window._imagingSupportModalInstance) { + window._imagingSupportModalInstance.hide(); } if (autoSave === true) {