Spaces:
Sleeping
Sleeping
| """ | |
| HK UTM LLM Assistant — Hugging Face Spaces deployment | |
| ====================================================== | |
| Pure RAG pipeline: FAISS + sentence-transformers + Cerebras Inference API | |
| No langchain dependency — avoids pydantic v1/v2 conflicts on Python 3.13. | |
| Features: | |
| - Two-column layout: chat (left) + sources sidebar (right) | |
| - Streaming response (token-by-token output) | |
| - FAISS confidence scores + star ratings per source | |
| - Chunk preview in sidebar (first 200 chars of retrieved text) | |
| - Conversation memory (last 10 turns) | |
| - Eager pipeline load at startup (background thread) | |
| Environment variables (set as HF Secrets): | |
| CEREBRAS_API_KEY : Your Cerebras Cloud API key (csk-...) | |
| HF_MODEL_ID : (optional) defaults to Qwen/Qwen2.5-72B-Instruct | |
| """ | |
| import os | |
| import json | |
| import threading | |
| import numpy as np | |
| import gradio as gr | |
| from pathlib import Path | |
| # ── Config ──────────────────────────────────────────────────────────────────── | |
| CEREBRAS_API_KEY = os.environ.get("CEREBRAS_API_KEY", "") | |
| INDEX_DIR = "data/processed/faiss_index" | |
| DATA_DIR = "data/raw" | |
| EMBED_MODEL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" | |
| TOP_K = 6 | |
| BM25_K = 20 | |
| RRF_K = 60 | |
| CE_POOL = 6 # reduced from 12 → faster reranking, RRF pre-filters so quality impact minimal | |
| # Upgraded from cross-encoder/ms-marco-MiniLM-L-6-v2 (English-only) | |
| # mxbai-rerank-large-v2: Chinese benchmark 84.16, 100+ languages, 0.89s latency | |
| # To revert: change back to "cross-encoder/ms-marco-MiniLM-L-6-v2" | |
| # cross-encoder/ms-marco-MiniLM-L-6-v2: 22.7M params, fast, English-focused | |
| # Stable and proven on HF Free CPU tier | |
| CE_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2" | |
| # ── Available LLM models (all free via HF Inference API) ───────────────────── | |
| # Cerebras Inference API — OpenAI-compatible, ~2100 tok/s, 1M tokens/day free | |
| LLM_MODELS = { | |
| "Llama-3.3-70B (Default ★)": { | |
| "id": "llama-3.3-70b", | |
| "desc": "最佳質素 · 強大中英文推理 · Cerebras ~1s", | |
| }, | |
| "Qwen3-32B": { | |
| "id": "qwen-3-32b", | |
| "desc": "Qwen3 旗艦 · 思維模式 · 強中文 · ~1s", | |
| }, | |
| "Llama-3.1-8B (Fast)": { | |
| "id": "llama3.1-8b", | |
| "desc": "輕量快速 · 適合快速測試 · <1s", | |
| }, | |
| } | |
| DEFAULT_MODEL_NAME = "Llama-3.3-70B (Default ★)" | |
| MODEL_NAMES = list(LLM_MODELS.keys()) | |
| UTM_SYSTEM_PROMPT = """You are an expert assistant in UAS Traffic Management (UTM) \ | |
| and U-space systems, specialising in Hong Kong and Greater Bay Area airspace operations. | |
| You have deep knowledge of U-space services (U1-U3), Hong Kong CAD regulations, \ | |
| ICAO/FAA/SESAR UTM frameworks, strategic and tactical deconfliction, \ | |
| Demand-Capacity Balancing (DCB), and eVTOL operations. | |
| LANGUAGE RULE (STRICT): \ | |
| If the user's question contains ANY Chinese characters, you MUST reply ENTIRELY in \ | |
| Traditional Chinese (繁體中文). This is MANDATORY. \ | |
| NEVER use Simplified Chinese (簡體字) under any circumstances — not even for proper nouns. \ | |
| If the user writes in English, respond in English. | |
| Answer clearly and accurately. Always cite your source document. | |
| If information is not in the provided context, say so explicitly.""" | |
| SIDEBAR_PLACEHOLDER = """*請輸入問題,以顯示已檢索的文件。* | |
| --- | |
| ### 三階段檢索流程 | |
| **第一階段 — 混合候選文件選取** | |
| | 標記 | 方法 | | |
| |---|---| | |
| | 🔄 混合 | BM25 + FAISS 均命中 | | |
| | 🧠 語義 | 僅 FAISS 向量搜尋 | | |
| | 🔍 關鍵字 | 僅 BM25 精確配對 | | |
| **第二階段 — 倒數排名融合** | |
| 每種方法取前 20 → 合併成前 12 候選池 | |
| **第三階段 — 交叉編碼器重新排序** | |
| 對 12 個候選項以「(查詢, 區塊)」配對評分 | |
| → 依 CE 分數選取最終前 6 名 | |
| *每個來源顯示 CE 分數,分數愈高代表愈相關。* | |
| **知識庫:** 22 份文件 · 4,960 個區塊 · ICAO · FAA · SESAR · 香港民航處 | |
| """ | |
| # ── Pipeline ────────────────────────────────────────────────────────────────── | |
| _pipeline = None | |
| _pipeline_ready = False | |
| _pipeline_error = None | |
| def _build_index_from_pdfs(embed_model): | |
| import faiss | |
| from pypdf import PdfReader | |
| print("Building FAISS index from PDFs...") | |
| texts, metas = [], [] | |
| chunk_size, overlap = 800, 100 | |
| for fp in sorted(Path(DATA_DIR).rglob("*.pdf")): | |
| try: | |
| reader = PdfReader(str(fp)) | |
| for page_num, page in enumerate(reader.pages): | |
| text = page.extract_text() or "" | |
| start = 0 | |
| while start < len(text): | |
| chunk = text[start:start + chunk_size].strip() | |
| if len(chunk) >= 30 and any(c.isalpha() for c in chunk): | |
| texts.append(chunk) | |
| metas.append({ | |
| "content": chunk, | |
| "source_file": fp.name, | |
| "page": page_num, | |
| }) | |
| start += chunk_size - overlap | |
| except Exception as e: | |
| print(f"Skip {fp.name}: {e}") | |
| print(f"Encoding {len(texts)} chunks...") | |
| embeddings = embed_model.encode( | |
| texts, batch_size=32, show_progress_bar=False, | |
| convert_to_numpy=True, normalize_embeddings=True | |
| ) | |
| dim = embeddings.shape[1] | |
| index = faiss.IndexFlatIP(dim) | |
| index.add(embeddings.astype(np.float32)) | |
| Path(INDEX_DIR).mkdir(parents=True, exist_ok=True) | |
| faiss.write_index(index, f"{INDEX_DIR}/index.faiss") | |
| with open(f"{INDEX_DIR}/metadata.json", "w", encoding="utf-8") as f: | |
| json.dump(metas, f, ensure_ascii=False) | |
| print(f"Index built: {len(texts)} chunks") | |
| return index, metas | |
| def _load_pipeline(): | |
| global _pipeline, _pipeline_ready, _pipeline_error | |
| try: | |
| import faiss | |
| from sentence_transformers import SentenceTransformer | |
| from openai import OpenAI # Cerebras is OpenAI-compatible | |
| print("=== Loading pipeline at startup ===") | |
| print("Loading embedding model...") | |
| embed_model = SentenceTransformer(EMBED_MODEL) | |
| print("Embedding model loaded.") | |
| meta_path = Path(f"{INDEX_DIR}/metadata.json") | |
| index_path = Path(f"{INDEX_DIR}/index.faiss") | |
| if index_path.exists() and meta_path.exists(): | |
| print("Loading pre-built FAISS index...") | |
| index = faiss.read_index(str(index_path)) | |
| with open(str(meta_path), "r", encoding="utf-8") as f: | |
| metadata = json.load(f) | |
| print(f"FAISS index loaded: {index.ntotal} vectors") | |
| else: | |
| index, metadata = _build_index_from_pdfs(embed_model) | |
| # ── BM25 keyword index ──────────────────────────────────────────────── | |
| print("Building BM25 index...") | |
| from rank_bm25 import BM25Okapi | |
| corpus = [m["content"].lower().split() for m in metadata] | |
| bm25 = BM25Okapi(corpus) | |
| print(f"BM25 index built: {len(corpus)} docs") | |
| # ── Cross-Encoder reranker ──────────────────────────────────────── | |
| print("Loading cross-encoder reranker...") | |
| from sentence_transformers import CrossEncoder | |
| cross_encoder = CrossEncoder(CE_MODEL, max_length=512) | |
| print("Cross-encoder (mxbai-rerank-large-v2) loaded.") | |
| print("Initialising LLM clients...") | |
| # Single Cerebras client — model is passed per-call | |
| cerebras_client = OpenAI( | |
| api_key=CEREBRAS_API_KEY, | |
| base_url="https://api.cerebras.ai/v1", | |
| ) | |
| llm_clients = {name: cerebras_client for name in LLM_MODELS} | |
| print(f"Cerebras client ready: {list(llm_clients.keys())}") | |
| _pipeline = { | |
| "embed_model": embed_model, | |
| "index": index, | |
| "metadata": metadata, | |
| "bm25": bm25, | |
| "cross_encoder": cross_encoder, | |
| "llm_clients": llm_clients, | |
| "history": {}, # per-model: {model_name: [messages]} | |
| } | |
| _pipeline_ready = True | |
| print("=== Pipeline ready ===") | |
| except Exception as e: | |
| import traceback | |
| _pipeline_error = str(e) | |
| print(f"Pipeline load FAILED:\n{traceback.format_exc()}") | |
| threading.Thread(target=_load_pipeline, daemon=True).start() | |
| # ── Hybrid Retrieval: BM25 (keyword) + FAISS (semantic) via RRF ────────────── | |
| def retrieve(query: str, k: int = TOP_K): | |
| p = _pipeline | |
| N = BM25_K | |
| K60 = RRF_K | |
| # 1. BM25 keyword ranking | |
| bm25_scores = p["bm25"].get_scores(query.lower().split()) | |
| bm25_top = sorted(enumerate(bm25_scores), key=lambda x: -x[1])[:N] | |
| bm25_ranks = {idx: rank for rank, (idx, _) in enumerate(bm25_top)} | |
| # 2. FAISS semantic ranking | |
| q_emb = p["embed_model"].encode( | |
| [query], convert_to_numpy=True, normalize_embeddings=True | |
| ).astype(np.float32) | |
| _, faiss_idxs = p["index"].search(q_emb, N) | |
| faiss_ranks = {int(i): rank for rank, i in enumerate(faiss_idxs[0]) if i >= 0} | |
| # 3. Reciprocal Rank Fusion (RRF) — get top CE_POOL candidates | |
| all_ids = set(bm25_ranks) | set(faiss_ranks) | |
| rrf_scores = { | |
| doc_id: ( | |
| 1.0 / (K60 + bm25_ranks.get(doc_id, N * 2)) + | |
| 1.0 / (K60 + faiss_ranks.get(doc_id, N * 2)) | |
| ) | |
| for doc_id in all_ids | |
| } | |
| pool = sorted(rrf_scores.items(), key=lambda x: -x[1])[:CE_POOL] | |
| # 4. Cross-Encoder reranking on CE_POOL candidates | |
| pool_ids = [doc_id for doc_id, _ in pool] | |
| pool_docs = [p["metadata"][doc_id] for doc_id in pool_ids] | |
| pairs = [(query, d["content"]) for d in pool_docs] | |
| ce_scores = p["cross_encoder"].predict(pairs).tolist() | |
| # Sort by CE score, take final top-k | |
| ranked = sorted(zip(ce_scores, pool_ids), key=lambda x: -x[0]) | |
| top_k = ranked[:k] | |
| # Normalise CE scores to 70-100% display range | |
| ce_vals = [s for s, _ in top_k] | |
| ce_min, ce_max = min(ce_vals), max(ce_vals) | |
| # 5. Annotate with confidence % + retrieval method badges | |
| results = [] | |
| for ce_s, doc_id in top_k: | |
| doc = dict(p["metadata"][doc_id]) | |
| pct = int((ce_s - ce_min) / (ce_max - ce_min) * 30 + 70) if ce_max > ce_min else 85 | |
| n = max(1, min(5, round(pct / 20))) | |
| doc["relevance_pct"] = pct | |
| doc["ce_score"] = round(ce_s, 2) | |
| doc["stars"] = "★" * n + "☆" * (5 - n) | |
| in_b = doc_id in bm25_ranks | |
| in_f = doc_id in faiss_ranks | |
| doc["method"] = ( | |
| "🔄 混合" if in_b and in_f else | |
| "🧠 語義" if in_f else | |
| "🔍 關鍵字" | |
| ) | |
| results.append(doc) | |
| return results | |
| # ── Build sidebar markdown from retrieved docs ──────────────────────────────── | |
| def build_sidebar(docs: list) -> str: | |
| if not docs: | |
| return SIDEBAR_PLACEHOLDER | |
| lines = ["### 📚 已檢索來源\n"] | |
| seen = set() | |
| rank = 1 | |
| for d in docs: | |
| key = f"{d['source_file']}::{d['page']}" | |
| if key in seen: | |
| continue | |
| seen.add(key) | |
| preview = d["content"].strip().replace("\n", " ") | |
| preview = preview[:220] + "…" if len(preview) > 220 else preview | |
| # Colour bar: green ≥85%, orange 70-84%, grey <70% | |
| pct = d["relevance_pct"] | |
| if pct >= 85: | |
| bar = "🟢" | |
| elif pct >= 70: | |
| bar = "🟡" | |
| else: | |
| bar = "⚪" | |
| method = d.get("method", "") | |
| ce_score = d.get("ce_score", None) | |
| ce_str = f" · CE `{ce_score}`" if ce_score is not None else "" | |
| lines.append( | |
| f"**{rank}. {d['stars']} {bar} `{pct}%`{ce_str} {method}**\n" | |
| f"📄 `{d['source_file']}` \n" | |
| f"📖 Page {d['page']}\n\n" | |
| f"> {preview}\n" | |
| ) | |
| lines.append("---") | |
| rank += 1 | |
| return "\n".join(lines) | |
| # ── Streaming chat handler ──────────────────────────────────────────────────── | |
| # Uses gr.State for sidebar text so Markdown is output-only (Gradio 5 requirement) | |
| def chat(user_message: str, history: list, sidebar_state: str, model_name: str = DEFAULT_MODEL_NAME): | |
| if not user_message.strip(): | |
| yield "", history, sidebar_state, sidebar_state | |
| return | |
| history = history or [] | |
| if not _pipeline_ready: | |
| msg = ("❌ 管道錯誤:" + _pipeline_error) if _pipeline_error else ( | |
| "⏳ 知識庫仍在載入中(約 1 分鐘),請稍後再試。" | |
| ) | |
| history.append({"role": "user", "content": user_message}) | |
| history.append({"role": "assistant", "content": msg}) | |
| yield "", history, sidebar_state, sidebar_state | |
| return | |
| # Retrieve + build sidebar immediately (before LLM call) | |
| docs = retrieve(user_message) | |
| new_sidebar = build_sidebar(docs) | |
| context_parts = [ | |
| f"[Source: {d['source_file']}, Page: {d['page']}]\n{d['content']}" | |
| for d in docs | |
| ] | |
| context = "\n\n---\n\n".join(context_parts) | |
| # Detect if user message contains Chinese characters | |
| has_chinese = any('\u4e00' <= c <= '\u9fff' for c in user_message) | |
| lang_reminder = "\n\n【重要】請務必以繁體中文(Traditional Chinese)回答,嚴禁使用簡體中文。" if has_chinese else "" | |
| user_msg = ( | |
| "Use the following UTM/U-space reference material to answer the question.\n\n" | |
| f"--- CONTEXT ---\n{context}\n--- END CONTEXT ---\n\n" | |
| f"Question: {user_message}" | |
| f"{lang_reminder}" | |
| ) | |
| p = _pipeline | |
| model_name = model_name or DEFAULT_MODEL_NAME | |
| llm_client = p["llm_clients"][model_name] | |
| model_hist = p["history"].setdefault(model_name, []) | |
| model_info = LLM_MODELS[model_name] | |
| messages = [{"role": "system", "content": UTM_SYSTEM_PROMPT}] | |
| for h in model_hist[-10:]: | |
| messages.append(h) | |
| messages.append({"role": "user", "content": user_msg}) | |
| # Show thinking indicator immediately | |
| model_tag = f"\n\n---\n*模型:**{model_name}** — {model_info['desc']}*" | |
| history.append({"role": "user", "content": user_message}) | |
| history.append({"role": "assistant", "content": "⏳ 正在思考中…"}) | |
| yield "", history, new_sidebar, new_sidebar | |
| # Non-streaming single call — more stable on mobile / weak connections | |
| try: | |
| response = llm_client.chat.completions.create( | |
| model=LLM_MODELS[model_name]["id"], | |
| messages=messages, | |
| max_tokens=1024, | |
| temperature=0.3, | |
| ) | |
| answer = response.choices[0].message.content or "" | |
| except Exception as e: | |
| history[-1]["content"] = f"⚠️ 錯誤:{e}\n\n請重試。" | |
| yield "", history, new_sidebar, new_sidebar | |
| return | |
| if not answer.strip(): | |
| history[-1]["content"] = "⚠️ 未能取得回答,請重試。" | |
| yield "", history, new_sidebar, new_sidebar | |
| return | |
| # Finalise — store in per-model history, append model tag | |
| model_hist.append({"role": "user", "content": user_message}) | |
| model_hist.append({"role": "assistant", "content": answer}) | |
| history[-1]["content"] = answer + model_tag | |
| yield "", history, new_sidebar, new_sidebar | |
| def reset_chat(): | |
| if _pipeline: | |
| _pipeline["history"] = {} # clear all per-model histories | |
| return [], SIDEBAR_PLACEHOLDER, SIDEBAR_PLACEHOLDER | |
| def get_status(): | |
| if _pipeline_error: | |
| return f"❌ 錯誤:{_pipeline_error}" | |
| if _pipeline_ready: | |
| return "✅ 就緒 — 4,960 個區塊 · 22 份文件 · 4 個模型可用" | |
| return "⏳ 正在載入知識庫…(首次啟動約需 1–2 分鐘)" | |
| # ── Gradio UI — two-column layout ───────────────────────────────────────────── | |
| css = """ | |
| #sidebar { border-left: 2px solid #e5e7eb; padding-left: 16px; } | |
| #sidebar .prose blockquote { | |
| border-left: 3px solid #6b7280; | |
| padding: 6px 12px; | |
| margin: 4px 0; | |
| background: #f9fafb; | |
| font-size: 0.82em; | |
| color: #374151; | |
| border-radius: 4px; | |
| } | |
| #status-bar input { font-size: 0.85em; color: #6b7280; } | |
| /* ── Mobile responsive ───────────────────────────────────────────── */ | |
| @media (max-width: 768px) { | |
| /* Stack chat + sidebar vertically on mobile */ | |
| .equal_height > .gap { | |
| flex-direction: column !important; | |
| } | |
| /* Sidebar: remove left border, add top border instead */ | |
| #sidebar { | |
| border-left: none !important; | |
| border-top: 2px solid #e5e7eb; | |
| padding-left: 0 !important; | |
| padding-top: 12px; | |
| margin-top: 8px; | |
| } | |
| /* Make model dropdown full width */ | |
| .gr-dropdown { width: 100% !important; } | |
| /* Slightly smaller font in sidebar on mobile */ | |
| #sidebar .prose { font-size: 0.82em; } | |
| /* Ensure chatbot takes full width */ | |
| .chatbot { min-height: 300px !important; } | |
| } | |
| """ | |
| with gr.Blocks(title="HK UTM LLM Assistant", theme=gr.themes.Soft(), css=css) as demo: | |
| # ── Header ──────────────────────────────────────────────────────────────── | |
| gr.Markdown(""" | |
| # ✈️ HK UTM LLM Assistant | |
| **檢索增強生成問答系統 · U-Space / 無人機交通管理 · 香港及國際框架** | |
| """) | |
| status_box = gr.Textbox( | |
| value=get_status, | |
| label="系統狀態", | |
| interactive=False, | |
| every=5, | |
| elem_id="status-bar", | |
| ) | |
| # ── Model selector row ───────────────────────────────────────────────────── | |
| with gr.Row(): | |
| with gr.Column(scale=5): | |
| model_dropdown = gr.Dropdown( | |
| choices=MODEL_NAMES, | |
| value=DEFAULT_MODEL_NAME, | |
| label="🤖 模型選擇(A/B 測試)", | |
| interactive=True, | |
| ) | |
| with gr.Column(scale=7): | |
| model_desc_md = gr.Markdown( | |
| value=f"*{LLM_MODELS[DEFAULT_MODEL_NAME]['desc']}*", | |
| label="模型資訊", | |
| ) | |
| # ── Main two-column area ────────────────────────────────────────────────── | |
| with gr.Row(equal_height=True): | |
| # Left column — chat (65% width) | |
| with gr.Column(scale=13): | |
| chatbot = gr.Chatbot( | |
| label="UTM 問答", | |
| height=500, | |
| bubble_full_width=False, | |
| type="messages", | |
| show_copy_button=True, | |
| ) | |
| with gr.Row(): | |
| msg_box = gr.Textbox( | |
| placeholder="例如:香港民航處對無人機操作有何要求?", | |
| label="您的問題", | |
| scale=5, | |
| autofocus=True, | |
| lines=1, | |
| ) | |
| send_btn = gr.Button("發送 ✈️", variant="primary", scale=1, min_width=100) | |
| reset_btn = gr.Button("🔄 新對話", variant="secondary", size="sm") | |
| gr.Examples( | |
| examples=[ | |
| "香港民航處對無人機系統操作有哪些主要要求?", | |
| "U-space 的 U2 服務包含哪些內容?", | |
| "戰略衝突解除與戰術衝突解除有何分別?", | |
| "請解釋 UTM 中的需求容量平衡(DCB)。", | |
| "USSP 在 U-space 生態系統中擔演什麼角色?", | |
| "ICAO UTM 框架就互通性有何規定?", | |
| ], | |
| inputs=msg_box, | |
| label="範例問題", | |
| ) | |
| # Right column — sources sidebar (35% width) | |
| # sidebar_state (gr.State) holds the text; sidebar_md (gr.Markdown) displays it | |
| with gr.Column(scale=7, elem_id="sidebar"): | |
| sidebar_md = gr.Markdown( | |
| value=SIDEBAR_PLACEHOLDER, | |
| label="已檢索來源", | |
| elem_id="sidebar", | |
| ) | |
| # gr.State stores sidebar text between calls (Markdown is output-only in Gradio 5) | |
| sidebar_state = gr.State(value=SIDEBAR_PLACEHOLDER) | |
| # ── Footer ──────────────────────────────────────────────────────────────── | |
| gr.Markdown(""" | |
| --- | |
| *由 Gordon 建立 · 香港理工大學 AAE5302 · 民航資訊科技專業* | |
| *由 Qwen2.5-72B · Llama-3.3-70B · Qwen3-8B · FAISS · sentence-transformers · Gradio 5 驅動* | |
| """) | |
| # ── Model description update ────────────────────────────────────────────── | |
| model_dropdown.change( | |
| fn=lambda m: f"*{LLM_MODELS[m]['desc']}*", | |
| inputs=[model_dropdown], | |
| outputs=[model_desc_md], | |
| ) | |
| # ── Event wiring ────────────────────────────────────────────────────────── | |
| # outputs: msg_box, chatbot, sidebar_state (State), sidebar_md (Markdown display) | |
| send_btn.click( | |
| chat, | |
| inputs=[msg_box, chatbot, sidebar_state, model_dropdown], | |
| outputs=[msg_box, chatbot, sidebar_state, sidebar_md], | |
| ) | |
| msg_box.submit( | |
| chat, | |
| inputs=[msg_box, chatbot, sidebar_state, model_dropdown], | |
| outputs=[msg_box, chatbot, sidebar_state, sidebar_md], | |
| ) | |
| reset_btn.click( | |
| reset_chat, | |
| outputs=[chatbot, sidebar_state, sidebar_md], | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch() | |