playlist-admin 2.15.23 → 2.15.25

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,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.
@@ -20,6 +21,8 @@ Usage:
20
21
  playlist-admin favorite toggle <song> Toggle favorite (我的最愛)
21
22
  playlist-admin rag build [--reset] Build podcast RAG vector DB
22
23
  playlist-admin rag query "問題" [--topk N] [--show 節目] [--json]
24
+ playlist-admin study build [--reset] Build study RAG (PDF + 課程)
25
+ playlist-admin study query "問題" [--topk N] [--category X] [--json]
23
26
  `;
24
27
 
25
28
  function projectRoot() {
@@ -37,17 +40,39 @@ function projectRoot() {
37
40
  }
38
41
  }
39
42
 
43
+ /// npm 包自身目錄(全域 npm i -g 時,cwd 向上找不到 pubspec,
44
+ /// 但包內自帶 cli/ + rag/,RAG 命令靠它就能跑)。
45
+ function packageRoot() {
46
+ try {
47
+ return path.dirname(path.dirname(fileURLToPath(import.meta.url)));
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
40
53
  function exePath(root) {
41
54
  const candidates = [
42
55
  path.join(root, 'build', 'windows', 'x64', 'runner', 'Release', 'playlist-admin.exe'),
43
56
  path.join(root, 'build', 'windows', 'x64', 'runner', 'Debug', 'playlist-admin.exe'),
57
+ path.join(root, 'build', 'windows', 'arm64', 'runner', 'Release', 'playlist-admin.exe'),
58
+ path.join(root, 'build', 'windows', 'arm64', 'runner', 'Debug', 'playlist-admin.exe'),
44
59
  path.join(root, 'build', 'windows', 'x64', 'runner', 'Release', 'playlist_administrator.exe'),
45
60
  ];
46
61
  return candidates.find((c) => fs.existsSync(c)) || null;
47
62
  }
48
63
 
49
64
  function python() {
50
- return process.env.PYTHON || 'python';
65
+ if (process.env.PYTHON) return process.env.PYTHON;
66
+ // Windows 常只有 py launcher 或 python:依序探測可用的。
67
+ for (const cmd of ['py', 'python', 'python3']) {
68
+ try {
69
+ const r = spawnSync(cmd, ['--version'], { stdio: 'ignore', windowsHide: true });
70
+ if (r.status === 0) return cmd;
71
+ } catch {
72
+ // 試下一個
73
+ }
74
+ }
75
+ return 'python';
51
76
  }
52
77
 
53
78
  function forward(exe, args) {
@@ -82,15 +107,33 @@ function forwardPy(args) {
82
107
  }
83
108
 
84
109
  function runRag(args) {
85
- const root = projectRoot();
86
- if (!root) {
87
- console.error('找不到專案根目錄(pubspec.yaml)。請在專案內執行,或設定 PA_ROOT');
110
+ const sub = args[0];
111
+ if (sub !== 'build' && sub !== 'query') {
112
+ console.error(`未知 rag 子命令: ${sub ?? '(空)'}\n用法: playlist-admin rag build | playlist-admin rag query "問題"`);
88
113
  process.exit(1);
89
114
  }
115
+ // 優先專案內 rag/(開發時最新),全域安裝時退回包內自帶的 rag/。
116
+ const roots = [projectRoot(), packageRoot()].filter(Boolean);
117
+ const fname = sub === 'build' ? 'build_db.py' : 'query.py';
118
+ const script = roots.map((r) => path.join(r, 'rag', fname)).find((s) => fs.existsSync(s));
119
+ if (!script) {
120
+ console.error(`找不到 rag/${fname}。請在專案內執行,或設定 PA_ROOT`);
121
+ process.exit(1);
122
+ }
123
+ forwardPy([script, ...args.slice(1)]);
124
+ }
125
+
126
+ function runStudy(args) {
90
127
  const sub = args[0];
91
- const script = path.join(root, 'rag', sub === 'build' ? 'build_db.py' : 'query.py');
92
- if (!fs.existsSync(script)) {
93
- console.error(`找不到 ${script}`);
128
+ if (sub !== 'build' && sub !== 'query') {
129
+ console.error(`未知 study 子命令: ${sub ?? '(空)'}\n用法: playlist-admin study build | playlist-admin study query "問題"`);
130
+ process.exit(1);
131
+ }
132
+ const roots = [projectRoot(), packageRoot()].filter(Boolean);
133
+ const fname = sub === 'build' ? 'study_build.py' : 'study_query.py';
134
+ const script = roots.map((r) => path.join(r, 'rag', fname)).find((s) => fs.existsSync(s));
135
+ if (!script) {
136
+ console.error(`找不到 rag/${fname}。請在專案內執行,或設定 PA_ROOT`);
94
137
  process.exit(1);
95
138
  }
96
139
  forwardPy([script, ...args.slice(1)]);
@@ -106,6 +149,10 @@ async function main() {
106
149
  runRag(args.slice(1));
107
150
  return;
108
151
  }
152
+ if (args[0] === 'study') {
153
+ runStudy(args.slice(1));
154
+ return;
155
+ }
109
156
 
110
157
  const root = projectRoot();
111
158
  if (!root) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "playlist-admin",
3
- "version": "2.15.23",
4
- "description": "playlist-admin CLI - thin launcher for the Flutter app engine (pipeline, podcast) + podcast RAG query",
3
+ "version": "2.15.25",
4
+ "description": "playlist-admin CLI - pipeline, podcast, music + podcast RAG + study RAG (PDF 試題/課本)",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "playlist-admin": "./cli/index.js"
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: 直接用 /mnt/c/ 路徑
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
- data = json.loads(pointer.read_text(encoding="utf-8"))
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
- text = re.sub(r"\s+", "", text)
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
- batch = col.get(limit=5000, offset=offset,
198
- include=["metadatas"])["metadatas"]
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 file_sig(f) not in done_sigs
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
- col.add(ids=ids, documents=kept_chunks, metadatas=metadatas, embeddings=vectors)
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
- return re.sub(r"\s+", "", text)
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
- print(token, end="", flush=True)
107
+ if not quiet:
108
+ print(token, end="", flush=True)
105
109
  except json.JSONDecodeError:
106
110
  pass
107
- print() # 換行
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
- for doc, meta, dist in zip(
143
- res["documents"][0], res["metadatas"][0], res["distances"][0]
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
- full = read_text_file(Path(e["path"]))
185
- full_norm = normalize(full)
186
- entry["full_text"] = full
187
- entry["full_text_norm_len"] = len(full_norm)
188
- entry["offsets"] = [
189
- locate_chunk(full_norm, h["chunk"]) for h in e["hits"]
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(args.question, raw_hits[: max(args.topk_answer, 3)])
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)
@@ -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.read_text(encoding="utf-8-sig", errors="replace")
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)
@@ -0,0 +1,311 @@
1
+ """Build study RAG: PDF 試題/課本 + 課程逐字稿 → ChromaDB study collection
2
+ 用法:
3
+ python rag/study_build.py # 增量建庫
4
+ python rag/study_build.py --reset # 清空重建
5
+ python rag/study_build.py --pdf-root <DIR> # 自訂 PDF 根目錄
6
+ python rag/study_build.py --limit 10 # 只建前 10 個 PDF(測試用)
7
+ """
8
+ import argparse
9
+ import hashlib
10
+ import os
11
+ import re
12
+ import sys
13
+ import time
14
+ from concurrent.futures import ThreadPoolExecutor, as_completed
15
+ from pathlib import Path
16
+
17
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
18
+
19
+ import chromadb
20
+ import requests
21
+
22
+ from _resolve import find_base, chroma_db_dir
23
+
24
+ if hasattr(sys.stdout, "reconfigure"):
25
+ sys.stdout.reconfigure(encoding="utf-8")
26
+
27
+ COLLECTION = "study"
28
+ OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
29
+ EMBED_MODEL = os.environ.get("OLLAMA_EMBED_MODEL", "bge-m3")
30
+
31
+ # ── Embedding ────────────────────────────────────────────────────────
32
+
33
+ def embed_one_batch(model: str, texts: list[str]) -> tuple[list[str], list[list[float]]]:
34
+ resp = requests.post(
35
+ f"{OLLAMA_URL}/api/embed",
36
+ json={"model": model, "input": texts},
37
+ timeout=120,
38
+ )
39
+ if resp.status_code == 400:
40
+ ok, vecs = [], []
41
+ for t in texts:
42
+ try:
43
+ r2 = requests.post(
44
+ f"{OLLAMA_URL}/api/embed",
45
+ json={"model": model, "input": [t]}, timeout=120,
46
+ )
47
+ r2.raise_for_status()
48
+ ok.append(t)
49
+ vecs.append(r2.json()["embeddings"][0])
50
+ except Exception:
51
+ pass
52
+ return ok, vecs
53
+ resp.raise_for_status()
54
+ return texts, resp.json()["embeddings"]
55
+
56
+
57
+ def embed_texts(model: str, texts: list[str], batch: int = 32, workers: int = 6):
58
+ batches = [texts[i:i + batch] for i in range(0, len(texts), batch)]
59
+ if len(batches) <= 1:
60
+ return embed_one_batch(model, batches[0]) if batches else ([], [])
61
+ kept, vecs = [], []
62
+ results = [None] * len(batches)
63
+ with ThreadPoolExecutor(max_workers=workers) as ex:
64
+ fut = {ex.submit(embed_one_batch, model, b): i for i, b in enumerate(batches)}
65
+ for f in as_completed(fut):
66
+ results[fut[f]] = f.result()
67
+ for k, v in results:
68
+ if k is None:
69
+ continue
70
+ kept.extend(k)
71
+ vecs.extend(v)
72
+ return kept, vecs
73
+
74
+
75
+ # ── PDF extraction ───────────────────────────────────────────────────
76
+
77
+ def extract_pdf_text(pdf_path: Path) -> str:
78
+ """用 PyMuPDF 提取 PDF 文字"""
79
+ try:
80
+ import fitz
81
+ doc = fitz.open(str(pdf_path))
82
+ parts = []
83
+ for page in doc:
84
+ parts.append(page.get_text())
85
+ doc.close()
86
+ return "\n".join(parts)
87
+ except Exception as e:
88
+ return f"[PDF extraction error: {e}]"
89
+
90
+
91
+ # ── Chunking ─────────────────────────────────────────────────────────
92
+
93
+ def split_sentences(text: str) -> list[str]:
94
+ text = re.sub(r"\s+", " ", text).strip()
95
+ parts = re.split(r"(?<=[。!?!?;;\n])", text)
96
+ return [p.strip() for p in parts if len(p.strip()) >= 4]
97
+
98
+
99
+ def make_chunks(sentences: list[str], max_len: int = 800, overlap: int = 80) -> list[str]:
100
+ """句子接成 chunk,max_len 較大(試題需要更多上下文)"""
101
+ chunks: list[str] = []
102
+ cur = ""
103
+ for s in sentences:
104
+ if len(cur) + len(s) > max_len and cur:
105
+ chunks.append(cur.strip())
106
+ # overlap: 保留尾巴
107
+ overlap_text = cur[-overlap:] if len(cur) > overlap else cur
108
+ cur = overlap_text + " " + s
109
+ else:
110
+ cur = cur + " " + s if cur else s
111
+ if cur.strip():
112
+ chunks.append(cur.strip())
113
+ # 超長強制硬切
114
+ final = []
115
+ for ch in chunks:
116
+ if len(ch) > 2800:
117
+ final.extend(ch[i:i + 2800] for i in range(0, len(ch), 2800))
118
+ else:
119
+ final.append(ch)
120
+ return final
121
+
122
+
123
+ def file_sig(path: Path) -> str:
124
+ return hashlib.md5(str(path.resolve()).encode()).hexdigest()
125
+
126
+
127
+ def content_sig(text: str) -> str:
128
+ return hashlib.md5(text[:4096].encode()).hexdigest()
129
+
130
+
131
+ # ── PDF discovery ────────────────────────────────────────────────────
132
+
133
+ def discover_pdfs(root: Path) -> list[tuple[str, Path]]:
134
+ """回傳 (category, pdf_path) 列表"""
135
+ results = []
136
+ for dirpath, dirnames, filenames in os.walk(str(root)):
137
+ dirnames[:] = [d for d in dirnames if not d.startswith('.')]
138
+ dp = Path(dirpath)
139
+ rel = dp.relative_to(root)
140
+ category = str(rel).replace("\\", "/") if str(rel) != "." else root.name
141
+ for f in sorted(filenames):
142
+ if f.lower().endswith(".pdf"):
143
+ results.append((category, dp / f))
144
+ return results
145
+
146
+
147
+ def discover_txt_transcripts(podcasts_dir: Path) -> list[tuple[str, Path]]:
148
+ """掃描 podcast 資料夾中的課程目錄(非 RSS)"""
149
+ results = []
150
+ # 課程目錄名單(有 .txt 逐字稿的非 RSS 資料夾)
151
+ course_dirs = [
152
+ 'T夜工智CoCo英文S', 'T夜工智張瑀國文S', 'T夜工智胡傑數學S',
153
+ '機械群劉徹機械力學', '機械群劉徹機械力學S',
154
+ '機械群許凱機件原理', '機械群許凱機件原理S',
155
+ '機械群陳海程機械製圖', '機械群陳海程機械製圖S',
156
+ '機械群陳海程機械製造與實習', '機械群陳海程機械製造與實習S',
157
+ ]
158
+ for name in course_dirs:
159
+ d = podcasts_dir / name
160
+ if not d.is_dir():
161
+ continue
162
+ for f in sorted(d.glob("*.txt")):
163
+ results.append((f"課程/{name}", f))
164
+ return results
165
+
166
+
167
+ # ── Main ─────────────────────────────────────────────────────────────
168
+
169
+ def main():
170
+ ap = argparse.ArgumentParser(description="Build study RAG")
171
+ ap.add_argument("--reset", action="store_true", help="清空重建")
172
+ ap.add_argument("--pdf-root", default="", help="PDF 根目錄")
173
+ ap.add_argument("--limit", type=int, default=0, help="只建前 N 個 PDF(測試)")
174
+ ap.add_argument("--db", default=str(chroma_db_dir()))
175
+ ap.add_argument("--embed", default=EMBED_MODEL)
176
+ ap.add_argument("--workers", type=int, default=6)
177
+ args = ap.parse_args()
178
+
179
+ # Resolve PDF root
180
+ if args.pdf_root:
181
+ pdf_root = Path(args.pdf_root)
182
+ else:
183
+ # Auto-discover: check Desktop, then common locations
184
+ home = Path.home()
185
+ candidates = [
186
+ home / "Desktop" / "四技二專歷屆試題與解答",
187
+ find_base() / "study_materials",
188
+ ]
189
+ pdf_root = None
190
+ for c in candidates:
191
+ if c.exists():
192
+ pdf_root = c
193
+ break
194
+ if pdf_root is None:
195
+ print("找不到 PDF 目錄。請用 --pdf-root 指定。")
196
+ sys.exit(1)
197
+
198
+ print(f"PDF 根目錄: {pdf_root}")
199
+
200
+ # Connect ChromaDB
201
+ client = chromadb.PersistentClient(path=args.db)
202
+ if args.reset:
203
+ try:
204
+ client.delete_collection(COLLECTION)
205
+ print("已清空 study collection")
206
+ except Exception:
207
+ pass
208
+ col = client.get_or_create_collection(
209
+ COLLECTION,
210
+ metadata={"hnsw:space": "cosine"},
211
+ )
212
+
213
+ # Existing signatures (dedup)
214
+ existing = col.get(include=["metadatas"])
215
+ done_sigs: set[str] = set()
216
+ if existing["ids"]:
217
+ for meta in existing["metadatas"]:
218
+ sig = meta.get("sig", "")
219
+ if sig:
220
+ done_sigs.add(sig)
221
+ print(f"已存在 {len(existing['ids'])} 筆, 已知 sig {len(done_sigs)} 個")
222
+
223
+ # Discover files
224
+ pdfs = discover_pdfs(pdf_root)
225
+ transcripts = discover_txt_transcripts(find_base() / "podcasts")
226
+ all_files = [(cat, p, "pdf") for cat, p in pdfs] + [(cat, p, "txt") for cat, p in transcripts]
227
+ print(f"發現 {len(pdfs)} 個 PDF, {len(transcripts)} 個逐字稿")
228
+
229
+ if args.limit:
230
+ all_files = all_files[:args.limit]
231
+ print(f"測試模式: 只建前 {args.limit} 個")
232
+
233
+ # Extract → chunk → embed
234
+ total_chunks = 0
235
+ t_start = time.time()
236
+
237
+ for fi, (category, fpath, ftype) in enumerate(all_files, 1):
238
+ if ftype == "pdf":
239
+ sig = file_sig(fpath)
240
+ if sig in done_sigs:
241
+ continue
242
+ text = extract_pdf_text(fpath)
243
+ else:
244
+ sig = file_sig(fpath)
245
+ if sig in done_sigs:
246
+ continue
247
+ text = fpath.read_text(encoding="utf-8-sig", errors="replace")
248
+
249
+ if not text or len(text.strip()) < 20:
250
+ continue
251
+
252
+ chunks = make_chunks(split_sentences(text))
253
+ if not chunks:
254
+ continue
255
+
256
+ # Token safety: 2800 chars per chunk
257
+ final_chunks = []
258
+ for ch in chunks:
259
+ if len(ch) > 2800:
260
+ final_chunks.extend(ch[i:i + 2800] for i in range(0, len(ch), 2800))
261
+ else:
262
+ final_chunks.append(ch)
263
+ chunks = final_chunks
264
+
265
+ # Embed
266
+ kept, vectors = embed_texts(args.embed, chunks, workers=args.workers)
267
+ if not kept:
268
+ continue
269
+
270
+ # Write to ChromaDB
271
+ prefix = f"study_{sig[:8]}"
272
+ ids = [f"{prefix}_{i}" for i in range(len(kept))]
273
+ metas = [
274
+ {
275
+ "category": category,
276
+ "file": fpath.name,
277
+ "path": str(fpath),
278
+ "sig": sig if i == 0 else "", # only first chunk carries sig
279
+ "chunk_index": i,
280
+ "total_chunks": len(kept),
281
+ "source_type": ftype,
282
+ }
283
+ for i in range(len(kept))
284
+ ]
285
+
286
+ batch_size = 4500
287
+ for bi in range(0, len(ids), batch_size):
288
+ col.add(
289
+ ids=ids[bi:bi + batch_size],
290
+ documents=kept[bi:bi + batch_size],
291
+ metadatas=metas[bi:bi + batch_size],
292
+ embeddings=vectors[bi:bi + batch_size],
293
+ )
294
+
295
+ total_chunks += len(kept)
296
+ elapsed = time.time() - t_start
297
+ pct = fi / len(all_files) * 100
298
+ rate = fi / elapsed if elapsed > 0 else 0
299
+ eta = (len(all_files) - fi) / rate if rate > 0 else 0
300
+ print(
301
+ f"\r [{fi}/{len(all_files)}] {pct:.1f}% "
302
+ f"| {rate:.2f} files/s | ETA {eta / 60:.1f}min "
303
+ f"| chunks累計 {total_chunks} | {category}/{fpath.name[:30]}",
304
+ end="", flush=True,
305
+ )
306
+
307
+ print(f"\n完成! 共 {total_chunks} 個新 chunk, 總計 {col.count()} 筆")
308
+
309
+
310
+ if __name__ == "__main__":
311
+ main()
@@ -0,0 +1,180 @@
1
+ """Study RAG 查詢: 問題 → bge-m3 → top-k 片段
2
+ 用法:
3
+ python rag/study_query.py "問題" # 文字輸出
4
+ python rag/study_query.py "問題" --json # JSON 輸出
5
+ python rag/study_query.py "問題" --topk 5 # 前 5 個結果
6
+ python rag/study_query.py "問題" --category 機械 # 只搜某分類
7
+ python rag/study_query.py "問題" --answer # Ollama 生成回答
8
+ """
9
+ import argparse
10
+ import json
11
+ import os
12
+ import re
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
17
+
18
+ import chromadb
19
+ import requests
20
+
21
+ from _resolve import chroma_db_dir
22
+
23
+ if hasattr(sys.stdout, "reconfigure"):
24
+ sys.stdout.reconfigure(encoding="utf-8")
25
+
26
+ OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
27
+ CHAT_MODEL = os.environ.get("OLLAMA_CHAT_MODEL", "qwen2.5:7b")
28
+ COLLECTION = "study"
29
+
30
+
31
+ def embed_one(model: str, text: str) -> list[float]:
32
+ resp = requests.post(
33
+ f"{OLLAMA_URL}/api/embed", json={"model": model, "input": [text]}, timeout=120
34
+ )
35
+ resp.raise_for_status()
36
+ return resp.json()["embeddings"][0]
37
+
38
+
39
+ def read_file(path_str: str) -> str:
40
+ p = Path(path_str)
41
+ if not p.exists():
42
+ return ""
43
+ for enc in ("utf-8-sig", "utf-8", "big5", "gb18030"):
44
+ try:
45
+ return p.read_text(encoding=enc)
46
+ except (UnicodeDecodeError, UnicodeError):
47
+ continue
48
+ return p.read_text(encoding="utf-8", errors="replace")
49
+
50
+
51
+ def pick_chat_models() -> list[str]:
52
+ forced = os.environ.get("OLLAMA_CHAT_MODEL", "").strip()
53
+ try:
54
+ tags = requests.get(f"{OLLAMA_URL}/api/tags", timeout=30).json().get("models", [])
55
+ except Exception:
56
+ return [forced] if forced else []
57
+ local = [m["name"] for m in tags if m.get("size", 0) > 0]
58
+ cloud = [m["name"] for m in tags if m.get("size", 0) == 0]
59
+ banned = {"bge-m3", "bge-m3:latest"}
60
+ local = [m for m in local if m not in banned]
61
+ if forced:
62
+ return [forced, *local, *cloud]
63
+ return [*local, *cloud]
64
+
65
+
66
+ def generate_answer(question: str, hits: list[dict], quiet: bool = False) -> str:
67
+ context = "\n".join(
68
+ f"[{h['category']} | {h['file']}]\n{h['chunk']}" for h in hits
69
+ )
70
+ prompt = (
71
+ "你是學習助理,專精四技二專統一入學測驗與機械群專業科目。"
72
+ "請只根據以下學習資料片段回答使用者的問題,"
73
+ "回答用繁體中文,簡潔、分點陳述,並在結尾列出引用來源。\n\n"
74
+ f"片段資料:\n{context}\n\n問題:{question}"
75
+ )
76
+ errors = []
77
+ for model in pick_chat_models():
78
+ try:
79
+ resp = requests.post(
80
+ f"{OLLAMA_URL}/api/chat",
81
+ json={"model": model, "messages": [{"role": "user", "content": prompt}], "stream": True},
82
+ timeout=600, stream=True,
83
+ )
84
+ if resp.status_code in (400, 401, 404):
85
+ errors.append(f"{model}: HTTP {resp.status_code}")
86
+ continue
87
+ resp.raise_for_status()
88
+ answer = []
89
+ for line in resp.iter_lines():
90
+ if not line:
91
+ continue
92
+ try:
93
+ chunk = json.loads(line)
94
+ token = chunk.get("message", {}).get("content", "")
95
+ if token:
96
+ answer.append(token)
97
+ if not quiet:
98
+ print(token, end="", flush=True)
99
+ except json.JSONDecodeError:
100
+ pass
101
+ if not quiet:
102
+ print()
103
+ return "".join(answer).strip()
104
+ except requests.RequestException as e:
105
+ errors.append(f"{model}: {e}")
106
+ if errors:
107
+ raise RuntimeError(" | ".join(errors))
108
+ return ""
109
+
110
+
111
+ def main():
112
+ ap = argparse.ArgumentParser(description="Study RAG query")
113
+ ap.add_argument("question", nargs="?", help="要查的問題")
114
+ ap.add_argument("--db", default=str(chroma_db_dir()))
115
+ ap.add_argument("--embed", default="bge-m3")
116
+ ap.add_argument("--topk", type=int, default=5)
117
+ ap.add_argument("--json", action="store_true")
118
+ ap.add_argument("--category", default="", help="只搜某分類")
119
+ ap.add_argument("--answer", action="store_true", help="用 Ollama 生成回答")
120
+ ap.add_argument("--topk-answer", type=int, default=4)
121
+ args = ap.parse_args()
122
+
123
+ if not args.question:
124
+ args.question = input("問題: ").strip()
125
+ if not args.question:
126
+ sys.exit(1)
127
+
128
+ client = chromadb.PersistentClient(path=args.db)
129
+ try:
130
+ col = client.get_collection(COLLECTION)
131
+ except Exception:
132
+ print(f"找不到 {COLLECTION} collection。請先執行: python rag/study_build.py")
133
+ sys.exit(1)
134
+
135
+ qvec = embed_one(args.embed, args.question)
136
+ fetch_n = max(args.topk * 5, 30) if args.category else args.topk
137
+ where_filter = {"category": {"$contains": args.category}} if args.category else None
138
+ res = col.query(
139
+ query_embeddings=[qvec],
140
+ n_results=fetch_n,
141
+ where=where_filter,
142
+ )
143
+
144
+ raw_hits = []
145
+ docs = res["documents"][0] if res.get("documents") else []
146
+ metas = res["metadatas"][0] if res.get("metadatas") else []
147
+ dists = res["distances"][0] if res.get("distances") else []
148
+ for doc, meta, dist in zip(docs, metas, dists):
149
+ if args.category and args.category not in meta.get("category", ""):
150
+ continue
151
+ raw_hits.append({
152
+ "similarity": round(1 - dist, 3),
153
+ "category": meta.get("category", "?"),
154
+ "file": meta.get("file", "?"),
155
+ "path": meta.get("path", ""),
156
+ "source_type": meta.get("source_type", "?"),
157
+ "chunk": doc,
158
+ })
159
+
160
+ if args.json:
161
+ payload = {"question": args.question, "results": raw_hits[:args.topk]}
162
+ if args.answer:
163
+ try:
164
+ payload["answer"] = generate_answer(
165
+ args.question, raw_hits[:args.topk_answer], quiet=True
166
+ )
167
+ except Exception as e:
168
+ payload["answer_error"] = str(e)
169
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
170
+ return
171
+
172
+ print(f"\n🔍 {args.question}\n")
173
+ for i, h in enumerate(raw_hits[:args.topk], 1):
174
+ print(f"[{i}] 類似度 {h['similarity']} | {h['source_type']} | {h['category']}/{h['file']}")
175
+ print(f" {h['chunk'][:150]}...")
176
+ print()
177
+
178
+
179
+ if __name__ == "__main__":
180
+ main()