CoolFace
Apppublic

utehy-nlu/ScriptCorrector

sourceHugging Facecc-by-nc-sa-4.0updated 4mo agoView on Hugging Face
0likes
app.js557 linesDownload Raw Back to root
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, "&amp;")
553    .replace(/</g, "&lt;")
554    .replace(/>/g, "&gt;")
555    .replace(/"/g, "&quot;");
556}
557