playlist-admin 2.6.83 → 2.6.85

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
@@ -41,8 +41,9 @@ function projectRoot() {
41
41
 
42
42
  function exePath(root) {
43
43
  const candidates = [
44
+ path.join(root, 'build', 'windows', 'x64', 'runner', 'Release', 'playlist-admin.exe'),
45
+ path.join(root, 'build', 'windows', 'x64', 'runner', 'Debug', 'playlist-admin.exe'),
44
46
  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
  ];
47
48
  return candidates.find((c) => fs.existsSync(c)) || null;
48
49
  }
@@ -54,10 +55,16 @@ function python() {
54
55
  function forward(exe, args) {
55
56
  // Deliver CLI args via PA_CLI_ARGS (JSON) — Flutter Dart exposes no
56
57
  // command-line args in release on this SDK (see lib/main.dart).
58
+ // PA_ROOT lets the Python bridge find rag/ scripts when invoked from the CLI.
59
+ const root = projectRoot();
57
60
  const child = spawn(exe, [], {
58
61
  stdio: 'inherit',
59
62
  windowsHide: false,
60
- env: { ...process.env, PA_CLI_ARGS: JSON.stringify(args) },
63
+ env: {
64
+ ...process.env,
65
+ PA_CLI_ARGS: JSON.stringify(args),
66
+ ...(root ? { PA_ROOT: root } : {}),
67
+ },
61
68
  });
62
69
  child.on('exit', (code) => process.exit(code ?? 1));
63
70
  child.on('error', (e) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playlist-admin",
3
- "version": "2.6.83",
3
+ "version": "2.6.85",
4
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": {
package/rag/build_db.py CHANGED
@@ -17,6 +17,9 @@ import requests
17
17
 
18
18
  from _resolve import podcast_downloads_dir, chroma_db_dir
19
19
 
20
+ if hasattr(sys.stdout, "reconfigure"):
21
+ sys.stdout.reconfigure(encoding="utf-8")
22
+
20
23
  OLLAMA_URL = "http://localhost:11434"
21
24
 
22
25
 
package/rag/query.py CHANGED
@@ -3,6 +3,7 @@
3
3
  python rag/query.py "問題" --topk 8 # 文字輸出
4
4
  python rag/query.py "問題" --json # JSON 輸出 (含 full_text)
5
5
  python rag/query.py "問題" --no-full # 不回傳全文, 只回傳命中片段
6
+ python rag/query.py "問題" --answer # 用 Ollama qwen2.5:7b 生成回答
6
7
  """
7
8
  import argparse
8
9
  import json
@@ -19,7 +20,11 @@ import requests
19
20
  from build_db import read_text_file
20
21
  from _resolve import chroma_db_dir
21
22
 
22
- OLLAMA_URL = "http://localhost:11434"
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")
23
28
 
24
29
 
25
30
  def embed_one(model: str, text: str) -> list[float]:
@@ -43,6 +48,57 @@ def locate_chunk(full_norm: str, chunk: str, window: int = 100) -> int:
43
48
  return idx
44
49
 
45
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
+
46
102
  def main() -> None:
47
103
  ap = argparse.ArgumentParser()
48
104
  ap.add_argument("question", nargs="?", help="要查的問題")
@@ -53,6 +109,8 @@ def main() -> None:
53
109
  ap.add_argument("--no-full", action="store_true", help="不回傳全文")
54
110
  ap.add_argument("--out", default="", help="JSON 輸出檔路徑 (與 --json 併用)")
55
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="生成回答使用的片段數")
56
114
  args = ap.parse_args()
57
115
 
58
116
  if not args.question:
@@ -120,6 +178,11 @@ def main() -> None:
120
178
 
121
179
  if args.json:
122
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})"
123
186
  out = json.dumps(payload, ensure_ascii=False, indent=2)
124
187
  if args.out:
125
188
  Path(args.out).write_text(out, encoding="utf-8")