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/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');

View File

@ -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<int, array<string, mixed>> $visionImages
* @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'), '/');
$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 = '<p><strong>Detected labels:</strong> ' . esc(implode(', ', $safeLabels)) . '</p>';
}
}
$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 [
'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
{
$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<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>
*/
@ -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<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)
{
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');
}
$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<int, string>
*/
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)) {

View File

@ -5,84 +5,88 @@
<div class="card-body">
<?= csrf_field() ?>
<h6 class="mb-2">Gemini (optional fallback)</h6>
<h6 class="mb-2">AI Server (Primary - Python FastAPI)</h6>
<div class="mb-3">
<label class="form-label">Gemini API Key</label>
<input type="password" class="form-control" id="gemini_api_key" placeholder="Paste Gemini API key">
<div class="form-text">
<?php if (!empty($gemini_key_exists)) : ?>
Current key: <?= esc($gemini_key_masked ?? '') ?>
<?php else : ?>
No API key configured yet.
<?php endif; ?>
</div>
<label class="form-label">AI Server Base URL</label>
<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">Used for imaging diagnosis endpoint: /diagnosis</div>
</div>
<div class="mb-3">
<label class="form-label">AI OCR Endpoint (optional, for pathology file OCR)</label>
<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 ?? '') ?>">
<div class="form-text">If not configured, pathology AI extraction OCR will be unavailable.</div>
</div>
<div class="alert alert-info">
<h6 class="mb-2">How to get Gemini API key</h6>
<ol class="mb-2 ps-3">
<li>
Open <a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noopener">Google AI Studio API Keys</a>
<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>.
</li>
<li>Sign in and click <strong>Create API key</strong>.</li>
<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 class="mb-3">
<label class="form-label">Imaging Report Prompt Template <span class="badge bg-secondary">Optional Override</span></label>
<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>
<div class="form-text">
<strong>Auto-detection is active:</strong> 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.<br>
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.
</div>
</div>
<hr>
<h6 class="mb-2">Azure OpenAI (Primary)</h6>
<h6 class="mb-2">Diagnosis AI Parse Endpoint</h6>
<div class="mb-3">
<label class="form-label">Azure OpenAI Endpoint</label>
<input type="text" class="form-control" id="azure_openai_endpoint" placeholder="https://your-resource.openai.azure.com" value="<?= esc($azure_openai_endpoint ?? '') ?>">
<label class="form-label">Diagnosis Parse Endpoint</label>
<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 class="mb-3">
<label class="form-label">Azure OpenAI API Key</label>
<input type="password" class="form-control" id="azure_openai_api_key" placeholder="Paste Azure OpenAI key">
<label class="form-label">Diagnosis Parse Token (optional)</label>
<input type="password" class="form-control" id="diagnosis_ai_parse_token" placeholder="Bearer token">
<div class="form-text">
<?php if (!empty($azure_openai_key_exists)) : ?>
Current key: <?= esc($azure_openai_key_masked ?? '') ?>
<?php if (!empty($diagnosis_ai_token_exists)) : ?>
Current token: <?= esc($diagnosis_ai_token_masked ?? '') ?>
<?php else : ?>
No Azure OpenAI key configured yet.
No token configured yet.
<?php endif; ?>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Deployment Name</label>
<input type="text" class="form-control" id="azure_openai_deployment" placeholder="hms-opd-draft" value="<?= esc($azure_openai_deployment ?? '') ?>">
<label class="form-label">Diagnosis AI Timeout (seconds)</label>
<input type="number" min="5" max="180" class="form-control" id="diagnosis_ai_timeout_seconds" value="<?= esc($diagnosis_ai_timeout_seconds ?? '45') ?>">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">API Version</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') ?>">
<label class="form-label">Diagnosis AI Retry Attempts</label>
<input type="number" min="1" max="5" class="form-control" id="diagnosis_ai_retry_attempts" value="<?= esc($diagnosis_ai_retry_attempts ?? '2') ?>">
</div>
</div>
<hr>
<h6 class="mb-2">OCR (Azure Document Intelligence)</h6>
<div class="mb-3">
<label class="form-label">Document Intelligence Endpoint</label>
<input type="text" class="form-control" id="azure_docintel_endpoint" placeholder="https://your-docintel.cognitiveservices.azure.com" value="<?= esc($azure_docintel_endpoint ?? '') ?>">
</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 class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Gemini Daily Limit (guardrail)</label>
<input type="number" min="1" max="10000" class="form-control" id="diagnosis_ai_daily_limit" value="<?= esc($diagnosis_ai_daily_limit ?? '20') ?>">
<div class="form-text">Used for warning levels at 70% and 90%.</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 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-outline-success" id="btn_test_ai_key">Test Gemini</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-dark" id="btn_test_ai_server">Test AI Server</button>
<button type="button" class="btn btn-outline-secondary" id="btn_test_diagnosis_ai">Test Parse Endpoint</button>
</div>
<div id="ai_settings_msg" class="mt-3"></div>
@ -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('<?= base_url('setting/admin/ai-settings/save') ?>', {
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('<?= base_url('setting/admin/ai-settings/test') ?>', {
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('<?= base_url('setting/admin/ai-settings/test') ?>', {
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('<?= base_url('setting/admin/ai-settings/test') ?>', {
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('<?= 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>

View File

@ -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);
?>
<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 !== ''): ?>
<div class="small text-muted">Study: <?= esc($studyName) ?></div>
<?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 class="d-flex gap-2">
<button
@ -43,6 +66,12 @@ $impressionInputId = 'ai_impression_' . $batchId;
<div class="alert alert-info py-2"><?= esc($summaryText) ?></div>
<?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="col-lg-7">
<div class="card h-100">

View File

@ -6,7 +6,7 @@ $studyName = trim((string) ($study_name ?? ''));
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center gap-2 flex-wrap">
<div>
<h6 class="mb-1">Uploaded Images</h6>
<h6 class="mb-1">Uploaded Imaging Files</h6>
<?php if ($studyName !== ''): ?>
<div class="small text-muted">Study: <?= esc($studyName) ?></div>
<?php endif; ?>
@ -16,7 +16,7 @@ $studyName = trim((string) ($study_name ?? ''));
</div>
<?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: ?>
<div class="row g-3">
<?php foreach ($fileRows as $row): ?>
@ -41,11 +41,26 @@ $studyName = trim((string) ($study_name ?? ''));
<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;">
</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'])): ?>
<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>
<a href="<?= esc((string) $row['url']) ?>" target="_blank" rel="noopener">Open PDF</a>
</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: ?>
<div class="border rounded p-3 text-muted bg-light">Preview unavailable</div>
<?php endif; ?>

View File

@ -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 || '<div class="alert alert-warning mb-0">No content found.</div>';
}
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) {