First commit

This commit is contained in:
Saufi
2026-05-18 08:56:23 +08:00
commit fd3d3a4d2b
147 changed files with 22099 additions and 0 deletions

55
app/Models/AuditLog.php Normal file
View 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
View 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();
}
}

View 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
View 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');
}
}

View 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
View 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
View 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;
}
}

View 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);
}
}

View 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);
}
}

View 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]);
}
}

View 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
View 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',
};
}
}