437 lines
15 KiB
PHP
437 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Controllers\Setting;
|
|
|
|
use App\Controllers\BaseController;
|
|
|
|
class AiSettings extends BaseController
|
|
{
|
|
public function index()
|
|
{
|
|
if (! $this->canManageAiSettings()) {
|
|
return $this->response->setStatusCode(403)->setBody('Access denied');
|
|
}
|
|
|
|
$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 !== '',
|
|
]);
|
|
}
|
|
|
|
public function save()
|
|
{
|
|
if (! $this->request->isAJAX()) {
|
|
return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
|
|
}
|
|
|
|
if (! $this->canManageAiSettings()) {
|
|
return $this->response->setStatusCode(403)->setJSON(['update' => 0, 'error_text' => 'Access denied']);
|
|
}
|
|
|
|
if (! $this->db->tableExists('hospital_setting')) {
|
|
return $this->response->setJSON(['update' => 0, 'error_text' => 'hospital_setting table not found']);
|
|
}
|
|
|
|
$fields = [
|
|
'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')),
|
|
];
|
|
|
|
$savedCount = 0;
|
|
foreach ($fields as $name => $value) {
|
|
if ($value === '') {
|
|
continue;
|
|
}
|
|
|
|
if ($this->upsertSettingValue($name, $value)) {
|
|
$savedCount++;
|
|
}
|
|
}
|
|
|
|
if ($savedCount === 0) {
|
|
return $this->response->setJSON([
|
|
'update' => 0,
|
|
'error_text' => 'Provide at least one setting value to save',
|
|
'csrfName' => csrf_token(),
|
|
'csrfHash' => csrf_hash(),
|
|
]);
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'update' => 1,
|
|
'error_text' => 'Saved ' . $savedCount . ' setting(s)',
|
|
'csrfName' => csrf_token(),
|
|
'csrfHash' => csrf_hash(),
|
|
]);
|
|
}
|
|
|
|
public function test()
|
|
{
|
|
if (! $this->request->isAJAX()) {
|
|
return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']);
|
|
}
|
|
|
|
if (! $this->canManageAiSettings()) {
|
|
return $this->response->setStatusCode(403)->setJSON(['update' => 0, 'error_text' => 'Access denied']);
|
|
}
|
|
|
|
$provider = strtolower(trim((string) $this->request->getPost('provider')));
|
|
if ($provider === '') {
|
|
$provider = 'gemini';
|
|
}
|
|
|
|
if ($provider === 'azure') {
|
|
return $this->testAzureOpenAi();
|
|
}
|
|
|
|
$apiKey = trim((string) $this->request->getPost('gemini_api_key'));
|
|
if ($apiKey === '') {
|
|
$apiKey = $this->readSettingValue('GEMINI_API_KEY');
|
|
}
|
|
|
|
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(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
private function testAzureOpenAi()
|
|
{
|
|
$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 ($endpoint === '' || $apiKey === '' || $deployment === '') {
|
|
$missing = [];
|
|
if ($endpoint === '') {
|
|
$missing[] = 'endpoint';
|
|
}
|
|
if ($apiKey === '') {
|
|
$missing[] = 'key';
|
|
}
|
|
if ($deployment === '') {
|
|
$missing[] = 'deployment';
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'update' => 0,
|
|
'error_text' => 'Azure OpenAI settings incomplete: missing ' . implode(', ', $missing),
|
|
'csrfName' => csrf_token(),
|
|
'csrfHash' => csrf_hash(),
|
|
]);
|
|
}
|
|
|
|
$endpoint = rtrim($endpoint, '/');
|
|
$url = $endpoint . '/openai/deployments/' . rawurlencode($deployment) . '/chat/completions?api-version=' . rawurlencode($apiVersion);
|
|
|
|
try {
|
|
$client = service('curlrequest', $this->geminiHttpOptions());
|
|
$response = $client->post($url, [
|
|
'headers' => [
|
|
'api-key' => $apiKey,
|
|
'Content-Type' => 'application/json',
|
|
],
|
|
'json' => [
|
|
'messages' => [[
|
|
'role' => 'user',
|
|
'content' => 'Reply with exactly: CONNECTED',
|
|
]],
|
|
'temperature' => 0,
|
|
'max_tokens' => 20,
|
|
],
|
|
]);
|
|
|
|
$status = $response->getStatusCode();
|
|
$body = (string) $response->getBody();
|
|
|
|
if ($status < 200 || $status >= 300) {
|
|
return $this->response->setJSON([
|
|
'update' => 0,
|
|
'error_text' => 'Azure OpenAI test failed (HTTP ' . $status . ')',
|
|
'detail' => mb_substr($body, 0, 300),
|
|
'csrfName' => csrf_token(),
|
|
'csrfHash' => csrf_hash(),
|
|
]);
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'update' => 1,
|
|
'error_text' => 'Azure OpenAI 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(),
|
|
'csrfName' => csrf_token(),
|
|
'csrfHash' => csrf_hash(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function geminiHttpOptions(): array
|
|
{
|
|
$options = [
|
|
'timeout' => 20,
|
|
'http_errors' => false,
|
|
];
|
|
|
|
if (defined('ENVIRONMENT') && in_array((string) ENVIRONMENT, ['development', 'testing'], true)) {
|
|
$options['verify'] = false;
|
|
}
|
|
|
|
return $options;
|
|
}
|
|
|
|
private function 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()) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function readSettingValue(string $name): string
|
|
{
|
|
if (defined($name)) {
|
|
$definedValue = trim((string) constant($name));
|
|
if ($definedValue !== '') {
|
|
return $definedValue;
|
|
}
|
|
}
|
|
|
|
if (! $this->db->tableExists('hospital_setting')) {
|
|
return '';
|
|
}
|
|
|
|
$row = $this->db->table('hospital_setting')
|
|
->select('s_value')
|
|
->where('s_name', $name)
|
|
->get(1)
|
|
->getRowArray();
|
|
|
|
$value = trim((string) ($row['s_value'] ?? ''));
|
|
if ($value !== '') {
|
|
return $value;
|
|
}
|
|
|
|
$rows = $this->db->table('hospital_setting')
|
|
->select('s_name, s_value')
|
|
->get()
|
|
->getResultArray();
|
|
|
|
$target = strtoupper(trim($name));
|
|
foreach ($rows as $item) {
|
|
$itemName = strtoupper(trim((string) ($item['s_name'] ?? '')));
|
|
if ($itemName === $target) {
|
|
$itemValue = trim((string) ($item['s_value'] ?? ''));
|
|
if ($itemValue !== '') {
|
|
return $itemValue;
|
|
}
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
private function upsertSettingValue(string $name, string $value): bool
|
|
{
|
|
$existing = $this->db->table('hospital_setting')
|
|
->select('id, s_value')
|
|
->where('s_name', $name)
|
|
->get(1)
|
|
->getRowArray();
|
|
|
|
$oldValue = trim((string) ($existing['s_value'] ?? ''));
|
|
if ($existing) {
|
|
$ok = $this->db->table('hospital_setting')
|
|
->where('id', (int) ($existing['id'] ?? 0))
|
|
->update(['s_value' => $value]);
|
|
} else {
|
|
$ok = $this->db->table('hospital_setting')->insert([
|
|
's_name' => $name,
|
|
's_value' => $value,
|
|
]);
|
|
}
|
|
|
|
if ($ok) {
|
|
$this->auditClinicalUpdate('hospital_setting', $name, $name, $oldValue, $value);
|
|
}
|
|
|
|
return (bool) $ok;
|
|
}
|
|
|
|
private function maskKey(string $key): string
|
|
{
|
|
$key = trim($key);
|
|
if ($key === '') {
|
|
return '';
|
|
}
|
|
|
|
if (mb_strlen($key) <= 8) {
|
|
return str_repeat('*', mb_strlen($key));
|
|
}
|
|
|
|
return mb_substr($key, 0, 4) . str_repeat('*', mb_strlen($key) - 8) . mb_substr($key, -4);
|
|
}
|
|
}
|