playlist-admin 2.6.81 → 2.6.83

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 CHANGED
@@ -1,33 +1,95 @@
1
1
  #!/usr/bin/env node
2
- import { loadConfig } from './lib/config.js';
3
- import { PipelineOrchestrator } from './lib/pipeline.js';
4
- import { PodcastPipeline } from './lib/podcast.js';
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
- const HELP = `Playlist Administrator CLI (Node)
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 Run full pipeline
12
- playlist-admin pipeline --step N Run single step
13
- playlist-admin spotube-download <name> Download one playlist
14
- playlist-admin spotube-download-all Download all playlists
15
- playlist-admin spotube-move Move M4A files
16
- playlist-admin spotube-cleanup Remove metadata-renamed mp3 duplicates
17
- playlist-admin podcast Run podcast pipeline
18
- playlist-admin status Show status
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 getFlag(args, flag) {
22
- const idx = args.indexOf(flag);
23
- if (idx >= 0 && idx + 1 < args.length) {
24
- return parseInt(args[idx + 1], 10) || 0;
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
- const onProgress = () => {};
30
- const isCancelled = () => false;
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 config = loadConfig();
40
- const cmd = args[0];
41
-
42
- switch (cmd) {
43
- case 'pipeline': {
44
- const fromStep = getFlag(args, '--step');
45
- const orch = new PipelineOrchestrator({ config, onLog: (m) => console.log(m), onProgress, isCancelled });
46
- await orch.run({ fromStep });
47
- break;
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.81",
4
- "description": "Playlist Administrator CLI - music library pipeline, podcast pipeline and Spotube automation (Node.js)",
3
+ "version": "2.6.83",
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,9 @@
16
16
  "spotube-cleanup": "node cli/index.js spotube-cleanup"
17
17
  },
18
18
  "files": [
19
- "cli/",
20
- "assets/zhcdict.json",
21
- "README.md",
22
- "COMMANDS.md"
19
+ "cli/index.js",
20
+ "rag/*.py",
21
+ "rag/README.md"
23
22
  ],
24
23
  "keywords": [
25
24
  "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 路徑
@@ -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"
@@ -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()