GeoNetAgent, LDAPWeb, server-audit, server-connection
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

<?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();
}
}
}