playlist-admin 2.15.22 → 2.15.24
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 +34 -9
- package/package.json +1 -1
- package/rag/_resolve.py +17 -2
- package/rag/build_db.py +100 -12
- package/rag/query.py +34 -16
- package/rag/srt_to_text.py +13 -1
package/cli/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { spawn } from 'node:child_process';
|
|
2
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
5
6
|
|
|
6
7
|
// Thin launcher: the CLI engine lives in the Flutter app binary
|
|
7
8
|
// (lib/cli_main.dart), so GUI and CLI share one implementation.
|
|
@@ -37,17 +38,39 @@ function projectRoot() {
|
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
/// npm 包自身目錄(全域 npm i -g 時,cwd 向上找不到 pubspec,
|
|
42
|
+
/// 但包內自帶 cli/ + rag/,RAG 命令靠它就能跑)。
|
|
43
|
+
function packageRoot() {
|
|
44
|
+
try {
|
|
45
|
+
return path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
function exePath(root) {
|
|
41
52
|
const candidates = [
|
|
42
53
|
path.join(root, 'build', 'windows', 'x64', 'runner', 'Release', 'playlist-admin.exe'),
|
|
43
54
|
path.join(root, 'build', 'windows', 'x64', 'runner', 'Debug', 'playlist-admin.exe'),
|
|
55
|
+
path.join(root, 'build', 'windows', 'arm64', 'runner', 'Release', 'playlist-admin.exe'),
|
|
56
|
+
path.join(root, 'build', 'windows', 'arm64', 'runner', 'Debug', 'playlist-admin.exe'),
|
|
44
57
|
path.join(root, 'build', 'windows', 'x64', 'runner', 'Release', 'playlist_administrator.exe'),
|
|
45
58
|
];
|
|
46
59
|
return candidates.find((c) => fs.existsSync(c)) || null;
|
|
47
60
|
}
|
|
48
61
|
|
|
49
62
|
function python() {
|
|
50
|
-
|
|
63
|
+
if (process.env.PYTHON) return process.env.PYTHON;
|
|
64
|
+
// Windows 常只有 py launcher 或 python:依序探測可用的。
|
|
65
|
+
for (const cmd of ['py', 'python', 'python3']) {
|
|
66
|
+
try {
|
|
67
|
+
const r = spawnSync(cmd, ['--version'], { stdio: 'ignore', windowsHide: true });
|
|
68
|
+
if (r.status === 0) return cmd;
|
|
69
|
+
} catch {
|
|
70
|
+
// 試下一個
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return 'python';
|
|
51
74
|
}
|
|
52
75
|
|
|
53
76
|
function forward(exe, args) {
|
|
@@ -82,15 +105,17 @@ function forwardPy(args) {
|
|
|
82
105
|
}
|
|
83
106
|
|
|
84
107
|
function runRag(args) {
|
|
85
|
-
const
|
|
86
|
-
if (
|
|
87
|
-
console.error(
|
|
108
|
+
const sub = args[0];
|
|
109
|
+
if (sub !== 'build' && sub !== 'query') {
|
|
110
|
+
console.error(`未知 rag 子命令: ${sub ?? '(空)'}\n用法: playlist-admin rag build | playlist-admin rag query "問題"`);
|
|
88
111
|
process.exit(1);
|
|
89
112
|
}
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
113
|
+
// 優先專案內 rag/(開發時最新),全域安裝時退回包內自帶的 rag/。
|
|
114
|
+
const roots = [projectRoot(), packageRoot()].filter(Boolean);
|
|
115
|
+
const fname = sub === 'build' ? 'build_db.py' : 'query.py';
|
|
116
|
+
const script = roots.map((r) => path.join(r, 'rag', fname)).find((s) => fs.existsSync(s));
|
|
117
|
+
if (!script) {
|
|
118
|
+
console.error(`找不到 rag/${fname}。請在專案內執行,或設定 PA_ROOT`);
|
|
94
119
|
process.exit(1);
|
|
95
120
|
}
|
|
96
121
|
forwardPy([script, ...args.slice(1)]);
|
package/package.json
CHANGED
package/rag/_resolve.py
CHANGED
|
@@ -32,8 +32,22 @@ def find_base() -> Path:
|
|
|
32
32
|
bp = _win_to_wsl(bp)
|
|
33
33
|
return bp
|
|
34
34
|
|
|
35
|
-
# WSL
|
|
35
|
+
# WSL:優先 HOME/Music 推導,寫死的個人路徑只當最後 fallback。
|
|
36
|
+
# 多使用者機器:優先 USERNAME 對得上的那個。
|
|
36
37
|
if _is_wsl():
|
|
38
|
+
home_music = Path.home() / "Music" / "playlist-admin"
|
|
39
|
+
if home_music.exists():
|
|
40
|
+
return home_music
|
|
41
|
+
try:
|
|
42
|
+
hits = sorted(Path("/mnt/c/Users").glob("*/Music/playlist-admin"))
|
|
43
|
+
if hits:
|
|
44
|
+
me = (os.environ.get("USERNAME") or os.environ.get("USER") or "").lower()
|
|
45
|
+
for h in hits:
|
|
46
|
+
if me and h.parts[3].lower() == me:
|
|
47
|
+
return h
|
|
48
|
+
return hits[0]
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
37
51
|
return Path("/mnt/c/Users/CPXru/Music/playlist-admin")
|
|
38
52
|
|
|
39
53
|
# Windows: 讀 config.json
|
|
@@ -45,7 +59,8 @@ def find_base() -> Path:
|
|
|
45
59
|
pointer = Path(local) / "Playlist Administrator" / "data" / "config.json"
|
|
46
60
|
try:
|
|
47
61
|
if pointer and pointer.exists():
|
|
48
|
-
|
|
62
|
+
# utf-8-sig:BOM 檔用 utf-8 讀會讓 json.loads 炸掉。
|
|
63
|
+
data = json.loads(pointer.read_text(encoding="utf-8-sig"))
|
|
49
64
|
bp = data.get("base_path") or data.get("basePath")
|
|
50
65
|
if bp:
|
|
51
66
|
return Path(bp)
|
package/rag/build_db.py
CHANGED
|
@@ -85,10 +85,12 @@ def read_text_file(path: Path) -> str:
|
|
|
85
85
|
|
|
86
86
|
|
|
87
87
|
def split_sentences(text: str) -> list[str]:
|
|
88
|
-
"""
|
|
89
|
-
|
|
88
|
+
"""依中文句號斷句,過濾空白行。
|
|
89
|
+
空白壓成單空格不斷字:舊寫法刪掉所有空白,英文全黏在一起傷 embedding。
|
|
90
|
+
(query.py 的 normalize 已同步改同一規則,否則 offset 對不上。)"""
|
|
91
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
90
92
|
parts = re.split(r"(?<=[。!?!?;;])", text)
|
|
91
|
-
return [p for p in parts if len(p) >= 4]
|
|
93
|
+
return [p.strip() for p in parts if len(p.strip()) >= 4]
|
|
92
94
|
|
|
93
95
|
|
|
94
96
|
def make_chunks(sentences: list[str], max_len: int = 600, overlap: int = 60) -> list[str]:
|
|
@@ -135,7 +137,41 @@ def main() -> None:
|
|
|
135
137
|
ap.add_argument("--batch", type=int, default=64, help="每批 embedding 數量")
|
|
136
138
|
args = ap.parse_args()
|
|
137
139
|
|
|
140
|
+
# 先拿鎖再開 PersistentClient:反過來雙進程仍會同時開 SQLite。
|
|
141
|
+
lock_file = Path(args.db) / ".build.lock"
|
|
142
|
+
if lock_file.exists():
|
|
143
|
+
# stale 鎖:mtime 超過 30 分鐘(kill -9 殘留)直接清掉重拿。
|
|
144
|
+
try:
|
|
145
|
+
age = time.time() - lock_file.stat().st_mtime
|
|
146
|
+
except Exception:
|
|
147
|
+
age = 0
|
|
148
|
+
if age < 1800:
|
|
149
|
+
print("另一個 build_db 正在寫入此 DB,本次跳過(避免 ChromaDB 寫入衝突)")
|
|
150
|
+
sys.exit(2)
|
|
151
|
+
try:
|
|
152
|
+
lock_file.unlink()
|
|
153
|
+
print("清掉過期 build lock,繼續執行")
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
try:
|
|
157
|
+
fd = os.open(str(lock_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
158
|
+
with os.fdopen(fd, "w") as lf:
|
|
159
|
+
lf.write(f"{os.getpid()}|{time.time()}")
|
|
160
|
+
except FileExistsError:
|
|
161
|
+
print("另一個 build_db 正在寫入此 DB,本次跳過(避免 ChromaDB 寫入衝突)")
|
|
162
|
+
sys.exit(2)
|
|
163
|
+
|
|
138
164
|
client = chromadb.PersistentClient(path=args.db)
|
|
165
|
+
try:
|
|
166
|
+
_main_build(client, args)
|
|
167
|
+
finally:
|
|
168
|
+
try:
|
|
169
|
+
lock_file.unlink()
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _main_build(client, args) -> None:
|
|
139
175
|
if args.reset:
|
|
140
176
|
try:
|
|
141
177
|
client.delete_collection("podcasts")
|
|
@@ -192,13 +228,16 @@ def main() -> None:
|
|
|
192
228
|
return hashlib.md5(raw).hexdigest()
|
|
193
229
|
|
|
194
230
|
migrated = 0
|
|
231
|
+
to_update_ids: list[str] = []
|
|
232
|
+
to_update_metas: list[dict] = []
|
|
195
233
|
offset = 0
|
|
196
234
|
while True:
|
|
197
|
-
|
|
198
|
-
|
|
235
|
+
got = col.get(limit=5000, offset=offset, include=["metadatas"])
|
|
236
|
+
batch = got["metadatas"]
|
|
237
|
+
ids = got["ids"]
|
|
199
238
|
if not batch:
|
|
200
239
|
break
|
|
201
|
-
for m in batch:
|
|
240
|
+
for rid, m in zip(ids, batch):
|
|
202
241
|
if not m or m.get("sig"):
|
|
203
242
|
continue
|
|
204
243
|
p = m.get("path")
|
|
@@ -206,13 +245,26 @@ def main() -> None:
|
|
|
206
245
|
continue
|
|
207
246
|
try:
|
|
208
247
|
sig = file_sig(Path(p))
|
|
248
|
+
m["sig"] = sig
|
|
209
249
|
done_sigs.add(sig)
|
|
250
|
+
to_update_ids.append(rid)
|
|
251
|
+
to_update_metas.append(m)
|
|
210
252
|
migrated += 1
|
|
211
253
|
except Exception:
|
|
212
254
|
pass
|
|
213
255
|
offset += 5000
|
|
214
256
|
if offset > 1000000:
|
|
215
257
|
break
|
|
258
|
+
# 寫回 DB:舊寫法只加記憶體 set,從不寫回,每次啟動重掃且刪檔殘留。
|
|
259
|
+
for i in range(0, len(to_update_ids), 4500):
|
|
260
|
+
try:
|
|
261
|
+
col.update(
|
|
262
|
+
ids=to_update_ids[i:i + 4500],
|
|
263
|
+
metadatas=to_update_metas[i:i + 4500],
|
|
264
|
+
)
|
|
265
|
+
except Exception as e:
|
|
266
|
+
print(f"migration write-back failed: {e}")
|
|
267
|
+
break
|
|
216
268
|
if migrated:
|
|
217
269
|
print(f"migrated {migrated} legacy chunks to content-sig dedup")
|
|
218
270
|
|
|
@@ -231,9 +283,31 @@ def main() -> None:
|
|
|
231
283
|
def has_content(path: Path) -> bool:
|
|
232
284
|
return bool(make_chunks(split_sentences(read_text_file(path))))
|
|
233
285
|
|
|
286
|
+
# 過濾非 podcast 的 .txt:目錄黑名單 + 檔名/大小門檻
|
|
287
|
+
_SKIP_DIRS = {'podcast_rag', '__pycache__', 'chroma_db', 'node_modules', '.git'}
|
|
288
|
+
_SKIP_STEMS = {'README', 'changelog', 'license', 'requirements', 'setup', 'config'}
|
|
289
|
+
_MIN_TXT_BYTES = 100 # podcast 逐字稿至少 ~100 bytes
|
|
290
|
+
|
|
291
|
+
def _is_valid_podcast_txt(f: Path) -> bool:
|
|
292
|
+
# 跳過黑名單目錄下的所有檔案
|
|
293
|
+
for part in f.relative_to(Path(args.data)).parts[:-1]:
|
|
294
|
+
if part in _SKIP_DIRS:
|
|
295
|
+
return False
|
|
296
|
+
# 跳過已知非逐字稿檔名
|
|
297
|
+
if f.stem.lower() in _SKIP_STEMS:
|
|
298
|
+
return False
|
|
299
|
+
# 跳過太短的檔案(通常是設定檔或說明)
|
|
300
|
+
try:
|
|
301
|
+
if f.stat().st_size < _MIN_TXT_BYTES:
|
|
302
|
+
return False
|
|
303
|
+
except OSError:
|
|
304
|
+
return False
|
|
305
|
+
return True
|
|
306
|
+
|
|
234
307
|
files = [
|
|
235
308
|
f for f in sorted(Path(args.data).rglob("*.txt"))
|
|
236
|
-
if
|
|
309
|
+
if _is_valid_podcast_txt(f)
|
|
310
|
+
and file_sig(f) not in done_sigs
|
|
237
311
|
and not (f.stem in skipped and not has_content(f))
|
|
238
312
|
]
|
|
239
313
|
if args.limit:
|
|
@@ -266,23 +340,37 @@ def main() -> None:
|
|
|
266
340
|
sig = file_sig(f)
|
|
267
341
|
try:
|
|
268
342
|
kept_chunks, vectors = embed_texts(args.model, chunks, batch=args.batch, workers=args.workers)
|
|
269
|
-
except requests.exceptions.HTTPError as e:
|
|
270
|
-
print(f"!! HTTP 錯誤 檔案={meta['file']} 前300字={chunks[0][:300] if chunks else ''}")
|
|
271
|
-
raise
|
|
272
343
|
except requests.exceptions.ConnectionError:
|
|
273
344
|
print("無法連線 Ollama,請先啟動 (ollama serve) 並 pull bge-m3")
|
|
274
345
|
sys.exit(1)
|
|
346
|
+
except Exception as e:
|
|
347
|
+
# 單檔失敗(500/429/timeout)不可中斷整個索引:
|
|
348
|
+
# 跳過,下次增量 run 會自動補上(dedup:沒寫入就不在 done_sigs)。
|
|
349
|
+
print(f"!! 跳過 {meta['file']}: {e}")
|
|
350
|
+
continue
|
|
275
351
|
if not kept_chunks:
|
|
276
352
|
continue
|
|
353
|
+
# ID 混入 show+sig+檔名:同節目同內容不同檔也互不相撞;
|
|
354
|
+
# 寫入成功立刻登記 done_sigs(批次內第二檔直接跳過,不等下次 run)。
|
|
355
|
+
try:
|
|
356
|
+
col.delete(where={"path": str(f)})
|
|
357
|
+
except Exception as e:
|
|
358
|
+
print(f"!! 清舊 chunks 失敗 {meta['file']}: {e}")
|
|
277
359
|
ids = [
|
|
278
|
-
hashlib.md5(f"{meta['file']}|{j}".encode()).hexdigest()
|
|
360
|
+
hashlib.md5(f"{meta['show']}|{meta['file']}|{sig}|{j}".encode()).hexdigest()
|
|
279
361
|
for j in range(len(kept_chunks))
|
|
280
362
|
]
|
|
281
363
|
metadatas = [
|
|
282
364
|
{**meta, "sig": sig, "chunk": j, "chars": len(c), "path": str(f)}
|
|
283
365
|
for j, c in enumerate(kept_chunks)
|
|
284
366
|
]
|
|
285
|
-
|
|
367
|
+
try:
|
|
368
|
+
col.add(ids=ids, documents=kept_chunks, metadatas=metadatas, embeddings=vectors)
|
|
369
|
+
except Exception as e:
|
|
370
|
+
# DuplicateID 等單檔失敗不可崩整個 build:下次增量補上。
|
|
371
|
+
print(f"!! 寫入失敗跳過 {meta['file']}: {e}")
|
|
372
|
+
continue
|
|
373
|
+
done_sigs.add(sig)
|
|
286
374
|
total_chunks += len(kept_chunks)
|
|
287
375
|
if i % 5 == 0 or i == len(files):
|
|
288
376
|
elapsed = time.time() - t_start
|
package/rag/query.py
CHANGED
|
@@ -36,7 +36,9 @@ def embed_one(model: str, text: str) -> list[float]:
|
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
def normalize(text: str) -> str:
|
|
39
|
-
|
|
39
|
+
# 與 build_db.split_sentences 同規則(單空格),否則 chunk offset 對不上。
|
|
40
|
+
# 注意:此規則變更前的舊 chunks 是無空白版,offset 可能為 -1(僅顯示用,不影響檢索)。
|
|
41
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
40
42
|
|
|
41
43
|
|
|
42
44
|
def locate_chunk(full_norm: str, chunk: str, window: int = 100) -> int:
|
|
@@ -64,8 +66,9 @@ def pick_chat_models() -> list[str]:
|
|
|
64
66
|
return [*local, *cloud]
|
|
65
67
|
|
|
66
68
|
|
|
67
|
-
def generate_answer(question: str, hits: list[dict]) -> str:
|
|
68
|
-
"""用 Ollama
|
|
69
|
+
def generate_answer(question: str, hits: list[dict], quiet: bool = False) -> str:
|
|
70
|
+
"""用 Ollama 生成基於檢索片段的回答;全部模型都失敗就回傳錯誤訊息。
|
|
71
|
+
quiet=True 時不印 token(--json 模式:stdout 只能有 JSON,否則解析失敗)。"""
|
|
69
72
|
context = "\n".join(
|
|
70
73
|
f"[{h['show']} | {h['date']} | {h['file']}]\n{h['chunk']}"
|
|
71
74
|
for h in hits
|
|
@@ -101,10 +104,12 @@ def generate_answer(question: str, hits: list[dict]) -> str:
|
|
|
101
104
|
token = chunk.get("message", {}).get("content", "")
|
|
102
105
|
if token:
|
|
103
106
|
answer.append(token)
|
|
104
|
-
|
|
107
|
+
if not quiet:
|
|
108
|
+
print(token, end="", flush=True)
|
|
105
109
|
except json.JSONDecodeError:
|
|
106
110
|
pass
|
|
107
|
-
|
|
111
|
+
if not quiet:
|
|
112
|
+
print() # 換行
|
|
108
113
|
return "".join(answer).strip()
|
|
109
114
|
except requests.RequestException as e:
|
|
110
115
|
errors.append(f"{model}: {e}")
|
|
@@ -139,9 +144,10 @@ def main() -> None:
|
|
|
139
144
|
res = col.query(query_embeddings=[qvec], n_results=fetch_n)
|
|
140
145
|
|
|
141
146
|
raw_hits = []
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
)
|
|
147
|
+
docs = res["documents"][0] if res.get("documents") else []
|
|
148
|
+
metas = res["metadatas"][0] if res.get("metadatas") else []
|
|
149
|
+
dists = res["distances"][0] if res.get("distances") else []
|
|
150
|
+
for doc, meta, dist in zip(docs, metas, dists):
|
|
145
151
|
if args.show and args.show not in meta.get("show", ""):
|
|
146
152
|
continue
|
|
147
153
|
raw_hits.append({
|
|
@@ -181,20 +187,32 @@ def main() -> None:
|
|
|
181
187
|
],
|
|
182
188
|
}
|
|
183
189
|
if not args.no_full and e["path"]:
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
190
|
+
# 檔案被搬走/刪除時跳過全文(舊寫法直接拋 FileNotFoundError 整個查詢崩潰)。
|
|
191
|
+
# 缺檔也要寫固定鍵:下游取 full_text/offsets 不可 KeyError。
|
|
192
|
+
try:
|
|
193
|
+
full = read_text_file(Path(e["path"]))
|
|
194
|
+
except (OSError, FileNotFoundError):
|
|
195
|
+
full = ""
|
|
196
|
+
if full:
|
|
197
|
+
full_norm = normalize(full)
|
|
198
|
+
entry["full_text"] = full
|
|
199
|
+
entry["full_text_norm_len"] = len(full_norm)
|
|
200
|
+
entry["offsets"] = [
|
|
201
|
+
locate_chunk(full_norm, h["chunk"]) for h in e["hits"]
|
|
202
|
+
]
|
|
203
|
+
else:
|
|
204
|
+
entry["full_text"] = ""
|
|
205
|
+
entry["full_text_norm_len"] = 0
|
|
206
|
+
entry["offsets"] = []
|
|
191
207
|
results.append(entry)
|
|
192
208
|
|
|
193
209
|
if args.json:
|
|
194
210
|
payload = {"question": args.question, "results": results}
|
|
195
211
|
if args.answer:
|
|
196
212
|
try:
|
|
197
|
-
payload["answer"] = generate_answer(
|
|
213
|
+
payload["answer"] = generate_answer(
|
|
214
|
+
args.question, raw_hits[: max(args.topk_answer, 3)], quiet=True
|
|
215
|
+
)
|
|
198
216
|
except Exception as e:
|
|
199
217
|
payload["answer_error"] = f"(Ollama 生成失敗: {e})"
|
|
200
218
|
out = json.dumps(payload, ensure_ascii=False, indent=2)
|
package/rag/srt_to_text.py
CHANGED
|
@@ -11,8 +11,20 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
11
11
|
from _resolve import podcast_downloads_dir
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
def read_srt(srt_path: Path) -> str:
|
|
15
|
+
"""讀檔:utf-8-sig → utf-8 → big5 → gb18030,和 build_db 同順序。
|
|
16
|
+
舊寫法只試 utf-8-sig,Big5 繁中字幕直接亂碼進庫。"""
|
|
17
|
+
raw = srt_path.read_bytes()
|
|
18
|
+
for enc in ("utf-8-sig", "utf-8", "big5", "gb18030"):
|
|
19
|
+
try:
|
|
20
|
+
return raw.decode(enc)
|
|
21
|
+
except UnicodeDecodeError:
|
|
22
|
+
continue
|
|
23
|
+
return raw.decode("utf-8", errors="replace")
|
|
24
|
+
|
|
25
|
+
|
|
14
26
|
def srt_to_text(srt_path: Path) -> str:
|
|
15
|
-
text = srt_path
|
|
27
|
+
text = read_srt(srt_path)
|
|
16
28
|
# 移除 srt 索引行 (純數字行)
|
|
17
29
|
text = re.sub(r"^\d+\s*$", "", text, flags=re.MULTILINE)
|
|
18
30
|
# 移除時間軸行 (00:00:00,000 --> 00:00:01,000)
|