baseUrl = config('ollama.base_url'); $this->chatModel = config('ollama.chat_model'); $this->embeddingModel = config('ollama.embedding_model'); $this->timeouts = config('ollama.timeout'); $this->retryConfig = config('ollama.retry'); $this->chatParams = config('ollama.chat'); } /** * Jana embedding vector untuk satu teks. * * @param string $text Teks yang akan di-embed * @return float[] Array vector embedding * @throws RuntimeException Jika Ollama tidak boleh dihubungi */ public function embed(string $text): array { $text = $this->sanitizeText($text); if (empty(trim($text))) { throw new RuntimeException('Teks untuk embedding tidak boleh kosong.'); } try { $response = Http::timeout($this->timeouts['embed']) ->retry($this->retryConfig['times'], $this->retryConfig['sleep']) ->post("{$this->baseUrl}/api/embed", [ 'model' => $this->embeddingModel, 'input' => $text, ]); $response->throw(); $data = $response->json(); // Qdrant REST API format: {"embeddings": [[...]]} if (isset($data['embeddings'][0])) { return $data['embeddings'][0]; } // Format lama Ollama: {"embedding": [...]} if (isset($data['embedding'])) { return $data['embedding']; } throw new RuntimeException( 'Format response embedding tidak dijangka: ' . json_encode(array_keys($data)) ); } catch (ConnectionException $e) { Log::error('Ollama tidak boleh dihubungi (embed)', [ 'url' => $this->baseUrl, 'error' => $e->getMessage(), ]); throw new RuntimeException( 'Ollama tidak boleh dihubungi. Pastikan Ollama sedang berjalan.', 0, $e ); } catch (RequestException $e) { Log::error('Ollama embed request gagal', [ 'status' => $e->response->status(), 'body' => $e->response->body(), ]); throw new RuntimeException( 'Embedding gagal: ' . $e->response->body(), 0, $e ); } } /** * Jana embedding untuk banyak teks dalam batch. * Lebih efisien berbanding panggil embed() satu per satu. * * @param string[] $texts * @return array */ public function embedBatch(array $texts): array { $results = []; foreach ($texts as $index => $text) { try { $results[$index] = $this->embed($text); } catch (RuntimeException $e) { Log::warning("Embedding batch gagal untuk index {$index}", [ 'error' => $e->getMessage(), ]); throw $e; } } return $results; } /** * Hantar pertanyaan ke Ollama dengan context RAG. * * @param string $question Soalan pengguna * @param string $context Context dari Qdrant (chunk-chunk relevan) * @param ?string $systemPrompt Override system prompt (optional) * @return array{answer: string, model: string, tokens: int|null} * @throws RuntimeException */ public function chat( string $question, string $context, ?string $systemPrompt = null ): array { $systemPrompt ??= config('ollama.rag_system_prompt'); $userMessage = $this->buildRagUserMessage($question, $context); try { $response = Http::timeout($this->timeouts['chat']) ->retry($this->retryConfig['times'], $this->retryConfig['sleep']) ->post("{$this->baseUrl}/api/chat", [ 'model' => $this->chatModel, 'stream' => false, 'options' => [ 'temperature' => $this->chatParams['temperature'], 'top_p' => $this->chatParams['top_p'], 'num_ctx' => $this->chatParams['num_ctx'], ], 'messages' => [ [ 'role' => 'system', 'content' => $systemPrompt, ], [ 'role' => 'user', 'content' => $userMessage, ], ], ]); $response->throw(); $data = $response->json(); $answer = $data['message']['content'] ?? $data['response'] ?? ''; if (empty(trim($answer))) { Log::warning('Ollama mengembalikan jawapan kosong', [ 'question' => substr($question, 0, 100), ]); } return [ 'answer' => trim($answer), 'model' => $data['model'] ?? $this->chatModel, 'tokens' => $data['eval_count'] ?? null, ]; } catch (ConnectionException $e) { Log::error('Ollama tidak boleh dihubungi (chat)', [ 'error' => $e->getMessage(), ]); throw new RuntimeException( 'Perkhidmatan AI tidak tersedia pada masa ini.', 0, $e ); } catch (RequestException $e) { Log::error('Ollama chat request gagal', [ 'status' => $e->response->status(), 'body' => $e->response->body(), ]); throw new RuntimeException( 'Permintaan ke model AI gagal.', 0, $e ); } } /** * Semak sama ada Ollama sedang berjalan dan model tersedia. * * @return array{online: bool, chat_model: bool, embed_model: bool, error: ?string} */ public function healthCheck(): array { $result = [ 'online' => false, 'chat_model' => false, 'embed_model' => false, 'error' => null, ]; try { $response = Http::timeout($this->timeouts['connect']) ->get("{$this->baseUrl}/api/tags"); if (!$response->ok()) { $result['error'] = 'Ollama tidak responsif'; return $result; } $result['online'] = true; $models = collect($response->json('models', [])) ->pluck('name') ->map(fn($m) => explode(':', $m)[0]) ->unique() ->toArray(); $chatModelBase = explode(':', $this->chatModel)[0]; $embedModelBase = explode(':', $this->embeddingModel)[0]; $result['chat_model'] = in_array($chatModelBase, $models); $result['embed_model'] = in_array($embedModelBase, $models); } catch (ConnectionException $e) { $result['error'] = 'Tidak dapat sambung ke Ollama: ' . $e->getMessage(); } return $result; } /** * Bina mesej user untuk RAG dengan context yang terformat. * Teks dari dokumen dibersih untuk elak prompt injection. */ private function buildRagUserMessage(string $question, string $context): string { return "Konteks Rujukan:\n" . "================\n" . $context . "\n" . "================\n\n" . "Soalan: " . $question; } /** * Sanitize teks sebelum dihantar ke Ollama. * Elak prompt injection dari kandungan dokumen. */ private function sanitizeText(string $text): string { // Hadkan panjang $text = mb_substr($text, 0, 8000); // Buang null bytes dan karakter kawalan berbahaya $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text); return $text; } }