Files
ChatbotAI/resources/views/admin/chunks/split.blade.php
2026-05-18 08:56:23 +08:00

435 lines
20 KiB
PHP

@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