First commit
This commit is contained in:
18
.editorconfig
Normal file
18
.editorconfig
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{yml,yaml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[compose.yaml]
|
||||||
|
indent_size = 4
|
||||||
129
.env.example
Normal file
129
.env.example
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# SISTEM PANGKALAN PENGETAHUAN — Laravel 11
|
||||||
|
# Salin fail ini menjadi .env dan isi nilai yang sesuai
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# ── Aplikasi ──────────────────────────────────────────────────────
|
||||||
|
APP_NAME="Pangkalan Pengetahuan"
|
||||||
|
APP_ENV=local
|
||||||
|
APP_KEY=
|
||||||
|
APP_DEBUG=true
|
||||||
|
APP_URL=http://localhost:8000
|
||||||
|
APP_LOCALE=ms
|
||||||
|
APP_FALLBACK_LOCALE=ms
|
||||||
|
APP_FAKER_LOCALE=ms_MY
|
||||||
|
|
||||||
|
APP_MAINTENANCE_DRIVER=file
|
||||||
|
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
|
# ── Log ───────────────────────────────────────────────────────────
|
||||||
|
LOG_CHANNEL=stack
|
||||||
|
LOG_STACK=single
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# ── Database MySQL ────────────────────────────────────────────────
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=knowledge_base
|
||||||
|
DB_USERNAME=root
|
||||||
|
DB_PASSWORD=
|
||||||
|
|
||||||
|
# ── Sesi ──────────────────────────────────────────────────────────
|
||||||
|
SESSION_DRIVER=database
|
||||||
|
SESSION_LIFETIME=480
|
||||||
|
SESSION_ENCRYPT=false
|
||||||
|
SESSION_PATH=/
|
||||||
|
SESSION_DOMAIN=null
|
||||||
|
|
||||||
|
# ── Queue ─────────────────────────────────────────────────────────
|
||||||
|
# Gunakan 'database' untuk development, 'redis' untuk production
|
||||||
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
|
# ── Cache ─────────────────────────────────────────────────────────
|
||||||
|
CACHE_STORE=database
|
||||||
|
|
||||||
|
# ── Storage ───────────────────────────────────────────────────────
|
||||||
|
FILESYSTEM_DISK=local
|
||||||
|
|
||||||
|
# ── Ollama ────────────────────────────────────────────────────────
|
||||||
|
# URL Ollama yang berjalan secara lokal
|
||||||
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
|
|
||||||
|
# Model untuk chat/generate
|
||||||
|
# Pilihan: llama3, mistral, qwen2, gemma2
|
||||||
|
OLLAMA_CHAT_MODEL=llama3
|
||||||
|
|
||||||
|
# Model untuk embedding
|
||||||
|
# Pilihan: nomic-embed-text (disyorkan), mxbai-embed-large
|
||||||
|
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||||
|
|
||||||
|
# Timeout (saat)
|
||||||
|
OLLAMA_CONNECT_TIMEOUT=5
|
||||||
|
OLLAMA_CHAT_TIMEOUT=120
|
||||||
|
OLLAMA_EMBED_TIMEOUT=30
|
||||||
|
|
||||||
|
# Retry
|
||||||
|
OLLAMA_RETRY_TIMES=2
|
||||||
|
OLLAMA_RETRY_SLEEP=1000
|
||||||
|
|
||||||
|
# Parameter model
|
||||||
|
OLLAMA_TEMPERATURE=0.1
|
||||||
|
OLLAMA_TOP_P=0.9
|
||||||
|
OLLAMA_NUM_CTX=4096
|
||||||
|
|
||||||
|
# ── Qdrant ────────────────────────────────────────────────────────
|
||||||
|
QDRANT_BASE_URL=http://localhost:6333
|
||||||
|
QDRANT_API_KEY=
|
||||||
|
QDRANT_COLLECTION=knowledge_base
|
||||||
|
|
||||||
|
# Saiz vector — MESTI sepadan dengan model embedding
|
||||||
|
# nomic-embed-text: 768
|
||||||
|
# mxbai-embed-large: 1024
|
||||||
|
QDRANT_VECTOR_SIZE=768
|
||||||
|
QDRANT_VECTOR_DISTANCE=Cosine
|
||||||
|
|
||||||
|
# Carian
|
||||||
|
QDRANT_TOP_K=5
|
||||||
|
QDRANT_SCORE_THRESHOLD=0.3
|
||||||
|
|
||||||
|
# Timeout
|
||||||
|
QDRANT_CONNECT_TIMEOUT=5
|
||||||
|
QDRANT_REQUEST_TIMEOUT=30
|
||||||
|
|
||||||
|
# Batch size untuk upsert
|
||||||
|
QDRANT_BATCH_SIZE=50
|
||||||
|
|
||||||
|
# ── Knowledge Base ────────────────────────────────────────────────
|
||||||
|
# Upload
|
||||||
|
KB_MAX_FILE_SIZE=20480
|
||||||
|
KB_STORAGE_DISK=local
|
||||||
|
|
||||||
|
# Chunking
|
||||||
|
KB_CHUNK_MAX_WORDS=500
|
||||||
|
KB_CHUNK_OVERLAP_WORDS=75
|
||||||
|
KB_CHUNK_MIN_WORDS=30
|
||||||
|
|
||||||
|
# RAG
|
||||||
|
KB_RAG_MAX_CHUNKS=5
|
||||||
|
KB_RAG_MAX_CONTEXT_WORDS=2000
|
||||||
|
|
||||||
|
# Rate limiting chatbot (request per minit per IP)
|
||||||
|
KB_CHAT_RATE_LIMIT=20
|
||||||
|
|
||||||
|
# Queue names
|
||||||
|
KB_QUEUE_INGESTION=default
|
||||||
|
KB_QUEUE_EMBEDDING=default
|
||||||
|
KB_QUEUE_CHAT_LOG=default
|
||||||
|
|
||||||
|
# ── Mail (optional) ───────────────────────────────────────────────
|
||||||
|
MAIL_MAILER=log
|
||||||
|
MAIL_HOST=127.0.0.1
|
||||||
|
MAIL_PORT=2525
|
||||||
|
MAIL_USERNAME=null
|
||||||
|
MAIL_PASSWORD=null
|
||||||
|
MAIL_FROM_ADDRESS="noreply@majlis.gov.my"
|
||||||
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
11
.gitattributes
vendored
Normal file
11
.gitattributes
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.blade.php diff=html
|
||||||
|
*.css diff=css
|
||||||
|
*.html diff=html
|
||||||
|
*.md diff=markdown
|
||||||
|
*.php diff=php
|
||||||
|
|
||||||
|
/.github export-ignore
|
||||||
|
CHANGELOG.md export-ignore
|
||||||
|
.styleci.yml export-ignore
|
||||||
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.backup
|
||||||
|
.env.production
|
||||||
|
.phpactor.json
|
||||||
|
.phpunit.result.cache
|
||||||
|
/.fleet
|
||||||
|
/.idea
|
||||||
|
/.nova
|
||||||
|
/.phpunit.cache
|
||||||
|
/.vscode
|
||||||
|
/.zed
|
||||||
|
/auth.json
|
||||||
|
/node_modules
|
||||||
|
/public/build
|
||||||
|
/public/hot
|
||||||
|
/public/storage
|
||||||
|
/storage/*.key
|
||||||
|
/storage/pail
|
||||||
|
/vendor
|
||||||
|
_ide_helper.php
|
||||||
|
Homestead.json
|
||||||
|
Homestead.yaml
|
||||||
|
Thumbs.db
|
||||||
58
README.md
Normal file
58
README.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||||
|
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||||
|
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||||
|
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## About Laravel
|
||||||
|
|
||||||
|
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||||
|
|
||||||
|
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||||
|
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||||
|
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||||
|
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||||
|
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||||
|
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||||
|
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||||
|
|
||||||
|
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||||
|
|
||||||
|
## Learning Laravel
|
||||||
|
|
||||||
|
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
|
||||||
|
|
||||||
|
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||||
|
|
||||||
|
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.
|
||||||
|
|
||||||
|
## Agentic Development
|
||||||
|
|
||||||
|
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer require laravel/boost --dev
|
||||||
|
|
||||||
|
php artisan boost:install
|
||||||
|
```
|
||||||
|
|
||||||
|
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||||
|
|
||||||
|
## Security Vulnerabilities
|
||||||
|
|
||||||
|
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||||
214
SETUP.md
Normal file
214
SETUP.md
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
# Panduan Setup — Sistem Pangkalan Pengetahuan
|
||||||
|
|
||||||
|
## Keperluan Sistem
|
||||||
|
|
||||||
|
- PHP 8.3+ (PHP 8.5 disokong)
|
||||||
|
- MySQL 8.0+
|
||||||
|
- Composer 2.x
|
||||||
|
- Ollama (berjalan pada localhost:11434)
|
||||||
|
- Qdrant (berjalan pada localhost:6333)
|
||||||
|
- Node.js (optional, untuk Vite)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Langkah 1: Konfigurasi .env
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Salin contoh .env
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Jana app key
|
||||||
|
php artisan key:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` dengan tetapan anda:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Database MySQL
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=knowledge_base
|
||||||
|
DB_USERNAME=root
|
||||||
|
DB_PASSWORD=your_password_here
|
||||||
|
|
||||||
|
# Ollama (tukar model ikut yang anda ada)
|
||||||
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
|
OLLAMA_CHAT_MODEL=llama3
|
||||||
|
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||||
|
|
||||||
|
# Qdrant
|
||||||
|
QDRANT_BASE_URL=http://localhost:6333
|
||||||
|
QDRANT_VECTOR_SIZE=768
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Langkah 2: Setup Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Buat database (dalam MySQL)
|
||||||
|
mysql -u root -p -e "CREATE DATABASE knowledge_base CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||||
|
|
||||||
|
# Jalankan migrasi
|
||||||
|
php artisan migrate
|
||||||
|
|
||||||
|
# Seed data awal (kategori + user admin + sample FAQ)
|
||||||
|
php artisan db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Langkah 3: Pull Model Ollama
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Model embedding (wajib)
|
||||||
|
ollama pull nomic-embed-text
|
||||||
|
|
||||||
|
# Model chat (pilih satu)
|
||||||
|
ollama pull llama3 # atau
|
||||||
|
ollama pull mistral # atau
|
||||||
|
ollama pull qwen2 # (lebih ringan)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Langkah 4: Setup Qdrant
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Jalankan Qdrant dengan Docker
|
||||||
|
docker run -p 6333:6333 -p 6334:6334 \
|
||||||
|
-v $(pwd)/qdrant_storage:/qdrant/storage:z \
|
||||||
|
qdrant/qdrant
|
||||||
|
|
||||||
|
# Collection akan dibuat secara automatik semasa pertama embed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Langkah 5: Semak Kesihatan Sistem
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan kb:health-check
|
||||||
|
```
|
||||||
|
|
||||||
|
Output yang dijangka:
|
||||||
|
```
|
||||||
|
════════════════════════════════════════
|
||||||
|
PEMERIKSAAN KESIHATAN SISTEM
|
||||||
|
════════════════════════════════════════
|
||||||
|
|
||||||
|
📦 MySQL
|
||||||
|
✅ Berjaya sambung ke: knowledge_base
|
||||||
|
|
||||||
|
🤖 Ollama
|
||||||
|
✅ Online
|
||||||
|
✅ Model Chat: llama3
|
||||||
|
✅ Model Embed: nomic-embed-text
|
||||||
|
|
||||||
|
🗄️ Qdrant
|
||||||
|
✅ Online
|
||||||
|
✅ Collection: knowledge_base
|
||||||
|
📊 Vectors: 0
|
||||||
|
|
||||||
|
⏳ Queue
|
||||||
|
✅ Driver konfigurasi untuk async.
|
||||||
|
|
||||||
|
════════════════════════════════════════
|
||||||
|
✅ SEMUA PERKHIDMATAN OK
|
||||||
|
════════════════════════════════════════
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Langkah 6: Setup Storage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Link public storage
|
||||||
|
php artisan storage:link
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Langkah 7: Jalankan Queue Worker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
php artisan queue:work --timeout=600 --tries=2
|
||||||
|
|
||||||
|
# Production (gunakan Supervisor)
|
||||||
|
php artisan queue:work --queue=default --sleep=3 --tries=2 --timeout=600
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Langkah 8: Jalankan Aplikasi
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Akses:
|
||||||
|
- **Chatbot:** http://localhost:8000/chatbot
|
||||||
|
- **Admin:** http://localhost:8000/admin
|
||||||
|
- **Login:** admin@example.com / password
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arahan Artisan Berguna
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Semak kesihatan sistem
|
||||||
|
php artisan kb:health-check
|
||||||
|
|
||||||
|
# Reindex satu dokumen
|
||||||
|
php artisan kb:reindex-document --document_id=1
|
||||||
|
|
||||||
|
# Reindex semua dokumen yang gagal
|
||||||
|
php artisan kb:reindex-document --all-failed
|
||||||
|
|
||||||
|
# Reindex semua dalam kategori pelesenan
|
||||||
|
php artisan kb:reindex-category --slug=pelesenan
|
||||||
|
|
||||||
|
# Preview tanpa jalankan (dry run)
|
||||||
|
php artisan kb:reindex-category --slug=pelesenan --dry-run
|
||||||
|
|
||||||
|
# Monitor queue
|
||||||
|
php artisan queue:monitor default
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nota Production
|
||||||
|
|
||||||
|
1. **Tukar kata laluan admin** di admin panel atau melalui tinker
|
||||||
|
2. **Set QUEUE_CONNECTION=redis** dan gunakan Supervisor untuk queue worker
|
||||||
|
3. **Set APP_DEBUG=false** dalam .env
|
||||||
|
4. **Setup backup** untuk storage/app/documents/ (fail PDF asal)
|
||||||
|
5. **Rate limiting** chatbot sudah dikonfigurasi (20 req/min per IP) — laraskan `KB_CHAT_RATE_LIMIT`
|
||||||
|
6. **Qdrant** — tambah API key untuk keselamatan (`QDRANT_API_KEY=xxx`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Struktur Direktori Penting
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── Actions/ ← Single-purpose business logic
|
||||||
|
├── Console/Commands/ ← Artisan commands (kb:*)
|
||||||
|
├── Http/Controllers/ ← Admin & Chatbot controllers
|
||||||
|
├── Jobs/ ← Queue jobs
|
||||||
|
├── Models/ ← Eloquent models
|
||||||
|
└── Services/
|
||||||
|
├── Document/ ← PDF extraction & chunking
|
||||||
|
├── KnowledgeBase/ ← Ingestion, RAG, Audit
|
||||||
|
├── Ollama/ ← Ollama HTTP client
|
||||||
|
└── Qdrant/ ← Qdrant HTTP client
|
||||||
|
|
||||||
|
config/
|
||||||
|
├── knowledgebase.php ← Konfigurasi umum KB
|
||||||
|
├── ollama.php ← Konfigurasi Ollama
|
||||||
|
└── qdrant.php ← Konfigurasi Qdrant
|
||||||
|
|
||||||
|
storage/app/documents/ ← Fail PDF asal (JANGAN padam)
|
||||||
|
```
|
||||||
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}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
18
artisan
Normal file
18
artisan
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Symfony\Component\Console\Input\ArgvInput;
|
||||||
|
|
||||||
|
define('LARAVEL_START', microtime(true));
|
||||||
|
|
||||||
|
// Register the Composer autoloader...
|
||||||
|
require __DIR__.'/vendor/autoload.php';
|
||||||
|
|
||||||
|
// Bootstrap Laravel and handle the command...
|
||||||
|
/** @var Application $app */
|
||||||
|
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||||
|
|
||||||
|
$status = $app->handleCommand(new ArgvInput);
|
||||||
|
|
||||||
|
exit($status);
|
||||||
20
bootstrap/app.php
Normal file
20
bootstrap/app.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Application;
|
||||||
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
|
||||||
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
|
->withRouting(
|
||||||
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
commands: __DIR__.'/../routes/console.php',
|
||||||
|
health: '/up',
|
||||||
|
)
|
||||||
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
|
$middleware->alias([
|
||||||
|
'role' => \App\Http\Middleware\EnsureUserRole::class,
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
|
//
|
||||||
|
})->create();
|
||||||
2
bootstrap/cache/.gitignore
vendored
Normal file
2
bootstrap/cache/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
7
bootstrap/providers.php
Normal file
7
bootstrap/providers.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Providers\AppServiceProvider;
|
||||||
|
|
||||||
|
return [
|
||||||
|
AppServiceProvider::class,
|
||||||
|
];
|
||||||
86
composer.json
Normal file
86
composer.json
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://getcomposer.org/schema.json",
|
||||||
|
"name": "laravel/laravel",
|
||||||
|
"type": "project",
|
||||||
|
"description": "The skeleton application for the Laravel framework.",
|
||||||
|
"keywords": ["laravel", "framework"],
|
||||||
|
"license": "MIT",
|
||||||
|
"require": {
|
||||||
|
"php": "^8.3",
|
||||||
|
"laravel/framework": "^13.0",
|
||||||
|
"laravel/tinker": "^3.0",
|
||||||
|
"smalot/pdfparser": "^2.12"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"fakerphp/faker": "^1.23",
|
||||||
|
"laravel/pail": "^1.2.5",
|
||||||
|
"laravel/pint": "^1.27",
|
||||||
|
"mockery/mockery": "^1.6",
|
||||||
|
"nunomaduro/collision": "^8.6",
|
||||||
|
"phpunit/phpunit": "^12.5.12"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "app/",
|
||||||
|
"Database\\Factories\\": "database/factories/",
|
||||||
|
"Database\\Seeders\\": "database/seeders/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"setup": [
|
||||||
|
"composer install",
|
||||||
|
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||||
|
"@php artisan key:generate",
|
||||||
|
"@php artisan migrate --force",
|
||||||
|
"npm install --ignore-scripts",
|
||||||
|
"npm run build"
|
||||||
|
],
|
||||||
|
"dev": [
|
||||||
|
"Composer\\Config::disableProcessTimeout",
|
||||||
|
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||||
|
],
|
||||||
|
"test": [
|
||||||
|
"@php artisan config:clear --ansi",
|
||||||
|
"@php artisan test"
|
||||||
|
],
|
||||||
|
"post-autoload-dump": [
|
||||||
|
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||||
|
"@php artisan package:discover --ansi"
|
||||||
|
],
|
||||||
|
"post-update-cmd": [
|
||||||
|
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||||
|
],
|
||||||
|
"post-root-package-install": [
|
||||||
|
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||||
|
],
|
||||||
|
"post-create-project-cmd": [
|
||||||
|
"@php artisan key:generate --ansi",
|
||||||
|
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||||
|
"@php artisan migrate --graceful --ansi"
|
||||||
|
],
|
||||||
|
"pre-package-uninstall": [
|
||||||
|
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"dont-discover": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"optimize-autoloader": true,
|
||||||
|
"preferred-install": "dist",
|
||||||
|
"sort-packages": true,
|
||||||
|
"allow-plugins": {
|
||||||
|
"pestphp/pest-plugin": true,
|
||||||
|
"php-http/discovery": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"prefer-stable": true
|
||||||
|
}
|
||||||
8077
composer.lock
generated
Normal file
8077
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
126
config/app.php
Normal file
126
config/app.php
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value is the name of your application, which will be used when the
|
||||||
|
| framework needs to place the application's name in a notification or
|
||||||
|
| other UI elements where an application name needs to be displayed.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'name' => env('APP_NAME', 'Laravel'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Environment
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value determines the "environment" your application is currently
|
||||||
|
| running in. This may determine how you prefer to configure various
|
||||||
|
| services the application utilizes. Set this in your ".env" file.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'env' => env('APP_ENV', 'production'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Debug Mode
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When your application is in debug mode, detailed error messages with
|
||||||
|
| stack traces will be shown on every error that occurs within your
|
||||||
|
| application. If disabled, a simple generic error page is shown.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'debug' => (bool) env('APP_DEBUG', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application URL
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This URL is used by the console to properly generate URLs when using
|
||||||
|
| the Artisan command line tool. You should set this to the root of
|
||||||
|
| the application so that it's available within Artisan commands.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'url' => env('APP_URL', 'http://localhost'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Timezone
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify the default timezone for your application, which
|
||||||
|
| will be used by the PHP date and date-time functions. The timezone
|
||||||
|
| is set to "UTC" by default as it is suitable for most use cases.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'timezone' => 'UTC',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Application Locale Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The application locale determines the default locale that will be used
|
||||||
|
| by Laravel's translation / localization methods. This option can be
|
||||||
|
| set to any locale for which you plan to have translation strings.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'locale' => env('APP_LOCALE', 'en'),
|
||||||
|
|
||||||
|
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||||
|
|
||||||
|
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Encryption Key
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This key is utilized by Laravel's encryption services and should be set
|
||||||
|
| to a random, 32 character string to ensure that all encrypted values
|
||||||
|
| are secure. You should do this prior to deploying the application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'cipher' => 'AES-256-CBC',
|
||||||
|
|
||||||
|
'key' => env('APP_KEY'),
|
||||||
|
|
||||||
|
'previous_keys' => [
|
||||||
|
...array_filter(
|
||||||
|
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Maintenance Mode Driver
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These configuration options determine the driver used to determine and
|
||||||
|
| manage Laravel's "maintenance mode" status. The "cache" driver will
|
||||||
|
| allow maintenance mode to be controlled across multiple machines.
|
||||||
|
|
|
||||||
|
| Supported drivers: "file", "cache"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'maintenance' => [
|
||||||
|
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||||
|
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
117
config/auth.php
Normal file
117
config/auth.php
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Authentication Defaults
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option defines the default authentication "guard" and password
|
||||||
|
| reset "broker" for your application. You may change these values
|
||||||
|
| as required, but they're a perfect start for most applications.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'defaults' => [
|
||||||
|
'guard' => env('AUTH_GUARD', 'web'),
|
||||||
|
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Authentication Guards
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Next, you may define every authentication guard for your application.
|
||||||
|
| Of course, a great default configuration has been defined for you
|
||||||
|
| which utilizes session storage plus the Eloquent user provider.
|
||||||
|
|
|
||||||
|
| All authentication guards have a user provider, which defines how the
|
||||||
|
| users are actually retrieved out of your database or other storage
|
||||||
|
| system used by the application. Typically, Eloquent is utilized.
|
||||||
|
|
|
||||||
|
| Supported: "session"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'guards' => [
|
||||||
|
'web' => [
|
||||||
|
'driver' => 'session',
|
||||||
|
'provider' => 'users',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| User Providers
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| All authentication guards have a user provider, which defines how the
|
||||||
|
| users are actually retrieved out of your database or other storage
|
||||||
|
| system used by the application. Typically, Eloquent is utilized.
|
||||||
|
|
|
||||||
|
| If you have multiple user tables or models you may configure multiple
|
||||||
|
| providers to represent the model / table. These providers may then
|
||||||
|
| be assigned to any extra authentication guards you have defined.
|
||||||
|
|
|
||||||
|
| Supported: "database", "eloquent"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'providers' => [
|
||||||
|
'users' => [
|
||||||
|
'driver' => 'eloquent',
|
||||||
|
'model' => env('AUTH_MODEL', User::class),
|
||||||
|
],
|
||||||
|
|
||||||
|
// 'users' => [
|
||||||
|
// 'driver' => 'database',
|
||||||
|
// 'table' => 'users',
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Resetting Passwords
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These configuration options specify the behavior of Laravel's password
|
||||||
|
| reset functionality, including the table utilized for token storage
|
||||||
|
| and the user provider that is invoked to actually retrieve users.
|
||||||
|
|
|
||||||
|
| The expiry time is the number of minutes that each reset token will be
|
||||||
|
| considered valid. This security feature keeps tokens short-lived so
|
||||||
|
| they have less time to be guessed. You may change this as needed.
|
||||||
|
|
|
||||||
|
| The throttle setting is the number of seconds a user must wait before
|
||||||
|
| generating more password reset tokens. This prevents the user from
|
||||||
|
| quickly generating a very large amount of password reset tokens.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'passwords' => [
|
||||||
|
'users' => [
|
||||||
|
'provider' => 'users',
|
||||||
|
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||||
|
'expire' => 60,
|
||||||
|
'throttle' => 60,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Password Confirmation Timeout
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define the number of seconds before a password confirmation
|
||||||
|
| window expires and users are asked to re-enter their password via the
|
||||||
|
| confirmation screen. By default, the timeout lasts for three hours.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||||
|
|
||||||
|
];
|
||||||
130
config/cache.php
Normal file
130
config/cache.php
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Cache Store
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default cache store that will be used by the
|
||||||
|
| framework. This connection is utilized if another isn't explicitly
|
||||||
|
| specified when running a cache operation inside the application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('CACHE_STORE', 'database'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cache Stores
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define all of the cache "stores" for your application as
|
||||||
|
| well as their drivers. You may even define multiple stores for the
|
||||||
|
| same cache driver to group types of items stored in your caches.
|
||||||
|
|
|
||||||
|
| Supported drivers: "array", "database", "file", "memcached",
|
||||||
|
| "redis", "dynamodb", "octane",
|
||||||
|
| "failover", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'stores' => [
|
||||||
|
|
||||||
|
'array' => [
|
||||||
|
'driver' => 'array',
|
||||||
|
'serialize' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'database' => [
|
||||||
|
'driver' => 'database',
|
||||||
|
'connection' => env('DB_CACHE_CONNECTION'),
|
||||||
|
'table' => env('DB_CACHE_TABLE', 'cache'),
|
||||||
|
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
|
||||||
|
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'file' => [
|
||||||
|
'driver' => 'file',
|
||||||
|
'path' => storage_path('framework/cache/data'),
|
||||||
|
'lock_path' => storage_path('framework/cache/data'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'memcached' => [
|
||||||
|
'driver' => 'memcached',
|
||||||
|
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
|
||||||
|
'sasl' => [
|
||||||
|
env('MEMCACHED_USERNAME'),
|
||||||
|
env('MEMCACHED_PASSWORD'),
|
||||||
|
],
|
||||||
|
'options' => [
|
||||||
|
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||||
|
],
|
||||||
|
'servers' => [
|
||||||
|
[
|
||||||
|
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('MEMCACHED_PORT', 11211),
|
||||||
|
'weight' => 100,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'redis' => [
|
||||||
|
'driver' => 'redis',
|
||||||
|
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
|
||||||
|
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'dynamodb' => [
|
||||||
|
'driver' => 'dynamodb',
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||||
|
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
|
||||||
|
'endpoint' => env('DYNAMODB_ENDPOINT'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'octane' => [
|
||||||
|
'driver' => 'octane',
|
||||||
|
],
|
||||||
|
|
||||||
|
'failover' => [
|
||||||
|
'driver' => 'failover',
|
||||||
|
'stores' => [
|
||||||
|
'database',
|
||||||
|
'array',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cache Key Prefix
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
|
||||||
|
| stores, there might be other applications using the same cache. For
|
||||||
|
| that reason, you may prefix every cache key to avoid collisions.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Serializable Classes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value determines the classes that can be unserialized from cache
|
||||||
|
| storage. By default, no PHP classes will be unserialized from your
|
||||||
|
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'serializable_classes' => false,
|
||||||
|
|
||||||
|
];
|
||||||
184
config/database.php
Normal file
184
config/database.php
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Pdo\Mysql;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Database Connection Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify which of the database connections below you wish
|
||||||
|
| to use as your default connection for database operations. This is
|
||||||
|
| the connection which will be utilized unless another connection
|
||||||
|
| is explicitly specified when you execute a query / statement.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Database Connections
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Below are all of the database connections defined for your application.
|
||||||
|
| An example configuration is provided for each database system which
|
||||||
|
| is supported by Laravel. You're free to add / remove connections.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'connections' => [
|
||||||
|
|
||||||
|
'sqlite' => [
|
||||||
|
'driver' => 'sqlite',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||||
|
'prefix' => '',
|
||||||
|
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||||
|
'busy_timeout' => null,
|
||||||
|
'journal_mode' => null,
|
||||||
|
'synchronous' => null,
|
||||||
|
'transaction_mode' => 'DEFERRED',
|
||||||
|
],
|
||||||
|
|
||||||
|
'mysql' => [
|
||||||
|
'driver' => 'mysql',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '3306'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'unix_socket' => env('DB_SOCKET', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||||
|
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'strict' => true,
|
||||||
|
'engine' => null,
|
||||||
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
|
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||||
|
]) : [],
|
||||||
|
],
|
||||||
|
|
||||||
|
'mariadb' => [
|
||||||
|
'driver' => 'mariadb',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '3306'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'unix_socket' => env('DB_SOCKET', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||||
|
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'strict' => true,
|
||||||
|
'engine' => null,
|
||||||
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
|
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||||
|
]) : [],
|
||||||
|
],
|
||||||
|
|
||||||
|
'pgsql' => [
|
||||||
|
'driver' => 'pgsql',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('DB_PORT', '5432'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
'search_path' => 'public',
|
||||||
|
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'sqlsrv' => [
|
||||||
|
'driver' => 'sqlsrv',
|
||||||
|
'url' => env('DB_URL'),
|
||||||
|
'host' => env('DB_HOST', 'localhost'),
|
||||||
|
'port' => env('DB_PORT', '1433'),
|
||||||
|
'database' => env('DB_DATABASE', 'laravel'),
|
||||||
|
'username' => env('DB_USERNAME', 'root'),
|
||||||
|
'password' => env('DB_PASSWORD', ''),
|
||||||
|
'charset' => env('DB_CHARSET', 'utf8'),
|
||||||
|
'prefix' => '',
|
||||||
|
'prefix_indexes' => true,
|
||||||
|
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
|
||||||
|
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Migration Repository Table
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This table keeps track of all the migrations that have already run for
|
||||||
|
| your application. Using this information, we can determine which of
|
||||||
|
| the migrations on disk haven't actually been run on the database.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'migrations' => [
|
||||||
|
'table' => 'migrations',
|
||||||
|
'update_date_on_publish' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Redis Databases
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Redis is an open source, fast, and advanced key-value store that also
|
||||||
|
| provides a richer body of commands than a typical key-value system
|
||||||
|
| such as Memcached. You may define your connection settings here.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'redis' => [
|
||||||
|
|
||||||
|
'client' => env('REDIS_CLIENT', 'phpredis'),
|
||||||
|
|
||||||
|
'options' => [
|
||||||
|
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||||
|
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
||||||
|
'persistent' => env('REDIS_PERSISTENT', false),
|
||||||
|
],
|
||||||
|
|
||||||
|
'default' => [
|
||||||
|
'url' => env('REDIS_URL'),
|
||||||
|
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||||
|
'username' => env('REDIS_USERNAME'),
|
||||||
|
'password' => env('REDIS_PASSWORD'),
|
||||||
|
'port' => env('REDIS_PORT', '6379'),
|
||||||
|
'database' => env('REDIS_DB', '0'),
|
||||||
|
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||||
|
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||||
|
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||||
|
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||||
|
],
|
||||||
|
|
||||||
|
'cache' => [
|
||||||
|
'url' => env('REDIS_URL'),
|
||||||
|
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||||
|
'username' => env('REDIS_USERNAME'),
|
||||||
|
'password' => env('REDIS_PASSWORD'),
|
||||||
|
'port' => env('REDIS_PORT', '6379'),
|
||||||
|
'database' => env('REDIS_CACHE_DB', '1'),
|
||||||
|
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||||
|
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||||
|
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||||
|
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
80
config/filesystems.php
Normal file
80
config/filesystems.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Filesystem Disk
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify the default filesystem disk that should be used
|
||||||
|
| by the framework. The "local" disk, as well as a variety of cloud
|
||||||
|
| based disks are available to your application for file storage.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Filesystem Disks
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Below you may configure as many filesystem disks as necessary, and you
|
||||||
|
| may even configure multiple disks for the same driver. Examples for
|
||||||
|
| most supported storage drivers are configured here for reference.
|
||||||
|
|
|
||||||
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'disks' => [
|
||||||
|
|
||||||
|
'local' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => storage_path('app/private'),
|
||||||
|
'serve' => true,
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'public' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => storage_path('app/public'),
|
||||||
|
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
|
||||||
|
'visibility' => 'public',
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
's3' => [
|
||||||
|
'driver' => 's3',
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION'),
|
||||||
|
'bucket' => env('AWS_BUCKET'),
|
||||||
|
'url' => env('AWS_URL'),
|
||||||
|
'endpoint' => env('AWS_ENDPOINT'),
|
||||||
|
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Symbolic Links
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure the symbolic links that will be created when the
|
||||||
|
| `storage:link` Artisan command is executed. The array keys should be
|
||||||
|
| the locations of the links and the values should be their targets.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'links' => [
|
||||||
|
public_path('storage') => storage_path('app/public'),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
65
config/knowledgebase.php
Normal file
65
config/knowledgebase.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
// config/knowledgebase.php
|
||||||
|
// Konfigurasi umum untuk sistem knowledge base
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Upload Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'upload' => [
|
||||||
|
'max_file_size' => (int) env('KB_MAX_FILE_SIZE', 20480), // KB (20MB)
|
||||||
|
'allowed_mimes' => ['pdf'],
|
||||||
|
'storage_disk' => env('KB_STORAGE_DISK', 'local'),
|
||||||
|
'storage_path' => 'documents', // dalam storage/app/documents/
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Chunking Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'chunking' => [
|
||||||
|
'max_words' => (int) env('KB_CHUNK_MAX_WORDS', 500),
|
||||||
|
// Max patah perkataan per chunk
|
||||||
|
'overlap_words' => (int) env('KB_CHUNK_OVERLAP_WORDS', 75),
|
||||||
|
// Overlap antara chunk berturutan
|
||||||
|
'min_words' => (int) env('KB_CHUNK_MIN_WORDS', 30),
|
||||||
|
// Chunk dengan < min_words akan digabung atau dibuang
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| RAG Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'rag' => [
|
||||||
|
'max_context_chunks' => (int) env('KB_RAG_MAX_CHUNKS', 5),
|
||||||
|
// Max chunk yang dimasukkan dalam context untuk model
|
||||||
|
'max_context_words' => (int) env('KB_RAG_MAX_CONTEXT_WORDS', 2000),
|
||||||
|
// Jika terlalu banyak words, potong context
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Rate Limiting
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'rate_limit' => [
|
||||||
|
'chatbot_per_minute' => (int) env('KB_CHAT_RATE_LIMIT', 20),
|
||||||
|
// Request chatbot per minit per IP
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Queue Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'queue' => [
|
||||||
|
'ingestion' => env('KB_QUEUE_INGESTION', 'default'),
|
||||||
|
'embedding' => env('KB_QUEUE_EMBEDDING', 'default'),
|
||||||
|
'chat_log' => env('KB_QUEUE_CHAT_LOG', 'default'),
|
||||||
|
],
|
||||||
|
];
|
||||||
132
config/logging.php
Normal file
132
config/logging.php
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Monolog\Handler\NullHandler;
|
||||||
|
use Monolog\Handler\StreamHandler;
|
||||||
|
use Monolog\Handler\SyslogUdpHandler;
|
||||||
|
use Monolog\Processor\PsrLogMessageProcessor;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Log Channel
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option defines the default log channel that is utilized to write
|
||||||
|
| messages to your logs. The value provided here should match one of
|
||||||
|
| the channels present in the list of "channels" configured below.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('LOG_CHANNEL', 'stack'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Deprecations Log Channel
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the log channel that should be used to log warnings
|
||||||
|
| regarding deprecated PHP and library features. This allows you to get
|
||||||
|
| your application ready for upcoming major versions of dependencies.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'deprecations' => [
|
||||||
|
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
|
||||||
|
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Log Channels
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure the log channels for your application. Laravel
|
||||||
|
| utilizes the Monolog PHP logging library, which includes a variety
|
||||||
|
| of powerful log handlers and formatters that you're free to use.
|
||||||
|
|
|
||||||
|
| Available drivers: "single", "daily", "slack", "syslog",
|
||||||
|
| "errorlog", "monolog", "custom", "stack"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'channels' => [
|
||||||
|
|
||||||
|
'stack' => [
|
||||||
|
'driver' => 'stack',
|
||||||
|
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
|
||||||
|
'ignore_exceptions' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'single' => [
|
||||||
|
'driver' => 'single',
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'daily' => [
|
||||||
|
'driver' => 'daily',
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'days' => env('LOG_DAILY_DAYS', 14),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'slack' => [
|
||||||
|
'driver' => 'slack',
|
||||||
|
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||||
|
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
|
||||||
|
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
|
||||||
|
'level' => env('LOG_LEVEL', 'critical'),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'papertrail' => [
|
||||||
|
'driver' => 'monolog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
|
||||||
|
'handler_with' => [
|
||||||
|
'host' => env('PAPERTRAIL_URL'),
|
||||||
|
'port' => env('PAPERTRAIL_PORT'),
|
||||||
|
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
||||||
|
],
|
||||||
|
'processors' => [PsrLogMessageProcessor::class],
|
||||||
|
],
|
||||||
|
|
||||||
|
'stderr' => [
|
||||||
|
'driver' => 'monolog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'handler' => StreamHandler::class,
|
||||||
|
'handler_with' => [
|
||||||
|
'stream' => 'php://stderr',
|
||||||
|
],
|
||||||
|
'formatter' => env('LOG_STDERR_FORMATTER'),
|
||||||
|
'processors' => [PsrLogMessageProcessor::class],
|
||||||
|
],
|
||||||
|
|
||||||
|
'syslog' => [
|
||||||
|
'driver' => 'syslog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'errorlog' => [
|
||||||
|
'driver' => 'errorlog',
|
||||||
|
'level' => env('LOG_LEVEL', 'debug'),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'null' => [
|
||||||
|
'driver' => 'monolog',
|
||||||
|
'handler' => NullHandler::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
'emergency' => [
|
||||||
|
'path' => storage_path('logs/laravel.log'),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
118
config/mail.php
Normal file
118
config/mail.php
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Mailer
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default mailer that is used to send all email
|
||||||
|
| messages unless another mailer is explicitly specified when sending
|
||||||
|
| the message. All additional mailers can be configured within the
|
||||||
|
| "mailers" array. Examples of each type of mailer are provided.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('MAIL_MAILER', 'log'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Mailer Configurations
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure all of the mailers used by your application plus
|
||||||
|
| their respective settings. Several examples have been configured for
|
||||||
|
| you and you are free to add your own as your application requires.
|
||||||
|
|
|
||||||
|
| Laravel supports a variety of mail "transport" drivers that can be used
|
||||||
|
| when delivering an email. You may specify which one you're using for
|
||||||
|
| your mailers below. You may also add additional mailers if needed.
|
||||||
|
|
|
||||||
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
|
||||||
|
| "postmark", "resend", "log", "array",
|
||||||
|
| "failover", "roundrobin"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'mailers' => [
|
||||||
|
|
||||||
|
'smtp' => [
|
||||||
|
'transport' => 'smtp',
|
||||||
|
'scheme' => env('MAIL_SCHEME'),
|
||||||
|
'url' => env('MAIL_URL'),
|
||||||
|
'host' => env('MAIL_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('MAIL_PORT', 2525),
|
||||||
|
'username' => env('MAIL_USERNAME'),
|
||||||
|
'password' => env('MAIL_PASSWORD'),
|
||||||
|
'timeout' => null,
|
||||||
|
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
||||||
|
],
|
||||||
|
|
||||||
|
'ses' => [
|
||||||
|
'transport' => 'ses',
|
||||||
|
],
|
||||||
|
|
||||||
|
'postmark' => [
|
||||||
|
'transport' => 'postmark',
|
||||||
|
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
|
||||||
|
// 'client' => [
|
||||||
|
// 'timeout' => 5,
|
||||||
|
// ],
|
||||||
|
],
|
||||||
|
|
||||||
|
'resend' => [
|
||||||
|
'transport' => 'resend',
|
||||||
|
],
|
||||||
|
|
||||||
|
'sendmail' => [
|
||||||
|
'transport' => 'sendmail',
|
||||||
|
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'log' => [
|
||||||
|
'transport' => 'log',
|
||||||
|
'channel' => env('MAIL_LOG_CHANNEL'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'array' => [
|
||||||
|
'transport' => 'array',
|
||||||
|
],
|
||||||
|
|
||||||
|
'failover' => [
|
||||||
|
'transport' => 'failover',
|
||||||
|
'mailers' => [
|
||||||
|
'smtp',
|
||||||
|
'log',
|
||||||
|
],
|
||||||
|
'retry_after' => 60,
|
||||||
|
],
|
||||||
|
|
||||||
|
'roundrobin' => [
|
||||||
|
'transport' => 'roundrobin',
|
||||||
|
'mailers' => [
|
||||||
|
'ses',
|
||||||
|
'postmark',
|
||||||
|
],
|
||||||
|
'retry_after' => 60,
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Global "From" Address
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| You may wish for all emails sent by your application to be sent from
|
||||||
|
| the same address. Here you may specify a name and address that is
|
||||||
|
| used globally for all emails that are sent by your application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'from' => [
|
||||||
|
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
||||||
|
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
86
config/ollama.php
Normal file
86
config/ollama.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
// config/ollama.php
|
||||||
|
// Konfigurasi untuk Ollama API (local LLM + embedding)
|
||||||
|
// Nilai diambil dari .env supaya mudah tukar tanpa ubah code
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Ollama Base URL
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| URL Ollama yang berjalan secara lokal atau dalam rangkaian dalaman.
|
||||||
|
| Default: http://localhost:11434
|
||||||
|
*/
|
||||||
|
'base_url' => env('OLLAMA_BASE_URL', 'http://localhost:11434'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Model Chat (LLM)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Model yang digunakan untuk chat/generate.
|
||||||
|
| Contoh: llama3, mistral, qwen2, gemma2
|
||||||
|
*/
|
||||||
|
'chat_model' => env('OLLAMA_CHAT_MODEL', 'llama3'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Model Embedding
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Model yang digunakan untuk jana embedding vector.
|
||||||
|
| Cadangan: nomic-embed-text (pilihan terbaik untuk teks Melayu/English)
|
||||||
|
| Alternatif: mxbai-embed-large
|
||||||
|
*/
|
||||||
|
'embedding_model' => env('OLLAMA_EMBEDDING_MODEL', 'nomic-embed-text'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Timeout (dalam saat)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'timeout' => [
|
||||||
|
'connect' => (int) env('OLLAMA_CONNECT_TIMEOUT', 5),
|
||||||
|
'chat' => (int) env('OLLAMA_CHAT_TIMEOUT', 120),
|
||||||
|
'embed' => (int) env('OLLAMA_EMBED_TIMEOUT', 30),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Retry Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'retry' => [
|
||||||
|
'times' => (int) env('OLLAMA_RETRY_TIMES', 2),
|
||||||
|
'sleep' => (int) env('OLLAMA_RETRY_SLEEP', 1000), // milliseconds
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Chat Parameters
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'chat' => [
|
||||||
|
'temperature' => (float) env('OLLAMA_TEMPERATURE', 0.1),
|
||||||
|
// Rendah = lebih deterministik, sesuai untuk RAG
|
||||||
|
'top_p' => (float) env('OLLAMA_TOP_P', 0.9),
|
||||||
|
'num_ctx' => (int) env('OLLAMA_NUM_CTX', 4096),
|
||||||
|
// Context window — bergantung pada model
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| RAG System Prompt
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Prompt sistem untuk kawalan jawapan RAG.
|
||||||
|
| Dipisah dari code supaya mudah disesuaikan.
|
||||||
|
*/
|
||||||
|
'rag_system_prompt' => env(
|
||||||
|
'OLLAMA_RAG_SYSTEM_PROMPT',
|
||||||
|
'Anda adalah pembantu maklumat rasmi untuk jabatan kerajaan tempatan. ' .
|
||||||
|
'Jawab soalan berdasarkan konteks yang diberikan SAHAJA. ' .
|
||||||
|
'Jika maklumat tidak terdapat dalam konteks, jawab: ' .
|
||||||
|
'"Maaf, saya tidak menemui maklumat berkaitan dalam pangkalan pengetahuan kami. ' .
|
||||||
|
'Sila hubungi pejabat kami untuk maklumat lanjut." ' .
|
||||||
|
'Jangan reka-reka jawapan. Gunakan Bahasa Melayu yang formal.'
|
||||||
|
),
|
||||||
|
];
|
||||||
77
config/qdrant.php
Normal file
77
config/qdrant.php
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
// config/qdrant.php
|
||||||
|
// Konfigurasi untuk Qdrant vector database
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Qdrant Base URL
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| URL Qdrant yang berjalan secara lokal atau dalam rangkaian dalaman.
|
||||||
|
| Default: http://localhost:6333
|
||||||
|
*/
|
||||||
|
'base_url' => env('QDRANT_BASE_URL', 'http://localhost:6333'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| API Key (optional)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Qdrant boleh dikonfigurasi dengan API key untuk keselamatan.
|
||||||
|
| Kosongkan jika tidak menggunakan API key.
|
||||||
|
*/
|
||||||
|
'api_key' => env('QDRANT_API_KEY', null),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Collection Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Nama satu collection untuk semua knowledge base.
|
||||||
|
| Gunakan payload filtering untuk bezakan kategori/jenis.
|
||||||
|
*/
|
||||||
|
'collection' => env('QDRANT_COLLECTION', 'knowledge_base'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Vector Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Saiz vector mesti sepadan dengan output embedding model.
|
||||||
|
| nomic-embed-text: 768 dimensi
|
||||||
|
| mxbai-embed-large: 1024 dimensi
|
||||||
|
*/
|
||||||
|
'vector' => [
|
||||||
|
'size' => (int) env('QDRANT_VECTOR_SIZE', 768),
|
||||||
|
'distance' => env('QDRANT_VECTOR_DISTANCE', 'Cosine'),
|
||||||
|
// Cosine sesuai untuk semantic similarity teks
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Search Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'search' => [
|
||||||
|
'top_k' => (int) env('QDRANT_TOP_K', 5),
|
||||||
|
// Bilangan chunk paling relevan yang dikembalikan
|
||||||
|
'score_threshold' => (float) env('QDRANT_SCORE_THRESHOLD', 0.3),
|
||||||
|
// Tolak hasil dengan skor < threshold (0.0 - 1.0)
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Timeout (dalam saat)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'timeout' => [
|
||||||
|
'connect' => (int) env('QDRANT_CONNECT_TIMEOUT', 5),
|
||||||
|
'request' => (int) env('QDRANT_REQUEST_TIMEOUT', 30),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Batch Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Bilangan point yang dihantar ke Qdrant dalam satu batch upsert
|
||||||
|
*/
|
||||||
|
'batch_size' => (int) env('QDRANT_BATCH_SIZE', 50),
|
||||||
|
];
|
||||||
129
config/queue.php
Normal file
129
config/queue.php
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Queue Connection Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Laravel's queue supports a variety of backends via a single, unified
|
||||||
|
| API, giving you convenient access to each backend using identical
|
||||||
|
| syntax for each. The default queue connection is defined below.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('QUEUE_CONNECTION', 'database'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Queue Connections
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure the connection options for every queue backend
|
||||||
|
| used by your application. An example configuration is provided for
|
||||||
|
| each backend supported by Laravel. You're also free to add more.
|
||||||
|
|
|
||||||
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
|
||||||
|
| "deferred", "background", "failover", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'connections' => [
|
||||||
|
|
||||||
|
'sync' => [
|
||||||
|
'driver' => 'sync',
|
||||||
|
],
|
||||||
|
|
||||||
|
'database' => [
|
||||||
|
'driver' => 'database',
|
||||||
|
'connection' => env('DB_QUEUE_CONNECTION'),
|
||||||
|
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
||||||
|
'queue' => env('DB_QUEUE', 'default'),
|
||||||
|
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||||
|
'after_commit' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'beanstalkd' => [
|
||||||
|
'driver' => 'beanstalkd',
|
||||||
|
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
|
||||||
|
'queue' => env('BEANSTALKD_QUEUE', 'default'),
|
||||||
|
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
|
||||||
|
'block_for' => 0,
|
||||||
|
'after_commit' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'sqs' => [
|
||||||
|
'driver' => 'sqs',
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
|
||||||
|
'queue' => env('SQS_QUEUE', 'default'),
|
||||||
|
'suffix' => env('SQS_SUFFIX'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||||
|
'after_commit' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'redis' => [
|
||||||
|
'driver' => 'redis',
|
||||||
|
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||||
|
'queue' => env('REDIS_QUEUE', 'default'),
|
||||||
|
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
|
||||||
|
'block_for' => null,
|
||||||
|
'after_commit' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'deferred' => [
|
||||||
|
'driver' => 'deferred',
|
||||||
|
],
|
||||||
|
|
||||||
|
'background' => [
|
||||||
|
'driver' => 'background',
|
||||||
|
],
|
||||||
|
|
||||||
|
'failover' => [
|
||||||
|
'driver' => 'failover',
|
||||||
|
'connections' => [
|
||||||
|
'database',
|
||||||
|
'deferred',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Job Batching
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following options configure the database and table that store job
|
||||||
|
| batching information. These options can be updated to any database
|
||||||
|
| connection and table which has been defined by your application.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'batching' => [
|
||||||
|
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||||
|
'table' => 'job_batches',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Failed Queue Jobs
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These options configure the behavior of failed queue job logging so you
|
||||||
|
| can control how and where failed jobs are stored. Laravel ships with
|
||||||
|
| support for storing failed jobs in a simple file or in a database.
|
||||||
|
|
|
||||||
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'failed' => [
|
||||||
|
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
|
||||||
|
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||||
|
'table' => 'failed_jobs',
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
38
config/services.php
Normal file
38
config/services.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Third Party Services
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This file is for storing the credentials for third party services such
|
||||||
|
| as Mailgun, Postmark, AWS and more. This file provides the de facto
|
||||||
|
| location for this type of information, allowing packages to have
|
||||||
|
| a conventional file to locate the various service credentials.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'postmark' => [
|
||||||
|
'key' => env('POSTMARK_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'resend' => [
|
||||||
|
'key' => env('RESEND_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'ses' => [
|
||||||
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
|
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||||
|
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'slack' => [
|
||||||
|
'notifications' => [
|
||||||
|
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
|
||||||
|
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
233
config/session.php
Normal file
233
config/session.php
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Session Driver
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option determines the default session driver that is utilized for
|
||||||
|
| incoming requests. Laravel supports a variety of storage options to
|
||||||
|
| persist session data. Database storage is a great default choice.
|
||||||
|
|
|
||||||
|
| Supported: "file", "cookie", "database", "memcached",
|
||||||
|
| "redis", "dynamodb", "array"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'driver' => env('SESSION_DRIVER', 'database'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Lifetime
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may specify the number of minutes that you wish the session
|
||||||
|
| to be allowed to remain idle before it expires. If you want them
|
||||||
|
| to expire immediately when the browser is closed then you may
|
||||||
|
| indicate that via the expire_on_close configuration option.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'lifetime' => (int) env('SESSION_LIFETIME', 120),
|
||||||
|
|
||||||
|
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Encryption
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option allows you to easily specify that all of your session data
|
||||||
|
| should be encrypted before it's stored. All encryption is performed
|
||||||
|
| automatically by Laravel and you may use the session like normal.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'encrypt' => env('SESSION_ENCRYPT', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session File Location
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When utilizing the "file" session driver, the session files are placed
|
||||||
|
| on disk. The default storage location is defined here; however, you
|
||||||
|
| are free to provide another location where they should be stored.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'files' => storage_path('framework/sessions'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Database Connection
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When using the "database" or "redis" session drivers, you may specify a
|
||||||
|
| connection that should be used to manage these sessions. This should
|
||||||
|
| correspond to a connection in your database configuration options.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'connection' => env('SESSION_CONNECTION'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Database Table
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When using the "database" session driver, you may specify the table to
|
||||||
|
| be used to store sessions. Of course, a sensible default is defined
|
||||||
|
| for you; however, you're welcome to change this to another table.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'table' => env('SESSION_TABLE', 'sessions'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Cache Store
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When using one of the framework's cache driven session backends, you may
|
||||||
|
| define the cache store which should be used to store the session data
|
||||||
|
| between requests. This must match one of your defined cache stores.
|
||||||
|
|
|
||||||
|
| Affects: "dynamodb", "memcached", "redis"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'store' => env('SESSION_STORE'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Sweeping Lottery
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Some session drivers must manually sweep their storage location to get
|
||||||
|
| rid of old sessions from storage. Here are the chances that it will
|
||||||
|
| happen on a given request. By default, the odds are 2 out of 100.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'lottery' => [2, 100],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Cookie Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may change the name of the session cookie that is created by
|
||||||
|
| the framework. Typically, you should not need to change this value
|
||||||
|
| since doing so does not grant a meaningful security improvement.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'cookie' => env(
|
||||||
|
'SESSION_COOKIE',
|
||||||
|
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
|
||||||
|
),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Cookie Path
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The session cookie path determines the path for which the cookie will
|
||||||
|
| be regarded as available. Typically, this will be the root path of
|
||||||
|
| your application, but you're free to change this when necessary.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'path' => env('SESSION_PATH', '/'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Cookie Domain
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value determines the domain and subdomains the session cookie is
|
||||||
|
| available to. By default, the cookie will be available to the root
|
||||||
|
| domain without subdomains. Typically, this shouldn't be changed.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'domain' => env('SESSION_DOMAIN'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| HTTPS Only Cookies
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| By setting this option to true, session cookies will only be sent back
|
||||||
|
| to the server if the browser has a HTTPS connection. This will keep
|
||||||
|
| the cookie from being sent to you when it can't be done securely.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'secure' => env('SESSION_SECURE_COOKIE'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| HTTP Access Only
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Setting this value to true will prevent JavaScript from accessing the
|
||||||
|
| value of the cookie and the cookie will only be accessible through
|
||||||
|
| the HTTP protocol. It's unlikely you should disable this option.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'http_only' => env('SESSION_HTTP_ONLY', true),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Same-Site Cookies
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option determines how your cookies behave when cross-site requests
|
||||||
|
| take place, and can be used to mitigate CSRF attacks. By default, we
|
||||||
|
| will set this value to "lax" to permit secure cross-site requests.
|
||||||
|
|
|
||||||
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
||||||
|
|
|
||||||
|
| Supported: "lax", "strict", "none", null
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'same_site' => env('SESSION_SAME_SITE', 'lax'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Partitioned Cookies
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Setting this value to true will tie the cookie to the top-level site for
|
||||||
|
| a cross-site context. Partitioned cookies are accepted by the browser
|
||||||
|
| when flagged "secure" and the Same-Site attribute is set to "none".
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Session Serialization
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value controls the serialization strategy for session data, which
|
||||||
|
| is JSON by default. Setting this to "php" allows the storage of PHP
|
||||||
|
| objects in the session but can make an application vulnerable to
|
||||||
|
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
||||||
|
|
|
||||||
|
| Supported: "json", "php"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'serialization' => 'json',
|
||||||
|
|
||||||
|
];
|
||||||
1
database/.gitignore
vendored
Normal file
1
database/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.sqlite*
|
||||||
45
database/factories/UserFactory.php
Normal file
45
database/factories/UserFactory.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<User>
|
||||||
|
*/
|
||||||
|
class UserFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The current password being used by the factory.
|
||||||
|
*/
|
||||||
|
protected static ?string $password;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => fake()->name(),
|
||||||
|
'email' => fake()->unique()->safeEmail(),
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
'password' => static::$password ??= Hash::make('password'),
|
||||||
|
'remember_token' => Str::random(10),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicate that the model's email address should be unverified.
|
||||||
|
*/
|
||||||
|
public function unverified(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'email_verified_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
49
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('users', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('email')->unique();
|
||||||
|
$table->timestamp('email_verified_at')->nullable();
|
||||||
|
$table->string('password');
|
||||||
|
$table->rememberToken();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||||
|
$table->string('email')->primary();
|
||||||
|
$table->string('token');
|
||||||
|
$table->timestamp('created_at')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('sessions', function (Blueprint $table) {
|
||||||
|
$table->string('id')->primary();
|
||||||
|
$table->foreignId('user_id')->nullable()->index();
|
||||||
|
$table->string('ip_address', 45)->nullable();
|
||||||
|
$table->text('user_agent')->nullable();
|
||||||
|
$table->longText('payload');
|
||||||
|
$table->integer('last_activity')->index();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('users');
|
||||||
|
Schema::dropIfExists('password_reset_tokens');
|
||||||
|
Schema::dropIfExists('sessions');
|
||||||
|
}
|
||||||
|
};
|
||||||
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('cache', function (Blueprint $table) {
|
||||||
|
$table->string('key')->primary();
|
||||||
|
$table->mediumText('value');
|
||||||
|
$table->bigInteger('expiration')->index();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('cache_locks', function (Blueprint $table) {
|
||||||
|
$table->string('key')->primary();
|
||||||
|
$table->string('owner');
|
||||||
|
$table->bigInteger('expiration')->index();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('cache');
|
||||||
|
Schema::dropIfExists('cache_locks');
|
||||||
|
}
|
||||||
|
};
|
||||||
57
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal file
57
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('jobs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('queue')->index();
|
||||||
|
$table->longText('payload');
|
||||||
|
$table->unsignedTinyInteger('attempts');
|
||||||
|
$table->unsignedInteger('reserved_at')->nullable();
|
||||||
|
$table->unsignedInteger('available_at');
|
||||||
|
$table->unsignedInteger('created_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('job_batches', function (Blueprint $table) {
|
||||||
|
$table->string('id')->primary();
|
||||||
|
$table->string('name');
|
||||||
|
$table->integer('total_jobs');
|
||||||
|
$table->integer('pending_jobs');
|
||||||
|
$table->integer('failed_jobs');
|
||||||
|
$table->longText('failed_job_ids');
|
||||||
|
$table->mediumText('options')->nullable();
|
||||||
|
$table->integer('cancelled_at')->nullable();
|
||||||
|
$table->integer('created_at');
|
||||||
|
$table->integer('finished_at')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('uuid')->unique();
|
||||||
|
$table->text('connection');
|
||||||
|
$table->text('queue');
|
||||||
|
$table->longText('payload');
|
||||||
|
$table->longText('exception');
|
||||||
|
$table->timestamp('failed_at')->useCurrent();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('jobs');
|
||||||
|
Schema::dropIfExists('job_batches');
|
||||||
|
Schema::dropIfExists('failed_jobs');
|
||||||
|
}
|
||||||
|
};
|
||||||
44
database/migrations/2024_01_01_000001_create_roles_table.php
Normal file
44
database/migrations/2024_01_01_000001_create_roles_table.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
// database/migrations/2024_01_01_000001_create_roles_table.php
|
||||||
|
// Jadual roles untuk RBAC ringkas — admin, staff, viewer
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('roles', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name')->unique(); // admin, staff, viewer
|
||||||
|
$table->string('label'); // Admin Sistem, Kakitangan, Pengguna
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('user_roles', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('role_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->unique(['user_id', 'role_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tambah kolum role pada users untuk single-role semudah mungkin
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('role')->default('viewer')->after('password');
|
||||||
|
// role: admin | staff | viewer
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('role');
|
||||||
|
});
|
||||||
|
Schema::dropIfExists('user_roles');
|
||||||
|
Schema::dropIfExists('roles');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
// database/migrations/2024_01_01_000002_create_categories_table.php
|
||||||
|
// Kategori dokumen — boleh ditambah/diubah/dinyahaktifkan tanpa ubah code
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('categories', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name'); // Pelesenan, Cukai, dll.
|
||||||
|
$table->string('slug')->unique(); // pelesenan, cukai, wifi-johor
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->string('color', 10)->default('#6c757d'); // warna badge UI
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->integer('sort_order')->default(0);
|
||||||
|
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('categories');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
// database/migrations/2024_01_01_000003_create_documents_table.php
|
||||||
|
// Metadata dokumen utama — satu rekod per dokumen (bukan per versi)
|
||||||
|
// Setiap dokumen boleh ada banyak versi dalam document_versions
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('documents', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('category_id')->constrained()->restrictOnDelete();
|
||||||
|
$table->string('title');
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->string('status')->default('draft');
|
||||||
|
// status: draft | processing | active | inactive | failed
|
||||||
|
$table->boolean('is_active')->default(false);
|
||||||
|
$table->date('effective_date')->nullable(); // tarikh kuat kuasa
|
||||||
|
$table->date('expiry_date')->nullable(); // tarikh luput (optional)
|
||||||
|
$table->json('tags')->nullable(); // ["lesen", "perniagaan"]
|
||||||
|
$table->string('language', 10)->default('ms');
|
||||||
|
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
|
||||||
|
$table->index(['category_id', 'is_active']);
|
||||||
|
$table->index('status');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('documents');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
// database/migrations/2024_01_01_000004_create_document_versions_table.php
|
||||||
|
// Setiap upload PDF adalah satu versi baru — versi lama TIDAK dipadam
|
||||||
|
// Ini adalah source of truth untuk fail asal
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('document_versions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('document_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->unsignedInteger('version_number'); // 1, 2, 3, ...
|
||||||
|
$table->string('original_filename'); // nama fail asal
|
||||||
|
$table->string('stored_path'); // path dalam storage
|
||||||
|
$table->string('mime_type')->default('application/pdf');
|
||||||
|
$table->unsignedBigInteger('file_size'); // dalam bytes
|
||||||
|
$table->string('file_hash', 64)->nullable(); // SHA256 untuk detect duplicate
|
||||||
|
$table->unsignedInteger('page_count')->nullable();
|
||||||
|
$table->string('processing_status')->default('pending');
|
||||||
|
// pending | processing | extracting | chunking | embedding | indexed | failed | extraction_failed
|
||||||
|
$table->text('processing_error')->nullable(); // mesej error jika gagal
|
||||||
|
$table->timestamp('processing_started_at')->nullable();
|
||||||
|
$table->timestamp('processing_completed_at')->nullable();
|
||||||
|
$table->boolean('is_current')->default(false); // hanya satu versi = current
|
||||||
|
$table->text('change_notes')->nullable(); // nota perubahan versi
|
||||||
|
$table->foreignId('uploaded_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['document_id', 'version_number']);
|
||||||
|
$table->index(['document_id', 'is_current']);
|
||||||
|
$table->index('processing_status');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('document_versions');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
// database/migrations/2024_01_01_000005_create_document_chunks_table.php
|
||||||
|
// Teks yang telah dipecahkan untuk embedding — dikaitkan dengan versi dokumen
|
||||||
|
// Teks asal disimpan di sini untuk audit, re-embed, dan preview
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('document_chunks', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('document_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('document_version_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->unsignedInteger('chunk_index'); // 0, 1, 2, ...
|
||||||
|
$table->unsignedInteger('page_number')->nullable(); // muka surat PDF
|
||||||
|
$table->text('content'); // teks chunk asal
|
||||||
|
$table->unsignedInteger('token_count')->nullable(); // anggaran token
|
||||||
|
$table->string('section_heading')->nullable(); // heading jika ada
|
||||||
|
$table->string('qdrant_point_id', 36)->nullable()->unique();
|
||||||
|
// UUID simpan di Qdrant — untuk update/delete tepat
|
||||||
|
$table->boolean('is_embedded')->default(false);
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->timestamp('embedded_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['document_version_id', 'chunk_index']);
|
||||||
|
$table->index(['document_id', 'is_active']);
|
||||||
|
$table->index('qdrant_point_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('document_chunks');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
// database/migrations/2024_01_01_000006_create_knowledge_items_table.php
|
||||||
|
// Knowledge items manual — FAQ, Q&A rasmi, polisi ringkas, nota dalaman
|
||||||
|
// Ini pelengkap kepada dokumen PDF — admin boleh tambah terus tanpa upload fail
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('knowledge_items', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('category_id')->constrained()->restrictOnDelete();
|
||||||
|
$table->string('item_type');
|
||||||
|
// faq | policy | note | announcement
|
||||||
|
$table->string('title'); // soalan / tajuk polisi
|
||||||
|
$table->text('content'); // jawapan / kandungan penuh
|
||||||
|
$table->text('content_short')->nullable(); // ringkasan (optional)
|
||||||
|
$table->json('tags')->nullable();
|
||||||
|
$table->string('language', 10)->default('ms');
|
||||||
|
$table->date('effective_date')->nullable();
|
||||||
|
$table->date('expiry_date')->nullable();
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->boolean('is_public')->default(true); // boleh dicari oleh public
|
||||||
|
$table->string('qdrant_point_id', 36)->nullable()->unique();
|
||||||
|
$table->boolean('is_embedded')->default(false);
|
||||||
|
$table->timestamp('embedded_at')->nullable();
|
||||||
|
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
|
||||||
|
$table->index(['category_id', 'is_active', 'item_type']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('knowledge_items');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
// database/migrations/2024_01_01_000007_create_chat_sessions_table.php
|
||||||
|
// Sesi perbualan chatbot — satu sesi boleh ada banyak soalan
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('chat_sessions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('session_token', 64)->unique(); // untuk public user tanpa login
|
||||||
|
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||||
|
$table->foreignId('category_id')->nullable()->constrained()->nullOnDelete();
|
||||||
|
// null = soal semua kategori, ada nilai = tapis kategori
|
||||||
|
$table->string('ip_address', 45)->nullable();
|
||||||
|
$table->string('user_agent')->nullable();
|
||||||
|
$table->timestamp('last_activity_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['user_id']);
|
||||||
|
$table->index('last_activity_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('chat_sessions');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
// database/migrations/2024_01_01_000008_create_chat_logs_table.php
|
||||||
|
// Log setiap pertanyaan + jawapan AI + sumber yang digunakan
|
||||||
|
// Ini penting untuk audit, improve FAQ, dan semak kualiti jawapan
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('chat_logs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('chat_session_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||||
|
$table->foreignId('category_id')->nullable()->constrained()->nullOnDelete();
|
||||||
|
$table->text('question'); // soalan user
|
||||||
|
$table->text('answer'); // jawapan AI
|
||||||
|
$table->json('sources_used')->nullable();
|
||||||
|
// array of: {type, title, category, page, chunk_id, score}
|
||||||
|
$table->json('context_chunks')->nullable(); // chunk teks yang dihantar ke model
|
||||||
|
$table->string('model_used')->nullable(); // nama model Ollama
|
||||||
|
$table->integer('tokens_used')->nullable(); // anggaran token
|
||||||
|
$table->float('response_time')->nullable(); // masa (saat)
|
||||||
|
$table->boolean('has_answer')->default(true); // false = model tidak tahu
|
||||||
|
$table->boolean('is_flagged')->default(false); // admin flag untuk semak
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['chat_session_id']);
|
||||||
|
$table->index(['user_id', 'created_at']);
|
||||||
|
$table->index('is_flagged');
|
||||||
|
$table->index('created_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('chat_logs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
// database/migrations/2024_01_01_000009_create_chat_feedbacks_table.php
|
||||||
|
// Feedback pengguna untuk setiap jawapan AI
|
||||||
|
// Admin boleh convert soalan yang gagal dijawab kepada FAQ rasmi
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('chat_feedbacks', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('chat_log_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||||
|
$table->string('rating');
|
||||||
|
// helpful | not_helpful | partially_helpful
|
||||||
|
$table->text('comment')->nullable(); // komen pembetulan user
|
||||||
|
$table->text('correct_answer')->nullable(); // jawapan betul (dari user)
|
||||||
|
$table->boolean('converted_to_faq')->default(false);
|
||||||
|
$table->foreignId('converted_faq_id')->nullable()->constrained('knowledge_items')->nullOnDelete();
|
||||||
|
$table->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->timestamp('reviewed_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['chat_log_id']);
|
||||||
|
$table->index(['rating', 'converted_to_faq']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('chat_feedbacks');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
// database/migrations/2024_01_01_000010_create_audit_logs_table.php
|
||||||
|
// Audit trail sistem — rekod semua tindakan penting oleh admin/sistem
|
||||||
|
// Tidak boleh diubah atau dipadam — append-only log
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('audit_logs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||||
|
$table->string('event');
|
||||||
|
// document.uploaded | document.activated | document.deactivated
|
||||||
|
// document.reindexed | document.version_added
|
||||||
|
// knowledge_item.created | knowledge_item.updated | knowledge_item.deactivated
|
||||||
|
// category.created | category.updated
|
||||||
|
// faq.converted_from_feedback
|
||||||
|
// system.reindex_started | system.reindex_completed
|
||||||
|
$table->string('auditable_type')->nullable(); // model class
|
||||||
|
$table->unsignedBigInteger('auditable_id')->nullable();
|
||||||
|
$table->json('old_values')->nullable(); // data sebelum perubahan
|
||||||
|
$table->json('new_values')->nullable(); // data selepas perubahan
|
||||||
|
$table->text('description')->nullable(); // huraian tindakan
|
||||||
|
$table->string('ip_address', 45)->nullable();
|
||||||
|
$table->string('user_agent')->nullable();
|
||||||
|
$table->timestamp('created_at'); // hanya created_at (append-only)
|
||||||
|
|
||||||
|
$table->index(['user_id', 'created_at']);
|
||||||
|
$table->index(['auditable_type', 'auditable_id']);
|
||||||
|
$table->index(['event', 'created_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('audit_logs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
// database/migrations/2024_01_01_000011_create_processing_logs_table.php
|
||||||
|
// Log status pemprosesan setiap document_version — untuk debug dan monitoring
|
||||||
|
// Berbeza dari audit_logs: ini untuk teknikal, bukan untuk governance
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('processing_logs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('processable_type'); // DocumentVersion | KnowledgeItem
|
||||||
|
$table->unsignedBigInteger('processable_id');
|
||||||
|
$table->string('stage');
|
||||||
|
// upload | extract | chunk | embed | qdrant_sync | completed | failed
|
||||||
|
$table->string('status'); // started | completed | failed
|
||||||
|
$table->text('message')->nullable();
|
||||||
|
$table->json('metadata')->nullable(); // data tambahan (chunk count, page count, dsb.)
|
||||||
|
$table->float('duration')->nullable(); // masa dalam saat
|
||||||
|
$table->timestamp('created_at');
|
||||||
|
|
||||||
|
$table->index(['processable_type', 'processable_id']);
|
||||||
|
$table->index(['stage', 'status']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('processing_logs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// database/migrations/2026_04_08_000001_add_chunk_review_fields_to_document_chunks.php
|
||||||
|
// Tambah sokongan Chunk Review & Editing:
|
||||||
|
// - Teks berlapis: cleaned_text, final_text (content kekal sebagai raw_text)
|
||||||
|
// - Status enum: chunk_status
|
||||||
|
// - Flags: is_edited, exclude_from_index, needs_reindex
|
||||||
|
// - Split tracking: parent_chunk_id, split_group_id, split_order
|
||||||
|
// - Admin metadata: edited_by, edited_at, last_embedded_at, notes
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('document_chunks', function (Blueprint $table) {
|
||||||
|
// === Teks berlapis ===
|
||||||
|
// content sedia ada = raw_text (teks asal extraction — TIDAK PERNAH DIUBAH)
|
||||||
|
$table->longText('cleaned_text')->nullable()->after('content');
|
||||||
|
// Auto-cleaned text — null bermakna tiada cleanup dilakukan
|
||||||
|
$table->longText('final_text')->nullable()->after('cleaned_text');
|
||||||
|
// Teks akhir untuk embedding — null bermakna guna cleaned_text atau content
|
||||||
|
|
||||||
|
// === Status chunk ===
|
||||||
|
// Values: pending, indexed, needs_review, needs_reindex, excluded, superseded, failed_embedding
|
||||||
|
$table->string('chunk_status', 25)->default('pending')->after('final_text');
|
||||||
|
|
||||||
|
// === Admin flags ===
|
||||||
|
$table->boolean('is_edited')->default(false)->after('is_active');
|
||||||
|
// true jika admin pernah edit final_text
|
||||||
|
$table->boolean('exclude_from_index')->default(false)->after('is_edited');
|
||||||
|
// true jika admin kecualikan chunk dari indexing
|
||||||
|
$table->boolean('needs_reindex')->default(false)->after('exclude_from_index');
|
||||||
|
// true jika final_text berubah tapi belum direindex semula
|
||||||
|
|
||||||
|
// === Split tracking ===
|
||||||
|
$table->unsignedBigInteger('parent_chunk_id')->nullable()->after('needs_reindex');
|
||||||
|
// FK ke chunk asal jika ini adalah hasil split
|
||||||
|
$table->foreign('parent_chunk_id')
|
||||||
|
->references('id')
|
||||||
|
->on('document_chunks')
|
||||||
|
->nullOnDelete();
|
||||||
|
|
||||||
|
$table->string('split_group_id', 36)->nullable()->after('parent_chunk_id');
|
||||||
|
// UUID bersama untuk semua siblings dalam satu split operation
|
||||||
|
|
||||||
|
$table->unsignedTinyInteger('split_order')->nullable()->after('split_group_id');
|
||||||
|
// 0, 1, 2... untuk susun atur dalam split group
|
||||||
|
|
||||||
|
// === Admin editing metadata ===
|
||||||
|
$table->unsignedBigInteger('edited_by')->nullable()->after('split_order');
|
||||||
|
$table->foreign('edited_by')
|
||||||
|
->references('id')
|
||||||
|
->on('users')
|
||||||
|
->nullOnDelete();
|
||||||
|
|
||||||
|
$table->timestamp('edited_at')->nullable()->after('edited_by');
|
||||||
|
// Bila admin terakhir kali edit
|
||||||
|
|
||||||
|
$table->timestamp('last_embedded_at')->nullable()->after('edited_at');
|
||||||
|
// Masa embed terkini (berbeza dari embedded_at = masa embed pertama)
|
||||||
|
|
||||||
|
$table->text('notes')->nullable()->after('last_embedded_at');
|
||||||
|
// Nota admin untuk chunk ini
|
||||||
|
|
||||||
|
// === Indexes ===
|
||||||
|
$table->index('chunk_status');
|
||||||
|
$table->index(['document_version_id', 'chunk_status']);
|
||||||
|
$table->index('needs_reindex');
|
||||||
|
$table->index('split_group_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Kemaskini chunk_status sedia ada berdasarkan is_embedded dan is_active
|
||||||
|
DB::statement("
|
||||||
|
UPDATE document_chunks
|
||||||
|
SET
|
||||||
|
chunk_status = CASE
|
||||||
|
WHEN is_active = 0 THEN 'excluded'
|
||||||
|
WHEN is_embedded = 1 AND is_active = 1 THEN 'indexed'
|
||||||
|
ELSE 'pending'
|
||||||
|
END,
|
||||||
|
last_embedded_at = embedded_at
|
||||||
|
");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('document_chunks', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['parent_chunk_id']);
|
||||||
|
$table->dropForeign(['edited_by']);
|
||||||
|
|
||||||
|
$table->dropIndex(['chunk_status']);
|
||||||
|
$table->dropIndex(['document_version_id', 'chunk_status']);
|
||||||
|
$table->dropIndex(['needs_reindex']);
|
||||||
|
$table->dropIndex(['split_group_id']);
|
||||||
|
|
||||||
|
$table->dropColumn([
|
||||||
|
'cleaned_text',
|
||||||
|
'final_text',
|
||||||
|
'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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// database/migrations/2026_04_08_000002_create_chunk_audits_table.php
|
||||||
|
// Audit trail khusus untuk operasi chunk:
|
||||||
|
// edit_final_text, exclude, include, reindex, split_parent, split_child
|
||||||
|
//
|
||||||
|
// Berbeza dari audit_logs: chunk_audits simpan perubahan teks sebenar (old/new final_text)
|
||||||
|
// yang terlalu besar untuk disimpan dalam kolum JSON audit_logs.
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('chunk_audits', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
|
||||||
|
$table->foreignId('document_chunk_id')
|
||||||
|
->constrained('document_chunks')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
|
||||||
|
$table->foreignId('user_id')
|
||||||
|
->nullable()
|
||||||
|
->constrained('users')
|
||||||
|
->nullOnDelete();
|
||||||
|
|
||||||
|
$table->string('operation', 30);
|
||||||
|
// 'edit_final_text' | 'exclude' | 'include' | 'reindex'
|
||||||
|
// | 'split_parent' | 'split_child'
|
||||||
|
|
||||||
|
// Perubahan teks (boleh null jika operasi bukan edit teks)
|
||||||
|
$table->longText('old_final_text')->nullable();
|
||||||
|
$table->longText('new_final_text')->nullable();
|
||||||
|
|
||||||
|
// Perubahan status
|
||||||
|
$table->string('old_status', 25)->nullable();
|
||||||
|
$table->string('new_status', 25)->nullable();
|
||||||
|
|
||||||
|
// Metadata tambahan (parent_chunk_id, split_group_id, bilangan segmen, dll.)
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
|
|
||||||
|
$table->text('notes')->nullable();
|
||||||
|
// Nota admin yang dimasukkan semasa operasi
|
||||||
|
|
||||||
|
$table->string('ip_address', 45)->nullable();
|
||||||
|
|
||||||
|
// Append-only — hanya created_at, tiada updated_at
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
|
||||||
|
$table->index(['document_chunk_id', 'created_at']);
|
||||||
|
$table->index('operation');
|
||||||
|
$table->index('user_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('chunk_audits');
|
||||||
|
}
|
||||||
|
};
|
||||||
78
database/seeders/CategorySeeder.php
Normal file
78
database/seeders/CategorySeeder.php
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Category;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class CategorySeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Kategori awal mengikut keperluan sistem.
|
||||||
|
* Kategori boleh ditambah, diubah, atau dinyahaktifkan
|
||||||
|
* melalui admin panel tanpa perlu ubah code.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$categories = [
|
||||||
|
[
|
||||||
|
'name' => 'Pelesenan',
|
||||||
|
'slug' => 'pelesenan',
|
||||||
|
'description' => 'Maklumat berkaitan lesen perniagaan, lesen premis, dan kelulusan berkaitan.',
|
||||||
|
'color' => '#3b82f6', // biru
|
||||||
|
'sort_order' => 1,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Cukai',
|
||||||
|
'slug' => 'cukai',
|
||||||
|
'description' => 'Maklumat berkaitan cukai taksiran, cukai pintu, dan bayaran berkaitan harta.',
|
||||||
|
'color' => '#10b981', // hijau
|
||||||
|
'sort_order' => 2,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'WiFi Johor',
|
||||||
|
'slug' => 'wifi-johor',
|
||||||
|
'description' => 'Maklumat dan panduan penggunaan WiFi Johor di kawasan awam.',
|
||||||
|
'color' => '#6366f1', // ungu
|
||||||
|
'sort_order' => 3,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Permohonan Permit Sementara',
|
||||||
|
'slug' => 'permohonan-permit-sementara',
|
||||||
|
'description' => 'Maklumat berkaitan permit sementara untuk aktiviti, acara, atau pembinaan.',
|
||||||
|
'color' => '#f59e0b', // kuning
|
||||||
|
'sort_order' => 4,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Penguatkuasaan',
|
||||||
|
'slug' => 'penguatkuasaan',
|
||||||
|
'description' => 'Maklumat berkaitan tindakan penguatkuasaan, kompaun, dan prosedur aduan.',
|
||||||
|
'color' => '#ef4444', // merah
|
||||||
|
'sort_order' => 5,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Sewaan Gerai Majlis',
|
||||||
|
'slug' => 'sewaan-gerai-majlis',
|
||||||
|
'description' => 'Maklumat berkaitan sewaan gerai, premis, dan kemudahan majlis.',
|
||||||
|
'color' => '#14b8a6', // teal
|
||||||
|
'sort_order' => 6,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'name' => 'Lain-lain',
|
||||||
|
'slug' => 'lain-lain',
|
||||||
|
'description' => 'Soalan umum dan maklumat yang tidak termasuk dalam kategori khusus.',
|
||||||
|
'color' => '#6b7280', // kelabu
|
||||||
|
'sort_order' => 99,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($categories as $data) {
|
||||||
|
Category::updateOrCreate(
|
||||||
|
['slug' => $data['slug']],
|
||||||
|
array_merge($data, ['is_active' => true])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->command->info('✓ ' . count($categories) . ' kategori berjaya dibuat/dikemaskini.');
|
||||||
|
}
|
||||||
|
}
|
||||||
17
database/seeders/DatabaseSeeder.php
Normal file
17
database/seeders/DatabaseSeeder.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class DatabaseSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->call([
|
||||||
|
UserSeeder::class,
|
||||||
|
CategorySeeder::class,
|
||||||
|
KnowledgeItemSeeder::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
156
database/seeders/KnowledgeItemSeeder.php
Normal file
156
database/seeders/KnowledgeItemSeeder.php
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\KnowledgeItem;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class KnowledgeItemSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Sample FAQ untuk development dan demo awal.
|
||||||
|
* Data ini bukan fakta sebenar — untuk tujuan ujian sahaja.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$items = [
|
||||||
|
// ── Pelesenan ─────────────────────────────────────────────────
|
||||||
|
[
|
||||||
|
'category_slug' => 'pelesenan',
|
||||||
|
'item_type' => KnowledgeItem::TYPE_FAQ,
|
||||||
|
'title' => 'Apakah dokumen yang diperlukan untuk memohon lesen perniagaan baru?',
|
||||||
|
'content' => "Untuk memohon lesen perniagaan baru, pemohon perlu menyediakan dokumen berikut:\n\n" .
|
||||||
|
"1. Salinan kad pengenalan pemohon (MyKad)\n" .
|
||||||
|
"2. Borang permohonan yang telah dilengkapkan\n" .
|
||||||
|
"3. Salinan geran tanah atau perjanjian sewaan premis\n" .
|
||||||
|
"4. Pelan lantai premis perniagaan\n" .
|
||||||
|
"5. Sijil pendaftaran perniagaan (SSM)\n" .
|
||||||
|
"6. Gambar premis dari dalam dan luar\n\n" .
|
||||||
|
"Sila hubungi Bahagian Pelesenan untuk maklumat lanjut.",
|
||||||
|
'tags' => ['lesen', 'perniagaan', 'permohonan', 'dokumen'],
|
||||||
|
'is_active' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'category_slug' => 'pelesenan',
|
||||||
|
'item_type' => KnowledgeItem::TYPE_FAQ,
|
||||||
|
'title' => 'Berapa lama masa yang diperlukan untuk kelulusan lesen perniagaan?',
|
||||||
|
'content' => "Proses kelulusan lesen perniagaan biasanya mengambil masa 14 hingga 30 hari bekerja " .
|
||||||
|
"bergantung kepada jenis lesen dan kelengkapan dokumen yang dikemukakan.\n\n" .
|
||||||
|
"Pemohon boleh semak status permohonan melalui kaunter Bahagian Pelesenan atau " .
|
||||||
|
"menghubungi talian hotline kami.",
|
||||||
|
'tags' => ['lesen', 'tempoh', 'kelulusan'],
|
||||||
|
'is_active' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// ── Cukai ─────────────────────────────────────────────────────
|
||||||
|
[
|
||||||
|
'category_slug' => 'cukai',
|
||||||
|
'item_type' => KnowledgeItem::TYPE_FAQ,
|
||||||
|
'title' => 'Bila tarikh akhir pembayaran cukai taksiran?',
|
||||||
|
'content' => "Cukai taksiran perlu dibayar dalam dua penggal:\n\n" .
|
||||||
|
"• Penggal pertama: 28 Februari setiap tahun\n" .
|
||||||
|
"• Penggal kedua: 31 Ogos setiap tahun\n\n" .
|
||||||
|
"Bayaran lewat akan dikenakan denda atau caj penalti. " .
|
||||||
|
"Pembayaran boleh dibuat di kaunter, atas talian, atau melalui bank.",
|
||||||
|
'tags' => ['cukai', 'taksiran', 'tarikh', 'bayaran'],
|
||||||
|
'is_active' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// ── WiFi Johor ────────────────────────────────────────────────
|
||||||
|
[
|
||||||
|
'category_slug' => 'wifi-johor',
|
||||||
|
'item_type' => KnowledgeItem::TYPE_FAQ,
|
||||||
|
'title' => 'Bagaimana cara untuk sambung ke WiFi Johor?',
|
||||||
|
'content' => "Cara menyambung ke WiFi Johor:\n\n" .
|
||||||
|
"1. Hidupkan WiFi pada peranti anda\n" .
|
||||||
|
"2. Pilih rangkaian 'WiFi Johor' atau 'WiFiJohor'\n" .
|
||||||
|
"3. Buka pelayar web — halaman log masuk akan muncul\n" .
|
||||||
|
"4. Daftar menggunakan nombor telefon atau e-mel\n" .
|
||||||
|
"5. Masukkan OTP yang diterima\n" .
|
||||||
|
"6. Anda kini boleh menggunakan internet percuma\n\n" .
|
||||||
|
"WiFi Johor tersedia di taman awam, perpustakaan, dan bangunan kerajaan terpilih.",
|
||||||
|
'tags' => ['wifi', 'johor', 'internet', 'sambung'],
|
||||||
|
'is_active' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// ── Penguatkuasaan ────────────────────────────────────────────
|
||||||
|
[
|
||||||
|
'category_slug' => 'penguatkuasaan',
|
||||||
|
'item_type' => KnowledgeItem::TYPE_FAQ,
|
||||||
|
'title' => 'Bagaimana cara untuk membuat aduan berkaitan penguatkuasaan?',
|
||||||
|
'content' => "Aduan berkaitan penguatkuasaan boleh dibuat melalui cara berikut:\n\n" .
|
||||||
|
"1. Kaunter aduan di Jabatan Penguatkuasaan\n" .
|
||||||
|
"2. Hotline: 07-XXX XXXX (Isnin-Jumaat, 8pagi-5ptg)\n" .
|
||||||
|
"3. E-mel: penguatkuasaan@majlis.gov.my\n" .
|
||||||
|
"4. Sistem aduan atas talian\n\n" .
|
||||||
|
"Sertakan maklumat berikut dalam aduan:\n" .
|
||||||
|
"• Nama dan nombor telefon pengadu\n" .
|
||||||
|
"• Lokasi kejadian\n" .
|
||||||
|
"• Tarikh dan masa kejadian\n" .
|
||||||
|
"• Penerangan ringkas masalah",
|
||||||
|
'tags' => ['aduan', 'penguatkuasaan', 'prosedur'],
|
||||||
|
'is_active' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// ── Sewaan Gerai ──────────────────────────────────────────────
|
||||||
|
[
|
||||||
|
'category_slug' => 'sewaan-gerai-majlis',
|
||||||
|
'item_type' => KnowledgeItem::TYPE_FAQ,
|
||||||
|
'title' => 'Apakah syarat untuk menyewa gerai majlis?',
|
||||||
|
'content' => "Syarat-syarat untuk menyewa gerai majlis:\n\n" .
|
||||||
|
"1. Pemohon mestilah warganegara Malaysia\n" .
|
||||||
|
"2. Berumur 18 tahun ke atas\n" .
|
||||||
|
"3. Tiada tunggakan dengan pihak majlis\n" .
|
||||||
|
"4. Mempunyai lesen perniagaan yang sah (jika berniaga)\n" .
|
||||||
|
"5. Menandatangani perjanjian sewaan\n\n" .
|
||||||
|
"Kadar sewa bergantung kepada lokasi dan jenis gerai. " .
|
||||||
|
"Sila rujuk jadual kadar sewa yang terkini di kaunter.",
|
||||||
|
'tags' => ['gerai', 'sewa', 'syarat', 'permohonan'],
|
||||||
|
'is_active' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// ── Lain-lain ─────────────────────────────────────────────────
|
||||||
|
[
|
||||||
|
'category_slug' => 'lain-lain',
|
||||||
|
'item_type' => KnowledgeItem::TYPE_FAQ,
|
||||||
|
'title' => 'Apakah waktu operasi pejabat majlis?',
|
||||||
|
'content' => "Waktu operasi pejabat majlis adalah seperti berikut:\n\n" .
|
||||||
|
"• Isnin – Khamis: 8:00 pagi – 5:00 petang\n" .
|
||||||
|
"• Jumaat: 8:00 pagi – 12:00 tengah hari (tutup 12:15 – 2:45 ptg)\n" .
|
||||||
|
"• Sabtu, Ahad, dan Cuti Umum: Tutup\n\n" .
|
||||||
|
"Perkhidmatan kaunter ditutup 15 minit sebelum masa tutup pejabat.",
|
||||||
|
'tags' => ['waktu', 'operasi', 'pejabat', 'jadual'],
|
||||||
|
'is_active' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
foreach ($items as $data) {
|
||||||
|
$slug = $data['category_slug'];
|
||||||
|
unset($data['category_slug']);
|
||||||
|
|
||||||
|
$category = Category::where('slug', $slug)->first();
|
||||||
|
if (!$category) {
|
||||||
|
$this->command->warn(" Kategori '{$slug}' tidak dijumpai. Skip.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
KnowledgeItem::updateOrCreate(
|
||||||
|
[
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'title' => $data['title'],
|
||||||
|
],
|
||||||
|
array_merge($data, [
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'language' => 'ms',
|
||||||
|
'is_public' => true,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->command->info("✓ {$count} knowledge items (sample FAQ) berjaya dibuat.");
|
||||||
|
}
|
||||||
|
}
|
||||||
42
database/seeders/UserSeeder.php
Normal file
42
database/seeders/UserSeeder.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
class UserSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Buat pengguna awal untuk development.
|
||||||
|
* JANGAN guna kata laluan ini dalam production.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// Admin
|
||||||
|
User::updateOrCreate(
|
||||||
|
['email' => 'admin@example.com'],
|
||||||
|
[
|
||||||
|
'name' => 'Admin Sistem',
|
||||||
|
'password' => Hash::make('password'),
|
||||||
|
'role' => User::ROLE_ADMIN,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Staff
|
||||||
|
User::updateOrCreate(
|
||||||
|
['email' => 'staff@example.com'],
|
||||||
|
[
|
||||||
|
'name' => 'Kakitangan',
|
||||||
|
'password' => Hash::make('password'),
|
||||||
|
'role' => User::ROLE_STAFF,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->command->info('✓ 2 pengguna awal berjaya dibuat.');
|
||||||
|
$this->command->warn(' Admin: admin@example.com / password');
|
||||||
|
$this->command->warn(' Staff: staff@example.com / password');
|
||||||
|
$this->command->warn(' TUKAR KATA LALUAN SEBELUM DEPLOY KE PRODUCTION!');
|
||||||
|
}
|
||||||
|
}
|
||||||
17
package.json
Normal file
17
package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://www.schemastore.org/package.json",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build",
|
||||||
|
"dev": "vite"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"axios": ">=1.11.0 <=1.14.0",
|
||||||
|
"concurrently": "^9.0.1",
|
||||||
|
"laravel-vite-plugin": "^3.0.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"vite": "^8.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
36
phpunit.xml
Normal file
36
phpunit.xml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
bootstrap="vendor/autoload.php"
|
||||||
|
colors="true"
|
||||||
|
>
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Unit">
|
||||||
|
<directory>tests/Unit</directory>
|
||||||
|
</testsuite>
|
||||||
|
<testsuite name="Feature">
|
||||||
|
<directory>tests/Feature</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
<source>
|
||||||
|
<include>
|
||||||
|
<directory>app</directory>
|
||||||
|
</include>
|
||||||
|
</source>
|
||||||
|
<php>
|
||||||
|
<env name="APP_ENV" value="testing"/>
|
||||||
|
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
|
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||||
|
<env name="CACHE_STORE" value="array"/>
|
||||||
|
<env name="DB_CONNECTION" value="sqlite"/>
|
||||||
|
<env name="DB_DATABASE" value=":memory:"/>
|
||||||
|
<env name="DB_URL" value=""/>
|
||||||
|
<env name="MAIL_MAILER" value="array"/>
|
||||||
|
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||||
|
<env name="SESSION_DRIVER" value="array"/>
|
||||||
|
<env name="PULSE_ENABLED" value="false"/>
|
||||||
|
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||||
|
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||||
|
</php>
|
||||||
|
</phpunit>
|
||||||
25
public/.htaccess
Normal file
25
public/.htaccess
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
<IfModule mod_negotiation.c>
|
||||||
|
Options -MultiViews -Indexes
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
# Handle Authorization Header
|
||||||
|
RewriteCond %{HTTP:Authorization} .
|
||||||
|
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||||
|
|
||||||
|
# Handle X-XSRF-Token Header
|
||||||
|
RewriteCond %{HTTP:x-xsrf-token} .
|
||||||
|
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
|
||||||
|
|
||||||
|
# Redirect Trailing Slashes If Not A Folder...
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteCond %{REQUEST_URI} (.+)/$
|
||||||
|
RewriteRule ^ %1 [L,R=301]
|
||||||
|
|
||||||
|
# Send Requests To Front Controller...
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteRule ^ index.php [L]
|
||||||
|
</IfModule>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user