XiaoBai1221 commited on
Commit
e2e01e0
·
1 Parent(s): e8439b4

Update MCP tools and add TDX tools

Browse files
features/mcp/agent_bridge.py CHANGED
@@ -685,33 +685,98 @@ class MCPAgentBridge:
685
  }
686
 
687
  def _get_tools_description(self) -> str:
688
- """獲取簡化的工具描述,專注於核心信息"""
689
- descriptions = []
690
-
691
- for tool_name, tool in self.mcp_server.tools.items():
692
- # 簡化描述格式
693
- desc = f"{tool_name}: {tool.description}"
694
-
695
- # 只保留最重要的參數信息
696
- input_schema = tool.inputSchema
697
- properties = input_schema.get("properties", {})
698
-
699
- if properties:
700
- # 只顯示必需參數
701
- required = input_schema.get("required", [])
702
- if required:
703
- params = []
704
- for param_name in required:
705
- if param_name in properties:
706
- param_info = properties[param_name]
707
- param_type = param_info.get("type", "string")
708
- params.append(f"{param_name}({param_type})")
709
- if params:
710
- desc += f" | 參數: {', '.join(params)}"
711
-
712
- descriptions.append(desc)
713
-
714
- return "\n".join(descriptions)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
715
 
716
  def _keyword_intent_detection(self, message: str) -> Tuple[bool, Optional[Dict[str, Any]]]:
717
  """關鍵詞匹配檢測 (備用方案)"""
 
685
  }
686
 
687
  def _get_tools_description(self) -> str:
688
+ """獲取分類整理的工具摘要(使用輕量級摘要,減少 token 消耗 60-70%)"""
689
+ # 使用 MCPServer 的 get_tools_summary() 獲取輕量級摘要
690
+ try:
691
+ tools_summary = self.mcp_server.get_tools_summary()
692
+ except Exception as e:
693
+ logger.error(f"獲取工具摘要失敗: {e}")
694
+ # 降級:使用舊邏輯
695
+ tools_summary = []
696
+ for tool_name, tool in self.mcp_server.tools.items():
697
+ tools_summary.append({
698
+ "name": tool_name,
699
+ "description": tool.description if hasattr(tool, 'description') else "",
700
+ "category": "其他",
701
+ "keywords": [],
702
+ "is_complex": False
703
+ })
704
+
705
+ # 按類別組織工具
706
+ categorized_tools = {
707
+ "地理定位": [],
708
+ "軌道運輸": [],
709
+ "道路運輸": [],
710
+ "微型運具": [],
711
+ "停車與充電": [],
712
+ "生活資訊": [],
713
+ "健康數據": [],
714
+ "其他": []
715
+ }
716
+
717
+ for summary in tools_summary:
718
+ category = summary.get("category", "其他")
719
+ name = summary.get("name", "unknown")
720
+ desc = summary.get("description", "")
721
+ keywords = summary.get("keywords", [])
722
+ is_complex = summary.get("is_complex", False)
723
+
724
+ # 格式化:工具名 - 描述 | 關鍵字
725
+ keywords_str = ", ".join(keywords[:5]) if keywords else "" # 最多顯示 5 個關鍵字
726
+ if keywords_str:
727
+ line = f"- {name}: {desc} | 關鍵字: {keywords_str}"
728
+ else:
729
+ line = f"- {name}: {desc}"
730
+
731
+ # 標記複雜工具
732
+ if is_complex:
733
+ line += " [複雜]"
734
+
735
+ # 將工具加入對應類別
736
+ if category in categorized_tools:
737
+ categorized_tools[category].append(line)
738
+ else:
739
+ categorized_tools["其他"].append(line)
740
+
741
+ # 構建分類描述
742
+ result = []
743
+
744
+ # 定義類別順序和說明
745
+ category_order = [
746
+ ("地理定位", "【地理定位與導航】地點查詢、路線規劃"),
747
+ ("軌道運輸", "【軌道運輸】捷運、台鐵、高鐵"),
748
+ ("道路運輸", "【道路運輸】公車、客運"),
749
+ ("微型運具", "【微型運具】YouBike 共享單車"),
750
+ ("停車與充電", "【停車與充電】停車場、充電站"),
751
+ ("生活資訊", "【生活資訊】天氣、新聞、匯率"),
752
+ ("健康數據", "【健康數據】心率、步數、血氧、睡眠"),
753
+ ("其他", "【其他功能】")
754
+ ]
755
+
756
+ for category, header in category_order:
757
+ tools = categorized_tools.get(category, [])
758
+ if tools:
759
+ result.append(f"\n{header}")
760
+ result.extend(tools)
761
+
762
+ # 添加工具選擇指引
763
+ result.append("\n【工具選擇指引】")
764
+ result.append("1. 導航問題(「怎麼去」「路線」「導航」) → directions")
765
+ result.append("2. 地點查詢(「XXX在哪」「地址」) → forward_geocode")
766
+ result.append("3. 公共運輸查詢 → 根據運具類型選擇對應工具")
767
+ result.append(" - 公車 → tdx_bus_arrival")
768
+ result.append(" - 捷運 → tdx_metro")
769
+ result.append(" - 台鐵 → tdx_train")
770
+ result.append(" - 高鐵 → tdx_thsr")
771
+ result.append(" - YouBike → tdx_youbike")
772
+ result.append(" - 停車場/充電站 → tdx_parking")
773
+ result.append("4. 所有 tdx 工具都會自動感知用戶���置,無需手動提供座標")
774
+ result.append("5. 健康數據查詢 → healthkit_query(心率、步數、血氧等)")
775
+ result.append("6. 生活資訊 → weather_query(天氣)、news_query(新聞)、exchange_query(匯率)")
776
+ result.append("7. 標記 [複雜] 的工具只需返回工具名稱,參數稍後填充")
777
+
778
+ logger.debug(f"工具描述已生成,總長度: {len(''.join(result))} 字元")
779
+ return "\n".join(result)
780
 
781
  def _keyword_intent_detection(self, message: str) -> Tuple[bool, Optional[Dict[str, Any]]]:
782
  """關鍵詞匹配檢測 (備用方案)"""
features/mcp/server.py CHANGED
@@ -7,7 +7,8 @@ import json
7
  import sys
8
  import asyncio
9
  import logging
10
- from typing import Dict, Any, List, Optional, Callable
 
11
  from enum import Enum
12
  from .types import Tool
13
  from .auto_registry import MCPAutoRegistry
@@ -51,6 +52,10 @@ class FeaturesMCPServer:
51
  self.tools: Dict[str, Tool] = {}
52
  self.handlers: Dict[str, Callable] = {}
53
 
 
 
 
 
54
  # 保存註冊器引用以便清理
55
  self._registry = None
56
 
@@ -208,6 +213,108 @@ class FeaturesMCPServer:
208
  """註冊工具"""
209
  self.tools[tool.name] = tool
210
  logger.info(f"註冊工具: {tool.name}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
 
212
  async def _handle_initialize(self, params: Dict[str, Any]) -> Dict[str, Any]:
213
  """處理初始化請求"""
 
7
  import sys
8
  import asyncio
9
  import logging
10
+ import time
11
+ from typing import Dict, Any, List, Optional, Callable, Tuple
12
  from enum import Enum
13
  from .types import Tool
14
  from .auto_registry import MCPAutoRegistry
 
52
  self.tools: Dict[str, Tool] = {}
53
  self.handlers: Dict[str, Callable] = {}
54
 
55
+ # Schema 快取(用於 Lazy Loading)
56
+ self._schema_cache: Dict[str, Tuple[Dict, float]] = {} # {tool_name: (schema, timestamp)}
57
+ self._schema_cache_ttl = 600 # 10 分鐘
58
+
59
  # 保存註冊器引用以便清理
60
  self._registry = None
61
 
 
213
  """註冊工具"""
214
  self.tools[tool.name] = tool
215
  logger.info(f"註冊工具: {tool.name}")
216
+
217
+ def get_tools_summary(self) -> List[Dict[str, Any]]:
218
+ """
219
+ 獲取所有工具的摘要(用於 Intent Detection,減少 token 消耗)
220
+
221
+ 返回輕量級工具列表,只包含:
222
+ - name, description_short, category, keywords, is_complex
223
+ - 簡單工具額外包含 params 列表
224
+
225
+ 相比完整 schema,節省約 60-70% tokens
226
+ """
227
+ summaries = []
228
+
229
+ for tool_name, tool in self.tools.items():
230
+ try:
231
+ # 檢查工具是否有 get_summary 方法(新工具)
232
+ if hasattr(tool, 'get_summary') and callable(tool.get_summary):
233
+ summary = tool.get_summary()
234
+ else:
235
+ # 向後兼容:為舊工具生成簡化摘要
236
+ summary = {
237
+ "name": tool_name,
238
+ "description": tool.description[:50] + "..." if len(tool.description) > 50 else tool.description,
239
+ "category": tool.metadata.get('category', 'general') if hasattr(tool, 'metadata') and tool.metadata else 'general',
240
+ "keywords": tool.metadata.get('keywords', []) if hasattr(tool, 'metadata') and tool.metadata else [],
241
+ "is_complex": False # 舊工具預設為簡單工具
242
+ }
243
+
244
+ summaries.append(summary)
245
+
246
+ except Exception as e:
247
+ logger.error(f"生成工具摘要失敗: {tool_name}, 錯誤: {e}")
248
+ # 降級:返回最基本的資訊
249
+ summaries.append({
250
+ "name": tool_name,
251
+ "description": tool.description if hasattr(tool, 'description') else "未知工具",
252
+ "category": "其他",
253
+ "keywords": [],
254
+ "is_complex": False
255
+ })
256
+
257
+ logger.debug(f"生成工具摘要: {len(summaries)} 個工具")
258
+ return summaries
259
+
260
+ def get_tool_full_schema(self, tool_name: str) -> Dict[str, Any]:
261
+ """
262
+ 獲取工具的完整 Schema(Lazy Loading,按需載入)
263
+
264
+ 使用快取機制:
265
+ - 首次調用:載入完整 schema 並快取 10 分鐘
266
+ - 後續調用:直接從快取返回(節省計算)
267
+ - 快取過期:重新載入
268
+
269
+ Args:
270
+ tool_name: 工具名稱
271
+
272
+ Returns:
273
+ 完整的工具定義(包含 inputSchema, outputSchema 等)
274
+
275
+ Raises:
276
+ ValueError: 工具不存在
277
+ """
278
+ # 檢查快取
279
+ if tool_name in self._schema_cache:
280
+ schema, timestamp = self._schema_cache[tool_name]
281
+ if time.time() - timestamp < self._schema_cache_ttl:
282
+ logger.debug(f"Schema 快取命中: {tool_name}")
283
+ return schema
284
+ else:
285
+ # 快取過期,刪除
286
+ del self._schema_cache[tool_name]
287
+ logger.debug(f"Schema 快取過期: {tool_name}")
288
+
289
+ # 快取未命中,載入完整 schema
290
+ if tool_name not in self.tools:
291
+ raise ValueError(f"工具不存在: {tool_name}")
292
+
293
+ tool = self.tools[tool_name]
294
+
295
+ try:
296
+ # 檢查工具是否有 get_full_definition 方法(新工具)
297
+ if hasattr(tool, 'get_full_definition') and callable(tool.get_full_definition):
298
+ schema = tool.get_full_definition()
299
+ else:
300
+ # 向後兼容:從現有屬性構建完整定義
301
+ schema = {
302
+ "name": tool_name,
303
+ "description": tool.description if hasattr(tool, 'description') else "",
304
+ "inputSchema": tool.inputSchema if hasattr(tool, 'inputSchema') else {},
305
+ "outputSchema": getattr(tool, 'outputSchema', {}),
306
+ "metadata": getattr(tool, 'metadata', {})
307
+ }
308
+
309
+ # 更新快取
310
+ self._schema_cache[tool_name] = (schema, time.time())
311
+ logger.debug(f"Schema 已快取: {tool_name}")
312
+
313
+ return schema
314
+
315
+ except Exception as e:
316
+ logger.error(f"載入工具 Schema 失敗: {tool_name}, 錯誤: {e}")
317
+ raise ValueError(f"無法載入工具 {tool_name} 的 Schema: {e}")
318
 
319
  async def _handle_initialize(self, params: Dict[str, Any]) -> Dict[str, Any]:
320
  """處理初始化請求"""
features/mcp/tools/base_tool.py CHANGED
@@ -52,25 +52,61 @@ class MCPTool(ABC):
52
  # 工具基本信息
53
  NAME: str = ""
54
  DESCRIPTION: str = ""
 
55
  CATEGORY: str = "general"
56
  TAGS: List[str] = []
 
57
  USAGE_TIPS: List[str] = []
 
58
 
59
  @classmethod
60
- def get_definition(cls) -> Dict[str, Any]:
61
- """獲取工具定義"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  return {
63
  "name": cls.NAME,
64
  "description": cls.DESCRIPTION,
65
  "metadata": {
66
  "category": cls.CATEGORY,
67
  "tags": cls.TAGS,
 
 
68
  "usage_tips": cls.USAGE_TIPS
69
  },
70
  "inputSchema": cls.get_input_schema(),
71
  "outputSchema": cls.get_output_schema()
72
  }
73
 
 
 
 
 
 
74
  @classmethod
75
  @abstractmethod
76
  def get_input_schema(cls) -> Dict[str, Any]:
 
52
  # 工具基本信息
53
  NAME: str = ""
54
  DESCRIPTION: str = ""
55
+ DESCRIPTION_SHORT: str = "" # 簡短描述(用於 Intent Detection,10-20 tokens)
56
  CATEGORY: str = "general"
57
  TAGS: List[str] = []
58
+ KEYWORDS: List[str] = [] # 用於快速意圖匹配的關鍵字
59
  USAGE_TIPS: List[str] = []
60
+ IS_COMPLEX: bool = False # 標記是否為複雜工具(需要兩階段參數填充)
61
 
62
  @classmethod
63
+ def get_summary(cls) -> Dict[str, Any]:
64
+ """獲取工具摘要(用於 Intent Detection,減少 token 消耗)"""
65
+ # 自動生成簡短描述(截取前 50 字元或使用自訂)
66
+ short_desc = cls.DESCRIPTION_SHORT or (
67
+ cls.DESCRIPTION[:47] + "..." if len(cls.DESCRIPTION) > 50 else cls.DESCRIPTION
68
+ )
69
+
70
+ summary = {
71
+ "name": cls.NAME,
72
+ "description": short_desc,
73
+ "category": cls.CATEGORY,
74
+ "keywords": cls.KEYWORDS,
75
+ "is_complex": cls.IS_COMPLEX
76
+ }
77
+
78
+ # 簡單工具:提供簡化參數列表(只有參數名,不含詳細 schema)
79
+ if not cls.IS_COMPLEX:
80
+ try:
81
+ schema = cls.get_input_schema()
82
+ summary["params"] = list(schema.get("properties", {}).keys())
83
+ except:
84
+ summary["params"] = []
85
+
86
+ return summary
87
+
88
+ @classmethod
89
+ def get_full_definition(cls) -> Dict[str, Any]:
90
+ """獲取完整工具定義(用於實際調用,包含完整 schema)"""
91
  return {
92
  "name": cls.NAME,
93
  "description": cls.DESCRIPTION,
94
  "metadata": {
95
  "category": cls.CATEGORY,
96
  "tags": cls.TAGS,
97
+ "keywords": cls.KEYWORDS,
98
+ "is_complex": cls.IS_COMPLEX,
99
  "usage_tips": cls.USAGE_TIPS
100
  },
101
  "inputSchema": cls.get_input_schema(),
102
  "outputSchema": cls.get_output_schema()
103
  }
104
 
105
+ @classmethod
106
+ def get_definition(cls) -> Dict[str, Any]:
107
+ """獲取工具定義(向後兼容,使用完整定義)"""
108
+ return cls.get_full_definition()
109
+
110
  @classmethod
111
  @abstractmethod
112
  def get_input_schema(cls) -> Dict[str, Any]:
features/mcp/tools/directions_tool.py CHANGED
@@ -23,8 +23,9 @@ ORS_API_KEY = os.getenv("OPENROUTESERVICE_API_KEY", "")
23
  class DirectionsTool(MCPTool):
24
  NAME = "directions"
25
  DESCRIPTION = "規劃兩點之間的路線(walk/drive/cycle),返回距離、時間與 polyline"
26
- CATEGORY = "地理"
27
  TAGS = ["route", "navigation", "directions"]
 
28
  USAGE_TIPS = ["提供起訖兩點經緯度"]
29
  _COORDINATE_FIELDS = {
30
  "origin_lat": "起點緯度",
 
23
  class DirectionsTool(MCPTool):
24
  NAME = "directions"
25
  DESCRIPTION = "規劃兩點之間的路線(walk/drive/cycle),返回距離、時間與 polyline"
26
+ CATEGORY = "地理定位"
27
  TAGS = ["route", "navigation", "directions"]
28
+ KEYWORDS = ["導航", "路線", "怎麼去", "怎麼走", "規劃", "開車", "步行", "騎車"]
29
  USAGE_TIPS = ["提供起訖兩點經緯度"]
30
  _COORDINATE_FIELDS = {
31
  "origin_lat": "起點緯度",
features/mcp/tools/exchange_tool.py CHANGED
@@ -28,10 +28,15 @@ class ExchangeTool(MCPTool):
28
  """匯率查詢 MCP 工具"""
29
 
30
  NAME = "exchange_query"
31
- DESCRIPTION = "查詢匯率資訊,支援即時匯率查詢和貨幣轉換計算"
32
- CATEGORY = "匯率"
33
- TAGS = ["exchange", "currency", "finance"]
34
- USAGE_TIPS = ["直接說「美元匯率」或「美金對台幣」"]
 
 
 
 
 
35
 
36
  @classmethod
37
  def get_input_schema(cls) -> Dict[str, Any]:
 
28
  """匯率查詢 MCP 工具"""
29
 
30
  NAME = "exchange_query"
31
+ DESCRIPTION = "查詢即時匯率(支援主要貨幣兌換)"
32
+ CATEGORY = "生活資訊"
33
+ TAGS = ["exchange", "匯率", "貨幣"]
34
+ KEYWORDS = ["匯率", "美元", "台幣", "exchange", "USD", "TWD", "貨幣", "換算"]
35
+ USAGE_TIPS = [
36
+ "提供源貨幣和目標貨幣代碼(如 USD, TWD)",
37
+ "支援主要國際貨幣",
38
+ "可查詢即時匯率與歷史趨勢"
39
+ ]
40
 
41
  @classmethod
42
  def get_input_schema(cls) -> Dict[str, Any]:
features/mcp/tools/geocode_tool.py CHANGED
@@ -18,8 +18,9 @@ logger = logging.getLogger("mcp.tools.geocode")
18
  class ReverseGeocodeTool(MCPTool):
19
  NAME = "reverse_geocode"
20
  DESCRIPTION = "以經緯度反查城市/行政區(優先使用快取)"
21
- CATEGORY = "地理"
22
  TAGS = ["geocode", "reverse", "city"]
 
23
  USAGE_TIPS = ["提供 lat/lon 即可"]
24
 
25
  @classmethod
 
18
  class ReverseGeocodeTool(MCPTool):
19
  NAME = "reverse_geocode"
20
  DESCRIPTION = "以經緯度反查城市/行政區(優先使用快取)"
21
+ CATEGORY = "地理定位"
22
  TAGS = ["geocode", "reverse", "city"]
23
+ KEYWORDS = ["座標", "經緯度", "反查", "地址", "我在哪"]
24
  USAGE_TIPS = ["提供 lat/lon 即可"]
25
 
26
  @classmethod
features/mcp/tools/geocoding_tool.py CHANGED
@@ -18,8 +18,9 @@ logger = logging.getLogger("mcp.tools.geocoding")
18
  class ForwardGeocodeTool(MCPTool):
19
  NAME = "forward_geocode"
20
  DESCRIPTION = "將地點名稱(如「銘傳大學」「桃園火車站」)轉換為經緯度座標"
21
- CATEGORY = "地理"
22
  TAGS = ["geocode", "forward", "地點", "座標"]
 
23
  USAGE_TIPS = [
24
  "提供地點名稱即可(如「台北101」「淡水捷運站」)",
25
  "支援地標、車站、學校、商圈等",
 
18
  class ForwardGeocodeTool(MCPTool):
19
  NAME = "forward_geocode"
20
  DESCRIPTION = "將地點名稱(如「銘傳大學」「桃園火車站」)轉換為經緯度座標"
21
+ CATEGORY = "地理定位"
22
  TAGS = ["geocode", "forward", "地點", "座標"]
23
+ KEYWORDS = ["地點", "位置", "座標", "在哪裡", "地址查詢"]
24
  USAGE_TIPS = [
25
  "提供地點名稱即可(如「台北101」「淡水捷運站」)",
26
  "支援地標、車站、學校、商圈等",
features/mcp/tools/healthkit_tool.py CHANGED
@@ -21,8 +21,14 @@ class HealthKitTool(MCPTool):
21
 
22
  NAME = "healthkit_query"
23
  DESCRIPTION = "查詢用戶的健康數據,包括心率、步數、血氧、呼吸頻率等(數據由iOS設備自動同步到Firestore)"
24
- CATEGORY = "health"
25
  TAGS = ["health", "fitness", "database", "firestore"]
 
 
 
 
 
 
26
 
27
  def __init__(self):
28
  super().__init__()
 
21
 
22
  NAME = "healthkit_query"
23
  DESCRIPTION = "查詢用戶的健康數據,包括心率、步數、血氧、呼吸頻率等(數據由iOS設備自動同步到Firestore)"
24
+ CATEGORY = "健康數據"
25
  TAGS = ["health", "fitness", "database", "firestore"]
26
+ KEYWORDS = ["健康", "心率", "步數", "血氧", "睡眠", "health", "運動", "卡路里", "呼吸"]
27
+ USAGE_TIPS = [
28
+ "可查詢心率、步數、血氧、呼吸頻率、睡眠等",
29
+ "支援指定查詢天數(1-365天)",
30
+ "數據由 iOS 設備自動同步"
31
+ ]
32
 
33
  def __init__(self):
34
  super().__init__()
features/mcp/tools/news_tool.py CHANGED
@@ -31,10 +31,15 @@ class NewsTool(MCPTool):
31
  """新聞查詢 MCP 工具 - 使用 NewsData.io(更好的台灣與繁中新聞支援)"""
32
 
33
  NAME = "news_query"
34
- DESCRIPTION = "查詢最新新聞資訊,支援關鍵詞搜尋、台灣新聞和分類篩選,提供可靠的繁體中文新聞來源"
35
- CATEGORY = "新聞"
36
- TAGS = ["news", "information", "current events", "taiwan", "chinese"]
37
- USAGE_TIPS = ["直接說「科技新聞」或「今日新聞」", "支援台灣本地新聞", "繁體中文新聞源豐富"]
 
 
 
 
 
38
 
39
  @classmethod
40
  def get_input_schema(cls) -> Dict[str, Any]:
 
31
  """新聞查詢 MCP 工具 - 使用 NewsData.io(更好的台灣與繁中新聞支援)"""
32
 
33
  NAME = "news_query"
34
+ DESCRIPTION = "查詢最新新聞(可指定類別、語言、數量)"
35
+ CATEGORY = "生活資訊"
36
+ TAGS = ["news", "新聞", "資訊"]
37
+ KEYWORDS = ["新聞", "消息", "報導", "news", "頭條", "時事"]
38
+ USAGE_TIPS = [
39
+ "可指定新聞類別(科技、商業、娛樂等)",
40
+ "支援多國新聞(台灣、美國、日本等)",
41
+ "可限制返回數量"
42
+ ]
43
 
44
  @classmethod
45
  def get_input_schema(cls) -> Dict[str, Any]:
features/mcp/tools/tdx_base.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TDX 基礎工具類
3
+ 提供 OAuth 認證、API 呼叫、快取等共用功能
4
+ """
5
+
6
+ import os
7
+ import json
8
+ import logging
9
+ import aiohttp
10
+ import asyncio
11
+ from typing import Dict, Any, Optional
12
+ from datetime import datetime, timedelta
13
+
14
+ from .base_tool import ExecutionError
15
+ from core.database.cache import db_cache
16
+
17
+ logger = logging.getLogger("mcp.tools.tdx")
18
+
19
+ TDX_BASE_URL = "https://tdx.transportdata.tw/api/basic/v2"
20
+ TDX_CLIENT_ID = os.getenv("TDX_CLIENT_ID", "")
21
+ TDX_CLIENT_SECRET = os.getenv("TDX_CLIENT_SECRET", "")
22
+
23
+
24
+ class TDXBaseAPI:
25
+ """TDX API 基礎類別"""
26
+
27
+ _token_cache: Dict[str, Any] = {}
28
+
29
+ @classmethod
30
+ async def get_access_token(cls) -> str:
31
+ """獲取 TDX Access Token(快取 1 小時)"""
32
+ # 檢查快取
33
+ if cls._token_cache.get("token") and cls._token_cache.get("expires_at"):
34
+ if datetime.now() < cls._token_cache["expires_at"]:
35
+ return cls._token_cache["token"]
36
+
37
+ if not TDX_CLIENT_ID or not TDX_CLIENT_SECRET:
38
+ raise ExecutionError("未設定 TDX_CLIENT_ID 或 TDX_CLIENT_SECRET 環境變數")
39
+
40
+ # 請求新 token
41
+ auth_url = "https://tdx.transportdata.tw/auth/realms/TDXConnect/protocol/openid-connect/token"
42
+ data = {
43
+ "grant_type": "client_credentials",
44
+ "client_id": TDX_CLIENT_ID,
45
+ "client_secret": TDX_CLIENT_SECRET
46
+ }
47
+
48
+ try:
49
+ async with aiohttp.ClientSession() as session:
50
+ async with session.post(auth_url, data=data, timeout=aiohttp.ClientTimeout(total=15)) as resp:
51
+ if resp.status != 200:
52
+ error_text = await resp.text()
53
+ raise ExecutionError(f"TDX 認證失敗: HTTP {resp.status} - {error_text}")
54
+
55
+ token_data = await resp.json()
56
+ access_token = token_data.get("access_token")
57
+ expires_in = token_data.get("expires_in", 3600)
58
+
59
+ if not access_token:
60
+ raise ExecutionError("TDX 認證回應缺少 access_token")
61
+
62
+ # 快取(提前 60 秒過期)
63
+ cls._token_cache = {
64
+ "token": access_token,
65
+ "expires_at": datetime.now() + timedelta(seconds=expires_in - 60)
66
+ }
67
+
68
+ logger.info("✅ TDX Access Token 取得成功")
69
+ return access_token
70
+
71
+ except aiohttp.ClientError as e:
72
+ raise ExecutionError(f"TDX 認證網路錯誤: {e}")
73
+
74
+ @classmethod
75
+ async def call_api(cls, endpoint: str, params: Optional[Dict[str, Any]] = None,
76
+ cache_ttl: int = 60) -> Any:
77
+ """呼叫 TDX API 並處理快取"""
78
+ access_token = await cls.get_access_token()
79
+
80
+ url = f"{TDX_BASE_URL}/{endpoint}"
81
+ headers = {
82
+ "Authorization": f"Bearer {access_token}",
83
+ "Accept": "application/json"
84
+ }
85
+
86
+ # 生成快取鍵
87
+ cache_key = f"tdx:{endpoint}:{json.dumps(params or {}, sort_keys=True)}"
88
+
89
+ # 檢查快取
90
+ if cache_ttl > 0:
91
+ cached = await db_cache.get_tdx_cached(cache_key)
92
+ if cached:
93
+ logger.debug(f"📦 TDX 快取命中: {endpoint}")
94
+ return cached
95
+
96
+ # 呼叫 API
97
+ try:
98
+ async with aiohttp.ClientSession(headers=headers) as session:
99
+ async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=20)) as resp:
100
+ if resp.status == 304:
101
+ logger.info("TDX 資料未變更 (304)")
102
+ return cached if cached else []
103
+
104
+ if resp.status != 200:
105
+ error_text = await resp.text()
106
+ raise ExecutionError(f"TDX API 錯誤 {endpoint}: HTTP {resp.status} - {error_text[:200]}")
107
+
108
+ data = await resp.json()
109
+
110
+ # 快取結果
111
+ if cache_ttl > 0:
112
+ await db_cache.set_tdx_cache(cache_key, data, ttl=cache_ttl)
113
+
114
+ logger.info(f"✅ TDX API 成功: {endpoint}")
115
+ return data
116
+
117
+ except asyncio.TimeoutError:
118
+ raise ExecutionError(f"TDX API 逾時: {endpoint}")
119
+ except aiohttp.ClientError as e:
120
+ raise ExecutionError(f"TDX API 網路錯誤: {e}")
121
+
122
+ @staticmethod
123
+ def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
124
+ """計算兩點間距離(公尺)"""
125
+ from math import radians, cos, sin, asin, sqrt
126
+
127
+ R = 6371000 # 地球半徑(公尺)
128
+ lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
129
+
130
+ dlat = lat2 - lat1
131
+ dlon = lon2 - lon1
132
+ a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
133
+ c = 2 * asin(sqrt(a))
134
+
135
+ return R * c
136
+
137
+ @staticmethod
138
+ def format_datetime(dt_str: str) -> str:
139
+ """格式化 TDX 時間字串"""
140
+ if not dt_str:
141
+ return "未知"
142
+ try:
143
+ # TDX 格式: 2024-11-01T14:30:00+08:00
144
+ dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
145
+ return dt.strftime("%H:%M")
146
+ except:
147
+ return dt_str
features/mcp/tools/tdx_bus_arrival.py ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TDX 公車即時到站工具
3
+ 查詢附近公車站、特定路線到站時間
4
+ """
5
+
6
+ import logging
7
+ from typing import Dict, Any, List, Optional
8
+
9
+ from .base_tool import MCPTool, StandardToolSchemas, ExecutionError
10
+ from .tdx_base import TDXBaseAPI
11
+ from core.database import get_user_env_current
12
+
13
+ logger = logging.getLogger("mcp.tools.tdx.bus")
14
+
15
+
16
+ class TDXBusArrivalTool(MCPTool):
17
+ """TDX 公車即時到站查詢"""
18
+
19
+ NAME = "tdx_bus_arrival"
20
+ DESCRIPTION = "查詢公車即時到站時間(自動感知用戶位置,找最近站點)"
21
+ CATEGORY = "道路運輸"
22
+ TAGS = ["tdx", "公車", "即時到站", "公共運輸"]
23
+ KEYWORDS = ["公車", "巴士", "bus", "到站", "即時", "幾分鐘"]
24
+ USAGE_TIPS = [
25
+ "查詢特定路線: 「307 公車還要多久」",
26
+ "查詢附近公車站: 「附近有什麼公車」",
27
+ "指定城市: 「台北 307」「高雄紅30」"
28
+ ]
29
+
30
+ @classmethod
31
+ def get_input_schema(cls) -> Dict[str, Any]:
32
+ return StandardToolSchemas.create_input_schema({
33
+ "route_name": {
34
+ "type": "string",
35
+ "description": "路線名稱(如「307」「紅30」)。不提供則查詢附近所有公車站"
36
+ },
37
+ "city": {
38
+ "type": "string",
39
+ "description": "城市(預設從環境感知自動判斷)",
40
+ "enum": ["Taipei", "NewTaipei", "Taoyuan", "Taichung", "Tainan", "Kaohsiung",
41
+ "Keelung", "Hsinchu", "HsinchuCounty", "MiaoliCounty", "ChanghuaCounty",
42
+ "NantouCounty", "YunlinCounty", "ChiayiCounty", "Chiayi", "PingtungCounty",
43
+ "YilanCounty", "HualienCounty", "TaitungCounty", "KinmenCounty", "PenghuCounty",
44
+ "LienchiangCounty"]
45
+ },
46
+ "limit": {
47
+ "type": "integer",
48
+ "description": "返回結果數量上限",
49
+ "default": 5
50
+ }
51
+ }, required=[])
52
+
53
+ @classmethod
54
+ def get_output_schema(cls) -> Dict[str, Any]:
55
+ schema = StandardToolSchemas.create_output_schema()
56
+ schema["properties"].update({
57
+ "arrivals": {
58
+ "type": "array",
59
+ "items": {
60
+ "type": "object",
61
+ "properties": {
62
+ "route_name": {"type": "string"},
63
+ "stop_name": {"type": "string"},
64
+ "direction": {"type": "string"},
65
+ "estimate_time": {"type": "integer"},
66
+ "status": {"type": "string"}
67
+ }
68
+ }
69
+ }
70
+ })
71
+ return schema
72
+
73
+ @classmethod
74
+ async def execute(cls, arguments: Dict[str, Any], user_id: str = None) -> Dict[str, Any]:
75
+ route_name = arguments.get("route_name", "").strip()
76
+ city = arguments.get("city")
77
+ limit = min(int(arguments.get("limit", 5)), 20)
78
+
79
+ # 1. 取得用戶位置
80
+ env_ctx = await get_user_env_current(user_id) if user_id else None
81
+ if not env_ctx or not env_ctx.get("success"):
82
+ if not route_name:
83
+ raise ExecutionError("無法取得您的位置,請提供路線名稱或開啟定位權限")
84
+ user_lat, user_lon, user_city = None, None, None
85
+ else:
86
+ ctx = env_ctx.get("context", {})
87
+ user_lat = ctx.get("lat")
88
+ user_lon = ctx.get("lon")
89
+ user_city = ctx.get("city", "")
90
+
91
+ # 2. 自動判斷城市
92
+ if not city:
93
+ city = cls._map_city_name(user_city) if user_city else "Taipei"
94
+
95
+ # 3. 查詢邏輯分支
96
+ if route_name:
97
+ # 指定路線:找最近站點並查詢到站時間
98
+ result = await cls._query_route_arrival(route_name, city, user_lat, user_lon, limit)
99
+ else:
100
+ # 未指定路線:查詢附近所有站點
101
+ if not user_lat or not user_lon:
102
+ raise ExecutionError("查詢附近公車需要定位權限")
103
+ result = await cls._query_nearby_stops(user_lat, user_lon, city, limit)
104
+
105
+ return result
106
+
107
+ @classmethod
108
+ async def _query_route_arrival(cls, route_name: str, city: str,
109
+ user_lat: Optional[float], user_lon: Optional[float],
110
+ limit: int) -> Dict[str, Any]:
111
+ """查詢特定路線的即時到站"""
112
+ # 1. 查詢路線基本資訊
113
+ route_endpoint = f"Bus/Route/City/{city}"
114
+ route_params = {
115
+ "$filter": f"contains(RouteName/Zh_tw, '{route_name}')",
116
+ "$format": "JSON",
117
+ "$top": 1
118
+ }
119
+
120
+ routes = await TDXBaseAPI.call_api(route_endpoint, route_params, cache_ttl=3600)
121
+
122
+ if not routes or len(routes) == 0:
123
+ raise ExecutionError(f"找不到路線「{route_name}」���請確認路線名稱是否正確")
124
+
125
+ route = routes[0]
126
+ route_uid = route.get("RouteUID")
127
+ full_route_name = route.get("RouteName", {}).get("Zh_tw", route_name)
128
+
129
+ # 2. 查詢該路線所有站點
130
+ stop_endpoint = f"Bus/StopOfRoute/City/{city}"
131
+ stop_params = {
132
+ "$filter": f"RouteUID eq '{route_uid}'",
133
+ "$format": "JSON"
134
+ }
135
+
136
+ stops = await TDXBaseAPI.call_api(stop_endpoint, stop_params, cache_ttl=1800)
137
+
138
+ if not stops:
139
+ raise ExecutionError(f"路線「{full_route_name}」暫無站點資訊")
140
+
141
+ # 3. 如果有用戶位置,找最近的站點
142
+ if user_lat and user_lon:
143
+ for stop_seq in stops:
144
+ for stop in stop_seq.get("Stops", []):
145
+ pos = stop.get("StopPosition", {})
146
+ if pos.get("PositionLat") and pos.get("PositionLon"):
147
+ stop["distance_m"] = TDXBaseAPI.haversine_distance(
148
+ user_lat, user_lon,
149
+ pos["PositionLat"], pos["PositionLon"]
150
+ )
151
+
152
+ # 取前 3 個最近的站點
153
+ all_stops = []
154
+ for stop_seq in stops:
155
+ all_stops.extend(stop_seq.get("Stops", []))
156
+
157
+ all_stops = [s for s in all_stops if "distance_m" in s]
158
+ all_stops.sort(key=lambda x: x["distance_m"])
159
+ target_stops = all_stops[:3]
160
+ else:
161
+ # 沒有位置,取前幾個站點
162
+ target_stops = []
163
+ for stop_seq in stops[:1]:
164
+ target_stops.extend(stop_seq.get("Stops", [])[:limit])
165
+
166
+ # 4. 查詢這些站點的即時到站
167
+ arrivals = []
168
+ for stop in target_stops:
169
+ stop_uid = stop.get("StopUID")
170
+ stop_name = stop.get("StopName", {}).get("Zh_tw", "未知")
171
+
172
+ arrival_endpoint = f"Bus/EstimatedTimeOfArrival/City/{city}"
173
+ arrival_params = {
174
+ "$filter": f"RouteUID eq '{route_uid}' and StopUID eq '{stop_uid}'",
175
+ "$format": "JSON"
176
+ }
177
+
178
+ arrival_data = await TDXBaseAPI.call_api(arrival_endpoint, arrival_params, cache_ttl=30)
179
+
180
+ for arr in arrival_data[:2]: # 每個站點最多 2 筆(雙向)
181
+ estimate_time = arr.get("EstimateTime")
182
+ stop_status = arr.get("StopStatus", 0)
183
+
184
+ if stop_status == 0: # 正常
185
+ status_text = f"{estimate_time // 60} 分鐘" if estimate_time else "即將進站"
186
+ elif stop_status == 1: # 尚未發車
187
+ status_text = "尚未發車"
188
+ elif stop_status == 2: # 交管不停靠
189
+ status_text = "交管不停靠"
190
+ elif stop_status == 3: # 末班車已過
191
+ status_text = "末班車已過"
192
+ elif stop_status == 4: # 今日未營運
193
+ status_text = "今日未營運"
194
+ else:
195
+ status_text = "未知"
196
+
197
+ arrivals.append({
198
+ "route_name": full_route_name,
199
+ "stop_name": stop_name,
200
+ "direction": arr.get("Direction", 0),
201
+ "estimate_time": estimate_time,
202
+ "status": status_text,
203
+ "distance_m": stop.get("distance_m", 0)
204
+ })
205
+
206
+ # 5. 格式化回覆
207
+ content = cls._format_arrival_result(arrivals, full_route_name, user_lat is not None)
208
+
209
+ return cls.create_success_response(
210
+ content=content,
211
+ data={"arrivals": arrivals, "route_name": full_route_name}
212
+ )
213
+
214
+ @classmethod
215
+ async def _query_nearby_stops(cls, lat: float, lon: float, city: str, limit: int) -> Dict[str, Any]:
216
+ """查詢附近公車站"""
217
+ # TDX 附近站點查詢
218
+ endpoint = f"Bus/Stop/City/{city}"
219
+ params = {
220
+ "$spatialFilter": f"nearby({lat}, {lon}, 300)", # 300m 範圍
221
+ "$format": "JSON",
222
+ "$top": limit * 3 # 多取一些,後續過濾
223
+ }
224
+
225
+ stops = await TDXBaseAPI.call_api(endpoint, params, cache_ttl=1800)
226
+
227
+ if not stops:
228
+ return cls.create_success_response(
229
+ content="附近 300 公尺內沒有公車站,請擴大範圍或移動位置",
230
+ data={"stops": []}
231
+ )
232
+
233
+ # 計算距離並排序
234
+ for stop in stops:
235
+ pos = stop.get("StopPosition", {})
236
+ if pos.get("PositionLat") and pos.get("PositionLon"):
237
+ stop["distance_m"] = TDXBaseAPI.haversine_distance(
238
+ lat, lon,
239
+ pos["PositionLat"], pos["PositionLon"]
240
+ )
241
+
242
+ stops = [s for s in stops if "distance_m" in s]
243
+ stops.sort(key=lambda x: x["distance_m"])
244
+ stops = stops[:limit]
245
+
246
+ # 格式化結果
247
+ results = []
248
+ for stop in stops:
249
+ stop_name = stop.get("StopName", {}).get("Zh_tw", "未知")
250
+ distance = stop["distance_m"]
251
+ walking_time = int(distance / 80) # 80m/min
252
+
253
+ results.append({
254
+ "stop_name": stop_name,
255
+ "distance_m": int(distance),
256
+ "walking_time_min": walking_time,
257
+ "stop_uid": stop.get("StopUID")
258
+ })
259
+
260
+ content = cls._format_nearby_result(results)
261
+
262
+ return cls.create_success_response(
263
+ content=content,
264
+ data={"stops": results}
265
+ )
266
+
267
+ @staticmethod
268
+ def _map_city_name(chinese_city: str) -> str:
269
+ """中文城市名稱轉 TDX 代碼"""
270
+ city_map = {
271
+ "台北": "Taipei", "臺北": "Taipei",
272
+ "新北": "NewTaipei", "新北市": "NewTaipei",
273
+ "桃園": "Taoyuan",
274
+ "台中": "Taichung", "臺中": "Taichung",
275
+ "台南": "Tainan", "臺南": "Tainan",
276
+ "高雄": "Kaohsiung",
277
+ "基隆": "Keelung",
278
+ "新竹": "Hsinchu",
279
+ "嘉義": "Chiayi"
280
+ }
281
+
282
+ for key, value in city_map.items():
283
+ if key in chinese_city:
284
+ return value
285
+
286
+ return "Taipei" # 預設台北
287
+
288
+ @staticmethod
289
+ def _format_arrival_result(arrivals: List[Dict], route_name: str, has_location: bool) -> str:
290
+ """格式化到站結果"""
291
+ if not arrivals:
292
+ return f"路線 {route_name} 目前無即時到站資訊"
293
+
294
+ lines = [f"🚌 {route_name} 即時到站資訊:\n"]
295
+
296
+ # 按站點分組
297
+ stops_dict = {}
298
+ for arr in arrivals:
299
+ stop = arr["stop_name"]
300
+ if stop not in stops_dict:
301
+ stops_dict[stop] = []
302
+ stops_dict[stop].append(arr)
303
+
304
+ for i, (stop_name, stop_arrivals) in enumerate(stops_dict.items(), 1):
305
+ dist_info = ""
306
+ if has_location and stop_arrivals[0].get("distance_m"):
307
+ dist = stop_arrivals[0]["distance_m"]
308
+ walk_time = int(dist / 80)
309
+ dist_info = f" - 步行 {walk_time} 分鐘 ({int(dist)}m)"
310
+
311
+ lines.append(f"{i}. 🚏 {stop_name}{dist_info}")
312
+
313
+ for arr in stop_arrivals:
314
+ direction = "往 ↑" if arr["direction"] == 0 else "返 ↓"
315
+ lines.append(f" {direction} {arr['status']}")
316
+
317
+ lines.append("")
318
+
319
+ return "\n".join(lines)
320
+
321
+ @staticmethod
322
+ def _format_nearby_result(stops: List[Dict]) -> str:
323
+ """格式化附近站點結果"""
324
+ if not stops:
325
+ return "附近沒有找到公車站"
326
+
327
+ lines = ["📍 附近的公車站:\n"]
328
+
329
+ for i, stop in enumerate(stops, 1):
330
+ lines.append(
331
+ f"{i}. 🚏 {stop['stop_name']}\n"
332
+ f" 步行 {stop['walking_time_min']} 分鐘 ({stop['distance_m']}m)\n"
333
+ )
334
+
335
+ lines.append("💡 提供路線名稱查詢即時到站時間")
336
+
337
+ return "\n".join(lines)
features/mcp/tools/tdx_metro.py ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TDX 捷運即時資訊工具
3
+ 支援台北捷運、高雄捷運、桃園捷運、台中捷運
4
+ """
5
+
6
+ import logging
7
+ from typing import Dict, Any, List, Optional
8
+
9
+ from .base_tool import MCPTool, StandardToolSchemas, ExecutionError
10
+ from .tdx_base import TDXBaseAPI
11
+ from core.database import get_user_env_current
12
+
13
+ logger = logging.getLogger("mcp.tools.tdx.metro")
14
+
15
+
16
+ class TDXMetroTool(MCPTool):
17
+ """TDX 捷運即時到站查詢"""
18
+
19
+ NAME = "tdx_metro"
20
+ DESCRIPTION = "查詢捷運即時到站、最近車站(台北/高雄/桃園/台中捷運)"
21
+ CATEGORY = "軌道運輸"
22
+ TAGS = ["tdx", "捷運", "MRT", "即時到站"]
23
+ KEYWORDS = ["捷運", "MRT", "地鐵", "metro", "到站"]
24
+ USAGE_TIPS = [
25
+ "查詢最近捷運站: 「最近的捷運站在哪」",
26
+ "查詢特定站點: 「台北車站捷運幾分鐘到」",
27
+ "指定路線: 「板南線 市政府站」"
28
+ ]
29
+
30
+ # TDX 捷運系統對應
31
+ METRO_SYSTEMS = {
32
+ "台北": "TRTC",
33
+ "臺北": "TRTC",
34
+ "高雄": "KRTC",
35
+ "桃園": "TYMC",
36
+ "台中": "TMRT",
37
+ "臺中": "TMRT"
38
+ }
39
+
40
+ @classmethod
41
+ def get_input_schema(cls) -> Dict[str, Any]:
42
+ return StandardToolSchemas.create_input_schema({
43
+ "station_name": {
44
+ "type": "string",
45
+ "description": "車站名稱(如「台北車站」「西門站」)。不提供則查詢最近車站"
46
+ },
47
+ "metro_system": {
48
+ "type": "string",
49
+ "description": "捷運系統(TRTC=台北, KRTC=高雄, TYMC=桃園, TMRT=台中)",
50
+ "enum": ["TRTC", "KRTC", "TYMC", "TMRT"]
51
+ },
52
+ "line": {
53
+ "type": "string",
54
+ "description": "路線名稱(如「板南線」「淡水信義線」)"
55
+ }
56
+ }, required=[])
57
+
58
+ @classmethod
59
+ def get_output_schema(cls) -> Dict[str, Any]:
60
+ schema = StandardToolSchemas.create_output_schema()
61
+ schema["properties"].update({
62
+ "arrivals": {
63
+ "type": "array",
64
+ "items": {
65
+ "type": "object",
66
+ "properties": {
67
+ "station_name": {"type": "string"},
68
+ "line_name": {"type": "string"},
69
+ "destination": {"type": "string"},
70
+ "arrival_time_sec": {"type": "integer"},
71
+ "train_status": {"type": "string"}
72
+ }
73
+ }
74
+ }
75
+ })
76
+ return schema
77
+
78
+ @classmethod
79
+ async def execute(cls, arguments: Dict[str, Any], user_id: str = None) -> Dict[str, Any]:
80
+ station_name = arguments.get("station_name", "").strip()
81
+ metro_system = arguments.get("metro_system")
82
+ line_filter = arguments.get("line")
83
+
84
+ # 1. 取得用戶位置
85
+ env_ctx = await get_user_env_current(user_id) if user_id else None
86
+ if not env_ctx or not env_ctx.get("success"):
87
+ if not station_name:
88
+ raise ExecutionError("無法取得您的位置,請提供車站名稱")
89
+ user_lat, user_lon, user_city = None, None, None
90
+ else:
91
+ ctx = env_ctx.get("context", {})
92
+ user_lat = ctx.get("lat")
93
+ user_lon = ctx.get("lon")
94
+ user_city = ctx.get("city", "")
95
+
96
+ # 2. 自動判斷捷運系統
97
+ if not metro_system:
98
+ metro_system = cls._detect_metro_system(user_city)
99
+
100
+ # 3. 查詢邏輯
101
+ if station_name:
102
+ result = await cls._query_station_arrival(station_name, metro_system, line_filter)
103
+ else:
104
+ if not user_lat or not user_lon:
105
+ raise ExecutionError("查詢最近捷運站需要定位權限")
106
+ result = await cls._query_nearest_station(user_lat, user_lon, metro_system)
107
+
108
+ return result
109
+
110
+ @classmethod
111
+ async def _query_station_arrival(cls, station_name: str, metro_system: str,
112
+ line_filter: Optional[str]) -> Dict[str, Any]:
113
+ """查詢特定車站的即時到站"""
114
+ # 1. 查詢車站資訊
115
+ station_endpoint = f"Metro/Station/{metro_system}"
116
+ station_params = {
117
+ "$filter": f"contains(StationName/Zh_tw, '{station_name}')",
118
+ "$format": "JSON",
119
+ "$top": 5
120
+ }
121
+
122
+ stations = await TDXBaseAPI.call_api(station_endpoint, station_params, cache_ttl=3600)
123
+
124
+ if not stations:
125
+ raise ExecutionError(f"找不到車站「{station_name}」")
126
+
127
+ # 2. 如果有多個結果,優先選擇完全匹配
128
+ target_station = None
129
+ for station in stations:
130
+ name = station.get("StationName", {}).get("Zh_tw", "")
131
+ if name == station_name:
132
+ target_station = station
133
+ break
134
+
135
+ if not target_station:
136
+ target_station = stations[0]
137
+
138
+ station_uid = target_station.get("StationUID")
139
+ full_station_name = target_station.get("StationName", {}).get("Zh_tw", station_name)
140
+
141
+ # 3. 查詢即時到站
142
+ arrival_endpoint = f"Metro/LiveBoard/{metro_system}"
143
+ arrival_params = {
144
+ "$filter": f"StationUID eq '{station_uid}'",
145
+ "$format": "JSON"
146
+ }
147
+
148
+ arrivals = await TDXBaseAPI.call_api(arrival_endpoint, arrival_params, cache_ttl=15)
149
+
150
+ if not arrivals:
151
+ return cls.create_success_response(
152
+ content=f"🚇 {full_station_name} 目前無即時到站資訊",
153
+ data={"arrivals": []}
154
+ )
155
+
156
+ # 4. 路線過濾
157
+ if line_filter:
158
+ arrivals = [a for a in arrivals if line_filter in a.get("LineName", {}).get("Zh_tw", "")]
159
+
160
+ # 5. 格式化結果
161
+ results = []
162
+ for arr in arrivals[:10]: # 最多 10 筆
163
+ line_name = arr.get("LineName", {}).get("Zh_tw", "未知路線")
164
+ dest = arr.get("DestinationStationName", {}).get("Zh_tw", "未知")
165
+ arrival_time = arr.get("ArrivalTime", 0)
166
+ status_code = arr.get("TrainStatus", 0)
167
+
168
+ status_map = {
169
+ 0: "正常",
170
+ 1: "尚未發車",
171
+ 2: "交管不停靠",
172
+ 3: "末班車已過",
173
+ 4: "今日未營運"
174
+ }
175
+ status = status_map.get(status_code, "未知")
176
+
177
+ results.append({
178
+ "station_name": full_station_name,
179
+ "line_name": line_name,
180
+ "destination": dest,
181
+ "arrival_time_sec": arrival_time,
182
+ "train_status": status
183
+ })
184
+
185
+ content = cls._format_arrival_result(results, full_station_name)
186
+
187
+ return cls.create_success_response(
188
+ content=content,
189
+ data={"arrivals": results}
190
+ )
191
+
192
+ @classmethod
193
+ async def _query_nearest_station(cls, lat: float, lon: float, metro_system: str) -> Dict[str, Any]:
194
+ """查詢最近的捷運站"""
195
+ # 1. 取得所有車站
196
+ station_endpoint = f"Metro/Station/{metro_system}"
197
+ station_params = {
198
+ "$format": "JSON"
199
+ }
200
+
201
+ stations = await TDXBaseAPI.call_api(station_endpoint, station_params, cache_ttl=3600)
202
+
203
+ if not stations:
204
+ raise ExecutionError("無法取得捷運站資訊")
205
+
206
+ # 2. 計算距離
207
+ for station in stations:
208
+ pos = station.get("StationPosition", {})
209
+ if pos.get("PositionLat") and pos.get("PositionLon"):
210
+ station["distance_m"] = TDXBaseAPI.haversine_distance(
211
+ lat, lon,
212
+ pos["PositionLat"], pos["PositionLon"]
213
+ )
214
+
215
+ stations_with_distance = [s for s in stations if "distance_m" in s]
216
+
217
+ if not stations_with_distance:
218
+ raise ExecutionError("附近沒有捷運站資訊")
219
+
220
+ stations_with_distance.sort(key=lambda x: x["distance_m"])
221
+ nearest = stations_with_distance[:3]
222
+
223
+ # 3. 格式化結果
224
+ results = []
225
+ for station in nearest:
226
+ station_name = station.get("StationName", {}).get("Zh_tw", "未知")
227
+ distance = station["distance_m"]
228
+ walking_time = int(distance / 80)
229
+
230
+ results.append({
231
+ "station_name": station_name,
232
+ "distance_m": int(distance),
233
+ "walking_time_min": walking_time,
234
+ "station_uid": station.get("StationUID"),
235
+ "address": station.get("StationAddress", "")
236
+ })
237
+
238
+ content = cls._format_nearest_result(results)
239
+
240
+ return cls.create_success_response(
241
+ content=content,
242
+ data={"stations": results}
243
+ )
244
+
245
+ @staticmethod
246
+ def _detect_metro_system(city: str) -> str:
247
+ """根據城市自動偵測捷運系統"""
248
+ for key, code in TDXMetroTool.METRO_SYSTEMS.items():
249
+ if key in city:
250
+ return code
251
+ return "TRTC" # 預設台北
252
+
253
+ @staticmethod
254
+ def _format_arrival_result(arrivals: List[Dict], station_name: str) -> str:
255
+ """格式化到站資訊"""
256
+ if not arrivals:
257
+ return f"🚇 {station_name} 目前無列車資訊"
258
+
259
+ lines = [f"🚇 {station_name} 即時到站:\n"]
260
+
261
+ # 按路線分組
262
+ lines_dict = {}
263
+ for arr in arrivals:
264
+ line = arr["line_name"]
265
+ if line not in lines_dict:
266
+ lines_dict[line] = []
267
+ lines_dict[line].append(arr)
268
+
269
+ for line_name, line_arrivals in lines_dict.items():
270
+ lines.append(f"━�� {line_name} ━━")
271
+
272
+ for arr in line_arrivals[:3]: # 每條路線最多 3 筆
273
+ dest = arr["destination"]
274
+ time_sec = arr["arrival_time_sec"]
275
+ status = arr["train_status"]
276
+
277
+ if time_sec > 0:
278
+ time_min = time_sec // 60
279
+ time_str = f"{time_min} 分 {time_sec % 60} 秒" if time_min > 0 else f"{time_sec} 秒"
280
+ lines.append(f" → {dest} {time_str}")
281
+ else:
282
+ lines.append(f" → {dest} {status}")
283
+
284
+ lines.append("")
285
+
286
+ return "\n".join(lines)
287
+
288
+ @staticmethod
289
+ def _format_nearest_result(stations: List[Dict]) -> str:
290
+ """格式化最近車站結果"""
291
+ lines = ["📍 最近的捷運站:\n"]
292
+
293
+ for i, station in enumerate(stations, 1):
294
+ lines.append(
295
+ f"{i}. 🚇 {station['station_name']}\n"
296
+ f" 步行 {station['walking_time_min']} 分鐘 ({station['distance_m']}m)\n"
297
+ )
298
+
299
+ return "\n".join(lines)
features/mcp/tools/tdx_parking.py ADDED
@@ -0,0 +1,410 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TDX 停車場與充電站查詢工具
3
+ 查詢附近停車場、即時剩餘車位、充電站資訊
4
+ """
5
+
6
+ import logging
7
+ from typing import Dict, Any, List, Optional
8
+
9
+ from .base_tool import MCPTool, StandardToolSchemas, ExecutionError
10
+ from .tdx_base import TDXBaseAPI
11
+ from core.database import get_user_env_current
12
+
13
+ logger = logging.getLogger("mcp.tools.tdx.parking")
14
+
15
+
16
+ class TDXParkingTool(MCPTool):
17
+ """TDX 停車場與充電站查詢"""
18
+
19
+ NAME = "tdx_parking"
20
+ DESCRIPTION = "查詢附近停車場、即時剩餘車位、收費標準、充電站資訊"
21
+ CATEGORY = "停車與充電"
22
+ TAGS = ["tdx", "停車", "充電站", "電動車"]
23
+ KEYWORDS = ["停車", "停車場", "充電", "充電站", "車位", "電動車"]
24
+ USAGE_TIPS = [
25
+ "查詢附近停車場: 「附近哪裡有停車位」",
26
+ "查詢充電站: 「附近的充電站在哪」",
27
+ "指定停車場: 「台北車站停車場還有位子嗎」"
28
+ ]
29
+
30
+ @classmethod
31
+ def get_input_schema(cls) -> Dict[str, Any]:
32
+ return StandardToolSchemas.create_input_schema({
33
+ "parking_name": {
34
+ "type": "string",
35
+ "description": "停車場名稱(如「台北車站」「市政府」)。不提供則查詢附近停車場"
36
+ },
37
+ "city": {
38
+ "type": "string",
39
+ "description": "城市代碼(如「Taipei」「Kaohsiung」)",
40
+ "enum": ["Taipei", "NewTaipei", "Taoyuan", "Taichung", "Tainan", "Kaohsiung"]
41
+ },
42
+ "parking_type": {
43
+ "type": "string",
44
+ "description": "停車場類型",
45
+ "enum": ["路邊", "路外"]
46
+ },
47
+ "charge_station": {
48
+ "type": "boolean",
49
+ "description": "是否只查詢有充電站的停車場",
50
+ "default": False
51
+ },
52
+ "radius_m": {
53
+ "type": "integer",
54
+ "description": "搜尋半徑(公尺)",
55
+ "default": 1000
56
+ },
57
+ "limit": {
58
+ "type": "integer",
59
+ "description": "返回結果數量",
60
+ "default": 5
61
+ }
62
+ }, required=[])
63
+
64
+ @classmethod
65
+ def get_output_schema(cls) -> Dict[str, Any]:
66
+ schema = StandardToolSchemas.create_output_schema()
67
+ schema["properties"].update({
68
+ "parkings": {
69
+ "type": "array",
70
+ "items": {
71
+ "type": "object",
72
+ "properties": {
73
+ "parking_name": {"type": "string"},
74
+ "available_spaces": {"type": "integer"},
75
+ "total_spaces": {"type": "integer"},
76
+ "charge_station": {"type": "boolean"},
77
+ "fee_info": {"type": "string"}
78
+ }
79
+ }
80
+ }
81
+ })
82
+ return schema
83
+
84
+ @classmethod
85
+ async def execute(cls, arguments: Dict[str, Any], user_id: str = None) -> Dict[str, Any]:
86
+ parking_name = arguments.get("parking_name", "").strip()
87
+ city = arguments.get("city")
88
+ parking_type = arguments.get("parking_type")
89
+ charge_station_only = arguments.get("charge_station", False)
90
+ radius_m = min(int(arguments.get("radius_m", 1000)), 5000)
91
+ limit = min(int(arguments.get("limit", 5)), 20)
92
+
93
+ # 1. 取得用戶位置
94
+ env_ctx = await get_user_env_current(user_id) if user_id else None
95
+ if not env_ctx or not env_ctx.get("success"):
96
+ if not parking_name:
97
+ raise ExecutionError("無法取得您的位置,請提供停車場名稱或開啟定位權限")
98
+ user_lat, user_lon, user_city = None, None, None
99
+ else:
100
+ ctx = env_ctx.get("context", {})
101
+ user_lat = ctx.get("lat")
102
+ user_lon = ctx.get("lon")
103
+ user_city = ctx.get("city", "")
104
+
105
+ # 2. 自動判斷城市
106
+ if not city:
107
+ city = cls._map_city_name(user_city) if user_city else "Taipei"
108
+
109
+ # 3. 查詢分支
110
+ if charge_station_only:
111
+ # 查詢充電站
112
+ if not user_lat or not user_lon:
113
+ raise ExecutionError("查詢充電站需要定位權限")
114
+ result = await cls._query_charge_stations(user_lat, user_lon, city, radius_m, limit)
115
+ elif parking_name:
116
+ # 查詢特定停車場
117
+ result = await cls._query_parking_availability(parking_name, city)
118
+ else:
119
+ # 查詢附近停車場
120
+ if not user_lat or not user_lon:
121
+ raise ExecutionError("查詢附近停車場需要定位權限")
122
+ result = await cls._query_nearby_parkings(user_lat, user_lon, city, parking_type, radius_m, limit)
123
+
124
+ return result
125
+
126
+ @classmethod
127
+ async def _query_parking_availability(cls, parking_name: str, city: str) -> Dict[str, Any]:
128
+ """查詢特定停車場即時資訊"""
129
+ # 1. 查詢停車場基本資訊
130
+ parking_endpoint = f"Parking/OffStreet/CarPark/City/{city}"
131
+ parking_params = {
132
+ "$filter": f"contains(CarParkName/Zh_tw, '{parking_name}')",
133
+ "$format": "JSON",
134
+ "$top": 5
135
+ }
136
+
137
+ parkings = await TDXBaseAPI.call_api(parking_endpoint, parking_params, cache_ttl=3600)
138
+
139
+ if not parkings:
140
+ raise ExecutionError(f"找不到停車場「{parking_name}」")
141
+
142
+ # 2. 取得第一個結果
143
+ parking = parkings[0]
144
+ parking_id = parking.get("CarParkID")
145
+ full_parking_name = parking.get("CarParkName", {}).get("Zh_tw", parking_name)
146
+
147
+ # 3. 查詢即時剩餘車位
148
+ avail_endpoint = f"Parking/OffStreet/ParkingAvailability/City/{city}"
149
+ avail_params = {
150
+ "$filter": f"CarParkID eq '{parking_id}'",
151
+ "$format": "JSON"
152
+ }
153
+
154
+ availability = await TDXBaseAPI.call_api(avail_endpoint, avail_params, cache_ttl=60)
155
+
156
+ # 4. 組合資訊
157
+ total_spaces = parking.get("TotalSpaces", 0)
158
+ available_spaces = 0
159
+
160
+ if availability and len(availability) > 0:
161
+ avail = availability[0]
162
+ available_spaces = avail.get("AvailableSpaces", 0)
163
+
164
+ # 收費資訊
165
+ fee_info = cls._format_fee_info(parking.get("FareDescription", {}))
166
+
167
+ # 充電站資訊
168
+ has_charge = parking.get("HasChargingPoint", False)
169
+
170
+ result = {
171
+ "parking_name": full_parking_name,
172
+ "available_spaces": available_spaces,
173
+ "total_spaces": total_spaces,
174
+ "charge_station": has_charge,
175
+ "fee_info": fee_info,
176
+ "address": parking.get("Address", ""),
177
+ "service_time": parking.get("ServiceTime", "")
178
+ }
179
+
180
+ # 5. 格式化結果
181
+ content = (
182
+ f"🅿️ {result['parking_name']}\n"
183
+ f"剩餘車位: {result['available_spaces']} / {result['total_spaces']}\n"
184
+ f"收費: {result['fee_info']}\n"
185
+ f"充電站: {'有' if result['charge_station'] else '無'}\n"
186
+ f"地址: {result['address']}\n"
187
+ )
188
+
189
+ return cls.create_success_response(
190
+ content=content,
191
+ data={"parking": result}
192
+ )
193
+
194
+ @classmethod
195
+ async def _query_nearby_parkings(cls, lat: float, lon: float, city: str,
196
+ parking_type: Optional[str], radius_m: int, limit: int) -> Dict[str, Any]:
197
+ """查詢附近停車場"""
198
+ # 1. 查詢附近停車場
199
+ if parking_type == "路邊":
200
+ parking_endpoint = f"Parking/OnStreet/ParkingSpace/City/{city}"
201
+ else:
202
+ parking_endpoint = f"Parking/OffStreet/CarPark/City/{city}"
203
+
204
+ parking_params = {
205
+ "$spatialFilter": f"nearby({lat}, {lon}, {radius_m})",
206
+ "$format": "JSON",
207
+ "$top": limit * 2
208
+ }
209
+
210
+ parkings = await TDXBaseAPI.call_api(parking_endpoint, parking_params, cache_ttl=3600)
211
+
212
+ if not parkings:
213
+ return cls.create_success_response(
214
+ content=f"附近 {radius_m} 公尺內沒有停車場",
215
+ data={"parkings": []}
216
+ )
217
+
218
+ # 2. 計算距離並排序
219
+ for parking in parkings:
220
+ pos = parking.get("Position", {})
221
+ if pos.get("PositionLat") and pos.get("PositionLon"):
222
+ parking["distance_m"] = TDXBaseAPI.haversine_distance(
223
+ lat, lon,
224
+ pos["PositionLat"], pos["PositionLon"]
225
+ )
226
+
227
+ parkings = [p for p in parkings if "distance_m" in p]
228
+ parkings.sort(key=lambda x: x["distance_m"])
229
+ parkings = parkings[:limit]
230
+
231
+ # 3. 批次查詢即時車位(僅路外停車場)
232
+ if parking_type != "路邊":
233
+ parking_ids = [p.get("CarParkID") for p in parkings]
234
+
235
+ avail_endpoint = f"Parking/OffStreet/ParkingAvailability/City/{city}"
236
+ avail_params = {
237
+ "$filter": " or ".join([f"CarParkID eq '{pid}'" for pid in parking_ids if pid]),
238
+ "$format": "JSON"
239
+ }
240
+
241
+ availability = await TDXBaseAPI.call_api(avail_endpoint, avail_params, cache_ttl=60)
242
+
243
+ # 建立 ID -> 可用性 映射
244
+ avail_map = {a.get("CarParkID"): a for a in availability}
245
+ else:
246
+ avail_map = {}
247
+
248
+ # 4. 組合結果
249
+ results = []
250
+ for parking in parkings:
251
+ parking_id = parking.get("CarParkID") or parking.get("ParkingSpaceID")
252
+ parking_name = (parking.get("CarParkName") or parking.get("ParkingName") or {}).get("Zh_tw", "未知")
253
+ distance = parking["distance_m"]
254
+ walking_time = int(distance / 80)
255
+
256
+ avail = avail_map.get(parking_id, {})
257
+ total_spaces = parking.get("TotalSpaces", 0)
258
+ available_spaces = avail.get("AvailableSpaces", 0)
259
+
260
+ fee_info = cls._format_fee_info(parking.get("FareDescription", {}))
261
+
262
+ results.append({
263
+ "parking_name": parking_name,
264
+ "available_spaces": available_spaces,
265
+ "total_spaces": total_spaces,
266
+ "distance_m": int(distance),
267
+ "walking_time_min": walking_time,
268
+ "charge_station": parking.get("HasChargingPoint", False),
269
+ "fee_info": fee_info
270
+ })
271
+
272
+ content = cls._format_nearby_result(results, parking_type)
273
+
274
+ return cls.create_success_response(
275
+ content=content,
276
+ data={"parkings": results}
277
+ )
278
+
279
+ @classmethod
280
+ async def _query_charge_stations(cls, lat: float, lon: float, city: str,
281
+ radius_m: int, limit: int) -> Dict[str, Any]:
282
+ """查詢附近充電站"""
283
+ # 查詢有充電站的停車場
284
+ parking_endpoint = f"Parking/OffStreet/CarPark/City/{city}"
285
+ parking_params = {
286
+ "$filter": "HasChargingPoint eq true",
287
+ "$format": "JSON"
288
+ }
289
+
290
+ parkings = await TDXBaseAPI.call_api(parking_endpoint, parking_params, cache_ttl=3600)
291
+
292
+ if not parkings:
293
+ return cls.create_success_response(
294
+ content="此區域無充電站資訊",
295
+ data={"charge_stations": []}
296
+ )
297
+
298
+ # 計算距離並過濾
299
+ for parking in parkings:
300
+ pos = parking.get("Position", {})
301
+ if pos.get("PositionLat") and pos.get("PositionLon"):
302
+ parking["distance_m"] = TDXBaseAPI.haversine_distance(
303
+ lat, lon,
304
+ pos["PositionLat"], pos["PositionLon"]
305
+ )
306
+
307
+ parkings = [p for p in parkings if "distance_m" in p and p["distance_m"] <= radius_m]
308
+ parkings.sort(key=lambda x: x["distance_m"])
309
+ parkings = parkings[:limit]
310
+
311
+ if not parkings:
312
+ return cls.create_success_response(
313
+ content=f"附近 {radius_m} 公尺內沒有充電站",
314
+ data={"charge_stations": []}
315
+ )
316
+
317
+ # 格式化結果
318
+ results = []
319
+ for parking in parkings:
320
+ parking_name = parking.get("CarParkName", {}).get("Zh_tw", "未知")
321
+ distance = parking["distance_m"]
322
+ walking_time = int(distance / 80)
323
+
324
+ results.append({
325
+ "parking_name": parking_name,
326
+ "distance_m": int(distance),
327
+ "walking_time_min": walking_time,
328
+ "address": parking.get("Address", ""),
329
+ "total_spaces": parking.get("TotalSpaces", 0)
330
+ })
331
+
332
+ content = cls._format_charge_result(results)
333
+
334
+ return cls.create_success_response(
335
+ content=content,
336
+ data={"charge_stations": results}
337
+ )
338
+
339
+ @staticmethod
340
+ def _map_city_name(chinese_city: str) -> str:
341
+ """中文城市名稱轉 TDX 代碼"""
342
+ city_map = {
343
+ "台北": "Taipei", "臺北": "Taipei",
344
+ "新北": "NewTaipei",
345
+ "桃園": "Taoyuan",
346
+ "台中": "Taichung", "臺中": "Taichung",
347
+ "台南": "Tainan", "臺南": "Tainan",
348
+ "高雄": "Kaohsiung"
349
+ }
350
+
351
+ for key, value in city_map.items():
352
+ if key in chinese_city:
353
+ return value
354
+
355
+ return "Taipei"
356
+
357
+ @staticmethod
358
+ def _format_fee_info(fare_desc: Dict) -> str:
359
+ """格式化收費資訊"""
360
+ if not fare_desc:
361
+ return "未提供"
362
+
363
+ zh_tw = fare_desc.get("Zh_tw", "")
364
+ if zh_tw:
365
+ # 簡化長文字
366
+ if len(zh_tw) > 50:
367
+ return zh_tw[:47] + "..."
368
+ return zh_tw
369
+
370
+ return "未提供"
371
+
372
+ @staticmethod
373
+ def _format_nearby_result(parkings: List[Dict], parking_type: Optional[str]) -> str:
374
+ """格式化附近停車場結果"""
375
+ if not parkings:
376
+ return "附近沒有停車場"
377
+
378
+ type_emoji = "🅿️" if parking_type == "路外" else "🚗"
379
+ lines = [f"📍 附近的停車場:\n"]
380
+
381
+ for i, parking in enumerate(parkings, 1):
382
+ charge_emoji = "⚡" if parking["charge_station"] else ""
383
+
384
+ if parking["total_spaces"] > 0:
385
+ avail_info = f"剩餘 {parking['available_spaces']}/{parking['total_spaces']}"
386
+ else:
387
+ avail_info = "無車位資訊"
388
+
389
+ lines.append(
390
+ f"{i}. {type_emoji} {parking['parking_name']} {charge_emoji}\n"
391
+ f" {avail_info}\n"
392
+ f" {parking['fee_info']}\n"
393
+ f" 步行 {parking['walking_time_min']} 分鐘 ({parking['distance_m']}m)\n"
394
+ )
395
+
396
+ return "\n".join(lines)
397
+
398
+ @staticmethod
399
+ def _format_charge_result(stations: List[Dict]) -> str:
400
+ """格式化充電站結果"""
401
+ lines = ["⚡ 附近的充電站:\n"]
402
+
403
+ for i, station in enumerate(stations, 1):
404
+ lines.append(
405
+ f"{i}. {station['parking_name']}\n"
406
+ f" 步行 {station['walking_time_min']} 分鐘 ({station['distance_m']}m)\n"
407
+ f" {station['address']}\n"
408
+ )
409
+
410
+ return "\n".join(lines)
features/mcp/tools/tdx_thsr.py ADDED
@@ -0,0 +1,402 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TDX 台灣高鐵查詢工具
3
+ 查詢高鐵時刻表、票價、最近車站
4
+ """
5
+
6
+ import logging
7
+ from typing import Dict, Any, List, Optional
8
+ from datetime import datetime, timedelta
9
+
10
+ from .base_tool import MCPTool, StandardToolSchemas, ExecutionError
11
+ from .tdx_base import TDXBaseAPI
12
+ from core.database import get_user_env_current
13
+
14
+ logger = logging.getLogger("mcp.tools.tdx.thsr")
15
+
16
+
17
+ class TDXTHSRTool(MCPTool):
18
+ """TDX 台灣高鐵時刻表查詢"""
19
+
20
+ NAME = "tdx_thsr"
21
+ DESCRIPTION = "查詢台灣高鐵時刻表、票價、最近車站(南港-左營)"
22
+ CATEGORY = "軌道運輸"
23
+ TAGS = ["tdx", "高鐵", "THSR", "時刻表", "票價"]
24
+ KEYWORDS = ["高鐵", "THSR", "HSR", "高速鐵路", "時刻", "票價"]
25
+ USAGE_TIPS = [
26
+ "查詢車次: 「高鐵 123 次」",
27
+ "查詢路線: 「台北到台中的高鐵」",
28
+ "查詢最近車站: 「最近的高鐵站在哪」",
29
+ "查詢時刻: 「下午2點台北到高雄的高鐵」"
30
+ ]
31
+
32
+ # 高鐵車站代碼對照
33
+ STATION_MAP = {
34
+ "南港": "NAG", "台北": "TPE", "臺北": "TPE", "板橋": "BAC",
35
+ "桃園": "TAY", "新竹": "HSC", "苗栗": "MIA", "台中": "TAC",
36
+ "臺中": "TAC", "彰化": "CHA", "雲林": "YUL", "嘉義": "CHY",
37
+ "台南": "TNN", "臺南": "TNN", "左營": "ZUY", "高雄": "ZUY"
38
+ }
39
+
40
+ @classmethod
41
+ def get_input_schema(cls) -> Dict[str, Any]:
42
+ return StandardToolSchemas.create_input_schema({
43
+ "origin_station": {
44
+ "type": "string",
45
+ "description": "起站名稱(南港/台北/板橋/桃園/新竹/苗栗/台中/彰化/雲林/嘉義/台南/左營)"
46
+ },
47
+ "destination_station": {
48
+ "type": "string",
49
+ "description": "迄站名稱"
50
+ },
51
+ "train_no": {
52
+ "type": "string",
53
+ "description": "車次號碼(如「123」)"
54
+ },
55
+ "departure_date": {
56
+ "type": "string",
57
+ "description": "出發日期(YYYY-MM-DD 格式,預設今日)"
58
+ },
59
+ "departure_time": {
60
+ "type": "string",
61
+ "description": "出發時間(HH:MM 格式,如「14:30」)"
62
+ },
63
+ "limit": {
64
+ "type": "integer",
65
+ "description": "返回結果數量",
66
+ "default": 5
67
+ }
68
+ }, required=[])
69
+
70
+ @classmethod
71
+ def get_output_schema(cls) -> Dict[str, Any]:
72
+ schema = StandardToolSchemas.create_output_schema()
73
+ schema["properties"].update({
74
+ "trains": {
75
+ "type": "array",
76
+ "items": {
77
+ "type": "object",
78
+ "properties": {
79
+ "train_no": {"type": "string"},
80
+ "train_type": {"type": "string"},
81
+ "departure_time": {"type": "string"},
82
+ "arrival_time": {"type": "string"},
83
+ "duration_min": {"type": "integer"},
84
+ "fare": {"type": "integer"}
85
+ }
86
+ }
87
+ }
88
+ })
89
+ return schema
90
+
91
+ @classmethod
92
+ async def execute(cls, arguments: Dict[str, Any], user_id: str = None) -> Dict[str, Any]:
93
+ origin = arguments.get("origin_station", "").strip()
94
+ destination = arguments.get("destination_station", "").strip()
95
+ train_no = arguments.get("train_no", "").strip()
96
+ departure_date = arguments.get("departure_date", "").strip()
97
+ departure_time = arguments.get("departure_time", "").strip()
98
+ limit = min(int(arguments.get("limit", 5)), 20)
99
+
100
+ # 1. 取得用戶位置(用於最近車站查詢)
101
+ env_ctx = await get_user_env_current(user_id) if user_id else None
102
+ user_lat, user_lon = None, None
103
+ if env_ctx and env_ctx.get("success"):
104
+ ctx = env_ctx.get("context", {})
105
+ user_lat = ctx.get("lat")
106
+ user_lon = ctx.get("lon")
107
+
108
+ # 2. 查詢分支
109
+ if train_no:
110
+ # 查詢特定車次
111
+ result = await cls._query_train_schedule(train_no, departure_date)
112
+ elif origin and destination:
113
+ # 查詢起迄站列車
114
+ result = await cls._query_od_trains(origin, destination, departure_date, departure_time, limit)
115
+ elif not origin and not destination:
116
+ # 查詢最近車站
117
+ if not user_lat or not user_lon:
118
+ raise ExecutionError("查詢最近高鐵站需要定位權限,或請提供起迄站名稱")
119
+ result = await cls._query_nearest_station(user_lat, user_lon)
120
+ else:
121
+ raise ExecutionError("請提供車次號碼,或起迄站名稱,或開啟定位查詢最近高鐵站")
122
+
123
+ return result
124
+
125
+ @classmethod
126
+ async def _query_train_schedule(cls, train_no: str, departure_date: str = "") -> Dict[str, Any]:
127
+ """查詢特定車次時刻表"""
128
+ # 日期處理
129
+ if not departure_date:
130
+ date_str = datetime.now().strftime("%Y-%m-%d")
131
+ else:
132
+ date_str = departure_date
133
+
134
+ # TDX 高鐵每日時刻表
135
+ endpoint = f"Rail/THSR/DailyTimetable/TrainDate/{date_str}"
136
+ params = {
137
+ "$filter": f"DailyTrainInfo/TrainNo eq '{train_no}'",
138
+ "$format": "JSON"
139
+ }
140
+
141
+ trains = await TDXBaseAPI.call_api(endpoint, params, cache_ttl=1800)
142
+
143
+ if not trains or len(trains) == 0:
144
+ raise ExecutionError(f"找不到車次 {train_no},請確認車次號碼與日期")
145
+
146
+ train = trains[0]
147
+ train_info = train.get("DailyTrainInfo", {})
148
+ stops = train_info.get("StopTimes", [])
149
+
150
+ if not stops:
151
+ raise ExecutionError(f"車次 {train_no} 無停靠站資訊")
152
+
153
+ # 判斷車種
154
+ train_type = "標準車廂"
155
+ if any("商務" in stop.get("StationName", {}).get("Zh_tw", "") for stop in stops):
156
+ train_type = "商務車廂"
157
+
158
+ # 格式化時刻表
159
+ schedule_lines = [f"🚄 高鐵 {train_no} 次 ({train_type})\n"]
160
+ schedule_lines.append(f"日期: {date_str}\n")
161
+
162
+ for stop in stops:
163
+ station_name = stop.get("StationName", {}).get("Zh_tw", "未知")
164
+ arrival_time = stop.get("ArrivalTime", "")
165
+ departure_time = stop.get("DepartureTime", "")
166
+
167
+ if arrival_time == departure_time:
168
+ time_str = arrival_time[:5] if arrival_time else "-"
169
+ else:
170
+ arr = arrival_time[:5] if arrival_time else "-"
171
+ dep = departure_time[:5] if departure_time else "-"
172
+ time_str = f"到 {arr} / 開 {dep}"
173
+
174
+ schedule_lines.append(f" {station_name:<6} {time_str}")
175
+
176
+ content = "\n".join(schedule_lines)
177
+
178
+ return cls.create_success_response(
179
+ content=content,
180
+ data={"train": train_info, "stops": stops}
181
+ )
182
+
183
+ @classmethod
184
+ async def _query_od_trains(cls, origin: str, destination: str,
185
+ departure_date: str, departure_time: Optional[str],
186
+ limit: int) -> Dict[str, Any]:
187
+ """查詢起迄站列車與票價"""
188
+ # 站點代碼轉換
189
+ origin_code = cls._get_station_code(origin)
190
+ dest_code = cls._get_station_code(destination)
191
+
192
+ if not origin_code:
193
+ raise ExecutionError(f"找不到車站「{origin}」,請使用正確的高鐵站名")
194
+ if not dest_code:
195
+ raise ExecutionError(f"找不到車站「{destination}」,請使用正確的高鐵站名")
196
+
197
+ # 日期處理
198
+ if not departure_date:
199
+ date_str = datetime.now().strftime("%Y-%m-%d")
200
+ else:
201
+ date_str = departure_date
202
+
203
+ # 1. 查詢當日所有班次
204
+ endpoint = f"Rail/THSR/DailyTimetable/TrainDate/{date_str}"
205
+ params = {
206
+ "$format": "JSON"
207
+ }
208
+
209
+ all_trains = await TDXBaseAPI.call_api(endpoint, params, cache_ttl=1800)
210
+
211
+ if not all_trains:
212
+ raise ExecutionError("無法取得高鐵時刻表資訊")
213
+
214
+ # 2. 過濾符合起迄站的列車
215
+ matching_trains = []
216
+
217
+ for train_data in all_trains:
218
+ train_info = train_data.get("DailyTrainInfo", {})
219
+ stops = train_info.get("StopTimes", [])
220
+
221
+ # 找起站和迄站
222
+ origin_stop, dest_stop = None, None
223
+ origin_idx, dest_idx = -1, -1
224
+
225
+ for i, stop in enumerate(stops):
226
+ station_id = stop.get("StationID")
227
+ if station_id == origin_code:
228
+ origin_stop = stop
229
+ origin_idx = i
230
+ if station_id == dest_code:
231
+ dest_stop = stop
232
+ dest_idx = i
233
+
234
+ # 起站在迄站之前才符合
235
+ if origin_stop and dest_stop and origin_idx < dest_idx:
236
+ dep_time = origin_stop.get("DepartureTime", "")
237
+ arr_time = dest_stop.get("ArrivalTime", "")
238
+
239
+ train_result = {
240
+ "train_no": train_info.get("TrainNo"),
241
+ "origin_station": origin_stop.get("StationName", {}).get("Zh_tw"),
242
+ "destination_station": dest_stop.get("StationName", {}).get("Zh_tw"),
243
+ "departure_time": dep_time,
244
+ "arrival_time": arr_time,
245
+ }
246
+
247
+ # 計算行駛時間
248
+ try:
249
+ dep_dt = datetime.strptime(dep_time, "%H:%M:%S")
250
+ arr_dt = datetime.strptime(arr_time, "%H:%M:%S")
251
+ duration = (arr_dt - dep_dt).total_seconds() / 60
252
+ train_result["duration_min"] = int(duration)
253
+ except:
254
+ train_result["duration_min"] = 0
255
+
256
+ matching_trains.append(train_result)
257
+
258
+ if not matching_trains:
259
+ raise ExecutionError(f"找不到 {origin} 到 {destination} 的直達高鐵")
260
+
261
+ # 3. 時間過濾
262
+ if departure_time:
263
+ try:
264
+ target_time = datetime.strptime(departure_time, "%H:%M")
265
+ matching_trains = [
266
+ t for t in matching_trains
267
+ if datetime.strptime(t["departure_time"][:5], "%H:%M") >= target_time
268
+ ]
269
+ except:
270
+ pass
271
+
272
+ # 4. 查詢票價
273
+ fare_endpoint = f"Rail/THSR/ODFare/{origin_code}/to/{dest_code}"
274
+ fare_params = {
275
+ "$format": "JSON"
276
+ }
277
+
278
+ try:
279
+ fare_data = await TDXBaseAPI.call_api(fare_endpoint, fare_params, cache_ttl=86400)
280
+ if fare_data and len(fare_data) > 0:
281
+ fares = fare_data[0].get("Fares", [])
282
+ standard_fare = next((f.get("Price") for f in fares if f.get("TicketType") == "標準"), 0)
283
+
284
+ for train in matching_trains:
285
+ train["fare"] = standard_fare
286
+ except:
287
+ # 票價查詢失敗不影響時刻表結果
288
+ pass
289
+
290
+ # 5. 排序並限制數量
291
+ matching_trains.sort(key=lambda x: x["departure_time"])
292
+ matching_trains = matching_trains[:limit]
293
+
294
+ # 6. 格式化結果
295
+ content = cls._format_od_result(matching_trains, origin, destination, date_str)
296
+
297
+ return cls.create_success_response(
298
+ content=content,
299
+ data={"trains": matching_trains, "date": date_str}
300
+ )
301
+
302
+ @classmethod
303
+ async def _query_nearest_station(cls, lat: float, lon: float) -> Dict[str, Any]:
304
+ """查詢最近的高鐵站"""
305
+ # 1. 取得所有高鐵車站
306
+ endpoint = "Rail/THSR/Station"
307
+ params = {
308
+ "$format": "JSON"
309
+ }
310
+
311
+ stations = await TDXBaseAPI.call_api(endpoint, params, cache_ttl=86400)
312
+
313
+ if not stations:
314
+ raise ExecutionError("無法取得高鐵車站資訊")
315
+
316
+ # 2. 計算距離
317
+ for station in stations:
318
+ pos = station.get("StationPosition", {})
319
+ if pos.get("PositionLat") and pos.get("PositionLon"):
320
+ station["distance_m"] = TDXBaseAPI.haversine_distance(
321
+ lat, lon,
322
+ pos["PositionLat"], pos["PositionLon"]
323
+ )
324
+
325
+ stations_with_distance = [s for s in stations if "distance_m" in s]
326
+
327
+ if not stations_with_distance:
328
+ raise ExecutionError("附近沒有高鐵車站資訊")
329
+
330
+ stations_with_distance.sort(key=lambda x: x["distance_m"])
331
+ nearest = stations_with_distance[:3]
332
+
333
+ # 3. 格式化結果
334
+ results = []
335
+ for station in nearest:
336
+ station_name = station.get("StationName", {}).get("Zh_tw", "未知")
337
+ distance = station["distance_m"]
338
+ driving_time = int(distance / 500) # 假設開車 500m/min (30km/h)
339
+
340
+ results.append({
341
+ "station_name": station_name,
342
+ "station_id": station.get("StationID"),
343
+ "distance_m": int(distance),
344
+ "driving_time_min": driving_time,
345
+ "address": station.get("StationAddress", "")
346
+ })
347
+
348
+ content = cls._format_nearest_result(results)
349
+
350
+ return cls.create_success_response(
351
+ content=content,
352
+ data={"stations": results}
353
+ )
354
+
355
+ @staticmethod
356
+ def _get_station_code(station_name: str) -> Optional[str]:
357
+ """中文站名轉站點代碼"""
358
+ for name, code in TDXTHSRTool.STATION_MAP.items():
359
+ if name in station_name or station_name in name:
360
+ return code
361
+ return None
362
+
363
+ @staticmethod
364
+ def _format_od_result(trains: List[Dict], origin: str, destination: str, date: str) -> str:
365
+ """格式化起迄站查詢結果"""
366
+ if not trains:
367
+ return f"🚄 {origin} → {destination} ({date}) 目前無可搭乘高鐵"
368
+
369
+ lines = [f"🚄 {origin} → {destination} ({date})\n"]
370
+
371
+ for i, train in enumerate(trains, 1):
372
+ duration_hours = train["duration_min"] // 60
373
+ duration_mins = train["duration_min"] % 60
374
+
375
+ if duration_hours > 0:
376
+ duration_str = f"{duration_hours}小時{duration_mins}分"
377
+ else:
378
+ duration_str = f"{duration_mins}分鐘"
379
+
380
+ fare_str = f" - ${train['fare']}" if train.get("fare") else ""
381
+
382
+ lines.append(
383
+ f"{i}. 高鐵 {train['train_no']}次\n"
384
+ f" {train['departure_time'][:5]} → {train['arrival_time'][:5]}"
385
+ f" ({duration_str}){fare_str}\n"
386
+ )
387
+
388
+ return "\n".join(lines)
389
+
390
+ @staticmethod
391
+ def _format_nearest_result(stations: List[Dict]) -> str:
392
+ """格式化最近車站結果"""
393
+ lines = ["📍 最近的高鐵站:\n"]
394
+
395
+ for i, station in enumerate(stations, 1):
396
+ lines.append(
397
+ f"{i}. 🚄 {station['station_name']}\n"
398
+ f" 開車約 {station['driving_time_min']} 分鐘 ({station['distance_m']/1000:.1f}km)\n"
399
+ f" {station.get('address', '')}\n"
400
+ )
401
+
402
+ return "\n".join(lines)
features/mcp/tools/tdx_train.py ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TDX 台鐵時刻表查詢工具
3
+ 查詢台鐵列車時刻、票價、車站資訊
4
+ """
5
+
6
+ import logging
7
+ from typing import Dict, Any, List, Optional
8
+ from datetime import datetime, timedelta
9
+
10
+ from .base_tool import MCPTool, StandardToolSchemas, ExecutionError
11
+ from .tdx_base import TDXBaseAPI
12
+ from core.database import get_user_env_current
13
+
14
+ logger = logging.getLogger("mcp.tools.tdx.train")
15
+
16
+
17
+ class TDXTrainTool(MCPTool):
18
+ """TDX 台鐵時刻表查詢"""
19
+
20
+ NAME = "tdx_train"
21
+ DESCRIPTION = "查詢台鐵列車時刻表、票價、最近車站(含高鐵轉乘資訊)"
22
+ CATEGORY = "軌道運輸"
23
+ TAGS = ["tdx", "台鐵", "TRA", "火車", "時刻表"]
24
+ KEYWORDS = ["台鐵", "臺鐵", "火車", "TRA", "列車", "時刻"]
25
+ USAGE_TIPS = [
26
+ "查詢車次: 「自強號 123 次」",
27
+ "查詢路線: 「台北到台中的火車」",
28
+ "查詢最近車站: 「最近的火車站在哪」",
29
+ "查詢時刻: 「下午3點台北到高雄」"
30
+ ]
31
+
32
+ @classmethod
33
+ def get_input_schema(cls) -> Dict[str, Any]:
34
+ return StandardToolSchemas.create_input_schema({
35
+ "origin_station": {
36
+ "type": "string",
37
+ "description": "起站名稱(如「台北」「台中」)"
38
+ },
39
+ "destination_station": {
40
+ "type": "string",
41
+ "description": "迄站名稱"
42
+ },
43
+ "train_no": {
44
+ "type": "string",
45
+ "description": "車次號碼(如「123」)"
46
+ },
47
+ "departure_time": {
48
+ "type": "string",
49
+ "description": "出發時間(HH:MM 格式,如「14:30」)"
50
+ },
51
+ "train_type": {
52
+ "type": "string",
53
+ "description": "列車種類",
54
+ "enum": ["自強", "莒光", "區間", "區間快", "普快", "復興", "太魯閣", "普悠瑪"]
55
+ },
56
+ "limit": {
57
+ "type": "integer",
58
+ "description": "返回結果數量",
59
+ "default": 5
60
+ }
61
+ }, required=[])
62
+
63
+ @classmethod
64
+ def get_output_schema(cls) -> Dict[str, Any]:
65
+ schema = StandardToolSchemas.create_output_schema()
66
+ schema["properties"].update({
67
+ "trains": {
68
+ "type": "array",
69
+ "items": {
70
+ "type": "object",
71
+ "properties": {
72
+ "train_no": {"type": "string"},
73
+ "train_type": {"type": "string"},
74
+ "departure_time": {"type": "string"},
75
+ "arrival_time": {"type": "string"},
76
+ "duration_min": {"type": "integer"}
77
+ }
78
+ }
79
+ }
80
+ })
81
+ return schema
82
+
83
+ @classmethod
84
+ async def execute(cls, arguments: Dict[str, Any], user_id: str = None) -> Dict[str, Any]:
85
+ origin = arguments.get("origin_station", "").strip()
86
+ destination = arguments.get("destination_station", "").strip()
87
+ train_no = arguments.get("train_no", "").strip()
88
+ departure_time = arguments.get("departure_time", "").strip()
89
+ train_type = arguments.get("train_type")
90
+ limit = min(int(arguments.get("limit", 5)), 20)
91
+
92
+ # 1. 取得用戶位置(用於最近車站查詢)
93
+ env_ctx = await get_user_env_current(user_id) if user_id else None
94
+ user_lat, user_lon = None, None
95
+ if env_ctx and env_ctx.get("success"):
96
+ ctx = env_ctx.get("context", {})
97
+ user_lat = ctx.get("lat")
98
+ user_lon = ctx.get("lon")
99
+
100
+ # 2. 查詢分支
101
+ if train_no:
102
+ # 查詢特定車次
103
+ result = await cls._query_train_schedule(train_no)
104
+ elif origin and destination:
105
+ # 查詢起迄站列車
106
+ result = await cls._query_od_trains(origin, destination, departure_time, train_type, limit)
107
+ elif not origin and not destination:
108
+ # 查詢最近車站
109
+ if not user_lat or not user_lon:
110
+ raise ExecutionError("查詢最近車站需要定位權限,或請提供起迄站名稱")
111
+ result = await cls._query_nearest_station(user_lat, user_lon)
112
+ else:
113
+ raise ExecutionError("請提供車次號碼,或起迄站名稱,或開啟定位查詢最近車站")
114
+
115
+ return result
116
+
117
+ @classmethod
118
+ async def _query_train_schedule(cls, train_no: str) -> Dict[str, Any]:
119
+ """查詢特定車次時刻表"""
120
+ today = datetime.now().strftime("%Y-%m-%d")
121
+
122
+ endpoint = "Rail/TRA/DailyTrainInfo/Today"
123
+ params = {
124
+ "$filter": f"TrainNo eq '{train_no}'",
125
+ "$format": "JSON"
126
+ }
127
+
128
+ trains = await TDXBaseAPI.call_api(endpoint, params, cache_ttl=1800)
129
+
130
+ if not trains:
131
+ raise ExecutionError(f"找不到車次 {train_no},請確認車次號碼")
132
+
133
+ train = trains[0]
134
+ train_type = train.get("TrainTypeName", {}).get("Zh_tw", "未知")
135
+
136
+ # 取得停靠站資訊
137
+ stops = train.get("StopTimes", [])
138
+
139
+ if not stops:
140
+ raise ExecutionError(f"車次 {train_no} 無停靠站資訊")
141
+
142
+ # 格式化時刻表
143
+ schedule_lines = [f"🚂 {train_type} {train_no} 次\n"]
144
+
145
+ for stop in stops:
146
+ station_name = stop.get("StationName", {}).get("Zh_tw", "未知")
147
+ arrival_time = stop.get("ArrivalTime", "")
148
+ departure_time = stop.get("DepartureTime", "")
149
+
150
+ if arrival_time == departure_time:
151
+ time_str = arrival_time[:5] if arrival_time else "-"
152
+ else:
153
+ arr = arrival_time[:5] if arrival_time else "-"
154
+ dep = departure_time[:5] if departure_time else "-"
155
+ time_str = f"{arr} / {dep}"
156
+
157
+ schedule_lines.append(f" {station_name:<10} {time_str}")
158
+
159
+ content = "\n".join(schedule_lines)
160
+
161
+ return cls.create_success_response(
162
+ content=content,
163
+ data={"train": train, "stops": stops}
164
+ )
165
+
166
+ @classmethod
167
+ async def _query_od_trains(cls, origin: str, destination: str,
168
+ departure_time: Optional[str], train_type: Optional[str],
169
+ limit: int) -> Dict[str, Any]:
170
+ """查詢起迄站列車"""
171
+ # 1. 先取得今日所有列車
172
+ endpoint = "Rail/TRA/DailyTrainInfo/Today"
173
+ params = {
174
+ "$format": "JSON"
175
+ }
176
+
177
+ all_trains = await TDXBaseAPI.call_api(endpoint, params, cache_ttl=1800)
178
+
179
+ if not all_trains:
180
+ raise ExecutionError("無法取得台鐵列車資訊")
181
+
182
+ # 2. 過濾符合起迄站的列車
183
+ matching_trains = []
184
+
185
+ for train in all_trains:
186
+ stops = train.get("StopTimes", [])
187
+
188
+ # 找起站和迄站
189
+ origin_idx, dest_idx = -1, -1
190
+ for i, stop in enumerate(stops):
191
+ station = stop.get("StationName", {}).get("Zh_tw", "")
192
+ if origin in station:
193
+ origin_idx = i
194
+ if destination in station:
195
+ dest_idx = i
196
+
197
+ # 起站在迄站之前才符合
198
+ if origin_idx >= 0 and dest_idx > origin_idx:
199
+ origin_stop = stops[origin_idx]
200
+ dest_stop = stops[dest_idx]
201
+
202
+ train_info = {
203
+ "train_no": train.get("TrainNo"),
204
+ "train_type": train.get("TrainTypeName", {}).get("Zh_tw", "未知"),
205
+ "origin_station": origin_stop.get("StationName", {}).get("Zh_tw"),
206
+ "destination_station": dest_stop.get("StationName", {}).get("Zh_tw"),
207
+ "departure_time": origin_stop.get("DepartureTime", ""),
208
+ "arrival_time": dest_stop.get("ArrivalTime", ""),
209
+ }
210
+
211
+ # 計算行駛時間
212
+ try:
213
+ dep_dt = datetime.strptime(train_info["departure_time"], "%H:%M:%S")
214
+ arr_dt = datetime.strptime(train_info["arrival_time"], "%H:%M:%S")
215
+ if arr_dt < dep_dt: # 跨日
216
+ arr_dt += timedelta(days=1)
217
+ duration = (arr_dt - dep_dt).total_seconds() / 60
218
+ train_info["duration_min"] = int(duration)
219
+ except:
220
+ train_info["duration_min"] = 0
221
+
222
+ matching_trains.append(train_info)
223
+
224
+ if not matching_trains:
225
+ raise ExecutionError(f"找不到 {origin} 到 {destination} 的直達列車")
226
+
227
+ # 3. 時間過濾
228
+ if departure_time:
229
+ try:
230
+ target_time = datetime.strptime(departure_time, "%H:%M")
231
+ matching_trains = [
232
+ t for t in matching_trains
233
+ if datetime.strptime(t["departure_time"][:5], "%H:%M") >= target_time
234
+ ]
235
+ except:
236
+ pass
237
+
238
+ # 4. 車種過濾
239
+ if train_type:
240
+ matching_trains = [t for t in matching_trains if train_type in t["train_type"]]
241
+
242
+ # 5. 排序並限制數量
243
+ matching_trains.sort(key=lambda x: x["departure_time"])
244
+ matching_trains = matching_trains[:limit]
245
+
246
+ # 6. 格式化結果
247
+ content = cls._format_od_result(matching_trains, origin, destination)
248
+
249
+ return cls.create_success_response(
250
+ content=content,
251
+ data={"trains": matching_trains}
252
+ )
253
+
254
+ @classmethod
255
+ async def _query_nearest_station(cls, lat: float, lon: float) -> Dict[str, Any]:
256
+ """查詢最近的台鐵車站"""
257
+ # 1. 取得所有車站
258
+ endpoint = "Rail/TRA/Station"
259
+ params = {
260
+ "$format": "JSON"
261
+ }
262
+
263
+ stations = await TDXBaseAPI.call_api(endpoint, params, cache_ttl=86400)
264
+
265
+ if not stations:
266
+ raise ExecutionError("無法取得台鐵車站資訊")
267
+
268
+ # 2. 計算距離
269
+ for station in stations:
270
+ pos = station.get("StationPosition", {})
271
+ if pos.get("PositionLat") and pos.get("PositionLon"):
272
+ station["distance_m"] = TDXBaseAPI.haversine_distance(
273
+ lat, lon,
274
+ pos["PositionLat"], pos["PositionLon"]
275
+ )
276
+
277
+ stations_with_distance = [s for s in stations if "distance_m" in s]
278
+
279
+ if not stations_with_distance:
280
+ raise ExecutionError("附近沒有台鐵車站資訊")
281
+
282
+ stations_with_distance.sort(key=lambda x: x["distance_m"])
283
+ nearest = stations_with_distance[:3]
284
+
285
+ # 3. 格式化結果
286
+ results = []
287
+ for station in nearest:
288
+ station_name = station.get("StationName", {}).get("Zh_tw", "未知")
289
+ distance = station["distance_m"]
290
+ walking_time = int(distance / 80)
291
+
292
+ results.append({
293
+ "station_name": station_name,
294
+ "station_id": station.get("StationID"),
295
+ "distance_m": int(distance),
296
+ "walking_time_min": walking_time,
297
+ "address": station.get("StationAddress", "")
298
+ })
299
+
300
+ content = cls._format_nearest_result(results)
301
+
302
+ return cls.create_success_response(
303
+ content=content,
304
+ data={"stations": results}
305
+ )
306
+
307
+ @staticmethod
308
+ def _format_od_result(trains: List[Dict], origin: str, destination: str) -> str:
309
+ """格式化起迄站查詢結果"""
310
+ if not trains:
311
+ return f"🚂 {origin} → {destination} 目前無可搭乘列車"
312
+
313
+ lines = [f"🚂 {origin} → {destination}\n"]
314
+
315
+ for i, train in enumerate(trains, 1):
316
+ duration_hours = train["duration_min"] // 60
317
+ duration_mins = train["duration_min"] % 60
318
+
319
+ if duration_hours > 0:
320
+ duration_str = f"{duration_hours}小時{duration_mins}分"
321
+ else:
322
+ duration_str = f"{duration_mins}分鐘"
323
+
324
+ lines.append(
325
+ f"{i}. {train['train_type']} {train['train_no']}次\n"
326
+ f" {train['departure_time'][:5]} → {train['arrival_time'][:5]}"
327
+ f" ({duration_str})\n"
328
+ )
329
+
330
+ return "\n".join(lines)
331
+
332
+ @staticmethod
333
+ def _format_nearest_result(stations: List[Dict]) -> str:
334
+ """格式化最近車站結果"""
335
+ lines = ["📍 最近的台鐵車站:\n"]
336
+
337
+ for i, station in enumerate(stations, 1):
338
+ lines.append(
339
+ f"{i}. 🚂 {station['station_name']}\n"
340
+ f" 步行 {station['walking_time_min']} 分鐘 ({station['distance_m']}m)\n"
341
+ f" {station.get('address', '')}\n"
342
+ )
343
+
344
+ return "\n".join(lines)
features/mcp/tools/tdx_youbike.py ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TDX YouBike 即時查詢工具
3
+ 查詢附近 YouBike 站點、即時車輛數、空位數
4
+ """
5
+
6
+ import logging
7
+ from typing import Dict, Any, List
8
+
9
+ from .base_tool import MCPTool, StandardToolSchemas, ExecutionError
10
+ from .tdx_base import TDXBaseAPI
11
+ from core.database import get_user_env_current
12
+
13
+ logger = logging.getLogger("mcp.tools.tdx.bike")
14
+
15
+
16
+ class TDXBikeTool(MCPTool):
17
+ """TDX YouBike 即時查詢"""
18
+
19
+ NAME = "tdx_youbike"
20
+ DESCRIPTION = "查詢附近 YouBike 站點、即時車輛數、空位數(支援 YouBike 1.0/2.0)"
21
+ CATEGORY = "微型運具"
22
+ TAGS = ["tdx", "youbike", "ubike", "共享單車", "微笑單車"]
23
+ KEYWORDS = ["YouBike", "UBike", "微笑單車", "共享單車", "腳踏車", "自行車"]
24
+ USAGE_TIPS = [
25
+ "查詢附近站點: 「附近的 YouBike 在哪」",
26
+ "查詢特定站點: 「市政府 YouBike 還有車嗎」",
27
+ "指定城市: 「台北 YouBike」「高雄 CityBike」"
28
+ ]
29
+
30
+ # 城市對應
31
+ CITY_MAP = {
32
+ "台北": "Taipei",
33
+ "臺北": "Taipei",
34
+ "新北": "NewTaipei",
35
+ "桃園": "Taoyuan",
36
+ "台中": "Taichung",
37
+ "臺中": "Taichung",
38
+ "台南": "Tainan",
39
+ "臺南": "Tainan",
40
+ "高雄": "Kaohsiung",
41
+ "新竹": "Hsinchu"
42
+ }
43
+
44
+ @classmethod
45
+ def get_input_schema(cls) -> Dict[str, Any]:
46
+ return StandardToolSchemas.create_input_schema({
47
+ "station_name": {
48
+ "type": "string",
49
+ "description": "站點名稱(如「市政府」「台北車站」)。不提供則查詢附近站點"
50
+ },
51
+ "city": {
52
+ "type": "string",
53
+ "description": "城市名稱(如「Taipei」「Kaohsiung」)",
54
+ "enum": list(cls.CITY_MAP.values())
55
+ },
56
+ "radius_m": {
57
+ "type": "integer",
58
+ "description": "搜尋半徑(公尺)",
59
+ "default": 500
60
+ },
61
+ "limit": {
62
+ "type": "integer",
63
+ "description": "返回結果數量",
64
+ "default": 5
65
+ }
66
+ }, required=[])
67
+
68
+ @classmethod
69
+ def get_output_schema(cls) -> Dict[str, Any]:
70
+ schema = StandardToolSchemas.create_output_schema()
71
+ schema["properties"].update({
72
+ "stations": {
73
+ "type": "array",
74
+ "items": {
75
+ "type": "object",
76
+ "properties": {
77
+ "station_name": {"type": "string"},
78
+ "available_bikes": {"type": "integer"},
79
+ "available_spaces": {"type": "integer"},
80
+ "distance_m": {"type": "integer"},
81
+ "bike_type": {"type": "string"}
82
+ }
83
+ }
84
+ }
85
+ })
86
+ return schema
87
+
88
+ @classmethod
89
+ async def execute(cls, arguments: Dict[str, Any], user_id: str = None) -> Dict[str, Any]:
90
+ station_name = arguments.get("station_name", "").strip()
91
+ city = arguments.get("city")
92
+ radius_m = min(int(arguments.get("radius_m", 500)), 2000)
93
+ limit = min(int(arguments.get("limit", 5)), 20)
94
+
95
+ # 1. 取得用戶位置
96
+ env_ctx = await get_user_env_current(user_id) if user_id else None
97
+ if not env_ctx or not env_ctx.get("success"):
98
+ if not station_name:
99
+ raise ExecutionError("無法取得您的位置,請提供站點名稱或開啟定位權限")
100
+ user_lat, user_lon, user_city = None, None, None
101
+ else:
102
+ ctx = env_ctx.get("context", {})
103
+ user_lat = ctx.get("lat")
104
+ user_lon = ctx.get("lon")
105
+ user_city = ctx.get("city", "")
106
+
107
+ # 2. 自動判斷城市
108
+ if not city:
109
+ city = cls._map_city_name(user_city) if user_city else "Taipei"
110
+
111
+ # 3. 查詢分支
112
+ if station_name:
113
+ result = await cls._query_station_availability(station_name, city)
114
+ else:
115
+ if not user_lat or not user_lon:
116
+ raise ExecutionError("查詢附近 YouBike 需要定位權限")
117
+ result = await cls._query_nearby_stations(user_lat, user_lon, city, radius_m, limit)
118
+
119
+ return result
120
+
121
+ @classmethod
122
+ async def _query_station_availability(cls, station_name: str, city: str) -> Dict[str, Any]:
123
+ """查詢特定站點即時資訊"""
124
+ # 1. 查詢站點基本資訊
125
+ station_endpoint = f"Bike/Station/City/{city}"
126
+ station_params = {
127
+ "$filter": f"contains(StationName/Zh_tw, '{station_name}')",
128
+ "$format": "JSON",
129
+ "$top": 5
130
+ }
131
+
132
+ stations = await TDXBaseAPI.call_api(station_endpoint, station_params, cache_ttl=1800)
133
+
134
+ if not stations:
135
+ raise ExecutionError(f"找不到站點「{station_name}」")
136
+
137
+ # 2. 取得完全匹配或第一個結果
138
+ target_station = None
139
+ for station in stations:
140
+ name = station.get("StationName", {}).get("Zh_tw", "")
141
+ if station_name in name:
142
+ target_station = station
143
+ break
144
+
145
+ if not target_station:
146
+ target_station = stations[0]
147
+
148
+ station_uid = target_station.get("StationUID")
149
+ full_station_name = target_station.get("StationName", {}).get("Zh_tw", station_name)
150
+
151
+ # 3. 查詢即時可用車輛數
152
+ avail_endpoint = f"Bike/Availability/City/{city}"
153
+ avail_params = {
154
+ "$filter": f"StationUID eq '{station_uid}'",
155
+ "$format": "JSON"
156
+ }
157
+
158
+ availability = await TDXBaseAPI.call_api(avail_endpoint, avail_params, cache_ttl=30)
159
+
160
+ if not availability or len(availability) == 0:
161
+ return cls.create_success_response(
162
+ content=f"🚲 {full_station_name} 目前無即時資訊",
163
+ data={"stations": []}
164
+ )
165
+
166
+ avail = availability[0]
167
+
168
+ result = {
169
+ "station_name": full_station_name,
170
+ "available_bikes": avail.get("AvailableRentBikes", 0),
171
+ "available_spaces": avail.get("AvailableReturnBikes", 0),
172
+ "service_status": avail.get("ServiceStatus", 1),
173
+ "update_time": avail.get("UpdateTime", ""),
174
+ "bike_type": "YouBike 2.0" if "2.0" in target_station.get("BikesCapacity", "") else "YouBike 1.0"
175
+ }
176
+
177
+ # 4. 格式化結果
178
+ status_map = {
179
+ 0: "停止營運",
180
+ 1: "正常營運",
181
+ 2: "暫停營運"
182
+ }
183
+ status = status_map.get(result["service_status"], "未知")
184
+
185
+ content = (
186
+ f"🚲 {result['station_name']}\n"
187
+ f"狀態: {status}\n"
188
+ f"可借: {result['available_bikes']} 輛\n"
189
+ f"可還: {result['available_spaces']} 位\n"
190
+ f"類型: {result['bike_type']}\n"
191
+ )
192
+
193
+ return cls.create_success_response(
194
+ content=content,
195
+ data={"station": result}
196
+ )
197
+
198
+ @classmethod
199
+ async def _query_nearby_stations(cls, lat: float, lon: float, city: str,
200
+ radius_m: int, limit: int) -> Dict[str, Any]:
201
+ """查詢附近站點"""
202
+ # 1. 查詢附近站點(使用空間過濾)
203
+ station_endpoint = f"Bike/Station/City/{city}"
204
+ station_params = {
205
+ "$spatialFilter": f"nearby({lat}, {lon}, {radius_m})",
206
+ "$format": "JSON",
207
+ "$top": limit * 2
208
+ }
209
+
210
+ stations = await TDXBaseAPI.call_api(station_endpoint, station_params, cache_ttl=1800)
211
+
212
+ if not stations:
213
+ return cls.create_success_response(
214
+ content=f"附近 {radius_m} 公尺內沒有 YouBike 站點",
215
+ data={"stations": []}
216
+ )
217
+
218
+ # 2. 計算距離並排序
219
+ for station in stations:
220
+ pos = station.get("StationPosition", {})
221
+ if pos.get("PositionLat") and pos.get("PositionLon"):
222
+ station["distance_m"] = TDXBaseAPI.haversine_distance(
223
+ lat, lon,
224
+ pos["PositionLat"], pos["PositionLon"]
225
+ )
226
+
227
+ stations = [s for s in stations if "distance_m" in s]
228
+ stations.sort(key=lambda x: x["distance_m"])
229
+ stations = stations[:limit]
230
+
231
+ # 3. 批次查詢即時資訊
232
+ station_uids = [s.get("StationUID") for s in stations]
233
+
234
+ avail_endpoint = f"Bike/Availability/City/{city}"
235
+ avail_params = {
236
+ "$filter": " or ".join([f"StationUID eq '{uid}'" for uid in station_uids]),
237
+ "$format": "JSON"
238
+ }
239
+
240
+ availability = await TDXBaseAPI.call_api(avail_endpoint, avail_params, cache_ttl=30)
241
+
242
+ # 建立 UID -> 可用性 映射
243
+ avail_map = {a.get("StationUID"): a for a in availability}
244
+
245
+ # 4. 組合結果
246
+ results = []
247
+ for station in stations:
248
+ station_uid = station.get("StationUID")
249
+ station_name = station.get("StationName", {}).get("Zh_tw", "未知")
250
+ distance = station["distance_m"]
251
+ walking_time = int(distance / 80)
252
+
253
+ avail = avail_map.get(station_uid, {})
254
+
255
+ results.append({
256
+ "station_name": station_name,
257
+ "available_bikes": avail.get("AvailableRentBikes", 0),
258
+ "available_spaces": avail.get("AvailableReturnBikes", 0),
259
+ "distance_m": int(distance),
260
+ "walking_time_min": walking_time,
261
+ "service_status": avail.get("ServiceStatus", 1),
262
+ "bike_type": "YouBike 2.0" if "2.0" in station.get("BikesCapacity", "") else "YouBike 1.0"
263
+ })
264
+
265
+ content = cls._format_nearby_result(results)
266
+
267
+ return cls.create_success_response(
268
+ content=content,
269
+ data={"stations": results}
270
+ )
271
+
272
+ @staticmethod
273
+ def _map_city_name(chinese_city: str) -> str:
274
+ """中文城市名稱轉 TDX 代碼"""
275
+ for key, value in TDXBikeTool.CITY_MAP.items():
276
+ if key in chinese_city:
277
+ return value
278
+ return "Taipei"
279
+
280
+ @staticmethod
281
+ def _format_nearby_result(stations: List[Dict]) -> str:
282
+ """格式化附近站點結果"""
283
+ if not stations:
284
+ return "附近沒有 YouBike 站點"
285
+
286
+ lines = ["📍 附近的 YouBike 站點:\n"]
287
+
288
+ for i, station in enumerate(stations, 1):
289
+ status_emoji = "✅" if station["service_status"] == 1 else "⚠️"
290
+ bikes = station["available_bikes"]
291
+ spaces = station["available_spaces"]
292
+
293
+ lines.append(
294
+ f"{i}. {status_emoji} {station['station_name']}\n"
295
+ f" 可借 {bikes} 輛 | 可還 {spaces} 位\n"
296
+ f" 步行 {station['walking_time_min']} 分鐘 ({station['distance_m']}m)\n"
297
+ )
298
+
299
+ return "\n".join(lines)
features/mcp/tools/weather_tool.py CHANGED
@@ -30,10 +30,15 @@ class WeatherTool(MCPTool):
30
  """天氣查詢 MCP 工具"""
31
 
32
  NAME = "weather_query"
33
- DESCRIPTION = "查詢指定城市的天氣資訊,支援城市名稱或座標查詢"
34
- CATEGORY = "天氣"
35
- TAGS = ["weather", "climate", "forecast"]
36
- USAGE_TIPS = ["直接說「台北天氣」或「東京天氣」"]
 
 
 
 
 
37
 
38
  @classmethod
39
  def get_input_schema(cls) -> Dict[str, Any]:
 
30
  """天氣查詢 MCP 工具"""
31
 
32
  NAME = "weather_query"
33
+ DESCRIPTION = "查詢指定城市的即時天氣資訊(溫度、濕度、天氣狀況等)"
34
+ CATEGORY = "生活資訊"
35
+ TAGS = ["weather", "天氣", "氣象"]
36
+ KEYWORDS = ["天氣", "氣溫", "下雨", "晴天", "陰天", "weather", "溫度"]
37
+ USAGE_TIPS = [
38
+ "提供城市名稱(英文)如 Taipei, Tokyo",
39
+ "支援經緯度查詢",
40
+ "可指定語言 (zh_tw, en, zh_cn)"
41
+ ]
42
 
43
  @classmethod
44
  def get_input_schema(cls) -> Dict[str, Any]: