Spaces:
Running
Running
Commit
·
e2e01e0
1
Parent(s):
e8439b4
Update MCP tools and add TDX tools
Browse files- features/mcp/agent_bridge.py +92 -27
- features/mcp/server.py +108 -1
- features/mcp/tools/base_tool.py +38 -2
- features/mcp/tools/directions_tool.py +2 -1
- features/mcp/tools/exchange_tool.py +9 -4
- features/mcp/tools/geocode_tool.py +2 -1
- features/mcp/tools/geocoding_tool.py +2 -1
- features/mcp/tools/healthkit_tool.py +7 -1
- features/mcp/tools/news_tool.py +9 -4
- features/mcp/tools/tdx_base.py +147 -0
- features/mcp/tools/tdx_bus_arrival.py +337 -0
- features/mcp/tools/tdx_metro.py +299 -0
- features/mcp/tools/tdx_parking.py +410 -0
- features/mcp/tools/tdx_thsr.py +402 -0
- features/mcp/tools/tdx_train.py +344 -0
- features/mcp/tools/tdx_youbike.py +299 -0
- features/mcp/tools/weather_tool.py +9 -4
features/mcp/agent_bridge.py
CHANGED
|
@@ -685,33 +685,98 @@ class MCPAgentBridge:
|
|
| 685 |
}
|
| 686 |
|
| 687 |
def _get_tools_description(self) -> str:
|
| 688 |
-
"""
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
| 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", "
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = "
|
| 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", "
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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", "
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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]:
|