Commit 33d4579f 33d4579f45f072ee5a45fc36239cb989e233ca4a by 沈秋雨

更新数据导入脚本、前端仪表盘和测试指南

- import_hk_songs.py: 优化数据导入逻辑(+157行)
- serve_l2_dashboard.py: 增强仪表盘服务功能(+231行)
- l2_review_dashboard.html: 前端页面微调
- 测试流程指南.md: 补充测试文档
1 parent cc6acccb
...@@ -12,6 +12,8 @@ import re ...@@ -12,6 +12,8 @@ import re
12 import sys 12 import sys
13 import time 13 import time
14 import hashlib 14 import hashlib
15 import hmac
16 import base64
15 import io 17 import io
16 import socket 18 import socket
17 import threading 19 import threading
...@@ -52,6 +54,9 @@ logging.basicConfig( ...@@ -52,6 +54,9 @@ logging.basicConfig(
52 ) 54 )
53 logger = logging.getLogger(__name__) 55 logger = logging.getLogger(__name__)
54 56
57 # 抑制 oss2 SDK 内部的 ERROR 级别日志(已被重试机制捕获处理)
58 logging.getLogger('oss2').setLevel(logging.CRITICAL)
59
55 # ==================== 数据库配置 ==================== 60 # ==================== 数据库配置 ====================
56 61
57 SOURCE_DB_CONFIG = { 62 SOURCE_DB_CONFIG = {
...@@ -188,6 +193,7 @@ WHERE sp.deleted = b'0' ...@@ -188,6 +193,7 @@ WHERE sp.deleted = b'0'
188 # ==================== 目标表 INSERT 语句 ==================== 193 # ==================== 目标表 INSERT 语句 ====================
189 194
190 TARGET_TABLE_NAME = os.getenv('TARGET_TABLE_NAME', 'hk_songs') 195 TARGET_TABLE_NAME = os.getenv('TARGET_TABLE_NAME', 'hk_songs')
196 TARGET_TABLE_NAME_TMP = os.getenv('TARGET_TABLE_NAME_TMP', f'{TARGET_TABLE_NAME}_import_staging')
191 197
192 INSERT_SQL_TEMPLATE = """ 198 INSERT_SQL_TEMPLATE = """
193 INSERT INTO {table} ( 199 INSERT INTO {table} (
...@@ -217,6 +223,52 @@ INSERT INTO {table} ( ...@@ -217,6 +223,52 @@ INSERT INTO {table} (
217 ) 223 )
218 """ 224 """
219 225
226 STAGING_INSERT_SQL_TEMPLATE = """
227 INSERT INTO {table} (
228 staging_id, import_batch_id,
229 id, name, lyricist, composer, issue_status, intro,
230 audio_url, accompany_url, lyrics_url, lrc_url,
231 song_time, song_start, song_end,
232 creation_url, opern_url, cover_version, issue_time,
233 cover_url, animation_type, bpm_class, review_status,
234 in_status, song_status, commit_time, review_time,
235 shelf_time, review_remark, create_time, creator,
236 modify_time, modifier, deleted, cooperate_type, singer,
237 off_shelf_remark, musician_id, commit_id, sheet_music,
238 commit_desc, price, source_table_name, source_song_id,
239 lyric_archive_element_id, melody_archive_element_id, audio_fingerprint,
240 dedup_action, dedup_decision, dedup_confidence, matched_song_id, l1_matched_id,
241 merge_authors, dedup_reason, biz_review_status, staging_status, imported_song_id
242 ) VALUES (
243 %s,%s,
244 %s,%s,%s,%s,%s,%s,
245 %s,%s,%s,%s,
246 %s,%s,%s,
247 %s,%s,%s,%s,
248 %s,%s,%s,%s,
249 %s,%s,%s,%s,
250 %s,%s,%s,%s,
251 %s,%s,%s,%s,%s,
252 %s,%s,%s,%s,
253 %s,%s,%s,%s,
254 %s,%s,%s,
255 %s,%s,%s,%s,%s,
256 %s,%s,%s,%s,%s
257 )
258 ON DUPLICATE KEY UPDATE
259 dedup_action = VALUES(dedup_action),
260 dedup_decision = VALUES(dedup_decision),
261 dedup_confidence = VALUES(dedup_confidence),
262 matched_song_id = VALUES(matched_song_id),
263 l1_matched_id = VALUES(l1_matched_id),
264 merge_authors = VALUES(merge_authors),
265 dedup_reason = VALUES(dedup_reason),
266 biz_review_status = VALUES(biz_review_status),
267 staging_status = VALUES(staging_status),
268 imported_song_id = VALUES(imported_song_id),
269 error_message = NULL
270 """
271
220 # URL 字段索引映射 (在 row tuple 中的位置) 272 # URL 字段索引映射 (在 row tuple 中的位置)
221 # 0:id, 1:name, 2:lyricist, 3:composer, 4:issue_status, 5:intro, 273 # 0:id, 1:name, 2:lyricist, 3:composer, 4:issue_status, 5:intro,
222 # 6:audio_url, 7:accompany_url, 8:lyrics_url, 9:lrc_url, 274 # 6:audio_url, 7:accompany_url, 8:lyrics_url, 9:lrc_url,
...@@ -284,36 +336,66 @@ def get_oss_bucket(): ...@@ -284,36 +336,66 @@ def get_oss_bucket():
284 return bucket 336 return bucket
285 337
286 338
287 def download_file(url, timeout=30): 339 def download_file(url, timeout=30, max_retries=5):
288 """下载文件,返回 (content_bytes, content_type) 或 (None, None)""" 340 """下载文件,返回 (content_bytes, content_type) 或 (None, None)。
341 失败时自动重试,最多 max_retries 次,重试间隔指数退避。"""
289 if not url: 342 if not url:
290 return None, None 343 return None, None
291 # 如果不是 http/https 开头,尝试补前缀(源库有些是相对路径) 344 # 如果不是 http/https 开头,尝试补前缀(源库有些是相对路径)
292 if not url.startswith(('http://', 'https://')): 345 if not url.startswith(('http://', 'https://')):
293 return None, None 346 return None, None
294 try: 347 last_err = None
295 resp = requests.get(url, timeout=timeout, stream=True) 348 for attempt in range(1, max_retries + 1):
296 resp.raise_for_status() 349 try:
297 content_type = resp.headers.get('Content-Type', '') 350 resp = requests.get(url, timeout=timeout, stream=True)
298 return resp.content, content_type 351 resp.raise_for_status()
299 except Exception as e: 352 content_type = resp.headers.get('Content-Type', '')
300 logger.warning(f"下载失败: {url}, 错误: {e}") 353 return resp.content, content_type
301 return None, None 354 except Exception as e:
302 355 last_err = e
303 356 if attempt < max_retries:
304 def upload_to_oss(bucket, content, oss_key, content_type=None): 357 wait = 2 ** (attempt - 1) # 1s, 2s, ...
305 """上传内容到 OSS,返回 OSS URL 或 None""" 358 logger.debug(f"下载重试 ({attempt}/{max_retries}): {url}, 等待 {wait}s, 错误: {e}")
359 time.sleep(wait)
360 logger.warning(f"下载失败(已重试 {max_retries} 次): {url}, 错误: {last_err}")
361 return None, None
362
363
364 def _oss_sign(method, content_type, oss_key):
365 """生成 OSS V1 签名头"""
366 date = time.strftime('%a, %d %b %Y %H:%M:%S GMT', time.gmtime())
367 string_to_sign = f'{method}\n\n{content_type}\n{date}\n/{OSS_CONFIG["bucket_name"]}/{oss_key}'
368 signature = base64.b64encode(
369 hmac.new(OSS_CONFIG['access_key_secret'].encode(), string_to_sign.encode(), hashlib.sha1).digest()
370 ).decode()
371 return date, f"OSS {OSS_CONFIG['access_key_id']}:{signature}"
372
373
374 def upload_to_oss(bucket, content, oss_key, content_type=None, max_retries=5):
375 """上传内容到 OSS,使用 requests 直接调 REST API,避免 oss2 SDK 多线程问题。
376 失败时自动重试,最多 max_retries 次,重试间隔指数退避。"""
306 if not content: 377 if not content:
307 return None 378 return None
308 try: 379 ct = content_type or ''
309 headers = {} 380 url = f"http://{OSS_CONFIG['bucket_name']}.{OSS_CONFIG['endpoint']}/{oss_key}"
310 if content_type: 381 last_err = None
311 headers['Content-Type'] = content_type 382 for attempt in range(1, max_retries + 1):
312 bucket.put_object(oss_key, content, headers=headers if headers else None) 383 try:
313 return f"{OSS_CONFIG['base_url']}/{oss_key}" 384 date, auth = _oss_sign('PUT', ct, oss_key)
314 except Exception as e: 385 headers = {'Date': date, 'Authorization': auth}
315 logger.warning(f"OSS 上传失败: {oss_key}, 错误: {e}") 386 if ct:
316 return None 387 headers['Content-Type'] = ct
388 resp = requests.put(url, data=content, headers=headers, timeout=30)
389 resp.raise_for_status()
390 return f"{OSS_CONFIG['base_url']}/{oss_key}"
391 except Exception as e:
392 last_err = e
393 if attempt < max_retries:
394 wait = 2 ** (attempt - 1)
395 logger.debug(f"OSS 上传重试 ({attempt}/{max_retries}): {oss_key}, 等待 {wait}s, 错误: {e}")
396 time.sleep(wait)
397 logger.warning(f"OSS 上传失败(已重试 {max_retries} 次): {oss_key}, 错误: {last_err}")
398 return None
317 399
318 400
319 def process_url_field(bucket, source_url, field_type, record_id, skip_oss=False): 401 def process_url_field(bucket, source_url, field_type, record_id, skip_oss=False):
...@@ -456,10 +538,44 @@ def build_row_tuple(row, bucket, skip_oss=False): ...@@ -456,10 +538,44 @@ def build_row_tuple(row, bucket, skip_oss=False):
456 ) 538 )
457 539
458 540
541 def build_staging_tuple(tuple_row: tuple, action: dict, import_batch_id: str) -> tuple:
542 """构建暂存表行;tuple_row 前 45 列与目标表保持同构。"""
543 dedup_action = action.get('action') or ''
544 if dedup_action == 'new':
545 biz_review_status = 'not_required'
546 staging_status = 'imported'
547 imported_song_id = tuple_row[0]
548 elif dedup_action == 'merge':
549 biz_review_status = 'not_required'
550 staging_status = 'skipped'
551 imported_song_id = None
552 else:
553 biz_review_status = 'pending'
554 staging_status = 'staged'
555 imported_song_id = None
556
557 return (
558 ID_GENERATOR.next_id(),
559 import_batch_id,
560 *tuple_row,
561 dedup_action,
562 action.get('decision'),
563 action.get('confidence'),
564 action.get('matched_id'),
565 action.get('l1_matched_id'),
566 1 if action.get('merge_authors') else 0,
567 action.get('reason'),
568 biz_review_status,
569 staging_status,
570 imported_song_id,
571 )
572
573
459 def process_row_with_oss(args_tuple): 574 def process_row_with_oss(args_tuple):
460 """线程工作函数:处理单行的 OSS 上传/下载""" 575 """线程工作函数:处理单行的 OSS 上传/下载"""
461 idx, row, bucket, skip_oss = args_tuple 576 idx, row, bucket, skip_oss = args_tuple
462 try: 577 try:
578 # upload_to_oss 已改用 requests 直接调 REST API,bucket 参数仅作兼容保留
463 tuple_row = build_row_tuple(row, bucket, skip_oss=skip_oss) 579 tuple_row = build_row_tuple(row, bucket, skip_oss=skip_oss)
464 return idx, tuple_row, None 580 return idx, tuple_row, None
465 except Exception as e: 581 except Exception as e:
...@@ -616,10 +732,11 @@ def check_l2( ...@@ -616,10 +732,11 @@ def check_l2(
616 return 'new', 1.0, None, '无候选集' 732 return 'new', 1.0, None, '无候选集'
617 733
618 result = checker.check_record_against_candidates(record, candidates, max_candidates=5) 734 result = checker.check_record_against_candidates(record, candidates, max_candidates=5)
619 matched_id = None 735 # new 记录不返回 matched_id,避免前端误认为有命中
620 if result.candidates: 736 if result.decision == DuplicateDecision.NEW:
621 best = result.candidates[0] 737 matched_id = None
622 matched_id = best.record_id if result.decision != DuplicateDecision.NEW else None 738 else:
739 matched_id = result.candidates[0].record_id if result.candidates else None
623 740
624 return result.decision.value, result.confidence, matched_id, result.reason 741 return result.decision.value, result.confidence, matched_id, result.reason
625 742
...@@ -713,7 +830,7 @@ def classify_dedup_action( ...@@ -713,7 +830,7 @@ def classify_dedup_action(
713 'action': 'new', 830 'action': 'new',
714 'decision': decision, 831 'decision': decision,
715 'confidence': confidence, 832 'confidence': confidence,
716 'matched_id': matched_id, 833 'matched_id': None,
717 'reason': reason, 834 'reason': reason,
718 'l1_matched': l1_matched, 835 'l1_matched': l1_matched,
719 'l1_matched_id': l1_matched_id, 836 'l1_matched_id': l1_matched_id,
...@@ -874,6 +991,7 @@ def main(): ...@@ -874,6 +991,7 @@ def main():
874 f"dedup_only={args.dedup_only}, review_result_csv={args.review_result_csv}, " 991 f"dedup_only={args.dedup_only}, review_result_csv={args.review_result_csv}, "
875 f"dry_run={args.dry_run}") 992 f"dry_run={args.dry_run}")
876 logger.info(f"目标表: {TARGET_TABLE_NAME}") 993 logger.info(f"目标表: {TARGET_TABLE_NAME}")
994 logger.info(f"暂存表: {TARGET_TABLE_NAME_TMP}")
877 logger.info("=" * 60) 995 logger.info("=" * 60)
878 996
879 # 初始化 OSS 997 # 初始化 OSS
...@@ -891,6 +1009,8 @@ def main(): ...@@ -891,6 +1009,8 @@ def main():
891 logger.info(f"连接目标库: {TARGET_DB_CONFIG['host']}:{TARGET_DB_CONFIG['port']}/{TARGET_DB_CONFIG['database']}") 1009 logger.info(f"连接目标库: {TARGET_DB_CONFIG['host']}:{TARGET_DB_CONFIG['port']}/{TARGET_DB_CONFIG['database']}")
892 target_conn = pymysql.connect(**TARGET_DB_CONFIG) 1010 target_conn = pymysql.connect(**TARGET_DB_CONFIG)
893 insert_sql = INSERT_SQL_TEMPLATE.format(table=TARGET_TABLE_NAME) 1011 insert_sql = INSERT_SQL_TEMPLATE.format(table=TARGET_TABLE_NAME)
1012 staging_insert_sql = STAGING_INSERT_SQL_TEMPLATE.format(table=TARGET_TABLE_NAME_TMP)
1013 import_batch_id = datetime.now().strftime("%Y%m%d_%H%M%S")
894 1014
895 # ===== 加载已导入的 source_song_id 集合(增量去重)===== 1015 # ===== 加载已导入的 source_song_id 集合(增量去重)=====
896 imported_ids: set[str] = set() 1016 imported_ids: set[str] = set()
...@@ -1109,6 +1229,7 @@ def main(): ...@@ -1109,6 +1229,7 @@ def main():
1109 batch_data = [results_map[i] for i in range(batch_start, batch_start + batch_size_actual) if i in results_map] 1229 batch_data = [results_map[i] for i in range(batch_start, batch_start + batch_size_actual) if i in results_map]
1110 1230
1111 # ===== 去重过滤 ===== 1231 # ===== 去重过滤 =====
1232 staging_data = []
1112 if not args.skip_dedup and not args.review_result_csv and batch_data: 1233 if not args.skip_dedup and not args.review_result_csv and batch_data:
1113 filtered_data = [] 1234 filtered_data = []
1114 for i, tuple_row in enumerate(batch_data): 1235 for i, tuple_row in enumerate(batch_data):
...@@ -1134,6 +1255,7 @@ def main(): ...@@ -1134,6 +1255,7 @@ def main():
1134 if action['action'] == 'merge': 1255 if action['action'] == 'merge':
1135 with stats_lock: 1256 with stats_lock:
1136 stats['l2_dup'] += 1 1257 stats['l2_dup'] += 1
1258 staging_data.append(build_staging_tuple(tuple_row, action, import_batch_id))
1137 merge_note = ';作者字段需增量合并' if action.get('merge_authors') else '' 1259 merge_note = ';作者字段需增量合并' if action.get('merge_authors') else ''
1138 dedup_report.write(record_id, record_name, 'L2', 'merge', 1260 dedup_report.write(record_id, record_name, 'L2', 'merge',
1139 confidence=f'{confidence:.4f}', 1261 confidence=f'{confidence:.4f}',
...@@ -1145,6 +1267,7 @@ def main(): ...@@ -1145,6 +1267,7 @@ def main():
1145 if action['action'] == 'review': 1267 if action['action'] == 'review':
1146 with stats_lock: 1268 with stats_lock:
1147 stats['l2_review'] += 1 1269 stats['l2_review'] += 1
1270 staging_data.append(build_staging_tuple(tuple_row, action, import_batch_id))
1148 dedup_report.write(record_id, record_name, 'L2', 'review', 1271 dedup_report.write(record_id, record_name, 'L2', 'review',
1149 confidence=f'{confidence:.4f}', 1272 confidence=f'{confidence:.4f}',
1150 matched_id=matched_action_id, reason=reason) 1273 matched_id=matched_action_id, reason=reason)
...@@ -1154,6 +1277,7 @@ def main(): ...@@ -1154,6 +1277,7 @@ def main():
1154 1277
1155 with stats_lock: 1278 with stats_lock:
1156 stats['l2_new'] += 1 1279 stats['l2_new'] += 1
1280 staging_data.append(build_staging_tuple(tuple_row, action, import_batch_id))
1157 dedup_report.write(record_id, record_name, 'L2', 'new', 1281 dedup_report.write(record_id, record_name, 'L2', 'new',
1158 confidence=f'{confidence:.4f}', 1282 confidence=f'{confidence:.4f}',
1159 matched_id=matched_action_id or '', 1283 matched_id=matched_action_id or '',
...@@ -1176,14 +1300,17 @@ def main(): ...@@ -1176,14 +1300,17 @@ def main():
1176 1300
1177 batch_data = filtered_data 1301 batch_data = filtered_data
1178 1302
1179 if not batch_data: 1303 if not batch_data and not staging_data:
1180 pbar_db.update(batch_size_actual) 1304 pbar_db.update(batch_size_actual)
1181 continue 1305 continue
1182 1306
1183 # ===== 顺序写入数据库(单连接,保证不出错)===== 1307 # ===== 顺序写入数据库(单连接,保证不出错)=====
1184 try: 1308 try:
1185 with target_conn.cursor() as cursor: 1309 with target_conn.cursor() as cursor:
1186 cursor.executemany(insert_sql, batch_data) 1310 if batch_data:
1311 cursor.executemany(insert_sql, batch_data)
1312 if staging_data:
1313 cursor.executemany(staging_insert_sql, staging_data)
1187 target_conn.commit() 1314 target_conn.commit()
1188 batch_inserted = len(batch_data) 1315 batch_inserted = len(batch_data)
1189 with stats_lock: 1316 with stats_lock:
...@@ -1249,6 +1376,15 @@ def main(): ...@@ -1249,6 +1376,15 @@ def main():
1249 logger.info("=" * 60) 1376 logger.info("=" * 60)
1250 1377
1251 finally: 1378 finally:
1379 # 显式关闭 OSS session,避免 __del__ 中的 Bad file descriptor / ConnectionPool 警告
1380 if bucket is not None:
1381 try:
1382 if hasattr(bucket, '_session') and bucket._session:
1383 bucket._session.do_close()
1384 except Exception:
1385 pass
1386 # 将 bucket 引用置空,防止 __del__ 再次尝试关闭已关闭的 session
1387 bucket = None
1252 target_conn.close() 1388 target_conn.close()
1253 logger.info("目标库连接已关闭") 1389 logger.info("目标库连接已关闭")
1254 if dedup_report: 1390 if dedup_report:
......
...@@ -947,6 +947,7 @@ ...@@ -947,6 +947,7 @@
947 const params = new URLSearchParams({ 947 const params = new URLSearchParams({
948 path: dataPath(), 948 path: dataPath(),
949 top_k: state.topK, 949 top_k: state.topK,
950 mode: state.mode,
950 q: els.searchInput.value.trim(), 951 q: els.searchInput.value.trim(),
951 page: String(state.page), 952 page: String(state.page),
952 page_size: String(state.pageSize) 953 page_size: String(state.pageSize)
......
...@@ -7,6 +7,7 @@ import argparse ...@@ -7,6 +7,7 @@ import argparse
7 import csv 7 import csv
8 import json 8 import json
9 import mimetypes 9 import mimetypes
10 import os
10 import subprocess 11 import subprocess
11 import sys 12 import sys
12 import time 13 import time
...@@ -14,12 +15,43 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer ...@@ -14,12 +15,43 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
14 from pathlib import Path 15 from pathlib import Path
15 from urllib.parse import parse_qs, unquote, urlparse 16 from urllib.parse import parse_qs, unquote, urlparse
16 17
18 import pymysql
19 import requests
20 from dotenv import load_dotenv
21
17 22
18 ROOT = Path(__file__).resolve().parent 23 ROOT = Path(__file__).resolve().parent
19 REPORT_DIR = ROOT / "output" / "reports" 24 REPORT_DIR = ROOT / "output" / "reports"
20 DASHBOARD = ROOT / "l2_review_dashboard.html" 25 DASHBOARD = ROOT / "l2_review_dashboard.html"
21 REPORT_DIR.mkdir(parents=True, exist_ok=True) 26 REPORT_DIR.mkdir(parents=True, exist_ok=True)
22 GROUP_INDEX_CACHE: dict[tuple[str, int, int, str], list[dict[str, object]]] = {} 27 GROUP_INDEX_CACHE: dict[tuple[str, int, int, str], list[dict[str, object]]] = {}
28 load_dotenv(ROOT / ".env")
29
30 TARGET_DB_CONFIG = {
31 "host": os.getenv("TARGET_DB_HOST"),
32 "port": int(os.getenv("TARGET_DB_PORT", 3306)),
33 "user": os.getenv("TARGET_DB_USER"),
34 "password": os.getenv("TARGET_DB_PASSWORD"),
35 "database": os.getenv("TARGET_DB_NAME"),
36 "charset": "utf8mb4",
37 "cursorclass": pymysql.cursors.DictCursor,
38 }
39 TARGET_TABLE_NAME = os.getenv("TARGET_TABLE_NAME", "hk_songs")
40 TARGET_TABLE_NAME_TMP = os.getenv("TARGET_TABLE_NAME_TMP", f"{TARGET_TABLE_NAME}_import_staging")
41 STAGING_DB_PATH = "__staging_db__"
42 TARGET_COLUMNS = [
43 "id", "name", "lyricist", "composer", "issue_status", "intro",
44 "audio_url", "accompany_url", "lyrics_url", "lrc_url",
45 "song_time", "song_start", "song_end",
46 "creation_url", "opern_url", "cover_version", "issue_time",
47 "cover_url", "animation_type", "bpm_class", "review_status",
48 "in_status", "song_status", "commit_time", "review_time",
49 "shelf_time", "review_remark", "create_time", "creator",
50 "modify_time", "modifier", "deleted", "cooperate_type", "singer",
51 "off_shelf_remark", "musician_id", "commit_id", "sheet_music",
52 "commit_desc", "price", "source_table_name", "source_song_id",
53 "lyric_archive_element_id", "melody_archive_element_id", "audio_fingerprint",
54 ]
23 55
24 56
25 def _json_response(handler: BaseHTTPRequestHandler, payload: object, status: int = 200) -> None: 57 def _json_response(handler: BaseHTTPRequestHandler, payload: object, status: int = 200) -> None:
...@@ -61,6 +93,159 @@ def _iter_csv(path: Path): ...@@ -61,6 +93,159 @@ def _iter_csv(path: Path):
61 yield from csv.DictReader(f) 93 yield from csv.DictReader(f)
62 94
63 95
96 def _target_conn():
97 return pymysql.connect(**TARGET_DB_CONFIG)
98
99
100 def _staging_summary() -> list[dict[str, str]]:
101 sql = f"""
102 SELECT
103 COUNT(*) AS total_count,
104 SUM(dedup_action = 'new') AS new_count,
105 SUM(dedup_action = 'merge') AS merge_count,
106 SUM(dedup_action = 'review') AS review_count
107 FROM {TARGET_TABLE_NAME_TMP}
108 """
109 with _target_conn() as conn, conn.cursor() as cursor:
110 cursor.execute(sql)
111 row = cursor.fetchone() or {}
112 return [{
113 "top_k": "staging",
114 "elapsed_seconds": "-",
115 "throughput_per_second": "-",
116 "avg_recalled_candidates": "-",
117 "hit_count": str((row.get("merge_count") or 0) + (row.get("review_count") or 0)),
118 "duplicate_count": str(row.get("merge_count") or 0),
119 "review_count": str(row.get("review_count") or 0),
120 "new_count": str(row.get("new_count") or 0),
121 "total_count": str(row.get("total_count") or 0),
122 }]
123
124
125 def _staging_dashboard_row(row: dict[str, object]) -> dict[str, str]:
126 return {
127 "top_k": "staging",
128 "rank": "1",
129 "query_source_id": str(row.get("source_song_id") or ""),
130 "query_name": str(row.get("name") or ""),
131 "query_lyricist": str(row.get("lyricist") or ""),
132 "query_composer": str(row.get("composer") or ""),
133 "query_lyrics_path": str(row.get("lyrics_url") or ""),
134 "candidate_id": str(row.get("matched_song_id") or ""),
135 "candidate_name": "",
136 "candidate_lyricist": "",
137 "candidate_composer": "",
138 "candidate_lyrics_path": "",
139 "candidate_decision": str(row.get("dedup_action") or ""),
140 "candidate_confidence": str(row.get("dedup_confidence") or ""),
141 "candidate_reason": str(row.get("dedup_reason") or ""),
142 "decision": str(row.get("dedup_action") or ""),
143 "action": str(row.get("dedup_action") or ""),
144 "source_id": str(row.get("source_song_id") or ""),
145 "staging_id": str(row.get("staging_id") or ""),
146 "review_status": str(row.get("biz_review_status") or ""),
147 "review_note": str(row.get("biz_review_note") or ""),
148 "l1_metadata_match": "1" if row.get("l1_matched_id") else "0",
149 "l1_l2_conflict": "0",
150 }
151
152
153 def _staging_groups_response(mode: str, term: str, page: int, page_size: int) -> dict[str, object]:
154 where_clauses = []
155 params: list[object] = []
156 if term:
157 where_clauses.append("(CAST(source_song_id AS CHAR) LIKE %s OR name LIKE %s OR lyricist LIKE %s OR composer LIKE %s)")
158 like = f"%{term}%"
159 params.extend([like, like, like, like])
160 # hits 模式下只展示有命中的记录(merge/review)
161 if mode == "hits":
162 where_clauses.append("dedup_action IN ('merge', 'review')")
163 where = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
164 count_sql = f"SELECT COUNT(*) AS n FROM {TARGET_TABLE_NAME_TMP} {where}"
165 sql = f"""
166 SELECT staging_id, source_song_id, name, lyricist, composer, dedup_action,
167 biz_review_status, l1_matched_id
168 FROM {TARGET_TABLE_NAME_TMP}
169 {where}
170 ORDER BY
171 CASE dedup_action WHEN 'review' THEN 0 WHEN 'merge' THEN 1 ELSE 2 END,
172 staging_create_time DESC
173 LIMIT %s OFFSET %s
174 """
175 with _target_conn() as conn, conn.cursor() as cursor:
176 cursor.execute(count_sql, params)
177 total = (cursor.fetchone() or {}).get("n", 0)
178 cursor.execute(sql, [*params, page_size, max(0, (page - 1) * page_size)])
179 rows = cursor.fetchall()
180 groups = [
181 {
182 "id": str(row.get("source_song_id") or ""),
183 "query_source_id": str(row.get("source_song_id") or ""),
184 "query_name": row.get("name") or "",
185 "query_lyricist": row.get("lyricist") or "",
186 "query_composer": row.get("composer") or "",
187 "count": 1,
188 "has_hit": row.get("dedup_action") in {"merge", "review"},
189 "has_conflict": False,
190 "has_l1_hint": bool(row.get("l1_matched_id")),
191 }
192 for row in rows
193 ]
194 end = page * page_size
195 return {"total": total, "page": page, "page_size": page_size, "has_more": end < total, "groups": groups}
196
197
198 def _staging_query_rows(query_id: str) -> dict[str, object]:
199 sql = f"SELECT * FROM {TARGET_TABLE_NAME_TMP} WHERE source_song_id = %s ORDER BY staging_id DESC LIMIT 1"
200 with _target_conn() as conn, conn.cursor() as cursor:
201 cursor.execute(sql, (query_id,))
202 row = cursor.fetchone()
203 return {"rows": [_staging_dashboard_row(row)] if row else []}
204
205
206 def _import_approved_staging(source_ids: list[str], reviewer: str = "dashboard") -> dict[str, object]:
207 if not source_ids:
208 raise ValueError("没有可入库的 source_id")
209 placeholders = ",".join(["%s"] * len(source_ids))
210 columns = ", ".join(f"`{column}`" for column in TARGET_COLUMNS)
211 select_columns = ", ".join(f"s.`{column}`" for column in TARGET_COLUMNS)
212 update_imported_sql = f"""
213 UPDATE {TARGET_TABLE_NAME_TMP}
214 SET biz_review_status = 'approved_import',
215 reviewed_by = %s,
216 reviewed_at = NOW()
217 WHERE source_song_id IN ({placeholders})
218 AND dedup_action = 'review'
219 AND biz_review_status IN ('pending', 'unsure')
220 """
221 insert_sql = f"""
222 INSERT INTO {TARGET_TABLE_NAME} ({columns})
223 SELECT {select_columns}
224 FROM {TARGET_TABLE_NAME_TMP} s
225 WHERE s.source_song_id IN ({placeholders})
226 AND s.dedup_action = 'review'
227 AND s.biz_review_status = 'approved_import'
228 AND s.staging_status <> 'imported'
229 """
230 mark_sql = f"""
231 UPDATE {TARGET_TABLE_NAME_TMP}
232 SET staging_status = 'imported',
233 imported_song_id = id,
234 error_message = NULL
235 WHERE source_song_id IN ({placeholders})
236 AND dedup_action = 'review'
237 AND biz_review_status = 'approved_import'
238 """
239 with _target_conn() as conn, conn.cursor() as cursor:
240 cursor.execute(update_imported_sql, [reviewer, *source_ids])
241 approved_count = cursor.rowcount
242 cursor.execute(insert_sql, source_ids)
243 inserted_count = cursor.rowcount
244 cursor.execute(mark_sql, source_ids)
245 conn.commit()
246 return {"approved_count": approved_count, "inserted_count": inserted_count}
247
248
64 def _report_runs() -> list[dict[str, str]]: 249 def _report_runs() -> list[dict[str, str]]:
65 summaries = { 250 summaries = {
66 path.name.replace("l2_topk_benchmark_summary_", "").removesuffix(".csv"): path 251 path.name.replace("l2_topk_benchmark_summary_", "").removesuffix(".csv"): path
...@@ -96,7 +281,15 @@ def _report_runs() -> list[dict[str, str]]: ...@@ -96,7 +281,15 @@ def _report_runs() -> list[dict[str, str]]:
96 } 281 }
97 for path in sorted(REPORT_DIR.glob("review_decisions_*.csv"), reverse=True) 282 for path in sorted(REPORT_DIR.glob("review_decisions_*.csv"), reverse=True)
98 ] 283 ]
99 return review_runs + benchmark_runs 284 staging_run = {
285 "id": "staging-db",
286 "type": "staging",
287 "summary": STAGING_DB_PATH,
288 "retrieval": STAGING_DB_PATH,
289 "hits": STAGING_DB_PATH,
290 "review": STAGING_DB_PATH,
291 }
292 return [staging_run] + review_runs + benchmark_runs
100 293
101 294
102 def _row_decision(row: dict[str, str]) -> str: 295 def _row_decision(row: dict[str, str]) -> str:
...@@ -230,8 +423,12 @@ def _group_index(path: Path, top_k: str) -> list[dict[str, object]]: ...@@ -230,8 +423,12 @@ def _group_index(path: Path, top_k: str) -> list[dict[str, object]]:
230 return groups 423 return groups
231 424
232 425
233 def _groups_response(path: Path, top_k: str, term: str, page: int, page_size: int) -> dict[str, object]: 426 def _groups_response(path: Path, top_k: str, mode: str, term: str, page: int, page_size: int) -> dict[str, object]:
234 filtered = [group for group in _group_index(path, top_k) if _matches_term(group, term)] 427 groups = _group_index(path, top_k)
428 # hits 模式下只展示有命中的记录(merge/review),排除纯 new
429 if mode == "hits":
430 groups = [g for g in groups if g.get("has_hit")]
431 filtered = [group for group in groups if _matches_term(group, term)]
235 start = max(0, (page - 1) * page_size) 432 start = max(0, (page - 1) * page_size)
236 end = start + page_size 433 end = start + page_size
237 page_groups = filtered[start:end] 434 page_groups = filtered[start:end]
...@@ -316,6 +513,9 @@ class Handler(BaseHTTPRequestHandler): ...@@ -316,6 +513,9 @@ class Handler(BaseHTTPRequestHandler):
316 params = parse_qs(parsed.query) 513 params = parse_qs(parsed.query)
317 raw_path = params.get("path", [""])[0] 514 raw_path = params.get("path", [""])[0]
318 try: 515 try:
516 if raw_path == STAGING_DB_PATH:
517 _json_response(self, {"path": raw_path, "rows": _staging_summary()})
518 return
319 path = _safe_path(raw_path) 519 path = _safe_path(raw_path)
320 if path.name.startswith("review_decisions_"): 520 if path.name.startswith("review_decisions_"):
321 _json_response(self, {"path": str(path), "rows": _review_summary(path)}) 521 _json_response(self, {"path": str(path), "rows": _review_summary(path)})
...@@ -327,21 +527,30 @@ class Handler(BaseHTTPRequestHandler): ...@@ -327,21 +527,30 @@ class Handler(BaseHTTPRequestHandler):
327 if parsed.path == "/api/groups": 527 if parsed.path == "/api/groups":
328 params = parse_qs(parsed.query) 528 params = parse_qs(parsed.query)
329 try: 529 try:
330 path = _safe_path(params.get("path", [""])[0]) 530 raw_path = params.get("path", [""])[0]
331 top_k = params.get("top_k", [""])[0] 531 top_k = params.get("top_k", [""])[0]
532 mode = params.get("mode", ["retrieval"])[0]
332 term = _norm(params.get("q", [""])[0]) 533 term = _norm(params.get("q", [""])[0])
333 page = max(1, int(params.get("page", ["1"])[0])) 534 page = max(1, int(params.get("page", ["1"])[0]))
334 page_size = min(200, max(10, int(params.get("page_size", ["50"])[0]))) 535 page_size = min(200, max(10, int(params.get("page_size", ["50"])[0])))
335 _json_response(self, _groups_response(path, top_k, term, page, page_size)) 536 if raw_path == STAGING_DB_PATH:
537 _json_response(self, _staging_groups_response(mode, term, page, page_size))
538 return
539 path = _safe_path(raw_path)
540 _json_response(self, _groups_response(path, top_k, mode, term, page, page_size))
336 except Exception as exc: # noqa: BLE001 - local diagnostic API 541 except Exception as exc: # noqa: BLE001 - local diagnostic API
337 _error(self, str(exc), status=404) 542 _error(self, str(exc), status=404)
338 return 543 return
339 if parsed.path == "/api/query": 544 if parsed.path == "/api/query":
340 params = parse_qs(parsed.query) 545 params = parse_qs(parsed.query)
341 try: 546 try:
342 path = _safe_path(params.get("path", [""])[0]) 547 raw_path = params.get("path", [""])[0]
343 top_k = params.get("top_k", [""])[0] 548 top_k = params.get("top_k", [""])[0]
344 query_id = params.get("query_id", [""])[0] 549 query_id = params.get("query_id", [""])[0]
550 if raw_path == STAGING_DB_PATH:
551 _json_response(self, _staging_query_rows(query_id))
552 return
553 path = _safe_path(raw_path)
345 _json_response(self, _query_rows_response(path, top_k, query_id)) 554 _json_response(self, _query_rows_response(path, top_k, query_id))
346 except Exception as exc: # noqa: BLE001 - local diagnostic API 555 except Exception as exc: # noqa: BLE001 - local diagnostic API
347 _error(self, str(exc), status=404) 556 _error(self, str(exc), status=404)
...@@ -350,6 +559,11 @@ class Handler(BaseHTTPRequestHandler): ...@@ -350,6 +559,11 @@ class Handler(BaseHTTPRequestHandler):
350 params = parse_qs(parsed.query) 559 params = parse_qs(parsed.query)
351 raw_path = params.get("path", [""])[0] 560 raw_path = params.get("path", [""])[0]
352 try: 561 try:
562 if raw_path.startswith(("http://", "https://")):
563 resp = requests.get(raw_path, timeout=10)
564 resp.raise_for_status()
565 _json_response(self, {"path": raw_path, "text": resp.content.decode("utf-8", errors="replace")})
566 return
353 path = _safe_path(raw_path) 567 path = _safe_path(raw_path)
354 text = path.read_text(encoding="utf-8", errors="replace") 568 text = path.read_text(encoding="utf-8", errors="replace")
355 _json_response(self, {"path": str(path), "text": text}) 569 _json_response(self, {"path": str(path), "text": text})
...@@ -385,9 +599,8 @@ class Handler(BaseHTTPRequestHandler): ...@@ -385,9 +599,8 @@ class Handler(BaseHTTPRequestHandler):
385 if not approved: 599 if not approved:
386 raise ValueError("没有可入库的审核通过样本") 600 raise ValueError("没有可入库的审核通过样本")
387 review_csv = _write_review_import_csv(approved) 601 review_csv = _write_review_import_csv(approved)
388 result = _run_import(review_csv) 602 result = _import_approved_staging([row["source_id"] for row in approved])
389 status = 200 if result["returncode"] == 0 else 500 603 _json_response(self, {"review_csv": str(review_csv), **result})
390 _json_response(self, {"review_csv": str(review_csv), **result}, status=status)
391 except Exception as exc: # noqa: BLE001 - local diagnostic API 604 except Exception as exc: # noqa: BLE001 - local diagnostic API
392 _error(self, str(exc), status=400) 605 _error(self, str(exc), status=400)
393 606
......
...@@ -2,6 +2,108 @@ ...@@ -2,6 +2,108 @@
2 2
3 本文档说明如何运行 L2 歌词召回测试、查看测试产物,并启动前端页面做人工复核。 3 本文档说明如何运行 L2 歌词召回测试、查看测试产物,并启动前端页面做人工复核。
4 4
5 ## 0. 当前导入流程 2000 条 smoke 测试
6
7 先确认 `.env` 中有:
8
9 ```text
10 TARGET_TABLE_NAME_TMP=hk_songs_import_staging
11 ```
12
13 第一次跑前,在目标库执行暂存表建表 SQL:
14
15 ```bash
16 create_hk_songs_import_staging.sql
17 ```
18
19 目标库已清空时,先跑第一阶段导入。这个阶段会边去重边导入:
20
21 - 判定为 `new` 的记录会按批次写入目标库。
22 - 判定为 `merge` 的记录不会插入新行,会写入暂存表和审核/记录清单。
23 - 判定为 `review` 的记录不会插入目标库,会进入暂存表等待业务人工审核。
24 - 所有 `new / merge / review` 结果都会写入 `TARGET_TABLE_NAME_TMP`
25 - 输出 `output/reports/review_decisions_*.csv`,供复核页面展示 `new / merge / review` 全量结果。
26
27 ### 0.1 第一阶段:跑 2000 条导入 smoke
28
29 ```bash
30 uv run python import_hk_songs.py \
31 --limit 2000 \
32 --offset 0 \
33 --batch-size 500 \
34 --workers 8 \
35 --load-existing-lyrics \
36 --l2-recall topk \
37 --l2-recall-top-k 20
38 ```
39
40 如果想先不上传 OSS、只验证数据库写入和去重流程,可以加:
41
42 ```bash
43 --skip-oss
44 ```
45
46 ### 0.2 启动人工审核页面
47
48 ```bash
49 uv run python serve_l2_dashboard.py --host 127.0.0.1 --port 8765
50 ```
51
52 打开:
53
54 ```text
55 http://127.0.0.1:8765
56 ```
57
58 页面会自动扫描 `output/reports/review_decisions_*.csv`。业务人员在页面里查看:
59
60 - 已经入库的 `new` 数据。
61 - 判定重复的 `merge` 样本。
62 - 需要人工审核的 `review` 样本。
63
64 `review` 样本,业务人员标注:
65
66 - `确认重复`:不入库。
67 - `确认不重复`:第二阶段入库。
68 - `待确认`:暂不入库。
69
70 ### 0.3 第二阶段:页面按钮入库审核通过项
71
72 业务人员完成所有 `review` 样本审核后,在页面点击:
73
74 ```text
75 入库审核通过
76 ```
77
78 服务端会自动生成:
79
80 ```text
81 output/reports/review_import_approved_YYYYMMDD_HHMMSS.csv
82 ```
83
84 并自动执行第二阶段导入:
85
86 ```bash
87 uv run python import_hk_songs.py \
88 --review-result-csv output/reports/review_import_approved_YYYYMMDD_HHMMSS.csv \
89 --load-existing-lyrics
90 ```
91
92 如果需要手动执行第二阶段,也可以使用上面的命令,把文件名替换成页面生成的实际 CSV。
93
94 ### 0.4 smoke 后检查
95
96 第一阶段完成后,重点看日志里的:
97
98 ```text
99 插入: <new 入库数量>
100 L2合并命中=<merge 数量>
101 L2待审=<review 数量>
102 人工审核清单: output/reports/review_decisions_*.csv
103 ```
104
105 第二阶段完成后,确认日志里的 `插入` 数量等于本次人工审核确认入库数量。
106
5 ## 1. 运行 L2 测试脚本 107 ## 1. 运行 L2 测试脚本
6 108
7 在项目根目录执行: 109 在项目根目录执行:
......