Commit 65615e7b 65615e7ba1bf368e72f2ef89e207a4fec7914fb6 by 沈秋雨

refactor(lyrics-cache): 重构歌词缓存读取写入逻辑并同步新增歌词缓存

- 抽取歌词缓存读取和写入为独立函数 _read_existing_lyrics_cache 与 _write_existing_lyrics_cache
- load_existing_l2_candidates 中改用新缓存读写函数,增加缓存加载日志和进度输出
- 处理下载异常时保证已下载缓存的歌词及时持久化,避免丢失数据
- 新增 sync_inserted_lyrics_cache 函数实现新增歌词缓存同步写入功能
- 后台写入线程写入数据库后调用歌词缓存同步,记录新增条数日志
- 修改批量写入队列数据结构,传递缓存同步相关数据
- 优化去重统计日志显示格式,新增 format_dedup_stats 函数统一格式化
- L2审核界面新增 skip 筛选选项,调整相关前端和后端代码支持该状态
- 移除 L2 审核界面中保存按钮,避免误操作手动保存
- 补充单元测试覆盖缓存同步、缓存读取异常处理和去重统计日志格式输出
1 parent 93b03ea9
...@@ -722,14 +722,19 @@ def _decode_lyric_content(content: bytes) -> str: ...@@ -722,14 +722,19 @@ def _decode_lyric_content(content: bytes) -> str:
722 return content.decode('utf-8', errors='replace') 722 return content.decode('utf-8', errors='replace')
723 723
724 724
725 def load_existing_l2_candidates( 725 def _write_existing_lyrics_cache(cache_path: Path, cache_items: dict[tuple[str, str], dict]) -> None:
726 rows: list[dict], 726 cache_path.parent.mkdir(parents=True, exist_ok=True)
727 cache_path: Path, 727 tmp_path = cache_path.with_suffix(cache_path.suffix + '.tmp')
728 downloader=download_file, 728 with tmp_path.open('w', encoding='utf-8') as f:
729 ) -> tuple[list[LyricRecord], dict[str, int]]: 729 for item in cache_items.values():
730 """从目标库 lyrics_url 行构建 L2 候选;本地缓存已下载歌词,避免重启全量拉 OSS。""" 730 f.write(json.dumps(item, ensure_ascii=False) + '\n')
731 tmp_path.replace(cache_path)
732
733
734 def _read_existing_lyrics_cache(cache_path: Path) -> dict[tuple[str, str], dict]:
731 cached_by_key: dict[tuple[str, str], dict] = {} 735 cached_by_key: dict[tuple[str, str], dict] = {}
732 if cache_path.exists(): 736 if not cache_path.exists():
737 return cached_by_key
733 with cache_path.open('r', encoding='utf-8') as f: 738 with cache_path.open('r', encoding='utf-8') as f:
734 for line in f: 739 for line in f:
735 line = line.strip() 740 line = line.strip()
...@@ -744,15 +749,67 @@ def load_existing_l2_candidates( ...@@ -744,15 +749,67 @@ def load_existing_l2_candidates(
744 lyrics = item.get('lyrics') 749 lyrics = item.get('lyrics')
745 if record_id and url and isinstance(lyrics, str) and lyrics.strip(): 750 if record_id and url and isinstance(lyrics, str) and lyrics.strip():
746 cached_by_key[(record_id, url)] = item 751 cached_by_key[(record_id, url)] = item
752 return cached_by_key
753
754
755 def sync_inserted_lyrics_cache(cache_path: Path, inserted_rows: list[tuple[tuple, dict]]) -> int:
756 """把本次已成功插入目标库的歌词同步到 L2 启动缓存。"""
757 if not inserted_rows:
758 return 0
759
760 cached_by_key = _read_existing_lyrics_cache(cache_path)
761 added = 0
762 for tuple_row, source_row in inserted_rows:
763 url = tuple_row[OSS_LYRIC_INDEX]
764 lyrics = source_row.get('lyrics_txt_content')
765 if not url or not str(url).startswith(('http://', 'https://')):
766 continue
767 if not lyrics or not str(lyrics).strip():
768 continue
769
770 record_id = str(tuple_row[0])
771 key = (record_id, str(url))
772 if key not in cached_by_key:
773 added += 1
774 cached_by_key[key] = {
775 'id': record_id,
776 'url': str(url),
777 'lyrics': str(lyrics),
778 'name': tuple_row[1],
779 'singer': tuple_row[33],
780 'lyricist': tuple_row[2],
781 'composer': tuple_row[3],
782 }
783
784 if added:
785 _write_existing_lyrics_cache(cache_path, cached_by_key)
786 return added
787
788
789 def load_existing_l2_candidates(
790 rows: list[dict],
791 cache_path: Path,
792 downloader=download_file,
793 ) -> tuple[list[LyricRecord], dict[str, int]]:
794 """从目标库 lyrics_url 行构建 L2 候选;本地缓存已下载歌词,避免重启全量拉 OSS。"""
795 cached_by_key = _read_existing_lyrics_cache(cache_path)
747 796
748 records: list[LyricRecord] = [] 797 records: list[LyricRecord] = []
749 next_cache: dict[tuple[str, str], dict] = {} 798 next_cache: dict[tuple[str, str], dict] = {}
750 stats = {'cached': 0, 'downloaded': 0, 'failed': 0} 799 stats = {'cached': 0, 'downloaded': 0, 'failed': 0}
751 800
752 for row in rows: 801 rows_with_url = [
802 row for row in rows
803 if row.get('lyrics_url') and str(row.get('lyrics_url')).startswith(('http://', 'https://'))
804 ]
805 logger.info(
806 f"目标库歌词缓存加载开始: rows={len(rows)}, 有效URL={len(rows_with_url)}, "
807 f"已有缓存={len(cached_by_key)}, 缓存文件={cache_path}"
808 )
809
810 try:
811 for idx, row in enumerate(rows_with_url, start=1):
753 url = row.get('lyrics_url') 812 url = row.get('lyrics_url')
754 if not url or not str(url).startswith(('http://', 'https://')):
755 continue
756 813
757 record_id = str(row['id']) 814 record_id = str(row['id'])
758 key = (record_id, str(url)) 815 key = (record_id, str(url))
...@@ -785,12 +842,13 @@ def load_existing_l2_candidates( ...@@ -785,12 +842,13 @@ def load_existing_l2_candidates(
785 composer=row.get('composer') or item.get('composer'), 842 composer=row.get('composer') or item.get('composer'),
786 )) 843 ))
787 844
788 cache_path.parent.mkdir(parents=True, exist_ok=True) 845 if idx % 50 == 0 or idx == len(rows_with_url):
789 tmp_path = cache_path.with_suffix(cache_path.suffix + '.tmp') 846 logger.info(
790 with tmp_path.open('w', encoding='utf-8') as f: 847 f"目标库歌词缓存进度: {idx}/{len(rows_with_url)} | "
791 for item in next_cache.values(): 848 f"命中={stats['cached']} 下载={stats['downloaded']} 失败={stats['failed']}"
792 f.write(json.dumps(item, ensure_ascii=False) + '\n') 849 )
793 tmp_path.replace(cache_path) 850 finally:
851 _write_existing_lyrics_cache(cache_path, next_cache)
794 852
795 return records, stats 853 return records, stats
796 854
...@@ -807,6 +865,15 @@ def check_l1(row: dict, l1_index: dict) -> tuple[bool, int | None]: ...@@ -807,6 +865,15 @@ def check_l1(row: dict, l1_index: dict) -> tuple[bool, int | None]:
807 return False, None 865 return False, None
808 866
809 867
868 def format_dedup_stats(stats: dict) -> str:
869 return (
870 f"L1命中={stats['l1_dup']} | "
871 f"L2合并命中={stats['l2_dup']} | L2待审={stats['l2_review']} | "
872 f"L2新词={stats['l2_new']} | 作者合并={stats['author_merged']} | "
873 f"最终跳过={stats['skipped']}"
874 )
875
876
810 class L2CandidateIndex: 877 class L2CandidateIndex:
811 """L2 歌词候选召回索引,最终判定仍交给 DuplicateChecker。""" 878 """L2 歌词候选召回索引,最终判定仍交给 DuplicateChecker。"""
812 879
...@@ -1693,7 +1760,7 @@ def main(): ...@@ -1693,7 +1760,7 @@ def main():
1693 if item is None: 1760 if item is None:
1694 db_write_queue.task_done() 1761 db_write_queue.task_done()
1695 break 1762 break
1696 batch_data, staging_data, batch_rows, batch_size_actual, batch_start, author_merge_tasks = item 1763 batch_data, staging_data, batch_cache_rows, batch_size_actual, batch_start, author_merge_tasks = item
1697 try: 1764 try:
1698 with target_conn.cursor() as cursor: 1765 with target_conn.cursor() as cursor:
1699 if batch_data: 1766 if batch_data:
...@@ -1708,6 +1775,12 @@ def main(): ...@@ -1708,6 +1775,12 @@ def main():
1708 batch_inserted = len(batch_data) 1775 batch_inserted = len(batch_data)
1709 with stats_lock: 1776 with stats_lock:
1710 stats['inserted'] += batch_inserted 1777 stats['inserted'] += batch_inserted
1778 cache_added = sync_inserted_lyrics_cache(
1779 CACHE_DIR / f'{TARGET_TABLE_NAME}_existing_lyrics.jsonl',
1780 batch_cache_rows,
1781 )
1782 if cache_added:
1783 logger.info(f"目标库歌词缓存同步新增: {cache_added} 条")
1711 except Exception as e: 1784 except Exception as e:
1712 logger.error(f"批量写入失败 (offset {batch_start}): {e}") 1785 logger.error(f"批量写入失败 (offset {batch_start}): {e}")
1713 target_conn.rollback() 1786 target_conn.rollback()
...@@ -1720,6 +1793,11 @@ def main(): ...@@ -1720,6 +1793,11 @@ def main():
1720 target_conn.commit() 1793 target_conn.commit()
1721 with stats_lock: 1794 with stats_lock:
1722 stats['inserted'] += 1 1795 stats['inserted'] += 1
1796 if row_i < len(batch_cache_rows):
1797 sync_inserted_lyrics_cache(
1798 CACHE_DIR / f'{TARGET_TABLE_NAME}_existing_lyrics.jsonl',
1799 [batch_cache_rows[row_i]],
1800 )
1723 except Exception as e2: 1801 except Exception as e2:
1724 with stats_lock: 1802 with stats_lock:
1725 stats['errors'] += 1 1803 stats['errors'] += 1
...@@ -1739,7 +1817,7 @@ def main(): ...@@ -1739,7 +1817,7 @@ def main():
1739 pbar_db.set_postfix( 1817 pbar_db.set_postfix(
1740 ins=stats['inserted'], 1818 ins=stats['inserted'],
1741 err=stats['errors'], 1819 err=stats['errors'],
1742 l1=stats['l1_dup'], 1820 l1_hit=stats['l1_dup'],
1743 l2=stats['l2_dup'], 1821 l2=stats['l2_dup'],
1744 rev=stats['l2_review'], 1822 rev=stats['l2_review'],
1745 auth=stats['author_merged'], 1823 auth=stats['author_merged'],
...@@ -1872,6 +1950,11 @@ def main(): ...@@ -1872,6 +1950,11 @@ def main():
1872 1950
1873 # 按顺序组装 batch_data 1951 # 按顺序组装 batch_data
1874 batch_data = [results_map[i] for i in range(len(new_rows)) if i in results_map] 1952 batch_data = [results_map[i] for i in range(len(new_rows)) if i in results_map]
1953 batch_cache_rows = [
1954 (results_map[i], new_rows[i])
1955 for i in range(len(new_rows))
1956 if i in results_map
1957 ]
1875 1958
1876 # ===== 步骤3:构建 staging tuples ===== 1959 # ===== 步骤3:构建 staging tuples =====
1877 if not args.skip_dedup and not args.review_result_csv: 1960 if not args.skip_dedup and not args.review_result_csv:
...@@ -1900,7 +1983,7 @@ def main(): ...@@ -1900,7 +1983,7 @@ def main():
1900 # ===== 步骤4:交给后台线程写入 DB(主线程继续下一批次 OSS)===== 1983 # ===== 步骤4:交给后台线程写入 DB(主线程继续下一批次 OSS)=====
1901 if not args.skip_dedup: 1984 if not args.skip_dedup:
1902 mark_rows_in_l1_index(batch_rows, l1_index) 1985 mark_rows_in_l1_index(batch_rows, l1_index)
1903 db_write_queue.put((batch_data, staging_data, batch_rows, batch_size_actual, batch_start, author_merge_tasks)) 1986 db_write_queue.put((batch_data, staging_data, batch_cache_rows, batch_size_actual, batch_start, author_merge_tasks))
1904 1987
1905 # 所有批次已入队;等待后台 DB 写入收尾后停止线程 1988 # 所有批次已入队;等待后台 DB 写入收尾后停止线程
1906 stop_db_writer(db_write_queue, db_writer_thread) 1989 stop_db_writer(db_write_queue, db_writer_thread)
...@@ -1915,10 +1998,7 @@ def main(): ...@@ -1915,10 +1998,7 @@ def main():
1915 logger.info("导入完成!") 1998 logger.info("导入完成!")
1916 logger.info(f"总计: {total} | 插入: {stats['inserted']} | 错误: {stats['errors']}") 1999 logger.info(f"总计: {total} | 插入: {stats['inserted']} | 错误: {stats['errors']}")
1917 if not args.skip_dedup: 2000 if not args.skip_dedup:
1918 logger.info(f"去重统计: L1跳过={stats['l1_dup']} | " 2001 logger.info(f"去重统计: {format_dedup_stats(stats)}")
1919 f"L2合并命中={stats['l2_dup']} | L2待审={stats['l2_review']} | "
1920 f"L2新词={stats['l2_new']} | 作者合并={stats['author_merged']} | "
1921 f"跳过={stats['skipped']}")
1922 if dedup_report: 2002 if dedup_report:
1923 logger.info(f"去重报告: {dedup_report.filepath}") 2003 logger.info(f"去重报告: {dedup_report.filepath}")
1924 if review_report: 2004 if review_report:
......
...@@ -466,6 +466,7 @@ ...@@ -466,6 +466,7 @@
466 <option value="new">new</option> 466 <option value="new">new</option>
467 <option value="merge">merge</option> 467 <option value="merge">merge</option>
468 <option value="review">review</option> 468 <option value="review">review</option>
469 <option value="skip">skip</option>
469 </select> 470 </select>
470 <input id="searchInput" type="search" placeholder="搜索歌名 / ID"> 471 <input id="searchInput" type="search" placeholder="搜索歌名 / ID">
471 <label for="pageSizeSelect">每页</label> 472 <label for="pageSizeSelect">每页</label>
...@@ -858,7 +859,6 @@ ...@@ -858,7 +859,6 @@
858 ${value === 'duplicate' ? '确认重复' : value === 'not_duplicate' ? '确认不重复' : '待确认'} 859 ${value === 'duplicate' ? '确认重复' : value === 'not_duplicate' ? '确认不重复' : '待确认'}
859 </button>`).join('')} 860 </button>`).join('')}
860 <input id="reviewNote" value="${esc(selectedReview.note || '')}" placeholder="人工备注"> 861 <input id="reviewNote" value="${esc(selectedReview.note || '')}" placeholder="人工备注">
861 <button id="saveReviewBtn" class="secondary">保存</button>
862 <button id="deleteReviewBtn" class="secondary" style="color:#c0392b;border-color:#c0392b">删除</button> 862 <button id="deleteReviewBtn" class="secondary" style="color:#c0392b;border-color:#c0392b">删除</button>
863 </div> 863 </div>
864 `} 864 `}
...@@ -906,14 +906,6 @@ ...@@ -906,14 +906,6 @@
906 renderDetail(); 906 renderDetail();
907 }); 907 });
908 } 908 }
909 const saveBtn = document.getElementById('saveReviewBtn');
910 if (saveBtn) {
911 saveBtn.addEventListener('click', () => {
912 if (!candidate) return;
913 setReview(candidate, { note: document.getElementById('reviewNote')?.value || '' });
914 renderDetail();
915 });
916 }
917 const deleteBtn = document.getElementById('deleteReviewBtn'); 909 const deleteBtn = document.getElementById('deleteReviewBtn');
918 if (deleteBtn) { 910 if (deleteBtn) {
919 deleteBtn.addEventListener('click', async () => { 911 deleteBtn.addEventListener('click', async () => {
......
...@@ -174,8 +174,8 @@ def _staging_groups_response(mode: str, term: str, page: int, page_size: int, de ...@@ -174,8 +174,8 @@ def _staging_groups_response(mode: str, term: str, page: int, page_size: int, de
174 # hits 模式下只展示有命中的记录(merge/review) 174 # hits 模式下只展示有命中的记录(merge/review)
175 if mode == "hits": 175 if mode == "hits":
176 where_clauses.append("dedup_action IN ('merge', 'review')") 176 where_clauses.append("dedup_action IN ('merge', 'review')")
177 # 决策筛选:new / merge / review 177 # 决策筛选:new / merge / review / skip
178 if decision and decision in ('new', 'merge', 'review'): 178 if decision and decision in ('new', 'merge', 'review', 'skip'):
179 where_clauses.append("dedup_action = %s") 179 where_clauses.append("dedup_action = %s")
180 params.append(decision) 180 params.append(decision)
181 where = "WHERE " + " AND ".join(where_clauses) if where_clauses else "" 181 where = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
...@@ -640,7 +640,7 @@ def _groups_response(path: Path, top_k: str, mode: str, term: str, page: int, pa ...@@ -640,7 +640,7 @@ def _groups_response(path: Path, top_k: str, mode: str, term: str, page: int, pa
640 if mode == "hits": 640 if mode == "hits":
641 groups = [g for g in groups if g.get("has_hit")] 641 groups = [g for g in groups if g.get("has_hit")]
642 # 决策筛选 642 # 决策筛选
643 if decision and decision in ('new', 'merge', 'review', 'duplicate'): 643 if decision and decision in ('new', 'merge', 'review', 'duplicate', 'skip'):
644 groups = [g for g in groups if g.get("decision") == decision or (decision == 'merge' and g.get("decision") == 'merge')] 644 groups = [g for g in groups if g.get("decision") == decision or (decision == 'merge' and g.get("decision") == 'merge')]
645 filtered = [group for group in groups if _matches_term(group, term)] 645 filtered = [group for group in groups if _matches_term(group, term)]
646 start = max(0, (page - 1) * page_size) 646 start = max(0, (page - 1) * page_size)
......
...@@ -43,6 +43,8 @@ from import_hk_songs import ( ...@@ -43,6 +43,8 @@ from import_hk_songs import (
43 process_lyrics, 43 process_lyrics,
44 DedupReport, 44 DedupReport,
45 L2CandidateIndex, 45 L2CandidateIndex,
46 format_dedup_stats,
47 sync_inserted_lyrics_cache,
46 load_approved_import_ids, 48 load_approved_import_ids,
47 load_existing_l2_candidates, 49 load_existing_l2_candidates,
48 ) 50 )
...@@ -140,6 +142,26 @@ class TestPendingL1Index: ...@@ -140,6 +142,26 @@ class TestPendingL1Index:
140 assert len(l1_index) == 1 142 assert len(l1_index) == 1
141 143
142 144
145 class TestDedupStatsLog:
146 """测试去重统计日志文案"""
147
148 def test_l1_counter_is_labeled_as_match_not_skip(self):
149 stats = {
150 'l1_dup': 2,
151 'l2_dup': 2,
152 'l2_review': 23,
153 'l2_new': 1725,
154 'author_merged': 1,
155 'skipped': 0,
156 }
157
158 message = format_dedup_stats(stats)
159
160 assert 'L1命中=2' in message
161 assert 'L1跳过' not in message
162 assert '最终跳过=0' in message
163
164
143 class TestDbWriterShutdown: 165 class TestDbWriterShutdown:
144 """测试后台 DB 写入线程的优雅停止""" 166 """测试后台 DB 写入线程的优雅停止"""
145 167
...@@ -213,6 +235,72 @@ class TestExistingLyricsCache: ...@@ -213,6 +235,72 @@ class TestExistingLyricsCache:
213 assert records[0].lyrics == '缓存里的歌词\n第二行' 235 assert records[0].lyrics == '缓存里的歌词\n第二行'
214 assert stats == {'cached': 1, 'downloaded': 1, 'failed': 0} 236 assert stats == {'cached': 1, 'downloaded': 1, 'failed': 0}
215 237
238 def test_load_existing_l2_candidates_persists_successes_before_later_download_exception(self, tmp_path):
239 cache_path = tmp_path / 'lyrics_cache.jsonl'
240 rows = [
241 {
242 'id': 100,
243 'lyrics_url': 'https://oss.example.test/100.txt',
244 'name': '第一首',
245 'singer': '歌手',
246 'lyricist': '词',
247 'composer': '曲',
248 },
249 {
250 'id': 101,
251 'lyrics_url': 'https://oss.example.test/101.txt',
252 'name': '第二首',
253 'singer': '歌手',
254 'lyricist': '词',
255 'composer': '曲',
256 },
257 ]
258
259 def downloader(url, timeout=10):
260 if url.endswith('/101.txt'):
261 raise RuntimeError('network interrupted')
262 return '已下载歌词'.encode('utf-8'), 'text/plain'
263
264 with pytest.raises(RuntimeError, match='network interrupted'):
265 load_existing_l2_candidates(rows, cache_path, downloader=downloader)
266
267 cache_lines = cache_path.read_text(encoding='utf-8').splitlines()
268 assert len(cache_lines) == 1
269 cached = json.loads(cache_lines[0])
270 assert cached['id'] == '100'
271 assert cached['lyrics'] == '已下载歌词'
272
273 def test_sync_inserted_lyrics_cache_adds_committed_inserted_rows(self, tmp_path):
274 cache_path = tmp_path / 'lyrics_cache.jsonl'
275 cache_path.write_text(
276 json.dumps({
277 'id': '100',
278 'url': 'https://oss.example.test/100.txt',
279 'lyrics': '已有歌词',
280 }, ensure_ascii=False) + '\n',
281 encoding='utf-8',
282 )
283 tuple_row = [None] * 45
284 tuple_row[0] = 200
285 tuple_row[1] = '新歌'
286 tuple_row[2] = '新词'
287 tuple_row[3] = '新曲'
288 tuple_row[8] = 'https://oss.example.test/200.txt'
289 tuple_row[33] = '新歌手'
290 source_row = {'lyrics_txt_content': '新歌词正文'}
291
292 added = sync_inserted_lyrics_cache(cache_path, [(tuple(tuple_row), source_row)])
293
294 cache_items = [
295 json.loads(line)
296 for line in cache_path.read_text(encoding='utf-8').splitlines()
297 ]
298 assert added == 1
299 assert len(cache_items) == 2
300 assert cache_items[1]['id'] == '200'
301 assert cache_items[1]['url'] == 'https://oss.example.test/200.txt'
302 assert cache_items[1]['lyrics'] == '新歌词正文'
303
216 304
217 # ==================================================================== 305 # ====================================================================
218 # L1 元数据去重 check_l1 306 # L1 元数据去重 check_l1
......