Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,8 +1,11 @@
|
|
| 1 |
"""
|
| 2 |
-
多策略 RAG 文件問答系統 v2 — ChromaDB + PDF/DOCX
|
| 3 |
|
| 4 |
安裝依賴:
|
| 5 |
pip install gradio groq pypdf python-docx sentence-transformers numpy chromadb scikit-learn requests
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
from __future__ import annotations
|
|
@@ -29,31 +32,39 @@ from sklearn.feature_extraction.text import TfidfVectorizer
|
|
| 29 |
|
| 30 |
|
| 31 |
# ══════════════════════════════════════════════════════════
|
| 32 |
-
# Telegram 推
|
| 33 |
# ══════════════════════════════════════════════════════════
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
if
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
|
| 59 |
# ══════════════════════════════════════════════════════════
|
|
@@ -381,7 +392,7 @@ class MultiStrategyRAG:
|
|
| 381 |
if not self.client:
|
| 382 |
return (
|
| 383 |
"⚠ 尚未設定 Groq API Key。\n"
|
| 384 |
-
"請在左欄「Step
|
| 385 |
"(檢索已完成,可在下方「查看檢索到的文本片段」確認結果)",
|
| 386 |
source_preview,
|
| 387 |
)
|
|
@@ -414,21 +425,16 @@ class MultiStrategyRAG:
|
|
| 414 |
# Gradio UI
|
| 415 |
# ══════════════════════════════════════════════════════════
|
| 416 |
STRATEGY_INFO = [
|
| 417 |
-
("semantic", "
|
| 418 |
-
("tfidf", "
|
| 419 |
-
("hybrid", "
|
| 420 |
-
("rerank", "
|
| 421 |
-
("multi_query", "
|
| 422 |
-
("compress", "
|
| 423 |
-
("parent_child", "
|
| 424 |
-
("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)
|
| 431 |
-
|
| 432 |
CSS = """
|
| 433 |
body, .gradio-container { background:#f5f4f1 !important; }
|
| 434 |
|
|
@@ -446,43 +452,34 @@ body, .gradio-container { background:#f5f4f1 !important; }
|
|
| 446 |
.pill { display:inline-block; margin:10px 5px 0 0; padding:3px 10px; border-radius:16px;
|
| 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;
|
| 454 |
border-radius: 10px;
|
| 455 |
padding: 12px 14px;
|
| 456 |
margin-bottom: 8px;
|
| 457 |
}
|
|
|
|
| 458 |
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
background: #eef6ff;
|
| 462 |
-
border: 1.5px solid #8ec4f0;
|
| 463 |
-
border-radius: 10px;
|
| 464 |
-
padding: 12px 14px;
|
| 465 |
-
margin-bottom: 8px;
|
| 466 |
-
}
|
| 467 |
-
|
| 468 |
-
/* 策略選擇區塊 */
|
| 469 |
-
#strategy-box {
|
| 470 |
background:#fff;
|
| 471 |
border:1.5px solid #e5e0d8;
|
| 472 |
border-radius:10px;
|
| 473 |
padding:10px 12px;
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
border:1.5px solid #e5e0d8 !important;
|
| 479 |
-
border-radius:8px !important;
|
| 480 |
-
padding:8px 10px !important;
|
| 481 |
-
margin:0 !important;
|
| 482 |
-
transition: border-color .15s, background .15s;
|
| 483 |
}
|
| 484 |
-
|
| 485 |
-
|
|
|
|
|
|
|
|
|
|
| 486 |
|
| 487 |
.sec-label { font-size:11px; letter-spacing:1.5px; text-transform:uppercase;
|
| 488 |
color:#7a6e67; font-weight:700; margin:16px 0 8px; }
|
|
@@ -490,25 +487,51 @@ body, .gradio-container { background:#f5f4f1 !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 |
-
#
|
| 494 |
"""
|
| 495 |
|
| 496 |
HEADER_HTML = """
|
| 497 |
<div id="hdr">
|
| 498 |
-
<div class="hdr-eyebrow">Intelligent Document Analysis · v2</div>
|
| 499 |
<div class="hdr-title">多策略 RAG 文件問答系統</div>
|
| 500 |
-
<div class="hdr-sub">支援 PDF / DOCX 上傳,採用 ChromaDB 持久化向量資料庫與 8 種 RAG 檢索策略,並可將
|
| 501 |
<div>
|
| 502 |
<span class="pill">▸ Groq API</span>
|
| 503 |
<span class="pill">▸ llama-3.1-8b-instant</span>
|
| 504 |
<span class="pill pill-amber">▸ ChromaDB</span>
|
| 505 |
<span class="pill pill-amber">▸ PDF / DOCX</span>
|
| 506 |
<span class="pill">▸ SentenceTransformers</span>
|
| 507 |
-
<span class="pill">▸ Telegram</span>
|
| 508 |
</div>
|
| 509 |
</div>
|
| 510 |
"""
|
| 511 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 512 |
EXAMPLE_QS = [
|
| 513 |
["這份文件的主要內容是什麼?"],
|
| 514 |
["文件中提到哪些重要概念或定義?"],
|
|
@@ -519,14 +542,17 @@ EXAMPLE_QS = [
|
|
| 519 |
|
| 520 |
|
| 521 |
def create_interface():
|
| 522 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
rag = MultiStrategyRAG(chroma_path="./chroma_db")
|
| 524 |
-
if
|
| 525 |
-
rag.set_api_key(
|
| 526 |
|
| 527 |
current_strategy = {"key": "semantic"}
|
| 528 |
-
|
| 529 |
-
last_result = {"answer": "", "source": ""}
|
| 530 |
|
| 531 |
def apply_api_key(api_key: str):
|
| 532 |
key = (api_key or "").strip()
|
|
@@ -536,46 +562,52 @@ def create_interface():
|
|
| 536 |
return f"✓ API Key 已套用({masked})"
|
| 537 |
return "⚠ API Key 已清除,無法呼叫 LLM"
|
| 538 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
def upload_document(file):
|
| 540 |
if file is None:
|
| 541 |
return "⚠ 請選擇 PDF 或 DOCX 檔案"
|
| 542 |
return rag.load_document(file.name)
|
| 543 |
|
| 544 |
-
def set_strategy(
|
| 545 |
-
key = STRATEGY_LABEL_TO_KEY.get(label, "semantic")
|
| 546 |
current_strategy["key"] = key
|
| 547 |
-
return f"✓ 已選擇策略:{
|
| 548 |
-
|
| 549 |
-
def ask(query, top_k):
|
| 550 |
-
answer, source = rag.generate_answer(query, current_strategy["key"], int(top_k))
|
| 551 |
-
last_result["answer"] = answer or ""
|
| 552 |
-
last_result["source"] = source or ""
|
| 553 |
-
return answer, source
|
| 554 |
|
| 555 |
-
def
|
| 556 |
-
|
| 557 |
-
return "⚠ 尚無可推送的回答,請先提問。"
|
| 558 |
|
| 559 |
-
|
| 560 |
-
if
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
|
|
|
|
|
|
| 571 |
|
| 572 |
-
|
| 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",
|
| 579 |
css=CSS,
|
| 580 |
theme=gr.themes.Base(
|
| 581 |
primary_hue=gr.themes.colors.green,
|
|
@@ -588,37 +620,13 @@ def create_interface():
|
|
| 588 |
# ── 左欄 ──────────────────────────────────
|
| 589 |
with gr.Column(scale=1, min_width=320, elem_classes="card-box"):
|
| 590 |
|
| 591 |
-
# Step 00
|
| 592 |
-
gr.HTML("<div class='sec-label'>Step 00 ·
|
| 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 |
-
)
|
| 600 |
-
tg_chat_id = gr.Textbox(
|
| 601 |
-
label="Chat ID",
|
| 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(
|
| 619 |
label="",
|
| 620 |
placeholder="gsk_xxxxxxxxxxxxxxxxxxxxxxxx",
|
| 621 |
-
value=
|
| 622 |
type="password",
|
| 623 |
lines=1,
|
| 624 |
show_label=False,
|
|
@@ -627,43 +635,78 @@ def create_interface():
|
|
| 627 |
"套用 API Key", size="sm", elem_id="apply-key-btn"
|
| 628 |
)
|
| 629 |
api_key_status = gr.Textbox(
|
| 630 |
-
value="✓ API Key 已從環境變數載入" if
|
| 631 |
interactive=False,
|
| 632 |
lines=1,
|
| 633 |
label="",
|
| 634 |
show_label=False,
|
| 635 |
)
|
| 636 |
|
| 637 |
-
# Step
|
| 638 |
-
gr.HTML("<div class='sec-label'>Step
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 644 |
-
gr.HTML("<div class='sec-label'>Step
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
gr.HTML(f"<div id='strategy-desc'>{STRATEGY_DESC_HTML}</div>")
|
| 653 |
strategy_status = gr.Textbox(
|
| 654 |
-
value=
|
| 655 |
interactive=False,
|
| 656 |
lines=1,
|
| 657 |
label="目前策略",
|
| 658 |
)
|
|
|
|
| 659 |
|
| 660 |
-
# Step
|
| 661 |
-
gr.HTML("<div class='sec-label'>Step
|
| 662 |
topk = gr.Slider(minimum=1, maximum=10, value=3, step=1, label="Top-K 片段數量")
|
| 663 |
|
| 664 |
# ── 右欄:問答 ────────────────────────────
|
| 665 |
with gr.Column(scale=2, elem_classes="card-box"):
|
| 666 |
-
gr.HTML("<div class='sec-label'>Step
|
| 667 |
qin = gr.Textbox(
|
| 668 |
label="",
|
| 669 |
placeholder="例如:這份文件的核心論點是什麼?",
|
|
@@ -674,6 +717,9 @@ def create_interface():
|
|
| 674 |
gr.HTML("<div class='sec-label'>AI 回答</div>")
|
| 675 |
ans = gr.Textbox(label="", lines=12, interactive=False)
|
| 676 |
|
|
|
|
|
|
|
|
|
|
| 677 |
with gr.Accordion("▸ 查看檢索到的文本片段", open=False):
|
| 678 |
src = gr.Textbox(label="", lines=10, interactive=False)
|
| 679 |
|
|
@@ -682,19 +728,31 @@ def create_interface():
|
|
| 682 |
# ── 事件綁定 ──────────────────────────────────
|
| 683 |
apply_key_btn.click(fn=apply_api_key, inputs=[api_key_input], outputs=[api_key_status])
|
| 684 |
api_key_input.submit(fn=apply_api_key, inputs=[api_key_input], outputs=[api_key_status])
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
send_tg_btn.click(
|
| 690 |
-
fn=push_to_telegram,
|
| 691 |
-
inputs=[tg_include_source, tg_chat_id, tg_token],
|
| 692 |
outputs=[tg_status],
|
| 693 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 694 |
|
| 695 |
return demo
|
| 696 |
|
| 697 |
|
| 698 |
if __name__ == "__main__":
|
| 699 |
demo = create_interface()
|
| 700 |
-
demo.launch(share=False, server_name="0.0.0.0")
|
|
|
|
| 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 |
+
python rag_telegram_app.py
|
| 9 |
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
|
|
|
| 32 |
|
| 33 |
|
| 34 |
# ══════════════════════════════════════════════════════════
|
| 35 |
+
# Telegram 推播工具
|
| 36 |
# ══════════════════════════════════════════════════════════
|
| 37 |
+
TELEGRAM_MAX_LEN = 4000 # Telegram 訊息長度上限為 4096,留一點餘量
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def send_telegram_message(token: str, chat_id: str, text: str) -> tuple[bool, str]:
|
| 41 |
+
"""將文字訊息發送至指定的 Telegram 聊天室。"""
|
| 42 |
+
token = (token or "").strip()
|
| 43 |
+
chat_id = (chat_id or "").strip()
|
| 44 |
+
if not token or not chat_id:
|
| 45 |
+
return False, "⚠ 尚未設定 Telegram Bot Token 或 Chat ID"
|
| 46 |
+
|
| 47 |
+
if len(text) > TELEGRAM_MAX_LEN:
|
| 48 |
+
text = text[:TELEGRAM_MAX_LEN] + "\n…(內容過長,已截斷)"
|
| 49 |
+
|
| 50 |
+
try:
|
| 51 |
+
resp = requests.post(
|
| 52 |
+
f"https://api.telegram.org/bot{token}/sendMessage",
|
| 53 |
+
data={"chat_id": chat_id, "text": text},
|
| 54 |
+
timeout=10,
|
| 55 |
+
)
|
| 56 |
+
data = resp.json()
|
| 57 |
+
if data.get("ok"):
|
| 58 |
+
return True, "✓ 已成功發送至 Telegram"
|
| 59 |
+
return False, f"✗ Telegram 發送失敗:{data.get('description', '未知錯誤')}"
|
| 60 |
+
except Exception as exc:
|
| 61 |
+
return False, f"✗ Telegram 發送失敗:{type(exc).__name__}: {exc}"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_telegram_connection(token: str, chat_id: str) -> str:
|
| 65 |
+
"""測試 Telegram Bot Token / Chat ID 是否正確(不會打擾使用者太多)。"""
|
| 66 |
+
ok, msg = send_telegram_message(token, chat_id, "✅ RAG 系統已連接,測試訊息成功!")
|
| 67 |
+
return msg if not ok else "✓ 連線成功,已發送測試訊息"
|
| 68 |
|
| 69 |
|
| 70 |
# ══════════════════════════════════════════════════════════
|
|
|
|
| 392 |
if not self.client:
|
| 393 |
return (
|
| 394 |
"⚠ 尚未設定 Groq API Key。\n"
|
| 395 |
+
"請在左欄「Step 00」輸入您的 Groq API Key 並點擊「套用」後再提問。\n\n"
|
| 396 |
"(檢索已完成,可在下方「查看檢索到的文本片段」確認結果)",
|
| 397 |
source_preview,
|
| 398 |
)
|
|
|
|
| 425 |
# Gradio UI
|
| 426 |
# ══════════════════════════════════════════════════════════
|
| 427 |
STRATEGY_INFO = [
|
| 428 |
+
("semantic", "語意搜尋", "ChromaDB 向量相似度,最通用", "🔍"),
|
| 429 |
+
("tfidf", "TF-IDF", "字元 n-gram 關鍵詞統計", "📊"),
|
| 430 |
+
("hybrid", "混合搜尋", "語意 + TF-IDF 結果合併去重", "⚡"),
|
| 431 |
+
("rerank", "重新排序", "LLM 對候選片段二次評分", "🎯"),
|
| 432 |
+
("multi_query", "多查詢擴展", "自動生成多角度問題聯合搜尋", "🔄"),
|
| 433 |
+
("compress", "上下文壓縮", "LLM 提取最相關句子精簡上下文", "✂️"),
|
| 434 |
+
("parent_child", "父子文檔", "小片段定位 → 回傳對應大片段", "📂"),
|
| 435 |
+
("hyde", "HyDE", "先生成假設答案再語意搜尋", "💡"),
|
| 436 |
]
|
| 437 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
CSS = """
|
| 439 |
body, .gradio-container { background:#f5f4f1 !important; }
|
| 440 |
|
|
|
|
| 452 |
.pill { display:inline-block; margin:10px 5px 0 0; padding:3px 10px; border-radius:16px;
|
| 453 |
font-size:11px; background:#e8f4f0; color:#2d6a4f; border:1px solid rgba(45,106,79,.2); }
|
| 454 |
.pill-amber { background:#fdf4e3; color:#b87a1a; border-color:rgba(184,122,26,.25); }
|
| 455 |
+
.pill-blue { background:#e8f0fd; color:#1a5cb8; border-color:rgba(26,92,184,.25); }
|
| 456 |
|
| 457 |
/* API Key 區塊 */
|
| 458 |
+
#apikey-box, #telegram-box {
|
| 459 |
background: #fffbf2;
|
| 460 |
border: 1.5px solid #f0c96a;
|
| 461 |
border-radius: 10px;
|
| 462 |
padding: 12px 14px;
|
| 463 |
margin-bottom: 8px;
|
| 464 |
}
|
| 465 |
+
#telegram-box { background:#f0f6ff; border-color:#a9c8f0; }
|
| 466 |
|
| 467 |
+
.strat-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin:10px 0 16px; }
|
| 468 |
+
.strat-card {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
background:#fff;
|
| 470 |
border:1.5px solid #e5e0d8;
|
| 471 |
border-radius:10px;
|
| 472 |
padding:10px 12px;
|
| 473 |
+
cursor:pointer;
|
| 474 |
+
transition:border-color .15s, box-shadow .15s;
|
| 475 |
+
text-align:left;
|
| 476 |
+
width:100%;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
}
|
| 478 |
+
.strat-card:hover { border-color:#2d6a4f; box-shadow:0 2px 8px rgba(45,106,79,.12); }
|
| 479 |
+
.strat-card.active { border-color:#2d6a4f; background:#f0f9f5; box-shadow:0 2px 10px rgba(45,106,79,.18); }
|
| 480 |
+
.strat-icon { font-size:20px; margin-bottom:4px; }
|
| 481 |
+
.strat-name { font-size:13px; font-weight:700; color:#1a1714; margin:0 0 2px; }
|
| 482 |
+
.strat-desc { font-size:11px; color:#7a6e67; line-height:1.4; }
|
| 483 |
|
| 484 |
.sec-label { font-size:11px; letter-spacing:1.5px; text-transform:uppercase;
|
| 485 |
color:#7a6e67; font-weight:700; margin:16px 0 8px; }
|
|
|
|
| 487 |
border-radius:12px !important; padding:16px !important; }
|
| 488 |
#ask-btn { background:#2d6a4f !important; color:#fff !important; border:0 !important; border-radius:8px !important; }
|
| 489 |
#apply-key-btn { background:#b87a1a !important; color:#fff !important; border:0 !important; border-radius:8px !important; }
|
| 490 |
+
#apply-tg-btn, #test-tg-btn { background:#1a5cb8 !important; color:#fff !important; border:0 !important; border-radius:8px !important; }
|
| 491 |
"""
|
| 492 |
|
| 493 |
HEADER_HTML = """
|
| 494 |
<div id="hdr">
|
| 495 |
+
<div class="hdr-eyebrow">Intelligent Document Analysis · v2 + Telegram</div>
|
| 496 |
<div class="hdr-title">多策略 RAG 文件問答系統</div>
|
| 497 |
+
<div class="hdr-sub">支援 PDF / DOCX 上傳,採用 ChromaDB 持久化向量資料庫與 8 種 RAG 檢索策略,並可將回答推播至 Telegram</div>
|
| 498 |
<div>
|
| 499 |
<span class="pill">▸ Groq API</span>
|
| 500 |
<span class="pill">▸ llama-3.1-8b-instant</span>
|
| 501 |
<span class="pill pill-amber">▸ ChromaDB</span>
|
| 502 |
<span class="pill pill-amber">▸ PDF / DOCX</span>
|
| 503 |
<span class="pill">▸ SentenceTransformers</span>
|
| 504 |
+
<span class="pill pill-blue">▸ Telegram Bot</span>
|
| 505 |
</div>
|
| 506 |
</div>
|
| 507 |
"""
|
| 508 |
|
| 509 |
+
|
| 510 |
+
def build_strategy_menu(selected: str = "semantic") -> str:
|
| 511 |
+
cards = []
|
| 512 |
+
for key, name, desc, icon in STRATEGY_INFO:
|
| 513 |
+
active_cls = "active" if key == selected else ""
|
| 514 |
+
cards.append(
|
| 515 |
+
f"""<button class="strat-card {active_cls}" onclick="selectStrategy('{key}', this)" type="button">
|
| 516 |
+
<div class="strat-icon">{icon}</div>
|
| 517 |
+
<div class="strat-name">{name}</div>
|
| 518 |
+
<div class="strat-desc">{desc}</div>
|
| 519 |
+
</button>"""
|
| 520 |
+
)
|
| 521 |
+
return f'<div class="strat-grid">{"".join(cards)}</div>'
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
STRATEGY_MENU_JS = """
|
| 525 |
+
<script>
|
| 526 |
+
function selectStrategy(key, el) {
|
| 527 |
+
document.querySelectorAll('.strat-card').forEach(c => c.classList.remove('active'));
|
| 528 |
+
el.classList.add('active');
|
| 529 |
+
const inp = document.getElementById('strategy-hidden');
|
| 530 |
+
if (inp) { inp.value = key; inp.dispatchEvent(new Event('input')); }
|
| 531 |
+
}
|
| 532 |
+
</script>
|
| 533 |
+
"""
|
| 534 |
+
|
| 535 |
EXAMPLE_QS = [
|
| 536 |
["這份文件的主要內容是什麼?"],
|
| 537 |
["文件中提到哪些重要概念或定義?"],
|
|
|
|
| 542 |
|
| 543 |
|
| 544 |
def create_interface():
|
| 545 |
+
# 啟動時嘗試從環境變數讀取(建議使用環境變數,避免在介面或程式碼中硬編碼)
|
| 546 |
+
env_groq_key = os.getenv("GROQ_API_KEY", "").strip()
|
| 547 |
+
env_tg_token = os.getenv("TELEGRAM_BOT_TOKEN", "").strip()
|
| 548 |
+
env_tg_chat_id = os.getenv("TELEGRAM_CHAT_ID", "").strip()
|
| 549 |
+
|
| 550 |
rag = MultiStrategyRAG(chroma_path="./chroma_db")
|
| 551 |
+
if env_groq_key:
|
| 552 |
+
rag.set_api_key(env_groq_key)
|
| 553 |
|
| 554 |
current_strategy = {"key": "semantic"}
|
| 555 |
+
telegram_config = {"token": env_tg_token, "chat_id": env_tg_chat_id}
|
|
|
|
| 556 |
|
| 557 |
def apply_api_key(api_key: str):
|
| 558 |
key = (api_key or "").strip()
|
|
|
|
| 562 |
return f"✓ API Key 已套用({masked})"
|
| 563 |
return "⚠ API Key 已清除,無法呼叫 LLM"
|
| 564 |
|
| 565 |
+
def apply_telegram_config(token: str, chat_id: str):
|
| 566 |
+
telegram_config["token"] = (token or "").strip()
|
| 567 |
+
telegram_config["chat_id"] = (chat_id or "").strip()
|
| 568 |
+
if telegram_config["token"] and telegram_config["chat_id"]:
|
| 569 |
+
masked = (
|
| 570 |
+
telegram_config["token"][:6] + "****" + telegram_config["token"][-4:]
|
| 571 |
+
if len(telegram_config["token"]) > 10
|
| 572 |
+
else "****"
|
| 573 |
+
)
|
| 574 |
+
return f"✓ Telegram 設定已套用(Token:{masked} · Chat ID:{telegram_config['chat_id']})"
|
| 575 |
+
return "⚠ Telegram Token 或 Chat ID 尚未完整設定"
|
| 576 |
+
|
| 577 |
+
def do_test_telegram():
|
| 578 |
+
return test_telegram_connection(telegram_config["token"], telegram_config["chat_id"])
|
| 579 |
+
|
| 580 |
def upload_document(file):
|
| 581 |
if file is None:
|
| 582 |
return "⚠ 請選擇 PDF 或 DOCX 檔案"
|
| 583 |
return rag.load_document(file.name)
|
| 584 |
|
| 585 |
+
def set_strategy(key: str):
|
|
|
|
| 586 |
current_strategy["key"] = key
|
| 587 |
+
return f"✓ 已選擇策略:{dict((k, n) for k, n, *_ in STRATEGY_INFO).get(key, key)}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 588 |
|
| 589 |
+
def ask(query, top_k, send_to_telegram):
|
| 590 |
+
answer, source_preview = rag.generate_answer(query, current_strategy["key"], int(top_k))
|
|
|
|
| 591 |
|
| 592 |
+
telegram_status = ""
|
| 593 |
+
if send_to_telegram:
|
| 594 |
+
strategy_label = MultiStrategyRAG.STRATEGY_MAP.get(
|
| 595 |
+
current_strategy["key"], current_strategy["key"]
|
| 596 |
+
)
|
| 597 |
+
tg_text = (
|
| 598 |
+
f"📄 文件:{rag.source_name or '(未載入)'}\n"
|
| 599 |
+
f"🧩 策略:{strategy_label}\n"
|
| 600 |
+
f"❓ 問題:{query}\n\n"
|
| 601 |
+
f"💬 回答:\n{answer}"
|
| 602 |
+
)
|
| 603 |
+
ok, telegram_status = send_telegram_message(
|
| 604 |
+
telegram_config["token"], telegram_config["chat_id"], tg_text
|
| 605 |
+
)
|
| 606 |
|
| 607 |
+
return answer, source_preview, telegram_status
|
|
|
|
|
|
|
|
|
|
| 608 |
|
| 609 |
with gr.Blocks(
|
| 610 |
+
title="多策略 RAG 文件問答 v2 + Telegram",
|
| 611 |
css=CSS,
|
| 612 |
theme=gr.themes.Base(
|
| 613 |
primary_hue=gr.themes.colors.green,
|
|
|
|
| 620 |
# ── 左欄 ──────────────────────────────────
|
| 621 |
with gr.Column(scale=1, min_width=320, elem_classes="card-box"):
|
| 622 |
|
| 623 |
+
# Step 00:Groq API Key
|
| 624 |
+
gr.HTML("<div class='sec-label'>Step 00 · Groq API Key</div>")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 625 |
with gr.Group(elem_id="apikey-box"):
|
| 626 |
api_key_input = gr.Textbox(
|
| 627 |
label="",
|
| 628 |
placeholder="gsk_xxxxxxxxxxxxxxxxxxxxxxxx",
|
| 629 |
+
value=env_groq_key,
|
| 630 |
type="password",
|
| 631 |
lines=1,
|
| 632 |
show_label=False,
|
|
|
|
| 635 |
"套用 API Key", size="sm", elem_id="apply-key-btn"
|
| 636 |
)
|
| 637 |
api_key_status = gr.Textbox(
|
| 638 |
+
value="✓ API Key 已從環境變數載入" if env_groq_key else "⚠ 尚未設定 API Key",
|
| 639 |
interactive=False,
|
| 640 |
lines=1,
|
| 641 |
label="",
|
| 642 |
show_label=False,
|
| 643 |
)
|
| 644 |
|
| 645 |
+
# Step 00b:Telegram 推播設定
|
| 646 |
+
gr.HTML("<div class='sec-label'>Step 00b · Telegram 推播設定</div>")
|
| 647 |
+
with gr.Group(elem_id="telegram-box"):
|
| 648 |
+
tg_token_input = gr.Textbox(
|
| 649 |
+
label="Bot Token",
|
| 650 |
+
placeholder="123456789:AAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
| 651 |
+
value=env_tg_token,
|
| 652 |
+
type="password",
|
| 653 |
+
lines=1,
|
| 654 |
+
)
|
| 655 |
+
tg_chat_id_input = gr.Textbox(
|
| 656 |
+
label="Chat ID",
|
| 657 |
+
placeholder="例如:123456789",
|
| 658 |
+
value=env_tg_chat_id,
|
| 659 |
+
lines=1,
|
| 660 |
+
)
|
| 661 |
+
with gr.Row():
|
| 662 |
+
apply_tg_btn = gr.Button("套用設定", size="sm", elem_id="apply-tg-btn")
|
| 663 |
+
test_tg_btn = gr.Button("發送測試訊息", size="sm", elem_id="test-tg-btn")
|
| 664 |
+
tg_status = gr.Textbox(
|
| 665 |
+
value=(
|
| 666 |
+
"✓ Telegram 設定已從環境變數載入"
|
| 667 |
+
if env_tg_token and env_tg_chat_id
|
| 668 |
+
else "⚠ 尚未設定 Telegram"
|
| 669 |
+
),
|
| 670 |
+
interactive=False,
|
| 671 |
+
lines=1,
|
| 672 |
+
label="",
|
| 673 |
+
show_label=False,
|
| 674 |
+
)
|
| 675 |
+
send_to_telegram_checkbox = gr.Checkbox(
|
| 676 |
+
label="提問後自動將回答推播至 Telegram",
|
| 677 |
+
value=False,
|
| 678 |
+
)
|
| 679 |
+
|
| 680 |
+
# Step 01:上傳文件
|
| 681 |
+
gr.HTML("<div class='sec-label'>Step 01 · 上傳文件</div>")
|
| 682 |
file_input = gr.File(label="PDF / DOCX", file_types=[".pdf", ".docx"])
|
| 683 |
load_btn = gr.Button("↑ 載入文件")
|
| 684 |
status = gr.Textbox(label="狀態", interactive=False, lines=3)
|
| 685 |
|
| 686 |
+
# Step 02:RAG 策略
|
| 687 |
+
gr.HTML("<div class='sec-label'>Step 02 · 選擇 RAG 策略</div>")
|
| 688 |
+
gr.HTML(build_strategy_menu("semantic"))
|
| 689 |
+
strategy_input = gr.Textbox(
|
| 690 |
+
value="semantic",
|
| 691 |
+
elem_id="strategy-hidden",
|
| 692 |
+
label="",
|
| 693 |
+
visible=False,
|
| 694 |
+
)
|
|
|
|
| 695 |
strategy_status = gr.Textbox(
|
| 696 |
+
value="✓ 已選擇策略:語意搜尋",
|
| 697 |
interactive=False,
|
| 698 |
lines=1,
|
| 699 |
label="目前策略",
|
| 700 |
)
|
| 701 |
+
gr.HTML(STRATEGY_MENU_JS)
|
| 702 |
|
| 703 |
+
# Step 03:參數
|
| 704 |
+
gr.HTML("<div class='sec-label'>Step 03 · 搜尋參數</div>")
|
| 705 |
topk = gr.Slider(minimum=1, maximum=10, value=3, step=1, label="Top-K 片段數量")
|
| 706 |
|
| 707 |
# ── 右欄:問答 ────────────────────────────
|
| 708 |
with gr.Column(scale=2, elem_classes="card-box"):
|
| 709 |
+
gr.HTML("<div class='sec-label'>Step 04 · 輸入問題</div>")
|
| 710 |
qin = gr.Textbox(
|
| 711 |
label="",
|
| 712 |
placeholder="例如:這份文件的核心論點是什麼?",
|
|
|
|
| 717 |
gr.HTML("<div class='sec-label'>AI 回答</div>")
|
| 718 |
ans = gr.Textbox(label="", lines=12, interactive=False)
|
| 719 |
|
| 720 |
+
gr.HTML("<div class='sec-label'>Telegram 推播狀態</div>")
|
| 721 |
+
tg_send_status = gr.Textbox(label="", lines=1, interactive=False)
|
| 722 |
+
|
| 723 |
with gr.Accordion("▸ 查看檢索到的文本片段", open=False):
|
| 724 |
src = gr.Textbox(label="", lines=10, interactive=False)
|
| 725 |
|
|
|
|
| 728 |
# ── 事件綁定 ──────────────────────────────────
|
| 729 |
apply_key_btn.click(fn=apply_api_key, inputs=[api_key_input], outputs=[api_key_status])
|
| 730 |
api_key_input.submit(fn=apply_api_key, inputs=[api_key_input], outputs=[api_key_status])
|
| 731 |
+
|
| 732 |
+
apply_tg_btn.click(
|
| 733 |
+
fn=apply_telegram_config,
|
| 734 |
+
inputs=[tg_token_input, tg_chat_id_input],
|
|
|
|
|
|
|
|
|
|
| 735 |
outputs=[tg_status],
|
| 736 |
)
|
| 737 |
+
test_tg_btn.click(fn=do_test_telegram, outputs=[tg_status])
|
| 738 |
+
|
| 739 |
+
load_btn.click(fn=upload_document, inputs=[file_input], outputs=[status])
|
| 740 |
+
strategy_input.change(fn=set_strategy, inputs=[strategy_input], outputs=[strategy_status])
|
| 741 |
+
|
| 742 |
+
ask_btn.click(
|
| 743 |
+
fn=ask,
|
| 744 |
+
inputs=[qin, topk, send_to_telegram_checkbox],
|
| 745 |
+
outputs=[ans, src, tg_send_status],
|
| 746 |
+
)
|
| 747 |
+
qin.submit(
|
| 748 |
+
fn=ask,
|
| 749 |
+
inputs=[qin, topk, send_to_telegram_checkbox],
|
| 750 |
+
outputs=[ans, src, tg_send_status],
|
| 751 |
+
)
|
| 752 |
|
| 753 |
return demo
|
| 754 |
|
| 755 |
|
| 756 |
if __name__ == "__main__":
|
| 757 |
demo = create_interface()
|
| 758 |
+
demo.launch(share=False, server_name="0.0.0.0")
|