You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
171 lines
5.6 KiB
171 lines
5.6 KiB
<?php |
|
|
|
namespace App\Services; |
|
|
|
use Illuminate\Support\Facades\Http; |
|
use Throwable; |
|
|
|
class AiChatService |
|
{ |
|
public function __construct( |
|
private readonly OllamaService $ollama |
|
) { |
|
} |
|
|
|
public function isAvailable(): bool |
|
{ |
|
return $this->ollama->isConfigured(); |
|
} |
|
|
|
/** |
|
* @param array<int, array{role: string, content: string}> $history |
|
* @return array{answer: string, chunks: array<int, array<string, mixed>>, meta: array<string, mixed>} |
|
*/ |
|
public function chat( |
|
string $query, |
|
string $username, |
|
string $displayName, |
|
string $jobTitle = '', |
|
array $history = [], |
|
int $contextLimit = 5 |
|
): array { |
|
$chunks = $this->retrieveContext($query, $contextLimit); |
|
$answer = $this->generate($query, $username, $displayName, $jobTitle, $chunks, $history); |
|
|
|
return [ |
|
'answer' => $answer, |
|
'chunks' => $chunks, |
|
'meta' => [ |
|
'query' => $query, |
|
'context_count' => count($chunks), |
|
'model' => config('ollama.chat_model'), |
|
], |
|
]; |
|
} |
|
|
|
/** |
|
* Embed query → similarity search di pgvector code_chunks. |
|
* |
|
* @return array<int, array<string, mixed>> |
|
*/ |
|
private function retrieveContext(string $query, int $limit): array |
|
{ |
|
$baseUrl = rtrim((string) config('ollama.base_url'), '/'); |
|
$embedModel = (string) config('ollama.embed_model', 'nomic-embed-text'); |
|
|
|
try { |
|
$resp = Http::timeout(30) |
|
->post($baseUrl.'/api/embeddings', [ |
|
'model' => $embedModel, |
|
'prompt' => $query, |
|
]); |
|
|
|
if (! $resp->successful()) { |
|
return []; |
|
} |
|
|
|
$embedding = $resp->json('embedding'); |
|
if (! is_array($embedding) || count($embedding) !== 768) { |
|
return []; |
|
} |
|
|
|
$pgHost = (string) config('pgvector.host', '10.100.1.24'); |
|
$pgPort = (int) config('pgvector.port', 5433); |
|
$pgDb = (string) config('pgvector.database', 'geonet_project_search'); |
|
$pgUser = (string) config('pgvector.username', 'geonet_ai'); |
|
$pgPass = (string) config('pgvector.password', ''); |
|
|
|
$dsn = "pgsql:host={$pgHost};port={$pgPort};dbname={$pgDb}"; |
|
$pdo = new \PDO($dsn, $pgUser, $pgPass, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); |
|
|
|
$vectorStr = '['.implode(',', $embedding).']'; |
|
$sql = " |
|
SELECT file_path, start_line, end_line, language, repo, |
|
content, |
|
1 - (embedding <=> :vec::vector) AS similarity |
|
FROM code_chunks |
|
ORDER BY embedding <=> :vec2::vector |
|
LIMIT :limit |
|
"; |
|
|
|
$stmt = $pdo->prepare($sql); |
|
$stmt->execute([':vec' => $vectorStr, ':vec2' => $vectorStr, ':limit' => $limit]); |
|
|
|
return array_map(fn ($row) => [ |
|
'repo' => $row['repo'], |
|
'file_path' => $row['file_path'], |
|
'start_line' => (int) $row['start_line'], |
|
'end_line' => (int) $row['end_line'], |
|
'language' => $row['language'], |
|
'content' => $row['content'], |
|
'similarity' => round((float) $row['similarity'], 4), |
|
], $stmt->fetchAll(\PDO::FETCH_ASSOC)); |
|
|
|
} catch (Throwable) { |
|
return []; |
|
} |
|
} |
|
|
|
/** |
|
* @param array<int, array<string, mixed>> $chunks |
|
* @param array<int, array{role: string, content: string}> $history |
|
*/ |
|
private function generate( |
|
string $query, |
|
string $username, |
|
string $displayName, |
|
string $jobTitle, |
|
array $chunks, |
|
array $history |
|
): string { |
|
$contextBlock = ''; |
|
if ($chunks !== []) { |
|
$contextBlock = "\n\n### Konteks dari Codebase:\n"; |
|
foreach ($chunks as $i => $chunk) { |
|
$contextBlock .= sprintf( |
|
"\n[%d] %s:%d-%d\n```%s\n%s\n```\n", |
|
$i + 1, |
|
$chunk['file_path'], |
|
$chunk['start_line'], |
|
$chunk['end_line'], |
|
$chunk['language'] ?? '', |
|
trim($chunk['content']) |
|
); |
|
} |
|
} |
|
|
|
$identity = "Nama: {$displayName}"; |
|
if ($jobTitle !== '') { |
|
$identity .= ", Jabatan: {$jobTitle}"; |
|
} |
|
|
|
$system = <<<SYS |
|
Anda adalah AI assistant on-premise untuk tim IT GIS Portal (Geonet). |
|
Jawab dalam Bahasa Indonesia yang formal dan ringkas. |
|
User yang sedang chat: {$identity} (username: {$username}). |
|
Gunakan konteks codebase yang disediakan bila relevan. |
|
Jangan mengarang path file atau nama fungsi yang tidak ada di konteks. |
|
Bila konteks tidak relevan, jawab berdasarkan pengetahuan umum. |
|
SYS; |
|
|
|
$messages = [['role' => 'system', 'content' => $system]]; |
|
|
|
foreach (array_slice($history, -10) as $item) { |
|
$role = $item['role'] ?? ''; |
|
$content = $item['content'] ?? ''; |
|
if (! in_array($role, ['user', 'assistant'], true) || $content === '') { |
|
continue; |
|
} |
|
$messages[] = ['role' => $role, 'content' => $content]; |
|
} |
|
|
|
$userContent = $query.$contextBlock; |
|
$messages[] = ['role' => 'user', 'content' => $userContent]; |
|
|
|
try { |
|
return $this->ollama->chat($messages); |
|
} catch (Throwable $e) { |
|
return 'Maaf, AI sedang tidak tersedia: '.$e->getMessage(); |
|
} |
|
} |
|
}
|
|
|