cwadayi commited on
Commit
3aab70e
·
verified ·
1 Parent(s): fb13531

Upload 15 files

Browse files
config/config.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # config.py (Gemini 最終版)
2
+ import os
3
+ import tempfile
4
+ from datetime import datetime
5
+
6
+ # ==============================================================================
7
+ # 1. 執行環境設定 (適用於 Hugging Face Spaces)
8
+ # ==============================================================================
9
+
10
+ # 設定一個暫存目錄給 Matplotlib 快取字體 (若未來有繪圖功能)
11
+ os.environ.setdefault("MPLCONFIGDIR", "/tmp/matplotlib")
12
+
13
+ # 定義一個暫存目錄來存放生成的靜態檔案
14
+ STATIC_DIR = os.getenv("STATIC_DIR", os.path.join(tempfile.gettempdir(), "static"))
15
+ os.makedirs(STATIC_DIR, exist_ok=True)
16
+
17
+
18
+ # ==============================================================================
19
+ # 2. 憑證與金鑰 (從 Secret Variables 讀取)
20
+ # ==============================================================================
21
+
22
+ # LINE Bot 憑證
23
+ CHANNEL_ACCESS_TOKEN = os.getenv("CHANNEL_ACCESS_TOKEN")
24
+ CHANNEL_SECRET = os.getenv("CHANNEL_SECRET")
25
+
26
+ # CWA (中央氣象署) API 金鑰
27
+ CWA_API_KEY = os.getenv("CWA_API_KEY")
28
+
29
+ # Google Gemini API 金鑰
30
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
31
+
32
+
33
+ # ==============================================================================
34
+ # 3. API 端點與 URL
35
+ # ==============================================================================
36
+
37
+ # CWA API 端點
38
+ CWA_ALARM_API = "https://app-2.cwa.gov.tw/api/v1/earthquake/alarm/list"
39
+ CWA_SIGNIFICANT_API = "https://opendata.cwa.gov.tw/api/v1/rest/datastore/E-A0015-001"
40
+
41
+ # USGS API 端點
42
+ USGS_API_BASE_URL = "https://earthquake.usgs.gov/fdsnws/event/1/query"
43
+
44
+ # MCP 伺服器 (Gradio App) URL
45
+ MCP_SERVER_URL = "https://cwadayi-mcp-2.hf.space"# MCP 伺服器 (Gradio App) URL
46
+
47
+ # [新增] PWS 伺服器 API 端點
48
+ PWS_API_URL = "https://cwadayi-mcp-pws.hf.space/gradio_api/mcp/sse"
49
+
50
+
51
+ # CWA PWS 地震訊息 API 端點
52
+ CWA_PWS_EARTHQUAKE_API = "https://cwadayi-app-show-pws.hf.space/cwa-earthquakes"
53
+
54
+ # [新增] PTS 新聞 API 端點
55
+ PTS_NEWS_API = "https://cwadayi-app-show-news.hf.space/pts-news"
56
+
57
+
58
+ # ==============================================================================
59
+ # 4. 一般應用程式設定
60
+ # ==============================================================================
61
+
62
+ # 顯示用的當年年份
63
+ CURRENT_YEAR = datetime.now().year
config/data.py CHANGED
@@ -1,7 +1,7 @@
1
  # config/data.py
2
  import pandas as pd
3
 
4
- # 課程進度表 (維持不變)
5
  schedule_data = {
6
  "週次": ["第一週 (9/10)", "第二週 (9/17)", "第三週 (9/24)", "第四週 (10/1)", "第五週 (10/8)", "第六週 (10/15)", "第七週 (10/22)", "第八週 (10/29)", "第九週 (11/5)", "第十週 (11/12)", "第十一週 (11/19)", "第十二週 (11/26)", "第十三週 (12/3)", "第十四週 (12/10)", "第十五週 (12/17)", "第十六週", "十七週", "第十八週"],
7
  "地球物理知識": ["地球物理概要", "折射探勘學", "校園野外實驗 (折射探勘)", "地球物理專題演講", "折射探勘學", "重力探勘學", "重力探勘學", "期中考", "板塊構造與地震", "板塊構造與地震", "板塊構造與地磁", "板塊構造與地磁", "板塊構造與地熱", "板塊構造與地熱", "期末專題發表", "行憲紀念日 (放假)", "元旦 (放假)", "參訪大屯火山觀測中心 (暫定)"],
@@ -9,7 +9,7 @@ schedule_data = {
9
  }
10
  schedule_df = pd.DataFrame(schedule_data)
11
 
12
- # 知識庫 (擴充答案內容)
13
  KNOWLEDGE_BASE = {
14
  "course": [
15
  {"keywords": ["成績", "評分", "分數", "grading"], "answer": "本課程的評分標準經過精心設計,旨在全面評估您的學習成效。其中 **作業佔比 50%**,是最大的一部分,因為我們深信「做中學」的價值,這包括所有程式碼實作、數據分析報告與 GitHub 更新紀錄,用以衡量您的實踐能力。**期中考佔 40%**,主要檢核您對前半學期地球物理核心理論與程式工具的理解程度。最後 **10% 為平時成績**,我們鼓勵您積極參與課堂討論、勇於提問,因為主動的學習態度是深化知識的關鍵。"},
@@ -27,7 +27,7 @@ KNOWLEDGE_BASE = {
27
  {"keywords": ["板塊", "tectonics"], "answer": "板塊構造學說(Plate Tectonics)是現代地球科學的基石理論,它完美地解釋了全球的地震、火山活動與造山運動。該學說認為,地球最外層的岩石圈並非完整一塊,而是由十幾個巨大的、剛性的板塊所拼合而成。這些板塊漂浮在底下較具塑性的軟流圈之上,並以每年數公分的速度持續移動。在板塊的邊界處,由於板塊間的相互作用(碰撞、分離或錯動),累積了巨大的應力,最終導致了地震的發生,並形成了火山、海溝與山脈等壯麗的地質景觀。"},
28
  {"keywords": ["地磁", "古地磁", "磁場", "geomagnetism", "paleomagnetism"], "answer": "地球本身就像一個巨大的棒狀磁鐵,擁有一個從南極指向北極的磁場,這就是「地磁」。而「古地磁」則是指被岩石記錄下來的古代地球磁場。當火山噴發的熾熱岩漿冷卻時,其中的磁性礦物(如磁鐵礦)就像成千上萬個微小的指南針,它們會將當時地球磁場的方向和強度「鎖定」在岩石中。科學家發現,海底中洋脊兩側的玄武岩,記錄了地球磁場在數百萬年間多次反轉的歷史,並且呈現完美對稱的條帶狀分佈。這成為了海底擴張與板塊構造學說最經典、最無可辯駁的證據之一。"},
29
  {"keywords": ["地熱", "能源", "geothermal"], "answer": "地熱(Geothermal Energy)是源自地球內部的一種穩定且潔淨的可再生能源。其能量主要來自地球深處放射性元素衰變所產生的熱。我們主要透過鑽井到地下的「熱儲層」,將高溫的蒸汽或熱水引導至地表,用以推動渦輪機發電。地熱發電最大的優點是**穩定**,能 24 小時不間斷供電,不像太陽能或風能受天氣影響;同時**碳排放極低**。地熱資源通常集中在板塊邊界,因為這裡地殼較薄、岩漿活動頻繁。台灣正好位於歐亞板塊與菲律賓海板塊的碰撞帶上,因此擁有發展地熱的絕佳地理條件。"},
30
- {"keywords": ["地震儀", "地震學", "seismograph", "seismology", "差別"], "answer": "這兩個詞彙的關係,就像是天文學家與望遠鏡的關係。**地震學(Seismology)** 是一門**科學**,是地球物理學的一個重要分支,它專門研究地震的成因、地震波的傳播、地球的內部構造以及如何減輕地震災害。而**地震儀(Seismograph)** 則是一種**儀器**,是地震學家進行研究時所使用的核心工具。它能精密地偵測並記錄地面的任何微小振動,並將這些振動轉換成一張稱為「地震圖」(Seismogram) 的紀錄。簡單來說:**地震學家使用地震儀來進行地震學研究。**"}
31
  ],
32
 
33
  "tools": [
@@ -44,8 +44,8 @@ KNOWLEDGE_BASE = {
44
  ],
45
 
46
  "general": [
47
- {"keywords": ["你好", "哈囉", "hello", "hi"], "answer": f"你好!我是本課程的 AI 助教,很高興能為您服務。今天是 {pd.Timestamp.now(tz='Asia/Taipei').strftime('%Y年%m月%d日')},有任何關於課程、地球物理或程式工具的問題,都隨時可以問我喔!"},
48
- {"keywords": ["你是誰", "功能", "幹嘛", "who are you"], "answer": "我是本課程專屬的 AI 助教。我的核心任務是根據內建的課程知識庫,即時回答您關於**地球物理核心概念**、**Python 程式工具**(如 PyGMT、ObsPy)以及**課程安排**(如評分標準、進度)的各種問題。當您在「互動體驗區」遇到程式碼問題時,我也能提供初步的除錯協助。您可以試著問我「如何安裝 Anaconda?」或「給我一些期末專題的靈感」。"},
49
  {"keywords": ["謝謝", "感謝", "thank"], "answer": "不客氣!很高興我的回答能對您有幫助。學習的路上有任何問題,都歡迎隨時再來找我討論。祝您學習順利,一起探索地球的奧秘!"},
50
  {"keywords": ["笑話", "好玩", "有趣"], "answer": "好的,這是一個我很喜歡的地質學冷笑話:\n\n一位地質學家走進一家酒吧,酒保問他:「嗨,今天要喝點什麼?」\n\n地質學家回答:「給我一杯『夸克』(Quark)!」\n\n...等等,抱歉,那是物理學家的笑話。我的版本是:\n\n為什麼地質學家從來不賭博?\n\n...因為他們太了解什麼叫做「斷層」(Fault) 了! 😄"}
51
  ]
 
1
  # config/data.py
2
  import pandas as pd
3
 
4
+ # 課程進度表
5
  schedule_data = {
6
  "週次": ["第一週 (9/10)", "第二週 (9/17)", "第三週 (9/24)", "第四週 (10/1)", "第五週 (10/8)", "第六週 (10/15)", "第七週 (10/22)", "第八週 (10/29)", "第九週 (11/5)", "第十週 (11/12)", "第十一週 (11/19)", "第十二週 (11/26)", "第十三週 (12/3)", "第十四週 (12/10)", "第十五週 (12/17)", "第十六週", "十七週", "第十八週"],
7
  "地球物理知識": ["地球物理概要", "折射探勘學", "校園野外實驗 (折射探勘)", "地球物理專題演講", "折射探勘學", "重力探勘學", "重力探勘學", "期中考", "板塊構造與地震", "板塊構造與地震", "板塊構造與地磁", "板塊構造與地磁", "板塊構造與地熱", "板塊構造與地熱", "期末專題發表", "行憲紀念日 (放假)", "元旦 (放假)", "參訪大屯火山觀測中心 (暫定)"],
 
9
  }
10
  schedule_df = pd.DataFrame(schedule_data)
11
 
12
+ # AI 助教知識庫
13
  KNOWLEDGE_BASE = {
14
  "course": [
15
  {"keywords": ["成績", "評分", "分數", "grading"], "answer": "本課程的評分標準經過精心設計,旨在全面評估您的學習成效。其中 **作業佔比 50%**,是最大的一部分,因為我們深信「做中學」的價值,這包括所有程式碼實作、數據分析報告與 GitHub 更新紀錄,用以衡量您的實踐能力。**期中考佔 40%**,主要檢核您對前半學期地球物理核心理論與程式工具的理解程度。最後 **10% 為平時成績**,我們鼓勵您積極參與課堂討論、勇於提問,因為主動的學習態度是深化知識的關鍵。"},
 
27
  {"keywords": ["板塊", "tectonics"], "answer": "板塊構造學說(Plate Tectonics)是現代地球科學的基石理論,它完美地解釋了全球的地震、火山活動與造山運動。該學說認為,地球最外層的岩石圈並非完整一塊,而是由十幾個巨大的、剛性的板塊所拼合而成。這些板塊漂浮在底下較具塑性的軟流圈之上,並以每年數公分的速度持續移動。在板塊的邊界處,由於板塊間的相互作用(碰撞、分離或錯動),累積了巨大的應力,最終導致了地震的發生,並形成了火山、海溝與山脈等壯麗的地質景觀。"},
28
  {"keywords": ["地磁", "古地磁", "磁場", "geomagnetism", "paleomagnetism"], "answer": "地球本身就像一個巨大的棒狀磁鐵,擁有一個從南極指向北極的磁場,這就是「地磁」。而「古地磁」則是指被岩石記錄下來的古代地球磁場。當火山噴發的熾熱岩漿冷卻時,其中的磁性礦物(如磁鐵礦)就像成千上萬個微小的指南針,它們會將當時地球磁場的方向和強度「鎖定」在岩石中。科學家發現,海底中洋脊兩側的玄武岩,記錄了地球磁場在數百萬年間多次反轉的歷史,並且呈現完美對稱的條帶狀分佈。這成為了海底擴張與板塊構造學說最經典、最無可辯駁的證據之一。"},
29
  {"keywords": ["地熱", "能源", "geothermal"], "answer": "地熱(Geothermal Energy)是源自地球內部的一種穩定且潔淨的可再生能源。其能量主要來自地球深處放射性元素衰變所產生的熱。我們主要透過鑽井到地下的「熱儲層」,將高溫的蒸汽或熱水引導至地表,用以推動渦輪機發電。地熱發電最大的優點是**穩定**,能 24 小時不間斷供電,不像太陽能或風能受天氣影響;同時**碳排放極低**。地熱資源通常集中在板塊邊界,因為這裡地殼較薄、岩漿活動頻繁。台灣正好位於歐亞板塊與菲律賓海板塊的碰撞帶上,因此擁有發展地熱的絕佳地理條件。"},
30
+ {"keywords": ["地震儀", "地震學", "seismograph", "seismology", "差別"], "answer": "這兩個詞彙的關係,就像是天文學家與望遠-鏡的關係。**地震學(Seismology)** 是一門**科學**,是地球物理學的一個重要分支,它專門研究地震的成因、地震波的傳播、地球的內部構造以及如何減輕地震災害。而**地震儀(Seismograph)** 則是一種**儀器**,是地震學家進行��究時所使用的核心工具。它能精密地偵測並記錄地面的任何微小振動,並將這些振動轉換成一張稱為「地震圖」(Seismogram) 的紀錄。簡單來說:**地震學家使用地震儀來進行地震學研究。**"}
31
  ],
32
 
33
  "tools": [
 
44
  ],
45
 
46
  "general": [
47
+ {"keywords": ["你好", "哈囉", "hello", "hi"], "answer": f"你好!我是課程 AI 助教,很高興能為您服務。今天是 {pd.Timestamp.now(tz='Asia/Taipei').strftime('%Y年%m月%d日')},有任何關於課程、地球物理或程式工具的問題,都隨時可以問我喔!"},
48
+ {"keywords": ["你是誰", "功能", "幹嘛", "who are you"], "answer": "我是本課程專屬的 AI 助教。我的核心任務是根據內建的課程知識庫,回答您關於**地球物理、程式工具**與**課程安排**的靜態問題。\n\n**更厲害的是,我現在還能查詢即時資訊!** 試著問我看看:\n- `「最新的 CWA 地震報告」`\n- `「今天有什麼新聞?」`\n- `「全球最近有什麼大地震?」`\n- `「現在有地震預警嗎?」`"},
49
  {"keywords": ["謝謝", "感謝", "thank"], "answer": "不客氣!很高興我的回答能對您有幫助。學習的路上有任何問題,都歡迎隨時再來找我討論。祝您學習順利,一起探索地球的奧秘!"},
50
  {"keywords": ["笑話", "好玩", "有趣"], "answer": "好的,這是一個我很喜歡的地質學冷笑話:\n\n一位地質學家走進一家酒吧,酒保問他:「嗨,今天要喝點什麼?」\n\n地質學家回答:「給我一杯『夸克』(Quark)!」\n\n...等等,抱歉,那是物理學家的笑話。我的版本是:\n\n為什麼地質學家從來不賭博?\n\n...因為他們太了解什麼叫做「斷層」(Fault) 了! 😄"}
51
  ]
core/callbacks.py CHANGED
@@ -4,8 +4,10 @@ import contextlib
4
  import traceback
5
  from datetime import datetime
6
  import pytz
7
- import difflib # ✨ 1. 導入 difflib 函式庫
8
 
 
 
9
  from core.visits import get_current_visit_count
10
  from core.notifications import send_line_notification_in_background
11
  from config.data import KNOWLEDGE_BASE
@@ -17,37 +19,28 @@ def execute_user_code(code_string, source_lab):
17
  status = "✅ 成功"
18
  error_info = ""
19
  fig = None
20
-
21
  try:
22
  with contextlib.redirect_stdout(string_io):
23
  local_scope = {}
24
- # Pre-import necessary libraries for the user's code
25
  exec("import matplotlib.pyplot as plt; import numpy as np; import cartopy.crs as ccrs; import cartopy.feature as cfeature; from matplotlib.ticker import FixedFormatter", local_scope)
26
  exec(code_string, local_scope)
27
-
28
  console_output = string_io.getvalue()
29
  fig = local_scope.get('fig')
30
-
31
  if fig is None:
32
  status = "⚠️ 警告"
33
  error_info = "程式碼執行完畢,但未找到 'fig' 物件。"
34
  return None, f"{error_info}\nPrint 輸出:\n{console_output}"
35
-
36
  success_message = f"✅ 程式碼執行成功!\n\n--- Console Output ---\n{console_output}"
37
  return fig, success_message
38
-
39
  except Exception:
40
  status = "❌ 失敗"
41
  error_info = traceback.format_exc()
42
  final_message = f"❌ 程式碼執行失敗!\n\n--- Error Traceback ---\n{error_info}"
43
  return None, final_message
44
-
45
  finally:
46
- # This block will always run, regardless of success or failure
47
  tz = pytz.timezone('Asia/Taipei')
48
  current_time = datetime.now(tz).strftime('%H:%M:%S')
49
  visit_count = get_current_visit_count()
50
-
51
  notification_text = (
52
  f"🔬 程式碼實驗室互動!\n\n"
53
  f"時間: {current_time}\n"
@@ -56,49 +49,47 @@ def execute_user_code(code_string, source_lab):
56
  f"總載入數: {visit_count}"
57
  )
58
  if status == "❌ 失敗":
59
- # Add specific error type to notification for quick debugging
60
  error_type = error_info.strip().split('\n')[-1]
61
  notification_text += f"\n錯誤類型: {error_type}"
62
-
63
  send_line_notification_in_background(notification_text)
64
 
 
 
 
 
 
 
 
 
 
65
 
66
- # --- 2. 加入新的模糊比對函式 ---
67
- def find_best_match(user_input, knowledge_base, threshold=0.6):
68
- """
69
- Finds the best matching answer from the knowledge base using fuzzy string matching.
70
- """
71
  best_score = 0
72
  best_answer = "這個問題很有趣,不過我的知識庫目前還沒有收錄相關的答案。您可以試著問我關於**課程評分、Anaconda安裝、Colab與Codespaces的差別**等問題!"
73
  best_match_keyword = None
74
-
75
- # Iterate through all keywords in the knowledge base
76
  for category, entries in knowledge_base.items():
77
  for entry in entries:
78
  for keyword in entry['keywords']:
79
- # Calculate similarity score
80
  score = difflib.SequenceMatcher(None, user_input.lower(), keyword.lower()).ratio()
81
  if score > best_score:
82
  best_score = score
83
  best_match_keyword = keyword
84
  best_answer = entry['answer']
85
-
86
- # If the best score is above the threshold, return the answer directly.
87
  if best_score >= threshold:
88
  return best_answer
89
- # If the score is too low, but we found a potential match, ask for confirmation.
90
  elif best_match_keyword:
91
  return f"這個問題我不是很確定,您是指 **「{best_match_keyword}」** 嗎?\n\n我目前找到的相關資料如下:\n\n{best_answer}"
92
- # If the knowledge base was empty or no match was found at all.
93
  else:
94
  return best_answer
95
 
96
-
97
  def ai_chatbot_with_kb(message, history):
98
  """
99
- Handles chatbot interaction by calling the fuzzy matching function and sends a notification.
 
100
  """
101
- # Notification logic (remains the same)
102
  tz = pytz.timezone('Asia/Taipei')
103
  current_time = datetime.now(tz).strftime('%H:%M:%S')
104
  visit_count = get_current_visit_count()
@@ -110,6 +101,18 @@ def ai_chatbot_with_kb(message, history):
110
  )
111
  send_line_notification_in_background(notification_text)
112
 
113
- # --- ✨ 3. 使用新的模糊比對函式取代舊的搜尋邏輯 ---
114
- # The old exact-match logic is now replaced by a single call to our new function.
115
- return find_best_match(message.strip(), KNOWLEDGE_BASE)
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  import traceback
5
  from datetime import datetime
6
  import pytz
7
+ import difflib
8
 
9
+ # --- ✨ 1. 匯入所有外部服務的函式 ---
10
+ from services import cwa_service, news_service, pws_service, usgs_service
11
  from core.visits import get_current_visit_count
12
  from core.notifications import send_line_notification_in_background
13
  from config.data import KNOWLEDGE_BASE
 
19
  status = "✅ 成功"
20
  error_info = ""
21
  fig = None
 
22
  try:
23
  with contextlib.redirect_stdout(string_io):
24
  local_scope = {}
 
25
  exec("import matplotlib.pyplot as plt; import numpy as np; import cartopy.crs as ccrs; import cartopy.feature as cfeature; from matplotlib.ticker import FixedFormatter", local_scope)
26
  exec(code_string, local_scope)
 
27
  console_output = string_io.getvalue()
28
  fig = local_scope.get('fig')
 
29
  if fig is None:
30
  status = "⚠️ 警告"
31
  error_info = "程式碼執行完畢,但未找到 'fig' 物件。"
32
  return None, f"{error_info}\nPrint 輸出:\n{console_output}"
 
33
  success_message = f"✅ 程式碼執行成功!\n\n--- Console Output ---\n{console_output}"
34
  return fig, success_message
 
35
  except Exception:
36
  status = "❌ 失敗"
37
  error_info = traceback.format_exc()
38
  final_message = f"❌ 程式碼執行失敗!\n\n--- Error Traceback ---\n{error_info}"
39
  return None, final_message
 
40
  finally:
 
41
  tz = pytz.timezone('Asia/Taipei')
42
  current_time = datetime.now(tz).strftime('%H:%M:%S')
43
  visit_count = get_current_visit_count()
 
44
  notification_text = (
45
  f"🔬 程式碼實驗室互動!\n\n"
46
  f"時間: {current_time}\n"
 
49
  f"總載入數: {visit_count}"
50
  )
51
  if status == "❌ 失敗":
 
52
  error_type = error_info.strip().split('\n')[-1]
53
  notification_text += f"\n錯誤類型: {error_type}"
 
54
  send_line_notification_in_background(notification_text)
55
 
56
+ # --- ✨ 2. 建立一個「工具清單」,將關鍵字與服務函式對應 ---
57
+ LIVE_TOOLS = [
58
+ {"keywords": ["新聞", "今日新聞", "news"], "function": news_service.fetch_today_news, "name": "今日新聞"},
59
+ {"keywords": ["cwa地震", "顯著地震", "有感地震"], "function": cwa_service.fetch_significant_earthquakes, "name": "CWA 顯著有感地震"},
60
+ {"keywords": ["地震預警", "cwa alarm"], "function": cwa_service.fetch_cwa_alarm_list, "name": "CWA 地震預警"},
61
+ {"keywords": ["全球地震", "usgs"], "function": usgs_service.fetch_global_last24h_text, "name": "全球顯著地震"},
62
+ {"keywords": ["pws發布", "pws info"], "function": pws_service.fetch_latest_pws_info, "name": "PWS 發布情形"},
63
+ {"keywords": ["pws地震", "pws alert"], "function": pws_service.fetch_cwa_pws_earthquake_info, "name": "PWS 地震警報"},
64
+ ]
65
 
66
+ def find_best_match_from_kb(user_input, knowledge_base, threshold=0.6):
67
+ """(原有的函式) 從靜態知識庫中尋找最佳匹配的答案。"""
 
 
 
68
  best_score = 0
69
  best_answer = "這個問題很有趣,不過我的知識庫目前還沒有收錄相關的答案。您可以試著問我關於**課程評分、Anaconda安裝、Colab與Codespaces的差別**等問題!"
70
  best_match_keyword = None
 
 
71
  for category, entries in knowledge_base.items():
72
  for entry in entries:
73
  for keyword in entry['keywords']:
 
74
  score = difflib.SequenceMatcher(None, user_input.lower(), keyword.lower()).ratio()
75
  if score > best_score:
76
  best_score = score
77
  best_match_keyword = keyword
78
  best_answer = entry['answer']
 
 
79
  if best_score >= threshold:
80
  return best_answer
 
81
  elif best_match_keyword:
82
  return f"這個問題我不是很確定,您是指 **「{best_match_keyword}」** 嗎?\n\n我目前找到的相關資料如下:\n\n{best_answer}"
 
83
  else:
84
  return best_answer
85
 
86
+ # --- ✨ 3. 更新主函式,優先檢查是否觸發工具,再查詢知識庫 ---
87
  def ai_chatbot_with_kb(message, history):
88
  """
89
+ 處理聊天機器人互動的主函式。
90
+ 優先檢查是否觸發即時工具,若無,則查詢靜態知識庫。
91
  """
92
+ # (通知邏輯維持不變)
93
  tz = pytz.timezone('Asia/Taipei')
94
  current_time = datetime.now(tz).strftime('%H:%M:%S')
95
  visit_count = get_current_visit_count()
 
101
  )
102
  send_line_notification_in_background(notification_text)
103
 
104
+ user_message = message.lower().strip()
105
+
106
+ # 步驟 1: 檢查是否觸發任何即時工具
107
+ for tool in LIVE_TOOLS:
108
+ for keyword in tool["keywords"]:
109
+ if keyword in user_message:
110
+ try:
111
+ # 如果觸發,執行對應的函式並回傳結果
112
+ print(f"🔍 觸發即時工具:{tool['name']}")
113
+ return tool["function"]()
114
+ except Exception as e:
115
+ return f"❌ 執行「{tool['name']}」工具時發生錯誤:{e}"
116
+
117
+ # 步驟 2: 如果沒有觸發工具,則查詢靜態知識庫 (使用舊有的模糊比對)
118
+ return find_best_match_from_kb(user_message, KNOWLEDGE_BASE)
services/__init__.py ADDED
File without changes
services/cwa_service.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # cwa_service.py (Final Defensive Parsing Version)
2
+ import requests
3
+ import re
4
+ import pandas as pd
5
+ from datetime import datetime, timedelta, timezone
6
+ from config import CWA_API_KEY, CWA_ALARM_API, CWA_SIGNIFICANT_API
7
+
8
+ TAIPEI_TZ = timezone(timedelta(hours=8))
9
+
10
+ def _to_float(x):
11
+ if x is None: return None
12
+ s = str(x).strip()
13
+ m = re.search(r"[-+]?\d+(?:\.\d+)?", s)
14
+ return float(m.group()) if m else None
15
+
16
+ def _parse_cwa_time(s: str) -> tuple[str, str]:
17
+ if not s: return ("未知", "未知")
18
+ dt_utc = None
19
+ try:
20
+ dt_utc = datetime.fromisoformat(s.replace("Z", "+00:00"))
21
+ except ValueError:
22
+ try:
23
+ dt_local = datetime.strptime(s, "%Y-%m-%d %H:%M:%S")
24
+ dt_local = dt_local.replace(tzinfo=TAIPEI_TZ)
25
+ dt_utc = dt_local.astimezone(timezone.utc)
26
+ except Exception:
27
+ return (s, "未知")
28
+ if dt_utc:
29
+ tw_str = dt_utc.astimezone(TAIPEI_TZ).strftime("%Y-%m-%d %H:%M")
30
+ utc_str = dt_utc.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M")
31
+ return (tw_str, utc_str)
32
+ return (s, "未知")
33
+
34
+ def fetch_cwa_alarm_list(limit: int = 5) -> str:
35
+ try:
36
+ r = requests.get(CWA_ALARM_API, timeout=10)
37
+ r.raise_for_status()
38
+ payload = r.json()
39
+ except Exception as e:
40
+ return f"❌ 地震預警查詢失敗:{e}"
41
+ items = payload.get("data", [])
42
+ if not items: return "✅ 目前沒有地震預警。"
43
+ def _key(it):
44
+ try: return datetime.fromisoformat(it.get("originTime", "").replace("Z", "+00:00"))
45
+ except: return datetime.min.replace(tzinfo=timezone.utc)
46
+ items = sorted(items, key=_key, reverse=True)
47
+ lines = ["🚨 地震預警(最新):", "-" * 20]
48
+ for it in items[:limit]:
49
+ mag = _to_float(it.get("magnitudeValue"))
50
+ depth = _to_float(it.get("depth"))
51
+ tw_str, _ = _parse_cwa_time(it.get("originTime", ""))
52
+ identifier = str(it.get('identifier', '—')).replace('{', '{{').replace('}', '}}')
53
+ msg_type = str(it.get('msgType', '—')).replace('{', '{{').replace('}', '}}')
54
+ msg_no = str(it.get('msgNo', '—')).replace('{', '{{').replace('}', '}}')
55
+ location_desc_list = it.get('locationDesc')
56
+ areas_str = ", ".join(str(area) for area in location_desc_list) if isinstance(location_desc_list, list) and location_desc_list else "—"
57
+ areas = areas_str.replace('{', '{{').replace('}', '}}')
58
+ mag_str = f"{mag:.1f}" if mag is not None else "—"
59
+ depth_str = f"{depth:.0f}" if depth is not None else "—"
60
+ lines.append(
61
+ f"事件: {identifier} | 類型: {msg_type}#{msg_no}\n"
62
+ f"規模/深度: M{mag_str} / {depth_str} km\n"
63
+ f"時間: {tw_str}(台灣)\n"
64
+ f"地點: {areas}"
65
+ )
66
+ return "\n\n".join(lines).strip()
67
+
68
+ def _parse_significant_earthquakes(obj: dict) -> pd.DataFrame:
69
+ records = obj.get("records", {})
70
+ quakes = records.get("Earthquake", [])
71
+ rows = []
72
+ for q in quakes:
73
+ # [偵錯] 如果需要,可以取消下面這行的註解,它會在 Log 中印出最原始的資料
74
+ # print(f"原始地震資料: {q}")
75
+
76
+ ei = q.get("EarthquakeInfo", {})
77
+
78
+ # [修正] 使用更穩健的方式取得所有資料,檢查所有已知的大小寫和備用名稱
79
+ epic = ei.get("Epicenter") or ei.get("epicenter") or {}
80
+ mag_info = ei.get("Magnitude") or ei.get("magnitude") or ei.get("EarthquakeMagnitude") or {}
81
+ depth_raw = ei.get("FocalDepth") or ei.get("depth") or ei.get("Depth")
82
+ mag_raw = mag_info.get("MagnitudeValue") or mag_info.get("magnitudeValue") or mag_info.get("Value") or mag_info.get("value")
83
+
84
+ rows.append({
85
+ "ID": q.get("EarthquakeNo"), "Time": ei.get("OriginTime"),
86
+ "Lat": _to_float(epic.get("EpicenterLatitude") or epic.get("epicenterLatitude")),
87
+ "Lon": _to_float(epic.get("EpicenterLongitude") or epic.get("epicenterLongitude")),
88
+ "Depth": _to_float(depth_raw),
89
+ "Magnitude": _to_float(mag_raw),
90
+ "Location": epic.get("Location") or epic.get("location"),
91
+ "URL": q.get("Web") or q.get("ReportURL"),
92
+ })
93
+
94
+ df = pd.DataFrame(rows)
95
+ if not df.empty and "Time" in df.columns:
96
+ # df["Time"] = pd.to_datetime(df["Time"], errors="coerce", utc=True).dt.tz_convert(TAIPEI_TZ)
97
+ # 假設 TAIPEI_TZ 已在檔案開頭定義
98
+ df["Time"] = pd.to_datetime(df["Time"], errors="coerce").dt.tz_localize(TAIPEI_TZ)
99
+ return df
100
+
101
+ def fetch_significant_earthquakes(days: int = 7, limit: int = 5) -> str:
102
+ if not CWA_API_KEY: return "❌ 顯著地震查詢失敗:管理者尚未設定 CWA_API_KEY。"
103
+ now = datetime.now(timezone.utc)
104
+ time_from = (now - timedelta(days=days)).strftime("%Y-%m-%d")
105
+ params = {"Authorization": CWA_API_KEY, "format": "JSON", "timeFrom": time_from}
106
+ try:
107
+ r = requests.get(CWA_SIGNIFICANT_API, params=params, timeout=15)
108
+ r.raise_for_status()
109
+ data = r.json()
110
+ df = _parse_significant_earthquakes(data)
111
+ if df.empty: return f"✅ 過去 {days} 天內沒有顯著有感地震報告。"
112
+ df = df.sort_values(by="Time", ascending=False).head(limit)
113
+ lines = [f"🚨 CWA 最新顯著有感地震 (近{days}天内):", "-" * 20]
114
+ for _, row in df.iterrows():
115
+ mag_str = f"{row['Magnitude']:.1f}" if pd.notna(row['Magnitude']) else "—"
116
+ depth_str = f"{row['Depth']:.0f}" if pd.notna(row['Depth']) else "—"
117
+ lines.append(
118
+ f"時間: {row['Time'].strftime('%Y-%m-%d %H:%M') if pd.notna(row['Time']) else '—'}\n"
119
+ f"地點: {row['Location'] or '—'}\n"
120
+ f"規模: M{mag_str} | 深度: {depth_str} km\n"
121
+ f"報告: {row['URL'] or '無'}"
122
+ )
123
+ return "\n\n".join(lines)
124
+ except Exception as e:
125
+ return f"❌ 顯著地震查詢失敗:{e}"
126
+
127
+ def fetch_latest_significant_earthquake() -> dict | None:
128
+ try:
129
+ if not CWA_API_KEY: raise ValueError("錯誤:尚未設定 CWA_API_KEY Secret。")
130
+ params = {"Authorization": CWA_API_KEY, "format": "JSON", "limit": 1, "orderby": "OriginTime desc"}
131
+ r = requests.get(CWA_SIGNIFICANT_API, params=params, timeout=15)
132
+ r.raise_for_status()
133
+ data = r.json()
134
+ df = _parse_significant_earthquakes(data)
135
+ if df.empty: return None
136
+
137
+ latest_eq_data = df.iloc[0].to_dict()
138
+
139
+ quakes = data.get("records", {}).get("Earthquake", [])
140
+ if quakes:
141
+ latest_eq_data["ImageURL"] = quakes[0].get("ReportImageURI")
142
+
143
+ if pd.notna(latest_eq_data.get("Time")):
144
+ latest_eq_data["TimeStr"] = latest_eq_data["Time"].strftime('%Y-%m-%d %H:%M')
145
+
146
+ return latest_eq_data
147
+ except Exception as e:
148
+ raise e
149
+
services/news_service.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # news_service.py
2
+ import requests
3
+ import json
4
+ from datetime import datetime
5
+
6
+ # 從 config.py 匯入 API URL
7
+ from config import PTS_NEWS_API
8
+
9
+ def fetch_today_news() -> str:
10
+ """
11
+ 從公視新聞網 API 擷取今日新聞並格式化為易於閱讀的文字訊息。
12
+ """
13
+ try:
14
+ # 發送 API 請求
15
+ response = requests.get(PTS_NEWS_API, timeout=20)
16
+ response.raise_for_status() # 確保請求成功
17
+
18
+ # 解析 JSON 回應
19
+ data = response.json()
20
+
21
+ source = data.get("source", "未知來源")
22
+ last_updated_raw = data.get("last_updated")
23
+
24
+ # 格式化資料更新時間
25
+ if last_updated_raw:
26
+ try:
27
+ # 處理可能包含時區資訊的 ISO 格式時間
28
+ last_updated_dt = datetime.fromisoformat(last_updated_raw.replace("Z", "+00:00"))
29
+ last_updated_str = last_updated_dt.strftime('%Y-%m-%d %H:%M')
30
+ except (ValueError, TypeError):
31
+ last_updated_str = str(last_updated_raw)
32
+ else:
33
+ last_updated_str = "未知"
34
+
35
+ # 建立訊息標頭
36
+ lines = [
37
+ f"📰 今日新聞摘要 ({source})",
38
+ f" (資料更新: {last_updated_str})",
39
+ "─" * 25
40
+ ]
41
+
42
+ articles = data.get("articles", [])
43
+ if not articles:
44
+ lines.append("\nℹ️ 目前沒有可顯示的新聞。")
45
+ return "\n".join(lines)
46
+
47
+ # 逐條解析並格式化新聞
48
+ for i, article in enumerate(articles[:10]): # 最多顯示 10 則新聞
49
+ title = article.get("title", "無標題").strip()
50
+ link = article.get("link", "#")
51
+ summary = article.get("summary", "無摘要。").strip()
52
+
53
+ # 組合單條新聞訊息
54
+ article_lines = [
55
+ f"📌 {title}",
56
+ f"📝 摘要: {summary}",
57
+ f"🔗 閱讀全文: {link}"
58
+ ]
59
+
60
+ lines.append("\n".join(article_lines))
61
+
62
+ # 在新聞之間加入分隔線
63
+ if i < len(articles) - 1:
64
+ lines.append("-" * 15)
65
+
66
+ return "\n\n".join(lines)
67
+
68
+ except requests.exceptions.Timeout:
69
+ return "❌ 新聞查詢失敗:連線超時。"
70
+ except requests.exceptions.RequestException as e:
71
+ return f"❌ 新聞查詢失敗:網路連線錯誤。\n錯誤訊息:{e}"
72
+ except json.JSONDecodeError:
73
+ return "❌ 新聞查詢失敗:無法解析伺服器回傳的資料格式。"
74
+ except Exception as e:
75
+ return f"❌ 新聞查詢失敗:發生未預期的錯誤。\n錯誤訊息:{e}"
services/pws_service.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # pws_service.py (Robust Version)
2
+ import requests
3
+ import json
4
+ from datetime import datetime
5
+ from config import PWS_API_URL, CWA_PWS_EARTHQUAKE_API
6
+
7
+ def fetch_latest_pws_info() -> str:
8
+ """
9
+ 從 MCP PWS 伺服器擷取最新的 PWS (Public Weather Service) 發布情形。
10
+ 此版本經過優化,能穩定處理 Gradio API 的 Server-Sent Events (SSE) 串流。
11
+ """
12
+ final_report = None
13
+
14
+ try:
15
+ # 使用 stream=True 保持連線,接收伺服器持續發送的事件
16
+ with requests.get(PWS_API_URL, timeout=20, stream=True) as r:
17
+ r.raise_for_status() # 確保 HTTP 連線成功
18
+
19
+ # 迭代處理從伺服器接收到的每一行資料
20
+ for line in r.iter_lines():
21
+ if line:
22
+ decoded_line = line.decode('utf-8')
23
+
24
+ # SSE 事件的資料以 "data: " 開頭
25
+ if decoded_line.startswith('data:'):
26
+ json_data_str = decoded_line[len('data:'):].strip()
27
+
28
+ try:
29
+ data = json.loads(json_data_str)
30
+ # Gradio API 在完成時會發送 'process_completed' 訊息
31
+ if data.get("msg") == "process_completed":
32
+ # 最終結果通常在 output['data'][0] 中
33
+ output_data = data.get("output", {}).get("data", [])
34
+ if output_data:
35
+ final_report = output_data[0]
36
+ break # 收到最終結果,跳出迴圈
37
+
38
+ except (json.JSONDecodeError, KeyError, IndexError):
39
+ # 如果中途的訊息格式不符或不含所需資料,靜默忽略並繼續等待
40
+ continue
41
+
42
+ # 迴圈結束後,檢查是否成功取得最終報告
43
+ if final_report:
44
+ header = "🛰️ 最新 PWS 發布情形:"
45
+ separator = "-" * 20
46
+ return f"{header}\n{separator}\n{final_report}"
47
+ else:
48
+ return "❌ PWS 查詢失敗:已連接伺服器,但未收到有效的最終報告資料。"
49
+
50
+ except requests.exceptions.Timeout:
51
+ return f"❌ PWS 查詢失敗:連線超時,伺服器沒有在 20 秒內回應。"
52
+ except requests.exceptions.RequestException as e:
53
+ return f"❌ PWS 查詢失敗:網路連線錯誤。\n錯誤訊息:{e}"
54
+ except Exception as e:
55
+ return f"❌ PWS 查詢失敗:發生未預期的錯誤。\n錯誤訊息:{e}"
56
+
57
+ def fetch_cwa_pws_earthquake_info() -> str:
58
+ """從指定的 API 端點擷取最新的 CWA PWS 地震訊息並格式化輸出。"""
59
+ try:
60
+ response = requests.get(CWA_PWS_EARTHQUAKE_API, timeout=15)
61
+ response.raise_for_status()
62
+
63
+ data = response.json()
64
+
65
+ source = data.get("source", "未知來源")
66
+ last_updated_raw = data.get("last_updated")
67
+
68
+ # 格式化資料更新時間
69
+ if last_updated_raw:
70
+ try:
71
+ last_updated_dt = datetime.fromisoformat(last_updated_raw)
72
+ last_updated_str = last_updated_dt.strftime('%Y-%m-%d %H:%M')
73
+ except (ValueError, TypeError):
74
+ last_updated_str = last_updated_raw
75
+ else:
76
+ last_updated_str = "未知"
77
+
78
+ # 建立訊息標頭
79
+ lines = [
80
+ f"📢 最新地震PWS警報 ({source})",
81
+ f" (資料更新時間: {last_updated_str})",
82
+ "─" * 25
83
+ ]
84
+
85
+ alerts = data.get("alerts", [])
86
+ if not alerts:
87
+ lines.append("\n✅ 目前無最新的地震PWS警報。")
88
+ return "\n".join(lines)
89
+
90
+ # 逐條解析並格式化警報
91
+ for i, alert in enumerate(alerts):
92
+ title = alert.get("title", "無標題")
93
+ publish_time_raw = alert.get("publish_time")
94
+
95
+ # 格式化警報發布時間
96
+ if publish_time_raw:
97
+ try:
98
+ publish_time_dt = datetime.fromisoformat(publish_time_raw)
99
+ publish_time_str = publish_time_dt.strftime('%Y-%m-%d %H:%M')
100
+ except (ValueError, TypeError):
101
+ publish_time_str = publish_time_raw
102
+ else:
103
+ publish_time_str = "未知"
104
+
105
+ summary = alert.get("summary", "無摘要。").strip()
106
+ affected_counties = alert.get("affected_counties", [])
107
+ affected_str = ", ".join(affected_counties) if affected_counties else "未提供"
108
+
109
+ # 組合單條警報訊息
110
+ alert_lines = [
111
+ f"🚨 {title} ({publish_time_str})",
112
+ f"🗺️ 影響地區: {affected_str}",
113
+ f"💬 內容: {summary}"
114
+ ]
115
+
116
+ # 將單條警報加入主訊息列表
117
+ lines.append("\n".join(alert_lines))
118
+
119
+ # 在警報之間加入分隔線
120
+ if i < len(alerts) - 1:
121
+ lines.append("-" * 15)
122
+
123
+ return "\n\n".join(lines)
124
+
125
+ except requests.exceptions.Timeout:
126
+ return "❌ PWS 地震訊息查詢失敗:連線超時。"
127
+ except requests.exceptions.RequestException as e:
128
+ return f"❌ PWS 地震訊息查詢失敗:網路連線錯誤。\n錯誤訊息:{e}"
129
+ except json.JSONDecodeError:
130
+ return "❌ PWS 地震訊息查詢失敗:無法解析伺服器回傳的資料格式。"
131
+ except Exception as e:
132
+ return f"❌ PWS 地震訊息查詢失敗:發生未預期的錯誤。\n錯誤訊息:{e}"
services/usgs_service.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # usgs_service.py
2
+ import requests
3
+ import pandas as pd
4
+ from datetime import datetime, timedelta, timezone
5
+ from config import USGS_API_BASE_URL, CURRENT_YEAR
6
+
7
+ def _iso(dt: datetime) -> str:
8
+ """將 datetime 物件格式化為 USGS API 需要的 ISO 8601 字串。"""
9
+ return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
10
+
11
+ def fetch_global_last24h_text(min_mag: float = 5.0, limit: int = 10) -> str:
12
+ """從 USGS 擷取過去 24 小時的全球顯著地震。"""
13
+ now_utc = datetime.now(timezone.utc)
14
+ since = now_utc - timedelta(hours=24)
15
+ params = {
16
+ "format": "geojson",
17
+ "starttime": _iso(since),
18
+ "endtime": _iso(now_utc),
19
+ "minmagnitude": float(min_mag),
20
+ "limit": int(limit),
21
+ "orderby": "time",
22
+ }
23
+ try:
24
+ r = requests.get(USGS_API_BASE_URL, params=params, timeout=15)
25
+ r.raise_for_status()
26
+ features = r.json().get("features", [])
27
+ if not features:
28
+ return f"✅ 過去 24 小時內,全球無規模 {min_mag} 以上的顯著地震。"
29
+
30
+ lines = [f"🚨 近 24 小時全球顯著地震 (M≥{min_mag}):", "-" * 20]
31
+ for f in features:
32
+ p = f["properties"]
33
+ t_utc = datetime.fromtimestamp(p["time"] / 1000, tz=timezone.utc)
34
+
35
+ lines.append(
36
+ # [修改] 將 "震級" 改為 "規模"
37
+ f"規模: {p['mag']:.1f} | 日期時間: {t_utc.strftime('%Y-%m-%d %H:%M')} (UTC)\n"
38
+ f"地點: {p.get('place', 'N/A')}\n"
39
+ f"報告連結: {p.get('url', '無')}"
40
+ )
41
+ return "\n\n".join(lines)
42
+ except Exception as e:
43
+ return f"❌ 查詢失敗:{e}"
44
+
45
+ def fetch_taiwan_df_this_year(min_mag: float = 5.0) -> pd.DataFrame | str:
46
+ """從USGS擷取今年以來台灣區域的顯著地震。"""
47
+ now_utc = datetime.now(timezone.utc)
48
+ start_of_year_utc = datetime(now_utc.year, 1, 1, tzinfo=timezone.utc)
49
+ params = {
50
+ "format": "geojson", "starttime": _iso(start_of_year_utc), "endtime": _iso(now_utc),
51
+ "minmagnitude": float(min_mag),
52
+ "minlatitude": 21, "maxlatitude": 26,
53
+ "minlongitude": 119, "maxlongitude": 123,
54
+ "limit": 250,
55
+ "orderby": "time",
56
+ }
57
+ try:
58
+ r = requests.get(USGS_API_BASE_URL, params=params, timeout=20)
59
+ r.raise_for_status()
60
+ features = r.json().get("features", [])
61
+ if not features:
62
+ return f"✅ 今年 ({CURRENT_YEAR} 年) 以來,台灣區域無 M≥{min_mag:.1f} 的顯著地震。"
63
+
64
+ rows = []
65
+ for f in features:
66
+ p = f["properties"]
67
+ lon, lat, *_ = f["geometry"]["coordinates"]
68
+ rows.append({
69
+ "latitude": lat,
70
+ "longitude": lon,
71
+ "magnitude": p["mag"],
72
+ "place": p.get("place", ""),
73
+ "time_utc": datetime.fromtimestamp(p["time"]/1000, tz=timezone.utc),
74
+ "url": p.get("url", "")
75
+ })
76
+ return pd.DataFrame(rows)
77
+ except Exception as e:
78
+ return f"❌ 查詢失敗: {e}"