ZeroJazz commited on
Commit
fd3f874
·
verified ·
1 Parent(s): b26fc0d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +122 -61
app.py CHANGED
@@ -1,8 +1,6 @@
1
  """
2
  多策略 RAG 文件問答系統 v2 — ChromaDB + PDF/DOCX 版本(含 Telegram 推送)
3
-
4
- 安裝依賴:
5
- pip install gradio groq pypdf python-docx sentence-transformers numpy chromadb scikit-learn requests
6
  """
7
 
8
  from __future__ import annotations
@@ -17,6 +15,8 @@ import chromadb
17
  import gradio as gr
18
  import numpy as np
19
  import requests
 
 
20
  from docx import Document
21
  from docx.oxml.table import CT_Tbl
22
  from docx.oxml.text.paragraph import CT_P
@@ -32,11 +32,32 @@ from sklearn.feature_extraction.text import TfidfVectorizer
32
  # Telegram 推送設定
33
  # ══════════════════════════════════════════════════════════
34
  DEFAULT_TELEGRAM_CHAT_ID = "8874400558"
35
- TELEGRAM_MAX_LEN = 4000 # Telegram 訊息長度限制 (4096),留一些緩衝
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
 
38
  def send_telegram_message(text: str, chat_id: str, token: str) -> dict:
39
- """將文字訊息送到 Telegram。若文字過長會自動分段傳送。"""
 
 
 
 
 
40
  if not token:
41
  return {"ok": False, "error": "尚未提供 Bot Token"}
42
  if not chat_id:
@@ -44,15 +65,52 @@ def send_telegram_message(text: str, chat_id: str, token: str) -> dict:
44
  if not text:
45
  return {"ok": False, "error": "empty text"}
46
 
 
 
 
 
47
  url = f"https://api.telegram.org/bot{token}/sendMessage"
 
48
  results = []
 
49
  for i in range(0, len(text), TELEGRAM_MAX_LEN):
50
- chunk = text[i:i + TELEGRAM_MAX_LEN]
51
- try:
52
- r = requests.post(url, data={"chat_id": chat_id, "text": chunk}, timeout=15)
53
- results.append(r.json())
54
- except Exception as exc:
55
- results.append({"ok": False, "error": f"{type(exc).__name__}: {exc}"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  return results[-1] if results else {"ok": False, "error": "no chunks sent"}
57
 
58
 
@@ -62,14 +120,14 @@ def send_telegram_message(text: str, chat_id: str, token: str) -> dict:
62
  class MultiStrategyRAG:
63
 
64
  STRATEGY_MAP = {
65
- "semantic": "1 ChromaDB 語意搜尋",
66
- "tfidf": "2 TF-IDF 關鍵詞",
67
- "hybrid": "3 混合搜尋",
68
- "rerank": "4 重新排序",
69
- "multi_query": "5 多查詢擴展",
70
- "compress": "6 上下文壓縮",
71
- "parent_child": "7 父子文檔",
72
- "hyde": "8 假設性答案 HyDE",
73
  }
74
 
75
  def __init__(
@@ -102,12 +160,10 @@ class MultiStrategyRAG:
102
  self.tfidf_vectorizer: TfidfVectorizer | None = None
103
  self.tfidf_matrix = None
104
 
105
- # ── API Key 管理 ─────────────────────────────────────
106
  def set_api_key(self, api_key: str) -> None:
107
  key = (api_key or "").strip()
108
  self.client = Groq(api_key=key) if key else None
109
 
110
- # ── 文件載入 ─────────────────────────────────────────
111
  def load_document(self, file_path: str) -> str:
112
  try:
113
  path = Path(file_path)
@@ -148,7 +204,6 @@ class MultiStrategyRAG:
148
  except Exception as exc:
149
  return f"✗ 載入失敗:{type(exc).__name__}: {exc}"
150
 
151
- # ── 文字擷取 ─────────────────────────────────────────
152
  def _extract_pdf(self, path: Path) -> tuple[str, str]:
153
  reader = PdfReader(str(path))
154
  parts = []
@@ -190,10 +245,9 @@ class MultiStrategyRAG:
190
  step = max(1, chunk_size - overlap)
191
  return [
192
  c for start in range(0, len(clean), step)
193
- if (c := clean[start: start + chunk_size].strip())
194
  ]
195
 
196
- # ── Index 建立 ───────────────────────────────────────
197
  def _encode(self, texts: list[str]) -> list[list[float]]:
198
  return (
199
  self.embedding_model
@@ -232,7 +286,6 @@ class MultiStrategyRAG:
232
  self.child_collection.add(ids=child_ids, documents=child_docs,
233
  metadatas=child_metas, embeddings=self._encode(child_docs))
234
 
235
- # ── 工具函式 ─────────────────────────────────────────
236
  def _where(self) -> dict[str, str]:
237
  return {"session_id": self.session_id or ""}
238
 
@@ -278,7 +331,6 @@ class MultiStrategyRAG:
278
  except Exception:
279
  return None
280
 
281
- # ── 8 種策略 ──────────────────────────────────────────
282
  def s_semantic(self, query: str, k: int = 3) -> list[str]:
283
  return [r["text"] for r in self._chroma_search(query, k)]
284
 
@@ -290,9 +342,7 @@ class MultiStrategyRAG:
290
  return [self.chunks[i] for i in scores.argsort()[-k:][::-1]]
291
 
292
  def s_hybrid(self, query: str, k: int = 3) -> list[str]:
293
- return self._dedupe(
294
- self.s_semantic(query, k * 2) + self.s_tfidf(query, k * 2), k
295
- )
296
 
297
  def s_rerank(self, query: str, k: int = 3) -> list[str]:
298
  candidates = self.s_semantic(query, k * 2)
@@ -348,7 +398,6 @@ class MultiStrategyRAG:
348
  hypo = self._llm(prompt, max_tokens=250, temperature=0.7) or query
349
  return self.s_semantic(hypo, k)
350
 
351
- # ── 策略路由 ──────────────────────────────────────────
352
  _FN = {
353
  "semantic": s_semantic,
354
  "tfidf": s_tfidf,
@@ -424,7 +473,6 @@ STRATEGY_INFO = [
424
  ("hyde", "💡 HyDE", "先生成假設答案再語意搜尋"),
425
  ]
426
 
427
- # 顯示用 label -> 內部 key 對照表
428
  STRATEGY_LABEL_TO_KEY = {label: key for key, label, _ in STRATEGY_INFO}
429
  STRATEGY_CHOICES = [label for _, label, _ in STRATEGY_INFO]
430
  STRATEGY_DESC_HTML = "<br>".join(f"<b>{label}</b> — {desc}" for _, label, desc in STRATEGY_INFO)
@@ -447,7 +495,6 @@ body, .gradio-container { background:#f5f4f1 !important; }
447
  font-size:11px; background:#e8f4f0; color:#2d6a4f; border:1px solid rgba(45,106,79,.2); }
448
  .pill-amber { background:#fdf4e3; color:#b87a1a; border-color:rgba(184,122,26,.25); }
449
 
450
- /* API Key 區塊 */
451
  #apikey-box {
452
  background: #fffbf2;
453
  border: 1.5px solid #f0c96a;
@@ -455,8 +502,6 @@ body, .gradio-container { background:#f5f4f1 !important; }
455
  padding: 12px 14px;
456
  margin-bottom: 8px;
457
  }
458
-
459
- /* Telegram 區塊 */
460
  #telegram-box {
461
  background: #eef6ff;
462
  border: 1.5px solid #8ec4f0;
@@ -464,8 +509,6 @@ body, .gradio-container { background:#f5f4f1 !important; }
464
  padding: 12px 14px;
465
  margin-bottom: 8px;
466
  }
467
-
468
- /* 策略選擇區塊 */
469
  #strategy-box {
470
  background:#fff;
471
  border:1.5px solid #e5e0d8;
@@ -488,9 +531,10 @@ body, .gradio-container { background:#f5f4f1 !important; }
488
  color:#7a6e67; font-weight:700; margin:16px 0 8px; }
489
  .card-box { background:#fff !important; border:1px solid #e5e0d8 !important;
490
  border-radius:12px !important; padding:16px !important; }
491
- #ask-btn { background:#2d6a4f !important; color:#fff !important; border:0 !important; border-radius:8px !important; }
492
  #apply-key-btn { background:#b87a1a !important; color:#fff !important; border:0 !important; border-radius:8px !important; }
493
  #send-tg-btn { background:#0088cc !important; color:#fff !important; border:0 !important; border-radius:8px !important; }
 
494
  """
495
 
496
  HEADER_HTML = """
@@ -525,7 +569,6 @@ def create_interface():
525
  rag.set_api_key(env_key)
526
 
527
  current_strategy = {"key": "semantic"}
528
- # 暫存最近一次回答 / 來源片段,供 Telegram 推送使用
529
  last_result = {"answer": "", "source": ""}
530
 
531
  def apply_api_key(api_key: str):
@@ -552,6 +595,23 @@ def create_interface():
552
  last_result["source"] = source or ""
553
  return answer, source
554
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
555
  def push_to_telegram(include_source: bool, chat_id_input: str, token_input: str):
556
  if not last_result["answer"]:
557
  return "⚠ 尚無可推送的回答,請先提問。"
@@ -562,17 +622,28 @@ def create_interface():
562
 
563
  chat_id = (chat_id_input or "").strip() or DEFAULT_TELEGRAM_CHAT_ID
564
 
565
- text = f"📄 文件:{rag.source_name or '未知'}\n"
566
- text += f"❓ 策略:{rag.STRATEGY_MAP.get(current_strategy['key'], current_strategy['key'])}\n\n"
567
- text += f"💬 回答:\n{last_result['answer']}"
 
 
 
 
 
568
 
569
  if include_source:
570
- text += f"\n\n— 檢索片段 —\n{last_result['source']}"
 
571
 
572
  result = send_telegram_message(text, chat_id=chat_id, token=token)
573
  if result.get("ok"):
574
  return "✓ 已成功推送至 Telegram"
575
- return f"✗ 推送失敗:{result.get('description') or result.get('error') or result}"
 
 
 
 
 
576
 
577
  with gr.Blocks(
578
  title="多策略 RAG 文件問答 v2",
@@ -588,12 +659,11 @@ def create_interface():
588
  # ── 左欄 ──────────────────────────────────
589
  with gr.Column(scale=1, min_width=320, elem_classes="card-box"):
590
 
591
- # Step 00 · Telegram 推送(移至最上方,避免找不到)
592
  gr.HTML("<div class='sec-label'>Step 00 · Telegram 推送</div>")
593
  with gr.Group(elem_id="telegram-box"):
594
  tg_token = gr.Textbox(
595
  label="Bot Token",
596
- placeholder="例如:123456789:AAAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
597
  type="password",
598
  lines=1,
599
  )
@@ -602,17 +672,12 @@ def create_interface():
602
  placeholder=f"預設:{DEFAULT_TELEGRAM_CHAT_ID}(留空使用此預設值)",
603
  lines=1,
604
  )
605
- tg_include_source = gr.Checkbox(
606
- label="同時推送檢索片段內容", value=False
607
- )
608
- send_tg_btn = gr.Button(
609
- "📨 推送回答到 Telegram", elem_id="send-tg-btn"
610
- )
611
- tg_status = gr.Textbox(
612
- label="推送狀態", interactive=False, lines=1
613
- )
614
 
615
- # Step 01:Groq API Key
616
  gr.HTML("<div class='sec-label'>Step 01 · Groq API Key</div>")
617
  with gr.Group(elem_id="apikey-box"):
618
  api_key_input = gr.Textbox(
@@ -623,9 +688,7 @@ def create_interface():
623
  lines=1,
624
  show_label=False,
625
  )
626
- apply_key_btn = gr.Button(
627
- "套用 API Key", size="sm", elem_id="apply-key-btn"
628
- )
629
  api_key_status = gr.Textbox(
630
  value="✓ API Key 已從環境變數載入" if env_key else "⚠ 尚未設定 API Key",
631
  interactive=False,
@@ -634,13 +697,11 @@ def create_interface():
634
  show_label=False,
635
  )
636
 
637
- # Step 02:上傳文件
638
  gr.HTML("<div class='sec-label'>Step 02 · 上傳文件</div>")
639
  file_input = gr.File(label="PDF / DOCX", file_types=[".pdf", ".docx"])
640
  load_btn = gr.Button("↑ 載入文件")
641
  status = gr.Textbox(label="狀態", interactive=False, lines=3)
642
 
643
- # Step 03:RAG 策略(改用 Radio,可正常點選)
644
  gr.HTML("<div class='sec-label'>Step 03 · 選擇 RAG 策略</div>")
645
  with gr.Group(elem_id="strategy-box"):
646
  strategy_radio = gr.Radio(
@@ -657,7 +718,6 @@ def create_interface():
657
  label="目前策略",
658
  )
659
 
660
- # Step 04:參數
661
  gr.HTML("<div class='sec-label'>Step 04 · 搜尋參數</div>")
662
  topk = gr.Slider(minimum=1, maximum=10, value=3, step=1, label="Top-K 片段數量")
663
 
@@ -686,6 +746,7 @@ def create_interface():
686
  strategy_radio.change(fn=set_strategy, inputs=[strategy_radio], outputs=[strategy_status])
687
  ask_btn.click(fn=ask, inputs=[qin, topk], outputs=[ans, src])
688
  qin.submit(fn=ask, inputs=[qin, topk], outputs=[ans, src])
 
689
  send_tg_btn.click(
690
  fn=push_to_telegram,
691
  inputs=[tg_include_source, tg_chat_id, tg_token],
 
1
  """
2
  多策略 RAG 文件問答系統 v2 — ChromaDB + PDF/DOCX 版本(含 Telegram 推送)
3
+ 已修正:Telegram ReadTimeout,加入 retry + 指數退避 + 分離 connect/read timeout
 
 
4
  """
5
 
6
  from __future__ import annotations
 
15
  import gradio as gr
16
  import numpy as np
17
  import requests
18
+ from requests.adapters import HTTPAdapter
19
+ from urllib3.util.retry import Retry
20
  from docx import Document
21
  from docx.oxml.table import CT_Tbl
22
  from docx.oxml.text.paragraph import CT_P
 
32
  # Telegram 推送設定
33
  # ══════════════════════════════════════════════════════════
34
  DEFAULT_TELEGRAM_CHAT_ID = "8874400558"
35
+ TELEGRAM_MAX_LEN = 4000
36
+
37
+
38
+ def _make_session() -> requests.Session:
39
+ """建立帶有 retry 策略的 requests Session。"""
40
+ session = requests.Session()
41
+ retry = Retry(
42
+ total=4, # 最多重試 4 次
43
+ backoff_factor=1.5, # 退避:1.5 / 3 / 4.5 / 6.75 秒
44
+ status_forcelist=[429, 500, 502, 503, 504],
45
+ allowed_methods=["POST"],
46
+ raise_on_status=False,
47
+ )
48
+ adapter = HTTPAdapter(max_retries=retry)
49
+ session.mount("https://", adapter)
50
+ session.mount("http://", adapter)
51
+ return session
52
 
53
 
54
  def send_telegram_message(text: str, chat_id: str, token: str) -> dict:
55
+ """
56
+ 將文字訊息送到 Telegram。
57
+ - 分離 connect timeout(10 s)與 read timeout(30 s)
58
+ - 自動重試最多 4 次(指數退避)
59
+ - 長訊息自動分段
60
+ """
61
  if not token:
62
  return {"ok": False, "error": "尚未提供 Bot Token"}
63
  if not chat_id:
 
65
  if not text:
66
  return {"ok": False, "error": "empty text"}
67
 
68
+ # 先驗證 token 格式(避免明顯錯誤)
69
+ if ":" not in token or len(token) < 20:
70
+ return {"ok": False, "error": "Bot Token 格式不正確,應為 123456789:AAAxxxxxx"}
71
+
72
  url = f"https://api.telegram.org/bot{token}/sendMessage"
73
+ session = _make_session()
74
  results = []
75
+
76
  for i in range(0, len(text), TELEGRAM_MAX_LEN):
77
+ chunk = text[i : i + TELEGRAM_MAX_LEN]
78
+ payload = {
79
+ "chat_id": chat_id,
80
+ "text": chunk,
81
+ "parse_mode": "HTML",
82
+ }
83
+ last_exc = None
84
+ # 手動額外重試迴圈(配合退避),requests Retry 已涵蓋 5xx,這裡補 timeout
85
+ for attempt in range(4):
86
+ try:
87
+ resp = session.post(
88
+ url,
89
+ data=payload,
90
+ timeout=(10, 30), # (connect_timeout, read_timeout)
91
+ )
92
+ result = resp.json()
93
+ results.append(result)
94
+ last_exc = None
95
+ break # 成功就跳出重試
96
+ except requests.exceptions.Timeout as exc:
97
+ last_exc = exc
98
+ wait = 1.5 ** attempt
99
+ time.sleep(wait)
100
+ except requests.exceptions.ConnectionError as exc:
101
+ last_exc = exc
102
+ wait = 2 ** attempt
103
+ time.sleep(wait)
104
+ except Exception as exc:
105
+ last_exc = exc
106
+ break # 非網路錯誤不重試
107
+
108
+ if last_exc is not None:
109
+ results.append({
110
+ "ok": False,
111
+ "error": f"{type(last_exc).__name__}: {last_exc} (已重試 4 次)",
112
+ })
113
+
114
  return results[-1] if results else {"ok": False, "error": "no chunks sent"}
115
 
116
 
 
120
  class MultiStrategyRAG:
121
 
122
  STRATEGY_MAP = {
123
+ "semantic": "1 ChromaDB 語意搜尋",
124
+ "tfidf": "2 TF-IDF 關鍵詞",
125
+ "hybrid": "3 混合搜尋",
126
+ "rerank": "4 重新排序",
127
+ "multi_query": "5 多查詢擴展",
128
+ "compress": "6 上下文壓縮",
129
+ "parent_child": "7 父子文檔",
130
+ "hyde": "8 假設性答案 HyDE",
131
  }
132
 
133
  def __init__(
 
160
  self.tfidf_vectorizer: TfidfVectorizer | None = None
161
  self.tfidf_matrix = None
162
 
 
163
  def set_api_key(self, api_key: str) -> None:
164
  key = (api_key or "").strip()
165
  self.client = Groq(api_key=key) if key else None
166
 
 
167
  def load_document(self, file_path: str) -> str:
168
  try:
169
  path = Path(file_path)
 
204
  except Exception as exc:
205
  return f"✗ 載入失敗:{type(exc).__name__}: {exc}"
206
 
 
207
  def _extract_pdf(self, path: Path) -> tuple[str, str]:
208
  reader = PdfReader(str(path))
209
  parts = []
 
245
  step = max(1, chunk_size - overlap)
246
  return [
247
  c for start in range(0, len(clean), step)
248
+ if (c := clean[start : start + chunk_size].strip())
249
  ]
250
 
 
251
  def _encode(self, texts: list[str]) -> list[list[float]]:
252
  return (
253
  self.embedding_model
 
286
  self.child_collection.add(ids=child_ids, documents=child_docs,
287
  metadatas=child_metas, embeddings=self._encode(child_docs))
288
 
 
289
  def _where(self) -> dict[str, str]:
290
  return {"session_id": self.session_id or ""}
291
 
 
331
  except Exception:
332
  return None
333
 
 
334
  def s_semantic(self, query: str, k: int = 3) -> list[str]:
335
  return [r["text"] for r in self._chroma_search(query, k)]
336
 
 
342
  return [self.chunks[i] for i in scores.argsort()[-k:][::-1]]
343
 
344
  def s_hybrid(self, query: str, k: int = 3) -> list[str]:
345
+ return self._dedupe(self.s_semantic(query, k * 2) + self.s_tfidf(query, k * 2), k)
 
 
346
 
347
  def s_rerank(self, query: str, k: int = 3) -> list[str]:
348
  candidates = self.s_semantic(query, k * 2)
 
398
  hypo = self._llm(prompt, max_tokens=250, temperature=0.7) or query
399
  return self.s_semantic(hypo, k)
400
 
 
401
  _FN = {
402
  "semantic": s_semantic,
403
  "tfidf": s_tfidf,
 
473
  ("hyde", "💡 HyDE", "先生成假設答案再語意搜尋"),
474
  ]
475
 
 
476
  STRATEGY_LABEL_TO_KEY = {label: key for key, label, _ in STRATEGY_INFO}
477
  STRATEGY_CHOICES = [label for _, label, _ in STRATEGY_INFO]
478
  STRATEGY_DESC_HTML = "<br>".join(f"<b>{label}</b> — {desc}" for _, label, desc in STRATEGY_INFO)
 
495
  font-size:11px; background:#e8f4f0; color:#2d6a4f; border:1px solid rgba(45,106,79,.2); }
496
  .pill-amber { background:#fdf4e3; color:#b87a1a; border-color:rgba(184,122,26,.25); }
497
 
 
498
  #apikey-box {
499
  background: #fffbf2;
500
  border: 1.5px solid #f0c96a;
 
502
  padding: 12px 14px;
503
  margin-bottom: 8px;
504
  }
 
 
505
  #telegram-box {
506
  background: #eef6ff;
507
  border: 1.5px solid #8ec4f0;
 
509
  padding: 12px 14px;
510
  margin-bottom: 8px;
511
  }
 
 
512
  #strategy-box {
513
  background:#fff;
514
  border:1.5px solid #e5e0d8;
 
531
  color:#7a6e67; font-weight:700; margin:16px 0 8px; }
532
  .card-box { background:#fff !important; border:1px solid #e5e0d8 !important;
533
  border-radius:12px !important; padding:16px !important; }
534
+ #ask-btn { background:#2d6a4f !important; color:#fff !important; border:0 !important; border-radius:8px !important; }
535
  #apply-key-btn { background:#b87a1a !important; color:#fff !important; border:0 !important; border-radius:8px !important; }
536
  #send-tg-btn { background:#0088cc !important; color:#fff !important; border:0 !important; border-radius:8px !important; }
537
+ #verify-tg-btn { background:#0099aa !important; color:#fff !important; border:0 !important; border-radius:8px !important; }
538
  """
539
 
540
  HEADER_HTML = """
 
569
  rag.set_api_key(env_key)
570
 
571
  current_strategy = {"key": "semantic"}
 
572
  last_result = {"answer": "", "source": ""}
573
 
574
  def apply_api_key(api_key: str):
 
595
  last_result["source"] = source or ""
596
  return answer, source
597
 
598
+ def verify_bot(token_input: str, chat_id_input: str):
599
+ """驗證 Bot Token 與 Chat ID 是否正確,發送一則測試訊息。"""
600
+ token = (token_input or "").strip()
601
+ chat_id = (chat_id_input or "").strip() or DEFAULT_TELEGRAM_CHAT_ID
602
+ if not token:
603
+ return "⚠ 請先輸入 Bot Token"
604
+ result = send_telegram_message("✅ Telegram 推送測試成功!RAG 系統連線正常。", chat_id=chat_id, token=token)
605
+ if result.get("ok"):
606
+ return "✓ 測試訊息已送達,Bot Token 與 Chat ID 正確!"
607
+ desc = result.get("description") or result.get("error") or str(result)
608
+ # 給出更友善的錯誤提示
609
+ if "Unauthorized" in desc:
610
+ return "✗ Bot Token 錯誤(Unauthorized),請確認 Token 正確"
611
+ if "chat not found" in desc:
612
+ return f"✗ Chat ID「{chat_id}」找不到,請確認:\n1. 個人 ID 需先對 Bot 傳訊\n2. 群組 ID 需含負號,如 -1001234567890"
613
+ return f"✗ 驗證失敗:{desc}"
614
+
615
  def push_to_telegram(include_source: bool, chat_id_input: str, token_input: str):
616
  if not last_result["answer"]:
617
  return "⚠ 尚無可推送的回答,請先提問。"
 
622
 
623
  chat_id = (chat_id_input or "").strip() or DEFAULT_TELEGRAM_CHAT_ID
624
 
625
+ # 避免 HTML parse_mode 與純文字衝突,移除尖括號
626
+ answer_clean = re.sub(r"[<>]", "", last_result["answer"])
627
+
628
+ text = (
629
+ f"📄 文件:{rag.source_name or '未知'}\n"
630
+ f"❓ 策略:{rag.STRATEGY_MAP.get(current_strategy['key'], current_strategy['key'])}\n\n"
631
+ f"💬 回答:\n{answer_clean}"
632
+ )
633
 
634
  if include_source:
635
+ source_clean = re.sub(r"[<>]", "", last_result["source"])
636
+ text += f"\n\n— 檢索片段 —\n{source_clean}"
637
 
638
  result = send_telegram_message(text, chat_id=chat_id, token=token)
639
  if result.get("ok"):
640
  return "✓ 已成功推送至 Telegram"
641
+ desc = result.get("description") or result.get("error") or str(result)
642
+ if "Unauthorized" in desc:
643
+ return "✗ 推送失敗:Bot Token 錯誤(Unauthorized)"
644
+ if "chat not found" in desc:
645
+ return f"✗ 推送失敗:找不到 Chat ID「{chat_id}」,請先對 Bot 傳訊或確認群組 ID"
646
+ return f"✗ 推送失敗:{desc}"
647
 
648
  with gr.Blocks(
649
  title="多策略 RAG 文件問答 v2",
 
659
  # ── 左欄 ──────────────────────────────────
660
  with gr.Column(scale=1, min_width=320, elem_classes="card-box"):
661
 
 
662
  gr.HTML("<div class='sec-label'>Step 00 · Telegram 推送</div>")
663
  with gr.Group(elem_id="telegram-box"):
664
  tg_token = gr.Textbox(
665
  label="Bot Token",
666
+ placeholder="123456789:AAAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
667
  type="password",
668
  lines=1,
669
  )
 
672
  placeholder=f"預設:{DEFAULT_TELEGRAM_CHAT_ID}(留空使用此預設值)",
673
  lines=1,
674
  )
675
+ with gr.Row():
676
+ verify_tg_btn = gr.Button("🔍 測試連線", elem_id="verify-tg-btn", size="sm")
677
+ send_tg_btn = gr.Button("📨 推送回答", elem_id="send-tg-btn", size="sm")
678
+ tg_include_source = gr.Checkbox(label="同時推送檢索片段內容", value=False)
679
+ tg_status = gr.Textbox(label="推送狀態", interactive=False, lines=2)
 
 
 
 
680
 
 
681
  gr.HTML("<div class='sec-label'>Step 01 · Groq API Key</div>")
682
  with gr.Group(elem_id="apikey-box"):
683
  api_key_input = gr.Textbox(
 
688
  lines=1,
689
  show_label=False,
690
  )
691
+ apply_key_btn = gr.Button("套用 API Key", size="sm", elem_id="apply-key-btn")
 
 
692
  api_key_status = gr.Textbox(
693
  value="✓ API Key 已從環境變數載入" if env_key else "⚠ 尚未設定 API Key",
694
  interactive=False,
 
697
  show_label=False,
698
  )
699
 
 
700
  gr.HTML("<div class='sec-label'>Step 02 · 上傳文件</div>")
701
  file_input = gr.File(label="PDF / DOCX", file_types=[".pdf", ".docx"])
702
  load_btn = gr.Button("↑ 載入文件")
703
  status = gr.Textbox(label="狀態", interactive=False, lines=3)
704
 
 
705
  gr.HTML("<div class='sec-label'>Step 03 · 選擇 RAG 策略</div>")
706
  with gr.Group(elem_id="strategy-box"):
707
  strategy_radio = gr.Radio(
 
718
  label="目前策略",
719
  )
720
 
 
721
  gr.HTML("<div class='sec-label'>Step 04 · 搜尋參數</div>")
722
  topk = gr.Slider(minimum=1, maximum=10, value=3, step=1, label="Top-K 片段數量")
723
 
 
746
  strategy_radio.change(fn=set_strategy, inputs=[strategy_radio], outputs=[strategy_status])
747
  ask_btn.click(fn=ask, inputs=[qin, topk], outputs=[ans, src])
748
  qin.submit(fn=ask, inputs=[qin, topk], outputs=[ans, src])
749
+ verify_tg_btn.click(fn=verify_bot, inputs=[tg_token, tg_chat_id], outputs=[tg_status])
750
  send_tg_btn.click(
751
  fn=push_to_telegram,
752
  inputs=[tg_include_source, tg_chat_id, tg_token],