更新数据导入脚本、前端仪表盘和测试指南
- import_hk_songs.py: 优化数据导入逻辑(+157行) - serve_l2_dashboard.py: 增强仪表盘服务功能(+231行) - l2_review_dashboard.html: 前端页面微调 - 测试流程指南.md: 补充测试文档
Showing
4 changed files
with
491 additions
and
39 deletions
| ... | @@ -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 | 在项目根目录执行: | ... | ... |
-
Please register or sign in to post a comment