Commit 4f7a9366 4f7a93667fdbbcf19c8c9db4260719617d077f5c by 沈秋雨

feat(dataset-review): 添加歌词三分类测试集查看页面与接口

1 parent a52c778d
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
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>
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 计算各分层召回率、误报率和政策分流结果。
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 }
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 指标。
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
......