435 lines
20 KiB
PHP
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 }} —
|
|
{{ $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
|