playlist-admin 2.15.17 → 2.15.18
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/README.md +99 -99
- package/cli/index.js +128 -128
- package/package.json +34 -34
- package/rag/README.md +47 -47
- package/rag/_resolve.py +65 -65
- package/rag/build_db.py +301 -301
- package/rag/query.py +216 -216
- package/rag/srt_to_text.py +49 -49
package/rag/query.py
CHANGED
|
@@ -1,217 +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": 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__":
|
|
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__":
|
|
217
217
|
main()
|
package/rag/srt_to_text.py
CHANGED
|
@@ -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()
|