Commit f9c6a51b f9c6a51bf403a392c0dfd57d2215d2b82a8b3d54 by 沈秋雨

初始化项目:词曲 & 音眼资产导入工具

- import_hk_songs.py: 主数据导入脚本
- lyric_dedup/: 歌词去重核心模块
- test_dedup.py: 单元测试 & L2 benchmark
- serve_l2_dashboard.py + l2_review_dashboard.html: 复核前端
- .gitignore: 排除 .env、数据文件、缓存、输出目录
- README.md: 项目说明文档
0 parents
1 # 源库 - 音眼正式
2 SOURCE_DB_HOST=
3 SOURCE_DB_PORT=3306
4 SOURCE_DB_USER=
5 SOURCE_DB_PASSWORD=
6 SOURCE_DB_NAME=
7
8 # 目标库 - 词曲库
9 TARGET_DB_HOST=
10 TARGET_DB_PORT=3306
11 TARGET_DB_USER=
12 TARGET_DB_PASSWORD=
13 TARGET_DB_NAME=
14 TARGET_TABLE_NAME=hk_songs
15
16 # OSS 配置
17 OSS_ACCESS_KEY_ID=
18 OSS_ACCESS_KEY_SECRET=
19 OSS_ENDPOINT=
20 OSS_BUCKET_NAME=
21 OSS_FILE_BASE_NAME=
1 # Python
2 __pycache__/
3 *.pyc
4 *.pyo
5 .venv/
6 .pytest_cache/
7
8 # Environment
9 .env
10
11 # macOS
12 .DS_Store
13
14 # Generated output
15 output/
16
17 # 数据文件(不纳入版本控制)
18 *.csv
19 *.sql
20 *.xlsx
1 # 词曲 & 音眼资产导入工具
2
3 本项目用于将音眼(Hikoon)数据库中的歌词和歌曲数据去重、比对后,导入至词曲库的 `hk_songs` 表,并提供 L2 歌词去重测试和人工复核前端。
4
5 ## 项目结构
6
7 ```
8 .
9 ├── import_hk_songs.py # 主脚本:聚合查询、去重、导入
10 ├── lyric_dedup/ # 歌词去重核心模块
11 │ ├── normalization.py # 歌词文本规范化
12 │ ├── checker.py # 去重匹配逻辑
13 │ ├── eval_dataset.py # 评测数据集构建
14 │ ├── file_import.py # 文件导入工具
15 │ └── cli.py # CLI 入口
16 ├── test_dedup.py # 单元测试 & L2 benchmark
17 ├── serve_l2_dashboard.py # L2 测试复核前端服务
18 ├── l2_review_dashboard.html # 前端页面
19 ├── requirements.txt # Python 依赖
20 ├── 测试流程指南.md # L2 测试流程说明
21 └── .env # 数据库配置(不纳入版本控制)
22 ```
23
24 ## 快速开始
25
26 ### 1. 安装依赖
27
28 ```bash
29 pip install -r requirements.txt
30 ```
31
32 ### 2. 配置数据库
33
34 复制 `.env.example``.env` 并填写数据库连接信息:
35
36 ```env
37 YINYAN_DB_HOST=...
38 YINYAN_DB_PORT=3306
39 YINYAN_DB_USER=...
40 YINYAN_DB_PASSWORD=...
41 YINYAN_DB_NAME=...
42
43 CIKU_DB_HOST=...
44 CIKU_DB_PORT=3306
45 CIKU_DB_USER=...
46 CIKU_DB_PASSWORD=...
47 CIKU_DB_NAME=...
48 ```
49
50 ### 3. 运行数据导入
51
52 ```bash
53 python import_hk_songs.py --help
54 ```
55
56 ### 4. 运行 L2 歌词去重测试
57
58 小样本验证:
59
60 ```bash
61 RUN_L2_BENCHMARK=1 \
62 L2_BENCHMARK_LIMIT=20 \
63 L2_BENCHMARK_EXISTING_LIMIT=50 \
64 L2_BENCHMARK_TOPKS=20,100 \
65 python -m pytest test_dedup.py::TestL2Benchmark::test_l2_recall_topk_efficiency_and_review_artifacts -q -s
66 ```
67
68 详见 [测试流程指南.md](./测试流程指南.md)
69
70 ### 5. 启动复核前端
71
72 ```bash
73 python serve_l2_dashboard.py --host 127.0.0.1 --port 8765
74 ```
75
76 然后访问 http://127.0.0.1:8765 查看测试报告、比对歌词、人工标注。
77
78 ## 普通单元测试
79
80 ```bash
81 python -m pytest test_dedup.py -q
82 ```
83
84 ## 注意事项
85
86 - `.env` 文件包含数据库凭据,**不要**提交到仓库。
87 - `output/` 目录存放测试生成的报告和歌词文件,不纳入版本控制。
88 - CSV / SQL / Excel 等数据文件不纳入版本控制。
1 """Lyric duplicate detection utilities."""
2
3 from lyric_dedup.checker import DuplicateCheckResult
4 from lyric_dedup.checker import DuplicateChecker
5 from lyric_dedup.checker import DuplicateDecision
6 from lyric_dedup.checker import LyricRecord
7
8 __all__ = [
9 "DuplicateCheckResult",
10 "DuplicateChecker",
11 "DuplicateDecision",
12 "LyricRecord",
13 ]
1 """PostgreSQL-backed command line tools for lyric duplicate checking."""
2
3 from __future__ import annotations
4
5 import argparse
6 import json
7 from pathlib import Path
8
9 from lyric_dedup.eval_dataset import generate_eval_set
10 from lyric_dedup.file_import import record_from_file
11
12
13 def main() -> None:
14 parser = argparse.ArgumentParser(prog="lyric-dedup")
15 subparsers = parser.add_subparsers(dest="command", required=True)
16
17 check = subparsers.add_parser("check-file", help="check one .lrc/.txt file using PostgreSQL recall")
18 check.add_argument("--dsn", default="postgresql:///lyric_dedup")
19 check.add_argument("--file", required=True)
20 check.add_argument("--max-candidates", type=int, default=5)
21 check.add_argument("--recall-limit", type=int, default=100)
22 check.add_argument("--enable-trgm", action="store_true")
23 check.add_argument("--trgm-threshold", type=float, default=0.3)
24 check.add_argument("--statement-timeout-ms", type=int, default=5000)
25
26 generate = subparsers.add_parser("generate-eval-set", help="generate labeled eval samples from a lyric library")
27 generate.add_argument("--library-dir", required=True)
28 generate.add_argument("--lyrics-dir", required=True)
29 generate.add_argument("--csv", required=True)
30 generate.add_argument("--size", type=int, default=100)
31 generate.add_argument("--positive-ratio", type=float, default=0.3)
32 generate.add_argument("--seed", type=int, default=20260602)
33 generate.add_argument(
34 "--profile",
35 choices=("standard", "hard"),
36 default="standard",
37 help="evaluation sample profile: standard production mix or harder business-realistic edge mix",
38 )
39
40 args = parser.parse_args()
41 if args.command == "check-file":
42 check_file_pg(args)
43 elif args.command == "generate-eval-set":
44 summary = generate_eval_set(
45 library_dir=Path(args.library_dir),
46 output_dir=Path(args.lyrics_dir),
47 csv_path=Path(args.csv),
48 size=args.size,
49 positive_ratio=args.positive_ratio,
50 seed=args.seed,
51 profile=args.profile,
52 )
53 print(json.dumps(summary, ensure_ascii=False))
54
55
56 def check_file_pg(args: argparse.Namespace) -> None:
57 from dedup_server.config import ServerConfig
58 from dedup_server.service import DedupService
59
60 record = record_from_file(Path(args.file))
61 config = ServerConfig(
62 dsn=args.dsn,
63 max_candidates=args.max_candidates,
64 recall_limit=args.recall_limit,
65 enable_trgm=args.enable_trgm,
66 trgm_threshold=args.trgm_threshold,
67 statement_timeout_ms=args.statement_timeout_ms,
68 )
69 service = DedupService(config=config)
70 result = service.check(record.lyrics, title=record.title, artist=record.artist)
71 print(
72 json.dumps(
73 {
74 "source": args.file,
75 "decision": result.decision,
76 "duplicate": result.duplicate,
77 "confidence": result.confidence,
78 "reason": result.reason,
79 "candidate_count": result.candidate_count,
80 },
81 ensure_ascii=False,
82 indent=2,
83 )
84 )
85
86
87 if __name__ == "__main__":
88 main()
1 """Import LRC/TXT lyric files into records."""
2
3 from __future__ import annotations
4
5 import hashlib
6 from pathlib import Path
7
8 from lyric_dedup.checker import LyricRecord
9
10
11 SUPPORTED_SUFFIXES = {".lrc", ".txt"}
12
13
14 def iter_lyric_files(root: str | Path) -> list[Path]:
15 base = Path(root)
16 return sorted(
17 path
18 for path in base.rglob("*")
19 if path.is_file() and path.suffix.lower() in SUPPORTED_SUFFIXES
20 )
21
22
23 def read_lyric_file(path: str | Path) -> str:
24 file_path = Path(path)
25 data = file_path.read_bytes()
26 for encoding in ("utf-8-sig", "utf-8", "gb18030", "big5"):
27 try:
28 return data.decode(encoding)
29 except UnicodeDecodeError:
30 continue
31 return data.decode("utf-8", errors="replace")
32
33
34 def record_from_file(path: str | Path, *, base_dir: str | Path | None = None) -> LyricRecord:
35 file_path = Path(path)
36 lyrics = read_lyric_file(file_path)
37 title, artist = _metadata_from_name(file_path.stem)
38 record_id = _record_id(file_path, base_dir)
39 return LyricRecord(record_id=record_id, lyrics=lyrics, title=title, artist=artist)
40
41
42 def records_from_dir(root: str | Path) -> list[LyricRecord]:
43 return [record_from_file(path, base_dir=root) for path in iter_lyric_files(root)]
44
45
46 def _record_id(path: Path, base_dir: str | Path | None) -> str:
47 if base_dir is None:
48 source = str(path.resolve())
49 else:
50 source = str(path.resolve().relative_to(Path(base_dir).resolve()))
51 digest = hashlib.sha1(source.encode("utf-8")).hexdigest()[:12]
52 return f"{digest}:{source}"
53
54
55 def _metadata_from_name(stem: str) -> tuple[str | None, str | None]:
56 cleaned = stem.removesuffix("-歌词").removesuffix("_歌词").removesuffix(" 歌词").strip()
57 if " - " in cleaned:
58 artist, title = cleaned.split(" - ", 1)
59 return title.strip() or None, artist.strip() or None
60 for sep in ("-", "_"):
61 if sep in cleaned:
62 title, artist = cleaned.rsplit(sep, 1)
63 return title.strip() or None, artist.strip() or None
64 return stem.strip() or None, None
1 pymysql
2 python-dotenv
3 oss2
4 requests
5 tqdm
6 opencc-python-reimplemented
1 # L2 歌词去重测试流程指南
2
3 本文档说明如何运行 L2 歌词召回测试、查看测试产物,并启动前端页面做人工复核。
4
5 ## 1. 运行 L2 测试脚本
6
7 在项目根目录执行:
8
9 ```bash
10 RUN_L2_BENCHMARK=1 \
11 L2_BENCHMARK_LIMIT=20000000 \
12 L2_BENCHMARK_OFFSET=0 \
13 L2_BENCHMARK_LOAD_EXISTING=1 \
14 L2_BENCHMARK_EXISTING_LIMIT=50000 \
15 L2_BENCHMARK_DOWNLOAD_WORKERS=32 \
16 L2_BENCHMARK_TOPKS=20 \
17 L2_BENCHMARK_RETRIEVAL_TOP_N=10 \
18 python -m pytest test_dedup.py::TestL2Benchmark::test_l2_recall_topk_efficiency_and_review_artifacts -q -s
19 ```
20
21 这个测试只处理歌词:
22
23 - 从源库读取待导入歌词。
24 - 可选从目标测试库读取已有 `lyrics_url` 并下载歌词文件。
25 - 不下载或上传音频、封面、伴奏、曲谱等资源。
26 - 不写入数据库。
27 - 对不同 `top_k` 召回配置输出效率指标和人工复核材料。
28
29 ## 2. 参数说明
30
31 | 参数 | 默认值 | 说明 |
32 | --------------------------------- | --------------------- | ------------------------------------------------------------------------------- |
33 | `RUN_L2_BENCHMARK` | 未开启 | 必须设为 `1` 才会运行联网 benchmark;否则 pytest 会跳过该测试。 |
34 | `L2_BENCHMARK_LIMIT` | `200` | 从源库查询多少条待评测歌曲。 |
35 | `L2_BENCHMARK_OFFSET` | `0` | 源库查询偏移量,用于分段抽样。 |
36 | `L2_BENCHMARK_LOAD_EXISTING` | `1` | 是否加载目标测试库已有歌词作为历史候选。设为 `0` 时只测试本批新歌之间的召回。 |
37 | `L2_BENCHMARK_EXISTING_LIMIT` | `500` | 从目标测试库加载多少条已有歌词候选;设为 `0` 表示不加 SQL `LIMIT`。 |
38 | `L2_BENCHMARK_TOPKS` | `20,50,100,200,500` | 要对比的 topK 召回候选规模,逗号分隔。 |
39 | `L2_BENCHMARK_RETRIEVAL_TOP_N` | `10` | 每条新歌在结果表中保留前多少个候选。前端当前按 top10 展示。 |
40 | `L2_BENCHMARK_DOWNLOAD_TIMEOUT` | `10` | 下载已有歌词文件的超时时间,单位秒。 |
41
42 ## 3. 输出结果
43
44 测试完成后会在 `output/reports/` 下生成:
45
46 ```text
47 l2_topk_benchmark_summary_YYYYMMDD_HHMMSS.csv
48 l2_topk_retrieval_top10_YYYYMMDD_HHMMSS.csv
49 l2_topk_duplicate_hits_YYYYMMDD_HHMMSS.csv
50 l2_topk_benchmark_assets_YYYYMMDD_HHMMSS/lyrics/
51 ```
52
53 各文件含义:
54
55 | 文件 | 说明 |
56 | -------------------------------------- | -------------------------------------------------------------------------------------------------------- |
57 | `l2_topk_benchmark_summary_*.csv` | 每个 topK 的效率汇总,包括耗时、吞吐、平均召回候选数、duplicate/review/new/hit 数量。 |
58 | `l2_topk_retrieval_top10_*.csv` | 每条新歌的 topN 召回候选明细,包含新歌和候选的歌名、歌手、词曲作者、歌词文件路径、相似度指标、判定原因。 |
59 | `l2_topk_duplicate_hits_*.csv` | 只保留命中 `duplicate``review` 的样本,适合人工重点复核。 |
60 | `l2_topk_benchmark_assets_*/lyrics/` | 新歌和候选歌词正文文件。CSV 中只保留路径,不直接塞歌词全文。 |
61
62 `l2_topk_retrieval_top10_*.csv` 还包含:
63
64 - `l1_metadata_match`:新歌与候选按 L1 元数据规则(歌名、作词人、作曲人)是否命中。
65 - `l1_l2_conflict`:L1 与 L2 结论是否冲突。比如 L2 判新歌但召回候选中有 L1 命中,或 L2 判重复但命中候选 L1 未命中。
66
67 ## 4. 启动前端页面
68
69 运行:
70
71 ```bash
72 python serve_l2_dashboard.py --host 127.0.0.1 --port 8765
73 ```
74
75 然后打开:
76
77 ```text
78 http://127.0.0.1:8765
79 ```
80
81 前端会自动扫描 `output/reports/` 下成套的 benchmark 结果文件。
82
83 ## 5. 前端使用方式
84
85 页面主要功能:
86
87 - 选择不同测试 run。
88 - 切换不同 `top_k`
89 - 查看效率指标:耗时、吞吐、平均召回候选数、duplicate/review/new/hit 数量。
90 - 在“召回样本”中查看每条新歌的 topN 候选。
91 - 在“命中样本”中只查看 `duplicate` / `review` 样本。
92 - 左右并排查看新歌歌词和候选歌词。
93 - 高亮 L1/L2 冲突样本。
94 - 对样本做人工标注:确认重复、确认不重复、待确认。
95 - 点击“导出标注”导出当前 run、topK 和当前视图下的人工标注 CSV。
96 - 按歌名、歌手、ID 搜索样本。
97
98 如果刚跑完新测试但页面没有更新,点击页面右上角“刷新”。
99
100 ## 6. 推荐测试方式
101
102 先用小样本确认流程:
103
104 ```bash
105 RUN_L2_BENCHMARK=1 \
106 L2_BENCHMARK_LIMIT=20 \
107 L2_BENCHMARK_EXISTING_LIMIT=50 \
108 L2_BENCHMARK_TOPKS=20,100 \
109 python -m pytest test_dedup.py::TestL2Benchmark::test_l2_recall_topk_efficiency_and_review_artifacts -q -s
110 ```
111
112 再扩大样本:
113
114 ```bash
115 RUN_L2_BENCHMARK=1 \
116 L2_BENCHMARK_LIMIT=1000 \
117 L2_BENCHMARK_EXISTING_LIMIT=5000 \
118 L2_BENCHMARK_TOPKS=20,50,100,200,500 \
119 python -m pytest test_dedup.py::TestL2Benchmark::test_l2_recall_topk_efficiency_and_review_artifacts -q -s
120 ```
121
122 如果目标库已有歌词很多,`L2_BENCHMARK_EXISTING_LIMIT=0` 会加载全部候选,运行时间和歌词下载时间会明显增加。
123
124 ## 7. 普通单元测试
125
126 运行全部普通测试:
127
128 ```bash
129 python -m pytest test_dedup.py -q
130 ```
131
132 默认情况下,L2 benchmark 会被跳过,不会联网。
1 音眼数据库配置:
2
3 测试:rm-bp18h64ad9ak4d7h5do.mysql.rds.aliyuncs.com 3306 root Hikoon123! hikoon-data-test
4
5 正式:username: yinyan360
6 password: yinyan_hikoon!@#6699 内网: rm-bp10xcwu0930i0h00.mysql.rds.aliyuncs.com 外网:rm-bp10xcwu0930i0h00ro.mysql.rds.aliyuncs.com 端口:3306
...\ No newline at end of file ...\ No newline at end of file