playlist-admin 2.6.81 → 2.6.82
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/index.js +99 -94
- package/package.json +4 -6
- package/rag/README.md +48 -0
- package/rag/__pycache__/_resolve.cpython-311.pyc +0 -0
- package/rag/__pycache__/build_db.cpython-311.pyc +0 -0
- package/rag/__pycache__/query.cpython-311.pyc +0 -0
- package/rag/__pycache__/srt_to_text.cpython-311.pyc +0 -0
- package/rag/_resolve.py +35 -0
- package/rag/build_db.py +213 -0
- package/rag/query.py +140 -0
- package/rag/srt_to_text.py +50 -0
- package/COMMANDS.md +0 -263
- package/assets/zhcdict.json +0 -26406
- package/cli/lib/audio_converter.js +0 -47
- package/cli/lib/cleanup.js +0 -61
- package/cli/lib/config.js +0 -166
- package/cli/lib/library_index.js +0 -370
- package/cli/lib/lufs.js +0 -178
- package/cli/lib/metadata.js +0 -31
- package/cli/lib/pipeline.js +0 -494
- package/cli/lib/playlist_parser.js +0 -85
- package/cli/lib/podcast.js +0 -572
- package/cli/lib/scraper.js +0 -267
- package/cli/lib/snapshot.js +0 -42
- package/cli/lib/spotube.js +0 -452
- package/cli/lib/util.js +0 -139
- package/cli/lib/zhcn.js +0 -68
package/cli/index.js
CHANGED
|
@@ -1,33 +1,95 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import { SpotubeController } from './lib/spotube.js';
|
|
6
|
-
import { cleanupMp3 } from './lib/cleanup.js';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
7
5
|
|
|
8
|
-
|
|
6
|
+
// Thin launcher: the CLI engine lives in the Flutter app binary
|
|
7
|
+
// (lib/cli_main.dart), so GUI and CLI share one implementation.
|
|
8
|
+
// This script just locates the built exe / rag scripts and forwards args.
|
|
9
|
+
|
|
10
|
+
const HELP = `Playlist Administrator CLI
|
|
11
|
+
|
|
12
|
+
Runs the same engine as the GUI app (shared Dart code). Requires a built
|
|
13
|
+
release: flutter build windows --release
|
|
9
14
|
|
|
10
15
|
Usage:
|
|
11
|
-
playlist-admin pipeline
|
|
12
|
-
playlist-admin
|
|
13
|
-
playlist-admin
|
|
14
|
-
playlist-admin spotube-download
|
|
15
|
-
playlist-admin spotube-
|
|
16
|
-
playlist-admin spotube-
|
|
17
|
-
playlist-admin
|
|
18
|
-
playlist-admin
|
|
16
|
+
playlist-admin pipeline [--step N] Run full pipeline (or from step N)
|
|
17
|
+
playlist-admin podcast Run podcast pipeline
|
|
18
|
+
playlist-admin status Show status
|
|
19
|
+
playlist-admin spotube-download <name> Download one playlist
|
|
20
|
+
playlist-admin spotube-download-all Download all playlists
|
|
21
|
+
playlist-admin spotube-move Move M4A files
|
|
22
|
+
playlist-admin spotube-cleanup Remove metadata-renamed mp3 duplicates
|
|
23
|
+
playlist-admin rag build [--reset] Build podcast RAG vector DB
|
|
24
|
+
playlist-admin rag query "問題" [--topk N] [--show 節目] [--json]
|
|
19
25
|
`;
|
|
20
26
|
|
|
21
|
-
function
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
function projectRoot() {
|
|
28
|
+
if (process.env.PA_ROOT && fs.existsSync(process.env.PA_ROOT)) {
|
|
29
|
+
return process.env.PA_ROOT;
|
|
30
|
+
}
|
|
31
|
+
// Start from cwd and walk up to find the Flutter project (works whether
|
|
32
|
+
// invoked locally or from a globally installed npm package).
|
|
33
|
+
let dir = process.cwd();
|
|
34
|
+
while (true) {
|
|
35
|
+
if (fs.existsSync(path.join(dir, 'pubspec.yaml'))) return dir;
|
|
36
|
+
const parent = path.dirname(dir);
|
|
37
|
+
if (parent === dir) return null;
|
|
38
|
+
dir = parent;
|
|
25
39
|
}
|
|
26
|
-
return 0;
|
|
27
40
|
}
|
|
28
41
|
|
|
29
|
-
|
|
30
|
-
const
|
|
42
|
+
function exePath(root) {
|
|
43
|
+
const candidates = [
|
|
44
|
+
path.join(root, 'build', 'windows', 'x64', 'runner', 'Release', 'playlist_administrator.exe'),
|
|
45
|
+
path.join(root, 'build', 'windows', 'x64', 'runner', 'Debug', 'playlist_administrator.exe'),
|
|
46
|
+
];
|
|
47
|
+
return candidates.find((c) => fs.existsSync(c)) || null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function python() {
|
|
51
|
+
return process.env.PYTHON || 'python';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function forward(exe, args) {
|
|
55
|
+
// Deliver CLI args via PA_CLI_ARGS (JSON) — Flutter Dart exposes no
|
|
56
|
+
// command-line args in release on this SDK (see lib/main.dart).
|
|
57
|
+
const child = spawn(exe, [], {
|
|
58
|
+
stdio: 'inherit',
|
|
59
|
+
windowsHide: false,
|
|
60
|
+
env: { ...process.env, PA_CLI_ARGS: JSON.stringify(args) },
|
|
61
|
+
});
|
|
62
|
+
child.on('exit', (code) => process.exit(code ?? 1));
|
|
63
|
+
child.on('error', (e) => {
|
|
64
|
+
console.error(`無法啟動 ${exe}: ${e.message}`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function forwardPy(args) {
|
|
70
|
+
// Python scripts take real argv.
|
|
71
|
+
const child = spawn(python(), args, { stdio: 'inherit', windowsHide: false });
|
|
72
|
+
child.on('exit', (code) => process.exit(code ?? 1));
|
|
73
|
+
child.on('error', (e) => {
|
|
74
|
+
console.error(`無法啟動 python: ${e.message}`);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function runRag(args) {
|
|
80
|
+
const root = projectRoot();
|
|
81
|
+
if (!root) {
|
|
82
|
+
console.error('找不到專案根目錄(pubspec.yaml)。請在專案內執行,或設定 PA_ROOT');
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
const sub = args[0];
|
|
86
|
+
const script = path.join(root, 'rag', sub === 'build' ? 'build_db.py' : 'query.py');
|
|
87
|
+
if (!fs.existsSync(script)) {
|
|
88
|
+
console.error(`找不到 ${script}`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
forwardPy([script, ...args.slice(1)]);
|
|
92
|
+
}
|
|
31
93
|
|
|
32
94
|
async function main() {
|
|
33
95
|
const args = process.argv.slice(2);
|
|
@@ -35,81 +97,24 @@ async function main() {
|
|
|
35
97
|
console.log(HELP);
|
|
36
98
|
return;
|
|
37
99
|
}
|
|
100
|
+
if (args[0] === 'rag') {
|
|
101
|
+
runRag(args.slice(1));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
38
104
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
case 'spotube-download': {
|
|
51
|
-
if (args.length < 2) {
|
|
52
|
-
console.log('Usage: playlist-admin spotube-download <playlist_name>');
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
const ctrl = new SpotubeController({
|
|
56
|
-
libraryPath: config.libraryPath,
|
|
57
|
-
coords: config.spotubeCoords,
|
|
58
|
-
});
|
|
59
|
-
if (!(await ctrl.isRunning())) {
|
|
60
|
-
console.log('Spotube is not running');
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
await ctrl.downloadPlaylist(args[1]);
|
|
64
|
-
break;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
case 'spotube-download-all': {
|
|
68
|
-
const ctrl = new SpotubeController({
|
|
69
|
-
libraryPath: config.libraryPath,
|
|
70
|
-
coords: config.spotubeCoords,
|
|
71
|
-
});
|
|
72
|
-
if (!(await ctrl.isRunning())) {
|
|
73
|
-
console.log('Spotube is not running');
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
const names = Object.values(config.urlNames);
|
|
77
|
-
for (const name of names) {
|
|
78
|
-
console.log(`Downloading: ${name}`);
|
|
79
|
-
await ctrl.downloadPlaylist(name);
|
|
80
|
-
}
|
|
81
|
-
break;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
case 'spotube-move': {
|
|
85
|
-
const ctrl = new SpotubeController({ libraryPath: config.libraryPath });
|
|
86
|
-
const moved = await ctrl.moveDownloads(config.resolvedSpotubeDownloadPath);
|
|
87
|
-
console.log(`Moved ${moved} files`);
|
|
88
|
-
break;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
case 'spotube-cleanup': {
|
|
92
|
-
await cleanupMp3(config.libraryPath);
|
|
93
|
-
break;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
case 'podcast': {
|
|
97
|
-
const pipeline = new PodcastPipeline({ config, onLog: (m) => console.log(m), isCancelled });
|
|
98
|
-
await pipeline.run();
|
|
99
|
-
break;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
case 'status': {
|
|
103
|
-
console.log(`Library: ${config.libraryPath}`);
|
|
104
|
-
console.log(`Playlists: ${Object.keys(config.urlNames).length}`);
|
|
105
|
-
console.log(`Downloaded: ${Object.keys(config.lastUpdated || {}).length}`);
|
|
106
|
-
break;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
default:
|
|
110
|
-
console.log(`Unknown command: ${cmd}\n`);
|
|
111
|
-
console.log(HELP);
|
|
105
|
+
const root = projectRoot();
|
|
106
|
+
if (!root) {
|
|
107
|
+
console.error('找不到專案根目錄(pubspec.yaml)。請在專案內執行,或設定 PA_ROOT');
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
const exe = exePath(root);
|
|
111
|
+
if (!exe) {
|
|
112
|
+
console.error(`找不到 build 好的 exe。
|
|
113
|
+
請先在專案內執行: flutter build windows --release
|
|
114
|
+
或設定 PA_ROOT=<專案根目錄>`);
|
|
115
|
+
process.exit(1);
|
|
112
116
|
}
|
|
117
|
+
forward(exe, args);
|
|
113
118
|
}
|
|
114
119
|
|
|
115
120
|
main().catch((e) => {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "playlist-admin",
|
|
3
|
-
"version": "2.6.
|
|
4
|
-
"description": "Playlist Administrator CLI -
|
|
3
|
+
"version": "2.6.82",
|
|
4
|
+
"description": "Playlist Administrator CLI - thin launcher for the Flutter app engine (pipeline, podcast, spotube) + podcast RAG query",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"playlist-admin": "./cli/index.js"
|
|
@@ -16,10 +16,8 @@
|
|
|
16
16
|
"spotube-cleanup": "node cli/index.js spotube-cleanup"
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
|
-
"cli/",
|
|
20
|
-
"
|
|
21
|
-
"README.md",
|
|
22
|
-
"COMMANDS.md"
|
|
19
|
+
"cli/index.js",
|
|
20
|
+
"rag/"
|
|
23
21
|
],
|
|
24
22
|
"keywords": [
|
|
25
23
|
"playlist",
|
package/rag/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Podcast RAG
|
|
2
|
+
|
|
3
|
+
對 podcast 逐字稿做本地語意檢索(embeddings + ChromaDB)。
|
|
4
|
+
|
|
5
|
+
## 前置需求
|
|
6
|
+
|
|
7
|
+
- 安裝 Ollama:`https://ollama.com/download`
|
|
8
|
+
```bash
|
|
9
|
+
ollama pull bge-m3 # embedding 模型
|
|
10
|
+
ollama pull qwen2.5:7b # (選用)本機生成
|
|
11
|
+
```
|
|
12
|
+
- Python 套件:
|
|
13
|
+
```bash
|
|
14
|
+
pip install chromadb requests
|
|
15
|
+
```
|
|
16
|
+
- 逐字稿:`podcast_downloads/<節目>/*.txt`(podcast pipeline 會自動產生 .txt)
|
|
17
|
+
|
|
18
|
+
## 指令
|
|
19
|
+
|
|
20
|
+
CLI(需已 build exe 或直接 python 執行):
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
playlist-admin rag build # 增量索引(沒被 index 過的 .txt)
|
|
24
|
+
playlist-admin rag build --reset # 全部重建
|
|
25
|
+
playlist-admin rag query "2026年台股會一直漲嗎?" --topk 8
|
|
26
|
+
playlist-admin rag query "問題" --json # JSON 含全文
|
|
27
|
+
playlist-admin rag query "問題" --show 游庭皓 # 只搜指定節目
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
直接用 Python(路徑從 config.json 自動解析):
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
python rag/build_db.py
|
|
34
|
+
python rag/query.py "問題"
|
|
35
|
+
python rag/srt_to_text.py # 把缺 .txt 的 .srt 補轉成 .txt
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Pipeline 整合
|
|
39
|
+
|
|
40
|
+
podcast pipeline 產生/更新逐字稿後,跑 `playlist-admin rag build`
|
|
41
|
+
即可把新集數加進向量庫(程式會自動跳過已索引的檔名)。
|
|
42
|
+
|
|
43
|
+
## 檔案
|
|
44
|
+
|
|
45
|
+
- `build_db.py` — 逐字稿切 chunk → Ollama embeddings → ChromaDB(cosine)
|
|
46
|
+
- `query.py` — 問題 embedding → top-k 片段 → 輸出命中的節目/日期/全文偏移
|
|
47
|
+
- `srt_to_text.py` — srt → 純文字
|
|
48
|
+
- `_resolve.py` — 從 app config 解析 podcast_downloads 與 chroma_db 路徑
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/rag/_resolve.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Resolve podcast RAG paths from the app config (same as the CLI/GUI)."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def find_base() -> Path:
|
|
8
|
+
env_bp = os.environ.get("BASE_PATH")
|
|
9
|
+
if env_bp:
|
|
10
|
+
return Path(env_bp)
|
|
11
|
+
local = os.environ.get("LOCALAPPDATA")
|
|
12
|
+
pointer = None
|
|
13
|
+
if local:
|
|
14
|
+
pointer = Path(local) / "Playlist Administrator" / "data" / "config.json"
|
|
15
|
+
try:
|
|
16
|
+
if pointer and pointer.exists():
|
|
17
|
+
data = json.loads(pointer.read_text(encoding="utf-8"))
|
|
18
|
+
bp = data.get("base_path") or data.get("basePath")
|
|
19
|
+
if bp:
|
|
20
|
+
return Path(bp)
|
|
21
|
+
except Exception:
|
|
22
|
+
pass
|
|
23
|
+
return Path(r"C:\Users\CPXru\Music\Spotube")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def podcast_downloads_dir() -> Path:
|
|
27
|
+
return find_base() / "podcast_downloads"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def rag_data_dir() -> Path:
|
|
31
|
+
return find_base() / "podcast_downloads" / "podcast_rag"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def chroma_db_dir() -> Path:
|
|
35
|
+
return rag_data_dir() / "chroma_db"
|
package/rag/build_db.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""把 podcast 逐字稿 (.txt) 建立成 ChromaDB 向量資料庫
|
|
2
|
+
用法: python rag/build_db.py [--data DIR] [--db DIR] [--model MODEL] [--reset] [--workers N]
|
|
3
|
+
"""
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import hashlib
|
|
9
|
+
import time
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
14
|
+
|
|
15
|
+
import chromadb
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from _resolve import podcast_downloads_dir, chroma_db_dir
|
|
19
|
+
|
|
20
|
+
OLLAMA_URL = "http://localhost:11434"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def embed_one_batch(model: str, texts: list[str]) -> tuple[list[str], list[list[float]]]:
|
|
24
|
+
"""單批 embedding;400 時逐個重試,回傳 (成功文字, 對應向量)"""
|
|
25
|
+
resp = requests.post(
|
|
26
|
+
f"{OLLAMA_URL}/api/embed",
|
|
27
|
+
json={"model": model, "input": texts},
|
|
28
|
+
timeout=600,
|
|
29
|
+
)
|
|
30
|
+
if resp.status_code == 400:
|
|
31
|
+
kept: list[str] = []
|
|
32
|
+
vectors: list[list[float]] = []
|
|
33
|
+
for t in texts:
|
|
34
|
+
r2 = requests.post(
|
|
35
|
+
f"{OLLAMA_URL}/api/embed",
|
|
36
|
+
json={"model": model, "input": [t]},
|
|
37
|
+
timeout=600,
|
|
38
|
+
)
|
|
39
|
+
if r2.status_code == 400:
|
|
40
|
+
print(f"!! 跳過問題 chunk (len={len(t)}): {t[:100]!r}")
|
|
41
|
+
continue
|
|
42
|
+
r2.raise_for_status()
|
|
43
|
+
kept.append(t)
|
|
44
|
+
vectors.append(r2.json()["embeddings"][0])
|
|
45
|
+
return kept, vectors
|
|
46
|
+
resp.raise_for_status()
|
|
47
|
+
return texts, resp.json()["embeddings"]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def embed_texts(model: str, texts: list[str], batch: int = 32, workers: int = 6) -> tuple[list[str], list[list[float]]]:
|
|
51
|
+
"""併發呼叫 Ollama /api/embed;回傳 (成功文字, 對應向量)"""
|
|
52
|
+
batches = [texts[i:i + batch] for i in range(0, len(texts), batch)]
|
|
53
|
+
if len(batches) <= 1:
|
|
54
|
+
if not batches:
|
|
55
|
+
return [], []
|
|
56
|
+
return embed_one_batch(model, batches[0])
|
|
57
|
+
|
|
58
|
+
kept_all: list[str] = []
|
|
59
|
+
vectors: list[list[float]] = []
|
|
60
|
+
results: list[tuple[list[str], list[list[float]]]] = [None] * len(batches)
|
|
61
|
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
62
|
+
fut = {ex.submit(embed_one_batch, model, b): i for i, b in enumerate(batches)}
|
|
63
|
+
for f in as_completed(fut):
|
|
64
|
+
results[fut[f]] = f.result()
|
|
65
|
+
for k, v in results:
|
|
66
|
+
if k is None:
|
|
67
|
+
continue
|
|
68
|
+
kept_all.extend(k)
|
|
69
|
+
vectors.extend(v)
|
|
70
|
+
return kept_all, vectors
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def read_text_file(path: Path) -> str:
|
|
74
|
+
"""讀檔,自動嘗試 UTF-8 / Big5 / GBK"""
|
|
75
|
+
raw = path.read_bytes()
|
|
76
|
+
for enc in ("utf-8-sig", "utf-8", "big5", "gb18030"):
|
|
77
|
+
try:
|
|
78
|
+
return raw.decode(enc)
|
|
79
|
+
except UnicodeDecodeError:
|
|
80
|
+
continue
|
|
81
|
+
return raw.decode("utf-8", errors="replace")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def split_sentences(text: str) -> list[str]:
|
|
85
|
+
"""依中文句號斷句,過濾空白行"""
|
|
86
|
+
text = re.sub(r"\s+", "", text)
|
|
87
|
+
parts = re.split(r"(?<=[。!?!?;;])", text)
|
|
88
|
+
return [p for p in parts if len(p) >= 4]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def make_chunks(sentences: list[str], max_len: int = 600, overlap: int = 60) -> list[str]:
|
|
92
|
+
"""句子接成 chunk,每塊約 max_len 字元,前後重疊 overlap;超長句強制硬切"""
|
|
93
|
+
chunks: list[str] = []
|
|
94
|
+
cur = ""
|
|
95
|
+
for s in sentences:
|
|
96
|
+
while len(s) > max_len:
|
|
97
|
+
if cur:
|
|
98
|
+
chunks.append(cur)
|
|
99
|
+
cur = cur[-overlap:] if len(cur) > overlap else cur
|
|
100
|
+
chunks.append(s[:max_len])
|
|
101
|
+
s = s[max_len:]
|
|
102
|
+
if len(cur) + len(s) > max_len and cur:
|
|
103
|
+
chunks.append(cur)
|
|
104
|
+
tail = cur[-overlap:] if len(cur) > overlap else cur
|
|
105
|
+
cur = tail + s
|
|
106
|
+
else:
|
|
107
|
+
cur += s
|
|
108
|
+
if cur:
|
|
109
|
+
chunks.append(cur)
|
|
110
|
+
return chunks
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def detect_meta(path: Path) -> dict:
|
|
114
|
+
"""從檔名/路徑猜節目與日期"""
|
|
115
|
+
parts = path.parts
|
|
116
|
+
show = parts[-2] if len(parts) >= 2 else "unknown"
|
|
117
|
+
name = path.stem
|
|
118
|
+
m = re.search(r"(20\d{2})[_\-_](\d{1,2})[_\-_](\d{1,2})", name)
|
|
119
|
+
date = f"{m.group(1)}-{m.group(2).zfill(2)}-{m.group(3).zfill(2)}" if m else ""
|
|
120
|
+
return {"show": show, "file": name, "date": date}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main() -> None:
|
|
124
|
+
ap = argparse.ArgumentParser()
|
|
125
|
+
ap.add_argument("--data", default=str(podcast_downloads_dir()))
|
|
126
|
+
ap.add_argument("--db", default=str(chroma_db_dir()))
|
|
127
|
+
ap.add_argument("--model", default="bge-m3")
|
|
128
|
+
ap.add_argument("--reset", action="store_true", help="重建資料庫")
|
|
129
|
+
ap.add_argument("--limit", type=int, default=0, help="只索引前 N 篇 (測試用)")
|
|
130
|
+
ap.add_argument("--workers", type=int, default=6, help="併發 embedding 數")
|
|
131
|
+
args = ap.parse_args()
|
|
132
|
+
|
|
133
|
+
client = chromadb.PersistentClient(path=args.db)
|
|
134
|
+
if args.reset:
|
|
135
|
+
try:
|
|
136
|
+
client.delete_collection("podcasts")
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
col = client.get_or_create_collection(
|
|
140
|
+
"podcasts",
|
|
141
|
+
metadata={"hnsw:space": "cosine"},
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# 已索引的檔名 (resume 用) - 分段讀取避免 SQL 變數超限
|
|
145
|
+
done_files = set()
|
|
146
|
+
offset = 0
|
|
147
|
+
while True:
|
|
148
|
+
batch = col.get(limit=5000, offset=offset, include=["metadatas"])["metadatas"]
|
|
149
|
+
if not batch:
|
|
150
|
+
break
|
|
151
|
+
for m in batch:
|
|
152
|
+
if m:
|
|
153
|
+
done_files.add(m.get("file", ""))
|
|
154
|
+
offset += 5000
|
|
155
|
+
if offset > 1000000:
|
|
156
|
+
break
|
|
157
|
+
|
|
158
|
+
files = [f for f in sorted(Path(args.data).rglob("*.txt")) if f.stem not in done_files]
|
|
159
|
+
if args.limit:
|
|
160
|
+
files = files[: args.limit]
|
|
161
|
+
print(f"待索引 {len(files)} 篇 (已跳過 {len(done_files)} 篇)")
|
|
162
|
+
|
|
163
|
+
total_chunks = 0
|
|
164
|
+
t_start = time.time()
|
|
165
|
+
for i, f in enumerate(files, 1):
|
|
166
|
+
text = read_text_file(f)
|
|
167
|
+
chunks = make_chunks(split_sentences(text))
|
|
168
|
+
# token 保險: 單 chunk 超過 2800 字元就再切
|
|
169
|
+
final_chunks: list[str] = []
|
|
170
|
+
for ch in chunks:
|
|
171
|
+
final_chunks.extend(
|
|
172
|
+
[ch[i:i + 2800] for i in range(0, len(ch), 2800)] if len(ch) > 2800 else [ch]
|
|
173
|
+
)
|
|
174
|
+
chunks = final_chunks
|
|
175
|
+
if not chunks:
|
|
176
|
+
continue
|
|
177
|
+
meta = detect_meta(f)
|
|
178
|
+
try:
|
|
179
|
+
kept_chunks, vectors = embed_texts(args.model, chunks, workers=args.workers)
|
|
180
|
+
except requests.exceptions.HTTPError as e:
|
|
181
|
+
print(f"!! HTTP 錯誤 檔案={meta['file']} 前300字={chunks[0][:300] if chunks else ''}")
|
|
182
|
+
raise
|
|
183
|
+
except requests.exceptions.ConnectionError:
|
|
184
|
+
print("無法連線 Ollama,請先啟動 (ollama serve) 並 pull bge-m3")
|
|
185
|
+
sys.exit(1)
|
|
186
|
+
if not kept_chunks:
|
|
187
|
+
continue
|
|
188
|
+
ids = [
|
|
189
|
+
hashlib.md5(f"{meta['file']}|{j}".encode()).hexdigest()
|
|
190
|
+
for j in range(len(kept_chunks))
|
|
191
|
+
]
|
|
192
|
+
metadatas = [
|
|
193
|
+
{**meta, "chunk": j, "chars": len(c), "path": str(f)}
|
|
194
|
+
for j, c in enumerate(kept_chunks)
|
|
195
|
+
]
|
|
196
|
+
col.add(ids=ids, documents=kept_chunks, metadatas=metadatas, embeddings=vectors)
|
|
197
|
+
total_chunks += len(kept_chunks)
|
|
198
|
+
if i % 5 == 0 or i == len(files):
|
|
199
|
+
elapsed = time.time() - t_start
|
|
200
|
+
speed = i / elapsed
|
|
201
|
+
eta = (len(files) - i) / speed if speed > 0 else 0
|
|
202
|
+
print(
|
|
203
|
+
f"[{i}/{len(files)}] {i/len(files)*100:.1f}% | "
|
|
204
|
+
f"{speed:.2f}篇/秒 | 剩餘ETA {eta/60:.1f}分 | "
|
|
205
|
+
f"chunks累計 {total_chunks} | {meta['show'][:10]}/{meta['file'][:25]}",
|
|
206
|
+
flush=True,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
print(f"完成! 共 {len(files)} 篇, {total_chunks} 個 chunk")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
if __name__ == "__main__":
|
|
213
|
+
main()
|
package/rag/query.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""RAG 檢索: 問題 -> bge-m3 -> top-k 片段 -> 自動附加命中檔案的完整原文
|
|
2
|
+
用法:
|
|
3
|
+
python rag/query.py "問題" --topk 8 # 文字輸出
|
|
4
|
+
python rag/query.py "問題" --json # JSON 輸出 (含 full_text)
|
|
5
|
+
python rag/query.py "問題" --no-full # 不回傳全文, 只回傳命中片段
|
|
6
|
+
"""
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
15
|
+
|
|
16
|
+
import chromadb
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
from build_db import read_text_file
|
|
20
|
+
from _resolve import chroma_db_dir
|
|
21
|
+
|
|
22
|
+
OLLAMA_URL = "http://localhost:11434"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def embed_one(model: str, text: str) -> list[float]:
|
|
26
|
+
resp = requests.post(
|
|
27
|
+
f"{OLLAMA_URL}/api/embed", json={"model": model, "input": [text]}, timeout=120
|
|
28
|
+
)
|
|
29
|
+
resp.raise_for_status()
|
|
30
|
+
return resp.json()["embeddings"][0]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def normalize(text: str) -> str:
|
|
34
|
+
return re.sub(r"\s+", "", text)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def locate_chunk(full_norm: str, chunk: str, window: int = 100) -> int:
|
|
38
|
+
head = chunk[:window]
|
|
39
|
+
idx = full_norm.find(head)
|
|
40
|
+
if idx < 0:
|
|
41
|
+
head = chunk[:window // 2]
|
|
42
|
+
idx = full_norm.find(head)
|
|
43
|
+
return idx
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main() -> None:
|
|
47
|
+
ap = argparse.ArgumentParser()
|
|
48
|
+
ap.add_argument("question", nargs="?", help="要查的問題")
|
|
49
|
+
ap.add_argument("--db", default=str(chroma_db_dir()))
|
|
50
|
+
ap.add_argument("--embed", default="bge-m3")
|
|
51
|
+
ap.add_argument("--topk", type=int, default=8)
|
|
52
|
+
ap.add_argument("--json", action="store_true", help="輸出 JSON (含 full_text)")
|
|
53
|
+
ap.add_argument("--no-full", action="store_true", help="不回傳全文")
|
|
54
|
+
ap.add_argument("--out", default="", help="JSON 輸出檔路徑 (與 --json 併用)")
|
|
55
|
+
ap.add_argument("--show", default="", help="只搜指定節目")
|
|
56
|
+
args = ap.parse_args()
|
|
57
|
+
|
|
58
|
+
if not args.question:
|
|
59
|
+
args.question = input("問題: ").strip()
|
|
60
|
+
if not args.question:
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
|
|
63
|
+
client = chromadb.PersistentClient(path=args.db)
|
|
64
|
+
col = client.get_collection("podcasts")
|
|
65
|
+
qvec = embed_one(args.embed, args.question)
|
|
66
|
+
fetch_n = max(args.topk * 5, 50) if args.show else args.topk
|
|
67
|
+
res = col.query(query_embeddings=[qvec], n_results=fetch_n)
|
|
68
|
+
|
|
69
|
+
raw_hits = []
|
|
70
|
+
for doc, meta, dist in zip(
|
|
71
|
+
res["documents"][0], res["metadatas"][0], res["distances"][0]
|
|
72
|
+
):
|
|
73
|
+
if args.show and args.show not in meta.get("show", ""):
|
|
74
|
+
continue
|
|
75
|
+
raw_hits.append({
|
|
76
|
+
"similarity": round(1 - dist, 3),
|
|
77
|
+
"show": meta.get("show", "?"),
|
|
78
|
+
"date": meta.get("date", ""),
|
|
79
|
+
"file": meta.get("file", "?"),
|
|
80
|
+
"path": meta.get("path", ""),
|
|
81
|
+
"chunk": doc,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
by_file: dict[str, dict] = {}
|
|
85
|
+
for h in raw_hits:
|
|
86
|
+
key = h["path"] or h["file"]
|
|
87
|
+
if key not in by_file:
|
|
88
|
+
e = {k: h[k] for k in ("show", "date", "file", "path")}
|
|
89
|
+
e["hits"] = []
|
|
90
|
+
e["best_similarity"] = 0.0
|
|
91
|
+
by_file[key] = e
|
|
92
|
+
by_file[key]["hits"].append(h)
|
|
93
|
+
by_file[key]["best_similarity"] = max(
|
|
94
|
+
by_file[key]["best_similarity"], h["similarity"]
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
results = []
|
|
98
|
+
for key, e in sorted(
|
|
99
|
+
by_file.items(), key=lambda kv: kv[1]["best_similarity"], reverse=True
|
|
100
|
+
):
|
|
101
|
+
entry = {
|
|
102
|
+
"show": e["show"],
|
|
103
|
+
"date": e["date"],
|
|
104
|
+
"file": e["file"],
|
|
105
|
+
"best_similarity": e["best_similarity"],
|
|
106
|
+
"hits": [
|
|
107
|
+
{"similarity": h["similarity"], "chunk": h["chunk"]}
|
|
108
|
+
for h in e["hits"]
|
|
109
|
+
],
|
|
110
|
+
}
|
|
111
|
+
if not args.no_full and e["path"]:
|
|
112
|
+
full = read_text_file(Path(e["path"]))
|
|
113
|
+
full_norm = normalize(full)
|
|
114
|
+
entry["full_text"] = full
|
|
115
|
+
entry["full_text_norm_len"] = len(full_norm)
|
|
116
|
+
entry["offsets"] = [
|
|
117
|
+
locate_chunk(full_norm, h["chunk"]) for h in e["hits"]
|
|
118
|
+
]
|
|
119
|
+
results.append(entry)
|
|
120
|
+
|
|
121
|
+
if args.json:
|
|
122
|
+
payload = {"question": args.question, "results": results}
|
|
123
|
+
out = json.dumps(payload, ensure_ascii=False, indent=2)
|
|
124
|
+
if args.out:
|
|
125
|
+
Path(args.out).write_text(out, encoding="utf-8")
|
|
126
|
+
print(f"已寫入 {args.out}")
|
|
127
|
+
else:
|
|
128
|
+
print(out)
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
for r in results:
|
|
132
|
+
print(f"\n[相似度 {r['best_similarity']}] {r['show']} | {r['date']} | {r['file']}")
|
|
133
|
+
if "full_text" in r:
|
|
134
|
+
print(f" (全文 {len(r['full_text'])} 字, 命中偏移 {r['offsets']})")
|
|
135
|
+
for h in r["hits"][:2]:
|
|
136
|
+
print(f" > {h['chunk'][:120]}...")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
main()
|