ollama->isConfigured(); } /** * @param array $history * @return array{answer: string, chunks: array>, meta: array} */ 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> */ 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> $chunks * @param array $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 = << '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(); } } }