GordonHK
fix: correct Llama 3.3 70B model ID (llama3.3-70b → llama-3.3-70b)
7edc86a
Raw
History Blame Contribute Delete
22.7 kB
"""
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()