| import gradio as gr |
| import google.generativeai as genai |
| import os |
| from dotenv import load_dotenv |
| import json |
| import pandas as pd |
| import re |
| import tempfile |
| import textwrap |
| from ddgs import DDGS |
|
|
| load_dotenv() |
|
|
| |
| api_key = os.getenv("GEMINI_API_KEY") |
| if not api_key: |
| print("❌ 警告:未偵測到 API Key,請在 Settings > Secrets 中設定 'GEMINI_API_KEY'。") |
| else: |
| genai.configure(api_key=api_key) |
|
|
| |
| def search_weather_info(location, date): |
| """ |
| 使用 DuckDuckGo 搜尋當地的天氣資訊,不需 API Key。 |
| """ |
| search_query = f"{location} {date} 天氣 氣溫 降雨機率 中央氣象署" |
| print(f"🔍 正在搜尋:{search_query}") |
| |
| try: |
| results = DDGS().text(search_query, max_results=3) |
| |
| context_text = "" |
| for res in results: |
| context_text += f"- 標題:{res['title']}\n 內容:{res['body']}\n" |
| return context_text |
| except Exception as e: |
| print(f"⚠️ 搜尋失敗: {e}") |
| return "無法取得即時天氣資訊,請依據歷史氣候推估。" |
|
|
| |
| RAG_FILE = "rag_knowledge.json" |
| KNOWLEDGE_BASE = [] |
|
|
| def load_knowledge_base(): |
| """程式啟動時載入 JSON 資料集""" |
| global KNOWLEDGE_BASE |
| if os.path.exists(RAG_FILE): |
| try: |
| with open(RAG_FILE, "r", encoding="utf-8") as f: |
| KNOWLEDGE_BASE = json.load(f) |
| print(f"📚 RAG 知識庫載入成功!共 {len(KNOWLEDGE_BASE)} 筆片段。") |
| except Exception as e: |
| print(f"⚠️ 知識庫讀取失敗: {e}") |
| else: |
| print(f"⚠️ 找不到 {RAG_FILE},將無法使用 RAG 功能。") |
|
|
| |
| load_knowledge_base() |
|
|
| def retrieve_context(activity): |
| """ |
| 根據活動類型,從知識庫中篩選相關的內容 |
| 這是一個輕量化的 Metadata Filtering RAG |
| """ |
| if not KNOWLEDGE_BASE: |
| return "無知識庫資料" |
| |
| related_chunks = [] |
| |
| |
| keywords = [] |
| if activity == "登山健行": |
| keywords = ["登山", "玉山"] |
| elif activity == "自行車騎行": |
| keywords = ["單車"] |
| elif activity == "馬拉松": |
| keywords = ["跑步"] |
| |
| |
| for chunk in KNOWLEDGE_BASE: |
| source_name = chunk.get("source", "") |
| |
| if any(k in source_name for k in keywords): |
| |
| formatted_text = f"[出處: {os.path.basename(source_name)} P.{chunk['page']}]\n{chunk['content']}" |
| related_chunks.append(formatted_text) |
| |
| |
| if related_chunks: |
| print(f"🔍 RAG 檢索: 針對 '{activity}' 找到了 {len(related_chunks)} 個相關片段。") |
| return "\n\n".join(related_chunks) |
| else: |
| print(f"⚠️ RAG 檢索: 找不到 '{activity}' 的相關資料。") |
| return "無相關知識庫資料" |
|
|
| |
| def adapt_gear_advisor(activity, location, date, duration, feedback): |
| print(f"--- 開始處理請求: {activity} @ {location} ---") |
|
|
| |
| weather_context = search_weather_info(location, date) |
| print(f"📄 獲取外部資訊長度: {len(weather_context)} 字") |
| |
| |
| rag_context = retrieve_context(activity) |
| |
| |
| model_name = 'models/gemini-2.5-flash' |
| try: |
| model = genai.GenerativeModel(model_name) |
| except Exception as e: |
| return f"❌ 模型初始化失敗: {str(e)}", None, None |
|
|
| |
| prompt = f""" |
| 你是一個專業的戶外運動裝備顧問 'AdaptGear'。 |
| |
| 【外部搜尋到的天氣資訊】 |
| 以下是剛剛從網路上搜尋到的真實資料: |
| {weather_context} |
| |
| 【任務目標】 |
| 1. **天氣推估**:請根據 '{location}' 與 '{date}',自行推估該季節的平均氣候(氣溫、降雨機率)。 |
| 2. **核心任務**:參考下方的【RAG 知識庫內容】,生成一份符合該活動規範的客製化裝備清單。 |
| 請優先使用知識庫中提到的專業裝備名稱(例如:若知識庫提到「GTX 外套」,就不要只寫「雨衣」)。 |
| 3. 結合使用者的【歷史回饋】進行反思與調整。 |
| |
| 【RAG 知識庫內容 (請嚴格參考此資料建立清單)】 |
| {rag_context} |
| |
| 【使用者輸入】 |
| - 活動類型:{activity} |
| - 地點/路線:{location} |
| - 日期與時間:{date} |
| - 行程時長:{duration} |
| - 歷史回饋:{feedback if feedback else "無"} |
| |
| 【輸出格式要求】 |
| 只輸出一個標準 JSON 物件: |
| {{ |
| "weather_forecast": "真實天氣數據 (氣溫/降雨機率)", |
| "smart_advice": "結合「RAG 知識庫規範」與「使用者回饋」的綜合建議", |
| "checklist": [ |
| {{ "category": "分類(如:個人裝備)", "item": "裝備名稱", "reason": "說明(例如: 依據玉山檢查表規定必帶)", "quantity": "數量" }}, |
| ... |
| ] |
| }} |
| """ |
| |
| try: |
| print(f"正在呼叫模型 (含搜尋 & RAG)...") |
| response = model.generate_content(prompt) |
| print("✅ 生成成功") |
| raw_text = response.text |
| |
| |
| try: |
| |
| start_idx = raw_text.find('{') |
| end_idx = raw_text.rfind('}') + 1 |
| |
| if start_idx != -1 and end_idx != -1: |
| |
| json_str = raw_text[start_idx:end_idx] |
| data = json.loads(json_str) |
| else: |
| raise ValueError("無法在回應中找到 JSON 區塊 (找不到大括號)") |
|
|
| except json.JSONDecodeError as e: |
| |
| print(f"❌ JSON 解析失敗。原始回應:\n{raw_text}") |
| return f"解析錯誤:模型回傳了非 JSON 格式的內容。\n錯誤細節: {e}", None, None |
| |
| |
| weather_info = data.get('weather_forecast', '無資料') |
| advice_info = data.get('smart_advice', '無資料') |
| display_text = ( |
| f"## 🌤️ 天氣預測\n\n" |
| f"{weather_info}\n\n" |
| f"## 💡 AdaptGear 叮嚀\n\n" |
| f"{advice_info}\n\n" |
| ) |
| |
| |
| checklist = data.get('checklist', []) |
| df = pd.DataFrame(checklist) |
| df.columns = ['類別', '裝備項目', '推薦理由', '數量'] |
| |
| with tempfile.NamedTemporaryFile(delete=False, suffix=".csv", mode='w', encoding='utf-8-sig') as tmp: |
| df.to_csv(tmp.name, index=False) |
| file_path = tmp.name |
| |
| return display_text, df, file_path |
| |
| except Exception as e: |
| error_msg = str(e) |
| print(f"❌ 生成失敗: {error_msg}") |
| return f"發生錯誤:{error_msg}", None, None |
|
|
| |
| with gr.Blocks() as demo: |
| gr.Markdown("# 🏔️ AdaptGear 運動裝備智慧顧問") |
| |
| with gr.Row(): |
| with gr.Column(scale=1): |
| input_activity = gr.Dropdown( |
| label="活動類型", |
| choices=[ |
| "登山健行", |
| "自行車騎行", |
| "馬拉松", |
| ], |
| value="登山健行" |
| ) |
| input_location = gr.Textbox(label="地點/路線", value="嘉明湖", placeholder="例如:嘉明湖") |
| input_date = gr.DateTime(label="出發日期", include_time=False, type="string") |
| input_duration = gr.Textbox(label="行程時長", value="三天兩夜", placeholder="例如:三天兩夜") |
| input_feedback = gr.Textbox(label="歷史回饋 (選填)", lines=3, placeholder="例如:上次覺得水帶不夠...") |
| submit_btn = gr.Button("生成裝備清單", variant="primary") |
| |
| with gr.Column(scale=2): |
| |
| output_advice = gr.Markdown(label="智慧分析") |
| |
| |
| output_table = gr.Dataframe( |
| headers=["類別", "裝備項目", "推薦理由", "數量"], |
| label="客製化裝備清單", |
| interactive=False |
| ) |
| |
| |
| output_file = gr.File(label="下載清單 (.csv)") |
|
|
| submit_btn.click( |
| fn=adapt_gear_advisor, |
| inputs=[input_activity, input_location, input_date, input_duration, input_feedback], |
| outputs=[output_advice, output_table, output_file] |
| ) |
|
|
| |
| if __name__ == "__main__": |
| demo.launch() |