feat(dataset-review): 添加歌词三分类测试集查看页面与接口
Showing
7 changed files
with
674 additions
and
0 deletions
dataset_review.html
0 → 100644
| 1 | <!DOCTYPE html> | ||
| 2 | <html lang="zh-CN"> | ||
| 3 | <head> | ||
| 4 | <meta charset="UTF-8"> | ||
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| 6 | <title>歌词三分类测试集查看</title> | ||
| 7 | <style> | ||
| 8 | :root { | ||
| 9 | --bg: #f5f7fa; --card: #fff; --border: #e2e8f0; | ||
| 10 | --primary: #3b82f6; --primary-hover: #2563eb; | ||
| 11 | --success: #10b981; --warning: #f59e0b; --danger: #ef4444; | ||
| 12 | --text: #1e293b; --text-secondary: #64748b; | ||
| 13 | --radius: 8px; | ||
| 14 | } | ||
| 15 | * { box-sizing: border-box; margin: 0; padding: 0; } | ||
| 16 | body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; } | ||
| 17 | .container { max-width: 1400px; margin: 0 auto; padding: 16px; } | ||
| 18 | h1 { font-size: 1.4rem; margin-bottom: 12px; } | ||
| 19 | .stats-bar { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; } | ||
| 20 | .stat-card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 16px; min-width: 120px; text-align: center; } | ||
| 21 | .stat-card .num { font-size: 1.5rem; font-weight: 700; color: var(--primary); } | ||
| 22 | .stat-card .label { font-size: 0.8rem; color: var(--text-secondary); } | ||
| 23 | .class-stats { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; } | ||
| 24 | .class-chip { background: var(--card); border: 1px solid var(--border); border-radius: 20px; padding: 6px 16px; font-size: 0.82rem; cursor: pointer; user-select: none; transition: all 0.15s; } | ||
| 25 | .class-chip:hover { border-color: var(--primary); } | ||
| 26 | .class-chip.active { background: var(--primary); color: #fff; border-color: var(--primary); } | ||
| 27 | .class-chip .count { font-weight: 700; margin-left: 4px; } | ||
| 28 | .toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 16px; background: var(--card); padding: 12px; border-radius: var(--radius); border: 1px solid var(--border); } | ||
| 29 | .toolbar select, .toolbar input, .toolbar button { font-size: 0.85rem; padding: 6px 10px; border-radius: 4px; border: 1px solid var(--border); } | ||
| 30 | .toolbar button { background: var(--primary); color: #fff; border: none; cursor: pointer; font-weight: 500; } | ||
| 31 | .toolbar button:hover { background: var(--primary-hover); } | ||
| 32 | .sample-list { display: flex; flex-direction: column; gap: 12px; } | ||
| 33 | .sample-card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } | ||
| 34 | .sample-card.active { border-color: var(--primary); box-shadow: 0 0 0 2px rgba(59,130,246,0.15); } | ||
| 35 | .sample-header { display: flex; align-items: center; gap: 12px; padding: 10px 16px; cursor: pointer; user-select: none; } | ||
| 36 | .sample-header:hover { background: #f8fafc; } | ||
| 37 | .sample-id { font-weight: 700; font-size: 0.85rem; color: var(--primary); min-width: 70px; } | ||
| 38 | .sample-title { flex: 1; font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | ||
| 39 | .sample-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 10px; background: #e2e8f0; color: var(--text-secondary); } | ||
| 40 | .sample-badge.class-duplicate { background: #fee2e2; color: #991b1b; } | ||
| 41 | .sample-badge.class-review { background: #fef3c7; color: #92400e; } | ||
| 42 | .sample-badge.class-new { background: #d1fae5; color: #065f46; } | ||
| 43 | .sample-body { display: none; padding: 16px; border-top: 1px solid var(--border); } | ||
| 44 | .sample-card.active .sample-body { display: block; } | ||
| 45 | .pair-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; } | ||
| 46 | @media (max-width: 900px) { .pair-grid { grid-template-columns: 1fr; } } | ||
| 47 | .pair-col { border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; } | ||
| 48 | .pair-col h4 { font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 8px; text-transform: uppercase; } | ||
| 49 | .pair-col .meta { font-size: 0.85rem; margin-bottom: 4px; } | ||
| 50 | .pair-col .meta b { color: var(--text-secondary); font-weight: 500; } | ||
| 51 | .lyrics-box { background: #f8fafc; border: 1px solid var(--border); border-radius: 4px; padding: 10px; font-size: 0.8rem; max-height: 280px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; margin-top: 8px; } | ||
| 52 | .info-section { background: #f8fafc; border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; margin-bottom: 16px; font-size: 0.82rem; line-height: 1.7; } | ||
| 53 | .info-section .row { margin-bottom: 2px; } | ||
| 54 | .info-section b { color: var(--text); } | ||
| 55 | .form-actions { margin-top: 12px; display: flex; gap: 8px; } | ||
| 56 | .form-actions button { padding: 8px 20px; font-size: 0.85rem; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; } | ||
| 57 | .btn-nav { background: #e2e8f0; color: var(--text); } | ||
| 58 | .btn-nav:hover { background: #cbd5e1; } | ||
| 59 | .pagination { display: flex; align-items: center; gap: 8px; justify-content: center; margin-top: 16px; } | ||
| 60 | .pagination button { padding: 6px 14px; border: 1px solid var(--border); background: var(--card); border-radius: 4px; cursor: pointer; } | ||
| 61 | .pagination button:disabled { opacity: 0.4; cursor: default; } | ||
| 62 | .pagination span { font-size: 0.85rem; color: var(--text-secondary); } | ||
| 63 | .toast { position: fixed; bottom: 20px; right: 20px; background: #1e293b; color: #fff; padding: 10px 20px; border-radius: 6px; font-size: 0.85rem; opacity: 0; transition: opacity 0.3s; pointer-events: none; z-index: 999; } | ||
| 64 | .toast.show { opacity: 1; } | ||
| 65 | .guide-toggle { font-size: 0.8rem; color: var(--primary); cursor: pointer; text-decoration: underline; margin-left: 12px; } | ||
| 66 | .guide-panel { display: none; background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 16px; font-size: 0.82rem; line-height: 1.7; max-height: 500px; overflow-y: auto; } | ||
| 67 | .guide-panel.show { display: block; } | ||
| 68 | .guide-panel h1 { font-size: 1rem; margin-bottom: 8px; } | ||
| 69 | .guide-panel h2 { font-size: 0.9rem; margin: 10px 0 4px; } | ||
| 70 | .guide-panel h3 { font-size: 0.85rem; margin: 8px 0 4px; } | ||
| 71 | .guide-panel ul { padding-left: 18px; } | ||
| 72 | .guide-panel code { background: #f1f5f9; padding: 1px 4px; border-radius: 3px; font-size: 0.8rem; } | ||
| 73 | </style> | ||
| 74 | </head> | ||
| 75 | <body> | ||
| 76 | <div class="container"> | ||
| 77 | <h1>歌词三分类测试集查看 <span class="guide-toggle" onclick="toggleGuide()">查看说明文档</span></h1> | ||
| 78 | <div class="guide-panel" id="guidePanel"></div> | ||
| 79 | <div class="stats-bar" id="statsBar"></div> | ||
| 80 | <div class="class-stats" id="classStats"></div> | ||
| 81 | <div class="toolbar"> | ||
| 82 | <select id="filterSubtype"><option value="all">全部子类</option></select> | ||
| 83 | <input type="text" id="searchInput" placeholder="搜索歌名/ID/歌手..." style="width:220px;"> | ||
| 84 | <button onclick="applyFilter()">筛选</button> | ||
| 85 | </div> | ||
| 86 | <div class="sample-list" id="sampleList"></div> | ||
| 87 | <div class="pagination" id="pagination"></div> | ||
| 88 | </div> | ||
| 89 | <div class="toast" id="toast"></div> | ||
| 90 | <script> | ||
| 91 | const PAGE_SIZE = 20; | ||
| 92 | let allSamples = []; | ||
| 93 | let stats = {}; | ||
| 94 | let filtered = []; | ||
| 95 | let currentPage = 1; | ||
| 96 | let activeId = null; | ||
| 97 | let activeClass = 'all'; | ||
| 98 | |||
| 99 | const CLASS_ZH = { 'duplicate': '重复', 'review': '待复核', 'new': '新歌' }; | ||
| 100 | const CLASS_STYLE = { 'duplicate': 'class-duplicate', 'review': 'class-review', 'new': 'class-new' }; | ||
| 101 | const SUBTYPE_ZH = { | ||
| 102 | 'exact_hash': '精确哈希', | ||
| 103 | 'high_literal_overlap': '高字面重合', | ||
| 104 | 'high_primary_translation_ignored': '主翻译忽略', | ||
| 105 | 'suspected_wash': '疑似洗盗', | ||
| 106 | 'version_or_same_work': '版本/同作品', | ||
| 107 | 'fragment_reuse': '片段复用', | ||
| 108 | 'chorus_overlap': '副歌重合', | ||
| 109 | 'translation_overlap': '翻译重合', | ||
| 110 | 'general_borderline': '一般边界', | ||
| 111 | 'same_artist_different_title': '同歌手不同歌名', | ||
| 112 | 'same_lyricist_different_title': '同词作者不同歌名', | ||
| 113 | 'same_composer_different_title': '同曲作者不同歌名', | ||
| 114 | 'random_different_songs': '随机不同歌曲', | ||
| 115 | }; | ||
| 116 | function zh(map, v) { return map[v] || v; } | ||
| 117 | |||
| 118 | async function init() { | ||
| 119 | const [csvRes, statsRes, guideRes] = await Promise.all([ | ||
| 120 | fetch('/api/dataset-review/samples').then(r => r.json()), | ||
| 121 | fetch('/api/dataset-review/stats').then(r => r.json()), | ||
| 122 | fetch('/api/dataset-review/guide').then(r => r.json()), | ||
| 123 | ]); | ||
| 124 | allSamples = csvRes.samples || []; | ||
| 125 | stats = statsRes; | ||
| 126 | document.getElementById('guidePanel').innerHTML = renderGuide(guideRes.text || ''); | ||
| 127 | renderStats(); | ||
| 128 | renderClassChips(); | ||
| 129 | buildSubtypeFilter(); | ||
| 130 | applyFilter(); | ||
| 131 | } | ||
| 132 | |||
| 133 | function renderGuide(text) { | ||
| 134 | return text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') | ||
| 135 | .replace(/^# (.+)$/gm, '<h1>$1</h1>') | ||
| 136 | .replace(/^### (.+)$/gm, '<h3>$1</h3>') | ||
| 137 | .replace(/^## (.+)$/gm, '<h2>$1</h2>') | ||
| 138 | .replace(/^```text$/gm, '<code>').replace(/^```$/gm, '</code>') | ||
| 139 | .replace(/^- (.+)$/gm, '<li>$1</li>') | ||
| 140 | .replace(/(<li>.*<\/li>)/gs, '<ul>$1</ul>') | ||
| 141 | .replace(/\n{2,}/g, '<br><br>').replace(/\n/g, '<br>'); | ||
| 142 | } | ||
| 143 | function toggleGuide() { document.getElementById('guidePanel').classList.toggle('show'); } | ||
| 144 | |||
| 145 | function renderStats() { | ||
| 146 | const total = stats.total_pairs || allSamples.length; | ||
| 147 | const subtypes = stats.subtype_counts || {}; | ||
| 148 | const subtypeCount = Object.keys(subtypes).length; | ||
| 149 | const poolTotal = Object.values(stats.source_pool_counts || {}).reduce((a, b) => a + b, 0); | ||
| 150 | let html = `<div class="stat-card"><div class="num">${total}</div><div class="label">样本对总数</div></div>`; | ||
| 151 | html += `<div class="stat-card"><div class="num">${Object.keys(stats.class_counts || {}).length}</div><div class="label">分类数</div></div>`; | ||
| 152 | html += `<div class="stat-card"><div class="num">${subtypeCount}</div><div class="label">子类数</div></div>`; | ||
| 153 | html += `<div class="stat-card"><div class="num">${poolTotal || '-'}</div><div class="label">候选池总量</div></div>`; | ||
| 154 | document.getElementById('statsBar').innerHTML = html; | ||
| 155 | } | ||
| 156 | |||
| 157 | function renderClassChips() { | ||
| 158 | const counts = stats.class_counts || {}; | ||
| 159 | let html = `<div class="class-chip ${activeClass === 'all' ? 'active' : ''}" onclick="selectClass('all')">全部<span class="count">${allSamples.length}</span></div>`; | ||
| 160 | Object.keys(counts).sort().forEach(c => { | ||
| 161 | const style = activeClass === c ? 'active' : ''; | ||
| 162 | html += `<div class="class-chip ${style}" onclick="selectClass('${c}')">${zh(CLASS_ZH, c)}<span class="count">${counts[c]}</span></div>`; | ||
| 163 | }); | ||
| 164 | document.getElementById('classStats').innerHTML = html; | ||
| 165 | } | ||
| 166 | |||
| 167 | function selectClass(c) { | ||
| 168 | activeClass = c; | ||
| 169 | renderClassChips(); | ||
| 170 | rebuildSubtypeFilter(); | ||
| 171 | applyFilter(); | ||
| 172 | } | ||
| 173 | |||
| 174 | function buildSubtypeFilter() { | ||
| 175 | rebuildSubtypeFilter(); | ||
| 176 | } | ||
| 177 | |||
| 178 | function rebuildSubtypeFilter() { | ||
| 179 | const sel = document.getElementById('filterSubtype'); | ||
| 180 | const prevValue = sel.value; | ||
| 181 | sel.innerHTML = '<option value="all">全部子类</option>'; | ||
| 182 | // 按当前选中主类统计子类数量 | ||
| 183 | const counts = {}; | ||
| 184 | allSamples.forEach(s => { | ||
| 185 | if (activeClass !== 'all' && s.sampling_class !== activeClass) return; | ||
| 186 | const sub = s.sampling_subtype; | ||
| 187 | if (!sub) return; | ||
| 188 | counts[sub] = (counts[sub] || 0) + 1; | ||
| 189 | }); | ||
| 190 | Object.keys(counts).sort().forEach(s => { | ||
| 191 | const opt = document.createElement('option'); | ||
| 192 | opt.value = s; | ||
| 193 | opt.textContent = `${zh(SUBTYPE_ZH, s)}(${counts[s]})`; | ||
| 194 | sel.appendChild(opt); | ||
| 195 | }); | ||
| 196 | // 尽量保持之前选中的子类,若不存在则重置为 all | ||
| 197 | sel.value = counts[prevValue] ? prevValue : 'all'; | ||
| 198 | } | ||
| 199 | |||
| 200 | function applyFilter() { | ||
| 201 | const subtype = document.getElementById('filterSubtype').value; | ||
| 202 | const term = document.getElementById('searchInput').value.trim().toLowerCase(); | ||
| 203 | filtered = allSamples.filter(s => { | ||
| 204 | if (activeClass !== 'all' && s.sampling_class !== activeClass) return false; | ||
| 205 | if (subtype !== 'all' && s.sampling_subtype !== subtype) return false; | ||
| 206 | if (term) { | ||
| 207 | const hay = [s.sample_id, s.query_name, s.query_singer, s.candidate_name, s.candidate_singer, s.query_source_song_id, s.candidate_song_id].join(' ').toLowerCase(); | ||
| 208 | if (!hay.includes(term)) return false; | ||
| 209 | } | ||
| 210 | return true; | ||
| 211 | }); | ||
| 212 | currentPage = 1; | ||
| 213 | renderList(); | ||
| 214 | } | ||
| 215 | |||
| 216 | function renderList() { | ||
| 217 | const start = (currentPage - 1) * PAGE_SIZE; | ||
| 218 | const pageItems = filtered.slice(start, start + PAGE_SIZE); | ||
| 219 | const container = document.getElementById('sampleList'); | ||
| 220 | container.innerHTML = pageItems.map(s => { | ||
| 221 | const cls = s.sampling_class || ''; | ||
| 222 | const sub = s.sampling_subtype || ''; | ||
| 223 | const styleCls = CLASS_STYLE[cls] || ''; | ||
| 224 | return `<div class="sample-card ${activeId === s.sample_id ? 'active' : ''}" id="card-${s.sample_id}"> | ||
| 225 | <div class="sample-header" onclick="toggleCard('${s.sample_id}')"> | ||
| 226 | <span class="sample-id">${s.sample_id}</span> | ||
| 227 | <span class="sample-title">${esc(s.query_name)} ↔ ${esc(s.candidate_name)}</span> | ||
| 228 | ${cls ? `<span class="sample-badge ${styleCls}">${zh(CLASS_ZH, cls)}</span>` : ''} | ||
| 229 | ${sub ? `<span class="sample-badge">${zh(SUBTYPE_ZH, sub)}</span>` : ''} | ||
| 230 | </div> | ||
| 231 | <div class="sample-body" id="body-${s.sample_id}"></div> | ||
| 232 | </div>`; | ||
| 233 | }).join(''); | ||
| 234 | renderPagination(); | ||
| 235 | } | ||
| 236 | |||
| 237 | function renderPagination() { | ||
| 238 | const totalPages = Math.ceil(filtered.length / PAGE_SIZE) || 1; | ||
| 239 | document.getElementById('pagination').innerHTML = ` | ||
| 240 | <button onclick="goPage(${currentPage - 1})" ${currentPage <= 1 ? 'disabled' : ''}>上一页</button> | ||
| 241 | <span>第 ${currentPage} / ${totalPages} 页(共 ${filtered.length} 条)</span> | ||
| 242 | <button onclick="goPage(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled' : ''}>下一页</button>`; | ||
| 243 | } | ||
| 244 | function goPage(p) { const totalPages = Math.ceil(filtered.length / PAGE_SIZE) || 1; if (p < 1 || p > totalPages) return; currentPage = p; activeId = null; renderList(); window.scrollTo(0, 0); } | ||
| 245 | |||
| 246 | function toggleCard(id) { | ||
| 247 | if (activeId === id) { activeId = null; renderList(); return; } | ||
| 248 | activeId = id; | ||
| 249 | renderList(); | ||
| 250 | const body = document.getElementById('body-' + id); | ||
| 251 | const s = allSamples.find(x => x.sample_id === id); | ||
| 252 | if (!s || !body) return; | ||
| 253 | body.innerHTML = renderDetail(s); | ||
| 254 | loadLyrics(s, 'query'); loadLyrics(s, 'candidate'); | ||
| 255 | } | ||
| 256 | |||
| 257 | function renderDetail(s) { | ||
| 258 | return ` | ||
| 259 | <div class="pair-grid"> | ||
| 260 | <div class="pair-col"> | ||
| 261 | <h4>查询样本 (Query)</h4> | ||
| 262 | <div class="meta"><b>ID:</b> ${esc(s.query_source_song_id)}</div> | ||
| 263 | <div class="meta"><b>歌名:</b> ${esc(s.query_name)}</div> | ||
| 264 | <div class="meta"><b>歌手:</b> ${esc(s.query_singer || '-')}</div> | ||
| 265 | <div class="meta"><b>词:</b> ${esc(s.query_lyricist || '-')}</div> | ||
| 266 | <div class="meta"><b>曲:</b> ${esc(s.query_composer || '-')}</div> | ||
| 267 | <div class="meta"><b>歌词来源:</b> ${s.query_lyrics_source_type}</div> | ||
| 268 | <div class="lyrics-box" id="lyrics-query-${s.sample_id}">加载中...</div> | ||
| 269 | </div> | ||
| 270 | <div class="pair-col"> | ||
| 271 | <h4>候选样本 (Candidate)</h4> | ||
| 272 | <div class="meta"><b>ID:</b> ${esc(s.candidate_song_id)}</div> | ||
| 273 | <div class="meta"><b>歌名:</b> ${esc(s.candidate_name)}</div> | ||
| 274 | <div class="meta"><b>歌手:</b> ${esc(s.candidate_singer || '-')}</div> | ||
| 275 | <div class="meta"><b>词:</b> ${esc(s.candidate_lyricist || '-')}</div> | ||
| 276 | <div class="meta"><b>曲:</b> ${esc(s.candidate_composer || '-')}</div> | ||
| 277 | <div class="meta"><b>歌词来源:</b> ${s.candidate_lyrics_source_type}</div> | ||
| 278 | <div class="lyrics-box" id="lyrics-candidate-${s.sample_id}">加载中...</div> | ||
| 279 | </div> | ||
| 280 | </div> | ||
| 281 | <div class="info-section"> | ||
| 282 | <div class="row"><b>抽样主类:</b> ${zh(CLASS_ZH, s.sampling_class)}(${s.sampling_class || '-'})</div> | ||
| 283 | <div class="row"><b>抽样子类:</b> ${zh(SUBTYPE_ZH, s.sampling_subtype)}(${s.sampling_subtype || '-'})</div> | ||
| 284 | <div class="row"><b>配对来源:</b> ${s.legacy_pair_source || '-'}</div> | ||
| 285 | <div class="row"><b>旧系统决策:</b> ${s.legacy_dedup_decision || '-'}(置信度 ${s.legacy_dedup_confidence || '-'})</div> | ||
| 286 | ${s.legacy_dedup_reason ? `<div class="row"><b>旧系统理由:</b> ${esc(s.legacy_dedup_reason)}</div>` : ''} | ||
| 287 | </div> | ||
| 288 | <div class="form-actions"> | ||
| 289 | <button class="btn-nav" onclick="navSample(-1)">← 上一条</button> | ||
| 290 | <button class="btn-nav" onclick="navSample(1)">下一条 →</button> | ||
| 291 | </div>`; | ||
| 292 | } | ||
| 293 | |||
| 294 | async function loadLyrics(s, role) { | ||
| 295 | const el = document.getElementById(`lyrics-${role}-${s.sample_id}`); | ||
| 296 | if (!el) return; | ||
| 297 | const sourceType = s[`${role}_lyrics_source_type`]; | ||
| 298 | const value = s[`${role}_lyrics_url_or_text`] || ''; | ||
| 299 | if (sourceType === 'inline') { | ||
| 300 | el.textContent = value || '(无歌词)'; | ||
| 301 | } else if (sourceType === 'url' && value) { | ||
| 302 | try { | ||
| 303 | const res = await fetch('/api/text?path=' + encodeURIComponent(value)); | ||
| 304 | const data = await res.json(); | ||
| 305 | el.textContent = data.text || '(空)'; | ||
| 306 | } catch { el.textContent = '(加载失败: ' + value + ')'; } | ||
| 307 | } else { | ||
| 308 | el.textContent = '(无歌词)'; | ||
| 309 | } | ||
| 310 | } | ||
| 311 | |||
| 312 | function navSample(dir) { | ||
| 313 | const idx = filtered.findIndex(s => s.sample_id === activeId); | ||
| 314 | if (idx < 0) return; | ||
| 315 | const next = idx + dir; | ||
| 316 | if (next < 0 || next >= filtered.length) { toast(dir > 0 ? '已是最后一条' : '已是第一条'); return; } | ||
| 317 | const targetPage = Math.floor(next / PAGE_SIZE) + 1; | ||
| 318 | if (targetPage !== currentPage) { currentPage = targetPage; } | ||
| 319 | activeId = filtered[next].sample_id; | ||
| 320 | renderList(); | ||
| 321 | const body = document.getElementById('body-' + activeId); | ||
| 322 | const s = allSamples.find(x => x.sample_id === activeId); | ||
| 323 | if (s && body) { body.innerHTML = renderDetail(s); loadLyrics(s, 'query'); loadLyrics(s, 'candidate'); } | ||
| 324 | document.getElementById('card-' + activeId)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); | ||
| 325 | } | ||
| 326 | |||
| 327 | function esc(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); } | ||
| 328 | function toast(msg) { const el = document.getElementById('toast'); el.textContent = msg; el.classList.add('show'); setTimeout(() => el.classList.remove('show'), 2000); } | ||
| 329 | |||
| 330 | document.getElementById('searchInput').addEventListener('keydown', e => { if (e.key === 'Enter') applyFilter(); }); | ||
| 331 | document.getElementById('filterSubtype').addEventListener('change', applyFilter); | ||
| 332 | init(); | ||
| 333 | </script> | ||
| 334 | </body> | ||
| 335 | </html> |
datasets/lyric_reuse_review_v1_GUIDE.md
0 → 100644
| 1 | # 歌词复用测试集 v1 审核说明 | ||
| 2 | |||
| 3 | ## 文件 | ||
| 4 | |||
| 5 | - `lyric_reuse_review_v1.csv`:320 对盲审样本,实际审核使用这个文件。 | ||
| 6 | - `lyric_reuse_review_v1_manifest.csv`:抽样分层和旧系统弱标签。完成盲审前不要打开,避免旧结论影响判断。 | ||
| 7 | - `lyric_reuse_review_v1_stats.json`:构建参数、候选池规模和分层数量。 | ||
| 8 | |||
| 9 | 盲审表已经随机打散,不包含抽样分层、旧去重结论或旧置信度。 | ||
| 10 | |||
| 11 | ## 歌词字段 | ||
| 12 | |||
| 13 | `query_lyrics_url_or_text` 和 `candidate_lyrics_url_or_text` 根据对应的 `*_lyrics_source_type` 解释: | ||
| 14 | |||
| 15 | - `inline`:字段内容本身就是歌词原文。 | ||
| 16 | - `url`:字段是歌词地址,需要访问地址查看原文。 | ||
| 17 | |||
| 18 | ## 审核顺序 | ||
| 19 | |||
| 20 | 先判断内容关系,再看标题、歌手和词曲作者判断作品关系,最后给出业务政策标签。不要因为标题或作者相同就改变内容关系标签。 | ||
| 21 | |||
| 22 | ### 1. review_content_relation | ||
| 23 | |||
| 24 | 只判断歌词内容事实,填写以下一个值: | ||
| 25 | |||
| 26 | - `near_identical`:主体歌词基本一致,差异主要是格式、重复次数或极少改动。 | ||
| 27 | - `substantial_local_reuse`:存在较长或多段实质复用,但整首并非基本一致。 | ||
| 28 | - `limited_fragment`:存在可确认的小段复用,覆盖范围有限。 | ||
| 29 | - `weak_common_or_translation_only`:只有常见短句、副歌套话或翻译层面的弱重合。 | ||
| 30 | - `no_meaningful_reuse`:没有值得关注的歌词复用。 | ||
| 31 | - `cannot_judge`:歌词缺失、语言或文本质量导致无法判断。 | ||
| 32 | |||
| 33 | ### 2. review_metadata_relation | ||
| 34 | |||
| 35 | 结合标题、歌手和词曲作者填写: | ||
| 36 | |||
| 37 | - `same_work_same_version` | ||
| 38 | - `same_work_different_version` | ||
| 39 | - `different_work` | ||
| 40 | - `unknown` | ||
| 41 | |||
| 42 | 这里判断的是作品/版本关系,不代表已经确认授权。 | ||
| 43 | |||
| 44 | ### 3. review_policy_label | ||
| 45 | |||
| 46 | 填写建议的业务队列: | ||
| 47 | |||
| 48 | - `suspected_wash_reuse`:内容存在实质复用,且元数据不支持合理的同作品/版本关系。 | ||
| 49 | - `duplicate_or_same_record`:同一作品且歌词基本重复,适合按重复记录处理。 | ||
| 50 | - `version_or_rights_review`:可能是翻唱、Remix、同词不同曲、公共文本或其他版本/权利问题。 | ||
| 51 | - `no_reuse_action`:不需要因歌词复用采取动作。 | ||
| 52 | - `insufficient_evidence`:证据不足,暂时无法分流。 | ||
| 53 | |||
| 54 | 政策标签不是侵权裁定;授权状态仍需版权信息确认。 | ||
| 55 | |||
| 56 | ### 4. review_confidence | ||
| 57 | |||
| 58 | - `high` | ||
| 59 | - `medium` | ||
| 60 | - `low` | ||
| 61 | |||
| 62 | ### 5. review_notes | ||
| 63 | |||
| 64 | 自由填写关键依据,例如: | ||
| 65 | |||
| 66 | - 复用发生在哪一段 | ||
| 67 | - 仅制作名单相同 | ||
| 68 | - 仅译文相同 | ||
| 69 | - 同标题但词作者不同 | ||
| 70 | - 疑似古诗词或公共文本 | ||
| 71 | - URL 无法访问 | ||
| 72 | |||
| 73 | ## 建议审核方式 | ||
| 74 | |||
| 75 | - 第一轮先审核全部样本,不查看 manifest。 | ||
| 76 | - 对 `cannot_judge`、`unknown` 和低置信度记录进行第二轮复核。 | ||
| 77 | - 完成后保留原始 `sample_id`,不要重新排序或删除行。 | ||
| 78 | - 将填好的盲审 CSV 返回后,再结合 manifest 计算各分层召回率、误报率和政策分流结果。 |
datasets/lyric_reuse_review_v1_stats.json
0 → 100644
| 1 | { | ||
| 2 | "generated_at": "2026-07-22T01:57:04.122612+00:00", | ||
| 3 | "source_csv": "hk_songs_import_staging.csv", | ||
| 4 | "seed": "lyric-review-v1-20260722", | ||
| 5 | "sample_size_per_stratum": 40, | ||
| 6 | "total_pairs": 320, | ||
| 7 | "stratum_counts": { | ||
| 8 | "chorus_overlap": 40, | ||
| 9 | "exact_or_near_duplicate": 40, | ||
| 10 | "fragment_reuse": 40, | ||
| 11 | "general_borderline": 40, | ||
| 12 | "negative_control": 40, | ||
| 13 | "suspected_wash": 40, | ||
| 14 | "translation_overlap": 40, | ||
| 15 | "version_or_same_work": 40 | ||
| 16 | }, | ||
| 17 | "control_counts": { | ||
| 18 | "random_different_songs": 20, | ||
| 19 | "same_artist_different_title": 20 | ||
| 20 | }, | ||
| 21 | "source_pool_counts": { | ||
| 22 | "chorus_overlap": 248, | ||
| 23 | "exact_or_near_duplicate": 301, | ||
| 24 | "fragment_reuse": 123, | ||
| 25 | "general_borderline": 618, | ||
| 26 | "suspected_wash": 72, | ||
| 27 | "translation_overlap": 114, | ||
| 28 | "version_or_same_work": 601 | ||
| 29 | }, | ||
| 30 | "streaming": { | ||
| 31 | "first_pass_rows": 120873, | ||
| 32 | "second_pass_rows": 120873, | ||
| 33 | "candidate_ids_requested": 381, | ||
| 34 | "candidate_rows_found": 381 | ||
| 35 | }, | ||
| 36 | "outputs": { | ||
| 37 | "blind_review_csv": "datasets/lyric_reuse_review_v1.csv", | ||
| 38 | "sampling_manifest_csv": "datasets/lyric_reuse_review_v1_manifest.csv" | ||
| 39 | } | ||
| 40 | } |
datasets/lyric_reuse_review_v2_GUIDE.md
0 → 100644
| 1 | # 歌词 duplicate / review / new 三分类测试集 v2 | ||
| 2 | |||
| 3 | ## 测试集定位 | ||
| 4 | |||
| 5 | `lyric_reuse_review_v2.csv` 共 300 对,按原系统结果分层抽样: | ||
| 6 | |||
| 7 | - `duplicate`:100 | ||
| 8 | - `review`:100 | ||
| 9 | - `new`:100 | ||
| 10 | |||
| 11 | 表中的 `sampling_class` 和 `legacy_dedup_decision` 是抽样依据及原系统结果,不是人工真值。请在 `review_final_class` 中填写你的独立判断。 | ||
| 12 | |||
| 13 | ## 需要特别注意的字段 | ||
| 14 | |||
| 15 | - `sampling_class`:本次三分类抽样主类。 | ||
| 16 | - `sampling_subtype`:类内细分场景。 | ||
| 17 | - `legacy_pair_source`:旧系统是否实际比较过这一对。 | ||
| 18 | - `legacy_dedup_decision`:原系统的 `new/duplicate/review` 结果。 | ||
| 19 | - `legacy_dedup_confidence`、`legacy_dedup_reason`:原系统依据。 | ||
| 20 | |||
| 21 | `duplicate` 和 `review` 都来自原系统真实的 `matched_song_id` 对,`legacy_pair_source=existing_matched_pair`。 | ||
| 22 | |||
| 23 | `new` 类由于原 CSV 没有保存被判 new 时的候选对,因此由两条原系统分别判为 new 的歌曲构造: | ||
| 24 | |||
| 25 | ```text | ||
| 26 | legacy_pair_source=synthetic_pair_from_two_legacy_new_records | ||
| 27 | ``` | ||
| 28 | |||
| 29 | 这表示两首歌各自的原结果是 new,不表示旧系统曾直接比较过这两个对象。 | ||
| 30 | |||
| 31 | ## 类内细分 | ||
| 32 | |||
| 33 | ### duplicate:100 | ||
| 34 | |||
| 35 | - `exact_hash`:40 | ||
| 36 | - `high_literal_overlap`:54 | ||
| 37 | - `high_primary_translation_ignored`:6(候选池仅有 6 条,全部纳入) | ||
| 38 | |||
| 39 | ### review:100 | ||
| 40 | |||
| 41 | - `suspected_wash`:17 | ||
| 42 | - `version_or_same_work`:17 | ||
| 43 | - `fragment_reuse`:17 | ||
| 44 | - `chorus_overlap`:17 | ||
| 45 | - `translation_overlap`:16 | ||
| 46 | - `general_borderline`:16 | ||
| 47 | |||
| 48 | ### new:100 | ||
| 49 | |||
| 50 | - `same_artist_different_title`:25 | ||
| 51 | - `same_lyricist_different_title`:25 | ||
| 52 | - `same_composer_different_title`:25 | ||
| 53 | - `random_different_songs`:25 | ||
| 54 | |||
| 55 | ## 歌词字段 | ||
| 56 | |||
| 57 | 根据对应 `*_lyrics_source_type` 读取: | ||
| 58 | |||
| 59 | - `inline`:`*_lyrics_url_or_text` 本身就是歌词。 | ||
| 60 | - `url`:`*_lyrics_url_or_text` 是歌词地址。 | ||
| 61 | |||
| 62 | ## 审核字段 | ||
| 63 | |||
| 64 | ### review_final_class | ||
| 65 | |||
| 66 | 这是三分类评估的主要人工标签: | ||
| 67 | |||
| 68 | - `duplicate`:主体歌词足以作为目录重复或近重复处理。 | ||
| 69 | - `review`:存在局部复用、版本、翻唱、翻译、公共文本或权利不确定性,不能自动判 duplicate/new。 | ||
| 70 | - `new`:没有足以进入 duplicate 或 review 的歌词复用信号。 | ||
| 71 | - `cannot_judge`:歌词缺失、无法访问或文本质量不足。 | ||
| 72 | |||
| 73 | ### review_content_relation | ||
| 74 | |||
| 75 | - `near_identical` | ||
| 76 | - `substantial_local_reuse` | ||
| 77 | - `limited_fragment` | ||
| 78 | - `weak_common_or_translation_only` | ||
| 79 | - `no_meaningful_reuse` | ||
| 80 | - `cannot_judge` | ||
| 81 | |||
| 82 | ### review_metadata_relation | ||
| 83 | |||
| 84 | - `same_work_same_version` | ||
| 85 | - `same_work_different_version` | ||
| 86 | - `different_work` | ||
| 87 | - `unknown` | ||
| 88 | |||
| 89 | ### review_policy_label | ||
| 90 | |||
| 91 | - `suspected_wash_reuse` | ||
| 92 | - `duplicate_or_same_record` | ||
| 93 | - `version_or_rights_review` | ||
| 94 | - `no_reuse_action` | ||
| 95 | - `insufficient_evidence` | ||
| 96 | |||
| 97 | ### review_confidence | ||
| 98 | |||
| 99 | - `high` | ||
| 100 | - `medium` | ||
| 101 | - `low` | ||
| 102 | |||
| 103 | `review_notes` 可记录复用段落、公共文本、制作名单误报、URL 异常或判断依据。 | ||
| 104 | |||
| 105 | ## 审核建议 | ||
| 106 | |||
| 107 | - 即使能看到旧结果,也请先阅读双方歌词,再填写人工标签。 | ||
| 108 | - `sampling_class` 与人工判断不一致是测试集最有价值的部分,不需要迁就旧系统。 | ||
| 109 | - 不要删除行或修改 `sample_id`。 | ||
| 110 | - 完成后返回原 CSV,即可计算三分类混淆矩阵和各 subtype 指标。 |
datasets/lyric_reuse_review_v2_stats.json
0 → 100644
| 1 | { | ||
| 2 | "generated_at": "2026-07-22T02:32:59.642717+00:00", | ||
| 3 | "source_csv": "hk_songs_import_staging.csv", | ||
| 4 | "seed": "lyric-three-way-v2-20260722", | ||
| 5 | "total_pairs": 300, | ||
| 6 | "class_counts": { | ||
| 7 | "duplicate": 100, | ||
| 8 | "new": 100, | ||
| 9 | "review": 100 | ||
| 10 | }, | ||
| 11 | "subtype_counts": { | ||
| 12 | "chorus_overlap": 17, | ||
| 13 | "exact_hash": 40, | ||
| 14 | "fragment_reuse": 17, | ||
| 15 | "general_borderline": 16, | ||
| 16 | "high_literal_overlap": 54, | ||
| 17 | "high_primary_translation_ignored": 6, | ||
| 18 | "random_different_songs": 25, | ||
| 19 | "same_artist_different_title": 25, | ||
| 20 | "same_composer_different_title": 25, | ||
| 21 | "same_lyricist_different_title": 25, | ||
| 22 | "suspected_wash": 17, | ||
| 23 | "translation_overlap": 16, | ||
| 24 | "version_or_same_work": 17 | ||
| 25 | }, | ||
| 26 | "legacy_decision_counts": { | ||
| 27 | "duplicate": 100, | ||
| 28 | "new": 100, | ||
| 29 | "review": 100 | ||
| 30 | }, | ||
| 31 | "source_pool_counts": { | ||
| 32 | "chorus_overlap": 248, | ||
| 33 | "exact_hash": 69, | ||
| 34 | "fragment_reuse": 123, | ||
| 35 | "general_borderline": 618, | ||
| 36 | "high_literal_overlap": 226, | ||
| 37 | "high_primary_translation_ignored": 6, | ||
| 38 | "suspected_wash": 72, | ||
| 39 | "translation_overlap": 114, | ||
| 40 | "version_or_same_work": 601 | ||
| 41 | }, | ||
| 42 | "streaming": { | ||
| 43 | "first_pass_rows": 120873, | ||
| 44 | "second_pass_rows": 120873, | ||
| 45 | "candidate_ids_requested": 332, | ||
| 46 | "candidate_rows_found": 332 | ||
| 47 | }, | ||
| 48 | "output": "datasets/lyric_reuse_review_v2.csv" | ||
| 49 | } |
| ... | @@ -29,6 +29,8 @@ from dotenv import load_dotenv | ... | @@ -29,6 +29,8 @@ from dotenv import load_dotenv |
| 29 | ROOT = Path(__file__).resolve().parent | 29 | ROOT = Path(__file__).resolve().parent |
| 30 | REPORT_DIR = ROOT / "output" / "reports" | 30 | REPORT_DIR = ROOT / "output" / "reports" |
| 31 | DASHBOARD = ROOT / "l2_review_dashboard.html" | 31 | DASHBOARD = ROOT / "l2_review_dashboard.html" |
| 32 | DATASET_REVIEW_PAGE = ROOT / "dataset_review.html" | ||
| 33 | DATASETS_DIR = ROOT / "datasets" | ||
| 32 | REPORT_DIR.mkdir(parents=True, exist_ok=True) | 34 | REPORT_DIR.mkdir(parents=True, exist_ok=True) |
| 33 | GROUP_INDEX_CACHE: dict[tuple[str, int, int, str], list[dict[str, object]]] = {} | 35 | GROUP_INDEX_CACHE: dict[tuple[str, int, int, str], list[dict[str, object]]] = {} |
| 34 | load_dotenv(ROOT / ".env") | 36 | load_dotenv(ROOT / ".env") |
| ... | @@ -1412,6 +1414,21 @@ class Handler(BaseHTTPRequestHandler): | ... | @@ -1412,6 +1414,21 @@ class Handler(BaseHTTPRequestHandler): |
| 1412 | except Exception as exc: # noqa: BLE001 - local diagnostic API | 1414 | except Exception as exc: # noqa: BLE001 - local diagnostic API |
| 1413 | _error(self, str(exc), status=404) | 1415 | _error(self, str(exc), status=404) |
| 1414 | return | 1416 | return |
| 1417 | if parsed.path == "/dataset-review": | ||
| 1418 | self._serve_file(DATASET_REVIEW_PAGE) | ||
| 1419 | return | ||
| 1420 | if parsed.path == "/api/dataset-review/samples": | ||
| 1421 | self._dataset_review_samples() | ||
| 1422 | return | ||
| 1423 | if parsed.path == "/api/dataset-review/manifest": | ||
| 1424 | self._dataset_review_manifest() | ||
| 1425 | return | ||
| 1426 | if parsed.path == "/api/dataset-review/stats": | ||
| 1427 | self._dataset_review_stats() | ||
| 1428 | return | ||
| 1429 | if parsed.path == "/api/dataset-review/guide": | ||
| 1430 | self._dataset_review_guide() | ||
| 1431 | return | ||
| 1415 | if parsed.path.startswith("/static/"): | 1432 | if parsed.path.startswith("/static/"): |
| 1416 | self._serve_file(ROOT / parsed.path.lstrip("/")) | 1433 | self._serve_file(ROOT / parsed.path.lstrip("/")) |
| 1417 | return | 1434 | return |
| ... | @@ -1538,6 +1555,50 @@ class Handler(BaseHTTPRequestHandler): | ... | @@ -1538,6 +1555,50 @@ class Handler(BaseHTTPRequestHandler): |
| 1538 | except Exception as exc: # noqa: BLE001 - local diagnostic API | 1555 | except Exception as exc: # noqa: BLE001 - local diagnostic API |
| 1539 | _error(self, str(exc), status=400) | 1556 | _error(self, str(exc), status=400) |
| 1540 | 1557 | ||
| 1558 | def _dataset_review_samples(self) -> None: | ||
| 1559 | csv_path = DATASETS_DIR / "lyric_reuse_review_v2.csv" | ||
| 1560 | if not csv_path.exists(): | ||
| 1561 | _error(self, "测试集文件不存在: lyric_reuse_review_v2.csv", status=404) | ||
| 1562 | return | ||
| 1563 | try: | ||
| 1564 | samples = _read_csv(csv_path) | ||
| 1565 | _json_response(self, {"total": len(samples), "samples": samples}) | ||
| 1566 | except Exception as exc: # noqa: BLE001 | ||
| 1567 | _error(self, str(exc), status=500) | ||
| 1568 | |||
| 1569 | def _dataset_review_manifest(self) -> None: | ||
| 1570 | csv_path = DATASETS_DIR / "lyric_reuse_review_v2_manifest.csv" | ||
| 1571 | if not csv_path.exists(): | ||
| 1572 | _json_response(self, {"rows": []}) | ||
| 1573 | return | ||
| 1574 | try: | ||
| 1575 | rows = _read_csv(csv_path) | ||
| 1576 | _json_response(self, {"total": len(rows), "rows": rows}) | ||
| 1577 | except Exception as exc: # noqa: BLE001 | ||
| 1578 | _error(self, str(exc), status=500) | ||
| 1579 | |||
| 1580 | def _dataset_review_stats(self) -> None: | ||
| 1581 | stats_path = DATASETS_DIR / "lyric_reuse_review_v2_stats.json" | ||
| 1582 | if not stats_path.exists(): | ||
| 1583 | _json_response(self, {}) | ||
| 1584 | return | ||
| 1585 | try: | ||
| 1586 | data = json.loads(stats_path.read_text(encoding="utf-8")) | ||
| 1587 | _json_response(self, data) | ||
| 1588 | except Exception as exc: # noqa: BLE001 | ||
| 1589 | _error(self, str(exc), status=500) | ||
| 1590 | |||
| 1591 | def _dataset_review_guide(self) -> None: | ||
| 1592 | guide_path = DATASETS_DIR / "lyric_reuse_review_v2_GUIDE.md" | ||
| 1593 | if not guide_path.exists(): | ||
| 1594 | _json_response(self, {"text": ""}) | ||
| 1595 | return | ||
| 1596 | try: | ||
| 1597 | text = guide_path.read_text(encoding="utf-8") | ||
| 1598 | _json_response(self, {"text": text}) | ||
| 1599 | except Exception as exc: # noqa: BLE001 | ||
| 1600 | _error(self, str(exc), status=500) | ||
| 1601 | |||
| 1541 | def _serve_file(self, path: Path) -> None: | 1602 | def _serve_file(self, path: Path) -> None: |
| 1542 | if not path.exists() or not path.is_file(): | 1603 | if not path.exists() or not path.is_file(): |
| 1543 | _error(self, "not found", status=404) | 1604 | _error(self, "not found", status=404) |
| ... | @@ -1567,6 +1628,7 @@ def main() -> None: | ... | @@ -1567,6 +1628,7 @@ def main() -> None: |
| 1567 | _ensure_review_schema(apply_migration=args.migrate_review_schema) | 1628 | _ensure_review_schema(apply_migration=args.migrate_review_schema) |
| 1568 | server = ThreadingHTTPServer((args.host, args.port), Handler) | 1629 | server = ThreadingHTTPServer((args.host, args.port), Handler) |
| 1569 | print(f"L2 dashboard: http://{args.host}:{args.port}") | 1630 | print(f"L2 dashboard: http://{args.host}:{args.port}") |
| 1631 | print(f"Dataset review: http://{args.host}:{args.port}/dataset-review") | ||
| 1570 | server.serve_forever() | 1632 | server.serve_forever() |
| 1571 | 1633 | ||
| 1572 | 1634 | ... | ... |
-
Please register or sign in to post a comment