First commit
This commit is contained in:
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user