Spaces:
Sleeping
Sleeping
Update app.py
Browse files
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
|
| 38 |
def send_telegram_message(text: str, chat_id: str, token: str) -> dict:
|
| 39 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 66 |
-
"tfidf":
|
| 67 |
-
"hybrid":
|
| 68 |
-
"rerank":
|
| 69 |
-
"multi_query":
|
| 70 |
-
"compress":
|
| 71 |
-
"parent_child":
|
| 72 |
-
"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
|
| 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 |
-
|
| 566 |
-
|
| 567 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
|
| 569 |
if include_source:
|
| 570 |
-
|
|
|
|
| 571 |
|
| 572 |
result = send_telegram_message(text, chat_id=chat_id, token=token)
|
| 573 |
if result.get("ok"):
|
| 574 |
return "✓ 已成功推送至 Telegram"
|
| 575 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 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 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 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],
|