utehy-nlu/ScriptCorrector
0
1const DATASET_KEY = "humanlabeling_dataset";
2const AUTOSAVE_DELAY = 1500;
3
4let _dataset = [];
5let _activeRowId = null;
6let _autosaveTimer = null;
7let _currentReferences = [];
8let _currentVideoUrl = null;
9let _currentTitle = "";
10let _currentPage = 1;
11const PAGE_SIZE = 10;
12
13// ── Init ──────────────────────────────────────────────────────────────────────
14window.addEventListener("DOMContentLoaded", () => {
15 _loadDataset();
16
17 document.getElementById("transcriptArea").addEventListener("input", () => {
18 updateCharCount();
19 _scheduleAutosave();
20 });
21 document.getElementById("refInput").addEventListener("keydown", (e) => {
22 if (e.key === "Enter") addReference();
23 });
24});
25
26// ── References ────────────────────────────────────────────────────────────────
27function addReference() {
28 const input = document.getElementById("refInput");
29 const val = input.value.trim();
30 if (!val) return;
31 if (!_currentReferences.includes(val)) {
32 _currentReferences.push(val);
33 _renderRefPills();
34 _scheduleAutosave();
35 }
36 input.value = "";
37 input.focus();
38}
39
40function removeReference(idx) {
41 _currentReferences.splice(idx, 1);
42 _renderRefPills();
43 _scheduleAutosave();
44}
45
46function editReference(idx) {
47 const current = _currentReferences[idx];
48 const container = document.getElementById("refPills");
49 const pill = container.querySelectorAll(".ref-pill")[idx];
50 if (!pill) return;
51
52 const input = document.createElement("input");
53 input.className = "ref-pill-edit";
54 input.value = current;
55 pill.innerHTML = "";
56 pill.appendChild(input);
57 input.focus();
58 input.select();
59
60 function commit() {
61 const val = input.value.trim();
62 if (val && val !== current) {
63 _currentReferences[idx] = val;
64 _scheduleAutosave();
65 }
66 _renderRefPills();
67 }
68 input.addEventListener("keydown", (e) => {
69 if (e.key === "Enter") { e.preventDefault(); commit(); }
70 if (e.key === "Escape") _renderRefPills();
71 });
72 input.addEventListener("blur", commit);
73}
74
75function _renderRefPills() {
76 const container = document.getElementById("refPills");
77 if (_currentReferences.length === 0) {
78 container.innerHTML = "";
79 return;
80 }
81 container.innerHTML = _currentReferences
82 .map(
83 (ref, i) => `
84 <span class="ref-pill">
85 <span class="ref-pill-text" onclick="editReference(${i})" title="Nhấn để sửa">${_esc(ref)}</span>
86 <button onclick="removeReference(${i})" title="Xoá">×</button>
87 </span>
88 `,
89 )
90 .join("");
91}
92
93// ── Video preview ─────────────────────────────────────────────────────────────
94function loadVideoPreview(url) {
95 const container = document.getElementById("videoContainer");
96 const ytMatch = url.match(/(?:v=|youtu\.be\/|shorts\/)([A-Za-z0-9_-]{11})/);
97 if (!ytMatch) {
98 container.innerHTML = `<div class="video-placeholder"><p style="color:#475569;font-size:12px;">Không phải YouTube URL</p></div>`;
99 return;
100 }
101 container.innerHTML = "";
102 const iframe = document.createElement("iframe");
103 iframe.width = "100%";
104 iframe.height = "100%";
105 iframe.style.border = "none";
106 iframe.allow =
107 "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture";
108 iframe.allowFullscreen = true;
109 iframe.src = `https://www.youtube.com/embed/${ytMatch[1]}`;
110 container.appendChild(iframe);
111}
112
113// ── Transcript ────────────────────────────────────────────────────────────────
114function setTranscript(text) {
115 document.getElementById("transcriptArea").value = text || "";
116 updateCharCount();
117}
118
119function updateCharCount() {
120 const text = document.getElementById("transcriptArea").value;
121 const el = document.getElementById("charCount");
122 if (text.length > 0) {
123 const words = text.trim().split(/\s+/).length;
124 el.textContent = `${words} từ · ${text.length} ký tự`;
125 } else {
126 el.textContent = "";
127 }
128}
129
130async function copyText() {
131 const text = document.getElementById("transcriptArea").value;
132 if (!text) return;
133 try {
134 await navigator.clipboard.writeText(text);
135 const btn = document.getElementById("copyBtn");
136 btn.innerHTML = '<i class="ri-check-line"></i> Đã copy!';
137 setTimeout(() => {
138 btn.innerHTML = '<i class="ri-file-copy-line"></i> Copy';
139 }, 1500);
140 } catch (_) {}
141}
142
143const _INSTRUCTION_TEMPLATE = `Bạn là một trợ lý AI chuyên nghiệp về biên tập nội dung pháp luật. Nhiệm vụ của bạn là sửa lỗi chính tả và định dạng lại văn bản phụ đề (transcript) của video dựa trên các quy tắc nghiêm ngặt sau:
144
145### 1. QUY TẮC NGUYÊN VĂN (BẮT BUỘC)
146- GIỮ NGUYÊN VĂN TỪNG CÂU TỪNG CHỮ của bản phụ đề được cung cấp.
147- KHÔNG TÓM TẮT, không cắt xén, không tự ý viết lại câu theo ý mình, không chuyển thành dạng danh sách bài viết (trừ khi văn bản gốc đang liệt kê điều khoản 1, 2, 3...).
148
149### 2. QUY TẮC SỬA LỖI & CHUẨN HÓA KHÁCH QUAN
150- Sửa các lỗi chính tả do công cụ nhận diện giọng nói tự động (AI speech-to-text) nhận diện sai từ đồng âm (Ví dụ: "chữa thuế" -> "chịu thuế", "bài hát xã hội" -> "bảo hiểm xã hội").
151- Nếu trong phụ đề có số liệu bị nhận diện sai lệch so với thực tế phát âm của video (Ví dụ: nghe nhầm 135 thành 155), hãy đính chính lại cho đúng với âm thanh trong video.
152- Thêm dấu câu đầy đủ (chấm, phẩy, hai chấm, viết hoa đầu câu) để ngắt câu mạch lạc, rõ nghĩa.
153- Giữ nguyên các đoạn quảng cáo chèn giữa video của kênh (nếu có trong phụ đề).
154
155### 3. BỔ SUNG THÔNG TIN VĂN BẢN PHÁP LUẬT
156- Khi trong phụ đề nhắc đến một văn bản pháp luật (Nghị quyết, Nghị định, Thông tư...) nhưng chỉ có số mà thiếu ký hiệu hoặc năm ban hành, hãy tra cứu nhanh để bổ sung đầy đủ ký hiệu chuẩn pháp lý (Ví dụ: "Nghị quyết 278 ngày 13 tháng 9" -> "Nghị quyết số 278/NQ-CP ngày 13 tháng 9 năm 2025").
157
158### 4. ĐỊNH DẠNG ĐẦU RA
159- Xuất kết quả duy nhất dưới dạng một khối mã Markdown (Code block) chứa văn bản hoàn chỉnh để người dùng dễ sao chép.
160
161---
162### THÔNG TIN CẦN XỬ LÝ:
163- URL Video: {{URL}}
164- Nội dung phụ đề gốc:
165{{SCRIPT}}`;
166
167async function generateInstruction() {
168 const script = document.getElementById("transcriptArea").value.trim();
169 const url = _currentVideoUrl || "";
170 if (!script && !url) return;
171
172 const prompt = _INSTRUCTION_TEMPLATE
173 .replace("{{URL}}", url || "[Chèn link video vào đây]")
174 .replace("{{SCRIPT}}", script || "[Dán đoạn phụ đề cần sửa vào đây]");
175
176 try {
177 await navigator.clipboard.writeText(prompt);
178 const btn = document.getElementById("genInstrBtn");
179 btn.innerHTML = '<i class="ri-check-line"></i> Đã copy!';
180 setTimeout(() => {
181 btn.innerHTML = '<i class="ri-sparkling-line"></i> Gen Instruction';
182 }, 2000);
183 } catch (_) {}
184}
185
186// ── Status bar ────────────────────────────────────────────────────────────────
187function setStatus(type, text) {
188 const bar = document.getElementById("statusBar");
189 bar.className = `status-bar ${type}`;
190 document.getElementById("statusText").textContent = text;
191}
192
193// ── Dataset ───────────────────────────────────────────────────────────────────
194function _loadDataset() {
195 try {
196 _dataset = JSON.parse(localStorage.getItem(DATASET_KEY) || "[]");
197 } catch (_) {
198 _dataset = [];
199 }
200 _renderDataset();
201}
202
203function _saveDataset() {
204 localStorage.setItem(DATASET_KEY, JSON.stringify(_dataset));
205 _showAutosaved();
206}
207
208function _scheduleAutosave() {
209 if (!_activeRowId) return;
210 clearTimeout(_autosaveTimer);
211 document.getElementById("autosaveIndicator").textContent = "lưu...";
212 document.getElementById("autosaveIndicator").className = "autosave-indicator";
213 _autosaveTimer = setTimeout(() => {
214 const entry = _dataset.find((e) => e.id === _activeRowId);
215 if (entry) {
216 entry.script = document.getElementById("transcriptArea").value;
217 entry.references = [..._currentReferences];
218 _saveDataset();
219 _renderDataset();
220 }
221 }, AUTOSAVE_DELAY);
222}
223
224function _showAutosaved() {
225 const el = document.getElementById("autosaveIndicator");
226 el.textContent = "đã lưu";
227 el.className = "autosave-indicator saved";
228 setTimeout(() => {
229 el.textContent = "";
230 }, 2000);
231}
232
233function saveCurrentToDataset() {
234 if (!_activeRowId) return;
235 const script = document.getElementById("transcriptArea").value.trim();
236 const entry = _dataset.find((e) => e.id === _activeRowId);
237 if (!entry) return;
238
239 entry.script = script;
240 entry.references = [..._currentReferences];
241 entry.status = "verified";
242 _saveDataset();
243 _renderDataset();
244 setStatus("done", `Đã verify: ${_esc(entry.title)}`);
245
246 // Auto-advance to next unverified entry
247 const currentIdx = _dataset.findIndex((e) => e.id === _activeRowId);
248 const next = _dataset.slice(currentIdx + 1).find((e) => (e.status || "none") !== "verified");
249 if (next) selectRow(next.id);
250}
251
252function selectRow(id) {
253 const entry = _dataset.find((e) => e.id === id);
254 if (!entry) return;
255 _activeRowId = id;
256 _currentVideoUrl = entry.url;
257 _currentTitle = entry.title;
258 _currentReferences = [...(entry.references || [])];
259 setTranscript(entry.script);
260 _renderRefPills();
261 loadVideoPreview(entry.url);
262 document.getElementById("videoMeta").textContent = entry.title || entry.url;
263 _renderDataset();
264}
265
266function deleteRow(id) {
267 _dataset = _dataset.filter((e) => e.id !== id);
268 if (_activeRowId === id) {
269 _activeRowId = null;
270 setTranscript("");
271 _currentReferences = [];
272 _renderRefPills();
273 document.getElementById("videoMeta").textContent = "";
274 document.getElementById("videoContainer").innerHTML = `
275 <div class="video-placeholder">
276 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2">
277 <rect x="2" y="3" width="20" height="14" rx="2"/>
278 <path d="M8 21h8M12 17v4"/>
279 </svg>
280 <p>Chọn một video từ dataset để xem</p>
281 </div>`;
282 }
283 _saveDataset();
284 _renderDataset();
285}
286
287function clearDataset() {
288 if (!_dataset.length) return;
289 if (!confirm(`Xoá tất cả ${_dataset.length} video khỏi dataset?`)) return;
290 _dataset = [];
291 _activeRowId = null;
292 _saveDataset();
293 _renderDataset();
294}
295
296function _renderDataset() {
297 const tbody = document.getElementById("datasetBody");
298 const count = document.getElementById("datasetCount");
299
300 const verified = _dataset.filter((e) => (e.status || "none") === "verified").length;
301 count.textContent = _dataset.length
302 ? `${_dataset.length} video · ${verified} verified`
303 : "";
304
305 if (_dataset.length === 0) {
306 tbody.innerHTML =
307 '<tr><td colspan="7" class="empty-state">Import file JSON hoặc CSV để bắt đầu labeling.</td></tr>';
308 _renderPagination(0);
309 return;
310 }
311
312 const totalPages = Math.ceil(_dataset.length / PAGE_SIZE);
313 if (_currentPage > totalPages) _currentPage = totalPages;
314
315 const start = (_currentPage - 1) * PAGE_SIZE;
316 const pageItems = _dataset.slice(start, start + PAGE_SIZE);
317
318 tbody.innerHTML = pageItems
319 .map((e, i) => {
320 const refs = e.references || [];
321 const refText =
322 refs.length === 0
323 ? "—"
324 : refs.length === 1
325 ? refs[0]
326 : `${refs[0]} +${refs.length - 1}`;
327 const refTitle = refs.join(" | ");
328 const st = e.status || "none";
329 const statusBadge = `<span class="status-badge status-${st}">${st}</span>`;
330 return `
331 <tr data-id="${e.id}" class="${e.id === _activeRowId ? "active" : ""}" onclick="selectRow('${e.id}')">
332 <td class="td-num">${start + i + 1}</td>
333 <td class="td-title" title="${_esc(e.title)}">${_esc(e.title)}</td>
334 <td class="td-script" title="${_esc(e.script)}">${_esc(e.script)}</td>
335 <td class="td-refs" title="${_esc(refTitle)}">${_esc(refText)}</td>
336 <td style="max-width:100px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">
337 <a href="${_esc(e.url)}" target="_blank" style="color:#3b82f6;font-size:11px;" onclick="event.stopPropagation()">link</a>
338 </td>
339 <td>${statusBadge}</td>
340 <td class="td-actions">
341 <button onclick="event.stopPropagation();deleteRow('${e.id}')" title="Xoá">✕</button>
342 </td>
343 </tr>`;
344 })
345 .join("");
346
347 _renderPagination(totalPages);
348}
349
350function _renderPagination(totalPages) {
351 const bar = document.getElementById("paginationBar");
352 if (!bar) return;
353 if (totalPages <= 1) {
354 bar.innerHTML = "";
355 return;
356 }
357
358 const prev = _currentPage > 1;
359 const next = _currentPage < totalPages;
360
361 const pages = [];
362 const delta = 3;
363 for (let p = 1; p <= totalPages; p++) {
364 if (
365 p === 1 ||
366 p === totalPages ||
367 (p >= _currentPage - delta && p <= _currentPage + delta)
368 ) {
369 pages.push(p);
370 }
371 }
372
373 let html = `<button class="pg-btn" ${prev ? "" : "disabled"} onclick="gotoPage(${_currentPage - 1})"><i class="ri-arrow-left-s-line"></i></button>`;
374 let last = 0;
375 for (const p of pages) {
376 if (last && p - last > 1) html += `<span class="pg-ellipsis">…</span>`;
377 html += `<button class="pg-btn ${p === _currentPage ? "pg-active" : ""}" onclick="gotoPage(${p})">${p}</button>`;
378 last = p;
379 }
380 html += `<button class="pg-btn" ${next ? "" : "disabled"} onclick="gotoPage(${_currentPage + 1})"><i class="ri-arrow-right-s-line"></i></button>`;
381 bar.innerHTML = html;
382}
383
384function gotoPage(p) {
385 _currentPage = p;
386 _renderDataset();
387}
388
389// ── Import / Export ───────────────────────────────────────────────────────────
390function exportJSON() {
391 if (!_dataset.length) return;
392 const blob = new Blob([JSON.stringify(_dataset, null, 2)], {
393 type: "application/json;charset=utf-8",
394 });
395 const a = document.createElement("a");
396 a.href = URL.createObjectURL(blob);
397 a.download = `labeled_${new Date().toISOString().slice(0, 10)}.json`;
398 a.click();
399 URL.revokeObjectURL(a.href);
400}
401
402function importJSON(event) {
403 const file = event.target.files[0];
404 if (!file) return;
405 const reader = new FileReader();
406 reader.onload = (e) => {
407 try {
408 const imported = JSON.parse(e.target.result);
409 if (!Array.isArray(imported))
410 throw new Error("Không phải mảng JSON hợp lệ");
411
412 let added = 0;
413 for (const entry of imported) {
414 if (!entry.url) continue;
415 if (_dataset.find((d) => d.url === entry.url)) continue;
416 _dataset.push({
417 id: entry.id || (Date.now().toString(36) + Math.random().toString(36).slice(2, 6)),
418 title: entry.title || entry.url,
419 url: entry.url,
420 script: entry.script || "",
421 references: Array.isArray(entry.references) ? entry.references : [],
422 status: ["none", "extracted", "verified"].includes(entry.status)
423 ? entry.status
424 : entry.script
425 ? "extracted"
426 : "none",
427 });
428 added++;
429 }
430 _currentPage = 1;
431 _saveDataset();
432 _renderDataset();
433 alert(`Import thành công: +${added} video mới (bỏ qua ${imported.length - added} trùng URL).`);
434 } catch (err) {
435 alert(`Import thất bại: ${err.message}`);
436 }
437 event.target.value = "";
438 };
439 reader.readAsText(file, "utf-8");
440}
441
442function importCSV(event) {
443 const file = event.target.files[0];
444 if (!file) return;
445 const reader = new FileReader();
446 reader.onload = (e) => {
447 try {
448 const rows = _parseCSV(e.target.result);
449 if (rows.length < 2) throw new Error("File CSV không có dữ liệu");
450
451 const header = rows[0].map((h) => h.trim().toLowerCase());
452 const col = (name) => header.indexOf(name);
453 const iTitle = col("title"),
454 iUrl = col("url"),
455 iScript = col("script"),
456 iRefs = col("references"),
457 iStatus = col("status");
458
459 if (iUrl === -1) throw new Error('Không tìm thấy cột "url" trong CSV');
460
461 let added = 0;
462 for (let i = 1; i < rows.length; i++) {
463 const row = rows[i];
464 const url = (row[iUrl] || "").trim();
465 if (!url) continue;
466 if (_dataset.find((d) => d.url === url)) continue;
467 const refsRaw = iRefs !== -1 ? row[iRefs] || "" : "";
468 const references = refsRaw
469 ? refsRaw.split(";").map((r) => r.trim()).filter(Boolean)
470 : [];
471 const scriptVal = iScript !== -1 ? row[iScript] || "" : "";
472 const statusVal =
473 iStatus !== -1 && ["none", "extracted", "verified"].includes(row[iStatus])
474 ? row[iStatus]
475 : scriptVal
476 ? "extracted"
477 : "none";
478 _dataset.push({
479 id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6) + i,
480 title: iTitle !== -1 ? row[iTitle] || url : url,
481 url,
482 script: scriptVal,
483 references,
484 status: statusVal,
485 });
486 added++;
487 }
488 _currentPage = 1;
489 _saveDataset();
490 _renderDataset();
491 alert(`Import CSV thành công: +${added} video mới (bỏ qua ${rows.length - 1 - added} trùng URL).`);
492 } catch (err) {
493 alert(`Import CSV thất bại: ${err.message}`);
494 }
495 event.target.value = "";
496 };
497 reader.readAsText(file, "utf-8");
498}
499
500function exportCSV() {
501 if (!_dataset.length) return;
502 const header = ["title", "url", "script", "references", "status"];
503 const rows = _dataset.map((e) =>
504 [e.title, e.url, e.script, (e.references || []).join("; "), e.status || "none"]
505 .map(_csvCell)
506 .join(","),
507 );
508 const csv = [header.join(","), ...rows].join("\n");
509 const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" });
510 const a = document.createElement("a");
511 a.href = URL.createObjectURL(blob);
512 a.download = `labeled_${new Date().toISOString().slice(0, 10)}.csv`;
513 a.click();
514 URL.revokeObjectURL(a.href);
515}
516
517function _parseCSV(text) {
518 if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
519 const rows = [];
520 let row = [], field = "", inQuotes = false, i = 0;
521 while (i < text.length) {
522 const ch = text[i];
523 if (inQuotes) {
524 if (ch === '"' && text[i + 1] === '"') { field += '"'; i += 2; continue; }
525 if (ch === '"') { inQuotes = false; i++; continue; }
526 field += ch;
527 } else {
528 if (ch === '"') { inQuotes = true; i++; continue; }
529 if (ch === ",") { row.push(field); field = ""; i++; continue; }
530 if (ch === "\n" || (ch === "\r" && text[i + 1] === "\n")) {
531 row.push(field); field = "";
532 if (row.some((c) => c !== "")) rows.push(row);
533 row = [];
534 i += ch === "\r" ? 2 : 1;
535 continue;
536 }
537 field += ch;
538 }
539 i++;
540 }
541 if (field || row.length) { row.push(field); if (row.some((c) => c !== "")) rows.push(row); }
542 return rows;
543}
544
545function _csvCell(val) {
546 const s = String(val ?? "").replace(/"/g, '""');
547 return `"${s}"`;
548}
549
550function _esc(s) {
551 return String(s ?? "")
552 .replace(/&/g, "&")
553 .replace(/</g, "<")
554 .replace(/>/g, ">")
555 .replace(/"/g, """);
556}
557 