Commit 4f7a9366 4f7a93667fdbbcf19c8c9db4260719617d077f5c by 沈秋雨

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

1 parent a52c778d
......@@ -22,4 +22,4 @@ output/
*.sql
*.xlsx
docs/
docs/
\ No newline at end of file
......
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>歌词三分类测试集查看</title>
<style>
:root {
--bg: #f5f7fa; --card: #fff; --border: #e2e8f0;
--primary: #3b82f6; --primary-hover: #2563eb;
--success: #10b981; --warning: #f59e0b; --danger: #ef4444;
--text: #1e293b; --text-secondary: #64748b;
--radius: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; }
.container { max-width: 1400px; margin: 0 auto; padding: 16px; }
h1 { font-size: 1.4rem; margin-bottom: 12px; }
.stats-bar { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
.stat-card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 16px; min-width: 120px; text-align: center; }
.stat-card .num { font-size: 1.5rem; font-weight: 700; color: var(--primary); }
.stat-card .label { font-size: 0.8rem; color: var(--text-secondary); }
.class-stats { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
.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; }
.class-chip:hover { border-color: var(--primary); }
.class-chip.active { background: var(--primary); color: #fff; border-color: var(--primary); }
.class-chip .count { font-weight: 700; margin-left: 4px; }
.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); }
.toolbar select, .toolbar input, .toolbar button { font-size: 0.85rem; padding: 6px 10px; border-radius: 4px; border: 1px solid var(--border); }
.toolbar button { background: var(--primary); color: #fff; border: none; cursor: pointer; font-weight: 500; }
.toolbar button:hover { background: var(--primary-hover); }
.sample-list { display: flex; flex-direction: column; gap: 12px; }
.sample-card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.sample-card.active { border-color: var(--primary); box-shadow: 0 0 0 2px rgba(59,130,246,0.15); }
.sample-header { display: flex; align-items: center; gap: 12px; padding: 10px 16px; cursor: pointer; user-select: none; }
.sample-header:hover { background: #f8fafc; }
.sample-id { font-weight: 700; font-size: 0.85rem; color: var(--primary); min-width: 70px; }
.sample-title { flex: 1; font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sample-badge { font-size: 0.7rem; padding: 2px 8px; border-radius: 10px; background: #e2e8f0; color: var(--text-secondary); }
.sample-badge.class-duplicate { background: #fee2e2; color: #991b1b; }
.sample-badge.class-review { background: #fef3c7; color: #92400e; }
.sample-badge.class-new { background: #d1fae5; color: #065f46; }
.sample-body { display: none; padding: 16px; border-top: 1px solid var(--border); }
.sample-card.active .sample-body { display: block; }
.pair-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; }
@media (max-width: 900px) { .pair-grid { grid-template-columns: 1fr; } }
.pair-col { border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; }
.pair-col h4 { font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 8px; text-transform: uppercase; }
.pair-col .meta { font-size: 0.85rem; margin-bottom: 4px; }
.pair-col .meta b { color: var(--text-secondary); font-weight: 500; }
.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; }
.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; }
.info-section .row { margin-bottom: 2px; }
.info-section b { color: var(--text); }
.form-actions { margin-top: 12px; display: flex; gap: 8px; }
.form-actions button { padding: 8px 20px; font-size: 0.85rem; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; }
.btn-nav { background: #e2e8f0; color: var(--text); }
.btn-nav:hover { background: #cbd5e1; }
.pagination { display: flex; align-items: center; gap: 8px; justify-content: center; margin-top: 16px; }
.pagination button { padding: 6px 14px; border: 1px solid var(--border); background: var(--card); border-radius: 4px; cursor: pointer; }
.pagination button:disabled { opacity: 0.4; cursor: default; }
.pagination span { font-size: 0.85rem; color: var(--text-secondary); }
.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; }
.toast.show { opacity: 1; }
.guide-toggle { font-size: 0.8rem; color: var(--primary); cursor: pointer; text-decoration: underline; margin-left: 12px; }
.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; }
.guide-panel.show { display: block; }
.guide-panel h1 { font-size: 1rem; margin-bottom: 8px; }
.guide-panel h2 { font-size: 0.9rem; margin: 10px 0 4px; }
.guide-panel h3 { font-size: 0.85rem; margin: 8px 0 4px; }
.guide-panel ul { padding-left: 18px; }
.guide-panel code { background: #f1f5f9; padding: 1px 4px; border-radius: 3px; font-size: 0.8rem; }
</style>
</head>
<body>
<div class="container">
<h1>歌词三分类测试集查看 <span class="guide-toggle" onclick="toggleGuide()">查看说明文档</span></h1>
<div class="guide-panel" id="guidePanel"></div>
<div class="stats-bar" id="statsBar"></div>
<div class="class-stats" id="classStats"></div>
<div class="toolbar">
<select id="filterSubtype"><option value="all">全部子类</option></select>
<input type="text" id="searchInput" placeholder="搜索歌名/ID/歌手..." style="width:220px;">
<button onclick="applyFilter()">筛选</button>
</div>
<div class="sample-list" id="sampleList"></div>
<div class="pagination" id="pagination"></div>
</div>
<div class="toast" id="toast"></div>
<script>
const PAGE_SIZE = 20;
let allSamples = [];
let stats = {};
let filtered = [];
let currentPage = 1;
let activeId = null;
let activeClass = 'all';
const CLASS_ZH = { 'duplicate': '重复', 'review': '待复核', 'new': '新歌' };
const CLASS_STYLE = { 'duplicate': 'class-duplicate', 'review': 'class-review', 'new': 'class-new' };
const SUBTYPE_ZH = {
'exact_hash': '精确哈希',
'high_literal_overlap': '高字面重合',
'high_primary_translation_ignored': '主翻译忽略',
'suspected_wash': '疑似洗盗',
'version_or_same_work': '版本/同作品',
'fragment_reuse': '片段复用',
'chorus_overlap': '副歌重合',
'translation_overlap': '翻译重合',
'general_borderline': '一般边界',
'same_artist_different_title': '同歌手不同歌名',
'same_lyricist_different_title': '同词作者不同歌名',
'same_composer_different_title': '同曲作者不同歌名',
'random_different_songs': '随机不同歌曲',
};
function zh(map, v) { return map[v] || v; }
async function init() {
const [csvRes, statsRes, guideRes] = await Promise.all([
fetch('/api/dataset-review/samples').then(r => r.json()),
fetch('/api/dataset-review/stats').then(r => r.json()),
fetch('/api/dataset-review/guide').then(r => r.json()),
]);
allSamples = csvRes.samples || [];
stats = statsRes;
document.getElementById('guidePanel').innerHTML = renderGuide(guideRes.text || '');
renderStats();
renderClassChips();
buildSubtypeFilter();
applyFilter();
}
function renderGuide(text) {
return text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^```text$/gm, '<code>').replace(/^```$/gm, '</code>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)/gs, '<ul>$1</ul>')
.replace(/\n{2,}/g, '<br><br>').replace(/\n/g, '<br>');
}
function toggleGuide() { document.getElementById('guidePanel').classList.toggle('show'); }
function renderStats() {
const total = stats.total_pairs || allSamples.length;
const subtypes = stats.subtype_counts || {};
const subtypeCount = Object.keys(subtypes).length;
const poolTotal = Object.values(stats.source_pool_counts || {}).reduce((a, b) => a + b, 0);
let html = `<div class="stat-card"><div class="num">${total}</div><div class="label">样本对总数</div></div>`;
html += `<div class="stat-card"><div class="num">${Object.keys(stats.class_counts || {}).length}</div><div class="label">分类数</div></div>`;
html += `<div class="stat-card"><div class="num">${subtypeCount}</div><div class="label">子类数</div></div>`;
html += `<div class="stat-card"><div class="num">${poolTotal || '-'}</div><div class="label">候选池总量</div></div>`;
document.getElementById('statsBar').innerHTML = html;
}
function renderClassChips() {
const counts = stats.class_counts || {};
let html = `<div class="class-chip ${activeClass === 'all' ? 'active' : ''}" onclick="selectClass('all')">全部<span class="count">${allSamples.length}</span></div>`;
Object.keys(counts).sort().forEach(c => {
const style = activeClass === c ? 'active' : '';
html += `<div class="class-chip ${style}" onclick="selectClass('${c}')">${zh(CLASS_ZH, c)}<span class="count">${counts[c]}</span></div>`;
});
document.getElementById('classStats').innerHTML = html;
}
function selectClass(c) {
activeClass = c;
renderClassChips();
rebuildSubtypeFilter();
applyFilter();
}
function buildSubtypeFilter() {
rebuildSubtypeFilter();
}
function rebuildSubtypeFilter() {
const sel = document.getElementById('filterSubtype');
const prevValue = sel.value;
sel.innerHTML = '<option value="all">全部子类</option>';
// 按当前选中主类统计子类数量
const counts = {};
allSamples.forEach(s => {
if (activeClass !== 'all' && s.sampling_class !== activeClass) return;
const sub = s.sampling_subtype;
if (!sub) return;
counts[sub] = (counts[sub] || 0) + 1;
});
Object.keys(counts).sort().forEach(s => {
const opt = document.createElement('option');
opt.value = s;
opt.textContent = `${zh(SUBTYPE_ZH, s)}${counts[s]})`;
sel.appendChild(opt);
});
// 尽量保持之前选中的子类,若不存在则重置为 all
sel.value = counts[prevValue] ? prevValue : 'all';
}
function applyFilter() {
const subtype = document.getElementById('filterSubtype').value;
const term = document.getElementById('searchInput').value.trim().toLowerCase();
filtered = allSamples.filter(s => {
if (activeClass !== 'all' && s.sampling_class !== activeClass) return false;
if (subtype !== 'all' && s.sampling_subtype !== subtype) return false;
if (term) {
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();
if (!hay.includes(term)) return false;
}
return true;
});
currentPage = 1;
renderList();
}
function renderList() {
const start = (currentPage - 1) * PAGE_SIZE;
const pageItems = filtered.slice(start, start + PAGE_SIZE);
const container = document.getElementById('sampleList');
container.innerHTML = pageItems.map(s => {
const cls = s.sampling_class || '';
const sub = s.sampling_subtype || '';
const styleCls = CLASS_STYLE[cls] || '';
return `<div class="sample-card ${activeId === s.sample_id ? 'active' : ''}" id="card-${s.sample_id}">
<div class="sample-header" onclick="toggleCard('${s.sample_id}')">
<span class="sample-id">${s.sample_id}</span>
<span class="sample-title">${esc(s.query_name)}${esc(s.candidate_name)}</span>
${cls ? `<span class="sample-badge ${styleCls}">${zh(CLASS_ZH, cls)}</span>` : ''}
${sub ? `<span class="sample-badge">${zh(SUBTYPE_ZH, sub)}</span>` : ''}
</div>
<div class="sample-body" id="body-${s.sample_id}"></div>
</div>`;
}).join('');
renderPagination();
}
function renderPagination() {
const totalPages = Math.ceil(filtered.length / PAGE_SIZE) || 1;
document.getElementById('pagination').innerHTML = `
<button onclick="goPage(${currentPage - 1})" ${currentPage <= 1 ? 'disabled' : ''}>上一页</button>
<span>第 ${currentPage} / ${totalPages} 页(共 ${filtered.length} 条)</span>
<button onclick="goPage(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled' : ''}>下一页</button>`;
}
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); }
function toggleCard(id) {
if (activeId === id) { activeId = null; renderList(); return; }
activeId = id;
renderList();
const body = document.getElementById('body-' + id);
const s = allSamples.find(x => x.sample_id === id);
if (!s || !body) return;
body.innerHTML = renderDetail(s);
loadLyrics(s, 'query'); loadLyrics(s, 'candidate');
}
function renderDetail(s) {
return `
<div class="pair-grid">
<div class="pair-col">
<h4>查询样本 (Query)</h4>
<div class="meta"><b>ID:</b> ${esc(s.query_source_song_id)}</div>
<div class="meta"><b>歌名:</b> ${esc(s.query_name)}</div>
<div class="meta"><b>歌手:</b> ${esc(s.query_singer || '-')}</div>
<div class="meta"><b>词:</b> ${esc(s.query_lyricist || '-')}</div>
<div class="meta"><b>曲:</b> ${esc(s.query_composer || '-')}</div>
<div class="meta"><b>歌词来源:</b> ${s.query_lyrics_source_type}</div>
<div class="lyrics-box" id="lyrics-query-${s.sample_id}">加载中...</div>
</div>
<div class="pair-col">
<h4>候选样本 (Candidate)</h4>
<div class="meta"><b>ID:</b> ${esc(s.candidate_song_id)}</div>
<div class="meta"><b>歌名:</b> ${esc(s.candidate_name)}</div>
<div class="meta"><b>歌手:</b> ${esc(s.candidate_singer || '-')}</div>
<div class="meta"><b>词:</b> ${esc(s.candidate_lyricist || '-')}</div>
<div class="meta"><b>曲:</b> ${esc(s.candidate_composer || '-')}</div>
<div class="meta"><b>歌词来源:</b> ${s.candidate_lyrics_source_type}</div>
<div class="lyrics-box" id="lyrics-candidate-${s.sample_id}">加载中...</div>
</div>
</div>
<div class="info-section">
<div class="row"><b>抽样主类:</b> ${zh(CLASS_ZH, s.sampling_class)}${s.sampling_class || '-'})</div>
<div class="row"><b>抽样子类:</b> ${zh(SUBTYPE_ZH, s.sampling_subtype)}${s.sampling_subtype || '-'})</div>
<div class="row"><b>配对来源:</b> ${s.legacy_pair_source || '-'}</div>
<div class="row"><b>旧系统决策:</b> ${s.legacy_dedup_decision || '-'}(置信度 ${s.legacy_dedup_confidence || '-'})</div>
${s.legacy_dedup_reason ? `<div class="row"><b>旧系统理由:</b> ${esc(s.legacy_dedup_reason)}</div>` : ''}
</div>
<div class="form-actions">
<button class="btn-nav" onclick="navSample(-1)">← 上一条</button>
<button class="btn-nav" onclick="navSample(1)">下一条 →</button>
</div>`;
}
async function loadLyrics(s, role) {
const el = document.getElementById(`lyrics-${role}-${s.sample_id}`);
if (!el) return;
const sourceType = s[`${role}_lyrics_source_type`];
const value = s[`${role}_lyrics_url_or_text`] || '';
if (sourceType === 'inline') {
el.textContent = value || '(无歌词)';
} else if (sourceType === 'url' && value) {
try {
const res = await fetch('/api/text?path=' + encodeURIComponent(value));
const data = await res.json();
el.textContent = data.text || '(空)';
} catch { el.textContent = '(加载失败: ' + value + ')'; }
} else {
el.textContent = '(无歌词)';
}
}
function navSample(dir) {
const idx = filtered.findIndex(s => s.sample_id === activeId);
if (idx < 0) return;
const next = idx + dir;
if (next < 0 || next >= filtered.length) { toast(dir > 0 ? '已是最后一条' : '已是第一条'); return; }
const targetPage = Math.floor(next / PAGE_SIZE) + 1;
if (targetPage !== currentPage) { currentPage = targetPage; }
activeId = filtered[next].sample_id;
renderList();
const body = document.getElementById('body-' + activeId);
const s = allSamples.find(x => x.sample_id === activeId);
if (s && body) { body.innerHTML = renderDetail(s); loadLyrics(s, 'query'); loadLyrics(s, 'candidate'); }
document.getElementById('card-' + activeId)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function toast(msg) { const el = document.getElementById('toast'); el.textContent = msg; el.classList.add('show'); setTimeout(() => el.classList.remove('show'), 2000); }
document.getElementById('searchInput').addEventListener('keydown', e => { if (e.key === 'Enter') applyFilter(); });
document.getElementById('filterSubtype').addEventListener('change', applyFilter);
init();
</script>
</body>
</html>
# 歌词复用测试集 v1 审核说明
## 文件
- `lyric_reuse_review_v1.csv`:320 对盲审样本,实际审核使用这个文件。
- `lyric_reuse_review_v1_manifest.csv`:抽样分层和旧系统弱标签。完成盲审前不要打开,避免旧结论影响判断。
- `lyric_reuse_review_v1_stats.json`:构建参数、候选池规模和分层数量。
盲审表已经随机打散,不包含抽样分层、旧去重结论或旧置信度。
## 歌词字段
`query_lyrics_url_or_text``candidate_lyrics_url_or_text` 根据对应的 `*_lyrics_source_type` 解释:
- `inline`:字段内容本身就是歌词原文。
- `url`:字段是歌词地址,需要访问地址查看原文。
## 审核顺序
先判断内容关系,再看标题、歌手和词曲作者判断作品关系,最后给出业务政策标签。不要因为标题或作者相同就改变内容关系标签。
### 1. review_content_relation
只判断歌词内容事实,填写以下一个值:
- `near_identical`:主体歌词基本一致,差异主要是格式、重复次数或极少改动。
- `substantial_local_reuse`:存在较长或多段实质复用,但整首并非基本一致。
- `limited_fragment`:存在可确认的小段复用,覆盖范围有限。
- `weak_common_or_translation_only`:只有常见短句、副歌套话或翻译层面的弱重合。
- `no_meaningful_reuse`:没有值得关注的歌词复用。
- `cannot_judge`:歌词缺失、语言或文本质量导致无法判断。
### 2. review_metadata_relation
结合标题、歌手和词曲作者填写:
- `same_work_same_version`
- `same_work_different_version`
- `different_work`
- `unknown`
这里判断的是作品/版本关系,不代表已经确认授权。
### 3. review_policy_label
填写建议的业务队列:
- `suspected_wash_reuse`:内容存在实质复用,且元数据不支持合理的同作品/版本关系。
- `duplicate_or_same_record`:同一作品且歌词基本重复,适合按重复记录处理。
- `version_or_rights_review`:可能是翻唱、Remix、同词不同曲、公共文本或其他版本/权利问题。
- `no_reuse_action`:不需要因歌词复用采取动作。
- `insufficient_evidence`:证据不足,暂时无法分流。
政策标签不是侵权裁定;授权状态仍需版权信息确认。
### 4. review_confidence
- `high`
- `medium`
- `low`
### 5. review_notes
自由填写关键依据,例如:
- 复用发生在哪一段
- 仅制作名单相同
- 仅译文相同
- 同标题但词作者不同
- 疑似古诗词或公共文本
- URL 无法访问
## 建议审核方式
- 第一轮先审核全部样本,不查看 manifest。
-`cannot_judge``unknown` 和低置信度记录进行第二轮复核。
- 完成后保留原始 `sample_id`,不要重新排序或删除行。
- 将填好的盲审 CSV 返回后,再结合 manifest 计算各分层召回率、误报率和政策分流结果。
{
"generated_at": "2026-07-22T01:57:04.122612+00:00",
"source_csv": "hk_songs_import_staging.csv",
"seed": "lyric-review-v1-20260722",
"sample_size_per_stratum": 40,
"total_pairs": 320,
"stratum_counts": {
"chorus_overlap": 40,
"exact_or_near_duplicate": 40,
"fragment_reuse": 40,
"general_borderline": 40,
"negative_control": 40,
"suspected_wash": 40,
"translation_overlap": 40,
"version_or_same_work": 40
},
"control_counts": {
"random_different_songs": 20,
"same_artist_different_title": 20
},
"source_pool_counts": {
"chorus_overlap": 248,
"exact_or_near_duplicate": 301,
"fragment_reuse": 123,
"general_borderline": 618,
"suspected_wash": 72,
"translation_overlap": 114,
"version_or_same_work": 601
},
"streaming": {
"first_pass_rows": 120873,
"second_pass_rows": 120873,
"candidate_ids_requested": 381,
"candidate_rows_found": 381
},
"outputs": {
"blind_review_csv": "datasets/lyric_reuse_review_v1.csv",
"sampling_manifest_csv": "datasets/lyric_reuse_review_v1_manifest.csv"
}
}
# 歌词 duplicate / review / new 三分类测试集 v2
## 测试集定位
`lyric_reuse_review_v2.csv` 共 300 对,按原系统结果分层抽样:
- `duplicate`:100
- `review`:100
- `new`:100
表中的 `sampling_class``legacy_dedup_decision` 是抽样依据及原系统结果,不是人工真值。请在 `review_final_class` 中填写你的独立判断。
## 需要特别注意的字段
- `sampling_class`:本次三分类抽样主类。
- `sampling_subtype`:类内细分场景。
- `legacy_pair_source`:旧系统是否实际比较过这一对。
- `legacy_dedup_decision`:原系统的 `new/duplicate/review` 结果。
- `legacy_dedup_confidence``legacy_dedup_reason`:原系统依据。
`duplicate``review` 都来自原系统真实的 `matched_song_id` 对,`legacy_pair_source=existing_matched_pair`
`new` 类由于原 CSV 没有保存被判 new 时的候选对,因此由两条原系统分别判为 new 的歌曲构造:
```text
legacy_pair_source=synthetic_pair_from_two_legacy_new_records
```
这表示两首歌各自的原结果是 new,不表示旧系统曾直接比较过这两个对象。
## 类内细分
### duplicate:100
- `exact_hash`:40
- `high_literal_overlap`:54
- `high_primary_translation_ignored`:6(候选池仅有 6 条,全部纳入)
### review:100
- `suspected_wash`:17
- `version_or_same_work`:17
- `fragment_reuse`:17
- `chorus_overlap`:17
- `translation_overlap`:16
- `general_borderline`:16
### new:100
- `same_artist_different_title`:25
- `same_lyricist_different_title`:25
- `same_composer_different_title`:25
- `random_different_songs`:25
## 歌词字段
根据对应 `*_lyrics_source_type` 读取:
- `inline``*_lyrics_url_or_text` 本身就是歌词。
- `url``*_lyrics_url_or_text` 是歌词地址。
## 审核字段
### review_final_class
这是三分类评估的主要人工标签:
- `duplicate`:主体歌词足以作为目录重复或近重复处理。
- `review`:存在局部复用、版本、翻唱、翻译、公共文本或权利不确定性,不能自动判 duplicate/new。
- `new`:没有足以进入 duplicate 或 review 的歌词复用信号。
- `cannot_judge`:歌词缺失、无法访问或文本质量不足。
### review_content_relation
- `near_identical`
- `substantial_local_reuse`
- `limited_fragment`
- `weak_common_or_translation_only`
- `no_meaningful_reuse`
- `cannot_judge`
### review_metadata_relation
- `same_work_same_version`
- `same_work_different_version`
- `different_work`
- `unknown`
### review_policy_label
- `suspected_wash_reuse`
- `duplicate_or_same_record`
- `version_or_rights_review`
- `no_reuse_action`
- `insufficient_evidence`
### review_confidence
- `high`
- `medium`
- `low`
`review_notes` 可记录复用段落、公共文本、制作名单误报、URL 异常或判断依据。
## 审核建议
- 即使能看到旧结果,也请先阅读双方歌词,再填写人工标签。
- `sampling_class` 与人工判断不一致是测试集最有价值的部分,不需要迁就旧系统。
- 不要删除行或修改 `sample_id`
- 完成后返回原 CSV,即可计算三分类混淆矩阵和各 subtype 指标。
{
"generated_at": "2026-07-22T02:32:59.642717+00:00",
"source_csv": "hk_songs_import_staging.csv",
"seed": "lyric-three-way-v2-20260722",
"total_pairs": 300,
"class_counts": {
"duplicate": 100,
"new": 100,
"review": 100
},
"subtype_counts": {
"chorus_overlap": 17,
"exact_hash": 40,
"fragment_reuse": 17,
"general_borderline": 16,
"high_literal_overlap": 54,
"high_primary_translation_ignored": 6,
"random_different_songs": 25,
"same_artist_different_title": 25,
"same_composer_different_title": 25,
"same_lyricist_different_title": 25,
"suspected_wash": 17,
"translation_overlap": 16,
"version_or_same_work": 17
},
"legacy_decision_counts": {
"duplicate": 100,
"new": 100,
"review": 100
},
"source_pool_counts": {
"chorus_overlap": 248,
"exact_hash": 69,
"fragment_reuse": 123,
"general_borderline": 618,
"high_literal_overlap": 226,
"high_primary_translation_ignored": 6,
"suspected_wash": 72,
"translation_overlap": 114,
"version_or_same_work": 601
},
"streaming": {
"first_pass_rows": 120873,
"second_pass_rows": 120873,
"candidate_ids_requested": 332,
"candidate_rows_found": 332
},
"output": "datasets/lyric_reuse_review_v2.csv"
}
......@@ -29,6 +29,8 @@ from dotenv import load_dotenv
ROOT = Path(__file__).resolve().parent
REPORT_DIR = ROOT / "output" / "reports"
DASHBOARD = ROOT / "l2_review_dashboard.html"
DATASET_REVIEW_PAGE = ROOT / "dataset_review.html"
DATASETS_DIR = ROOT / "datasets"
REPORT_DIR.mkdir(parents=True, exist_ok=True)
GROUP_INDEX_CACHE: dict[tuple[str, int, int, str], list[dict[str, object]]] = {}
load_dotenv(ROOT / ".env")
......@@ -1412,6 +1414,21 @@ class Handler(BaseHTTPRequestHandler):
except Exception as exc: # noqa: BLE001 - local diagnostic API
_error(self, str(exc), status=404)
return
if parsed.path == "/dataset-review":
self._serve_file(DATASET_REVIEW_PAGE)
return
if parsed.path == "/api/dataset-review/samples":
self._dataset_review_samples()
return
if parsed.path == "/api/dataset-review/manifest":
self._dataset_review_manifest()
return
if parsed.path == "/api/dataset-review/stats":
self._dataset_review_stats()
return
if parsed.path == "/api/dataset-review/guide":
self._dataset_review_guide()
return
if parsed.path.startswith("/static/"):
self._serve_file(ROOT / parsed.path.lstrip("/"))
return
......@@ -1538,6 +1555,50 @@ class Handler(BaseHTTPRequestHandler):
except Exception as exc: # noqa: BLE001 - local diagnostic API
_error(self, str(exc), status=400)
def _dataset_review_samples(self) -> None:
csv_path = DATASETS_DIR / "lyric_reuse_review_v2.csv"
if not csv_path.exists():
_error(self, "测试集文件不存在: lyric_reuse_review_v2.csv", status=404)
return
try:
samples = _read_csv(csv_path)
_json_response(self, {"total": len(samples), "samples": samples})
except Exception as exc: # noqa: BLE001
_error(self, str(exc), status=500)
def _dataset_review_manifest(self) -> None:
csv_path = DATASETS_DIR / "lyric_reuse_review_v2_manifest.csv"
if not csv_path.exists():
_json_response(self, {"rows": []})
return
try:
rows = _read_csv(csv_path)
_json_response(self, {"total": len(rows), "rows": rows})
except Exception as exc: # noqa: BLE001
_error(self, str(exc), status=500)
def _dataset_review_stats(self) -> None:
stats_path = DATASETS_DIR / "lyric_reuse_review_v2_stats.json"
if not stats_path.exists():
_json_response(self, {})
return
try:
data = json.loads(stats_path.read_text(encoding="utf-8"))
_json_response(self, data)
except Exception as exc: # noqa: BLE001
_error(self, str(exc), status=500)
def _dataset_review_guide(self) -> None:
guide_path = DATASETS_DIR / "lyric_reuse_review_v2_GUIDE.md"
if not guide_path.exists():
_json_response(self, {"text": ""})
return
try:
text = guide_path.read_text(encoding="utf-8")
_json_response(self, {"text": text})
except Exception as exc: # noqa: BLE001
_error(self, str(exc), status=500)
def _serve_file(self, path: Path) -> None:
if not path.exists() or not path.is_file():
_error(self, "not found", status=404)
......@@ -1567,6 +1628,7 @@ def main() -> None:
_ensure_review_schema(apply_migration=args.migrate_review_schema)
server = ThreadingHTTPServer((args.host, args.port), Handler)
print(f"L2 dashboard: http://{args.host}:{args.port}")
print(f"Dataset review: http://{args.host}:{args.port}/dataset-review")
server.serve_forever()
......