ZeroJazz commited on
Commit
2a141d0
·
verified ·
1 Parent(s): 2613b99

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +628 -0
app.py ADDED
@@ -0,0 +1,628 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 多策略 RAG 文件問答系統 v2 — ChromaDB + PDF/DOCX 版本(優化版)
3
+
4
+ 安裝依賴:
5
+ pip install gradio groq pypdf python-docx sentence-transformers numpy chromadb scikit-learn
6
+
7
+ 執行:
8
+ python multistrategy_rag_chromadb_docx_v2.py
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ import time
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import chromadb
20
+ import gradio as gr
21
+ import numpy as np
22
+ from docx import Document
23
+ from docx.oxml.table import CT_Tbl
24
+ from docx.oxml.text.paragraph import CT_P
25
+ from docx.table import Table
26
+ from docx.text.paragraph import Paragraph
27
+ from groq import Groq
28
+ from pypdf import PdfReader
29
+ from sentence_transformers import SentenceTransformer
30
+ from sklearn.feature_extraction.text import TfidfVectorizer
31
+
32
+
33
+ # ══════════════════════════════════════════════════════════
34
+ # RAG 核心邏輯(優化版)
35
+ # ══════════════════════════════════════════════════════════
36
+ class MultiStrategyRAG:
37
+
38
+ STRATEGY_MAP = {
39
+ "semantic": "1 ChromaDB 語意搜尋",
40
+ "tfidf": "2 TF-IDF 關鍵詞",
41
+ "hybrid": "3 混合搜尋",
42
+ "rerank": "4 重新排序",
43
+ "multi_query": "5 多查詢擴展",
44
+ "compress": "6 上下文壓縮",
45
+ "parent_child": "7 父子文檔",
46
+ "hyde": "8 假設性答案 HyDE",
47
+ }
48
+
49
+ def __init__(
50
+ self,
51
+ chroma_path: str = "./chroma_db",
52
+ collection_name: str = "audit_rag_chunks",
53
+ child_collection_name: str = "audit_rag_child_chunks",
54
+ ):
55
+ # API client 改為 None,由使用者透過 UI 輸入後動態建立
56
+ self.client: Groq | None = None
57
+
58
+ self.embedding_model = SentenceTransformer(
59
+ "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
60
+ )
61
+
62
+ self.chroma_client = chromadb.PersistentClient(path=chroma_path)
63
+ self.collection = self.chroma_client.get_or_create_collection(
64
+ name=collection_name,
65
+ metadata={"hnsw:space": "cosine"},
66
+ )
67
+ self.child_collection = self.chroma_client.get_or_create_collection(
68
+ name=child_collection_name,
69
+ metadata={"hnsw:space": "cosine"},
70
+ )
71
+
72
+ self.session_id: str | None = None
73
+ self.source_name: str = ""
74
+ self.file_type: str = ""
75
+ self.chunks: list[str] = []
76
+ self.child_chunks: list[str] = []
77
+ self.tfidf_vectorizer: TfidfVectorizer | None = None
78
+ self.tfidf_matrix = None
79
+
80
+ # ── API Key 管理 ─────────────────────────────────────
81
+ def set_api_key(self, api_key: str) -> None:
82
+ """動態設定 Groq API Key,建立或更新 client。"""
83
+ key = (api_key or "").strip()
84
+ self.client = Groq(api_key=key) if key else None
85
+
86
+ # ── 文件載入 ─────────────────────────────────────────
87
+ def load_document(self, file_path: str) -> str:
88
+ try:
89
+ path = Path(file_path)
90
+ if not path.exists():
91
+ return "✗ 載入失敗:找不到檔案"
92
+
93
+ suffix = path.suffix.lower()
94
+ if suffix not in (".pdf", ".docx"):
95
+ return "✗ 目前僅支援 PDF 與 DOCX 檔案"
96
+
97
+ self.source_name = path.name
98
+ self.file_type = suffix.lstrip(".")
99
+ self.session_id = (
100
+ f"{int(time.time())}_{re.sub(r'[^a-zA-Z0-9]+', '_', path.stem)[:40]}"
101
+ )
102
+
103
+ if suffix == ".pdf":
104
+ full_text, stats = self._extract_pdf(path)
105
+ else:
106
+ full_text, stats = self._extract_docx(path)
107
+
108
+ if not full_text.strip():
109
+ return "✗ 載入失敗:文件沒有可擷取文字,可能是掃描圖片檔,需先 OCR"
110
+
111
+ self.chunks = self._split(full_text, chunk_size=800, overlap=150)
112
+ if not self.chunks:
113
+ return "✗ 載入失敗:切段後沒有有效內容"
114
+
115
+ self._build_chroma_index()
116
+ self._build_tfidf_index()
117
+ self._build_child_index()
118
+
119
+ return (
120
+ f"✓ 成功載入 {self.source_name}\n"
121
+ f"類型:{suffix.upper().lstrip('.')} · {stats}\n"
122
+ f"{len(self.chunks)} 個主片段 · ChromaDB Session:{self.session_id}"
123
+ )
124
+ except Exception as exc:
125
+ return f"✗ 載入失敗:{type(exc).__name__}: {exc}"
126
+
127
+ # ── 文字擷取 ─────────────────────────────────────────
128
+ def _extract_pdf(self, path: Path) -> tuple[str, str]:
129
+ reader = PdfReader(str(path))
130
+ parts = []
131
+ for idx, page in enumerate(reader.pages, 1):
132
+ text = page.extract_text() or ""
133
+ if text.strip():
134
+ parts.append(f"\n[PDF 第 {idx} 頁]\n{text}")
135
+ return "\n".join(parts), f"{len(reader.pages)} 頁"
136
+
137
+ def _extract_docx(self, path: Path) -> tuple[str, str]:
138
+ doc = Document(str(path))
139
+ blocks: list[str] = []
140
+ para_count = table_count = 0
141
+
142
+ for child in doc.element.body.iterchildren():
143
+ if isinstance(child, CT_P):
144
+ text = Paragraph(child, doc).text.strip()
145
+ if text:
146
+ para_count += 1
147
+ blocks.append(text)
148
+ elif isinstance(child, CT_Tbl):
149
+ table_count += 1
150
+ tbl_text = self._table_to_text(Table(child, doc))
151
+ if tbl_text.strip():
152
+ blocks.append(f"\n[DOCX 表格 {table_count}]\n{tbl_text}")
153
+
154
+ return "\n\n".join(blocks), f"{para_count} 段落 / {table_count} 表格"
155
+
156
+ def _table_to_text(self, table: Table) -> str:
157
+ rows = []
158
+ for row in table.rows:
159
+ cells = [re.sub(r"\s+", " ", c.text).strip() for c in row.cells if c.text.strip()]
160
+ if cells:
161
+ rows.append(" | ".join(cells))
162
+ return "\n".join(rows)
163
+
164
+ def _split(self, text: str, chunk_size: int, overlap: int) -> list[str]:
165
+ clean = re.sub(r"\s+", " ", text).strip()
166
+ step = max(1, chunk_size - overlap)
167
+ return [
168
+ c for start in range(0, len(clean), step)
169
+ if (c := clean[start: start + chunk_size].strip())
170
+ ]
171
+
172
+ # ── Index 建立 ───────────────────────────────────────
173
+ def _encode(self, texts: list[str]) -> list[list[float]]:
174
+ return (
175
+ self.embedding_model
176
+ .encode(texts, convert_to_numpy=True, normalize_embeddings=True, show_progress_bar=False)
177
+ .astype("float32")
178
+ .tolist()
179
+ )
180
+
181
+ def _build_chroma_index(self) -> None:
182
+ sid = self.session_id
183
+ ids = [f"{sid}_chunk_{i:05d}" for i in range(len(self.chunks))]
184
+ metas = [
185
+ {"session_id": sid, "source": self.source_name,
186
+ "file_type": self.file_type, "chunk_index": i}
187
+ for i in range(len(self.chunks))
188
+ ]
189
+ self.collection.add(ids=ids, documents=self.chunks,
190
+ metadatas=metas, embeddings=self._encode(self.chunks))
191
+
192
+ def _build_tfidf_index(self) -> None:
193
+ self.tfidf_vectorizer = TfidfVectorizer(analyzer="char", ngram_range=(2, 4), max_features=3000)
194
+ self.tfidf_matrix = self.tfidf_vectorizer.fit_transform(self.chunks)
195
+
196
+ def _build_child_index(self) -> None:
197
+ sid = self.session_id
198
+ child_docs, child_ids, child_metas = [], [], []
199
+ for pidx, parent in enumerate(self.chunks):
200
+ for cidx, child in enumerate(self._split(parent, chunk_size=300, overlap=50)):
201
+ child_docs.append(child)
202
+ child_ids.append(f"{sid}_parent_{pidx:05d}_child_{cidx:03d}")
203
+ child_metas.append({"session_id": sid, "source": self.source_name,
204
+ "file_type": self.file_type,
205
+ "parent_index": pidx, "child_index": cidx})
206
+ self.child_chunks = child_docs
207
+ if child_docs:
208
+ self.child_collection.add(ids=child_ids, documents=child_docs,
209
+ metadatas=child_metas, embeddings=self._encode(child_docs))
210
+
211
+ # ── 工具函式 ─────────────────────────────────────────
212
+ def _where(self) -> dict[str, str]:
213
+ return {"session_id": self.session_id or ""}
214
+
215
+ def _chroma_search(self, query: str, k: int, child: bool = False) -> list[dict[str, Any]]:
216
+ if not self.session_id:
217
+ return []
218
+ col = self.child_collection if child else self.collection
219
+ results = col.query(
220
+ query_embeddings=self._encode([query]),
221
+ n_results=max(1, k),
222
+ where=self._where(),
223
+ include=["documents", "metadatas", "distances"],
224
+ )
225
+ docs = results.get("documents", [[]])[0] or []
226
+ metas = results.get("metadatas", [[]])[0] or []
227
+ dists = results.get("distances", [[]])[0] or []
228
+ return [{"text": d, "metadata": m or {}, "distance": dist}
229
+ for d, m, dist in zip(docs, metas, dists)]
230
+
231
+ def _dedupe(self, chunks: list[str], k: int) -> list[str]:
232
+ seen: set[str] = set()
233
+ out: list[str] = []
234
+ for c in chunks:
235
+ key = c[:120]
236
+ if key not in seen:
237
+ seen.add(key)
238
+ out.append(c)
239
+ if len(out) >= k:
240
+ break
241
+ return out
242
+
243
+ def _llm(self, prompt: str, max_tokens: int = 300, temperature: float = 0.3) -> str | None:
244
+ if not self.client:
245
+ return None
246
+ try:
247
+ r = self.client.chat.completions.create(
248
+ model="llama-3.1-8b-instant",
249
+ messages=[{"role": "user", "content": prompt}],
250
+ max_tokens=max_tokens,
251
+ temperature=temperature,
252
+ )
253
+ return r.choices[0].message.content
254
+ except Exception:
255
+ return None
256
+
257
+ # ── 8 種策略 ──────────────────────────────────────────
258
+ def s_semantic(self, query: str, k: int = 3) -> list[str]:
259
+ return [r["text"] for r in self._chroma_search(query, k)]
260
+
261
+ def s_tfidf(self, query: str, k: int = 3) -> list[str]:
262
+ if self.tfidf_vectorizer is None or self.tfidf_matrix is None:
263
+ return []
264
+ qv = self.tfidf_vectorizer.transform([query])
265
+ scores = (self.tfidf_matrix * qv.T).toarray().flatten()
266
+ return [self.chunks[i] for i in scores.argsort()[-k:][::-1]]
267
+
268
+ def s_hybrid(self, query: str, k: int = 3) -> list[str]:
269
+ return self._dedupe(
270
+ self.s_semantic(query, k * 2) + self.s_tfidf(query, k * 2), k
271
+ )
272
+
273
+ def s_rerank(self, query: str, k: int = 3) -> list[str]:
274
+ candidates = self.s_semantic(query, k * 2)
275
+ if not self.client:
276
+ return candidates[:k]
277
+ scored: list[tuple[str, float]] = []
278
+ for chunk in candidates:
279
+ prompt = (f"問題:{query}\n\n文本:{chunk[:500]}\n\n"
280
+ f"請只輸出 0 到 10 的相關度分數(僅數字):")
281
+ resp = self._llm(prompt, max_tokens=10, temperature=0)
282
+ nums = re.findall(r"\d+(?:\.\d+)?", resp or "")
283
+ scored.append((chunk, float(nums[0]) if nums else 0.0))
284
+ scored.sort(key=lambda x: x[1], reverse=True)
285
+ return [c for c, _ in scored[:k]]
286
+
287
+ def s_multi_query(self, query: str, k: int = 3) -> list[str]:
288
+ queries = [query]
289
+ prompt = f"將以下問題改寫成 3 個角度不同的繁體中文問題,每行一題,不加編號:\n{query}"
290
+ resp = self._llm(prompt, max_tokens=200, temperature=0.7)
291
+ if resp:
292
+ extras = [ln.strip("-• 1234567890.、 ") for ln in resp.splitlines() if ln.strip()]
293
+ queries += extras[:3]
294
+ chunks: list[str] = []
295
+ for q in queries:
296
+ chunks.extend(self.s_semantic(q, 2))
297
+ return self._dedupe(chunks, k)
298
+
299
+ def s_compress(self, query: str, k: int = 3) -> list[str]:
300
+ chunks = self.s_semantic(query, k)
301
+ if not self.client:
302
+ return chunks
303
+ compressed = []
304
+ for chunk in chunks:
305
+ prompt = (f"從以下文本中,提取與問題「{query}」最相關的 1-2 句,"
306
+ f"保留繁體中文,不要添加任何解釋:\n\n{chunk}")
307
+ resp = self._llm(prompt, max_tokens=180, temperature=0)
308
+ compressed.append((resp or "").strip() or chunk[:350])
309
+ return compressed
310
+
311
+ def s_parent_child(self, query: str, k: int = 3) -> list[str]:
312
+ hits = self._chroma_search(query, k * 3, child=True)
313
+ seen_parents: list[int] = []
314
+ for h in hits:
315
+ pidx = h.get("metadata", {}).get("parent_index")
316
+ if isinstance(pidx, int) and pidx not in seen_parents:
317
+ seen_parents.append(pidx)
318
+ if len(seen_parents) >= k:
319
+ break
320
+ return [self.chunks[i] for i in seen_parents if 0 <= i < len(self.chunks)]
321
+
322
+ def s_hyde(self, query: str, k: int = 3) -> list[str]:
323
+ prompt = f"請對以下問題給出一段假設性簡短答案(繁體中文):\n{query}"
324
+ hypo = self._llm(prompt, max_tokens=250, temperature=0.7) or query
325
+ return self.s_semantic(hypo, k)
326
+
327
+ # ── 策略路由 ──────────────────────────────────────────
328
+ _FN = {
329
+ "semantic": s_semantic,
330
+ "tfidf": s_tfidf,
331
+ "hybrid": s_hybrid,
332
+ "rerank": s_rerank,
333
+ "multi_query": s_multi_query,
334
+ "compress": s_compress,
335
+ "parent_child": s_parent_child,
336
+ "hyde": s_hyde,
337
+ }
338
+
339
+ def generate_answer(self, query: str, strategy_key: str, top_k: int):
340
+ if not self.chunks:
341
+ return "請先上傳並載入 PDF 或 DOCX 文件。", ""
342
+ if not query.strip():
343
+ return "請輸入問題。", ""
344
+
345
+ fn = self._FN.get(strategy_key, self.s_semantic)
346
+ chunks = fn(self, query, int(top_k))
347
+ context = "\n\n—\n\n".join(chunks)
348
+
349
+ strategy_label = self.STRATEGY_MAP.get(strategy_key, strategy_key)
350
+ source_preview = (
351
+ f"文件:{self.source_name}\n"
352
+ f"策略:{strategy_label} · 片段數:{len(chunks)}\n"
353
+ f"ChromaDB Session:{self.session_id}\n\n"
354
+ f"{'─' * 56}\n\n{context}"
355
+ )
356
+
357
+ if not self.client:
358
+ return (
359
+ "⚠ 尚未設定 Groq API Key。\n"
360
+ "請在左欄「Step 00」輸入您的 Groq API Key 並點擊「套用」後再提問。\n\n"
361
+ "(檢索已完成,可在下方「查看檢索到的文本片段」確認結果)",
362
+ source_preview,
363
+ )
364
+
365
+ prompt = f"""請根據以下上下文回答問題。若上下文無相關資訊,請明確說明無法從文件回答,不要自行編造。
366
+
367
+ 上下文:
368
+ {context}
369
+
370
+ 問題:{query}
371
+
372
+ 請用繁體中文詳細回答,並以條列方式整理重點:"""
373
+
374
+ try:
375
+ r = self.client.chat.completions.create(
376
+ model="llama-3.1-8b-instant",
377
+ messages=[
378
+ {"role": "system", "content": "你是專業的文件分析與 RAG 問答助手。"},
379
+ {"role": "user", "content": prompt},
380
+ ],
381
+ max_tokens=1024,
382
+ temperature=0.3,
383
+ )
384
+ return r.choices[0].message.content, source_preview
385
+ except Exception as exc:
386
+ return f"生成失敗:{type(exc).__name__}: {exc}", source_preview
387
+
388
+
389
+ # ══════════════════════════════════════════════════════════
390
+ # Gradio UI
391
+ # ══════════════════════════════════════════════════════════
392
+ STRATEGY_INFO = [
393
+ ("semantic", "語意搜尋", "ChromaDB 向量相似度,最通用", "🔍"),
394
+ ("tfidf", "TF-IDF", "字元 n-gram 關鍵詞統計", "📊"),
395
+ ("hybrid", "混合搜尋", "語意 + TF-IDF 結果合併去重", "⚡"),
396
+ ("rerank", "重新排序", "LLM 對候選片段二次評分", "🎯"),
397
+ ("multi_query", "多查詢擴展", "自動生成多角度問題聯合搜尋", "🔄"),
398
+ ("compress", "上下文壓縮", "LLM 提取最相關句子精簡上下文", "✂️"),
399
+ ("parent_child", "父子文檔", "小片段定位 → 回傳對應大片段", "📂"),
400
+ ("hyde", "HyDE", "先生成假設答案再語意搜尋", "💡"),
401
+ ]
402
+
403
+ CSS = """
404
+ body, .gradio-container { background:#f5f4f1 !important; }
405
+
406
+ #hdr {
407
+ background:#fff;
408
+ border:1px solid #e5e0d8;
409
+ border-radius:14px;
410
+ padding:28px 36px;
411
+ margin-bottom:20px;
412
+ border-top: 4px solid #2d6a4f;
413
+ }
414
+ .hdr-eyebrow { font-size:11px; letter-spacing:2.5px; color:#2d6a4f; text-transform:uppercase; margin-bottom:6px; }
415
+ .hdr-title { font-size:26px; font-weight:700; color:#1a1714; margin:0 0 6px; }
416
+ .hdr-sub { font-size:14px; color:#6b5e56; }
417
+ .pill { display:inline-block; margin:10px 5px 0 0; padding:3px 10px; border-radius:16px;
418
+ font-size:11px; background:#e8f4f0; color:#2d6a4f; border:1px solid rgba(45,106,79,.2); }
419
+ .pill-amber { background:#fdf4e3; color:#b87a1a; border-color:rgba(184,122,26,.25); }
420
+
421
+ /* API Key 區塊 */
422
+ #apikey-box {
423
+ background: #fffbf2;
424
+ border: 1.5px solid #f0c96a;
425
+ border-radius: 10px;
426
+ padding: 12px 14px;
427
+ margin-bottom: 8px;
428
+ }
429
+
430
+ .strat-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin:10px 0 16px; }
431
+ .strat-card {
432
+ background:#fff;
433
+ border:1.5px solid #e5e0d8;
434
+ border-radius:10px;
435
+ padding:10px 12px;
436
+ cursor:pointer;
437
+ transition:border-color .15s, box-shadow .15s;
438
+ text-align:left;
439
+ width:100%;
440
+ }
441
+ .strat-card:hover { border-color:#2d6a4f; box-shadow:0 2px 8px rgba(45,106,79,.12); }
442
+ .strat-card.active { border-color:#2d6a4f; background:#f0f9f5; box-shadow:0 2px 10px rgba(45,106,79,.18); }
443
+ .strat-icon { font-size:20px; margin-bottom:4px; }
444
+ .strat-name { font-size:13px; font-weight:700; color:#1a1714; margin:0 0 2px; }
445
+ .strat-desc { font-size:11px; color:#7a6e67; line-height:1.4; }
446
+
447
+ .sec-label { font-size:11px; letter-spacing:1.5px; text-transform:uppercase;
448
+ color:#7a6e67; font-weight:700; margin:16px 0 8px; }
449
+ .card-box { background:#fff !important; border:1px solid #e5e0d8 !important;
450
+ border-radius:12px !important; padding:16px !important; }
451
+ #ask-btn { background:#2d6a4f !important; color:#fff !important; border:0 !important; border-radius:8px !important; }
452
+ #apply-key-btn { background:#b87a1a !important; color:#fff !important; border:0 !important; border-radius:8px !important; }
453
+ """
454
+
455
+ HEADER_HTML = """
456
+ <div id="hdr">
457
+ <div class="hdr-eyebrow">Intelligent Document Analysis · v2</div>
458
+ <div class="hdr-title">多策略 RAG 文件問答系統</div>
459
+ <div class="hdr-sub">支援 PDF / DOCX 上傳,採用 ChromaDB 持久化向量資料庫與 8 種 RAG 檢索策略</div>
460
+ <div>
461
+ <span class="pill">▸ Groq API</span>
462
+ <span class="pill">▸ llama-3.1-8b-instant</span>
463
+ <span class="pill pill-amber">▸ ChromaDB</span>
464
+ <span class="pill pill-amber">▸ PDF / DOCX</span>
465
+ <span class="pill">▸ SentenceTransformers</span>
466
+ </div>
467
+ </div>
468
+ """
469
+
470
+
471
+ def build_strategy_menu(selected: str = "semantic") -> str:
472
+ cards = []
473
+ for key, name, desc, icon in STRATEGY_INFO:
474
+ active_cls = "active" if key == selected else ""
475
+ cards.append(
476
+ f"""<button class="strat-card {active_cls}" onclick="selectStrategy('{key}', this)" type="button">
477
+ <div class="strat-icon">{icon}</div>
478
+ <div class="strat-name">{name}</div>
479
+ <div class="strat-desc">{desc}</div>
480
+ </button>"""
481
+ )
482
+ return f'<div class="strat-grid">{"".join(cards)}</div>'
483
+
484
+
485
+ STRATEGY_MENU_JS = """
486
+ <script>
487
+ function selectStrategy(key, el) {
488
+ document.querySelectorAll('.strat-card').forEach(c => c.classList.remove('active'));
489
+ el.classList.add('active');
490
+ const inp = document.getElementById('strategy-hidden');
491
+ if (inp) { inp.value = key; inp.dispatchEvent(new Event('input')); }
492
+ }
493
+ </script>
494
+ """
495
+
496
+ EXAMPLE_QS = [
497
+ ["這份文件的主要內容是什麼?"],
498
+ ["文件中提到哪些重要概念或定義?"],
499
+ ["有哪些關鍵數據、統計資料或案例?"],
500
+ ["文件的結論或建議是什麼?"],
501
+ ["文件提及哪些潛在風險或挑戰?"],
502
+ ]
503
+
504
+
505
+ def create_interface():
506
+ # 啟動時嘗試從環境變數讀取(可留空)
507
+ env_key = os.getenv("GROQ_API_KEY", "").strip()
508
+ rag = MultiStrategyRAG(chroma_path="./chroma_db")
509
+ if env_key:
510
+ rag.set_api_key(env_key)
511
+
512
+ current_strategy = {"key": "semantic"}
513
+
514
+ def apply_api_key(api_key: str):
515
+ key = (api_key or "").strip()
516
+ rag.set_api_key(key)
517
+ if key:
518
+ masked = key[:8] + "****" + key[-4:] if len(key) > 12 else "****"
519
+ return f"✓ API Key 已套用({masked})"
520
+ return "⚠ API Key 已清除,無法呼叫 LLM"
521
+
522
+ def upload_document(file):
523
+ if file is None:
524
+ return "⚠ 請選擇 PDF 或 DOCX 檔案"
525
+ return rag.load_document(file.name)
526
+
527
+ def set_strategy(key: str):
528
+ current_strategy["key"] = key
529
+ return f"✓ 已選擇策略:{dict((k, n) for k, n, *_ in STRATEGY_INFO).get(key, key)}"
530
+
531
+ def ask(query, top_k):
532
+ return rag.generate_answer(query, current_strategy["key"], int(top_k))
533
+
534
+ with gr.Blocks(
535
+ title="多策略 RAG 文件問答 v2",
536
+ css=CSS,
537
+ theme=gr.themes.Base(
538
+ primary_hue=gr.themes.colors.green,
539
+ neutral_hue=gr.themes.colors.stone,
540
+ ),
541
+ ) as demo:
542
+ gr.HTML(HEADER_HTML)
543
+
544
+ with gr.Row(equal_height=False):
545
+ # ── 左欄 ──────────────────────────────────
546
+ with gr.Column(scale=1, min_width=320, elem_classes="card-box"):
547
+
548
+ # ★ Step 00:API Key 輸入(新增)
549
+ gr.HTML("<div class='sec-label'>Step 00 · Groq API Key</div>")
550
+ with gr.Group(elem_id="apikey-box"):
551
+ api_key_input = gr.Textbox(
552
+ label="",
553
+ placeholder="gsk_xxxxxxxxxxxxxxxxxxxxxxxx",
554
+ value=env_key, # 若環境變數已設定則預填
555
+ type="password", # 輸入時遮蔽顯示
556
+ lines=1,
557
+ show_label=False,
558
+ )
559
+ apply_key_btn = gr.Button(
560
+ "套用 API Key", size="sm", elem_id="apply-key-btn"
561
+ )
562
+ api_key_status = gr.Textbox(
563
+ value="✓ API Key 已從環境變數載入" if env_key else "⚠ 尚未設定 API Key",
564
+ interactive=False,
565
+ lines=1,
566
+ label="",
567
+ show_label=False,
568
+ )
569
+
570
+ # Step 01:上傳文件
571
+ gr.HTML("<div class='sec-label'>Step 01 · 上傳文件</div>")
572
+ file_input = gr.File(label="PDF / DOCX", file_types=[".pdf", ".docx"])
573
+ load_btn = gr.Button("↑ 載入文件")
574
+ status = gr.Textbox(label="狀態", interactive=False, lines=3)
575
+
576
+ # Step 02:RAG 策略
577
+ gr.HTML("<div class='sec-label'>Step 02 · 選擇 RAG 策略</div>")
578
+ gr.HTML(build_strategy_menu("semantic"))
579
+ strategy_input = gr.Textbox(
580
+ value="semantic",
581
+ elem_id="strategy-hidden",
582
+ label="",
583
+ visible=False,
584
+ )
585
+ strategy_status = gr.Textbox(
586
+ value="✓ 已選擇策略:語意搜尋",
587
+ interactive=False,
588
+ lines=1,
589
+ label="目前策略",
590
+ )
591
+ gr.HTML(STRATEGY_MENU_JS)
592
+
593
+ # Step 03:參數
594
+ gr.HTML("<div class='sec-label'>Step 03 · 搜尋參數</div>")
595
+ topk = gr.Slider(minimum=1, maximum=10, value=3, step=1, label="Top-K 片段數量")
596
+
597
+ # ── 右欄:問答 ────────────────────────────
598
+ with gr.Column(scale=2, elem_classes="card-box"):
599
+ gr.HTML("<div class='sec-label'>Step 04 · 輸入問題</div>")
600
+ qin = gr.Textbox(
601
+ label="",
602
+ placeholder="例如:這份文件的核心論點是什麼?",
603
+ lines=4,
604
+ )
605
+ ask_btn = gr.Button("提問", variant="primary", size="lg", elem_id="ask-btn")
606
+
607
+ gr.HTML("<div class='sec-label'>AI 回答</div>")
608
+ ans = gr.Textbox(label="", lines=12, interactive=False)
609
+
610
+ with gr.Accordion("▸ 查看檢索到的文本片段", open=False):
611
+ src = gr.Textbox(label="", lines=10, interactive=False)
612
+
613
+ gr.Examples(examples=EXAMPLE_QS, inputs=qin, label="範例問題")
614
+
615
+ # ── 事件綁定 ──────────────────────────────────
616
+ apply_key_btn.click(fn=apply_api_key, inputs=[api_key_input], outputs=[api_key_status])
617
+ api_key_input.submit(fn=apply_api_key, inputs=[api_key_input], outputs=[api_key_status])
618
+ load_btn.click(fn=upload_document, inputs=[file_input], outputs=[status])
619
+ strategy_input.change(fn=set_strategy, inputs=[strategy_input], outputs=[strategy_status])
620
+ ask_btn.click(fn=ask, inputs=[qin, topk], outputs=[ans, src])
621
+ qin.submit(fn=ask, inputs=[qin, topk], outputs=[ans, src])
622
+
623
+ return demo
624
+
625
+
626
+ if __name__ == "__main__":
627
+ demo = create_interface()
628
+ demo.launch(share=False, server_name="0.0.0.0")