Spaces:
Sleeping
Sleeping
Upload 15 files
Browse files- config/config.py +63 -0
- config/data.py +5 -5
- core/callbacks.py +33 -30
- services/__init__.py +0 -0
- services/cwa_service.py +149 -0
- services/news_service.py +75 -0
- services/pws_service.py +132 -0
- services/usgs_service.py +78 -0
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": "
|
| 31 |
],
|
| 32 |
|
| 33 |
"tools": [
|
|
@@ -44,8 +44,8 @@ KNOWLEDGE_BASE = {
|
|
| 44 |
],
|
| 45 |
|
| 46 |
"general": [
|
| 47 |
-
{"keywords": ["你好", "哈囉", "hello", "hi"], "answer": f"
|
| 48 |
-
{"keywords": ["你是誰", "功能", "幹嘛", "who are you"], "answer": "我是本課程專屬的 AI
|
| 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
|
| 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 |
-
|
| 67 |
-
|
| 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 |
-
|
|
|
|
| 100 |
"""
|
| 101 |
-
#
|
| 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 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}"
|