feat(imaging-ai): add DICOM preview and AI diagnosis support
This commit is contained in:
parent
01156a1ee2
commit
68dfbaf006
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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; ?>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user