First commit
This commit is contained in:
474
resources/views/admin/chunks/show.blade.php
Normal file
474
resources/views/admin/chunks/show.blade.php
Normal 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 }} — v{{ $chunk->documentVersion->version_number }}
|
||||
@if($chunk->page_number) · ms. {{ $chunk->page_number }} @endif
|
||||
@if($chunk->section_heading) · {{ $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' }}
|
||||
· {{ $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
|
||||
434
resources/views/admin/chunks/split.blade.php
Normal file
434
resources/views/admin/chunks/split.blade.php
Normal 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 }} —
|
||||
{{ $chunk->document->title }} v{{ $chunk->documentVersion->version_number }}
|
||||
@if($chunk->page_number) · 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
|
||||
Reference in New Issue
Block a user