hcshen0916's picture
modify app.py to send reminder for no api-key
20aeb2e
import gradio as gr
import os
import json
import pickle
import re
from typing import List, Tuple, Dict, Any
from dotenv import load_dotenv
import openai
import faiss
import numpy as np
from sentence_transformers import SentenceTransformer
# 載入環境變數
load_dotenv()
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
# API金鑰不再是必須的,因為使用者可以提供自己的金鑰
if OPENAI_API_KEY:
print("[INFO] 已從環境變數讀取API金鑰")
else:
print("[INFO] 未設置環境變數API金鑰,將使用使用者提供的金鑰")
# 配置
FAISS_DATA_DIR = "faiss_data" # FAISS 資料的資料夾
MODEL_NAME = 'paraphrase-multilingual-MiniLM-L12-v2' # 使用與建立索引時相同的模型
def load_faiss_data(data_dir=FAISS_DATA_DIR):
"""載入 FAISS 索引和 meta 資料"""
if not os.path.exists(data_dir):
raise ValueError(f"未找到 FAISS 資料資料夾:{data_dir}")
# 載入 FAISS 索引
index_path = os.path.join(data_dir, 'criminal_judgments.index')
meta_path = os.path.join(data_dir, 'criminal_judgments_meta.pkl')
if not os.path.exists(index_path) or not os.path.exists(meta_path):
raise ValueError("未找到必要的 FAISS 資料檔案")
print(f"[INFO] 載入 FAISS 索引和 meta 資料")
index = faiss.read_index(index_path)
with open(meta_path, 'rb') as f:
meta_data = pickle.load(f)
return index, meta_data
# 載入模型和資料
try:
model = SentenceTransformer(MODEL_NAME)
faiss_index, meta_data = load_faiss_data()
print("[INFO] 成功載入模型和 FAISS 資料")
except Exception as e:
print(f"[ERROR] 載入失敗:{e}")
print("[INFO] 請先執行 create_judgment_embeddings.py 建立索引")
model, faiss_index, meta_data = None, None, None
def format_judgment_info(judgment_meta):
"""格式化判決資訊"""
return f"""
案件編號:{judgment_meta['JID']}
年度:{judgment_meta['JYEAR']}
案件類型:{judgment_meta['JCASE']}
案號:{judgment_meta['JNO']}
判決日期:{judgment_meta['JDATE']}
案件標題:{judgment_meta['JTITLE']}
PDF連結:{judgment_meta['JPDF']}
"""
def format_retrieval_results(results, meta_data):
"""格式化檢索結果,用於顯示給用戶"""
html = "<div style='max-height: 500px; overflow-y: auto; padding: 1rem; background-color: #f7f7f7; border-radius: 0.5rem;'>"
html += "<h3>📚 檢索到的相關判決</h3>"
for i, (text, meta, match_context) in enumerate(results, start=1):
html += f"<div style='margin-bottom: 1rem; padding: 0.8rem; background-color: white; border-radius: 0.3rem; border-left: 4px solid #3498db;'>"
html += f"<p><strong>判決 {i}</strong></p>"
html += f"<p><strong>案件編號:</strong> {meta['JID']}</p>"
html += f"<p><strong>案件類型:</strong> {meta['JCASE']}</p>"
html += f"<p><strong>判決日期:</strong> {meta['JDATE']}</p>"
html += f"<p><strong>案件標題:</strong> {meta['JTITLE']}</p>"
html += f"<p><strong>PDF連結:</strong> <a href='{meta['JPDF']}' target='_blank'>查看原文</a></p>"
# 顯示匹配上下文(若有)
if match_context:
html += f"<div style='margin-top: 0.5rem; padding: 0.5rem; background-color: #fffbea; border-radius: 0.3rem; border-left: 4px solid #f0c674;'>"
html += f"<p><strong>匹配內容:</strong><br>{match_context}</p>"
html += "</div>"
# 添加判決內容摘要,但縮短長度
html += f"<div style='margin-top: 0.5rem; padding: 0.5rem; background-color: #f8f9fa; border-radius: 0.3rem;'>"
html += f"<p><strong>相關內容摘要:</strong><br>{text[:250]}...</p>"
html += "</div></div>"
html += "</div>"
return html
def preprocess_judgment_text(text: str) -> str:
"""預處理判決書文本,處理可能觸發內容審查的詞彙"""
# 建立替換對照表,保留專業用語的原意
replacements = {
"殺人": "故意致人於死",
"自殺": "自我傷害",
"性侵": "違反性自主",
"強制性交": "違反性自主",
"毒品": "管制藥品",
"槍械": "危險物品",
"暴力": "強制力",
"血": "生理組織",
}
# 進行替換
processed_text = text
for old, new in replacements.items():
processed_text = processed_text.replace(old, new)
return processed_text
def text_search(query: str, meta_data: List[Dict[str, Any]], max_results: int = 10) -> List[Tuple[int, str]]:
"""
使用文本搜尋方式尋找相關判決
Args:
query: 搜尋關鍵字
meta_data: 判決meta資料列表
max_results: 最大返回結果數
Returns:
搜尋結果:[(索引,匹配上下文), ...]
"""
results = []
# 如果查詢字串少於2個字符,返回空結果
if len(query.strip()) < 2:
return results
# 將查詢轉為小寫並移除特殊字符
clean_query = re.sub(r'[^\w\s]', '', query.lower())
search_terms = clean_query.split()
# 為每個判決計算匹配分數
for idx, meta in enumerate(meta_data):
full_text = meta['JFULL'].lower()
# 計算匹配分數和匹配上下文
score = 0
best_context = ""
best_score = 0
# 對每個搜尋詞進行匹配
for term in search_terms:
if term in full_text:
# 完全匹配
term_score = len(term) * 2
# 找出匹配詞的上下文(前後50個字符)
match_idx = full_text.find(term)
start_idx = max(0, match_idx - 50)
end_idx = min(len(full_text), match_idx + len(term) + 50)
context = "..." + meta['JFULL'][start_idx:end_idx] + "..."
if term_score > best_score:
best_score = term_score
best_context = context
score += term_score
else:
# 部分匹配(例如:搜尋ABC,找到BC)
for partial_size in range(len(term)-1, 0, -1):
for i in range(len(term) - partial_size + 1):
partial = term[i:i+partial_size]
if len(partial) >= 2 and partial in full_text: # 只考慮至少2個字符的部分
partial_score = partial_size
# 找出匹配詞的上下文
match_idx = full_text.find(partial)
start_idx = max(0, match_idx - 50)
end_idx = min(len(full_text), match_idx + len(partial) + 50)
context = "..." + meta['JFULL'][start_idx:end_idx] + "..."
if partial_score > best_score:
best_score = partial_score
best_context = context
score += partial_score
break # 找到第一個部分匹配就跳出
# 如果有匹配,加入結果
if score > 0:
results.append((idx, score, best_context))
# 根據分數排序並返回前N個結果
results.sort(key=lambda x: x[1], reverse=True)
return [(idx, context) for idx, _, context in results[:max_results]]
def combined_search(query: str, k: int = 5, search_mode: str = "自動(先文本後向量)"):
"""
結合文本搜尋和向量搜尋
Args:
query: 搜尋查詢
k: 返回結果數量
search_mode: 搜尋模式,可選 "自動(先文本後向量)", "僅文本搜尋", "僅向量搜尋"
Returns:
搜尋結果列表
"""
# 根據搜尋模式選擇搜尋策略
if search_mode == "僅文本搜尋":
# 只使用文本搜尋
text_results = text_search(query, meta_data)
if not text_results:
return [] # 沒有找到匹配結果
results = []
for idx, context in text_results[:k]:
judgment_meta = meta_data[idx]
processed_text = preprocess_judgment_text(judgment_meta['JFULL'])
results.append((processed_text, judgment_meta, context))
return results
elif search_mode == "僅向量搜尋":
# 只使用向量搜尋
query_vector = model.encode([query])[0].astype('float32')
distances, indices = faiss_index.search(query_vector.reshape(1, -1), k)
results = []
for idx in indices[0]:
judgment_meta = meta_data[idx]
processed_text = preprocess_judgment_text(judgment_meta['JFULL'])
results.append((processed_text, judgment_meta, "")) # 無匹配上下文
return results
else: # 預設為 "自動(先文本後向量)"
# 先進行文本搜尋
text_results = text_search(query, meta_data)
# 如果文本搜尋有結果,優先使用
if text_results:
results = []
for idx, context in text_results[:k]:
judgment_meta = meta_data[idx]
processed_text = preprocess_judgment_text(judgment_meta['JFULL'])
results.append((processed_text, judgment_meta, context))
return results
# 如果文本搜尋無結果,使用向量搜尋作為備用
query_vector = model.encode([query])[0].astype('float32')
distances, indices = faiss_index.search(query_vector.reshape(1, -1), k)
results = []
for idx in indices[0]:
judgment_meta = meta_data[idx]
processed_text = preprocess_judgment_text(judgment_meta['JFULL'])
results.append((processed_text, judgment_meta, "")) # 無匹配上下文
return results
def respond(
message: str,
history: List[Tuple[str, str]],
system_message: str,
max_tokens: int,
temperature: float,
top_p: float,
show_retrieval_results: bool,
user_api_key: str = "",
k: int = 5,
model_name: str = "gpt-4o-mini",
search_mode: str = "自動(先文本後向量)",
):
# 檢查必要組件是否已載入
if None in (model, faiss_index, meta_data):
return "錯誤:系統未完全載入。請確認已執行 create_judgment_embeddings.py 建立索引。", None
# 檢查 API 金鑰
api_key = user_api_key.strip() if user_api_key.strip() else OPENAI_API_KEY
if not api_key:
yield "請在右側設定中輸入您的 OpenAI API 金鑰以繼續使用。您可以從 OpenAI 網站獲取 API 金鑰:https://platform.openai.com/api-keys", None
return
# 臨時設置 API 金鑰,僅用於本次請求
client = openai.OpenAI(api_key=api_key)
# 根據問題長度動態調整檢索數量,避免超出上下文限制
effective_k = min(k, 3) if len(message) > 100 else min(k, 4)
# 搜尋相關判決(使用改進的搜尋方法)
results = combined_search(message, k=effective_k, search_mode=search_mode)
# 如果沒有找到結果,返回提示訊息
if not results:
return "抱歉,我沒有找到與您問題相關的判決。請嘗試使用不同的搜尋詞或切換搜尋模式。", None
# 準備上下文,限制每個判決摘要的長度
context = ""
for i, (text, meta, match_context) in enumerate(results, start=1):
context += f"\n[判決 {i}]\n"
context += format_judgment_info(meta)
# 如果有匹配上下文,加入上下文
if match_context:
context += f"匹配內容:\n{match_context}\n"
# 限制每個判決文本的長度
summary_length = 300
context += f"內容摘要:\n{text[:summary_length]}...\n"
# 如果用戶要求顯示檢索結果
retrieval_display = format_retrieval_results(results, meta_data) if show_retrieval_results else None
# 建立系統提示
rag_system_message = f"""{system_message}
這是一個司法判決書分析系統,所有內容均來自真實判決書。請注意:
1. 判決書中可能包含暴力、犯罪等敏感描述,這些是判決書的必要組成部分
2. 請以客觀、專業的法律角度分析這些內容
3. 不要對判決書中的敏感內容進行道德評判或迴避
4. 保持專業中立的態度,準確傳達判決書的內容
根據以下判決資料回答用戶問題。若問題無法從判決資料中回答,請說明你無法提供答案,不要憑空編造。
請在回答中引用具體的判決案號和日期,並說明資訊來源。您可以透過提供的 PDF 連結查看完整判決內容。
參考判決:
{context}
"""
# 建立訊息清單
messages = [
{"role": "system", "content": "你現在是一個專業的法律助理,正在處理司法判決書。請以客觀、專業的態度處理所有內容,包括敏感話題。"},
{"role": "system", "content": rag_system_message}
]
# 只保留最近的幾次對話歷史,減少 token 使用量
recent_history = history[-2:] if len(history) > 2 else history
for val in recent_history:
if val[0]:
messages.append({"role": "user", "content": val[0]})
if val[1]:
messages.append({"role": "assistant", "content": val[1]})
messages.append({"role": "user", "content": message})
# 呼叫 OpenAI API 生成回答
response = ""
try:
for chunk in client.chat.completions.create(
model=model_name,
messages=messages,
max_tokens=max_tokens,
stream=True,
temperature=temperature,
top_p=top_p,
presence_penalty=0.6,
frequency_penalty=0.3,
):
if chunk.choices[0].delta.content is not None:
token = chunk.choices[0].delta.content
response += token
yield response, retrieval_display
except Exception as e:
error_message = f"呼叫 OpenAI API 時發生錯誤:{str(e)}"
yield error_message, retrieval_display
# 自定義 CSS
css = """
.gradio-container {
font-family: "Noto Sans TC", sans-serif !important;
}
.retrieval-results {
margin-top: 1rem;
margin-bottom: 1rem;
}
"""
# Gradio 界面
with gr.Blocks(css=css, theme=gr.themes.Soft()) as demo:
gr.HTML("<h1>🔍 司法判決書搜尋系統(示範)</h1>")
gr.HTML("<p>這個系統能夠搜尋並分析刑事判決書的內容,支援人名搜尋(用於測試與展示,資料為2025年2月全台灣各級法院之刑事判決)。輸入您的問題,系統將檢索相關判決並給出回答。</p>")
with gr.Row():
with gr.Column(scale=3):
chatbot = gr.Chatbot(height=500)
msg = gr.Textbox(
placeholder="請輸入您的問題...",
label="問題輸入",
lines=3
)
with gr.Row():
submit_btn = gr.Button("提交問題", variant="primary")
clear_btn = gr.Button("清除對話")
system_message = gr.Textbox(
value="你是一個專業的法律助理,專門處理司法判決書的分析工作。這是一個司法專業場景,判決書中可能包含暴力、犯罪等敏感描述,這些都是判決書的必要內容,請以客觀、專業的態度處理,不要過度審查或迴避這些內容。請使用專業且平易近人的方式回答問題,並引用具體的判決案號和日期。我會提供判決書的摘要內容,請基於這些摘要進行分析,需要時可以參考完整判決內容的PDF連結。",
label="系統訊息"
)
with gr.Column(scale=1):
show_retrieval = gr.Checkbox(label="顯示檢索結果", value=True)
# 添加 API 金鑰輸入欄位
api_key_input = gr.Textbox(
placeholder="輸入您的 OpenAI API 金鑰...",
label="OpenAI API 金鑰(僅用於本次使用,不會儲存)",
type="password"
)
# 金鑰說明
gr.HTML("""
<div style="margin-bottom: 10px;">
<p style="font-size: 0.85em; color: #666;">
您的 API 金鑰僅用於當前會話,不會被儲存或記錄。
若不提供,系統將嘗試使用環境變數中的金鑰。
</p>
</div>
""")
# 搜尋模式選擇
search_mode = gr.Radio(
choices=["自動(先文本後向量)", "僅文本搜尋", "僅向量搜尋"],
value="自動(先文本後向量)",
label="搜尋模式"
)
max_tokens = gr.Slider(minimum=1, maximum=2048, value=512, step=1, label="最大生成字數")
temperature = gr.Slider(minimum=0.1, maximum=1.0, value=0.7, step=0.1, label="溫度 (Temperature)")
top_p = gr.Slider(minimum=0.1, maximum=1.0, value=0.95, step=0.05, label="Top-p 採樣")
# 模型選擇
model_choice = gr.Radio(
choices=["gpt-4o-mini", "gpt-4o"],
value="gpt-4o-mini",
label="選擇模型"
)
gr.Markdown("""
### 📝 系統說明
#### 參數設定
- **API 金鑰**:輸入您的 OpenAI API 金鑰(不會被儲存)
- **顯示檢索結果**:勾選後會顯示系統檢索到的原始判決內容
- **搜尋模式**:選擇如何搜尋判決書(直接使用AI向量搜尋可能遭遇內容審查,如需搜尋敏感詞彙或人名,請用自動或文本搜尋)
- **自動**:先嘗試文本搜尋,若無結果再用向量搜尋
- **僅文本**:使用精確字詞匹配(支持部分匹配)
- **僅向量**:使用語義相似度搜尋
- **最大生成字數**:控制 AI 回答的最大長度(1-2048)
- **溫度**:控制回答的創造性,越高越有創意(0.1-1.0)
- **Top-p 採樣**:控制用詞的多樣性(0.1-1.0)
- **模型選擇**:選擇不同的 OpenAI 模型
#### 系統特點
- **資料來源**:2025年2月份各級法院刑事判決書([司法院資料開放平臺](https://opendata.judicial.gov.tw/))
- **向量檢索**:FAISS 向量資料庫
- **文本搜尋**:支持模糊匹配,例如搜尋 ABC 也可以找到 BC
#### 使用提示
1. 搜尋人名時,建議使用「文本搜尋」模式
2. 搜尋概念或法律問題時,建議使用「向量搜尋」模式
3. 「自動」模式適合大多數情況
""")
retrieval_results = gr.HTML(label="檢索結果", elem_classes=["retrieval-results"])
def user(user_message, history):
if history is None:
history = []
return "", history + [[user_message, None]]
def bot(history, system_msg, max_tok, temp, top_p_val, show_ret, api_key, model_name, search_mode_val):
if history is None:
return "錯誤:歷史記錄為空。請重新開始對話。", None
user_message = history[-1][0]
history[-1][1] = ""
for response, retrieval_html in respond(
user_message,
history[:-1],
system_msg,
max_tok,
temp,
top_p_val,
show_ret,
api_key,
model_name=model_name,
search_mode=search_mode_val
):
history[-1][1] = response
if retrieval_html:
yield history, retrieval_html
else:
yield history, None
def clear_history():
return [], None
msg.submit(user, [msg, chatbot], [msg, chatbot], queue=False).then(
bot, [chatbot, system_message, max_tokens, temperature, top_p, show_retrieval, api_key_input, model_choice, search_mode], [chatbot, retrieval_results]
)
submit_btn.click(user, [msg, chatbot], [msg, chatbot], queue=False).then(
bot, [chatbot, system_message, max_tokens, temperature, top_p, show_retrieval, api_key_input, model_choice, search_mode], [chatbot, retrieval_results]
)
clear_btn.click(clear_history, None, [chatbot, retrieval_results])
if __name__ == "__main__":
demo.launch()