First commit
This commit is contained in:
278
app/Services/Ollama/OllamaService.php
Normal file
278
app/Services/Ollama/OllamaService.php
Normal file
@@ -0,0 +1,278 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user