279 lines
8.8 KiB
PHP
279 lines
8.8 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Ollama;
|
|
|
|
use Illuminate\Http\Client\ConnectionException;
|
|
use Illuminate\Http\Client\RequestException;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* OllamaService
|
|
*
|
|
* Wrapper untuk semua komunikasi dengan Ollama API.
|
|
* Menguruskan: chat completion, embedding generation,
|
|
* timeout, retry, dan error handling.
|
|
*
|
|
* Semua konfigurasi diambil dari config/ollama.php
|
|
*/
|
|
class OllamaService
|
|
{
|
|
private string $baseUrl;
|
|
private string $chatModel;
|
|
private string $embeddingModel;
|
|
private array $timeouts;
|
|
private array $retryConfig;
|
|
private array $chatParams;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->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<int, float[]>
|
|
*/
|
|
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;
|
|
}
|
|
}
|