playlist-admin 2.7.15 → 2.15.17

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/rag/query.py CHANGED
@@ -1,203 +1,217 @@
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
- python rag/query.py "問題" --answer # 用 Ollama qwen2.5:7b 生成回答
7
- """
8
- import argparse
9
- import json
10
- import os
11
- import re
12
- import sys
13
- from pathlib import Path
14
-
15
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
16
-
17
- import chromadb
18
- import requests
19
-
20
- from build_db import read_text_file
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
-
29
-
30
- def embed_one(model: str, text: str) -> list[float]:
31
- resp = requests.post(
32
- f"{OLLAMA_URL}/api/embed", json={"model": model, "input": [text]}, timeout=120
33
- )
34
- resp.raise_for_status()
35
- return resp.json()["embeddings"][0]
36
-
37
-
38
- def normalize(text: str) -> str:
39
- return re.sub(r"\s+", "", text)
40
-
41
-
42
- def locate_chunk(full_norm: str, chunk: str, window: int = 100) -> int:
43
- head = chunk[:window]
44
- idx = full_norm.find(head)
45
- if idx < 0:
46
- head = chunk[:window // 2]
47
- idx = full_norm.find(head)
48
- return idx
49
-
50
-
51
- def pick_chat_models() -> list[str]:
52
- """回傳可嘗試的 chat 模型順序:env 指定 → 本機非 embedding 模型 → cloud 模型"""
53
- forced = os.environ.get("OLLAMA_CHAT_MODEL", "").strip()
54
- try:
55
- tags = requests.get(f"{OLLAMA_URL}/api/tags", timeout=30).json().get("models", [])
56
- except Exception:
57
- return [forced] if forced else []
58
- local = [m["name"] for m in tags if m.get("size", 0) > 0]
59
- cloud = [m["name"] for m in tags if m.get("size", 0) == 0]
60
- banned = {"bge-m3", "bge-m3:latest"}
61
- local = [m for m in local if m not in banned]
62
- if forced:
63
- return [forced, *local, *cloud]
64
- return [*local, *cloud]
65
-
66
-
67
- def generate_answer(question: str, hits: list[dict]) -> str:
68
- """用 Ollama 生成基於檢索片段的回答;全部模型都失敗就回傳錯誤訊息"""
69
- context = "\n".join(
70
- f"[{h['show']} | {h['date']} | {h['file']}]\n{h['chunk']}"
71
- for h in hits
72
- )
73
- prompt = (
74
- "你是 Podcast 內容助理。請只根據以下 Podcast 逐字稿片段回答使用者的問題,"
75
- "回答用繁體中文,簡潔、分點陳述,並在結尾列出引用來源(節目、日期、檔名)。\n\n"
76
- f"片段資料:\n{context}\n\n問題:{question}"
77
- )
78
- errors = []
79
- for model in pick_chat_models():
80
- try:
81
- resp = requests.post(
82
- f"{OLLAMA_URL}/api/chat",
83
- json={
84
- "model": model,
85
- "messages": [{"role": "user", "content": prompt}],
86
- "stream": False,
87
- },
88
- timeout=600,
89
- )
90
- if resp.status_code in (400, 401, 404):
91
- errors.append(f"{model}: HTTP {resp.status_code}")
92
- continue
93
- resp.raise_for_status()
94
- return resp.json()["message"]["content"].strip()
95
- except requests.RequestException as e:
96
- errors.append(f"{model}: {e}")
97
- if errors:
98
- raise RuntimeError(" | ".join(errors))
99
- return ""
100
-
101
-
102
- def main() -> None:
103
- ap = argparse.ArgumentParser()
104
- ap.add_argument("question", nargs="?", help="要查的問題")
105
- ap.add_argument("--db", default=str(chroma_db_dir()))
106
- ap.add_argument("--embed", default="bge-m3")
107
- ap.add_argument("--topk", type=int, default=8)
108
- ap.add_argument("--json", action="store_true", help="輸出 JSON (含 full_text)")
109
- ap.add_argument("--no-full", action="store_true", help="不回傳全文")
110
- ap.add_argument("--out", default="", help="JSON 輸出檔路徑 (與 --json 併用)")
111
- ap.add_argument("--show", default="", help="只搜指定節目")
112
- ap.add_argument("--answer", action="store_true", help="用 Ollama 生成回答 (qwen2.5:7b)")
113
- ap.add_argument("--topk-answer", type=int, default=6, help="生成回答使用的片段數")
114
- args = ap.parse_args()
115
-
116
- if not args.question:
117
- args.question = input("問題: ").strip()
118
- if not args.question:
119
- sys.exit(1)
120
-
121
- client = chromadb.PersistentClient(path=args.db)
122
- col = client.get_collection("podcasts")
123
- qvec = embed_one(args.embed, args.question)
124
- fetch_n = max(args.topk * 5, 50) if args.show else args.topk
125
- res = col.query(query_embeddings=[qvec], n_results=fetch_n)
126
-
127
- raw_hits = []
128
- for doc, meta, dist in zip(
129
- res["documents"][0], res["metadatas"][0], res["distances"][0]
130
- ):
131
- if args.show and args.show not in meta.get("show", ""):
132
- continue
133
- raw_hits.append({
134
- "similarity": round(1 - dist, 3),
135
- "show": meta.get("show", "?"),
136
- "date": meta.get("date", ""),
137
- "file": meta.get("file", "?"),
138
- "path": meta.get("path", ""),
139
- "chunk": doc,
140
- })
141
-
142
- by_file: dict[str, dict] = {}
143
- for h in raw_hits:
144
- key = h["path"] or h["file"]
145
- if key not in by_file:
146
- e = {k: h[k] for k in ("show", "date", "file", "path")}
147
- e["hits"] = []
148
- e["best_similarity"] = 0.0
149
- by_file[key] = e
150
- by_file[key]["hits"].append(h)
151
- by_file[key]["best_similarity"] = max(
152
- by_file[key]["best_similarity"], h["similarity"]
153
- )
154
-
155
- results = []
156
- for key, e in sorted(
157
- by_file.items(), key=lambda kv: kv[1]["best_similarity"], reverse=True
158
- ):
159
- entry = {
160
- "show": e["show"],
161
- "date": e["date"],
162
- "file": e["file"],
163
- "best_similarity": e["best_similarity"],
164
- "hits": [
165
- {"similarity": h["similarity"], "chunk": h["chunk"]}
166
- for h in e["hits"]
167
- ],
168
- }
169
- if not args.no_full and e["path"]:
170
- full = read_text_file(Path(e["path"]))
171
- full_norm = normalize(full)
172
- entry["full_text"] = full
173
- entry["full_text_norm_len"] = len(full_norm)
174
- entry["offsets"] = [
175
- locate_chunk(full_norm, h["chunk"]) for h in e["hits"]
176
- ]
177
- results.append(entry)
178
-
179
- if args.json:
180
- payload = {"question": args.question, "results": results}
181
- if args.answer:
182
- try:
183
- payload["answer"] = generate_answer(args.question, raw_hits[: max(args.topk_answer, 3)])
184
- except Exception as e:
185
- payload["answer_error"] = f"(Ollama 生成失敗: {e})"
186
- out = json.dumps(payload, ensure_ascii=False, indent=2)
187
- if args.out:
188
- Path(args.out).write_text(out, encoding="utf-8")
189
- print(f"已寫入 {args.out}")
190
- else:
191
- print(out)
192
- return
193
-
194
- for r in results:
195
- print(f"\n[相似度 {r['best_similarity']}] {r['show']} | {r['date']} | {r['file']}")
196
- if "full_text" in r:
197
- print(f" (全文 {len(r['full_text'])} 字, 命中偏移 {r['offsets']})")
198
- for h in r["hits"][:2]:
199
- print(f" > {h['chunk'][:120]}...")
200
-
201
-
202
- if __name__ == "__main__":
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
+ python rag/query.py "問題" --answer # 用 Ollama qwen2.5:7b 生成回答
7
+ """
8
+ import argparse
9
+ import json
10
+ import os
11
+ import re
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
16
+
17
+ import chromadb
18
+ import requests
19
+
20
+ from build_db import read_text_file
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
+
29
+
30
+ def embed_one(model: str, text: str) -> list[float]:
31
+ resp = requests.post(
32
+ f"{OLLAMA_URL}/api/embed", json={"model": model, "input": [text]}, timeout=120
33
+ )
34
+ resp.raise_for_status()
35
+ return resp.json()["embeddings"][0]
36
+
37
+
38
+ def normalize(text: str) -> str:
39
+ return re.sub(r"\s+", "", text)
40
+
41
+
42
+ def locate_chunk(full_norm: str, chunk: str, window: int = 100) -> int:
43
+ head = chunk[:window]
44
+ idx = full_norm.find(head)
45
+ if idx < 0:
46
+ head = chunk[:window // 2]
47
+ idx = full_norm.find(head)
48
+ return idx
49
+
50
+
51
+ def pick_chat_models() -> list[str]:
52
+ """回傳可嘗試的 chat 模型順序:env 指定 → 本機非 embedding 模型 → cloud 模型"""
53
+ forced = os.environ.get("OLLAMA_CHAT_MODEL", "").strip()
54
+ try:
55
+ tags = requests.get(f"{OLLAMA_URL}/api/tags", timeout=30).json().get("models", [])
56
+ except Exception:
57
+ return [forced] if forced else []
58
+ local = [m["name"] for m in tags if m.get("size", 0) > 0]
59
+ cloud = [m["name"] for m in tags if m.get("size", 0) == 0]
60
+ banned = {"bge-m3", "bge-m3:latest"}
61
+ local = [m for m in local if m not in banned]
62
+ if forced:
63
+ return [forced, *local, *cloud]
64
+ return [*local, *cloud]
65
+
66
+
67
+ def generate_answer(question: str, hits: list[dict]) -> str:
68
+ """用 Ollama 生成基於檢索片段的回答;全部模型都失敗就回傳錯誤訊息"""
69
+ context = "\n".join(
70
+ f"[{h['show']} | {h['date']} | {h['file']}]\n{h['chunk']}"
71
+ for h in hits
72
+ )
73
+ prompt = (
74
+ "你是 Podcast 內容助理。請只根據以下 Podcast 逐字稿片段回答使用者的問題,"
75
+ "回答用繁體中文,簡潔、分點陳述,並在結尾列出引用來源(節目、日期、檔名)。\n\n"
76
+ f"片段資料:\n{context}\n\n問題:{question}"
77
+ )
78
+ errors = []
79
+ for model in pick_chat_models():
80
+ try:
81
+ resp = requests.post(
82
+ f"{OLLAMA_URL}/api/chat",
83
+ json={
84
+ "model": model,
85
+ "messages": [{"role": "user", "content": prompt}],
86
+ "stream": True,
87
+ },
88
+ timeout=600,
89
+ stream=True,
90
+ )
91
+ if resp.status_code in (400, 401, 404):
92
+ errors.append(f"{model}: HTTP {resp.status_code}")
93
+ continue
94
+ resp.raise_for_status()
95
+ answer = []
96
+ for line in resp.iter_lines():
97
+ if not line:
98
+ continue
99
+ try:
100
+ chunk = json.loads(line)
101
+ token = chunk.get("message", {}).get("content", "")
102
+ if token:
103
+ answer.append(token)
104
+ print(token, end="", flush=True)
105
+ except json.JSONDecodeError:
106
+ pass
107
+ print() # 換行
108
+ return "".join(answer).strip()
109
+ except requests.RequestException as e:
110
+ errors.append(f"{model}: {e}")
111
+ if errors:
112
+ raise RuntimeError(" | ".join(errors))
113
+ return ""
114
+
115
+
116
+ def main() -> None:
117
+ ap = argparse.ArgumentParser()
118
+ ap.add_argument("question", nargs="?", help="要查的問題")
119
+ ap.add_argument("--db", default=str(chroma_db_dir()))
120
+ ap.add_argument("--embed", default="bge-m3")
121
+ ap.add_argument("--topk", type=int, default=8)
122
+ ap.add_argument("--json", action="store_true", help="輸出 JSON (含 full_text)")
123
+ ap.add_argument("--no-full", action="store_true", help="不回傳全文")
124
+ ap.add_argument("--out", default="", help="JSON 輸出檔路徑 (與 --json 併用)")
125
+ ap.add_argument("--show", default="", help="只搜指定節目")
126
+ ap.add_argument("--answer", action="store_true", help="用 Ollama 生成回答 (qwen2.5:7b)")
127
+ ap.add_argument("--topk-answer", type=int, default=6, help="生成回答使用的片段數")
128
+ args = ap.parse_args()
129
+
130
+ if not args.question:
131
+ args.question = input("問題: ").strip()
132
+ if not args.question:
133
+ sys.exit(1)
134
+
135
+ client = chromadb.PersistentClient(path=args.db)
136
+ col = client.get_collection("podcasts")
137
+ qvec = embed_one(args.embed, args.question)
138
+ fetch_n = max(args.topk * 5, 50) if args.show else args.topk
139
+ res = col.query(query_embeddings=[qvec], n_results=fetch_n)
140
+
141
+ raw_hits = []
142
+ for doc, meta, dist in zip(
143
+ res["documents"][0], res["metadatas"][0], res["distances"][0]
144
+ ):
145
+ if args.show and args.show not in meta.get("show", ""):
146
+ continue
147
+ raw_hits.append({
148
+ "similarity": round(1 - dist, 3),
149
+ "show": meta.get("show", "?"),
150
+ "date": meta.get("date", ""),
151
+ "file": meta.get("file", "?"),
152
+ "path": meta.get("path", ""),
153
+ "chunk": doc,
154
+ })
155
+
156
+ by_file: dict[str, dict] = {}
157
+ for h in raw_hits:
158
+ key = h["path"] or h["file"]
159
+ if key not in by_file:
160
+ e = {k: h[k] for k in ("show", "date", "file", "path")}
161
+ e["hits"] = []
162
+ e["best_similarity"] = 0.0
163
+ by_file[key] = e
164
+ by_file[key]["hits"].append(h)
165
+ by_file[key]["best_similarity"] = max(
166
+ by_file[key]["best_similarity"], h["similarity"]
167
+ )
168
+
169
+ results = []
170
+ for key, e in sorted(
171
+ by_file.items(), key=lambda kv: kv[1]["best_similarity"], reverse=True
172
+ ):
173
+ entry = {
174
+ "show": e["show"],
175
+ "date": e["date"],
176
+ "file": e["file"],
177
+ "best_similarity": e["best_similarity"],
178
+ "hits": [
179
+ {"similarity": h["similarity"], "chunk": h["chunk"]}
180
+ for h in e["hits"]
181
+ ],
182
+ }
183
+ 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
+ ]
191
+ results.append(entry)
192
+
193
+ if args.json:
194
+ payload = {"question": args.question, "results": results}
195
+ if args.answer:
196
+ try:
197
+ payload["answer"] = generate_answer(args.question, raw_hits[: max(args.topk_answer, 3)])
198
+ except Exception as e:
199
+ payload["answer_error"] = f"(Ollama 生成失敗: {e})"
200
+ out = json.dumps(payload, ensure_ascii=False, indent=2)
201
+ if args.out:
202
+ Path(args.out).write_text(out, encoding="utf-8")
203
+ print(f"已寫入 {args.out}")
204
+ else:
205
+ print(out)
206
+ return
207
+
208
+ for r in results:
209
+ print(f"\n[相似度 {r['best_similarity']}] {r['show']} | {r['date']} | {r['file']}")
210
+ if "full_text" in r:
211
+ print(f" (全文 {len(r['full_text'])} 字, 命中偏移 {r['offsets']})")
212
+ for h in r["hits"][:2]:
213
+ print(f" > {h['chunk'][:120]}...")
214
+
215
+
216
+ if __name__ == "__main__":
203
217
  main()
@@ -1,50 +1,50 @@
1
- """把沒有對應 .txt 的 .srt 字幕轉成 .txt (去除時間軸),供向量庫補索引
2
- 用法: python rag/srt_to_txt.py
3
- """
4
- import os
5
- import re
6
- import sys
7
- from pathlib import Path
8
-
9
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
10
-
11
- from _resolve import podcast_downloads_dir
12
-
13
-
14
- def srt_to_text(srt_path: Path) -> str:
15
- text = srt_path.read_text(encoding="utf-8-sig", errors="replace")
16
- # 移除 srt 索引行 (純數字行)
17
- text = re.sub(r"^\d+\s*$", "", text, flags=re.MULTILINE)
18
- # 移除時間軸行 (00:00:00,000 --> 00:00:01,000)
19
- text = re.sub(
20
- r"\d{1,2}:\d{2}:\d{2}[,.]\d{3}\s*-->\s*\d{1,2}:\d{2}:\d{2}[,.]\d{3}",
21
- "",
22
- text,
23
- )
24
- lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
25
- return "\n".join(lines)
26
-
27
-
28
- def main() -> None:
29
- root = podcast_downloads_dir()
30
- converted = 0
31
- for srt in sorted(root.rglob("*.srt")):
32
- name = srt.name
33
- if ".zh-TW.srt" in name or ".zh-Hans.srt" in name or ".zh-Hant.srt" in name:
34
- continue
35
- txt = srt.with_suffix(".txt")
36
- if txt.exists():
37
- continue
38
- try:
39
- content = srt_to_text(srt)
40
- except Exception as e:
41
- print(f"!! 失敗 {srt.name}: {e}")
42
- continue
43
- txt.write_text(content, encoding="utf-8")
44
- print(f"轉換: {srt.parent.name}/{srt.stem[:50]}")
45
- converted += 1
46
- print(f"完成! 轉換 {converted} 個 srt -> txt")
47
-
48
-
49
- if __name__ == "__main__":
1
+ """把沒有對應 .txt 的 .srt 字幕轉成 .txt (去除時間軸),供向量庫補索引
2
+ 用法: python rag/srt_to_txt.py
3
+ """
4
+ import os
5
+ import re
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
10
+
11
+ from _resolve import podcast_downloads_dir
12
+
13
+
14
+ def srt_to_text(srt_path: Path) -> str:
15
+ text = srt_path.read_text(encoding="utf-8-sig", errors="replace")
16
+ # 移除 srt 索引行 (純數字行)
17
+ text = re.sub(r"^\d+\s*$", "", text, flags=re.MULTILINE)
18
+ # 移除時間軸行 (00:00:00,000 --> 00:00:01,000)
19
+ text = re.sub(
20
+ r"\d{1,2}:\d{2}:\d{2}[,.]\d{3}\s*-->\s*\d{1,2}:\d{2}:\d{2}[,.]\d{3}",
21
+ "",
22
+ text,
23
+ )
24
+ lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
25
+ return "\n".join(lines)
26
+
27
+
28
+ def main() -> None:
29
+ root = podcast_downloads_dir()
30
+ converted = 0
31
+ for srt in sorted(root.rglob("*.srt")):
32
+ name = srt.name
33
+ if ".zh-TW.srt" in name or ".zh-Hans.srt" in name or ".zh-Hant.srt" in name:
34
+ continue
35
+ txt = srt.with_suffix(".txt")
36
+ if txt.exists():
37
+ continue
38
+ try:
39
+ content = srt_to_text(srt)
40
+ except Exception as e:
41
+ print(f"!! 失敗 {srt.name}: {e}")
42
+ continue
43
+ txt.write_text(content, encoding="utf-8")
44
+ print(f"轉換: {srt.parent.name}/{srt.stem[:50]}")
45
+ converted += 1
46
+ print(f"完成! 轉換 {converted} 個 srt -> txt")
47
+
48
+
49
+ if __name__ == "__main__":
50
50
  main()