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

View File

@@ -0,0 +1,474 @@
@extends('layouts.admin')
@section('title', 'Chunk #' . ($chunk->chunk_index + 1) . ' — ' . Str::limit($chunk->document->title, 30))
@section('breadcrumb')
<li class="breadcrumb-item"><a href="{{ route('admin.documents.index') }}">Dokumen</a></li>
<li class="breadcrumb-item">
<a href="{{ route('admin.documents.show', $chunk->document) }}">{{ Str::limit($chunk->document->title, 25) }}</a>
</li>
<li class="breadcrumb-item">
<a href="{{ route('admin.documents.chunks', ['document' => $chunk->document, 'version' => $chunk->documentVersion]) }}">
Chunks v{{ $chunk->documentVersion->version_number }}
</a>
</li>
<li class="breadcrumb-item active">Chunk #{{ $chunk->chunk_index + 1 }}</li>
@endsection
@section('content')
{{-- Flash Messages --}}
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show py-2">
<i class="bi bi-check-circle me-1"></i>{{ session('success') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
@endif
@if(session('error'))
<div class="alert alert-danger alert-dismissible fade show py-2">
<i class="bi bi-exclamation-triangle me-1"></i>{{ session('error') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
@endif
{{-- Header Row --}}
<div class="d-flex align-items-start justify-content-between mb-3 flex-wrap gap-2">
<div>
<div class="d-flex align-items-center gap-2 flex-wrap mb-1">
<h4 class="mb-0 fw-bold">Chunk #{{ $chunk->chunk_index + 1 }}</h4>
<span class="badge {{ $chunk->getStatusBadgeClass() }}">{{ $chunk->getStatusLabel() }}</span>
@if($chunk->is_edited)
<span class="badge bg-primary-subtle text-primary border border-primary">
<i class="bi bi-pencil me-1"></i>Telah Diedit
</span>
@endif
@if($chunk->parent_chunk_id)
<span class="badge bg-warning-subtle text-warning border border-warning">
<i class="bi bi-scissors me-1"></i>Hasil Split
</span>
@endif
</div>
<p class="text-muted small mb-0">
{{ $chunk->document->title }} &mdash; v{{ $chunk->documentVersion->version_number }}
@if($chunk->page_number) &middot; ms. {{ $chunk->page_number }} @endif
@if($chunk->section_heading) &middot; {{ $chunk->section_heading }} @endif
</p>
</div>
{{-- Action buttons --}}
<div class="d-flex gap-2 flex-wrap">
<a href="{{ route('admin.documents.chunks', ['document' => $chunk->document, 'version' => $chunk->documentVersion]) }}"
class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Senarai Chunk
</a>
@if(!$chunk->isSuperseded())
<a href="{{ route('admin.chunks.split', $chunk) }}"
class="btn btn-sm btn-warning">
<i class="bi bi-scissors me-1"></i>Split Chunk
</a>
@if($chunk->exclude_from_index)
<form method="POST" action="{{ route('admin.chunks.include', $chunk) }}" class="d-inline">
@csrf
<button type="submit" class="btn btn-sm btn-success"
onclick="return confirm('Kembalikan chunk ini ke indexing?')">
<i class="bi bi-check-circle me-1"></i>Include
</button>
</form>
@else
<form method="POST" action="{{ route('admin.chunks.exclude', $chunk) }}" class="d-inline">
@csrf
<button type="submit" class="btn btn-sm btn-outline-secondary"
onclick="return confirm('Kecualikan chunk ini dari indexing?')">
<i class="bi bi-slash-circle me-1"></i>Exclude
</button>
</form>
@endif
@if($chunk->isIndexable())
<form method="POST" action="{{ route('admin.chunks.reindex', $chunk) }}" class="d-inline">
@csrf
<button type="submit" class="btn btn-sm btn-outline-info"
onclick="return confirm('Trigger reindex untuk chunk ini?')">
<i class="bi bi-arrow-repeat me-1"></i>Reindex
</button>
</form>
@endif
@endif
</div>
</div>
{{-- Metadata Cards --}}
<div class="row g-2 mb-3">
<div class="col-sm-6 col-lg-3">
<div class="card border-0 bg-light h-100">
<div class="card-body py-2 px-3">
<p class="text-muted mb-0" style="font-size:.7rem">CHUNK INDEX</p>
<p class="mb-0 fw-bold">#{{ $chunk->chunk_index + 1 }}</p>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card border-0 bg-light h-100">
<div class="card-body py-2 px-3">
<p class="text-muted mb-0" style="font-size:.7rem">QDRANT POINT ID</p>
<p class="mb-0 fw-bold" style="font-size:.75rem;word-break:break-all">
{{ $chunk->qdrant_point_id ?? '—' }}
</p>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card border-0 bg-light h-100">
<div class="card-body py-2 px-3">
<p class="text-muted mb-0" style="font-size:.7rem">LAST EMBEDDED</p>
<p class="mb-0 fw-bold" style="font-size:.82rem">
{{ $chunk->last_embedded_at?->format('d/m/Y H:i') ?? '—' }}
</p>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card border-0 bg-light h-100">
<div class="card-body py-2 px-3">
<p class="text-muted mb-0" style="font-size:.7rem">DIEDIT OLEH</p>
<p class="mb-0 fw-bold" style="font-size:.82rem">
{{ $chunk->editor?->name ?? '—' }}
@if($chunk->edited_at)
<br><span class="text-muted fw-normal" style="font-size:.7rem">{{ $chunk->edited_at->format('d/m/Y H:i') }}</span>
@endif
</p>
</div>
</div>
</div>
</div>
{{-- Parent Chunk Info (jika ini adalah hasil split) --}}
@if($chunk->parentChunk)
<div class="alert alert-warning py-2 mb-3">
<i class="bi bi-scissors me-1"></i>
Chunk ini adalah hasil split daripada
<a href="{{ route('admin.chunks.show', $chunk->parentChunk) }}" class="alert-link">
Chunk #{{ $chunk->parentChunk->chunk_index + 1 }}
</a>
({{ $chunk->parentChunk->getStatusLabel() }}).
Urutan dalam split: {{ $chunk->split_order + 1 }} / dalam kumpulan yang sama.
</div>
@endif
{{-- Superseded Warning --}}
@if($chunk->isSuperseded())
<div class="alert alert-dark py-2 mb-3">
<i class="bi bi-exclamation-triangle me-1"></i>
Chunk ini telah <strong>digantikan</strong> oleh chunk-chunk baharu hasil split. Ia tidak lagi digunakan dalam Qdrant.
</div>
@endif
{{-- ===================================================================== --}}
{{-- TEXT PREVIEW 3 Panel --}}
{{-- ===================================================================== --}}
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white border-bottom">
<ul class="nav nav-tabs card-header-tabs" id="textTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link {{ !$chunk->final_text && !$chunk->cleaned_text ? 'active' : '' }}"
id="raw-tab" data-bs-toggle="tab" data-bs-target="#raw-panel"
type="button" role="tab">
<i class="bi bi-file-earmark-text me-1"></i>
raw_text
<span class="badge bg-light text-dark border ms-1" style="font-size:.65rem">
{{ number_format(str_word_count($chunk->content)) }} patah
</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link {{ $chunk->cleaned_text && !$chunk->final_text ? 'active' : '' }}"
id="cleaned-tab" data-bs-toggle="tab" data-bs-target="#cleaned-panel"
type="button" role="tab">
<i class="bi bi-magic me-1"></i>
cleaned_text
@if(!$chunk->cleaned_text)
<span class="badge bg-light text-muted border ms-1" style="font-size:.65rem">null</span>
@endif
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link {{ $chunk->final_text ? 'active' : '' }}"
id="final-tab" data-bs-toggle="tab" data-bs-target="#final-panel"
type="button" role="tab">
<i class="bi bi-check-circle me-1"></i>
final_text
@if($chunk->final_text)
<span class="badge bg-primary-subtle text-primary border border-primary ms-1" style="font-size:.65rem">
Aktif
</span>
@else
<span class="badge bg-light text-muted border ms-1" style="font-size:.65rem">null</span>
@endif
</button>
</li>
</ul>
</div>
<div class="card-body p-0">
<div class="tab-content" id="textTabsContent">
{{-- raw_text --}}
<div class="tab-pane fade {{ !$chunk->final_text && !$chunk->cleaned_text ? 'show active' : '' }}"
id="raw-panel" role="tabpanel">
<div class="p-3">
<p class="text-muted small mb-2">
Teks asal hasil extraction PDF. <strong>Tidak boleh diubah.</strong>
Ini adalah source of truth untuk chunk ini.
</p>
<pre class="mb-0 p-3 bg-light rounded" style="white-space:pre-wrap;max-height:300px;overflow-y:auto;font-family:inherit;font-size:.82rem">{{ $chunk->content }}</pre>
</div>
</div>
{{-- cleaned_text --}}
<div class="tab-pane fade {{ $chunk->cleaned_text && !$chunk->final_text ? 'show active' : '' }}"
id="cleaned-panel" role="tabpanel">
<div class="p-3">
@if($chunk->cleaned_text)
<p class="text-muted small mb-2">
Teks selepas auto cleanup. Jika final_text tidak ditetapkan, ini digunakan untuk embedding.
</p>
<pre class="mb-0 p-3 bg-light rounded" style="white-space:pre-wrap;max-height:300px;overflow-y:auto;font-family:inherit;font-size:.82rem">{{ $chunk->cleaned_text }}</pre>
@else
<div class="text-center py-4 text-muted">
<i class="bi bi-dash-circle display-6 d-block mb-2"></i>
<p class="mb-0">cleaned_text tidak ditetapkan.</p>
<p class="small">raw_text (content) digunakan untuk embedding.</p>
</div>
@endif
</div>
</div>
{{-- final_text --}}
<div class="tab-pane fade {{ $chunk->final_text ? 'show active' : '' }}"
id="final-panel" role="tabpanel">
<div class="p-3">
@if($chunk->final_text)
<p class="text-muted small mb-2">
Teks yang <strong>sebenarnya dihantar ke Qdrant</strong> untuk embedding.
Diedit oleh admin.
</p>
<pre class="mb-0 p-3 bg-success-subtle rounded" style="white-space:pre-wrap;max-height:300px;overflow-y:auto;font-family:inherit;font-size:.82rem">{{ $chunk->final_text }}</pre>
@else
<div class="text-center py-4 text-muted">
<i class="bi bi-dash-circle display-6 d-block mb-2"></i>
<p class="mb-0">final_text tidak ditetapkan.</p>
<p class="small">Sistem menggunakan cleaned_text atau raw_text untuk embedding.</p>
</div>
@endif
</div>
</div>
</div>
</div>
</div>
{{-- ===================================================================== --}}
{{-- EDIT FINAL TEXT FORM --}}
{{-- ===================================================================== --}}
@if(!$chunk->isSuperseded())
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white border-bottom py-2">
<h6 class="mb-0">
<i class="bi bi-pencil-square me-2"></i>Edit final_text
</h6>
<p class="text-muted small mb-0 mt-1">
Edit teks yang akan dihantar untuk embedding. raw_text tidak akan diubah.
Selepas simpan, reindex akan diantrikan secara automatik.
</p>
</div>
<div class="card-body">
<form method="POST" action="{{ route('admin.chunks.update', $chunk) }}" id="editForm">
@csrf
@method('PATCH')
@error('final_text')
<div class="alert alert-danger py-2 mb-3">{{ $message }}</div>
@enderror
<div class="mb-3">
<label for="final_text" class="form-label fw-semibold">
final_text
<small class="text-muted fw-normal">
(kosong = guna {{ $chunk->cleaned_text ? 'cleaned_text' : 'raw_text' }})
</small>
</label>
<textarea name="final_text" id="final_text" rows="10"
class="form-control font-monospace @error('final_text') is-invalid @enderror"
style="font-size:.82rem;line-height:1.5"
placeholder="Masukkan final_text yang telah dibersihkan...">{{ old('final_text', $chunk->final_text ?? $chunk->getEmbeddableText()) }}</textarea>
<div class="d-flex justify-content-between mt-1">
<small class="text-muted">Minimum 20 aksara, maksimum 10,000 aksara.</small>
<small class="text-muted" id="charCount">0 aksara</small>
</div>
</div>
{{-- Quick-fill dari raw_text --}}
<div class="mb-3">
<button type="button" class="btn btn-sm btn-outline-secondary" id="btnFillRaw">
<i class="bi bi-arrow-down me-1"></i>Isi dari raw_text
</button>
<button type="button" class="btn btn-sm btn-outline-secondary ms-1" id="btnClearFinal">
<i class="bi bi-x me-1"></i>Kosongkan
</button>
<span class="text-muted ms-2" style="font-size:.75rem">
raw_text: {{ number_format(mb_strlen($chunk->content)) }} aksara,
{{ number_format(str_word_count($chunk->content)) }} patah
</span>
</div>
<div class="mb-3">
<label for="notes" class="form-label">Nota perubahan <span class="text-muted fw-normal">(optional)</span></label>
<input type="text" name="notes" id="notes" class="form-control"
value="{{ old('notes') }}"
placeholder="Contoh: Buang header halaman, nombor muka surat...">
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i>Simpan & Queue Reindex
</button>
<button type="button" class="btn btn-outline-secondary" onclick="history.back()">
Batal
</button>
</div>
</form>
</div>
</div>
@endif
{{-- ===================================================================== --}}
{{-- CHILD CHUNKS (jika chunk ini pernah di-split) --}}
{{-- ===================================================================== --}}
@if($chunk->childChunks->count() > 0)
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white border-bottom py-2">
<h6 class="mb-0">
<i class="bi bi-diagram-2 me-2"></i>
Chunk Baharu Hasil Split
<span class="badge bg-secondary ms-1">{{ $chunk->childChunks->count() }}</span>
</h6>
</div>
<div class="card-body p-0">
@foreach($chunk->childChunks as $child)
<div class="d-flex align-items-center gap-3 px-3 py-2 border-bottom">
<span class="badge {{ $child->getStatusBadgeClass() }}" style="font-size:.7rem">
{{ $child->getStatusLabel() }}
</span>
<span class="text-muted" style="font-size:.75rem">#{{ $child->chunk_index + 1 }}</span>
<p class="mb-0 text-truncate flex-grow-1" style="font-size:.82rem">
{{ Str::limit($child->getEmbeddableText(), 100) }}
</p>
<a href="{{ route('admin.chunks.show', $child) }}"
class="btn btn-sm btn-outline-primary flex-shrink-0">
Lihat
</a>
</div>
@endforeach
</div>
</div>
@endif
{{-- ===================================================================== --}}
{{-- AUDIT TRAIL --}}
{{-- ===================================================================== --}}
@if($chunk->audits->count() > 0)
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white border-bottom py-2">
<h6 class="mb-0">
<i class="bi bi-clock-history me-2"></i>Audit Trail
<span class="badge bg-light text-dark border ms-1">{{ $chunk->audits->count() }} rekod</span>
</h6>
</div>
<div class="card-body p-0">
@foreach($chunk->audits as $audit)
<div class="px-3 py-2 border-bottom">
<div class="d-flex align-items-center gap-2 flex-wrap mb-1">
<span class="badge {{ $audit->getOperationBadgeClass() }}" style="font-size:.7rem">
{{ $audit->getOperationLabel() }}
</span>
@if($audit->old_status && $audit->new_status)
<span class="text-muted" style="font-size:.75rem">
{{ $audit->old_status ?? '—' }} {{ $audit->new_status }}
</span>
@endif
<span class="ms-auto text-muted" style="font-size:.72rem">
{{ $audit->user?->name ?? 'System' }}
&middot; {{ $audit->created_at->format('d/m/Y H:i') }}
</span>
</div>
@if($audit->notes)
<p class="mb-0 text-muted" style="font-size:.75rem">
<i class="bi bi-chat-left-text me-1"></i>{{ $audit->notes }}
</p>
@endif
@if($audit->operation === \App\Models\ChunkAudit::OP_EDIT_FINAL_TEXT && $audit->old_final_text)
<details class="mt-1">
<summary class="text-muted" style="font-size:.72rem;cursor:pointer">
Lihat perubahan teks
</summary>
<div class="row g-2 mt-1">
<div class="col-6">
<p class="text-muted mb-1" style="font-size:.68rem">SEBELUM</p>
<pre class="bg-danger-subtle p-2 rounded mb-0" style="font-size:.72rem;white-space:pre-wrap;max-height:100px;overflow-y:auto">{{ Str::limit($audit->old_final_text, 300) }}</pre>
</div>
<div class="col-6">
<p class="text-muted mb-1" style="font-size:.68rem">SELEPAS</p>
<pre class="bg-success-subtle p-2 rounded mb-0" style="font-size:.72rem;white-space:pre-wrap;max-height:100px;overflow-y:auto">{{ Str::limit($audit->new_final_text, 300) }}</pre>
</div>
</div>
</details>
@endif
</div>
@endforeach
</div>
</div>
@endif
@endsection
@push('scripts')
<script>
// ── Char counter ──────────────────────────────────────────────────────────────
const textarea = document.getElementById('final_text');
const charCount = document.getElementById('charCount');
function updateCount() {
if (textarea && charCount) {
charCount.textContent = textarea.value.length.toLocaleString() + ' aksara';
}
}
if (textarea) {
textarea.addEventListener('input', updateCount);
updateCount();
}
// ── Quick-fill dari raw_text ──────────────────────────────────────────────────
const rawText = @json($chunk->content);
document.getElementById('btnFillRaw')?.addEventListener('click', function() {
if (textarea.value.trim() !== '' && !confirm('Gantikan teks sedia ada dengan raw_text?')) {
return;
}
textarea.value = rawText;
updateCount();
textarea.focus();
});
document.getElementById('btnClearFinal')?.addEventListener('click', function() {
if (!confirm('Kosongkan final_text? Sistem akan guna raw_text untuk embedding.')) return;
textarea.value = '';
updateCount();
});
</script>
@endpush

View File

@@ -0,0 +1,434 @@
@extends('layouts.admin')
@section('title', 'Split Chunk #' . ($chunk->chunk_index + 1))
@section('breadcrumb')
<li class="breadcrumb-item"><a href="{{ route('admin.documents.index') }}">Dokumen</a></li>
<li class="breadcrumb-item">
<a href="{{ route('admin.documents.show', $chunk->document) }}">{{ Str::limit($chunk->document->title, 25) }}</a>
</li>
<li class="breadcrumb-item">
<a href="{{ route('admin.documents.chunks', ['document' => $chunk->document, 'version' => $chunk->documentVersion]) }}">
Chunks v{{ $chunk->documentVersion->version_number }}
</a>
</li>
<li class="breadcrumb-item">
<a href="{{ route('admin.chunks.show', $chunk) }}">Chunk #{{ $chunk->chunk_index + 1 }}</a>
</li>
<li class="breadcrumb-item active">Split</li>
@endsection
@section('content')
{{-- Flash / Validation errors --}}
@if($errors->any())
<div class="alert alert-danger alert-dismissible fade show py-2">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Ralat:</strong>
<ul class="mb-0 mt-1 ps-3">
@foreach($errors->all() as $err)
<li>{{ $err }}</li>
@endforeach
</ul>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
@endif
{{-- Header --}}
<div class="d-flex align-items-start justify-content-between mb-3 flex-wrap gap-2">
<div>
<h4 class="mb-0 fw-bold">
<i class="bi bi-scissors me-2 text-warning"></i>Split Chunk
</h4>
<p class="text-muted small mb-0">
Chunk #{{ $chunk->chunk_index + 1 }} &mdash;
{{ $chunk->document->title }} v{{ $chunk->documentVersion->version_number }}
@if($chunk->page_number) &middot; ms. {{ $chunk->page_number }} @endif
</p>
</div>
<a href="{{ route('admin.chunks.show', $chunk) }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Kembali
</a>
</div>
{{-- Warning panel --}}
<div class="alert alert-warning py-2 mb-3">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Perhatian:</strong> Selepas split, chunk asal akan ditandakan sebagai
<strong>Superseded</strong> dan tidak lagi digunakan dalam Qdrant.
Chunk-chunk baharu akan di-embed secara automatik. Tindakan ini <strong>tidak boleh diundo</strong>.
</div>
<div class="row g-3">
{{-- ── Kolum kiri: Teks asal (rujukan) ─────────────────────────────── --}}
<div class="col-lg-5">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-bottom py-2">
<h6 class="mb-0">
<i class="bi bi-file-earmark-text me-1"></i>Teks Asal (Rujukan)
</h6>
<p class="text-muted small mb-0 mt-1">
Salin bahagian yang diperlukan ke segmen di sebelah kanan.
</p>
</div>
<div class="card-body p-0">
{{-- Stats --}}
<div class="d-flex gap-3 px-3 py-2 border-bottom bg-light">
<span class="text-muted small">
<strong id="origWordCount">{{ str_word_count($chunk->getEmbeddableText()) }}</strong> patah
</span>
<span class="text-muted small">
<strong>{{ number_format(mb_strlen($chunk->getEmbeddableText())) }}</strong> aksara
</span>
@if($chunk->is_edited)
<span class="badge bg-primary-subtle text-primary border border-primary" style="font-size:.65rem">
final_text
</span>
@else
<span class="badge bg-light text-muted border" style="font-size:.65rem">raw_text</span>
@endif
</div>
{{-- Teks asal selectable untuk copy --}}
<textarea id="originalText" class="form-control border-0 rounded-0 font-monospace"
rows="20" readonly
style="font-size:.8rem;line-height:1.5;resize:none;background:#fafafa"
>{{ $chunk->getEmbeddableText() }}</textarea>
</div>
<div class="card-footer bg-white border-top py-2">
<button type="button" class="btn btn-sm btn-outline-secondary w-100" id="btnAutoSplit">
<i class="bi bi-magic me-1"></i>Auto-split mengikut perenggan
</button>
<p class="text-muted mb-0 mt-1" style="font-size:.7rem">
Pecahkan teks mengikut baris kosong berganda (\n\n) sebagai permulaan.
</p>
</div>
</div>
</div>
{{-- ── Kolum kanan: Form segmen ─────────────────────────────────────── --}}
<div class="col-lg-7">
<form method="POST" action="{{ route('admin.chunks.do-split', $chunk) }}" id="splitForm">
@csrf
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white border-bottom py-2 d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-0">
<i class="bi bi-collection me-1"></i>Segmen Baharu
<span class="badge bg-primary ms-1" id="segmentCount">2</span>
</h6>
<p class="text-muted small mb-0 mt-1">
Minimum 2, maksimum 10 segmen. Setiap segmen akan menjadi satu chunk baharu.
</p>
</div>
<div class="d-flex gap-1">
<button type="button" class="btn btn-sm btn-outline-primary" id="btnAddSegment">
<i class="bi bi-plus-lg me-1"></i>Tambah
</button>
<button type="button" class="btn btn-sm btn-outline-danger" id="btnRemoveSegment">
<i class="bi bi-dash-lg me-1"></i>Buang
</button>
</div>
</div>
<div class="card-body p-3" id="segmentsContainer">
{{-- Segmen 1 (default) --}}
<div class="segment-item mb-3" data-index="0">
<div class="d-flex align-items-center justify-content-between mb-1">
<label class="form-label fw-semibold mb-0 small">
<i class="bi bi-1-circle me-1 text-primary"></i>Segmen 1
</label>
<span class="text-muted segment-stats" style="font-size:.72rem">0 patah · 0 aksara</span>
</div>
<textarea name="segments[]"
class="form-control font-monospace segment-textarea @error('segments.0') is-invalid @enderror"
rows="6"
placeholder="Teks untuk segmen 1..."
style="font-size:.8rem;line-height:1.5">{{ old('segments.0') }}</textarea>
@error('segments.0')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
{{-- Segmen 2 (default) --}}
<div class="segment-item mb-3" data-index="1">
<div class="d-flex align-items-center justify-content-between mb-1">
<label class="form-label fw-semibold mb-0 small">
<i class="bi bi-2-circle me-1 text-primary"></i>Segmen 2
</label>
<span class="text-muted segment-stats" style="font-size:.72rem">0 patah · 0 aksara</span>
</div>
<textarea name="segments[]"
class="form-control font-monospace segment-textarea @error('segments.1') is-invalid @enderror"
rows="6"
placeholder="Teks untuk segmen 2..."
style="font-size:.8rem;line-height:1.5">{{ old('segments.1') }}</textarea>
@error('segments.1')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
</div>
</div>
{{-- Summary bar --}}
<div class="card border-0 bg-light mb-3">
<div class="card-body py-2 px-3">
<div class="d-flex gap-3 flex-wrap align-items-center">
<span class="small text-muted">
Jumlah patah dalam segmen:
<strong id="totalSegmentWords">0</strong>
<span class="text-muted">/ {{ str_word_count($chunk->getEmbeddableText()) }}</span>
</span>
<span class="small text-muted">
Jumlah aksara:
<strong id="totalSegmentChars">0</strong>
</span>
<span class="ms-auto small" id="coverageIndicator"></span>
</div>
</div>
</div>
{{-- Notes --}}
<div class="mb-3">
<label for="notes" class="form-label">
Sebab split <span class="text-muted fw-normal">(optional)</span>
</label>
<input type="text" name="notes" id="notes" class="form-control"
value="{{ old('notes') }}"
placeholder="Contoh: Chunk terlalu panjang, gabungan dua topik berbeza...">
</div>
{{-- Submit --}}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-warning fw-semibold" id="btnSubmit">
<i class="bi bi-scissors me-1"></i>
Jalankan Split
<span class="badge bg-white text-warning ms-1" id="btnSegmentCount">2</span>
Chunk Baharu
</button>
<a href="{{ route('admin.chunks.show', $chunk) }}" class="btn btn-outline-secondary">
Batal
</a>
</div>
<p class="text-muted small mt-2">
<i class="bi bi-info-circle me-1"></i>
Chunk asal akan ditandakan <strong>Superseded</strong>.
Setiap segmen akan di-embed secara automatik dalam queue.
</p>
</form>
</div>
</div>
@endsection
@push('scripts')
<script>
(function () {
'use strict';
// ── Data ─────────────────────────────────────────────────────────────────
const originalText = @json($chunk->getEmbeddableText());
const origWordCount = {{ str_word_count($chunk->getEmbeddableText()) }};
const MAX_SEGMENTS = 10;
const MIN_SEGMENTS = 2;
// Icon numerals — Bootstrap Icons
const icons = ['1-circle','2-circle','3-circle','4-circle','5-circle',
'6-circle','7-circle','8-circle','9-circle','10-circle'];
// ── Helpers ───────────────────────────────────────────────────────────────
function countWords(str) {
return str.trim() === '' ? 0 : str.trim().split(/\s+/).length;
}
function updateSegmentStats(textarea) {
const parent = textarea.closest('.segment-item');
const stats = parent.querySelector('.segment-stats');
const words = countWords(textarea.value);
const chars = textarea.value.length;
stats.textContent = words.toLocaleString() + ' patah · ' + chars.toLocaleString() + ' aksara';
}
function updateGlobalStats() {
const textareas = document.querySelectorAll('.segment-textarea');
let totalWords = 0, totalChars = 0;
textareas.forEach(ta => {
totalWords += countWords(ta.value);
totalChars += ta.value.length;
});
document.getElementById('totalSegmentWords').textContent = totalWords.toLocaleString();
document.getElementById('totalSegmentChars').textContent = totalChars.toLocaleString();
document.getElementById('segmentCount').textContent = textareas.length;
document.getElementById('btnSegmentCount').textContent = textareas.length;
// Coverage indicator
const indicator = document.getElementById('coverageIndicator');
if (origWordCount > 0) {
const pct = Math.round((totalWords / origWordCount) * 100);
const cls = pct >= 90 ? 'text-success' : (pct >= 60 ? 'text-warning' : 'text-danger');
indicator.innerHTML = `<span class="${cls}"><i class="bi bi-bar-chart me-1"></i>Liputan: ${pct}%</span>`;
}
}
function relabelAll() {
const items = document.querySelectorAll('.segment-item');
items.forEach((item, i) => {
const label = item.querySelector('label');
const iconClass = icons[i] || (i + 1) + '-circle';
label.innerHTML = `<i class="bi bi-${iconClass} me-1 text-primary"></i>Segmen ${i + 1}`;
item.dataset.index = i;
// Update error class name (not critical — server-side validates)
const ta = item.querySelector('textarea');
ta.placeholder = `Teks untuk segmen ${i + 1}...`;
});
}
// ── Attach listeners kepada textarea ─────────────────────────────────────
function attachListeners(textarea) {
textarea.addEventListener('input', function () {
updateSegmentStats(this);
updateGlobalStats();
});
// Initial update
updateSegmentStats(textarea);
}
// Init existing textareas
document.querySelectorAll('.segment-textarea').forEach(attachListeners);
updateGlobalStats();
// ── Add segment ───────────────────────────────────────────────────────────
document.getElementById('btnAddSegment').addEventListener('click', function () {
const items = document.querySelectorAll('.segment-item');
if (items.length >= MAX_SEGMENTS) {
alert('Maksimum ' + MAX_SEGMENTS + ' segmen dibenarkan.');
return;
}
const newIndex = items.length;
const iconClass = icons[newIndex] || (newIndex + 1) + '-circle';
const div = document.createElement('div');
div.className = 'segment-item mb-3';
div.dataset.index = newIndex;
div.innerHTML = `
<div class="d-flex align-items-center justify-content-between mb-1">
<label class="form-label fw-semibold mb-0 small">
<i class="bi bi-${iconClass} me-1 text-primary"></i>Segmen ${newIndex + 1}
</label>
<span class="text-muted segment-stats" style="font-size:.72rem">0 patah · 0 aksara</span>
</div>
<textarea name="segments[]"
class="form-control font-monospace segment-textarea"
rows="6"
placeholder="Teks untuk segmen ${newIndex + 1}..."
style="font-size:.8rem;line-height:1.5"></textarea>
`;
document.getElementById('segmentsContainer').appendChild(div);
attachListeners(div.querySelector('textarea'));
updateGlobalStats();
div.querySelector('textarea').focus();
});
// ── Remove last segment ───────────────────────────────────────────────────
document.getElementById('btnRemoveSegment').addEventListener('click', function () {
const items = document.querySelectorAll('.segment-item');
if (items.length <= MIN_SEGMENTS) {
alert('Minimum ' + MIN_SEGMENTS + ' segmen diperlukan.');
return;
}
if (!confirm('Buang segmen terakhir?')) return;
items[items.length - 1].remove();
relabelAll();
updateGlobalStats();
});
// ── Auto-split mengikut perenggan ─────────────────────────────────────────
document.getElementById('btnAutoSplit').addEventListener('click', function () {
// Pecahkan teks asal mengikut baris kosong berganda
const paragraphs = originalText
.split(/\n\s*\n/)
.map(p => p.trim())
.filter(p => p.length >= 20);
if (paragraphs.length < 2) {
alert('Teks asal tidak mempunyai cukup perenggan untuk auto-split (keperluan: sekurang-kurangnya 2 perenggan dengan 20+ aksara).');
return;
}
const maxAllowed = Math.min(paragraphs.length, MAX_SEGMENTS);
const msg = `Auto-split akan menghasilkan ${maxAllowed} segmen berdasarkan perenggan. Ini akan menggantikan semua segmen semasa. Teruskan?`;
if (!confirm(msg)) return;
// Buang semua segmen sedia ada
document.getElementById('segmentsContainer').innerHTML = '';
// Cipta segmen baru
paragraphs.slice(0, MAX_SEGMENTS).forEach((para, i) => {
const iconClass = icons[i] || (i + 1) + '-circle';
const div = document.createElement('div');
div.className = 'segment-item mb-3';
div.dataset.index = i;
div.innerHTML = `
<div class="d-flex align-items-center justify-content-between mb-1">
<label class="form-label fw-semibold mb-0 small">
<i class="bi bi-${iconClass} me-1 text-primary"></i>Segmen ${i + 1}
</label>
<span class="text-muted segment-stats" style="font-size:.72rem">0 patah · 0 aksara</span>
</div>
<textarea name="segments[]"
class="form-control font-monospace segment-textarea"
rows="5"
placeholder="Teks untuk segmen ${i + 1}..."
style="font-size:.8rem;line-height:1.5"></textarea>
`;
document.getElementById('segmentsContainer').appendChild(div);
div.querySelector('textarea').value = para;
attachListeners(div.querySelector('textarea'));
updateSegmentStats(div.querySelector('textarea'));
});
updateGlobalStats();
});
// ── Confirm on submit ────────────────────────────────────────────────────
document.getElementById('splitForm').addEventListener('submit', function (e) {
const items = document.querySelectorAll('.segment-item');
const count = items.length;
// Semak semua segmen tidak kosong
let allFilled = true;
items.forEach(item => {
if (item.querySelector('textarea').value.trim() === '') {
allFilled = false;
}
});
if (!allFilled) {
e.preventDefault();
alert('Semua segmen mesti diisi sebelum split boleh dijalankan.');
return;
}
if (!confirm(
`Jalankan split chunk #{{ $chunk->chunk_index + 1 }} kepada ${count} chunk baharu?\n\n` +
`Chunk asal akan ditandakan Superseded dan tidak lagi aktif dalam Qdrant.\n` +
`Tindakan ini tidak boleh diundo.`
)) {
e.preventDefault();
}
});
})();
</script>
@endpush