First commit
This commit is contained in:
96
app/Actions/Chatbot/AskQuestionAction.php
Normal file
96
app/Actions/Chatbot/AskQuestionAction.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Chatbot;
|
||||
|
||||
use App\Jobs\LogChatInteractionJob;
|
||||
use App\Models\ChatSession;
|
||||
use App\Services\KnowledgeBase\RAGService;
|
||||
use Illuminate\Http\Request;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* AskQuestionAction
|
||||
*
|
||||
* Tanggungjawab: Koordinasi satu soalan chatbot.
|
||||
* 1. Urus sesi
|
||||
* 2. Panggil RAGService
|
||||
* 3. Dispatch log job (async)
|
||||
* 4. Return result
|
||||
*/
|
||||
class AskQuestionAction
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RAGService $ragService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param string $question
|
||||
* @param ?int $categoryId
|
||||
* @param Request $request
|
||||
* @return array{
|
||||
* answer: string,
|
||||
* has_answer: bool,
|
||||
* sources: array[],
|
||||
* session_token: string,
|
||||
* chat_log_id: ?int
|
||||
* }
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function execute(
|
||||
string $question,
|
||||
?int $categoryId,
|
||||
Request $request
|
||||
): array {
|
||||
// ── Urus sesi ────────────────────────────────────────────────────
|
||||
$session = $this->resolveSession($request, $categoryId);
|
||||
|
||||
// ── Jawab soalan melalui RAG ──────────────────────────────────────
|
||||
$result = $this->ragService->ask($question, $categoryId);
|
||||
|
||||
// ── Log secara async (jangan tangguh response) ────────────────────
|
||||
LogChatInteractionJob::dispatch(
|
||||
$session->session_token,
|
||||
auth()->id(),
|
||||
$categoryId,
|
||||
$question,
|
||||
$result['answer'],
|
||||
$result['sources'],
|
||||
$result['context_chunks'],
|
||||
$result['model_used'],
|
||||
$result['tokens_used'],
|
||||
$result['response_time'],
|
||||
$result['has_answer'],
|
||||
);
|
||||
|
||||
return [
|
||||
'answer' => $result['answer'],
|
||||
'has_answer' => $result['has_answer'],
|
||||
'sources' => $result['sources'],
|
||||
'session_token' => $session->session_token,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveSession(Request $request, ?int $categoryId): ChatSession
|
||||
{
|
||||
$token = $request->session()->get('chat_session_token');
|
||||
|
||||
if ($token) {
|
||||
$session = ChatSession::where('session_token', $token)->first();
|
||||
if ($session) {
|
||||
return $session;
|
||||
}
|
||||
}
|
||||
|
||||
// Buat sesi baru
|
||||
$session = ChatSession::create([
|
||||
'user_id' => auth()->id(),
|
||||
'category_id' => $categoryId,
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
|
||||
$request->session()->put('chat_session_token', $session->session_token);
|
||||
|
||||
return $session;
|
||||
}
|
||||
}
|
||||
105
app/Actions/Document/CreateDocumentAction.php
Normal file
105
app/Actions/Document/CreateDocumentAction.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Document;
|
||||
|
||||
use App\Jobs\ProcessUploadedDocumentJob;
|
||||
use App\Models\Document;
|
||||
use App\Models\DocumentVersion;
|
||||
use App\Services\KnowledgeBase\AuditService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* CreateDocumentAction
|
||||
*
|
||||
* Tanggungjawab: Simpan dokumen baru + versi pertama + dispatch job.
|
||||
* Dipanggil oleh DocumentController@store.
|
||||
*/
|
||||
class CreateDocumentAction
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AuditService $auditService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array $data Data dari StoreDocumentRequest
|
||||
* @param UploadedFile $file Fail PDF yang diupload
|
||||
* @return Document
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function execute(array $data, UploadedFile $file): Document
|
||||
{
|
||||
// Semak duplicate (hash yang sama dalam kategori yang sama)
|
||||
$fileHash = hash_file('sha256', $file->getRealPath());
|
||||
$existing = DocumentVersion::where('file_hash', $fileHash)->first();
|
||||
|
||||
if ($existing) {
|
||||
throw new RuntimeException(
|
||||
"Fail ini sudah pernah diupload. Sila semak dokumen: " .
|
||||
$existing->document->title
|
||||
);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($data, $file, $fileHash) {
|
||||
// ── Buat rekod dokumen ────────────────────────────────────────
|
||||
$document = Document::create([
|
||||
'category_id' => $data['category_id'],
|
||||
'title' => $data['title'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'status' => Document::STATUS_PROCESSING,
|
||||
'is_active' => false,
|
||||
'effective_date' => $data['effective_date'] ?? null,
|
||||
'expiry_date' => $data['expiry_date'] ?? null,
|
||||
'tags' => $data['tags'] ?? [],
|
||||
'language' => $data['language'] ?? 'ms',
|
||||
'created_by' => auth()->id(),
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// ── Simpan fail PDF ────────────────────────────────────────────
|
||||
$storedPath = $this->storePdf($file, $document->id, 1);
|
||||
|
||||
// ── Buat rekod versi ──────────────────────────────────────────
|
||||
$version = DocumentVersion::create([
|
||||
'document_id' => $document->id,
|
||||
'version_number' => 1,
|
||||
'original_filename' => $file->getClientOriginalName(),
|
||||
'stored_path' => $storedPath,
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'file_size' => $file->getSize(),
|
||||
'file_hash' => $fileHash,
|
||||
'processing_status' => DocumentVersion::STATUS_PENDING,
|
||||
'is_current' => true,
|
||||
'change_notes' => $data['change_notes'] ?? 'Versi pertama.',
|
||||
'uploaded_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// ── Audit log ─────────────────────────────────────────────────
|
||||
$this->auditService->documentUploaded($document, $version);
|
||||
|
||||
// ── Dispatch job ke queue ─────────────────────────────────────
|
||||
ProcessUploadedDocumentJob::dispatch($version->id);
|
||||
|
||||
return $document->load('currentVersion');
|
||||
});
|
||||
}
|
||||
|
||||
private function storePdf(UploadedFile $file, int $documentId, int $versionNumber): string
|
||||
{
|
||||
$disk = config('knowledgebase.upload.storage_disk', 'local');
|
||||
$folder = "documents/{$documentId}/v{$versionNumber}";
|
||||
$filename = Str::uuid() . '.pdf';
|
||||
|
||||
$path = $file->storeAs($folder, $filename, $disk);
|
||||
|
||||
if (!$path) {
|
||||
throw new RuntimeException('Gagal simpan fail PDF ke storage.');
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
83
app/Actions/Document/UploadNewVersionAction.php
Normal file
83
app/Actions/Document/UploadNewVersionAction.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Document;
|
||||
|
||||
use App\Jobs\ProcessUploadedDocumentJob;
|
||||
use App\Models\Document;
|
||||
use App\Models\DocumentVersion;
|
||||
use App\Services\KnowledgeBase\AuditService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* UploadNewVersionAction
|
||||
*
|
||||
* Upload versi baru untuk dokumen yang sedia ada.
|
||||
* Versi lama TIDAK dipadam — kekal dalam storage dan MySQL.
|
||||
* Chunk versi lama akan di-deactivate (bukan delete) semasa ingestion.
|
||||
*/
|
||||
class UploadNewVersionAction
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AuditService $auditService
|
||||
) {}
|
||||
|
||||
public function execute(Document $document, UploadedFile $file, array $data): DocumentVersion
|
||||
{
|
||||
$fileHash = hash_file('sha256', $file->getRealPath());
|
||||
|
||||
// Semak sama ada hash sama dengan versi semasa
|
||||
$currentVersion = $document->currentVersion;
|
||||
if ($currentVersion && $currentVersion->file_hash === $fileHash) {
|
||||
throw new RuntimeException(
|
||||
'Fail ini sama dengan versi semasa. Tiada perubahan.'
|
||||
);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($document, $file, $fileHash, $data) {
|
||||
$newVersionNumber = $document->getLatestVersionNumber() + 1;
|
||||
|
||||
// ── Simpan fail baru ──────────────────────────────────────────
|
||||
$disk = config('knowledgebase.upload.storage_disk', 'local');
|
||||
$folder = "documents/{$document->id}/v{$newVersionNumber}";
|
||||
$filename = Str::uuid() . '.pdf';
|
||||
$path = $file->storeAs($folder, $filename, $disk);
|
||||
|
||||
if (!$path) {
|
||||
throw new RuntimeException('Gagal simpan fail PDF ke storage.');
|
||||
}
|
||||
|
||||
// ── Buat rekod versi baru ─────────────────────────────────────
|
||||
$version = DocumentVersion::create([
|
||||
'document_id' => $document->id,
|
||||
'version_number' => $newVersionNumber,
|
||||
'original_filename' => $file->getClientOriginalName(),
|
||||
'stored_path' => $path,
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'file_size' => $file->getSize(),
|
||||
'file_hash' => $fileHash,
|
||||
'processing_status' => DocumentVersion::STATUS_PENDING,
|
||||
'is_current' => false, // akan di-set current semasa ingestion
|
||||
'change_notes' => $data['change_notes'] ?? null,
|
||||
'uploaded_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// Kemaskini status dokumen ke processing
|
||||
$document->update([
|
||||
'status' => Document::STATUS_PROCESSING,
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// ── Audit log ─────────────────────────────────────────────────
|
||||
$this->auditService->documentUploaded($document, $version);
|
||||
|
||||
// ── Dispatch job ──────────────────────────────────────────────
|
||||
ProcessUploadedDocumentJob::dispatch($version->id);
|
||||
|
||||
return $version;
|
||||
});
|
||||
}
|
||||
}
|
||||
109
app/Console/Commands/HealthCheckCommand.php
Normal file
109
app/Console/Commands/HealthCheckCommand.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Ollama\OllamaService;
|
||||
use App\Services\Qdrant\QdrantService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* php artisan kb:health-check
|
||||
*
|
||||
* Semak status semua perkhidmatan yang diperlukan.
|
||||
*/
|
||||
class HealthCheckCommand extends Command
|
||||
{
|
||||
protected $signature = 'kb:health-check';
|
||||
protected $description = 'Semak status Ollama, Qdrant, dan MySQL';
|
||||
|
||||
public function handle(OllamaService $ollama, QdrantService $qdrant): int
|
||||
{
|
||||
$this->info('════════════════════════════════════════');
|
||||
$this->info(' PEMERIKSAAN KESIHATAN SISTEM');
|
||||
$this->info('════════════════════════════════════════');
|
||||
|
||||
$allOk = true;
|
||||
|
||||
// ── MySQL ─────────────────────────────────────────────────────────
|
||||
$this->line('');
|
||||
$this->info('📦 MySQL');
|
||||
try {
|
||||
DB::connection()->getPdo();
|
||||
$dbName = DB::getDatabaseName();
|
||||
$this->line(" ✅ Berjaya sambung ke: {$dbName}");
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" ❌ Tidak dapat sambung: {$e->getMessage()}");
|
||||
$allOk = false;
|
||||
}
|
||||
|
||||
// ── Ollama ────────────────────────────────────────────────────────
|
||||
$this->line('');
|
||||
$this->info('🤖 Ollama');
|
||||
$ollamaStatus = $ollama->healthCheck();
|
||||
|
||||
if ($ollamaStatus['online']) {
|
||||
$this->line(" ✅ Online");
|
||||
$chatOk = $ollamaStatus['chat_model'] ? '✅' : '❌';
|
||||
$embedOk = $ollamaStatus['embed_model'] ? '✅' : '❌';
|
||||
$this->line(" {$chatOk} Model Chat: " . config('ollama.chat_model'));
|
||||
$this->line(" {$embedOk} Model Embed: " . config('ollama.embedding_model'));
|
||||
|
||||
if (!$ollamaStatus['chat_model'] || !$ollamaStatus['embed_model']) {
|
||||
$allOk = false;
|
||||
}
|
||||
} else {
|
||||
$this->error(" ❌ Offline");
|
||||
if ($ollamaStatus['error']) {
|
||||
$this->line(" " . $ollamaStatus['error']);
|
||||
}
|
||||
$allOk = false;
|
||||
}
|
||||
|
||||
// ── Qdrant ────────────────────────────────────────────────────────
|
||||
$this->line('');
|
||||
$this->info('🗄️ Qdrant');
|
||||
$qdrantStatus = $qdrant->healthCheck();
|
||||
|
||||
if ($qdrantStatus['online']) {
|
||||
$this->line(" ✅ Online");
|
||||
$collOk = $qdrantStatus['collection_exists'] ? '✅' : '⚠️ ';
|
||||
$this->line(" {$collOk} Collection: " . config('qdrant.collection'));
|
||||
if ($qdrantStatus['collection_exists']) {
|
||||
$this->line(" 📊 Vectors: " . number_format($qdrantStatus['points_count'] ?? 0));
|
||||
} else {
|
||||
$this->warn(" Collection belum wujud. Akan dibuat secara automatik semasa pertama embed.");
|
||||
}
|
||||
} else {
|
||||
$this->error(" ❌ Offline");
|
||||
if ($qdrantStatus['error']) {
|
||||
$this->line(" " . $qdrantStatus['error']);
|
||||
}
|
||||
$allOk = false;
|
||||
}
|
||||
|
||||
// ── Queue ─────────────────────────────────────────────────────────
|
||||
$this->line('');
|
||||
$this->info('⏳ Queue');
|
||||
$driver = config('queue.default');
|
||||
$this->line(" ℹ️ Driver: {$driver}");
|
||||
if ($driver === 'sync') {
|
||||
$this->warn(" ⚠️ Queue driver adalah 'sync' — job akan dijalankan secara synchronous.");
|
||||
$this->warn(" Tukar kepada 'database' atau 'redis' untuk production.");
|
||||
} else {
|
||||
$this->line(" ✅ Driver konfigurasi untuk async.");
|
||||
}
|
||||
|
||||
// ── Ringkasan ─────────────────────────────────────────────────────
|
||||
$this->line('');
|
||||
$this->info('════════════════════════════════════════');
|
||||
if ($allOk) {
|
||||
$this->info(' ✅ SEMUA PERKHIDMATAN OK');
|
||||
} else {
|
||||
$this->error(' ❌ ADA PERKHIDMATAN YANG BERMASALAH');
|
||||
}
|
||||
$this->info('════════════════════════════════════════');
|
||||
|
||||
return $allOk ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
}
|
||||
96
app/Console/Commands/ReindexCategoryCommand.php
Normal file
96
app/Console/Commands/ReindexCategoryCommand.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\ReindexDocumentJob;
|
||||
use App\Jobs\ReindexKnowledgeItemJob;
|
||||
use App\Models\Category;
|
||||
use App\Models\DocumentVersion;
|
||||
use App\Models\KnowledgeItem;
|
||||
use App\Services\KnowledgeBase\AuditService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* php artisan kb:reindex-category {--category_id=} {--slug=} {--dry-run}
|
||||
*
|
||||
* Contoh:
|
||||
* php artisan kb:reindex-category --category_id=1
|
||||
* php artisan kb:reindex-category --slug=pelesenan
|
||||
* php artisan kb:reindex-category --slug=pelesenan --dry-run
|
||||
*/
|
||||
class ReindexCategoryCommand extends Command
|
||||
{
|
||||
protected $signature = 'kb:reindex-category
|
||||
{--category_id= : ID kategori}
|
||||
{--slug= : Slug kategori}
|
||||
{--dry-run : Papar sahaja tanpa dispatch}';
|
||||
|
||||
protected $description = 'Reindex semua dokumen dan knowledge items dalam satu kategori';
|
||||
|
||||
public function handle(AuditService $auditService): int
|
||||
{
|
||||
$category = null;
|
||||
|
||||
if ($id = $this->option('category_id')) {
|
||||
$category = Category::find((int) $id);
|
||||
} elseif ($slug = $this->option('slug')) {
|
||||
$category = Category::where('slug', $slug)->first();
|
||||
}
|
||||
|
||||
if (!$category) {
|
||||
$this->error('Kategori tidak dijumpai.');
|
||||
$this->listCategories();
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$dryRun = $this->option('dry-run');
|
||||
|
||||
$this->info("Kategori: {$category->name}");
|
||||
$this->line(str_repeat('─', 50));
|
||||
|
||||
// Dokumen
|
||||
$versions = DocumentVersion::whereHas('document', fn($q) => $q->where('category_id', $category->id))
|
||||
->where('is_current', true)
|
||||
->where('processing_status', DocumentVersion::STATUS_INDEXED)
|
||||
->with('document')
|
||||
->get();
|
||||
|
||||
$this->info("Dokumen (versi semasa): {$versions->count()}");
|
||||
foreach ($versions as $v) {
|
||||
$this->line(" → {$v->document->title} v{$v->version_number}");
|
||||
if (!$dryRun) {
|
||||
ReindexDocumentJob::dispatch($v->id);
|
||||
}
|
||||
}
|
||||
|
||||
// Knowledge Items
|
||||
$items = KnowledgeItem::where('category_id', $category->id)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
$this->info("\nKnowledge Items: {$items->count()}");
|
||||
foreach ($items as $item) {
|
||||
$this->line(" → [{$item->item_type}] {$item->title}");
|
||||
if (!$dryRun) {
|
||||
ReindexKnowledgeItemJob::dispatch($item->id);
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn("\n[DRY RUN] Tiada job dihantar. Buang --dry-run untuk reindex sebenar.");
|
||||
} else {
|
||||
$auditService->systemReindexStarted("category:{$category->slug}");
|
||||
$this->info("\n✓ Semua job telah dijadualkan.");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function listCategories(): void
|
||||
{
|
||||
$this->info("\nKategori tersedia:");
|
||||
Category::orderBy('name')->get()->each(function ($c) {
|
||||
$this->line(" ID: {$c->id} Slug: {$c->slug} Nama: {$c->name}");
|
||||
});
|
||||
}
|
||||
}
|
||||
107
app/Console/Commands/ReindexDocumentCommand.php
Normal file
107
app/Console/Commands/ReindexDocumentCommand.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\ReindexDocumentJob;
|
||||
use App\Models\Document;
|
||||
use App\Models\DocumentVersion;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* php artisan kb:reindex-document {--document_id=} {--version_id=} {--all-failed}
|
||||
*
|
||||
* Contoh:
|
||||
* php artisan kb:reindex-document --document_id=5
|
||||
* php artisan kb:reindex-document --version_id=12
|
||||
* php artisan kb:reindex-document --all-failed
|
||||
*/
|
||||
class ReindexDocumentCommand extends Command
|
||||
{
|
||||
protected $signature = 'kb:reindex-document
|
||||
{--document_id= : ID dokumen untuk reindex versi semasa}
|
||||
{--version_id= : ID versi spesifik untuk reindex}
|
||||
{--all-failed : Reindex semua versi yang gagal}';
|
||||
|
||||
protected $description = 'Reindex (re-embed) dokumen dalam Qdrant';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if ($this->option('all-failed')) {
|
||||
return $this->reindexAllFailed();
|
||||
}
|
||||
|
||||
if ($versionId = $this->option('version_id')) {
|
||||
return $this->reindexVersion((int) $versionId);
|
||||
}
|
||||
|
||||
if ($documentId = $this->option('document_id')) {
|
||||
return $this->reindexDocument((int) $documentId);
|
||||
}
|
||||
|
||||
$this->error('Sila nyatakan --document_id, --version_id, atau --all-failed');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
private function reindexDocument(int $documentId): int
|
||||
{
|
||||
$document = Document::find($documentId);
|
||||
|
||||
if (!$document) {
|
||||
$this->error("Dokumen ID {$documentId} tidak dijumpai.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$currentVersion = $document->currentVersion;
|
||||
|
||||
if (!$currentVersion) {
|
||||
$this->error("Dokumen '{$document->title}' tiada versi semasa.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
ReindexDocumentJob::dispatch($currentVersion->id);
|
||||
|
||||
$this->info("✓ Reindex dijadualkan untuk dokumen: {$document->title} (v{$currentVersion->version_number})");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function reindexVersion(int $versionId): int
|
||||
{
|
||||
$version = DocumentVersion::with('document')->find($versionId);
|
||||
|
||||
if (!$version) {
|
||||
$this->error("Version ID {$versionId} tidak dijumpai.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
ReindexDocumentJob::dispatch($version->id);
|
||||
|
||||
$this->info("✓ Reindex dijadualkan untuk: {$version->document->title} v{$version->version_number}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function reindexAllFailed(): int
|
||||
{
|
||||
$failedVersions = DocumentVersion::whereIn('processing_status', [
|
||||
DocumentVersion::STATUS_FAILED,
|
||||
DocumentVersion::STATUS_EXTRACTION_FAILED,
|
||||
])->with('document')->get();
|
||||
|
||||
if ($failedVersions->isEmpty()) {
|
||||
$this->info('Tiada versi yang gagal ditemui.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($failedVersions as $version) {
|
||||
ReindexDocumentJob::dispatch($version->id);
|
||||
$this->line(" → {$version->document->title} v{$version->version_number}");
|
||||
$count++;
|
||||
}
|
||||
|
||||
$this->info("✓ {$count} versi telah dijadualkan untuk reindex.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
45
app/Http/Controllers/Admin/AuditLogController.php
Normal file
45
app/Http/Controllers/Admin/AuditLogController.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AuditLog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AuditLogController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = AuditLog::with('user')->latest('created_at');
|
||||
|
||||
if ($request->filled('event')) {
|
||||
$query->where('event', $request->event);
|
||||
}
|
||||
|
||||
if ($request->filled('user_id')) {
|
||||
$query->where('user_id', $request->user_id);
|
||||
}
|
||||
|
||||
if ($request->filled('date_from')) {
|
||||
$query->where('created_at', '>=', $request->date_from . ' 00:00:00');
|
||||
}
|
||||
|
||||
if ($request->filled('date_to')) {
|
||||
$query->where('created_at', '<=', $request->date_to . ' 23:59:59');
|
||||
}
|
||||
|
||||
$logs = $query->paginate(30)->withQueryString();
|
||||
|
||||
$eventTypes = AuditLog::distinct()->pluck('event')->sort()->values();
|
||||
|
||||
return view('admin.audit-logs.index', compact('logs', 'eventTypes'));
|
||||
}
|
||||
|
||||
public function show(AuditLog $auditLog): View
|
||||
{
|
||||
$auditLog->load('user');
|
||||
|
||||
return view('admin.audit-logs.show', compact('auditLog'));
|
||||
}
|
||||
}
|
||||
102
app/Http/Controllers/Admin/CategoryController.php
Normal file
102
app/Http/Controllers/Admin/CategoryController.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\StoreCategoryRequest;
|
||||
use App\Models\Category;
|
||||
use App\Services\KnowledgeBase\AuditService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AuditService $auditService
|
||||
) {}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
$categories = Category::withCount([
|
||||
'documents as total_documents',
|
||||
'documents as active_documents' => fn($q) => $q->where('is_active', true),
|
||||
'knowledgeItems as total_knowledge_items',
|
||||
])
|
||||
->ordered()
|
||||
->withTrashed()
|
||||
->paginate(20);
|
||||
|
||||
return view('admin.categories.index', compact('categories'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.categories.create');
|
||||
}
|
||||
|
||||
public function store(StoreCategoryRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
if (empty($data['slug'])) {
|
||||
$data['slug'] = Str::slug($data['name']);
|
||||
}
|
||||
|
||||
$data['created_by'] = auth()->id();
|
||||
|
||||
$category = Category::create($data);
|
||||
|
||||
$this->auditService->categoryCreated($category);
|
||||
|
||||
return redirect()
|
||||
->route('admin.categories.index')
|
||||
->with('success', "Kategori '{$category->name}' berjaya dicipta.");
|
||||
}
|
||||
|
||||
public function edit(Category $category): View
|
||||
{
|
||||
return view('admin.categories.edit', compact('category'));
|
||||
}
|
||||
|
||||
public function update(StoreCategoryRequest $request, Category $category): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
if (empty($data['slug'])) {
|
||||
$data['slug'] = Str::slug($data['name']);
|
||||
}
|
||||
|
||||
$category->update($data);
|
||||
|
||||
return redirect()
|
||||
->route('admin.categories.index')
|
||||
->with('success', "Kategori '{$category->name}' berjaya dikemaskini.");
|
||||
}
|
||||
|
||||
public function toggleStatus(Category $category): RedirectResponse
|
||||
{
|
||||
$category->update(['is_active' => !$category->is_active]);
|
||||
|
||||
$status = $category->is_active ? 'diaktifkan' : 'dinyahaktifkan';
|
||||
|
||||
return back()->with('success', "Kategori '{$category->name}' telah {$status}.");
|
||||
}
|
||||
|
||||
public function destroy(Category $category): RedirectResponse
|
||||
{
|
||||
// Semak sama ada ada dokumen aktif dalam kategori ini
|
||||
if ($category->documents()->where('is_active', true)->exists()) {
|
||||
return back()->with('error',
|
||||
'Kategori tidak boleh dipadam kerana masih ada dokumen aktif di dalamnya.'
|
||||
);
|
||||
}
|
||||
|
||||
$category->delete(); // SoftDelete
|
||||
|
||||
return redirect()
|
||||
->route('admin.categories.index')
|
||||
->with('success', "Kategori '{$category->name}' telah dipadam.");
|
||||
}
|
||||
}
|
||||
117
app/Http/Controllers/Admin/ChatFeedbackController.php
Normal file
117
app/Http/Controllers/Admin/ChatFeedbackController.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use App\Models\ChatFeedback;
|
||||
use App\Models\ChatLog;
|
||||
use App\Models\KnowledgeItem;
|
||||
use App\Services\KnowledgeBase\AuditService;
|
||||
use App\Services\KnowledgeBase\IngestionService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ChatFeedbackController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AuditService $auditService,
|
||||
private readonly IngestionService $ingestionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Senarai log chat dengan filter dan status feedback.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = ChatLog::with(['session', 'category', 'feedback'])
|
||||
->latest();
|
||||
|
||||
if ($request->filled('has_answer')) {
|
||||
$query->where('has_answer', (bool) $request->has_answer);
|
||||
}
|
||||
|
||||
if ($request->filled('is_flagged')) {
|
||||
$query->where('is_flagged', true);
|
||||
}
|
||||
|
||||
if ($request->filled('category_id')) {
|
||||
$query->where('category_id', $request->category_id);
|
||||
}
|
||||
|
||||
if ($request->filled('rating')) {
|
||||
$query->whereHas('feedback', fn($q) => $q->where('rating', $request->rating));
|
||||
}
|
||||
|
||||
$logs = $query->paginate(20)->withQueryString();
|
||||
$categories = Category::active()->ordered()->get();
|
||||
|
||||
return view('admin.chat-feedback.index', compact('logs', 'categories'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Semak satu log chat dengan butiran penuh.
|
||||
*/
|
||||
public function show(ChatLog $chatLog): View
|
||||
{
|
||||
$chatLog->load(['session', 'category', 'feedback.convertedFaq']);
|
||||
|
||||
return view('admin.chat-feedback.show', compact('chatLog'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert soalan yang tidak dijawab dengan baik kepada FAQ rasmi.
|
||||
*/
|
||||
public function convertToFaq(Request $request, ChatLog $chatLog): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'category_id' => ['required', 'exists:categories,id'],
|
||||
'title' => ['required', 'string', 'max:500'],
|
||||
'content' => ['required', 'string', 'max:10000'],
|
||||
]);
|
||||
|
||||
$knowledgeItem = KnowledgeItem::create([
|
||||
'category_id' => $validated['category_id'],
|
||||
'item_type' => KnowledgeItem::TYPE_FAQ,
|
||||
'title' => $validated['title'],
|
||||
'content' => $validated['content'],
|
||||
'is_active' => true,
|
||||
'is_public' => true,
|
||||
'created_by' => auth()->id(),
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// Kemaskini feedback jika ada
|
||||
if ($feedback = $chatLog->feedback) {
|
||||
$feedback->update([
|
||||
'converted_to_faq' => true,
|
||||
'converted_faq_id' => $knowledgeItem->id,
|
||||
'reviewed_by' => auth()->id(),
|
||||
'reviewed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Flag log chat sebagai dah diselesaikan
|
||||
$chatLog->update(['is_flagged' => false]);
|
||||
|
||||
$this->auditService->faqConvertedFromFeedback($feedback, $knowledgeItem);
|
||||
|
||||
// Embed knowledge item baru
|
||||
\App\Jobs\ReindexKnowledgeItemJob::dispatch($knowledgeItem->id);
|
||||
|
||||
return redirect()
|
||||
->route('admin.knowledge-items.show', $knowledgeItem)
|
||||
->with('success', "FAQ baru '{$knowledgeItem->title}' berjaya dicipta dari log chat.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle flag pada log chat untuk tanda perlu semakan.
|
||||
*/
|
||||
public function toggleFlag(ChatLog $chatLog): RedirectResponse
|
||||
{
|
||||
$chatLog->update(['is_flagged' => !$chatLog->is_flagged]);
|
||||
|
||||
return back()->with('success', $chatLog->is_flagged ? 'Log ditanda untuk semakan.' : 'Flag dibuang.');
|
||||
}
|
||||
}
|
||||
271
app/Http/Controllers/Admin/ChunkReviewController.php
Normal file
271
app/Http/Controllers/Admin/ChunkReviewController.php
Normal file
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\SplitChunkRequest;
|
||||
use App\Http\Requests\Admin\UpdateChunkRequest;
|
||||
use App\Models\Document;
|
||||
use App\Models\DocumentChunk;
|
||||
use App\Models\DocumentVersion;
|
||||
use App\Services\Document\ChunkEditingService;
|
||||
use App\Services\Document\ChunkSplitService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* ChunkReviewController
|
||||
*
|
||||
* Menguruskan UI dan operasi Chunk Review & Editing:
|
||||
* - index() → Senarai chunk per versi (enhanced list dengan status filter)
|
||||
* - show() → Detail satu chunk + preview 3 teks + form edit
|
||||
* - update() → Simpan final_text yang diedit
|
||||
* - exclude() → Kecualikan chunk dari indexing
|
||||
* - include() → Kembalikan chunk ke indexing
|
||||
* - reindex() → Trigger reindex manual
|
||||
* - splitForm() → Form split chunk
|
||||
* - doSplit() → Jalankan split
|
||||
*
|
||||
* NOTA: Hanya admin yang boleh akses (dikuatkuasakan di routes via 'role:admin').
|
||||
*/
|
||||
class ChunkReviewController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ChunkEditingService $editor,
|
||||
private readonly ChunkSplitService $splitter,
|
||||
) {}
|
||||
|
||||
// =========================================================================
|
||||
// LIST VIEW
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Senarai chunk untuk satu versi dokumen.
|
||||
* Menggantikan DocumentController::chunks().
|
||||
*
|
||||
* Route: GET /admin/documents/{document}/versions/{version}/chunks
|
||||
*/
|
||||
public function index(Document $document, DocumentVersion $version): View
|
||||
{
|
||||
abort_if($version->document_id !== $document->id, 404);
|
||||
|
||||
$statusFilter = request('status');
|
||||
|
||||
$query = $version->chunks()
|
||||
->with(['editor', 'parentChunk', 'childChunks'])
|
||||
->withCount('audits')
|
||||
->orderBy('chunk_index');
|
||||
|
||||
if ($statusFilter) {
|
||||
$query->where('chunk_status', $statusFilter);
|
||||
}
|
||||
|
||||
$chunks = $query->paginate(20)->withQueryString();
|
||||
|
||||
// Bilangan chunk mengikut status untuk filter pills
|
||||
$statusCounts = $version->chunks()
|
||||
->selectRaw('chunk_status, count(*) as total')
|
||||
->groupBy('chunk_status')
|
||||
->pluck('total', 'chunk_status')
|
||||
->toArray();
|
||||
|
||||
$allStatuses = DocumentChunk::STATUS_PENDING === 'pending'
|
||||
? [
|
||||
DocumentChunk::STATUS_PENDING,
|
||||
DocumentChunk::STATUS_INDEXED,
|
||||
DocumentChunk::STATUS_NEEDS_REVIEW,
|
||||
DocumentChunk::STATUS_NEEDS_REINDEX,
|
||||
DocumentChunk::STATUS_EXCLUDED,
|
||||
DocumentChunk::STATUS_SUPERSEDED,
|
||||
DocumentChunk::STATUS_FAILED_EMBEDDING,
|
||||
]
|
||||
: [];
|
||||
|
||||
return view('admin.documents.chunks', compact(
|
||||
'document',
|
||||
'version',
|
||||
'chunks',
|
||||
'statusCounts',
|
||||
'statusFilter'
|
||||
));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DETAIL + EDIT VIEW
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Detail satu chunk: preview raw/cleaned/final text + form edit + audit trail.
|
||||
*
|
||||
* Route: GET /admin/chunks/{chunk}
|
||||
*/
|
||||
public function show(DocumentChunk $chunk): View
|
||||
{
|
||||
$chunk->load([
|
||||
'document.category',
|
||||
'documentVersion',
|
||||
'editor',
|
||||
'parentChunk',
|
||||
'childChunks.editor',
|
||||
'audits' => fn($q) => $q->with('user')->limit(10),
|
||||
]);
|
||||
|
||||
return view('admin.chunks.show', compact('chunk'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Simpan perubahan final_text.
|
||||
*
|
||||
* Route: PATCH /admin/chunks/{chunk}
|
||||
*/
|
||||
public function update(UpdateChunkRequest $request, DocumentChunk $chunk): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$this->editor->editFinalText(
|
||||
$chunk,
|
||||
$request->validated('final_text'),
|
||||
$request->validated('notes')
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('admin.chunks.show', $chunk)
|
||||
->with('success', 'final_text berjaya disimpan. Reindex sedang diantrikan dalam queue.');
|
||||
} catch (RuntimeException $e) {
|
||||
return back()->withInput()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// EXCLUDE / INCLUDE
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Kecualikan chunk dari indexing.
|
||||
*
|
||||
* Route: POST /admin/chunks/{chunk}/exclude
|
||||
*/
|
||||
public function exclude(Request $request, DocumentChunk $chunk): RedirectResponse
|
||||
{
|
||||
$request->validate(['notes' => ['nullable', 'string', 'max:500']]);
|
||||
|
||||
try {
|
||||
$this->editor->excludeChunk($chunk, $request->input('notes'));
|
||||
|
||||
return back()->with(
|
||||
'success',
|
||||
"Chunk #{$chunk->chunk_index} berjaya dikecualikan dari indexing."
|
||||
);
|
||||
} catch (RuntimeException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kembalikan chunk ke indexing.
|
||||
*
|
||||
* Route: POST /admin/chunks/{chunk}/include
|
||||
*/
|
||||
public function include(Request $request, DocumentChunk $chunk): RedirectResponse
|
||||
{
|
||||
$request->validate(['notes' => ['nullable', 'string', 'max:500']]);
|
||||
|
||||
try {
|
||||
$this->editor->includeChunk($chunk, $request->input('notes'));
|
||||
|
||||
return back()->with(
|
||||
'success',
|
||||
"Chunk #{$chunk->chunk_index} berjaya dikembalikan ke indexing."
|
||||
);
|
||||
} catch (RuntimeException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// REINDEX
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Trigger reindex manual untuk satu chunk.
|
||||
*
|
||||
* Route: POST /admin/chunks/{chunk}/reindex
|
||||
*/
|
||||
public function reindex(Request $request, DocumentChunk $chunk): RedirectResponse
|
||||
{
|
||||
$request->validate(['notes' => ['nullable', 'string', 'max:500']]);
|
||||
|
||||
try {
|
||||
$this->editor->triggerReindex($chunk, $request->input('notes'));
|
||||
|
||||
return back()->with(
|
||||
'success',
|
||||
"Chunk #{$chunk->chunk_index} sedang diantrikan untuk reindex."
|
||||
);
|
||||
} catch (RuntimeException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SPLIT
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Form split chunk.
|
||||
*
|
||||
* Route: GET /admin/chunks/{chunk}/split
|
||||
*/
|
||||
public function splitForm(DocumentChunk $chunk): View
|
||||
{
|
||||
if ($chunk->isSuperseded()) {
|
||||
abort(403, 'Chunk yang telah digantikan tidak boleh di-split semula.');
|
||||
}
|
||||
|
||||
if ($chunk->chunk_status === DocumentChunk::STATUS_EXCLUDED) {
|
||||
abort(403, 'Chunk yang dikecualikan tidak boleh di-split. Include semula dahulu.');
|
||||
}
|
||||
|
||||
$chunk->load(['document', 'documentVersion', 'childChunks']);
|
||||
|
||||
return view('admin.chunks.split', compact('chunk'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Jalankan split chunk.
|
||||
*
|
||||
* Route: POST /admin/chunks/{chunk}/split
|
||||
*/
|
||||
public function doSplit(SplitChunkRequest $request, DocumentChunk $chunk): RedirectResponse
|
||||
{
|
||||
if ($chunk->isSuperseded()) {
|
||||
return back()->with('error', 'Chunk yang telah digantikan tidak boleh di-split.');
|
||||
}
|
||||
|
||||
try {
|
||||
$children = $this->splitter->split(
|
||||
$chunk,
|
||||
$request->validated('segments'),
|
||||
$request->validated('notes')
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('admin.documents.chunks', [
|
||||
'document' => $chunk->document_id,
|
||||
'version' => $chunk->document_version_id,
|
||||
])
|
||||
->with(
|
||||
'success',
|
||||
"Chunk #{$chunk->chunk_index} berjaya di-split kepada "
|
||||
. count($children)
|
||||
. " chunk baharu. Reindex sedang dijalankan dalam queue."
|
||||
);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return back()->withInput()->with('error', $e->getMessage());
|
||||
} catch (RuntimeException $e) {
|
||||
return back()->withInput()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
54
app/Http/Controllers/Admin/DashboardController.php
Normal file
54
app/Http/Controllers/Admin/DashboardController.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Category;
|
||||
use App\Models\ChatLog;
|
||||
use App\Models\Document;
|
||||
use App\Models\DocumentVersion;
|
||||
use App\Models\KnowledgeItem;
|
||||
use App\Services\Ollama\OllamaService;
|
||||
use App\Services\Qdrant\QdrantService;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index(
|
||||
OllamaService $ollamaService,
|
||||
QdrantService $qdrantService
|
||||
): View {
|
||||
$stats = [
|
||||
'total_documents' => Document::count(),
|
||||
'active_documents' => Document::where('is_active', true)->count(),
|
||||
'processing_documents' => Document::where('status', 'processing')->count(),
|
||||
'failed_documents' => Document::whereIn('status', ['failed', 'extraction_failed'])->count(),
|
||||
'total_categories' => Category::where('is_active', true)->count(),
|
||||
'total_knowledge_items' => KnowledgeItem::where('is_active', true)->count(),
|
||||
'total_chats_today' => ChatLog::whereDate('created_at', today())->count(),
|
||||
'unanswered_chats' => ChatLog::where('has_answer', false)->count(),
|
||||
'flagged_chats' => ChatLog::where('is_flagged', true)->count(),
|
||||
];
|
||||
|
||||
$recentActivity = AuditLog::with('user')
|
||||
->latest('created_at')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
$recentChats = ChatLog::with('category')
|
||||
->latest()
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
// Health check (cache 60 saat supaya tidak lambat setiap load)
|
||||
$health = cache()->remember('service_health', 60, function () use ($ollamaService, $qdrantService) {
|
||||
return [
|
||||
'ollama' => $ollamaService->healthCheck(),
|
||||
'qdrant' => $qdrantService->healthCheck(),
|
||||
];
|
||||
});
|
||||
|
||||
return view('admin.dashboard', compact('stats', 'recentActivity', 'recentChats', 'health'));
|
||||
}
|
||||
}
|
||||
214
app/Http/Controllers/Admin/DocumentController.php
Normal file
214
app/Http/Controllers/Admin/DocumentController.php
Normal file
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Actions\Document\CreateDocumentAction;
|
||||
use App\Actions\Document\UploadNewVersionAction;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\StoreDocumentRequest;
|
||||
use App\Jobs\ReindexDocumentJob;
|
||||
use App\Models\Category;
|
||||
use App\Models\Document;
|
||||
use App\Models\DocumentVersion;
|
||||
use App\Services\KnowledgeBase\AuditService;
|
||||
use App\Services\KnowledgeBase\IngestionService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\View\View;
|
||||
use RuntimeException;
|
||||
|
||||
class DocumentController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AuditService $auditService,
|
||||
private readonly IngestionService $ingestionService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = Document::with(['category', 'currentVersion'])
|
||||
->withCount('versions');
|
||||
|
||||
if ($request->filled('category_id')) {
|
||||
$query->where('category_id', $request->category_id);
|
||||
}
|
||||
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where('title', 'like', '%' . $request->search . '%');
|
||||
}
|
||||
|
||||
$documents = $query->latest()->paginate(15)->withQueryString();
|
||||
$categories = Category::active()->ordered()->get();
|
||||
|
||||
return view('admin.documents.index', compact('documents', 'categories'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$categories = Category::active()->ordered()->get();
|
||||
return view('admin.documents.create', compact('categories'));
|
||||
}
|
||||
|
||||
public function store(StoreDocumentRequest $request, CreateDocumentAction $action): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$document = $action->execute(
|
||||
$request->validated(),
|
||||
$request->file('file')
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('admin.documents.show', $document)
|
||||
->with('success', "Dokumen '{$document->title}' berjaya diupload dan sedang diproses.");
|
||||
} catch (RuntimeException $e) {
|
||||
return back()
|
||||
->withInput()
|
||||
->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function show(Document $document): View
|
||||
{
|
||||
$document->load([
|
||||
'category',
|
||||
'versions.uploader',
|
||||
'currentVersion.chunks' => fn($q) => $q->limit(20),
|
||||
]);
|
||||
|
||||
return view('admin.documents.show', compact('document'));
|
||||
}
|
||||
|
||||
public function edit(Document $document): View
|
||||
{
|
||||
$categories = Category::active()->ordered()->get();
|
||||
return view('admin.documents.edit', compact('document', 'categories'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Document $document): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'category_id' => ['required', 'exists:categories,id'],
|
||||
'effective_date' => ['nullable', 'date'],
|
||||
'expiry_date' => ['nullable', 'date'],
|
||||
'tags' => ['nullable', 'array'],
|
||||
'language' => ['nullable', 'in:ms,en'],
|
||||
]);
|
||||
|
||||
$validated['updated_by'] = auth()->id();
|
||||
$document->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('admin.documents.show', $document)
|
||||
->with('success', 'Maklumat dokumen berjaya dikemaskini.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload versi baru untuk dokumen yang sedia ada.
|
||||
*/
|
||||
public function uploadVersion(
|
||||
Request $request,
|
||||
Document $document,
|
||||
UploadNewVersionAction $action
|
||||
): RedirectResponse {
|
||||
$request->validate([
|
||||
'file' => ['required', 'file', 'mimes:pdf', 'max:' . config('knowledgebase.upload.max_file_size', 20480)],
|
||||
'change_notes' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$action->execute($document, $request->file('file'), $request->only('change_notes'));
|
||||
|
||||
return redirect()
|
||||
->route('admin.documents.show', $document)
|
||||
->with('success', 'Versi baru berjaya diupload dan sedang diproses.');
|
||||
} catch (RuntimeException $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle status aktif/tidak aktif dokumen.
|
||||
* Apabila dinyahaktifkan, chunk dalam Qdrant juga dimatikan.
|
||||
*/
|
||||
public function toggleStatus(Document $document): RedirectResponse
|
||||
{
|
||||
$newStatus = !$document->is_active;
|
||||
|
||||
if ($newStatus) {
|
||||
// Aktifkan — versi semasa mesti sudah indexed
|
||||
$currentVersion = $document->currentVersion;
|
||||
if (!$currentVersion || !$currentVersion->isProcessed()) {
|
||||
return back()->with('error',
|
||||
'Dokumen tidak boleh diaktifkan kerana pemprosesan belum selesai.'
|
||||
);
|
||||
}
|
||||
|
||||
$document->update([
|
||||
'is_active' => true,
|
||||
'status' => Document::STATUS_ACTIVE,
|
||||
]);
|
||||
|
||||
$this->auditService->documentActivated($document);
|
||||
} else {
|
||||
// Deactivate — matikan juga dalam Qdrant
|
||||
$document->update([
|
||||
'is_active' => false,
|
||||
'status' => Document::STATUS_INACTIVE,
|
||||
]);
|
||||
|
||||
if ($currentVersion = $document->currentVersion) {
|
||||
$this->ingestionService->deactivateVersionInQdrant($currentVersion);
|
||||
}
|
||||
|
||||
$this->auditService->documentDeactivated($document);
|
||||
}
|
||||
|
||||
$status = $newStatus ? 'diaktifkan' : 'dinyahaktifkan';
|
||||
|
||||
return back()->with('success', "Dokumen '{$document->title}' telah {$status}.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger reindex untuk versi tertentu.
|
||||
*/
|
||||
public function reindex(Document $document): RedirectResponse
|
||||
{
|
||||
$currentVersion = $document->currentVersion;
|
||||
|
||||
if (!$currentVersion) {
|
||||
return back()->with('error', 'Tiada versi semasa untuk diindeks semula.');
|
||||
}
|
||||
|
||||
ReindexDocumentJob::dispatch($currentVersion->id);
|
||||
|
||||
$this->auditService->documentReindexed($document, $currentVersion);
|
||||
|
||||
return back()->with('success', 'Reindeks telah dimulakan. Sila semak semula sebentar lagi.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Download PDF asal.
|
||||
*/
|
||||
public function download(Document $document, DocumentVersion $version)
|
||||
{
|
||||
abort_if($version->document_id !== $document->id, 404);
|
||||
|
||||
$disk = config('knowledgebase.upload.storage_disk', 'local');
|
||||
|
||||
if (!Storage::disk($disk)->exists($version->stored_path)) {
|
||||
abort(404, 'Fail tidak dijumpai.');
|
||||
}
|
||||
|
||||
return Storage::disk($disk)->download(
|
||||
$version->stored_path,
|
||||
$version->original_filename
|
||||
);
|
||||
}
|
||||
}
|
||||
152
app/Http/Controllers/Admin/KnowledgeItemController.php
Normal file
152
app/Http/Controllers/Admin/KnowledgeItemController.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\StoreKnowledgeItemRequest;
|
||||
use App\Jobs\ReindexKnowledgeItemJob;
|
||||
use App\Models\Category;
|
||||
use App\Models\KnowledgeItem;
|
||||
use App\Services\KnowledgeBase\AuditService;
|
||||
use App\Services\KnowledgeBase\IngestionService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use RuntimeException;
|
||||
|
||||
class KnowledgeItemController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AuditService $auditService,
|
||||
private readonly IngestionService $ingestionService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = KnowledgeItem::with('category')
|
||||
->withTrashed();
|
||||
|
||||
if ($request->filled('category_id')) {
|
||||
$query->where('category_id', $request->category_id);
|
||||
}
|
||||
|
||||
if ($request->filled('item_type')) {
|
||||
$query->where('item_type', $request->item_type);
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where(function ($q) use ($request) {
|
||||
$q->where('title', 'like', '%' . $request->search . '%')
|
||||
->orWhere('content', 'like', '%' . $request->search . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$items = $query->latest()->paginate(20)->withQueryString();
|
||||
$categories = Category::active()->ordered()->get();
|
||||
$typeLabels = KnowledgeItem::typeLabels();
|
||||
|
||||
return view('admin.knowledge-items.index', compact('items', 'categories', 'typeLabels'));
|
||||
}
|
||||
|
||||
public function create(Request $request): View
|
||||
{
|
||||
$categories = Category::active()->ordered()->get();
|
||||
$typeLabels = KnowledgeItem::typeLabels();
|
||||
$prefillData = $request->only(['category_id', 'item_type', 'title', 'content']);
|
||||
// prefillData berguna bila convert dari feedback
|
||||
|
||||
return view('admin.knowledge-items.create', compact('categories', 'typeLabels', 'prefillData'));
|
||||
}
|
||||
|
||||
public function store(StoreKnowledgeItemRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$data['created_by'] = auth()->id();
|
||||
$data['updated_by'] = auth()->id();
|
||||
|
||||
$item = KnowledgeItem::create($data);
|
||||
|
||||
$this->auditService->knowledgeItemCreated($item);
|
||||
|
||||
// Embed secara async
|
||||
ReindexKnowledgeItemJob::dispatch($item->id);
|
||||
|
||||
return redirect()
|
||||
->route('admin.knowledge-items.show', $item)
|
||||
->with('success', "Knowledge item '{$item->title}' berjaya dicipta dan sedang di-embed.");
|
||||
}
|
||||
|
||||
public function show(KnowledgeItem $knowledgeItem): View
|
||||
{
|
||||
$knowledgeItem->load('category', 'creator');
|
||||
|
||||
return view('admin.knowledge-items.show', compact('knowledgeItem'));
|
||||
}
|
||||
|
||||
public function edit(KnowledgeItem $knowledgeItem): View
|
||||
{
|
||||
$categories = Category::active()->ordered()->get();
|
||||
$typeLabels = KnowledgeItem::typeLabels();
|
||||
|
||||
return view('admin.knowledge-items.edit', compact('knowledgeItem', 'categories', 'typeLabels'));
|
||||
}
|
||||
|
||||
public function update(StoreKnowledgeItemRequest $request, KnowledgeItem $knowledgeItem): RedirectResponse
|
||||
{
|
||||
$oldValues = $knowledgeItem->only(['title', 'content', 'is_active']);
|
||||
|
||||
$data = $request->validated();
|
||||
$data['updated_by'] = auth()->id();
|
||||
|
||||
$knowledgeItem->update($data);
|
||||
|
||||
$this->auditService->knowledgeItemUpdated($knowledgeItem, $oldValues);
|
||||
|
||||
// Re-embed kerana kandungan mungkin berubah
|
||||
ReindexKnowledgeItemJob::dispatch($knowledgeItem->id);
|
||||
|
||||
return redirect()
|
||||
->route('admin.knowledge-items.show', $knowledgeItem)
|
||||
->with('success', 'Knowledge item berjaya dikemaskini dan sedang di-embed semula.');
|
||||
}
|
||||
|
||||
public function toggleStatus(KnowledgeItem $knowledgeItem): RedirectResponse
|
||||
{
|
||||
$newStatus = !$knowledgeItem->is_active;
|
||||
|
||||
$knowledgeItem->update(['is_active' => $newStatus]);
|
||||
|
||||
if (!$newStatus && $knowledgeItem->qdrant_point_id) {
|
||||
$this->ingestionService->deactivateKnowledgeItemInQdrant($knowledgeItem);
|
||||
$this->auditService->knowledgeItemDeactivated($knowledgeItem);
|
||||
} elseif ($newStatus) {
|
||||
// Reactivate — update payload Qdrant
|
||||
ReindexKnowledgeItemJob::dispatch($knowledgeItem->id);
|
||||
}
|
||||
|
||||
$status = $newStatus ? 'diaktifkan' : 'dinyahaktifkan';
|
||||
|
||||
return back()->with('success', "Item '{$knowledgeItem->title}' telah {$status}.");
|
||||
}
|
||||
|
||||
public function reindex(KnowledgeItem $knowledgeItem): RedirectResponse
|
||||
{
|
||||
ReindexKnowledgeItemJob::dispatch($knowledgeItem->id);
|
||||
|
||||
return back()->with('success', 'Re-embed telah dimulakan.');
|
||||
}
|
||||
|
||||
public function destroy(KnowledgeItem $knowledgeItem): RedirectResponse
|
||||
{
|
||||
// Deactivate dalam Qdrant dulu
|
||||
if ($knowledgeItem->qdrant_point_id) {
|
||||
$this->ingestionService->deactivateKnowledgeItemInQdrant($knowledgeItem);
|
||||
}
|
||||
|
||||
$knowledgeItem->delete(); // SoftDelete
|
||||
|
||||
return redirect()
|
||||
->route('admin.knowledge-items.index')
|
||||
->with('success', 'Knowledge item telah dipadam.');
|
||||
}
|
||||
}
|
||||
59
app/Http/Controllers/Chatbot/ChatController.php
Normal file
59
app/Http/Controllers/Chatbot/ChatController.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Chatbot;
|
||||
|
||||
use App\Actions\Chatbot\AskQuestionAction;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Chatbot\AskQuestionRequest;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use RuntimeException;
|
||||
|
||||
class ChatController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AskQuestionAction $askQuestionAction
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Paparan chatbot UI.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$categories = Category::active()->ordered()->get();
|
||||
$selectedCatId = $request->query('category_id');
|
||||
|
||||
return view('chatbot.index', compact('categories', 'selectedCatId'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint untuk submit soalan.
|
||||
* Return JSON — AJAX call dari chatbot UI.
|
||||
*/
|
||||
public function ask(AskQuestionRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result = $this->askQuestionAction->execute(
|
||||
$request->question,
|
||||
$request->category_id,
|
||||
$request
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'answer' => $result['answer'],
|
||||
'has_answer' => $result['has_answer'],
|
||||
'sources' => $result['sources'],
|
||||
'session_token' => $result['session_token'],
|
||||
]);
|
||||
} catch (RuntimeException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Perkhidmatan AI tidak tersedia pada masa ini. Sila cuba sebentar lagi.',
|
||||
'error' => config('app.debug') ? $e->getMessage() : null,
|
||||
], 503);
|
||||
}
|
||||
}
|
||||
}
|
||||
53
app/Http/Controllers/Chatbot/FeedbackController.php
Normal file
53
app/Http/Controllers/Chatbot/FeedbackController.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Chatbot;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ChatFeedback;
|
||||
use App\Models\ChatLog;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class FeedbackController extends Controller
|
||||
{
|
||||
/**
|
||||
* Simpan feedback untuk satu jawapan chatbot.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'chat_log_id' => ['required', 'integer', 'exists:chat_logs,id'],
|
||||
'rating' => ['required', 'in:helpful,not_helpful,partially_helpful'],
|
||||
'comment' => ['nullable', 'string', 'max:1000'],
|
||||
'correct_answer' => ['nullable', 'string', 'max:5000'],
|
||||
]);
|
||||
|
||||
$chatLog = ChatLog::findOrFail($validated['chat_log_id']);
|
||||
|
||||
// Elak duplikasi feedback
|
||||
if ($chatLog->feedback) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Anda sudah memberikan feedback untuk soalan ini.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
$feedback = ChatFeedback::create([
|
||||
'chat_log_id' => $chatLog->id,
|
||||
'user_id' => auth()->id(),
|
||||
'rating' => $validated['rating'],
|
||||
'comment' => $validated['comment'] ?? null,
|
||||
'correct_answer' => $validated['correct_answer'] ?? null,
|
||||
]);
|
||||
|
||||
// Flag untuk semakan admin jika jawapan tidak membantu
|
||||
if ($validated['rating'] === ChatFeedback::RATING_NOT_HELPFUL) {
|
||||
$chatLog->update(['is_flagged' => true]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Terima kasih atas maklum balas anda.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
29
app/Http/Middleware/EnsureUserRole.php
Normal file
29
app/Http/Middleware/EnsureUserRole.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* EnsureUserRole Middleware
|
||||
*
|
||||
* Periksa sama ada user mempunyai role yang diperlukan.
|
||||
* Guna: ->middleware('role:admin') atau ->middleware('role:admin,staff')
|
||||
*/
|
||||
class EnsureUserRole
|
||||
{
|
||||
public function handle(Request $request, Closure $next, string ...$roles): Response
|
||||
{
|
||||
if (!$request->user()) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
if (!in_array($request->user()->role, $roles)) {
|
||||
abort(403, 'Anda tidak mempunyai kebenaran untuk akses halaman ini.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
51
app/Http/Requests/Admin/SplitChunkRequest.php
Normal file
51
app/Http/Requests/Admin/SplitChunkRequest.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SplitChunkRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
// Hanya admin yang boleh split chunk
|
||||
return auth()->check() && auth()->user()->role === 'admin';
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'segments' => ['required', 'array', 'min:2', 'max:10'],
|
||||
'segments.*' => ['required', 'string', 'min:20', 'max:10000'],
|
||||
'notes' => ['nullable', 'string', 'max:500'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'segments.required' => 'Sila masukkan segmen untuk split.',
|
||||
'segments.min' => 'Split memerlukan sekurang-kurangnya 2 segmen.',
|
||||
'segments.max' => 'Maksimum 10 segmen dibenarkan dalam satu operasi split.',
|
||||
'segments.*.required' => 'Segmen tidak boleh kosong.',
|
||||
'segments.*.min' => 'Setiap segmen mesti sekurang-kurangnya 20 aksara untuk embedding bermakna.',
|
||||
'segments.*.max' => 'Setiap segmen tidak boleh melebihi 10,000 aksara.',
|
||||
'notes.max' => 'Nota tidak boleh melebihi 500 aksara.',
|
||||
];
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
// Trim setiap segmen dan buang segmen yang benar-benar kosong
|
||||
if ($this->has('segments')) {
|
||||
$segments = array_values(
|
||||
array_filter(
|
||||
array_map('trim', $this->input('segments', [])),
|
||||
fn($s) => $s !== ''
|
||||
)
|
||||
);
|
||||
|
||||
$this->merge(['segments' => $segments]);
|
||||
}
|
||||
}
|
||||
}
|
||||
44
app/Http/Requests/Admin/StoreCategoryRequest.php
Normal file
44
app/Http/Requests/Admin/StoreCategoryRequest.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreCategoryRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->canManageCategories();
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$categoryId = $this->route('category')?->id;
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'slug' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:100',
|
||||
'regex:/^[a-z0-9-]+$/',
|
||||
Rule::unique('categories', 'slug')->ignore($categoryId)->whereNull('deleted_at'),
|
||||
],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
'color' => ['nullable', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
||||
'is_active' => ['boolean'],
|
||||
'sort_order' => ['nullable', 'integer', 'min:0'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.required' => 'Nama kategori wajib diisi.',
|
||||
'slug.unique' => 'Slug ini sudah digunakan.',
|
||||
'slug.regex' => 'Slug hanya boleh mengandungi huruf kecil, angka, dan tanda (-)',
|
||||
'color.regex' => 'Warna mesti dalam format hex (contoh: #3b82f6)',
|
||||
];
|
||||
}
|
||||
}
|
||||
51
app/Http/Requests/Admin/StoreDocumentRequest.php
Normal file
51
app/Http/Requests/Admin/StoreDocumentRequest.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreDocumentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->canManageDocuments();
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$maxSizeKb = config('knowledgebase.upload.max_file_size', 20480);
|
||||
|
||||
return [
|
||||
'category_id' => ['required', 'integer', 'exists:categories,id'],
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'file' => [
|
||||
'required',
|
||||
'file',
|
||||
'mimes:pdf',
|
||||
"max:{$maxSizeKb}",
|
||||
],
|
||||
'effective_date' => ['nullable', 'date'],
|
||||
'expiry_date' => ['nullable', 'date', 'after_or_equal:effective_date'],
|
||||
'tags' => ['nullable', 'array'],
|
||||
'tags.*' => ['string', 'max:50'],
|
||||
'language' => ['nullable', 'in:ms,en'],
|
||||
'change_notes' => ['nullable', 'string', 'max:500'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
$maxMb = round(config('knowledgebase.upload.max_file_size', 20480) / 1024);
|
||||
|
||||
return [
|
||||
'category_id.required' => 'Kategori wajib dipilih.',
|
||||
'category_id.exists' => 'Kategori tidak wujud.',
|
||||
'title.required' => 'Tajuk dokumen wajib diisi.',
|
||||
'file.required' => 'Fail PDF wajib diupload.',
|
||||
'file.mimes' => 'Hanya fail PDF yang dibenarkan.',
|
||||
'file.max' => "Saiz fail tidak boleh melebihi {$maxMb}MB.",
|
||||
'expiry_date.after_or_equal' => 'Tarikh luput mesti selepas atau sama dengan tarikh kuat kuasa.',
|
||||
];
|
||||
}
|
||||
}
|
||||
44
app/Http/Requests/Admin/StoreKnowledgeItemRequest.php
Normal file
44
app/Http/Requests/Admin/StoreKnowledgeItemRequest.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Models\KnowledgeItem;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreKnowledgeItemRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()->canManageDocuments();
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'category_id' => ['required', 'integer', 'exists:categories,id'],
|
||||
'item_type' => ['required', 'in:' . implode(',', array_keys(KnowledgeItem::typeLabels()))],
|
||||
'title' => ['required', 'string', 'max:500'],
|
||||
'content' => ['required', 'string', 'max:10000'],
|
||||
'content_short' => ['nullable', 'string', 'max:500'],
|
||||
'tags' => ['nullable', 'array'],
|
||||
'tags.*' => ['string', 'max:50'],
|
||||
'language' => ['nullable', 'in:ms,en'],
|
||||
'effective_date' => ['nullable', 'date'],
|
||||
'expiry_date' => ['nullable', 'date'],
|
||||
'is_active' => ['boolean'],
|
||||
'is_public' => ['boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'category_id.required' => 'Kategori wajib dipilih.',
|
||||
'item_type.required' => 'Jenis item wajib dipilih.',
|
||||
'item_type.in' => 'Jenis item tidak sah.',
|
||||
'title.required' => 'Tajuk/soalan wajib diisi.',
|
||||
'content.required' => 'Kandungan/jawapan wajib diisi.',
|
||||
'content.max' => 'Kandungan terlalu panjang (had: 10,000 karakter).',
|
||||
];
|
||||
}
|
||||
}
|
||||
42
app/Http/Requests/Admin/UpdateChunkRequest.php
Normal file
42
app/Http/Requests/Admin/UpdateChunkRequest.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateChunkRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
// Hanya admin yang boleh edit chunk
|
||||
return auth()->check() && auth()->user()->role === 'admin';
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'final_text' => ['required', 'string', 'min:20', 'max:10000'],
|
||||
'notes' => ['nullable', 'string', 'max:500'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'final_text.required' => 'final_text tidak boleh kosong.',
|
||||
'final_text.min' => 'final_text terlalu pendek. Minimum 20 aksara diperlukan untuk embedding bermakna.',
|
||||
'final_text.max' => 'final_text terlalu panjang. Maksimum 10,000 aksara.',
|
||||
'notes.max' => 'Nota tidak boleh melebihi 500 aksara.',
|
||||
];
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
// Trim whitespace pada final_text sebelum validasi
|
||||
if ($this->has('final_text')) {
|
||||
$this->merge([
|
||||
'final_text' => trim($this->input('final_text')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
58
app/Http/Requests/Chatbot/AskQuestionRequest.php
Normal file
58
app/Http/Requests/Chatbot/AskQuestionRequest.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Chatbot;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class AskQuestionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true; // Public access
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'question' => [
|
||||
'required',
|
||||
'string',
|
||||
'min:3',
|
||||
'max:1000',
|
||||
],
|
||||
'category_id' => [
|
||||
'nullable',
|
||||
'integer',
|
||||
'exists:categories,id',
|
||||
],
|
||||
'session_token' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:64',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'question.required' => 'Soalan wajib diisi.',
|
||||
'question.min' => 'Soalan terlalu pendek (minimum 3 karakter).',
|
||||
'question.max' => 'Soalan terlalu panjang (maksimum 1000 karakter).',
|
||||
'category_id.exists' => 'Kategori tidak wujud.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize soalan sebelum diproses.
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if ($this->has('question')) {
|
||||
// Buang karakter kawalan berbahaya yang mungkin prompt injection
|
||||
$sanitized = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $this->question);
|
||||
$sanitized = trim($sanitized);
|
||||
$this->merge(['question' => $sanitized]);
|
||||
}
|
||||
}
|
||||
}
|
||||
67
app/Jobs/LogChatInteractionJob.php
Normal file
67
app/Jobs/LogChatInteractionJob.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\ChatLog;
|
||||
use App\Models\ChatSession;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* LogChatInteractionJob
|
||||
*
|
||||
* Log pertanyaan + jawapan chatbot secara asynchronous.
|
||||
* Hantar ke queue supaya response chatbot tidak ditangguh oleh operasi DB.
|
||||
*/
|
||||
class LogChatInteractionJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
public int $timeout = 30;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $sessionToken,
|
||||
private readonly ?int $userId,
|
||||
private readonly ?int $categoryId,
|
||||
private readonly string $question,
|
||||
private readonly string $answer,
|
||||
private readonly array $sources,
|
||||
private readonly array $contextChunks,
|
||||
private readonly string $modelUsed,
|
||||
private readonly ?int $tokensUsed,
|
||||
private readonly float $responseTime,
|
||||
private readonly bool $hasAnswer,
|
||||
) {
|
||||
$this->onQueue(config('knowledgebase.queue.chat_log', 'default'));
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$session = ChatSession::where('session_token', $this->sessionToken)->first();
|
||||
|
||||
if (!$session) {
|
||||
return;
|
||||
}
|
||||
|
||||
ChatLog::create([
|
||||
'chat_session_id' => $session->id,
|
||||
'user_id' => $this->userId,
|
||||
'category_id' => $this->categoryId,
|
||||
'question' => $this->question,
|
||||
'answer' => $this->answer,
|
||||
'sources_used' => $this->sources,
|
||||
'context_chunks' => $this->contextChunks,
|
||||
'model_used' => $this->modelUsed,
|
||||
'tokens_used' => $this->tokensUsed,
|
||||
'response_time' => $this->responseTime,
|
||||
'has_answer' => $this->hasAnswer,
|
||||
'is_flagged' => false,
|
||||
]);
|
||||
|
||||
$session->update(['last_activity_at' => now()]);
|
||||
}
|
||||
}
|
||||
65
app/Jobs/ProcessUploadedDocumentJob.php
Normal file
65
app/Jobs/ProcessUploadedDocumentJob.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\DocumentVersion;
|
||||
use App\Services\KnowledgeBase\IngestionService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* ProcessUploadedDocumentJob
|
||||
*
|
||||
* Job utama untuk proses dokumen selepas upload.
|
||||
* Melaksanakan: extract PDF → chunk → embed → sync Qdrant
|
||||
*
|
||||
* Dihantar ke queue supaya upload tidak timeout.
|
||||
* Retry: 2 kali jika gagal, dengan 60s delay.
|
||||
*/
|
||||
class ProcessUploadedDocumentJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 2;
|
||||
public int $backoff = 60; // saat antara retry
|
||||
public int $timeout = 600; // 10 minit — PDF besar mungkin ambil masa
|
||||
|
||||
public function __construct(
|
||||
private readonly int $documentVersionId
|
||||
) {
|
||||
$this->onQueue(config('knowledgebase.queue.ingestion', 'default'));
|
||||
}
|
||||
|
||||
public function handle(IngestionService $ingestionService): void
|
||||
{
|
||||
$version = DocumentVersion::with(['document.category'])->findOrFail($this->documentVersionId);
|
||||
|
||||
Log::info("ProcessUploadedDocumentJob mula untuk version {$this->documentVersionId}");
|
||||
|
||||
$ingestionService->processDocumentVersion($version);
|
||||
|
||||
Log::info("ProcessUploadedDocumentJob selesai untuk version {$this->documentVersionId}");
|
||||
}
|
||||
|
||||
public function failed(Throwable $exception): void
|
||||
{
|
||||
Log::error("ProcessUploadedDocumentJob GAGAL untuk version {$this->documentVersionId}", [
|
||||
'error' => $exception->getMessage(),
|
||||
'trace' => $exception->getTraceAsString(),
|
||||
]);
|
||||
|
||||
// Kemaskini status ke failed dalam database
|
||||
$version = DocumentVersion::find($this->documentVersionId);
|
||||
if ($version) {
|
||||
$version->updateStatus(
|
||||
DocumentVersion::STATUS_FAILED,
|
||||
'Job gagal: ' . $exception->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
171
app/Jobs/ReindexChunkJob.php
Normal file
171
app/Jobs/ReindexChunkJob.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\DocumentChunk;
|
||||
use App\Models\ProcessingLog;
|
||||
use App\Services\Ollama\OllamaService;
|
||||
use App\Services\Qdrant\QdrantService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* ReindexChunkJob
|
||||
*
|
||||
* Reindex satu chunk sahaja:
|
||||
* 1. Embed semula getEmbeddableText() (final_text > cleaned_text > content)
|
||||
* 2. Upsert point ke Qdrant (guna semula qdrant_point_id lama jika ada)
|
||||
* 3. Kemaskini chunk: markAsEmbedded(), needs_reindex=false
|
||||
*
|
||||
* Berbeza dari ReindexDocumentJob yang reindex SELURUH dokumen.
|
||||
* Job ini digunakan untuk:
|
||||
* - Edit final_text oleh admin
|
||||
* - Include semula chunk yang excluded
|
||||
* - Manual trigger reindex
|
||||
* - Child chunks hasil split (perlu embed buat pertama kali)
|
||||
*/
|
||||
class ReindexChunkJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
public int $backoff = 30; // Tunggu 30s sebelum retry
|
||||
public int $timeout = 120; // 2 minit maksimum per chunk
|
||||
|
||||
public function __construct(
|
||||
public readonly int $chunkId,
|
||||
) {
|
||||
$this->onQueue(config('knowledgebase.queue.ingestion', 'default'));
|
||||
}
|
||||
|
||||
public function handle(OllamaService $ollama, QdrantService $qdrant): void
|
||||
{
|
||||
// Load chunk dengan semua relasi yang diperlukan untuk toQdrantPayload()
|
||||
$chunk = DocumentChunk::with(['document.category', 'documentVersion'])
|
||||
->find($this->chunkId);
|
||||
|
||||
if (! $chunk) {
|
||||
Log::warning("ReindexChunkJob: Chunk #{$this->chunkId} tidak dijumpai. Job dilangkau.");
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Guard: Jangan reindex chunk yang tidak sepatutnya ──────────────
|
||||
|
||||
if ($chunk->isSuperseded()) {
|
||||
Log::info("ReindexChunkJob: Chunk #{$this->chunkId} adalah superseded. Job dilangkau.", [
|
||||
'chunk_index' => $chunk->chunk_index,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($chunk->exclude_from_index) {
|
||||
Log::info("ReindexChunkJob: Chunk #{$this->chunkId} dikecualikan dari index. Job dilangkau.", [
|
||||
'chunk_index' => $chunk->chunk_index,
|
||||
'chunk_status' => $chunk->chunk_status,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Ambil teks untuk embedding ─────────────────────────────────────
|
||||
|
||||
$textToEmbed = $chunk->getEmbeddableText();
|
||||
|
||||
if (empty(trim($textToEmbed))) {
|
||||
$chunk->update(['chunk_status' => DocumentChunk::STATUS_FAILED_EMBEDDING]);
|
||||
|
||||
Log::error("ReindexChunkJob: Chunk #{$this->chunkId} mempunyai teks kosong.", [
|
||||
'has_final_text' => ! is_null($chunk->final_text),
|
||||
'has_cleaned_text' => ! is_null($chunk->cleaned_text),
|
||||
'content_length' => mb_strlen($chunk->content),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Log: mula proses ───────────────────────────────────────────────
|
||||
|
||||
ProcessingLog::record(
|
||||
DocumentChunk::class,
|
||||
$chunk->id,
|
||||
ProcessingLog::STAGE_EMBED,
|
||||
ProcessingLog::STATUS_STARTED,
|
||||
null,
|
||||
[
|
||||
'chunk_index' => $chunk->chunk_index,
|
||||
'text_source' => $chunk->final_text ? 'final_text'
|
||||
: ($chunk->cleaned_text ? 'cleaned_text' : 'content'),
|
||||
'text_length' => mb_strlen($textToEmbed),
|
||||
'is_reindex' => true,
|
||||
]
|
||||
);
|
||||
|
||||
try {
|
||||
// ── Embed teks ─────────────────────────────────────────────────
|
||||
$vector = $ollama->embed($textToEmbed);
|
||||
|
||||
// ── Tentukan point ID ──────────────────────────────────────────
|
||||
// Guna semula qdrant_point_id lama jika ada → upsert akan overwrite
|
||||
// Ini mengelakkan "ghost points" yang tidak dirujuk oleh mana-mana chunk
|
||||
$pointId = $chunk->qdrant_point_id ?? (string) Str::uuid();
|
||||
|
||||
// ── Upsert ke Qdrant ───────────────────────────────────────────
|
||||
$qdrant->ensureCollectionExists();
|
||||
$qdrant->upsertPoint($pointId, $vector, $chunk->toQdrantPayload());
|
||||
|
||||
// ── Kemaskini chunk ────────────────────────────────────────────
|
||||
$chunk->markAsEmbedded($pointId);
|
||||
|
||||
ProcessingLog::record(
|
||||
DocumentChunk::class,
|
||||
$chunk->id,
|
||||
ProcessingLog::STAGE_QDRANT,
|
||||
ProcessingLog::STATUS_COMPLETED,
|
||||
null,
|
||||
[
|
||||
'point_id' => $pointId,
|
||||
'is_reindex' => true,
|
||||
]
|
||||
);
|
||||
|
||||
Log::info("ReindexChunkJob: Chunk #{$this->chunkId} berjaya direindex.", [
|
||||
'chunk_index' => $chunk->chunk_index,
|
||||
'point_id' => $pointId,
|
||||
]);
|
||||
|
||||
} catch (RuntimeException $e) {
|
||||
// Tandakan failed — job akan cuba semula (mengikut $tries)
|
||||
$chunk->update(['chunk_status' => DocumentChunk::STATUS_FAILED_EMBEDDING]);
|
||||
|
||||
ProcessingLog::record(
|
||||
DocumentChunk::class,
|
||||
$chunk->id,
|
||||
ProcessingLog::STAGE_EMBED,
|
||||
ProcessingLog::STATUS_FAILED,
|
||||
$e->getMessage(),
|
||||
['attempt' => $this->attempts()]
|
||||
);
|
||||
|
||||
Log::error("ReindexChunkJob: Gagal reindex chunk #{$this->chunkId}.", [
|
||||
'error' => $e->getMessage(),
|
||||
'attempt' => $this->attempts(),
|
||||
]);
|
||||
|
||||
throw $e; // Allow retry
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dipanggil selepas semua retry habis.
|
||||
*/
|
||||
public function failed(\Throwable $e): void
|
||||
{
|
||||
DocumentChunk::where('id', $this->chunkId)
|
||||
->update(['chunk_status' => DocumentChunk::STATUS_FAILED_EMBEDDING]);
|
||||
|
||||
Log::error("ReindexChunkJob: Chunk #{$this->chunkId} gagal selepas semua cubaan semula.", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
80
app/Jobs/ReindexDocumentJob.php
Normal file
80
app/Jobs/ReindexDocumentJob.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\DocumentVersion;
|
||||
use App\Services\KnowledgeBase\AuditService;
|
||||
use App\Services\KnowledgeBase\IngestionService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* ReindexDocumentJob
|
||||
*
|
||||
* Reindex (reprocess embedding) untuk document version tertentu.
|
||||
* Berguna bila:
|
||||
* - model embedding ditukar
|
||||
* - chunking strategy dikemaskini
|
||||
* - Qdrant collection diset semula
|
||||
*
|
||||
* Proses: Deactivate chunk lama → Delete chunk lama → Re-chunk → Re-embed → Qdrant
|
||||
*/
|
||||
class ReindexDocumentJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 2;
|
||||
public int $backoff = 60;
|
||||
public int $timeout = 600;
|
||||
|
||||
public function __construct(
|
||||
private readonly int $documentVersionId
|
||||
) {
|
||||
$this->onQueue(config('knowledgebase.queue.ingestion', 'default'));
|
||||
}
|
||||
|
||||
public function handle(IngestionService $ingestionService, AuditService $auditService): void
|
||||
{
|
||||
$version = DocumentVersion::with(['document.category'])->findOrFail($this->documentVersionId);
|
||||
|
||||
Log::info("ReindexDocumentJob mula untuk version {$this->documentVersionId}");
|
||||
|
||||
// Deactivate point lama dalam Qdrant
|
||||
$ingestionService->deactivateVersionInQdrant($version);
|
||||
|
||||
// Padam chunk lama dari MySQL untuk reprocess
|
||||
$version->chunks()->delete();
|
||||
|
||||
// Reset status dan mula proses semula
|
||||
$version->update([
|
||||
'processing_status' => DocumentVersion::STATUS_PENDING,
|
||||
'processing_error' => null,
|
||||
'processing_completed_at' => null,
|
||||
]);
|
||||
|
||||
// Reprocess
|
||||
$ingestionService->processDocumentVersion($version);
|
||||
|
||||
$auditService->documentReindexed($version->document, $version);
|
||||
|
||||
Log::info("ReindexDocumentJob selesai untuk version {$this->documentVersionId}");
|
||||
}
|
||||
|
||||
public function failed(Throwable $exception): void
|
||||
{
|
||||
Log::error("ReindexDocumentJob GAGAL untuk version {$this->documentVersionId}", [
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
$version = DocumentVersion::find($this->documentVersionId);
|
||||
$version?->updateStatus(
|
||||
DocumentVersion::STATUS_FAILED,
|
||||
'Reindex gagal: ' . $exception->getMessage()
|
||||
);
|
||||
}
|
||||
}
|
||||
50
app/Jobs/ReindexKnowledgeItemJob.php
Normal file
50
app/Jobs/ReindexKnowledgeItemJob.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\KnowledgeItem;
|
||||
use App\Services\KnowledgeBase\IngestionService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* ReindexKnowledgeItemJob
|
||||
*
|
||||
* Reindex (re-embed) satu knowledge item ke Qdrant.
|
||||
* Berguna bila kandungan FAQ/polisi dikemaskini.
|
||||
*/
|
||||
class ReindexKnowledgeItemJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
public int $backoff = 30;
|
||||
public int $timeout = 120;
|
||||
|
||||
public function __construct(
|
||||
private readonly int $knowledgeItemId
|
||||
) {
|
||||
$this->onQueue(config('knowledgebase.queue.embedding', 'default'));
|
||||
}
|
||||
|
||||
public function handle(IngestionService $ingestionService): void
|
||||
{
|
||||
$item = KnowledgeItem::with('category')->findOrFail($this->knowledgeItemId);
|
||||
|
||||
Log::info("ReindexKnowledgeItemJob untuk item {$this->knowledgeItemId}");
|
||||
|
||||
$ingestionService->processKnowledgeItem($item);
|
||||
}
|
||||
|
||||
public function failed(Throwable $exception): void
|
||||
{
|
||||
Log::error("ReindexKnowledgeItemJob GAGAL untuk item {$this->knowledgeItemId}", [
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
55
app/Models/AuditLog.php
Normal file
55
app/Models/AuditLog.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class AuditLog extends Model
|
||||
{
|
||||
// Audit log adalah append-only — tiada updated_at
|
||||
const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'event',
|
||||
'auditable_type',
|
||||
'auditable_id',
|
||||
'old_values',
|
||||
'new_values',
|
||||
'description',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'old_values' => 'array',
|
||||
'new_values' => 'array',
|
||||
];
|
||||
|
||||
// === Relationships ===
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
// === Scopes ===
|
||||
|
||||
public function scopeForModel(Builder $query, string $type, int $id): Builder
|
||||
{
|
||||
return $query->where('auditable_type', $type)
|
||||
->where('auditable_id', $id);
|
||||
}
|
||||
|
||||
public function scopeByEvent(Builder $query, string $event): Builder
|
||||
{
|
||||
return $query->where('event', $event);
|
||||
}
|
||||
|
||||
public function scopeRecent(Builder $query, int $days = 30): Builder
|
||||
{
|
||||
return $query->where('created_at', '>=', now()->subDays($days));
|
||||
}
|
||||
}
|
||||
75
app/Models/Category.php
Normal file
75
app/Models/Category.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Category extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'color',
|
||||
'is_active',
|
||||
'sort_order',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
// Auto-generate slug jika tidak disediakan
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (Category $category) {
|
||||
if (empty($category->slug)) {
|
||||
$category->slug = Str::slug($category->name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// === Relationships ===
|
||||
|
||||
public function documents(): HasMany
|
||||
{
|
||||
return $this->hasMany(Document::class);
|
||||
}
|
||||
|
||||
public function knowledgeItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(KnowledgeItem::class);
|
||||
}
|
||||
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
// === Scopes ===
|
||||
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeOrdered(Builder $query): Builder
|
||||
{
|
||||
return $query->orderBy('sort_order')->orderBy('name');
|
||||
}
|
||||
|
||||
// === Accessors ===
|
||||
|
||||
public function getActiveDocumentCountAttribute(): int
|
||||
{
|
||||
return $this->documents()->where('is_active', true)->count();
|
||||
}
|
||||
}
|
||||
71
app/Models/ChatFeedback.php
Normal file
71
app/Models/ChatFeedback.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ChatFeedback extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'chat_log_id',
|
||||
'user_id',
|
||||
'rating',
|
||||
'comment',
|
||||
'correct_answer',
|
||||
'converted_to_faq',
|
||||
'converted_faq_id',
|
||||
'reviewed_by',
|
||||
'reviewed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'converted_to_faq' => 'boolean',
|
||||
'reviewed_at' => 'datetime',
|
||||
];
|
||||
|
||||
const RATING_HELPFUL = 'helpful';
|
||||
const RATING_NOT_HELPFUL = 'not_helpful';
|
||||
const RATING_PARTIALLY_HELPFUL = 'partially_helpful';
|
||||
|
||||
// === Relationships ===
|
||||
|
||||
public function chatLog(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ChatLog::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function reviewer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'reviewed_by');
|
||||
}
|
||||
|
||||
public function convertedFaq(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(KnowledgeItem::class, 'converted_faq_id');
|
||||
}
|
||||
|
||||
// === Scopes ===
|
||||
|
||||
public function scopeNegative(Builder $query): Builder
|
||||
{
|
||||
return $query->where('rating', self::RATING_NOT_HELPFUL);
|
||||
}
|
||||
|
||||
public function scopeNotConverted(Builder $query): Builder
|
||||
{
|
||||
return $query->where('converted_to_faq', false)
|
||||
->where('rating', '!=', self::RATING_HELPFUL);
|
||||
}
|
||||
|
||||
public function scopeUnreviewed(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('reviewed_by');
|
||||
}
|
||||
}
|
||||
73
app/Models/ChatLog.php
Normal file
73
app/Models/ChatLog.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ChatLog extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'chat_session_id',
|
||||
'user_id',
|
||||
'category_id',
|
||||
'question',
|
||||
'answer',
|
||||
'sources_used',
|
||||
'context_chunks',
|
||||
'model_used',
|
||||
'tokens_used',
|
||||
'response_time',
|
||||
'has_answer',
|
||||
'is_flagged',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sources_used' => 'array',
|
||||
'context_chunks' => 'array',
|
||||
'has_answer' => 'boolean',
|
||||
'is_flagged' => 'boolean',
|
||||
'response_time' => 'float',
|
||||
];
|
||||
|
||||
// === Relationships ===
|
||||
|
||||
public function session(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ChatSession::class, 'chat_session_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function feedback(): HasOne
|
||||
{
|
||||
return $this->hasOne(ChatFeedback::class);
|
||||
}
|
||||
|
||||
// === Scopes ===
|
||||
|
||||
public function scopeFlagged(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_flagged', true);
|
||||
}
|
||||
|
||||
public function scopeNoAnswer(Builder $query): Builder
|
||||
{
|
||||
return $query->where('has_answer', false);
|
||||
}
|
||||
|
||||
public function scopeWithoutFeedback(Builder $query): Builder
|
||||
{
|
||||
return $query->whereDoesntHave('feedback');
|
||||
}
|
||||
}
|
||||
58
app/Models/ChatSession.php
Normal file
58
app/Models/ChatSession.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ChatSession extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'session_token',
|
||||
'user_id',
|
||||
'category_id',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'last_activity_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'last_activity_at' => 'datetime',
|
||||
];
|
||||
|
||||
// Auto-generate session token
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (ChatSession $session) {
|
||||
if (empty($session->session_token)) {
|
||||
$session->session_token = Str::random(48);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// === Relationships ===
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function logs(): HasMany
|
||||
{
|
||||
return $this->hasMany(ChatLog::class)->orderBy('created_at');
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
public function touch($attribute = null): bool
|
||||
{
|
||||
return $this->update(['last_activity_at' => now()]);
|
||||
}
|
||||
}
|
||||
120
app/Models/ChunkAudit.php
Normal file
120
app/Models/ChunkAudit.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* ChunkAudit
|
||||
*
|
||||
* Audit trail khusus untuk operasi chunk.
|
||||
* Append-only — tiada update atau delete.
|
||||
*
|
||||
* Berbeza dari AuditLog: ChunkAudit menyimpan perubahan teks penuh (old/new final_text)
|
||||
* yang terlalu besar untuk JSON kolum dalam audit_logs.
|
||||
*/
|
||||
class ChunkAudit extends Model
|
||||
{
|
||||
// Append-only — tiada updated_at
|
||||
const UPDATED_AT = null;
|
||||
|
||||
// === Operation constants ===
|
||||
const OP_EDIT_FINAL_TEXT = 'edit_final_text';
|
||||
const OP_EXCLUDE = 'exclude';
|
||||
const OP_INCLUDE = 'include';
|
||||
const OP_REINDEX = 'reindex';
|
||||
const OP_SPLIT_PARENT = 'split_parent';
|
||||
const OP_SPLIT_CHILD = 'split_child';
|
||||
|
||||
protected $fillable = [
|
||||
'document_chunk_id',
|
||||
'user_id',
|
||||
'operation',
|
||||
'old_final_text',
|
||||
'new_final_text',
|
||||
'old_status',
|
||||
'new_status',
|
||||
'metadata',
|
||||
'notes',
|
||||
'ip_address',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// RELATIONSHIPS
|
||||
// =========================================================================
|
||||
|
||||
public function chunk(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DocumentChunk::class, 'document_chunk_id');
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// FACTORY METHOD
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Cipta satu rekod audit dengan data dari request semasa.
|
||||
*
|
||||
* @param int $chunkId ID chunk yang terlibat
|
||||
* @param string $operation Jenis operasi (guna konstant OP_*)
|
||||
* @param array $data Data tambahan (old_final_text, new_status, metadata, dll.)
|
||||
* @param string|null $notes Nota admin
|
||||
*/
|
||||
public static function record(
|
||||
int $chunkId,
|
||||
string $operation,
|
||||
array $data = [],
|
||||
?string $notes = null
|
||||
): static {
|
||||
return static::create(array_merge([
|
||||
'document_chunk_id' => $chunkId,
|
||||
'user_id' => auth()->id(),
|
||||
'operation' => $operation,
|
||||
'notes' => $notes,
|
||||
'ip_address' => request()->ip(),
|
||||
'created_at' => now(),
|
||||
], $data));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DISPLAY HELPERS
|
||||
// =========================================================================
|
||||
|
||||
public function getOperationLabel(): string
|
||||
{
|
||||
return match ($this->operation) {
|
||||
self::OP_EDIT_FINAL_TEXT => 'Edit Final Text',
|
||||
self::OP_EXCLUDE => 'Dikecualikan',
|
||||
self::OP_INCLUDE => 'Dikembalikan',
|
||||
self::OP_REINDEX => 'Reindex',
|
||||
self::OP_SPLIT_PARENT => 'Split (asal)',
|
||||
self::OP_SPLIT_CHILD => 'Split (baharu)',
|
||||
default => ucfirst(str_replace('_', ' ', $this->operation)),
|
||||
};
|
||||
}
|
||||
|
||||
public function getOperationBadgeClass(): string
|
||||
{
|
||||
return match ($this->operation) {
|
||||
self::OP_EDIT_FINAL_TEXT => 'bg-primary',
|
||||
self::OP_EXCLUDE => 'bg-secondary',
|
||||
self::OP_INCLUDE => 'bg-success',
|
||||
self::OP_REINDEX => 'bg-info text-dark',
|
||||
self::OP_SPLIT_PARENT => 'bg-warning text-dark',
|
||||
self::OP_SPLIT_CHILD => 'bg-warning text-dark',
|
||||
default => 'bg-light text-dark border',
|
||||
};
|
||||
}
|
||||
}
|
||||
99
app/Models/Document.php
Normal file
99
app/Models/Document.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class Document extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'category_id',
|
||||
'title',
|
||||
'description',
|
||||
'status',
|
||||
'is_active',
|
||||
'effective_date',
|
||||
'expiry_date',
|
||||
'tags',
|
||||
'language',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'effective_date' => 'date',
|
||||
'expiry_date' => 'date',
|
||||
'tags' => 'array',
|
||||
];
|
||||
|
||||
// Status constants untuk kejelasan
|
||||
const STATUS_DRAFT = 'draft';
|
||||
const STATUS_PROCESSING = 'processing';
|
||||
const STATUS_ACTIVE = 'active';
|
||||
const STATUS_INACTIVE = 'inactive';
|
||||
const STATUS_FAILED = 'failed';
|
||||
|
||||
// === Relationships ===
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function versions(): HasMany
|
||||
{
|
||||
return $this->hasMany(DocumentVersion::class)->orderBy('version_number');
|
||||
}
|
||||
|
||||
public function currentVersion(): HasOne
|
||||
{
|
||||
return $this->hasOne(DocumentVersion::class)->where('is_current', true);
|
||||
}
|
||||
|
||||
public function chunks(): HasMany
|
||||
{
|
||||
return $this->hasMany(DocumentChunk::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function updater(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by');
|
||||
}
|
||||
|
||||
// === Scopes ===
|
||||
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true)->where('status', self::STATUS_ACTIVE);
|
||||
}
|
||||
|
||||
public function scopeByCategory(Builder $query, int $categoryId): Builder
|
||||
{
|
||||
return $query->where('category_id', $categoryId);
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
public function getLatestVersionNumber(): int
|
||||
{
|
||||
return $this->versions()->max('version_number') ?? 0;
|
||||
}
|
||||
|
||||
public function isProcessing(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PROCESSING;
|
||||
}
|
||||
}
|
||||
357
app/Models/DocumentChunk.php
Normal file
357
app/Models/DocumentChunk.php
Normal file
@@ -0,0 +1,357 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class DocumentChunk extends Model
|
||||
{
|
||||
// =========================================================================
|
||||
// STATUS CONSTANTS
|
||||
// =========================================================================
|
||||
|
||||
/** Baru dicipta, belum di-embed */
|
||||
const STATUS_PENDING = 'pending';
|
||||
|
||||
/** Berjaya di-embed, aktif dalam Qdrant */
|
||||
const STATUS_INDEXED = 'indexed';
|
||||
|
||||
/** Ditandakan untuk semak admin, masih aktif dalam Qdrant */
|
||||
const STATUS_NEEDS_REVIEW = 'needs_review';
|
||||
|
||||
/** final_text ditukar, perlu embed semula */
|
||||
const STATUS_NEEDS_REINDEX = 'needs_reindex';
|
||||
|
||||
/** Admin kecualikan — is_active=false dalam Qdrant */
|
||||
const STATUS_EXCLUDED = 'excluded';
|
||||
|
||||
/** Chunk asal selepas split — digantikan oleh child chunks */
|
||||
const STATUS_SUPERSEDED = 'superseded';
|
||||
|
||||
/** Embedding gagal selepas semua retry */
|
||||
const STATUS_FAILED_EMBEDDING = 'failed_embedding';
|
||||
|
||||
// =========================================================================
|
||||
// MODEL DEFINITION
|
||||
// =========================================================================
|
||||
|
||||
protected $fillable = [
|
||||
'document_id',
|
||||
'document_version_id',
|
||||
'chunk_index',
|
||||
'page_number',
|
||||
'content', // raw_text asal — TIDAK PERNAH DIUBAH
|
||||
'cleaned_text', // auto-cleaned version (optional)
|
||||
'final_text', // teks akhir untuk embedding (admin-edited)
|
||||
'token_count',
|
||||
'section_heading',
|
||||
'qdrant_point_id',
|
||||
'is_embedded',
|
||||
'is_active',
|
||||
'embedded_at',
|
||||
'chunk_status',
|
||||
'is_edited',
|
||||
'exclude_from_index',
|
||||
'needs_reindex',
|
||||
'parent_chunk_id',
|
||||
'split_group_id',
|
||||
'split_order',
|
||||
'edited_by',
|
||||
'edited_at',
|
||||
'last_embedded_at',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_embedded' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'is_edited' => 'boolean',
|
||||
'exclude_from_index' => 'boolean',
|
||||
'needs_reindex' => 'boolean',
|
||||
'embedded_at' => 'datetime',
|
||||
'edited_at' => 'datetime',
|
||||
'last_embedded_at' => 'datetime',
|
||||
];
|
||||
|
||||
// =========================================================================
|
||||
// RELATIONSHIPS
|
||||
// =========================================================================
|
||||
|
||||
public function document(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Document::class);
|
||||
}
|
||||
|
||||
public function documentVersion(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DocumentVersion::class);
|
||||
}
|
||||
|
||||
/** Chunk asal jika ini adalah hasil split */
|
||||
public function parentChunk(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DocumentChunk::class, 'parent_chunk_id');
|
||||
}
|
||||
|
||||
/** Child chunks jika chunk ini pernah di-split */
|
||||
public function childChunks(): HasMany
|
||||
{
|
||||
return $this->hasMany(DocumentChunk::class, 'parent_chunk_id')
|
||||
->orderBy('split_order');
|
||||
}
|
||||
|
||||
/** Admin yang terakhir edit chunk ini */
|
||||
public function editor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'edited_by');
|
||||
}
|
||||
|
||||
/** Audit trail khusus chunk ini */
|
||||
public function audits(): HasMany
|
||||
{
|
||||
return $this->hasMany(ChunkAudit::class, 'document_chunk_id')
|
||||
->latest('created_at');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// QUERY SCOPES
|
||||
// =========================================================================
|
||||
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeEmbedded(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_embedded', true);
|
||||
}
|
||||
|
||||
public function scopeNotEmbedded(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_embedded', false);
|
||||
}
|
||||
|
||||
public function scopeForVersion(Builder $query, int $versionId): Builder
|
||||
{
|
||||
return $query->where('document_version_id', $versionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk yang layak untuk indexing (digunakan oleh chatbot).
|
||||
* Tidak termasuk: excluded, superseded, failed_embedding.
|
||||
*/
|
||||
public function scopeIndexable(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->where('is_active', true)
|
||||
->where('exclude_from_index', false)
|
||||
->whereNotIn('chunk_status', [
|
||||
self::STATUS_EXCLUDED,
|
||||
self::STATUS_SUPERSEDED,
|
||||
self::STATUS_FAILED_EMBEDDING,
|
||||
]);
|
||||
}
|
||||
|
||||
public function scopeNeedsReindex(Builder $query): Builder
|
||||
{
|
||||
return $query->where('needs_reindex', true);
|
||||
}
|
||||
|
||||
public function scopeByStatus(Builder $query, string $status): Builder
|
||||
{
|
||||
return $query->where('chunk_status', $status);
|
||||
}
|
||||
|
||||
/** Hanya chunk asal (bukan hasil split) */
|
||||
public function scopeTopLevel(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('parent_chunk_id');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TEXT HELPERS
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Teks yang digunakan untuk embedding.
|
||||
* Priority: final_text > cleaned_text > content
|
||||
*
|
||||
* Ini adalah SATU-SATUNYA method yang perlu digunakan untuk embedding.
|
||||
*/
|
||||
public function getEmbeddableText(): string
|
||||
{
|
||||
return $this->final_text
|
||||
?? $this->cleaned_text
|
||||
?? $this->content;
|
||||
}
|
||||
|
||||
/**
|
||||
* raw_text = alias untuk content (teks asal extraction).
|
||||
* Digunakan dalam views untuk kejelasan.
|
||||
*/
|
||||
public function getRawTextAttribute(): string
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bina Qdrant payload untuk chunk ini.
|
||||
* Panggil selepas eager load: document.category, documentVersion.
|
||||
*/
|
||||
public function toQdrantPayload(): array
|
||||
{
|
||||
$document = $this->document;
|
||||
$version = $this->documentVersion;
|
||||
$category = $document->category;
|
||||
|
||||
return [
|
||||
'knowledge_type' => 'pdf_chunk',
|
||||
'source_type' => 'pdf',
|
||||
'category_id' => $category->id,
|
||||
'category_name' => $category->name,
|
||||
'category_slug' => $category->slug,
|
||||
'document_id' => $document->id,
|
||||
'document_version_id' => $version->id,
|
||||
'document_chunk_id' => $this->id,
|
||||
'knowledge_item_id' => null,
|
||||
'title' => $document->title,
|
||||
'page_number' => $this->page_number,
|
||||
'chunk_index' => $this->chunk_index,
|
||||
'section_heading' => $this->section_heading,
|
||||
'text' => mb_substr($this->getEmbeddableText(), 0, 1000),
|
||||
'is_active' => true,
|
||||
'status' => 'active',
|
||||
'is_edited' => (bool) $this->is_edited,
|
||||
'tags' => $document->tags ?? [],
|
||||
'effective_date' => $document->effective_date?->toDateString(),
|
||||
'language' => $document->language,
|
||||
'created_at' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// STATE MUTATORS
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Deactivate chunk — digunakan bila versi baru diupload.
|
||||
*/
|
||||
public function deactivate(): void
|
||||
{
|
||||
$this->update(['is_active' => false]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tandakan chunk berjaya di-embed.
|
||||
* Dipanggil selepas upsert ke Qdrant berjaya.
|
||||
*/
|
||||
public function markAsEmbedded(string $qdrantPointId): void
|
||||
{
|
||||
$this->update([
|
||||
'qdrant_point_id' => $qdrantPointId,
|
||||
'is_embedded' => true,
|
||||
'embedded_at' => $this->embedded_at ?? now(), // kekalkan masa embed pertama
|
||||
'last_embedded_at' => now(),
|
||||
'chunk_status' => self::STATUS_INDEXED,
|
||||
'needs_reindex' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tandakan chunk sebagai superseded (selepas split).
|
||||
*/
|
||||
public function markAsSuperseded(): void
|
||||
{
|
||||
$this->update([
|
||||
'is_active' => false,
|
||||
'exclude_from_index' => true,
|
||||
'chunk_status' => self::STATUS_SUPERSEDED,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tandakan chunk sebagai excluded (admin kecualikan).
|
||||
*/
|
||||
public function markAsExcluded(): void
|
||||
{
|
||||
$this->update([
|
||||
'is_active' => false,
|
||||
'exclude_from_index' => true,
|
||||
'chunk_status' => self::STATUS_EXCLUDED,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kembalikan chunk ke indexing selepas excluded.
|
||||
*/
|
||||
public function markAsIncluded(): void
|
||||
{
|
||||
$status = $this->is_embedded
|
||||
? self::STATUS_INDEXED
|
||||
: self::STATUS_NEEDS_REINDEX;
|
||||
|
||||
$this->update([
|
||||
'is_active' => true,
|
||||
'exclude_from_index' => false,
|
||||
'chunk_status' => $status,
|
||||
'needs_reindex' => !$this->is_embedded,
|
||||
]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// STATUS HELPERS (untuk views)
|
||||
// =========================================================================
|
||||
|
||||
public function isIndexable(): bool
|
||||
{
|
||||
return $this->is_active
|
||||
&& ! $this->exclude_from_index
|
||||
&& ! in_array($this->chunk_status, [
|
||||
self::STATUS_EXCLUDED,
|
||||
self::STATUS_SUPERSEDED,
|
||||
self::STATUS_FAILED_EMBEDDING,
|
||||
]);
|
||||
}
|
||||
|
||||
public function isSuperseded(): bool
|
||||
{
|
||||
return $this->chunk_status === self::STATUS_SUPERSEDED;
|
||||
}
|
||||
|
||||
public function getStatusBadgeClass(): string
|
||||
{
|
||||
return match ($this->chunk_status) {
|
||||
self::STATUS_INDEXED => 'bg-success',
|
||||
self::STATUS_NEEDS_REINDEX => 'bg-warning text-dark',
|
||||
self::STATUS_NEEDS_REVIEW => 'bg-info text-dark',
|
||||
self::STATUS_EXCLUDED => 'bg-secondary',
|
||||
self::STATUS_SUPERSEDED => 'bg-dark',
|
||||
self::STATUS_FAILED_EMBEDDING => 'bg-danger',
|
||||
default => 'bg-light text-dark border',
|
||||
};
|
||||
}
|
||||
|
||||
public function getStatusLabel(): string
|
||||
{
|
||||
return match ($this->chunk_status) {
|
||||
self::STATUS_PENDING => 'Menunggu',
|
||||
self::STATUS_INDEXED => 'Diindex',
|
||||
self::STATUS_NEEDS_REVIEW => 'Perlu Semak',
|
||||
self::STATUS_NEEDS_REINDEX => 'Perlu Reindex',
|
||||
self::STATUS_EXCLUDED => 'Dikecualikan',
|
||||
self::STATUS_SUPERSEDED => 'Digantikan',
|
||||
self::STATUS_FAILED_EMBEDDING => 'Gagal Embed',
|
||||
default => ucfirst($this->chunk_status),
|
||||
};
|
||||
}
|
||||
|
||||
/** Anggaran token berdasarkan teks yang akan di-embed */
|
||||
public function estimateTokenCount(): int
|
||||
{
|
||||
return (int) ceil(mb_strlen($this->getEmbeddableText()) / 4);
|
||||
}
|
||||
}
|
||||
124
app/Models/DocumentVersion.php
Normal file
124
app/Models/DocumentVersion.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DocumentVersion extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'document_id',
|
||||
'version_number',
|
||||
'original_filename',
|
||||
'stored_path',
|
||||
'mime_type',
|
||||
'file_size',
|
||||
'file_hash',
|
||||
'page_count',
|
||||
'processing_status',
|
||||
'processing_error',
|
||||
'processing_started_at',
|
||||
'processing_completed_at',
|
||||
'is_current',
|
||||
'change_notes',
|
||||
'uploaded_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_current' => 'boolean',
|
||||
'file_size' => 'integer',
|
||||
'page_count' => 'integer',
|
||||
'processing_started_at' => 'datetime',
|
||||
'processing_completed_at' => 'datetime',
|
||||
];
|
||||
|
||||
// Status constants
|
||||
const STATUS_PENDING = 'pending';
|
||||
const STATUS_PROCESSING = 'processing';
|
||||
const STATUS_EXTRACTING = 'extracting';
|
||||
const STATUS_CHUNKING = 'chunking';
|
||||
const STATUS_EMBEDDING = 'embedding';
|
||||
const STATUS_INDEXED = 'indexed';
|
||||
const STATUS_FAILED = 'failed';
|
||||
const STATUS_EXTRACTION_FAILED = 'extraction_failed';
|
||||
|
||||
// === Relationships ===
|
||||
|
||||
public function document(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Document::class);
|
||||
}
|
||||
|
||||
public function chunks(): HasMany
|
||||
{
|
||||
return $this->hasMany(DocumentChunk::class)->orderBy('chunk_index');
|
||||
}
|
||||
|
||||
public function uploader(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'uploaded_by');
|
||||
}
|
||||
|
||||
// === Scopes ===
|
||||
|
||||
public function scopeCurrent(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_current', true);
|
||||
}
|
||||
|
||||
public function scopeIndexed(Builder $query): Builder
|
||||
{
|
||||
return $query->where('processing_status', self::STATUS_INDEXED);
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
public function getStorageUrl(): string
|
||||
{
|
||||
return Storage::url($this->stored_path);
|
||||
}
|
||||
|
||||
public function getFileSizeFormattedAttribute(): string
|
||||
{
|
||||
$bytes = $this->file_size;
|
||||
if ($bytes < 1024) return $bytes . ' B';
|
||||
if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB';
|
||||
return round($bytes / 1048576, 1) . ' MB';
|
||||
}
|
||||
|
||||
public function isProcessed(): bool
|
||||
{
|
||||
return $this->processing_status === self::STATUS_INDEXED;
|
||||
}
|
||||
|
||||
public function hasFailed(): bool
|
||||
{
|
||||
return in_array($this->processing_status, [
|
||||
self::STATUS_FAILED,
|
||||
self::STATUS_EXTRACTION_FAILED,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateStatus(string $status, ?string $error = null): void
|
||||
{
|
||||
$data = ['processing_status' => $status];
|
||||
|
||||
if ($status === self::STATUS_PROCESSING) {
|
||||
$data['processing_started_at'] = now();
|
||||
}
|
||||
|
||||
if ($status === self::STATUS_INDEXED) {
|
||||
$data['processing_completed_at'] = now();
|
||||
}
|
||||
|
||||
if ($error !== null) {
|
||||
$data['processing_error'] = $error;
|
||||
}
|
||||
|
||||
$this->update($data);
|
||||
}
|
||||
}
|
||||
135
app/Models/KnowledgeItem.php
Normal file
135
app/Models/KnowledgeItem.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class KnowledgeItem extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'category_id',
|
||||
'item_type',
|
||||
'title',
|
||||
'content',
|
||||
'content_short',
|
||||
'tags',
|
||||
'language',
|
||||
'effective_date',
|
||||
'expiry_date',
|
||||
'is_active',
|
||||
'is_public',
|
||||
'qdrant_point_id',
|
||||
'is_embedded',
|
||||
'embedded_at',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'tags' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'is_public' => 'boolean',
|
||||
'is_embedded' => 'boolean',
|
||||
'effective_date' => 'date',
|
||||
'expiry_date' => 'date',
|
||||
'embedded_at' => 'datetime',
|
||||
];
|
||||
|
||||
// Type constants
|
||||
const TYPE_FAQ = 'faq';
|
||||
const TYPE_POLICY = 'policy';
|
||||
const TYPE_NOTE = 'note';
|
||||
const TYPE_ANNOUNCEMENT = 'announcement';
|
||||
|
||||
public static function typeLabels(): array
|
||||
{
|
||||
return [
|
||||
self::TYPE_FAQ => 'FAQ / Soal Jawab',
|
||||
self::TYPE_POLICY => 'Polisi / Prosedur',
|
||||
self::TYPE_NOTE => 'Nota Dalaman',
|
||||
self::TYPE_ANNOUNCEMENT => 'Pengumuman',
|
||||
];
|
||||
}
|
||||
|
||||
// === Relationships ===
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function updater(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by');
|
||||
}
|
||||
|
||||
// === Scopes ===
|
||||
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopePublic(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_public', true);
|
||||
}
|
||||
|
||||
public function scopeByType(Builder $query, string $type): Builder
|
||||
{
|
||||
return $query->where('item_type', $type);
|
||||
}
|
||||
|
||||
public function scopeByCategory(Builder $query, int $categoryId): Builder
|
||||
{
|
||||
return $query->where('category_id', $categoryId);
|
||||
}
|
||||
|
||||
public function scopeNotEmbedded(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_embedded', false);
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
public function getTypeLabelAttribute(): string
|
||||
{
|
||||
return self::typeLabels()[$this->item_type] ?? $this->item_type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bina teks yang akan di-embed — gabung title + content untuk FAQ
|
||||
*/
|
||||
public function getEmbeddableText(): string
|
||||
{
|
||||
if ($this->item_type === self::TYPE_FAQ) {
|
||||
return "Soalan: {$this->title}\nJawapan: {$this->content}";
|
||||
}
|
||||
|
||||
return "{$this->title}\n\n{$this->content}";
|
||||
}
|
||||
|
||||
public function markAsEmbedded(string $qdrantPointId): void
|
||||
{
|
||||
$this->update([
|
||||
'qdrant_point_id' => $qdrantPointId,
|
||||
'is_embedded' => true,
|
||||
'embedded_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function deactivate(): void
|
||||
{
|
||||
$this->update(['is_active' => false]);
|
||||
}
|
||||
}
|
||||
67
app/Models/ProcessingLog.php
Normal file
67
app/Models/ProcessingLog.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ProcessingLog extends Model
|
||||
{
|
||||
const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'processable_type',
|
||||
'processable_id',
|
||||
'stage',
|
||||
'status',
|
||||
'message',
|
||||
'metadata',
|
||||
'duration',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
'duration' => 'float',
|
||||
];
|
||||
|
||||
const STAGE_UPLOAD = 'upload';
|
||||
const STAGE_EXTRACT = 'extract';
|
||||
const STAGE_CHUNK = 'chunk';
|
||||
const STAGE_EMBED = 'embed';
|
||||
const STAGE_QDRANT = 'qdrant_sync';
|
||||
const STAGE_COMPLETE = 'completed';
|
||||
const STAGE_FAILED = 'failed';
|
||||
|
||||
const STATUS_STARTED = 'started';
|
||||
const STATUS_COMPLETED = 'completed';
|
||||
const STATUS_FAILED = 'failed';
|
||||
|
||||
public function scopeForRecord(Builder $query, string $type, int $id): Builder
|
||||
{
|
||||
return $query->where('processable_type', $type)
|
||||
->where('processable_id', $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper untuk log processing dengan masa
|
||||
*/
|
||||
public static function record(
|
||||
string $type,
|
||||
int $id,
|
||||
string $stage,
|
||||
string $status,
|
||||
?string $message = null,
|
||||
?array $metadata = null,
|
||||
?float $duration = null
|
||||
): self {
|
||||
return static::create([
|
||||
'processable_type' => $type,
|
||||
'processable_id' => $id,
|
||||
'stage' => $stage,
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'metadata' => $metadata,
|
||||
'duration' => $duration,
|
||||
]);
|
||||
}
|
||||
}
|
||||
94
app/Models/User.php
Normal file
94
app/Models/User.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'role',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
// Role constants
|
||||
const ROLE_ADMIN = 'admin';
|
||||
const ROLE_STAFF = 'staff';
|
||||
const ROLE_VIEWER = 'viewer';
|
||||
|
||||
// === Role Checks ===
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->role === self::ROLE_ADMIN;
|
||||
}
|
||||
|
||||
public function isStaff(): bool
|
||||
{
|
||||
return in_array($this->role, [self::ROLE_ADMIN, self::ROLE_STAFF]);
|
||||
}
|
||||
|
||||
public function hasRole(string $role): bool
|
||||
{
|
||||
return $this->role === $role;
|
||||
}
|
||||
|
||||
public function canManageDocuments(): bool
|
||||
{
|
||||
return $this->isStaff();
|
||||
}
|
||||
|
||||
public function canManageCategories(): bool
|
||||
{
|
||||
return $this->isAdmin();
|
||||
}
|
||||
|
||||
public function canViewAuditLogs(): bool
|
||||
{
|
||||
return $this->isAdmin();
|
||||
}
|
||||
|
||||
// === Relationships ===
|
||||
|
||||
public function auditLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(AuditLog::class);
|
||||
}
|
||||
|
||||
public function chatLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(ChatLog::class);
|
||||
}
|
||||
|
||||
// === Accessor ===
|
||||
|
||||
public function getRoleLabelAttribute(): string
|
||||
{
|
||||
return match ($this->role) {
|
||||
self::ROLE_ADMIN => 'Admin Sistem',
|
||||
self::ROLE_STAFF => 'Kakitangan',
|
||||
self::ROLE_VIEWER => 'Pengguna',
|
||||
default => 'Tidak Diketahui',
|
||||
};
|
||||
}
|
||||
}
|
||||
24
app/Providers/AppServiceProvider.php
Normal file
24
app/Providers/AppServiceProvider.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
218
app/Services/Document/ChunkEditingService.php
Normal file
218
app/Services/Document/ChunkEditingService.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Document;
|
||||
|
||||
use App\Jobs\ReindexChunkJob;
|
||||
use App\Models\ChunkAudit;
|
||||
use App\Models\DocumentChunk;
|
||||
use App\Services\KnowledgeBase\AuditService;
|
||||
use App\Services\Qdrant\QdrantService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* ChunkEditingService
|
||||
*
|
||||
* Menguruskan operasi edit dan toggle status untuk satu chunk:
|
||||
* - Edit final_text
|
||||
* - Exclude chunk dari indexing
|
||||
* - Include semula chunk ke indexing
|
||||
*
|
||||
* Setiap operasi:
|
||||
* 1. Kemaskini rekod MySQL
|
||||
* 2. Sync status ke Qdrant jika perlu
|
||||
* 3. Rekod chunk_audits
|
||||
* 4. Log ke audit_logs
|
||||
* 5. Dispatch ReindexChunkJob jika perlu
|
||||
*/
|
||||
class ChunkEditingService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly QdrantService $qdrant,
|
||||
private readonly AuditService $audit,
|
||||
) {}
|
||||
|
||||
// =========================================================================
|
||||
// EDIT FINAL TEXT
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Edit final_text sebuah chunk.
|
||||
*
|
||||
* Raw_text (content) tidak disentuh.
|
||||
* Selepas edit, chunk ditandakan needs_reindex dan ReindexChunkJob diantrikan.
|
||||
*
|
||||
* @throws RuntimeException Jika chunk tidak boleh diedit (e.g. superseded)
|
||||
*/
|
||||
public function editFinalText(
|
||||
DocumentChunk $chunk,
|
||||
string $newFinalText,
|
||||
?string $notes = null
|
||||
): void {
|
||||
if ($chunk->isSuperseded()) {
|
||||
throw new RuntimeException(
|
||||
'Chunk yang telah digantikan (superseded) tidak boleh diedit.'
|
||||
);
|
||||
}
|
||||
|
||||
$oldFinalText = $chunk->final_text;
|
||||
$oldStatus = $chunk->chunk_status;
|
||||
|
||||
DB::transaction(function () use ($chunk, $newFinalText, $notes, $oldFinalText, $oldStatus) {
|
||||
$chunk->update([
|
||||
'final_text' => $newFinalText,
|
||||
'is_edited' => true,
|
||||
'chunk_status' => DocumentChunk::STATUS_NEEDS_REINDEX,
|
||||
'needs_reindex' => true,
|
||||
'edited_by' => auth()->id(),
|
||||
'edited_at' => now(),
|
||||
]);
|
||||
|
||||
ChunkAudit::record($chunk->id, ChunkAudit::OP_EDIT_FINAL_TEXT, [
|
||||
'old_final_text' => $oldFinalText,
|
||||
'new_final_text' => $newFinalText,
|
||||
'old_status' => $oldStatus,
|
||||
'new_status' => DocumentChunk::STATUS_NEEDS_REINDEX,
|
||||
'metadata' => [
|
||||
'word_count_before' => str_word_count($oldFinalText ?? $chunk->content),
|
||||
'word_count_after' => str_word_count($newFinalText),
|
||||
'char_count_before' => mb_strlen($oldFinalText ?? $chunk->content),
|
||||
'char_count_after' => mb_strlen($newFinalText),
|
||||
],
|
||||
], $notes);
|
||||
});
|
||||
|
||||
$this->audit->chunkFinalTextEdited($chunk, $oldFinalText, $newFinalText);
|
||||
|
||||
// Hantar ke queue untuk reindex
|
||||
ReindexChunkJob::dispatch($chunk->id);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// EXCLUDE / INCLUDE
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Kecualikan chunk dari indexing.
|
||||
*
|
||||
* - is_active = false
|
||||
* - chunk_status = 'excluded'
|
||||
* - Qdrant point ditandakan tidak aktif (jika ada)
|
||||
*/
|
||||
public function excludeChunk(DocumentChunk $chunk, ?string $notes = null): void
|
||||
{
|
||||
if ($chunk->chunk_status === DocumentChunk::STATUS_EXCLUDED) {
|
||||
return; // Sudah excluded — tidak perlu buat apa-apa
|
||||
}
|
||||
|
||||
if ($chunk->isSuperseded()) {
|
||||
throw new RuntimeException(
|
||||
'Chunk superseded tidak boleh di-exclude secara manual.'
|
||||
);
|
||||
}
|
||||
|
||||
$oldStatus = $chunk->chunk_status;
|
||||
|
||||
DB::transaction(function () use ($chunk, $notes, $oldStatus) {
|
||||
$chunk->markAsExcluded();
|
||||
|
||||
// Deactivate di Qdrant jika ada point
|
||||
if ($chunk->qdrant_point_id) {
|
||||
$this->qdrant->updatePayload($chunk->qdrant_point_id, [
|
||||
'is_active' => false,
|
||||
'status' => 'excluded',
|
||||
]);
|
||||
}
|
||||
|
||||
ChunkAudit::record($chunk->id, ChunkAudit::OP_EXCLUDE, [
|
||||
'old_status' => $oldStatus,
|
||||
'new_status' => DocumentChunk::STATUS_EXCLUDED,
|
||||
], $notes);
|
||||
});
|
||||
|
||||
$this->audit->chunkExcluded($chunk, $oldStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kembalikan chunk ke indexing.
|
||||
*
|
||||
* - is_active = true
|
||||
* - exclude_from_index = false
|
||||
* - Jika sudah embedded: reactivate di Qdrant + status kembali 'indexed'
|
||||
* - Jika belum embedded: queue reindex
|
||||
*
|
||||
* @throws RuntimeException Jika chunk adalah superseded (tidak boleh di-include)
|
||||
*/
|
||||
public function includeChunk(DocumentChunk $chunk, ?string $notes = null): void
|
||||
{
|
||||
if ($chunk->isSuperseded()) {
|
||||
throw new RuntimeException(
|
||||
'Chunk yang telah digantikan (superseded) tidak boleh dikembalikan. '
|
||||
. 'Gunakan child chunks yang dihasilkan dari split.'
|
||||
);
|
||||
}
|
||||
|
||||
if (! $chunk->exclude_from_index && $chunk->is_active) {
|
||||
return; // Sudah active — tidak perlu buat apa-apa
|
||||
}
|
||||
|
||||
$oldStatus = $chunk->chunk_status;
|
||||
|
||||
DB::transaction(function () use ($chunk, $notes, $oldStatus) {
|
||||
$chunk->markAsIncluded();
|
||||
|
||||
// Jika ada Qdrant point, aktifkan semula
|
||||
if ($chunk->qdrant_point_id && $chunk->is_embedded) {
|
||||
$this->qdrant->updatePayload($chunk->qdrant_point_id, [
|
||||
'is_active' => true,
|
||||
'status' => 'active',
|
||||
]);
|
||||
}
|
||||
|
||||
ChunkAudit::record($chunk->id, ChunkAudit::OP_INCLUDE, [
|
||||
'old_status' => $oldStatus,
|
||||
'new_status' => $chunk->fresh()->chunk_status,
|
||||
], $notes);
|
||||
});
|
||||
|
||||
$this->audit->chunkIncluded($chunk, $oldStatus);
|
||||
|
||||
// Queue reindex jika chunk belum embedded atau final_text berubah
|
||||
if ($chunk->fresh()->needs_reindex) {
|
||||
ReindexChunkJob::dispatch($chunk->id);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TRIGGER REINDEX
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Tandakan chunk perlu reindex dan dispatch job.
|
||||
* Digunakan oleh admin apabila mahu refresh embedding tanpa edit teks.
|
||||
*/
|
||||
public function triggerReindex(DocumentChunk $chunk, ?string $notes = null): void
|
||||
{
|
||||
if (! $chunk->isIndexable()) {
|
||||
throw new RuntimeException(
|
||||
'Chunk ini tidak boleh direindex (status: ' . $chunk->chunk_status . ').'
|
||||
);
|
||||
}
|
||||
|
||||
$oldStatus = $chunk->chunk_status;
|
||||
|
||||
$chunk->update([
|
||||
'chunk_status' => DocumentChunk::STATUS_NEEDS_REINDEX,
|
||||
'needs_reindex' => true,
|
||||
]);
|
||||
|
||||
ChunkAudit::record($chunk->id, ChunkAudit::OP_REINDEX, [
|
||||
'old_status' => $oldStatus,
|
||||
'new_status' => DocumentChunk::STATUS_NEEDS_REINDEX,
|
||||
], $notes);
|
||||
|
||||
$this->audit->chunkReindexTriggered($chunk);
|
||||
|
||||
ReindexChunkJob::dispatch($chunk->id);
|
||||
}
|
||||
}
|
||||
209
app/Services/Document/ChunkSplitService.php
Normal file
209
app/Services/Document/ChunkSplitService.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Document;
|
||||
|
||||
use App\Jobs\ReindexChunkJob;
|
||||
use App\Models\ChunkAudit;
|
||||
use App\Models\DocumentChunk;
|
||||
use App\Services\KnowledgeBase\AuditService;
|
||||
use App\Services\Qdrant\QdrantService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* ChunkSplitService
|
||||
*
|
||||
* Menguruskan operasi split chunk:
|
||||
* 1. Tandakan parent sebagai 'superseded'
|
||||
* 2. Deactivate Qdrant point parent
|
||||
* 3. Cipta child chunks dengan final_text dari admin
|
||||
* 4. Rekod audit trail (parent + setiap child)
|
||||
* 5. Dispatch ReindexChunkJob untuk setiap child
|
||||
*
|
||||
* PRINSIP:
|
||||
* - Parent chunk TIDAK DIPADAM — hanya ditandakan superseded
|
||||
* - content (raw_text) parent DISIMPAN dalam setiap child untuk audit trail
|
||||
* - Child chunks mendapat chunk_index baharu (selepas max sedia ada)
|
||||
* - Semua children dalam satu split operation berkongsi split_group_id yang sama
|
||||
*/
|
||||
class ChunkSplitService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly QdrantService $qdrant,
|
||||
private readonly AuditService $audit,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Split satu chunk kepada beberapa chunk kecil.
|
||||
*
|
||||
* @param DocumentChunk $parent Chunk asal yang akan di-split
|
||||
* @param string[] $segments Array teks untuk setiap child chunk
|
||||
* @param string|null $notes Nota admin (sebab split)
|
||||
* @return DocumentChunk[] Array child chunks yang baru dicipta
|
||||
*
|
||||
* @throws InvalidArgumentException Jika segments tidak valid
|
||||
* @throws RuntimeException Jika chunk tidak boleh di-split
|
||||
*/
|
||||
public function split(
|
||||
DocumentChunk $parent,
|
||||
array $segments,
|
||||
?string $notes = null
|
||||
): array {
|
||||
$this->validateSegments($parent, $segments);
|
||||
|
||||
// Index maksimum untuk version ini — child chunks akan guna index selepas ini
|
||||
$maxIndex = DocumentChunk::where('document_version_id', $parent->document_version_id)
|
||||
->max('chunk_index') ?? 0;
|
||||
|
||||
$splitGroupId = (string) Str::uuid();
|
||||
$children = [];
|
||||
|
||||
DB::transaction(function () use ($parent, $segments, $notes, $maxIndex, $splitGroupId, &$children) {
|
||||
$parentOldStatus = $parent->chunk_status;
|
||||
|
||||
// ── Langkah 1: Tandakan parent sebagai superseded ────────────────
|
||||
$parent->markAsSuperseded();
|
||||
|
||||
// ── Langkah 2: Deactivate Qdrant point parent ───────────────────
|
||||
if ($parent->qdrant_point_id) {
|
||||
$this->qdrant->updatePayload($parent->qdrant_point_id, [
|
||||
'is_active' => false,
|
||||
'status' => 'superseded',
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Langkah 3: Log audit untuk parent ───────────────────────────
|
||||
ChunkAudit::record($parent->id, ChunkAudit::OP_SPLIT_PARENT, [
|
||||
'old_status' => $parentOldStatus,
|
||||
'new_status' => DocumentChunk::STATUS_SUPERSEDED,
|
||||
'metadata' => [
|
||||
'split_group_id' => $splitGroupId,
|
||||
'segment_count' => count($segments),
|
||||
'original_length' => mb_strlen($parent->content),
|
||||
'original_words' => str_word_count($parent->content),
|
||||
'had_qdrant_point' => (bool) $parent->qdrant_point_id,
|
||||
],
|
||||
], $notes);
|
||||
|
||||
// ── Langkah 4: Cipta child chunks ────────────────────────────────
|
||||
foreach ($segments as $i => $segmentText) {
|
||||
$cleanSegment = trim($segmentText);
|
||||
|
||||
$child = DocumentChunk::create([
|
||||
// Warisi metadata penting dari parent
|
||||
'document_id' => $parent->document_id,
|
||||
'document_version_id' => $parent->document_version_id,
|
||||
'page_number' => $parent->page_number,
|
||||
'section_heading' => $parent->section_heading,
|
||||
|
||||
// content = raw_text parent (untuk audit trail — teks penuh sebelum split)
|
||||
// Admin boleh rujuk ini untuk memahami konteks asal
|
||||
'content' => $parent->content,
|
||||
|
||||
// final_text = teks baharu yang admin tetapkan untuk chunk ini
|
||||
'final_text' => $cleanSegment,
|
||||
'cleaned_text' => null,
|
||||
|
||||
// Index dan ordering
|
||||
'chunk_index' => $maxIndex + $i + 1,
|
||||
'split_order' => $i,
|
||||
'split_group_id' => $splitGroupId,
|
||||
'parent_chunk_id' => $parent->id,
|
||||
|
||||
// Token estimate berdasarkan final_text
|
||||
'token_count' => (int) ceil(mb_strlen($cleanSegment) / 4),
|
||||
|
||||
// Status
|
||||
'chunk_status' => DocumentChunk::STATUS_PENDING,
|
||||
'is_embedded' => false,
|
||||
'is_active' => true,
|
||||
'is_edited' => true,
|
||||
'exclude_from_index' => false,
|
||||
'needs_reindex' => true,
|
||||
|
||||
// Admin yang buat split
|
||||
'edited_by' => auth()->id(),
|
||||
'edited_at' => now(),
|
||||
'notes' => "Dicipta dari split chunk #{$parent->chunk_index} "
|
||||
. "(segmen " . ($i + 1) . "/" . count($segments) . ")",
|
||||
]);
|
||||
|
||||
// ── Langkah 5: Log audit untuk setiap child ─────────────────
|
||||
ChunkAudit::record($child->id, ChunkAudit::OP_SPLIT_CHILD, [
|
||||
'old_status' => null,
|
||||
'new_status' => DocumentChunk::STATUS_PENDING,
|
||||
'new_final_text' => $cleanSegment,
|
||||
'metadata' => [
|
||||
'parent_chunk_id' => $parent->id,
|
||||
'parent_chunk_idx' => $parent->chunk_index,
|
||||
'split_group_id' => $splitGroupId,
|
||||
'split_order' => $i,
|
||||
'segment_length' => mb_strlen($cleanSegment),
|
||||
'segment_words' => str_word_count($cleanSegment),
|
||||
],
|
||||
], $notes);
|
||||
|
||||
$children[] = $child;
|
||||
}
|
||||
}); // akhir DB::transaction
|
||||
|
||||
// ── Langkah 6: Log ke audit_logs sistem ─────────────────────────────
|
||||
$this->audit->chunkSplit($parent, $children, $splitGroupId);
|
||||
|
||||
// ── Langkah 7: Dispatch ReindexChunkJob untuk setiap child ──────────
|
||||
foreach ($children as $child) {
|
||||
ReindexChunkJob::dispatch($child->id);
|
||||
}
|
||||
|
||||
return $children;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// PRIVATE HELPERS
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Validasi input sebelum split dijalankan.
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
private function validateSegments(DocumentChunk $parent, array $segments): void
|
||||
{
|
||||
if ($parent->isSuperseded()) {
|
||||
throw new RuntimeException(
|
||||
'Chunk yang telah digantikan (superseded) tidak boleh di-split semula.'
|
||||
);
|
||||
}
|
||||
|
||||
if (count($segments) < 2) {
|
||||
throw new InvalidArgumentException(
|
||||
'Split memerlukan sekurang-kurangnya 2 segmen.'
|
||||
);
|
||||
}
|
||||
|
||||
if (count($segments) > 10) {
|
||||
throw new InvalidArgumentException(
|
||||
'Maksimum 10 segmen dibenarkan dalam satu operasi split.'
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($segments as $i => $seg) {
|
||||
$trimmed = trim($seg);
|
||||
|
||||
if (empty($trimmed)) {
|
||||
throw new InvalidArgumentException(
|
||||
'Segmen ' . ($i + 1) . ' tidak boleh kosong.'
|
||||
);
|
||||
}
|
||||
|
||||
if (mb_strlen($trimmed) < 20) {
|
||||
throw new InvalidArgumentException(
|
||||
'Segmen ' . ($i + 1) . ' terlalu pendek (minimum 20 aksara).'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
363
app/Services/Document/ChunkingService.php
Normal file
363
app/Services/Document/ChunkingService.php
Normal file
@@ -0,0 +1,363 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Document;
|
||||
|
||||
/**
|
||||
* ChunkingService
|
||||
*
|
||||
* Memecahkan teks dokumen kepada chunk yang sesuai untuk embedding.
|
||||
*
|
||||
* Strategi: Hierarchical chunking untuk dokumen rasmi
|
||||
* 1. Kesan heading/section → pecah ikut section
|
||||
* 2. Section terlalu panjang → pecah ikut perenggan
|
||||
* 3. Perenggan terlalu panjang → pecah ikut bilangan perkataan dengan overlap
|
||||
* 4. Chunk terlalu pendek → gabung dengan chunk sebelah
|
||||
*
|
||||
* BUKAN model yang chunk. Ini adalah logik aplikasi.
|
||||
*/
|
||||
class ChunkingService
|
||||
{
|
||||
private int $maxWords;
|
||||
private int $overlapWords;
|
||||
private int $minWords;
|
||||
|
||||
// Pattern heading untuk dokumen rasmi (Bahasa Melayu + English)
|
||||
private const HEADING_PATTERNS = [
|
||||
'/^(BAB|BAHAGIAN|SEKSYEN|SECTION|CHAPTER|APPENDIX|LAMPIRAN)\s+[IVXLC\d]+/iu',
|
||||
'/^\d+\.\s+[A-Z\u00C0-\u024F][^.]{2,50}$/u',
|
||||
'/^\d+\.\d+\s+[A-Z\u00C0-\u024F][^.]{2,50}$/u',
|
||||
'/^[A-Z][A-Z\s]{5,50}$/u', // ALL CAPS heading
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->maxWords = config('knowledgebase.chunking.max_words', 500);
|
||||
$this->overlapWords = config('knowledgebase.chunking.overlap_words', 75);
|
||||
$this->minWords = config('knowledgebase.chunking.min_words', 30);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk dokumen berdasarkan teks penuh dan data per halaman.
|
||||
*
|
||||
* @param string $fullText Teks penuh dokumen
|
||||
* @param array<int, string> $pages Teks per halaman [pageNum => text]
|
||||
* @return array<int, array{
|
||||
* chunk_index: int,
|
||||
* content: string,
|
||||
* page_number: ?int,
|
||||
* section_heading: ?string,
|
||||
* word_count: int
|
||||
* }>
|
||||
*/
|
||||
public function chunk(string $fullText, array $pages = []): array
|
||||
{
|
||||
if (empty(trim($fullText))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$wordCount = str_word_count($fullText);
|
||||
|
||||
// Dokumen sangat pendek — satu chunk
|
||||
if ($wordCount <= $this->maxWords) {
|
||||
return [[
|
||||
'chunk_index' => 0,
|
||||
'content' => trim($fullText),
|
||||
'page_number' => null,
|
||||
'section_heading' => null,
|
||||
'word_count' => $wordCount,
|
||||
]];
|
||||
}
|
||||
|
||||
// Jika ada data per halaman, chunk ikut halaman dahulu
|
||||
if (!empty($pages)) {
|
||||
return $this->chunkByPages($pages);
|
||||
}
|
||||
|
||||
// Chunk teks penuh ikut section/perenggan
|
||||
return $this->chunkByStructure($fullText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk berdasarkan halaman PDF.
|
||||
* Setiap halaman pecah kepada chunk yang sesuai.
|
||||
* Halaman yang terlalu pendek digabungkan dengan halaman berikut.
|
||||
*/
|
||||
private function chunkByPages(array $pages): array
|
||||
{
|
||||
$chunks = [];
|
||||
$chunkIndex = 0;
|
||||
$buffer = '';
|
||||
$bufferPage = null;
|
||||
|
||||
foreach ($pages as $pageNum => $pageText) {
|
||||
$pageText = trim($pageText);
|
||||
if (empty($pageText)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$combined = trim($buffer . "\n\n" . $pageText);
|
||||
$combinedWords = str_word_count($combined);
|
||||
|
||||
if ($combinedWords > $this->maxWords && !empty($buffer)) {
|
||||
// Flush buffer sebelum tambah halaman baru
|
||||
$pageChunks = $this->splitLongText(trim($buffer), $bufferPage, $chunkIndex);
|
||||
foreach ($pageChunks as $chunk) {
|
||||
$chunks[] = $chunk;
|
||||
$chunkIndex++;
|
||||
}
|
||||
|
||||
// Ambil overlap dari chunk terakhir
|
||||
$lastChunk = end($chunks);
|
||||
$overlap = $lastChunk
|
||||
? $this->getOverlapText($lastChunk['content'])
|
||||
: '';
|
||||
|
||||
$buffer = trim($overlap . "\n\n" . $pageText);
|
||||
$bufferPage = $pageNum;
|
||||
} else {
|
||||
$buffer = $combined;
|
||||
$bufferPage ??= $pageNum;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush sisa
|
||||
if (!empty(trim($buffer))) {
|
||||
$pageChunks = $this->splitLongText(trim($buffer), $bufferPage, $chunkIndex);
|
||||
foreach ($pageChunks as $chunk) {
|
||||
$chunks[] = $chunk;
|
||||
$chunkIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->filterAndReindex($chunks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk berdasarkan struktur teks (heading dan perenggan).
|
||||
*/
|
||||
private function chunkByStructure(string $text): array
|
||||
{
|
||||
$sections = $this->splitIntoSections($text);
|
||||
$chunks = [];
|
||||
$chunkIndex = 0;
|
||||
$buffer = '';
|
||||
$bufferHeading = null;
|
||||
|
||||
foreach ($sections as $section) {
|
||||
$sectionWords = str_word_count($section['text']);
|
||||
|
||||
if ($sectionWords === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Section terlalu panjang — split terus
|
||||
if ($sectionWords > $this->maxWords) {
|
||||
if (!empty($buffer)) {
|
||||
$chunks[] = [
|
||||
'chunk_index' => $chunkIndex++,
|
||||
'content' => trim($buffer),
|
||||
'page_number' => null,
|
||||
'section_heading' => $bufferHeading,
|
||||
'word_count' => str_word_count($buffer),
|
||||
];
|
||||
$buffer = '';
|
||||
$bufferHeading = null;
|
||||
}
|
||||
|
||||
$subChunks = $this->splitLongText(
|
||||
$section['text'],
|
||||
null,
|
||||
$chunkIndex,
|
||||
$section['heading']
|
||||
);
|
||||
|
||||
foreach ($subChunks as $chunk) {
|
||||
$chunks[] = $chunk;
|
||||
$chunkIndex++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cuba gabung dengan buffer
|
||||
$combined = trim($buffer . "\n\n" . $section['text']);
|
||||
$combinedWords = str_word_count($combined);
|
||||
|
||||
if ($combinedWords > $this->maxWords && !empty($buffer)) {
|
||||
$chunks[] = [
|
||||
'chunk_index' => $chunkIndex++,
|
||||
'content' => trim($buffer),
|
||||
'page_number' => null,
|
||||
'section_heading' => $bufferHeading,
|
||||
'word_count' => str_word_count($buffer),
|
||||
];
|
||||
|
||||
// Overlap
|
||||
$lastChunk = end($chunks);
|
||||
$overlap = $this->getOverlapText($lastChunk['content']);
|
||||
$buffer = trim($overlap . "\n\n" . $section['text']);
|
||||
$bufferHeading = $section['heading'];
|
||||
} else {
|
||||
$buffer .= ($buffer ? "\n\n" : '') . $section['text'];
|
||||
$bufferHeading ??= $section['heading'];
|
||||
}
|
||||
}
|
||||
|
||||
// Flush sisa
|
||||
if (!empty(trim($buffer))) {
|
||||
$chunks[] = [
|
||||
'chunk_index' => $chunkIndex,
|
||||
'content' => trim($buffer),
|
||||
'page_number' => null,
|
||||
'section_heading' => $bufferHeading,
|
||||
'word_count' => str_word_count($buffer),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->filterAndReindex($chunks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split teks panjang kepada chunk dengan overlap.
|
||||
*/
|
||||
private function splitLongText(
|
||||
string $text,
|
||||
?int $pageNum,
|
||||
int $startIndex,
|
||||
?string $heading = null
|
||||
): array {
|
||||
$paragraphs = preg_split('/\n{2,}/', $text);
|
||||
$chunks = [];
|
||||
$buffer = '';
|
||||
$index = $startIndex;
|
||||
|
||||
foreach ($paragraphs as $para) {
|
||||
$para = trim($para);
|
||||
if (empty($para)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$combined = trim($buffer . "\n\n" . $para);
|
||||
$combinedWords = str_word_count($combined);
|
||||
|
||||
if ($combinedWords > $this->maxWords && !empty($buffer)) {
|
||||
$chunks[] = [
|
||||
'chunk_index' => $index++,
|
||||
'content' => trim($buffer),
|
||||
'page_number' => $pageNum,
|
||||
'section_heading' => $heading,
|
||||
'word_count' => str_word_count($buffer),
|
||||
];
|
||||
|
||||
// Ambil overlap dari chunk terakhir
|
||||
$lastChunk = end($chunks);
|
||||
$overlap = $this->getOverlapText($lastChunk['content']);
|
||||
$buffer = trim($overlap . "\n\n" . $para);
|
||||
} else {
|
||||
$buffer = $combined;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty(trim($buffer))) {
|
||||
$chunks[] = [
|
||||
'chunk_index' => $index,
|
||||
'content' => trim($buffer),
|
||||
'page_number' => $pageNum,
|
||||
'section_heading' => $heading,
|
||||
'word_count' => str_word_count($buffer),
|
||||
];
|
||||
}
|
||||
|
||||
return $chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split teks kepada sections berdasarkan heading.
|
||||
* Jika tiada heading dijumpai, setiap perenggan adalah satu section.
|
||||
*
|
||||
* @return array<int, array{heading: ?string, text: string}>
|
||||
*/
|
||||
private function splitIntoSections(string $text): array
|
||||
{
|
||||
$lines = explode("\n", $text);
|
||||
$sections = [];
|
||||
$current = ['heading' => null, 'text' => ''];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$trimmed = trim($line);
|
||||
|
||||
if ($this->isHeading($trimmed)) {
|
||||
if (!empty(trim($current['text']))) {
|
||||
$sections[] = $current;
|
||||
}
|
||||
$current = [
|
||||
'heading' => $trimmed,
|
||||
'text' => $trimmed . "\n",
|
||||
];
|
||||
} else {
|
||||
$current['text'] .= $line . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty(trim($current['text']))) {
|
||||
$sections[] = $current;
|
||||
}
|
||||
|
||||
return $sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Semak sama ada satu baris adalah heading.
|
||||
*/
|
||||
private function isHeading(string $line): bool
|
||||
{
|
||||
if (empty($line) || strlen($line) > 120) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (self::HEADING_PATTERNS as $pattern) {
|
||||
if (preg_match($pattern, $line)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ambil N patah perkataan terakhir dari teks untuk overlap.
|
||||
*/
|
||||
private function getOverlapText(string $text): string
|
||||
{
|
||||
if ($this->overlapWords === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$words = preg_split('/\s+/', trim($text));
|
||||
$words = array_filter($words); // buang empty
|
||||
|
||||
if (count($words) <= $this->overlapWords) {
|
||||
return ''; // Jika teks lebih pendek dari overlap, jangan overlap
|
||||
}
|
||||
|
||||
$overlapSlice = array_slice($words, -$this->overlapWords);
|
||||
return implode(' ', $overlapSlice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Buang chunk yang terlalu pendek dan reindex semula.
|
||||
*/
|
||||
private function filterAndReindex(array $chunks): array
|
||||
{
|
||||
$filtered = array_filter($chunks, function ($chunk) {
|
||||
return ($chunk['word_count'] ?? str_word_count($chunk['content'])) >= $this->minWords;
|
||||
});
|
||||
|
||||
$result = [];
|
||||
foreach (array_values($filtered) as $i => $chunk) {
|
||||
$chunk['chunk_index'] = $i;
|
||||
$result[] = $chunk;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
133
app/Services/Document/PdfExtractorService.php
Normal file
133
app/Services/Document/PdfExtractorService.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Document;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use RuntimeException;
|
||||
use Smalot\PdfParser\Parser;
|
||||
|
||||
/**
|
||||
* PdfExtractorService
|
||||
*
|
||||
* Mengekstrak teks dari fail PDF menggunakan smalot/pdfparser.
|
||||
*
|
||||
* Mengembalikan:
|
||||
* - teks penuh
|
||||
* - teks per halaman (untuk chunk dengan page number)
|
||||
* - bilangan halaman
|
||||
* - status kejayaan/kegagalan
|
||||
*/
|
||||
class PdfExtractorService
|
||||
{
|
||||
/**
|
||||
* Extract teks dari PDF.
|
||||
*
|
||||
* @param string $storedPath Path dalam storage disk (bukan path penuh)
|
||||
* @param string $disk Storage disk name
|
||||
* @return array{
|
||||
* success: bool,
|
||||
* full_text: string,
|
||||
* pages: array<int, string>,
|
||||
* page_count: int,
|
||||
* error: ?string
|
||||
* }
|
||||
*/
|
||||
public function extract(string $storedPath, string $disk = 'local'): array
|
||||
{
|
||||
$result = [
|
||||
'success' => false,
|
||||
'full_text' => '',
|
||||
'pages' => [],
|
||||
'page_count' => 0,
|
||||
'error' => null,
|
||||
];
|
||||
|
||||
// Dapatkan path penuh fail
|
||||
$absolutePath = Storage::disk($disk)->path($storedPath);
|
||||
|
||||
if (!file_exists($absolutePath)) {
|
||||
$result['error'] = "Fail tidak dijumpai: {$storedPath}";
|
||||
return $result;
|
||||
}
|
||||
|
||||
try {
|
||||
$parser = new Parser();
|
||||
$pdf = $parser->parseFile($absolutePath);
|
||||
$pdfPages = $pdf->getPages();
|
||||
|
||||
$pages = [];
|
||||
$fullText = '';
|
||||
|
||||
foreach ($pdfPages as $pageNumber => $page) {
|
||||
try {
|
||||
$pageText = $page->getText();
|
||||
$pageText = $this->cleanPageText($pageText);
|
||||
|
||||
// Simpan muka surat bermula dari 1 (bukan 0)
|
||||
$pages[$pageNumber + 1] = $pageText;
|
||||
$fullText .= $pageText . "\n\n";
|
||||
} catch (\Exception $e) {
|
||||
// Jika satu halaman gagal, teruskan dengan halaman lain
|
||||
Log::warning("Gagal extract halaman " . ($pageNumber + 1), [
|
||||
'path' => $storedPath,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$pages[$pageNumber + 1] = '';
|
||||
}
|
||||
}
|
||||
|
||||
$fullText = trim($fullText);
|
||||
|
||||
if (empty($fullText)) {
|
||||
$result['error'] = 'PDF tidak mengandungi teks yang boleh diekstrak (mungkin PDF imej/scan).';
|
||||
return $result;
|
||||
}
|
||||
|
||||
$result['success'] = true;
|
||||
$result['full_text'] = $fullText;
|
||||
$result['pages'] = $pages;
|
||||
$result['page_count'] = count($pdfPages);
|
||||
} catch (\Exception $e) {
|
||||
$errorMsg = 'Gagal parse PDF: ' . $e->getMessage();
|
||||
Log::error('PdfExtractorService gagal', [
|
||||
'path' => $storedPath,
|
||||
'error' => $errorMsg,
|
||||
]);
|
||||
$result['error'] = $errorMsg;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bersihkan teks yang diextract dari PDF.
|
||||
* PDF sering ada karakter pelik, whitespace berlebihan, dsb.
|
||||
*/
|
||||
private function cleanPageText(string $text): string
|
||||
{
|
||||
// Buang null bytes
|
||||
$text = str_replace("\0", '', $text);
|
||||
|
||||
// Normalisasikan line break
|
||||
$text = str_replace(["\r\n", "\r"], "\n", $text);
|
||||
|
||||
// Buang whitespace berlebihan pada setiap baris
|
||||
$lines = explode("\n", $text);
|
||||
$lines = array_map('trim', $lines);
|
||||
|
||||
// Gabungkan baris kosong berturutan kepada satu baris kosong
|
||||
$cleaned = [];
|
||||
$lastEmpty = false;
|
||||
foreach ($lines as $line) {
|
||||
$isEmpty = empty($line);
|
||||
if ($isEmpty && $lastEmpty) {
|
||||
continue; // Skip baris kosong berturutan
|
||||
}
|
||||
$cleaned[] = $line;
|
||||
$lastEmpty = $isEmpty;
|
||||
}
|
||||
|
||||
return implode("\n", $cleaned);
|
||||
}
|
||||
}
|
||||
229
app/Services/KnowledgeBase/AuditService.php
Normal file
229
app/Services/KnowledgeBase/AuditService.php
Normal file
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\KnowledgeBase;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Request;
|
||||
|
||||
/**
|
||||
* AuditService
|
||||
*
|
||||
* Simpan audit trail untuk semua tindakan penting dalam sistem.
|
||||
* Append-only — tiada delete atau update audit log.
|
||||
*/
|
||||
class AuditService
|
||||
{
|
||||
/**
|
||||
* Log satu event.
|
||||
*
|
||||
* @param string $event Nama event (e.g. 'document.uploaded')
|
||||
* @param mixed $model Model yang terlibat (optional)
|
||||
* @param array $oldValues Data sebelum perubahan
|
||||
* @param array $newValues Data selepas perubahan
|
||||
* @param ?string $description Huraian untuk manusia
|
||||
*/
|
||||
public function log(
|
||||
string $event,
|
||||
mixed $model = null,
|
||||
array $oldValues = [],
|
||||
array $newValues = [],
|
||||
?string $description = null
|
||||
): AuditLog {
|
||||
return AuditLog::create([
|
||||
'user_id' => Auth::id(),
|
||||
'event' => $event,
|
||||
'auditable_type' => $model ? get_class($model) : null,
|
||||
'auditable_id' => $model?->getKey(),
|
||||
'old_values' => empty($oldValues) ? null : $oldValues,
|
||||
'new_values' => empty($newValues) ? null : $newValues,
|
||||
'description' => $description,
|
||||
'ip_address' => Request::ip(),
|
||||
'user_agent' => Request::userAgent(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Shortcut methods untuk event biasa
|
||||
|
||||
public function documentUploaded($document, $version): void
|
||||
{
|
||||
$this->log(
|
||||
'document.uploaded',
|
||||
$document,
|
||||
[],
|
||||
[
|
||||
'document_id' => $document->id,
|
||||
'version_number' => $version->version_number,
|
||||
'filename' => $version->original_filename,
|
||||
],
|
||||
"Dokumen '{$document->title}' versi {$version->version_number} diupload."
|
||||
);
|
||||
}
|
||||
|
||||
public function documentActivated($document): void
|
||||
{
|
||||
$this->log(
|
||||
'document.activated',
|
||||
$document,
|
||||
['is_active' => false],
|
||||
['is_active' => true],
|
||||
"Dokumen '{$document->title}' diaktifkan."
|
||||
);
|
||||
}
|
||||
|
||||
public function documentDeactivated($document): void
|
||||
{
|
||||
$this->log(
|
||||
'document.deactivated',
|
||||
$document,
|
||||
['is_active' => true],
|
||||
['is_active' => false],
|
||||
"Dokumen '{$document->title}' dinyahaktifkan."
|
||||
);
|
||||
}
|
||||
|
||||
public function documentReindexed($document, $version): void
|
||||
{
|
||||
$this->log(
|
||||
'document.reindexed',
|
||||
$version,
|
||||
[],
|
||||
['document_id' => $document->id, 'version_id' => $version->id],
|
||||
"Dokumen '{$document->title}' versi {$version->version_number} diindeks semula."
|
||||
);
|
||||
}
|
||||
|
||||
public function knowledgeItemCreated($item): void
|
||||
{
|
||||
$this->log(
|
||||
'knowledge_item.created',
|
||||
$item,
|
||||
[],
|
||||
['title' => $item->title, 'type' => $item->item_type],
|
||||
"Knowledge item '{$item->title}' ({$item->item_type}) dicipta."
|
||||
);
|
||||
}
|
||||
|
||||
public function knowledgeItemUpdated($item, array $oldValues): void
|
||||
{
|
||||
$this->log(
|
||||
'knowledge_item.updated',
|
||||
$item,
|
||||
$oldValues,
|
||||
$item->getAttributes(),
|
||||
"Knowledge item '{$item->title}' dikemaskini."
|
||||
);
|
||||
}
|
||||
|
||||
public function knowledgeItemDeactivated($item): void
|
||||
{
|
||||
$this->log(
|
||||
'knowledge_item.deactivated',
|
||||
$item,
|
||||
['is_active' => true],
|
||||
['is_active' => false],
|
||||
"Knowledge item '{$item->title}' dinyahaktifkan."
|
||||
);
|
||||
}
|
||||
|
||||
public function faqConvertedFromFeedback($feedback, $knowledgeItem): void
|
||||
{
|
||||
$this->log(
|
||||
'faq.converted_from_feedback',
|
||||
$knowledgeItem,
|
||||
[],
|
||||
['feedback_id' => $feedback->id, 'knowledge_item_id' => $knowledgeItem->id],
|
||||
"FAQ baru '{$knowledgeItem->title}' dicipta dari feedback chat."
|
||||
);
|
||||
}
|
||||
|
||||
public function categoryCreated($category): void
|
||||
{
|
||||
$this->log(
|
||||
'category.created',
|
||||
$category,
|
||||
[],
|
||||
['name' => $category->name, 'slug' => $category->slug],
|
||||
"Kategori '{$category->name}' dicipta."
|
||||
);
|
||||
}
|
||||
|
||||
public function systemReindexStarted(string $scope): void
|
||||
{
|
||||
$this->log(
|
||||
'system.reindex_started',
|
||||
null,
|
||||
[],
|
||||
['scope' => $scope],
|
||||
"Reindeks sistem dimulakan untuk: {$scope}"
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// CHUNK REVIEW & EDITING EVENTS
|
||||
// =========================================================================
|
||||
|
||||
public function chunkFinalTextEdited($chunk, ?string $oldText, string $newText): void
|
||||
{
|
||||
$this->log(
|
||||
'chunk.final_text_edited',
|
||||
$chunk,
|
||||
['final_text' => mb_substr($oldText ?? '[content asal]', 0, 200)],
|
||||
['final_text' => mb_substr($newText, 0, 200)],
|
||||
"final_text chunk #{$chunk->chunk_index} (ID: {$chunk->id}) diedit. Reindex diantrikan."
|
||||
);
|
||||
}
|
||||
|
||||
public function chunkExcluded($chunk, string $oldStatus): void
|
||||
{
|
||||
$this->log(
|
||||
'chunk.excluded',
|
||||
$chunk,
|
||||
['chunk_status' => $oldStatus, 'is_active' => true],
|
||||
['chunk_status' => 'excluded', 'is_active' => false],
|
||||
"Chunk #{$chunk->chunk_index} (ID: {$chunk->id}) dikecualikan dari indexing."
|
||||
);
|
||||
}
|
||||
|
||||
public function chunkIncluded($chunk, string $oldStatus): void
|
||||
{
|
||||
$this->log(
|
||||
'chunk.included',
|
||||
$chunk,
|
||||
['chunk_status' => $oldStatus, 'is_active' => false],
|
||||
['chunk_status' => $chunk->chunk_status, 'is_active' => true],
|
||||
"Chunk #{$chunk->chunk_index} (ID: {$chunk->id}) dikembalikan ke indexing."
|
||||
);
|
||||
}
|
||||
|
||||
public function chunkReindexTriggered($chunk): void
|
||||
{
|
||||
$this->log(
|
||||
'chunk.reindex_triggered',
|
||||
$chunk,
|
||||
[],
|
||||
['chunk_status' => 'needs_reindex'],
|
||||
"Reindex manual dicetuskan untuk chunk #{$chunk->chunk_index} (ID: {$chunk->id})."
|
||||
);
|
||||
}
|
||||
|
||||
public function chunkSplit($parentChunk, array $children, string $splitGroupId): void
|
||||
{
|
||||
$childIds = array_map(fn($c) => $c->id, $children);
|
||||
|
||||
$this->log(
|
||||
'chunk.split',
|
||||
$parentChunk,
|
||||
['chunk_status' => 'indexed', 'is_active' => true],
|
||||
[
|
||||
'chunk_status' => 'superseded',
|
||||
'is_active' => false,
|
||||
'split_group_id' => $splitGroupId,
|
||||
'child_count' => count($children),
|
||||
'child_chunk_ids' => $childIds,
|
||||
],
|
||||
"Chunk #{$parentChunk->chunk_index} (ID: {$parentChunk->id}) di-split kepada "
|
||||
. count($children) . " chunk baharu. Split group: {$splitGroupId}"
|
||||
);
|
||||
}
|
||||
}
|
||||
438
app/Services/KnowledgeBase/IngestionService.php
Normal file
438
app/Services/KnowledgeBase/IngestionService.php
Normal file
@@ -0,0 +1,438 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\KnowledgeBase;
|
||||
|
||||
use App\Models\DocumentChunk;
|
||||
use App\Models\DocumentVersion;
|
||||
use App\Models\KnowledgeItem;
|
||||
use App\Models\ProcessingLog;
|
||||
use App\Services\Document\ChunkingService;
|
||||
use App\Services\Document\PdfExtractorService;
|
||||
use App\Services\Ollama\OllamaService;
|
||||
use App\Services\Qdrant\QdrantService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* IngestionService
|
||||
*
|
||||
* Menyelaras keseluruhan proses ingestion dokumen:
|
||||
* Extract → Chunk → Embed → Qdrant Sync
|
||||
*
|
||||
* Ini adalah "orchestrator" — ia koordinasi semua service lain.
|
||||
* Setiap langkah dilog dalam processing_logs untuk monitoring.
|
||||
*/
|
||||
class IngestionService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PdfExtractorService $extractor,
|
||||
private readonly ChunkingService $chunker,
|
||||
private readonly OllamaService $ollama,
|
||||
private readonly QdrantService $qdrant,
|
||||
) {}
|
||||
|
||||
private function normalizeExtractedText(string $text): string
|
||||
{
|
||||
$text = str_replace(["\r\n", "\r"], "\n", $text);
|
||||
|
||||
// Buang control character pelik kecuali newline dan tab
|
||||
$text = preg_replace('/[^\P{C}\n\t]+/u', '', $text);
|
||||
|
||||
// Tukar multiple whitespace kepada satu space, tapi kekalkan line break asas
|
||||
$text = preg_replace("/[ \t]+/u", ' ', $text);
|
||||
$text = preg_replace("/\n{3,}/u", "\n\n", $text);
|
||||
|
||||
return trim($text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Proses penuh satu document version.
|
||||
* Dipanggil oleh ProcessUploadedDocumentJob.
|
||||
*
|
||||
* @throws RuntimeException Jika proses gagal pada mana-mana langkah
|
||||
*/
|
||||
public function processDocumentVersion(DocumentVersion $version): void
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
|
||||
Log::info("Mula proses document version {$version->id}", [
|
||||
'document_id' => $version->document_id,
|
||||
'version' => $version->version_number,
|
||||
]);
|
||||
|
||||
// ── Langkah 1: Extract ──────────────────────────────────────────────
|
||||
$version->updateStatus(DocumentVersion::STATUS_EXTRACTING);
|
||||
|
||||
ProcessingLog::record(
|
||||
DocumentVersion::class,
|
||||
$version->id,
|
||||
ProcessingLog::STAGE_EXTRACT,
|
||||
ProcessingLog::STATUS_STARTED
|
||||
);
|
||||
|
||||
$extraction = $this->extractor->extract(
|
||||
$version->stored_path,
|
||||
config('knowledgebase.upload.storage_disk', 'local')
|
||||
);
|
||||
|
||||
if (!$extraction['success']) {
|
||||
$version->updateStatus(DocumentVersion::STATUS_EXTRACTION_FAILED, $extraction['error']);
|
||||
|
||||
ProcessingLog::record(
|
||||
DocumentVersion::class,
|
||||
$version->id,
|
||||
ProcessingLog::STAGE_EXTRACT,
|
||||
ProcessingLog::STATUS_FAILED,
|
||||
$extraction['error']
|
||||
);
|
||||
|
||||
throw new RuntimeException(
|
||||
"Pengekstrakan teks gagal: " . $extraction['error']
|
||||
);
|
||||
}
|
||||
|
||||
// Kemaskini page count jika dapat
|
||||
if ($extraction['page_count'] > 0) {
|
||||
$version->update(['page_count' => $extraction['page_count']]);
|
||||
}
|
||||
|
||||
ProcessingLog::record(
|
||||
DocumentVersion::class,
|
||||
$version->id,
|
||||
ProcessingLog::STAGE_EXTRACT,
|
||||
ProcessingLog::STATUS_COMPLETED,
|
||||
null,
|
||||
['page_count' => $extraction['page_count']]
|
||||
);
|
||||
|
||||
// ── Langkah 2: Chunk ─────────────────────────────────────────────────
|
||||
$version->updateStatus(DocumentVersion::STATUS_CHUNKING);
|
||||
|
||||
ProcessingLog::record(
|
||||
DocumentVersion::class,
|
||||
$version->id,
|
||||
ProcessingLog::STAGE_CHUNK,
|
||||
ProcessingLog::STATUS_STARTED
|
||||
);
|
||||
|
||||
// Normalize teks sebelum dihantar ke chunker
|
||||
$normalizedText = $this->normalizeExtractedText($extraction['full_text']);
|
||||
|
||||
$chunks = $this->chunker->chunk(
|
||||
$normalizedText,
|
||||
$extraction['pages']
|
||||
);
|
||||
|
||||
if (empty($chunks)) {
|
||||
$version->updateStatus(DocumentVersion::STATUS_FAILED, 'Tiada chunk dihasilkan dari teks.');
|
||||
|
||||
ProcessingLog::record(
|
||||
DocumentVersion::class,
|
||||
$version->id,
|
||||
ProcessingLog::STAGE_CHUNK,
|
||||
ProcessingLog::STATUS_FAILED,
|
||||
'Tiada chunk dihasilkan'
|
||||
);
|
||||
|
||||
throw new RuntimeException('Tiada chunk dihasilkan dari dokumen.');
|
||||
}
|
||||
|
||||
// Deactivate chunk versi sebelumnya (jika ini bukan versi pertama)
|
||||
$this->deactivatePreviousChunks($version);
|
||||
|
||||
// Simpan chunk baru dalam MySQL
|
||||
$savedChunks = $this->saveChunks($version, $chunks);
|
||||
|
||||
ProcessingLog::record(
|
||||
DocumentVersion::class,
|
||||
$version->id,
|
||||
ProcessingLog::STAGE_CHUNK,
|
||||
ProcessingLog::STATUS_COMPLETED,
|
||||
null,
|
||||
['chunk_count' => count($savedChunks)]
|
||||
);
|
||||
|
||||
// ── Langkah 3: Embed & Qdrant ────────────────────────────────────────
|
||||
$version->updateStatus(DocumentVersion::STATUS_EMBEDDING);
|
||||
|
||||
ProcessingLog::record(
|
||||
DocumentVersion::class,
|
||||
$version->id,
|
||||
ProcessingLog::STAGE_EMBED,
|
||||
ProcessingLog::STATUS_STARTED
|
||||
);
|
||||
|
||||
$this->embedAndSyncChunks($version, $savedChunks);
|
||||
|
||||
// ── Selesai ──────────────────────────────────────────────────────────
|
||||
$version->updateStatus(DocumentVersion::STATUS_INDEXED);
|
||||
|
||||
// Aktifkan dokumen jika ini versi pertama yang berjaya
|
||||
$document = $version->document;
|
||||
if ($document->status !== 'active') {
|
||||
$document->update([
|
||||
'status' => 'active',
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
$duration = round(microtime(true) - $startTime, 2);
|
||||
|
||||
ProcessingLog::record(
|
||||
DocumentVersion::class,
|
||||
$version->id,
|
||||
ProcessingLog::STAGE_COMPLETE,
|
||||
ProcessingLog::STATUS_COMPLETED,
|
||||
null,
|
||||
['duration_seconds' => $duration, 'chunk_count' => count($savedChunks)]
|
||||
);
|
||||
|
||||
Log::info("Dokumen version {$version->id} berjaya diproses dalam {$duration}s", [
|
||||
'chunk_count' => count($savedChunks),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed dan sync satu knowledge item ke Qdrant.
|
||||
* Dipanggil selepas create/update knowledge item.
|
||||
*/
|
||||
public function processKnowledgeItem(KnowledgeItem $item): void
|
||||
{
|
||||
$text = $item->getEmbeddableText();
|
||||
|
||||
if (empty(trim($text))) {
|
||||
throw new RuntimeException('Knowledge item tidak mempunyai kandungan untuk di-embed.');
|
||||
}
|
||||
|
||||
// Jika ada qdrant_point_id lama, update
|
||||
// Jika tiada, jana UUID baru
|
||||
$pointId = $item->qdrant_point_id ?? (string) Str::uuid();
|
||||
|
||||
$vector = $this->ollama->embed($text);
|
||||
$payload = $this->buildKnowledgeItemPayload($item);
|
||||
|
||||
$this->qdrant->ensureCollectionExists();
|
||||
$this->qdrant->upsertPoint($pointId, $vector, $payload);
|
||||
|
||||
$item->markAsEmbedded($pointId);
|
||||
|
||||
Log::info("KnowledgeItem {$item->id} berjaya di-embed.", [
|
||||
'type' => $item->item_type,
|
||||
'category_id' => $item->category_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate semua chunk dalam Qdrant untuk versi lama.
|
||||
* Chunk dalam MySQL kekal — hanya is_active di Qdrant dikemaskini.
|
||||
*/
|
||||
public function deactivateVersionInQdrant(DocumentVersion $version): void
|
||||
{
|
||||
$chunks = $version->chunks()
|
||||
->whereNotNull('qdrant_point_id')
|
||||
->where('is_embedded', true)
|
||||
->get();
|
||||
|
||||
if ($chunks->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pointIds = $chunks->pluck('qdrant_point_id')->toArray();
|
||||
|
||||
$this->qdrant->updatePayloadBatch($pointIds, [
|
||||
'is_active' => false,
|
||||
'status' => 'inactive',
|
||||
]);
|
||||
|
||||
// Kemaskini MySQL juga
|
||||
$version->chunks()->update(['is_active' => false]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate knowledge item dalam Qdrant.
|
||||
*/
|
||||
public function deactivateKnowledgeItemInQdrant(KnowledgeItem $item): void
|
||||
{
|
||||
if ($item->qdrant_point_id) {
|
||||
$this->qdrant->updatePayload($item->qdrant_point_id, [
|
||||
'is_active' => false,
|
||||
'status' => 'inactive',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// PRIVATE HELPERS
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Deactivate chunk dari versi sebelumnya.
|
||||
*/
|
||||
private function deactivatePreviousChunks(DocumentVersion $currentVersion): void
|
||||
{
|
||||
$previousVersions = DocumentVersion::where('document_id', $currentVersion->document_id)
|
||||
->where('id', '!=', $currentVersion->id)
|
||||
->where('processing_status', DocumentVersion::STATUS_INDEXED)
|
||||
->get();
|
||||
|
||||
foreach ($previousVersions as $prev) {
|
||||
$this->deactivateVersionInQdrant($prev);
|
||||
|
||||
// Tandakan versi lama bukan current lagi
|
||||
$prev->update(['is_current' => false]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simpan semua chunk dalam MySQL.
|
||||
*
|
||||
* @return DocumentChunk[]
|
||||
*/
|
||||
private function saveChunks(DocumentVersion $version, array $chunks): array
|
||||
{
|
||||
$document = $version->document;
|
||||
|
||||
return DB::transaction(function () use ($version, $document, $chunks) {
|
||||
$saved = [];
|
||||
|
||||
foreach ($chunks as $chunk) {
|
||||
$saved[] = DocumentChunk::create([
|
||||
'document_id' => $document->id,
|
||||
'document_version_id' => $version->id,
|
||||
'chunk_index' => $chunk['chunk_index'],
|
||||
'page_number' => $chunk['page_number'] ?? null,
|
||||
'content' => $chunk['content'],
|
||||
'token_count' => $chunk['word_count'] ?? null,
|
||||
'section_heading' => $chunk['section_heading'] ?? null,
|
||||
'is_active' => true,
|
||||
'is_embedded' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
// Set versi ini sebagai current
|
||||
$version->update(['is_current' => true]);
|
||||
|
||||
return $saved;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Jana embedding dan sync semua chunk ke Qdrant.
|
||||
*/
|
||||
private function embedAndSyncChunks(DocumentVersion $version, array $chunks): void
|
||||
{
|
||||
$document = $version->document;
|
||||
$category = $document->category;
|
||||
|
||||
$this->qdrant->ensureCollectionExists();
|
||||
|
||||
$batchSize = 10; // Proses 10 chunk sekali untuk elak timeout Ollama
|
||||
$chunkBatches = array_chunk($chunks, $batchSize);
|
||||
|
||||
foreach ($chunkBatches as $batch) {
|
||||
$points = [];
|
||||
|
||||
foreach ($batch as $chunk) {
|
||||
try {
|
||||
// Guna getEmbeddableText() — final_text > cleaned_text > content
|
||||
// Semasa ingestion pertama, final_text dan cleaned_text adalah null
|
||||
// jadi ia akan fallback ke content (raw extraction)
|
||||
$vector = $this->ollama->embed($chunk->getEmbeddableText());
|
||||
$pointId = (string) Str::uuid();
|
||||
|
||||
$points[] = [
|
||||
'id' => $pointId,
|
||||
'vector' => $vector,
|
||||
'payload' => $this->buildChunkPayload($chunk, $version, $document, $category),
|
||||
];
|
||||
|
||||
$chunk->markAsEmbedded($pointId);
|
||||
} catch (RuntimeException $e) {
|
||||
Log::error("Gagal embed chunk {$chunk->id}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($points)) {
|
||||
$this->qdrant->upsertPoints($points);
|
||||
}
|
||||
}
|
||||
|
||||
ProcessingLog::record(
|
||||
DocumentVersion::class,
|
||||
$version->id,
|
||||
ProcessingLog::STAGE_QDRANT,
|
||||
ProcessingLog::STATUS_COMPLETED,
|
||||
null,
|
||||
['synced_points' => count($chunks)]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bina Qdrant payload untuk chunk PDF.
|
||||
* Payload ini yang akan digunakan untuk filter dan display sumber.
|
||||
*/
|
||||
private function buildChunkPayload(
|
||||
DocumentChunk $chunk,
|
||||
DocumentVersion $version,
|
||||
$document,
|
||||
$category
|
||||
): array {
|
||||
return [
|
||||
'knowledge_type' => 'pdf_chunk',
|
||||
'source_type' => 'pdf',
|
||||
'category_id' => $category->id,
|
||||
'category_name' => $category->name,
|
||||
'category_slug' => $category->slug,
|
||||
'document_id' => $document->id,
|
||||
'document_version_id' => $version->id,
|
||||
'document_chunk_id' => $chunk->id,
|
||||
'knowledge_item_id' => null,
|
||||
'title' => $document->title,
|
||||
'page_number' => $chunk->page_number,
|
||||
'chunk_index' => $chunk->chunk_index,
|
||||
'section_heading' => $chunk->section_heading,
|
||||
'text' => mb_substr($chunk->getEmbeddableText(), 0, 1000),
|
||||
// Excerpt teks yang di-embed (final_text > cleaned_text > content)
|
||||
'is_active' => true,
|
||||
'status' => 'active',
|
||||
'tags' => $document->tags ?? [],
|
||||
'effective_date' => $document->effective_date?->toDateString(),
|
||||
'language' => $document->language,
|
||||
'created_at' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bina Qdrant payload untuk knowledge item (FAQ, polisi, dll.)
|
||||
*/
|
||||
private function buildKnowledgeItemPayload(KnowledgeItem $item): array
|
||||
{
|
||||
return [
|
||||
'knowledge_type' => $item->item_type,
|
||||
'source_type' => 'manual',
|
||||
'category_id' => $item->category_id,
|
||||
'category_name' => $item->category->name,
|
||||
'category_slug' => $item->category->slug,
|
||||
'document_id' => null,
|
||||
'document_version_id' => null,
|
||||
'document_chunk_id' => null,
|
||||
'knowledge_item_id' => $item->id,
|
||||
'title' => $item->title,
|
||||
'page_number' => null,
|
||||
'chunk_index' => 0,
|
||||
'section_heading' => null,
|
||||
'text' => mb_substr($item->getEmbeddableText(), 0, 1000),
|
||||
'is_active' => $item->is_active,
|
||||
'status' => $item->is_active ? 'active' : 'inactive',
|
||||
'tags' => $item->tags ?? [],
|
||||
'effective_date' => $item->effective_date?->toDateString(),
|
||||
'language' => $item->language,
|
||||
'created_at' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
249
app/Services/KnowledgeBase/RAGService.php
Normal file
249
app/Services/KnowledgeBase/RAGService.php
Normal file
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\KnowledgeBase;
|
||||
|
||||
use App\Services\Ollama\OllamaService;
|
||||
use App\Services\Qdrant\QdrantService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* RAGService (Retrieval-Augmented Generation)
|
||||
*
|
||||
* Koordinasi proses RAG:
|
||||
* 1. Jana embedding untuk soalan user
|
||||
* 2. Cari context paling relevan dari Qdrant
|
||||
* 3. Bina context string
|
||||
* 4. Hantar ke Ollama untuk jawapan
|
||||
* 5. Return jawapan + source references
|
||||
*/
|
||||
class RAGService
|
||||
{
|
||||
private int $maxContextChunks;
|
||||
private int $maxContextWords;
|
||||
|
||||
public function __construct(
|
||||
private readonly OllamaService $ollama,
|
||||
private readonly QdrantService $qdrant,
|
||||
) {
|
||||
$this->maxContextChunks = config('knowledgebase.rag.max_context_chunks', 5);
|
||||
$this->maxContextWords = config('knowledgebase.rag.max_context_words', 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Jawab soalan menggunakan RAG.
|
||||
*
|
||||
* @param string $question Soalan pengguna
|
||||
* @param ?int $categoryId Filter kategori (null = semua)
|
||||
* @return array{
|
||||
* answer: string,
|
||||
* has_answer: bool,
|
||||
* sources: array[],
|
||||
* context_chunks: array[],
|
||||
* model_used: string,
|
||||
* tokens_used: ?int,
|
||||
* response_time: float
|
||||
* }
|
||||
* @throws RuntimeException Jika Ollama atau Qdrant tidak tersedia
|
||||
*/
|
||||
public function ask(string $question, ?int $categoryId = null): array
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
|
||||
// ── Langkah 1: Jana embedding untuk soalan ─────────────────────────
|
||||
$queryVector = $this->ollama->embed($question);
|
||||
|
||||
// ── Langkah 2: Cari context relevan dari Qdrant ─────────────────────
|
||||
$filter = $this->qdrant->buildFilter(
|
||||
categoryId: $categoryId,
|
||||
isActive: true,
|
||||
);
|
||||
|
||||
$scoreThreshold = config('qdrant.search.score_threshold', 0.3);
|
||||
|
||||
$searchResults = $this->qdrant->searchSimilar(
|
||||
vector: $queryVector,
|
||||
limit: $this->maxContextChunks,
|
||||
filter: $filter,
|
||||
scoreThreshold: $scoreThreshold,
|
||||
);
|
||||
|
||||
//log search result
|
||||
\Log::info('Qdrant search raw results', [
|
||||
'question' => $question,
|
||||
'results' => $searchResults,
|
||||
]);
|
||||
|
||||
\Log::info('Qdrant raw results', [
|
||||
'scores' => array_map(fn($r) => $r['score'] ?? null, $searchResults),
|
||||
]);
|
||||
|
||||
if (empty($searchResults)) {
|
||||
$responseTime = round(microtime(true) - $startTime, 3);
|
||||
|
||||
return [
|
||||
'answer' => config('ollama.rag_system_prompt_no_result',
|
||||
'Maaf, saya tidak menemui maklumat berkaitan dalam pangkalan pengetahuan kami. ' .
|
||||
'Sila hubungi pejabat kami untuk maklumat lanjut.'),
|
||||
'has_answer' => false,
|
||||
'sources' => [],
|
||||
'context_chunks' => [],
|
||||
'model_used' => config('ollama.chat_model'),
|
||||
'tokens_used' => null,
|
||||
'response_time' => $responseTime,
|
||||
];
|
||||
}
|
||||
|
||||
// ── Langkah 3: Bina context string ─────────────────────────────────
|
||||
[$context, $contextChunksData] = $this->buildContext($searchResults);
|
||||
|
||||
// ── Langkah 4: Hantar ke Ollama ─────────────────────────────────────
|
||||
$chatResult = $this->ollama->chat($question, $context);
|
||||
|
||||
// ── Langkah 5: Bina source references ──────────────────────────────
|
||||
$sources = $this->buildSourceReferences($searchResults);
|
||||
|
||||
$responseTime = round(microtime(true) - $startTime, 3);
|
||||
|
||||
// Tentukan sama ada model ada jawapan atau tidak
|
||||
$hasAnswer = $this->detectHasAnswer($chatResult['answer']);
|
||||
|
||||
return [
|
||||
'answer' => $chatResult['answer'],
|
||||
'has_answer' => $hasAnswer,
|
||||
'sources' => $sources,
|
||||
'context_chunks' => $contextChunksData,
|
||||
'model_used' => $chatResult['model'],
|
||||
'tokens_used' => $chatResult['tokens'],
|
||||
'response_time' => $responseTime,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bina context string dari search results.
|
||||
* Had bilangan perkataan supaya tidak melebihi context window model.
|
||||
*
|
||||
* @return array{0: string, 1: array[]}
|
||||
*/
|
||||
private function buildContext(array $searchResults): array
|
||||
{
|
||||
$contextParts = [];
|
||||
$chunksData = [];
|
||||
$totalWords = 0;
|
||||
|
||||
foreach ($searchResults as $result) {
|
||||
$payload = $result['payload'] ?? [];
|
||||
$text = $payload['text'] ?? '';
|
||||
|
||||
if (empty($text)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$words = str_word_count($text);
|
||||
|
||||
if ($totalWords + $words > $this->maxContextWords) {
|
||||
// Potong jika context dah terlalu panjang
|
||||
if (empty($contextParts)) {
|
||||
// Sekurang-kurangnya masukkan satu chunk
|
||||
$contextParts[] = $text;
|
||||
$chunksData[] = $this->extractChunkData($result);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$source = $this->formatSourceLabel($payload);
|
||||
$contextParts[] = "[Sumber: {$source}]\n{$text}";
|
||||
$chunksData[] = $this->extractChunkData($result);
|
||||
$totalWords += $words;
|
||||
}
|
||||
|
||||
return [implode("\n\n---\n\n", $contextParts), $chunksData];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bina array source references untuk paparan kepada pengguna.
|
||||
*/
|
||||
private function buildSourceReferences(array $searchResults): array
|
||||
{
|
||||
$sources = [];
|
||||
$seen = []; // Elak duplikasi sumber yang sama
|
||||
|
||||
foreach ($searchResults as $result) {
|
||||
$payload = $result['payload'] ?? [];
|
||||
|
||||
$sourceKey = ($payload['document_id'] ?? '') . '_' .
|
||||
($payload['knowledge_item_id'] ?? '') . '_' .
|
||||
($payload['page_number'] ?? '');
|
||||
|
||||
if (isset($seen[$sourceKey])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$seen[$sourceKey] = true;
|
||||
|
||||
$sources[] = [
|
||||
'type' => $payload['source_type'] ?? 'unknown',
|
||||
'knowledge_type' => $payload['knowledge_type'] ?? '',
|
||||
'title' => $payload['title'] ?? 'Tiada tajuk',
|
||||
'category' => $payload['category_name'] ?? '',
|
||||
'category_id' => $payload['category_id'] ?? null,
|
||||
'page_number' => $payload['page_number'] ?? null,
|
||||
'section_heading' => $payload['section_heading'] ?? null,
|
||||
'document_id' => $payload['document_id'] ?? null,
|
||||
'knowledge_item_id' => $payload['knowledge_item_id'] ?? null,
|
||||
'score' => round($result['score'] ?? 0, 4),
|
||||
];
|
||||
}
|
||||
|
||||
return $sources;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract data chunk untuk disimpan dalam chat_logs.
|
||||
*/
|
||||
private function extractChunkData(array $result): array
|
||||
{
|
||||
return [
|
||||
'point_id' => $result['id'] ?? null,
|
||||
'score' => round($result['score'] ?? 0, 4),
|
||||
'title' => $result['payload']['title'] ?? '',
|
||||
'category' => $result['payload']['category_name'] ?? '',
|
||||
'source_type' => $result['payload']['source_type'] ?? '',
|
||||
'page_number' => $result['payload']['page_number'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private function formatSourceLabel(array $payload): string
|
||||
{
|
||||
$title = $payload['title'] ?? 'Tanpa tajuk';
|
||||
$page = isset($payload['page_number']) ? ", ms. {$payload['page_number']}" : '';
|
||||
$category = $payload['category_name'] ?? '';
|
||||
|
||||
return "{$title}{$page} ({$category})";
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect sama ada model sebenarnya ada jawapan atau tidak.
|
||||
* Semak jika jawapan adalah "tidak tahu" / fallback.
|
||||
*/
|
||||
private function detectHasAnswer(string $answer): bool
|
||||
{
|
||||
$noAnswerPatterns = [
|
||||
'tidak menemui',
|
||||
'tiada maklumat',
|
||||
'tidak terdapat dalam',
|
||||
'sila hubungi',
|
||||
'tidak dapat menjawab',
|
||||
'maklumat tidak tersedia',
|
||||
];
|
||||
|
||||
$answerLower = mb_strtolower($answer);
|
||||
foreach ($noAnswerPatterns as $pattern) {
|
||||
if (str_contains($answerLower, $pattern)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return !empty(trim($answer));
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
466
app/Services/Qdrant/QdrantService.php
Normal file
466
app/Services/Qdrant/QdrantService.php
Normal file
@@ -0,0 +1,466 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Qdrant;
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* QdrantService
|
||||
*
|
||||
* Wrapper untuk Qdrant REST API.
|
||||
* Menguruskan: buat collection, upsert, cari, update, dan delete point.
|
||||
*
|
||||
* Reka bentuk: Satu collection 'knowledge_base' untuk semua jenis knowledge.
|
||||
* Gunakan payload filtering untuk bezakan kategori, jenis, status.
|
||||
*/
|
||||
class QdrantService
|
||||
{
|
||||
private string $baseUrl;
|
||||
private ?string $apiKey;
|
||||
private string $collection;
|
||||
private array $timeouts;
|
||||
private int $batchSize;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->baseUrl = config('qdrant.base_url');
|
||||
$this->apiKey = config('qdrant.api_key');
|
||||
$this->collection = config('qdrant.collection');
|
||||
$this->timeouts = config('qdrant.timeout');
|
||||
$this->batchSize = config('qdrant.batch_size');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// COLLECTION MANAGEMENT
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Buat collection jika belum wujud.
|
||||
* Selamat dipanggil berulang kali (idempotent).
|
||||
*
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function ensureCollectionExists(): void
|
||||
{
|
||||
try {
|
||||
$response = $this->request('GET', "/collections/{$this->collection}");
|
||||
|
||||
if ($response->status() === 200) {
|
||||
return; // Collection sudah wujud
|
||||
}
|
||||
} catch (RequestException $e) {
|
||||
if ($e->response->status() !== 404) {
|
||||
throw new RuntimeException(
|
||||
'Gagal semak collection Qdrant: ' . $e->response->body()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Collection tidak wujud — buat baru
|
||||
$this->createCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Buat collection baru dengan konfigurasi dari config/qdrant.php
|
||||
*/
|
||||
public function createCollection(): void
|
||||
{
|
||||
$vectorSize = config('qdrant.vector.size');
|
||||
$vectorDistance = config('qdrant.vector.distance');
|
||||
|
||||
$response = $this->request('PUT', "/collections/{$this->collection}", [
|
||||
'vectors' => [
|
||||
'size' => $vectorSize,
|
||||
'distance' => $vectorDistance,
|
||||
],
|
||||
]);
|
||||
|
||||
if (!$response->ok()) {
|
||||
throw new RuntimeException(
|
||||
"Gagal buat collection Qdrant: " . $response->body()
|
||||
);
|
||||
}
|
||||
|
||||
Log::info("Qdrant collection '{$this->collection}' berjaya dibuat.", [
|
||||
'vector_size' => $vectorSize,
|
||||
'vector_distance' => $vectorDistance,
|
||||
]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// POINT OPERATIONS
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Upsert satu point ke Qdrant.
|
||||
* Jika point dengan ID yang sama sudah wujud, ia akan digantikan.
|
||||
*
|
||||
* @param string $pointId UUID point
|
||||
* @param float[] $vector Embedding vector
|
||||
* @param array $payload Metadata point
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function upsertPoint(string $pointId, array $vector, array $payload): void
|
||||
{
|
||||
$this->upsertPoints([
|
||||
[
|
||||
'id' => $pointId,
|
||||
'vector' => $vector,
|
||||
'payload' => $payload,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert banyak point sekaligus (lebih efisien).
|
||||
*
|
||||
* @param array[] $points Array of {id, vector, payload}
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function upsertPoints(array $points): void
|
||||
{
|
||||
if (empty($points)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hantar dalam batch untuk elak request terlalu besar
|
||||
foreach (array_chunk($points, $this->batchSize) as $batch) {
|
||||
try {
|
||||
$response = $this->request(
|
||||
'PUT',
|
||||
"/collections/{$this->collection}/points",
|
||||
['points' => $batch]
|
||||
);
|
||||
|
||||
if (!$response->ok()) {
|
||||
throw new RuntimeException(
|
||||
"Qdrant upsert gagal: " . $response->body()
|
||||
);
|
||||
}
|
||||
} catch (ConnectionException $e) {
|
||||
throw new RuntimeException(
|
||||
'Tidak dapat sambung ke Qdrant semasa upsert.',
|
||||
0,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cari point yang paling serupa dengan vector yang diberikan.
|
||||
*
|
||||
* @param float[] $vector Query vector
|
||||
* @param int $limit Bilangan hasil
|
||||
* @param array $filter Payload filter (optional)
|
||||
* @param float $scoreThreshold Min score (optional)
|
||||
* @return array[] Array of {id, score, payload}
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function searchSimilar(
|
||||
array $vector,
|
||||
int $limit = 5,
|
||||
array $filter = [],
|
||||
float $scoreThreshold = 0.0
|
||||
): array {
|
||||
$body = [
|
||||
'vector' => $vector,
|
||||
'limit' => $limit,
|
||||
'with_payload' => true,
|
||||
'with_vector' => false,
|
||||
];
|
||||
|
||||
if ($scoreThreshold > 0.0) {
|
||||
$body['score_threshold'] = $scoreThreshold;
|
||||
}
|
||||
|
||||
if (!empty($filter)) {
|
||||
$body['filter'] = $filter;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->request(
|
||||
'POST',
|
||||
"/collections/{$this->collection}/points/search",
|
||||
$body
|
||||
);
|
||||
|
||||
$response->throw();
|
||||
|
||||
return $response->json('result', []);
|
||||
} catch (ConnectionException $e) {
|
||||
throw new RuntimeException(
|
||||
'Tidak dapat sambung ke Qdrant semasa carian.',
|
||||
0,
|
||||
$e
|
||||
);
|
||||
} catch (RequestException $e) {
|
||||
Log::error('Qdrant search gagal', [
|
||||
'status' => $e->response->status(),
|
||||
'body' => $e->response->body(),
|
||||
]);
|
||||
throw new RuntimeException(
|
||||
'Carian dalam Qdrant gagal.',
|
||||
0,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kemaskini payload point yang sedia ada.
|
||||
* Berguna untuk set is_active=false tanpa delete point.
|
||||
*
|
||||
* @param string $pointId
|
||||
* @param array $payload Hanya field yang hendak dikemaskini
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function updatePayload(string $pointId, array $payload): void
|
||||
{
|
||||
try {
|
||||
$response = $this->request(
|
||||
'POST',
|
||||
"/collections/{$this->collection}/points/payload",
|
||||
[
|
||||
'payload' => $payload,
|
||||
'points' => [$pointId],
|
||||
]
|
||||
);
|
||||
|
||||
if (!$response->ok()) {
|
||||
throw new RuntimeException(
|
||||
"Qdrant payload update gagal untuk point {$pointId}: " . $response->body()
|
||||
);
|
||||
}
|
||||
} catch (ConnectionException $e) {
|
||||
throw new RuntimeException(
|
||||
'Tidak dapat sambung ke Qdrant semasa update payload.',
|
||||
0,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kemaskini payload untuk banyak point sekaligus.
|
||||
* Berguna untuk deactivate semua chunk sesuatu dokumen.
|
||||
*
|
||||
* @param string[] $pointIds
|
||||
* @param array $payload
|
||||
*/
|
||||
public function updatePayloadBatch(array $pointIds, array $payload): void
|
||||
{
|
||||
if (empty($pointIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (array_chunk($pointIds, $this->batchSize) as $batch) {
|
||||
$this->request(
|
||||
'POST',
|
||||
"/collections/{$this->collection}/points/payload",
|
||||
[
|
||||
'payload' => $payload,
|
||||
'points' => $batch,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Padam point dari Qdrant.
|
||||
* Gunakan ini hanya untuk hard delete yang benar-benar diperlukan.
|
||||
* Untuk soft delete, gunakan updatePayload({is_active: false}).
|
||||
*
|
||||
* @param string|string[] $pointIds
|
||||
*/
|
||||
public function deletePoints(array|string $pointIds): void
|
||||
{
|
||||
$ids = is_array($pointIds) ? $pointIds : [$pointIds];
|
||||
|
||||
if (empty($ids)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (array_chunk($ids, $this->batchSize) as $batch) {
|
||||
try {
|
||||
$this->request(
|
||||
'POST',
|
||||
"/collections/{$this->collection}/points/delete",
|
||||
['points' => $batch]
|
||||
);
|
||||
} catch (ConnectionException $e) {
|
||||
Log::error('Qdrant delete gagal', ['error' => $e->getMessage()]);
|
||||
throw new RuntimeException(
|
||||
'Tidak dapat sambung ke Qdrant semasa delete.',
|
||||
0,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll — dapatkan semua point yang memenuhi filter.
|
||||
* Berguna untuk audit atau bulk operations.
|
||||
*
|
||||
* @param array $filter
|
||||
* @param int $limit
|
||||
* @param ?string $offset Point ID untuk paginasi
|
||||
* @return array{points: array[], next_page_offset: ?string}
|
||||
*/
|
||||
public function scroll(array $filter = [], int $limit = 100, ?string $offset = null): array
|
||||
{
|
||||
$body = [
|
||||
'limit' => $limit,
|
||||
'with_payload' => true,
|
||||
'with_vector' => false,
|
||||
];
|
||||
|
||||
if (!empty($filter)) {
|
||||
$body['filter'] = $filter;
|
||||
}
|
||||
|
||||
if ($offset !== null) {
|
||||
$body['offset'] = $offset;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->request(
|
||||
'POST',
|
||||
"/collections/{$this->collection}/points/scroll",
|
||||
$body
|
||||
);
|
||||
|
||||
$response->throw();
|
||||
|
||||
return [
|
||||
'points' => $response->json('result.points', []),
|
||||
'next_page_offset' => $response->json('result.next_page_offset'),
|
||||
];
|
||||
} catch (ConnectionException $e) {
|
||||
throw new RuntimeException(
|
||||
'Tidak dapat sambung ke Qdrant semasa scroll.',
|
||||
0,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Semak kesihatan Qdrant.
|
||||
*
|
||||
* @return array{online: bool, collection_exists: bool, points_count: int|null, error: ?string}
|
||||
*/
|
||||
public function healthCheck(): array
|
||||
{
|
||||
$result = [
|
||||
'online' => false,
|
||||
'collection_exists' => false,
|
||||
'points_count' => null,
|
||||
'error' => null,
|
||||
];
|
||||
|
||||
try {
|
||||
$response = Http::timeout($this->timeouts['connect'])
|
||||
->when($this->apiKey, fn($h) => $h->withToken($this->apiKey))
|
||||
->get("{$this->baseUrl}/healthz");
|
||||
|
||||
if (!$response->ok()) {
|
||||
$result['error'] = 'Qdrant tidak responsif';
|
||||
return $result;
|
||||
}
|
||||
|
||||
$result['online'] = true;
|
||||
|
||||
// Semak collection
|
||||
$collResponse = $this->request('GET', "/collections/{$this->collection}");
|
||||
if ($collResponse->ok()) {
|
||||
$result['collection_exists'] = true;
|
||||
$result['points_count'] = $collResponse->json(
|
||||
'result.points_count'
|
||||
);
|
||||
}
|
||||
} catch (ConnectionException $e) {
|
||||
$result['error'] = 'Tidak dapat sambung ke Qdrant: ' . $e->getMessage();
|
||||
} catch (\Exception $e) {
|
||||
$result['error'] = $e->getMessage();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// FILTER BUILDERS
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Bina filter Qdrant untuk carian berdasarkan kategori dan jenis.
|
||||
*
|
||||
* Gunakan: QdrantService::buildFilter(category_id: 1, is_active: true)
|
||||
*/
|
||||
public function buildFilter(
|
||||
?int $categoryId = null,
|
||||
?bool $isActive = true,
|
||||
?string $sourceType = null,
|
||||
?string $knowledgeType = null,
|
||||
): array {
|
||||
$must = [];
|
||||
|
||||
// Sentiasa tapis yang aktif sahaja (default)
|
||||
if ($isActive !== null) {
|
||||
$must[] = [
|
||||
'key' => 'is_active',
|
||||
'match' => ['value' => $isActive],
|
||||
];
|
||||
}
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$must[] = [
|
||||
'key' => 'category_id',
|
||||
'match' => ['value' => $categoryId],
|
||||
];
|
||||
}
|
||||
|
||||
if ($sourceType !== null) {
|
||||
$must[] = [
|
||||
'key' => 'source_type',
|
||||
'match' => ['value' => $sourceType],
|
||||
];
|
||||
}
|
||||
|
||||
if ($knowledgeType !== null) {
|
||||
$must[] = [
|
||||
'key' => 'knowledge_type',
|
||||
'match' => ['value' => $knowledgeType],
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($must)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ['must' => $must];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// PRIVATE HELPERS
|
||||
// =========================================================================
|
||||
|
||||
private function request(string $method, string $path, array $body = [])
|
||||
{
|
||||
$http = Http::timeout($this->timeouts['request'])
|
||||
->when($this->apiKey, fn($h) => $h->withHeaders(['api-key' => $this->apiKey]));
|
||||
|
||||
return match (strtoupper($method)) {
|
||||
'GET' => $http->get("{$this->baseUrl}{$path}"),
|
||||
'POST' => $http->post("{$this->baseUrl}{$path}", $body),
|
||||
'PUT' => $http->put("{$this->baseUrl}{$path}", $body),
|
||||
'DELETE' => $http->delete("{$this->baseUrl}{$path}", $body),
|
||||
default => throw new \InvalidArgumentException("Method tidak disokong: {$method}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user