Spaces:
Running
Running
Commit
·
e8439b4
1
Parent(s):
2473698
feat: 增強環境感知與導航功能
Browse files- 新增精確地理位置資訊(門牌號、路口、郵遞區號)
- 優化 reverse_geocode 工具,提取完整地址組件
- 新增 forward_geocode 工具,支援地點名稱查詢
- 增強意圖檢測,自動處理導航需求
- 優化環境上下文格式化,顯示詳細位置資訊
- 修復 directions 工具錯誤處理
- 提升前端地理位置精度設定
- 優化 voice-transcript-wrapper 樣式,縮小寬度並改善視覺效果
- 新增測試腳本驗證功能
- app.py +63 -0
- features/mcp/agent_bridge.py +154 -3
- features/mcp/tools/directions_tool.py +45 -2
- features/mcp/tools/geocode_tool.py +88 -12
- features/mcp/tools/geocoding_tool.py +245 -0
- services/ai_service.py +72 -26
- static/frontend/index.html +54 -28
- static/frontend/js/websocket.js +5 -1
- tests/features/mcp/test_agent_bridge_route_labels.py +67 -0
- tests/features/mcp/test_navigation_fix.py +81 -0
- tests/features/mcp/test_precise_location.py +146 -0
app.py
CHANGED
|
@@ -885,12 +885,60 @@ async def websocket_endpoint_with_jwt(websocket: WebSocket, token: str = Query(N
|
|
| 885 |
geo_res = await reverse_tool.handler({"lat": lat, "lon": lon})
|
| 886 |
if isinstance(geo_res, dict) and geo_res.get("success"):
|
| 887 |
payload = geo_res.get("data") or geo_res
|
|
|
|
| 888 |
city = payload.get("city") or city
|
| 889 |
admin = payload.get("admin") or admin
|
| 890 |
country_code = payload.get("country_code") or country_code
|
| 891 |
address_display = payload.get("label") or payload.get("display_name") or address_display
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 892 |
except Exception as ge:
|
| 893 |
logger.debug(f"反地理查詢失敗: {ge}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 894 |
|
| 895 |
env_payload = {
|
| 896 |
"lat": lat,
|
|
@@ -906,6 +954,21 @@ async def websocket_endpoint_with_jwt(websocket: WebSocket, token: str = Query(N
|
|
| 906 |
"admin": admin,
|
| 907 |
"country_code": country_code,
|
| 908 |
"address_display": address_display,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 909 |
}
|
| 910 |
|
| 911 |
# 更新會話暫存
|
|
|
|
| 885 |
geo_res = await reverse_tool.handler({"lat": lat, "lon": lon})
|
| 886 |
if isinstance(geo_res, dict) and geo_res.get("success"):
|
| 887 |
payload = geo_res.get("data") or geo_res
|
| 888 |
+
# 取得所有詳細欄位
|
| 889 |
city = payload.get("city") or city
|
| 890 |
admin = payload.get("admin") or admin
|
| 891 |
country_code = payload.get("country_code") or country_code
|
| 892 |
address_display = payload.get("label") or payload.get("display_name") or address_display
|
| 893 |
+
|
| 894 |
+
# 新增:精確地址資訊
|
| 895 |
+
detailed_address = payload.get("detailed_address")
|
| 896 |
+
label = payload.get("label")
|
| 897 |
+
road = payload.get("road")
|
| 898 |
+
house_number = payload.get("house_number")
|
| 899 |
+
suburb = payload.get("suburb")
|
| 900 |
+
city_district = payload.get("city_district")
|
| 901 |
+
postcode = payload.get("postcode")
|
| 902 |
+
amenity = payload.get("amenity")
|
| 903 |
+
shop = payload.get("shop")
|
| 904 |
+
building = payload.get("building")
|
| 905 |
+
office = payload.get("office")
|
| 906 |
+
leisure = payload.get("leisure")
|
| 907 |
+
tourism = payload.get("tourism")
|
| 908 |
+
name = payload.get("name")
|
| 909 |
except Exception as ge:
|
| 910 |
logger.debug(f"反地理查詢失敗: {ge}")
|
| 911 |
+
# 如果 reverse_geocode 失敗,保持原有變數為 None
|
| 912 |
+
detailed_address = None
|
| 913 |
+
label = None
|
| 914 |
+
road = None
|
| 915 |
+
house_number = None
|
| 916 |
+
suburb = None
|
| 917 |
+
city_district = None
|
| 918 |
+
postcode = None
|
| 919 |
+
amenity = None
|
| 920 |
+
shop = None
|
| 921 |
+
building = None
|
| 922 |
+
office = None
|
| 923 |
+
leisure = None
|
| 924 |
+
tourism = None
|
| 925 |
+
name = None
|
| 926 |
+
else:
|
| 927 |
+
# 如果沒有執行 reverse_geocode,初始化為 None
|
| 928 |
+
detailed_address = None
|
| 929 |
+
label = None
|
| 930 |
+
road = None
|
| 931 |
+
house_number = None
|
| 932 |
+
suburb = None
|
| 933 |
+
city_district = None
|
| 934 |
+
postcode = None
|
| 935 |
+
amenity = None
|
| 936 |
+
shop = None
|
| 937 |
+
building = None
|
| 938 |
+
office = None
|
| 939 |
+
leisure = None
|
| 940 |
+
tourism = None
|
| 941 |
+
name = None
|
| 942 |
|
| 943 |
env_payload = {
|
| 944 |
"lat": lat,
|
|
|
|
| 954 |
"admin": admin,
|
| 955 |
"country_code": country_code,
|
| 956 |
"address_display": address_display,
|
| 957 |
+
# 新增:精確地址欄位
|
| 958 |
+
"detailed_address": detailed_address,
|
| 959 |
+
"label": label,
|
| 960 |
+
"road": road,
|
| 961 |
+
"house_number": house_number,
|
| 962 |
+
"suburb": suburb,
|
| 963 |
+
"city_district": city_district,
|
| 964 |
+
"postcode": postcode,
|
| 965 |
+
"amenity": amenity,
|
| 966 |
+
"shop": shop,
|
| 967 |
+
"building": building,
|
| 968 |
+
"office": office,
|
| 969 |
+
"leisure": leisure,
|
| 970 |
+
"tourism": tourism,
|
| 971 |
+
"name": name,
|
| 972 |
}
|
| 973 |
|
| 974 |
# 更新會話暫存
|
features/mcp/agent_bridge.py
CHANGED
|
@@ -275,6 +275,74 @@ class MCPAgentBridge:
|
|
| 275 |
|
| 276 |
return polite_message, sanitized_tool_data
|
| 277 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
def get_current_time_data(self) -> Dict[str, Any]:
|
| 279 |
"""
|
| 280 |
獲取當前時間數據,用於生成個性化歡迎詞
|
|
@@ -393,6 +461,21 @@ class MCPAgentBridge:
|
|
| 393 |
* 參數:query(關鍵詞)、country(國家,預設 tw)、category(分類,預設 top)、language(語言,預設 zh)
|
| 394 |
* 今日新聞、科技新聞、台灣新聞都應該調用此工具
|
| 395 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
情緒判斷(emotion):
|
| 397 |
根據文字的語氣、用詞、標點符號判斷用戶情緒,選擇以下之一:
|
| 398 |
- neutral: 平靜、中性(預設)
|
|
@@ -722,8 +805,8 @@ class MCPAgentBridge:
|
|
| 722 |
async def _call_mcp_tool(self, tool_name: str, arguments: Dict[str, Any],
|
| 723 |
user_id: str = None, original_message: str = "") -> str:
|
| 724 |
"""
|
| 725 |
-
調用 MCP 工具(帶智慧重試機制 +
|
| 726 |
-
2025年最佳實踐:指數退避重試 + 錯誤分類 + AI 格式化
|
| 727 |
"""
|
| 728 |
if tool_name not in self.mcp_server.tools:
|
| 729 |
return self._generate_tool_not_found_error(tool_name)
|
|
@@ -732,6 +815,69 @@ class MCPAgentBridge:
|
|
| 732 |
if not tool.handler:
|
| 733 |
return f"⚠️ 工具 {tool_name} 尚未實作,請稍後再試"
|
| 734 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 735 |
arguments = await self._enrich_arguments_with_env(tool_name, arguments, user_id)
|
| 736 |
route_labels: Dict[str, str] = {}
|
| 737 |
if tool_name == "directions":
|
|
@@ -861,7 +1007,12 @@ class MCPAgentBridge:
|
|
| 861 |
except Exception as e:
|
| 862 |
error_msg = str(e)
|
| 863 |
error_lower = error_msg.lower()
|
| 864 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 865 |
# 判斷是否值得重試
|
| 866 |
is_retryable = any(keyword in error_lower for keyword in ["timeout", "network", "connection"])
|
| 867 |
|
|
|
|
| 275 |
|
| 276 |
return polite_message, sanitized_tool_data
|
| 277 |
|
| 278 |
+
@staticmethod
|
| 279 |
+
def _haversine_km(lat1: Optional[float], lon1: Optional[float], lat2: Optional[float], lon2: Optional[float]) -> Optional[float]:
|
| 280 |
+
"""計算兩點之間的近似球面距離(公里)。"""
|
| 281 |
+
try:
|
| 282 |
+
from math import radians, sin, cos, sqrt, atan2
|
| 283 |
+
|
| 284 |
+
if None in (lat1, lon1, lat2, lon2):
|
| 285 |
+
return None
|
| 286 |
+
|
| 287 |
+
rlat1, rlon1, rlat2, rlon2 = map(radians, [lat1, lon1, lat2, lon2])
|
| 288 |
+
dlat = rlat2 - rlat1
|
| 289 |
+
dlon = rlon2 - rlon1
|
| 290 |
+
a = sin(dlat / 2) ** 2 + cos(rlat1) * cos(rlat2) * sin(dlon / 2) ** 2
|
| 291 |
+
c = 2 * atan2(sqrt(a), sqrt(1 - a))
|
| 292 |
+
earth_radius_km = 6371.0
|
| 293 |
+
return earth_radius_km * c
|
| 294 |
+
except Exception:
|
| 295 |
+
return None
|
| 296 |
+
|
| 297 |
+
def _build_directions_failure_response(
|
| 298 |
+
self,
|
| 299 |
+
arguments: Dict[str, Any],
|
| 300 |
+
labels: Dict[str, str],
|
| 301 |
+
error_message: str,
|
| 302 |
+
) -> Dict[str, Any]:
|
| 303 |
+
"""建立 directions 工具失敗時的替代回傳內容。"""
|
| 304 |
+
origin_label = labels.get("origin_label") or arguments.get("origin_label") or "起點"
|
| 305 |
+
dest_label = labels.get("dest_label") or arguments.get("dest_label") or "目的地"
|
| 306 |
+
|
| 307 |
+
o_lat = arguments.get("origin_lat")
|
| 308 |
+
o_lon = arguments.get("origin_lon")
|
| 309 |
+
d_lat = arguments.get("dest_lat")
|
| 310 |
+
d_lon = arguments.get("dest_lon")
|
| 311 |
+
|
| 312 |
+
distance_km = self._haversine_km(o_lat, o_lon, d_lat, d_lon)
|
| 313 |
+
distance_m = distance_km * 1000 if distance_km is not None else None
|
| 314 |
+
distance_str = self._format_distance(distance_m)
|
| 315 |
+
|
| 316 |
+
# 推估行駛時間:假設平均速率 35km/h
|
| 317 |
+
duration_seconds = None
|
| 318 |
+
if distance_km is not None:
|
| 319 |
+
duration_minutes = max(5, int(round((distance_km / 35) * 60)))
|
| 320 |
+
duration_seconds = duration_minutes * 60
|
| 321 |
+
|
| 322 |
+
duration_str = self._format_duration(duration_seconds)
|
| 323 |
+
|
| 324 |
+
message = (
|
| 325 |
+
f"目前無法向路線服務取得詳細路線,但從 {origin_label} 前往 {dest_label} 直線距離約 {distance_str},"
|
| 326 |
+
f"若以車輛移動約需 {duration_str}。建議在 Google 地圖或 Apple 地圖輸入上述地點,以獲得即時的轉乘與路況。"
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
fallback_payload = {
|
| 330 |
+
"fallback": True,
|
| 331 |
+
"origin_label": origin_label,
|
| 332 |
+
"dest_label": dest_label,
|
| 333 |
+
"distance_estimated_m": distance_m,
|
| 334 |
+
"distance_readable": distance_str,
|
| 335 |
+
"duration_estimated_s": duration_seconds,
|
| 336 |
+
"duration_readable": duration_str,
|
| 337 |
+
"error": error_message,
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
return {
|
| 341 |
+
"message": message,
|
| 342 |
+
"tool_name": "directions",
|
| 343 |
+
"tool_data": fallback_payload,
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
def get_current_time_data(self) -> Dict[str, Any]:
|
| 347 |
"""
|
| 348 |
獲取當前時間數據,用於生成個性化歡迎詞
|
|
|
|
| 461 |
* 參數:query(關鍵詞)、country(國家,預設 tw)、category(分類,預設 top)、language(語言,預設 zh)
|
| 462 |
* 今日新聞、科技新聞、台灣新聞都應該調用此工具
|
| 463 |
|
| 464 |
+
- 地點查詢與導航(重要!):
|
| 465 |
+
* **導航需求判斷**:
|
| 466 |
+
- 問「怎麼去 X」「如何去 X」「去 X 怎麼走」「到 X 怎麼走」→ 使用 forward_geocode 查詢目的地座標
|
| 467 |
+
- 問「從 A 到 B 要多久」「A 到 B 怎麼走」→ 同時使用 forward_geocode 查詢起點與終點
|
| 468 |
+
* **不要猜測座標**:
|
| 469 |
+
- ❌ 錯誤:directions:origin_lat=25.1288,origin_lon=121.9234,dest_lat=24.9932,dest_lon=121.3261
|
| 470 |
+
- ✅ 正確:forward_geocode:query=銘傳大學桃園校區
|
| 471 |
+
* **工具使用順序**:
|
| 472 |
+
1. 先使用 forward_geocode 將地點名稱轉換為座標
|
| 473 |
+
2. 再使用 directions 規劃路線(系統會自動處理)
|
| 474 |
+
* **範例**:
|
| 475 |
+
- 「怎麼去桃園火車站」→ forward_geocode:query=桃園火車站
|
| 476 |
+
- 「從銘傳大學到桃園火車站」→ forward_geocode:query=銘傳大學桃園校區
|
| 477 |
+
- 「台北車站到淡水捷運站」→ forward_geocode:query=台北車站
|
| 478 |
+
|
| 479 |
情緒判斷(emotion):
|
| 480 |
根據文字的語氣、用詞、標點符號判斷用戶情緒,選擇以下之一:
|
| 481 |
- neutral: 平靜、中性(預設)
|
|
|
|
| 805 |
async def _call_mcp_tool(self, tool_name: str, arguments: Dict[str, Any],
|
| 806 |
user_id: str = None, original_message: str = "") -> str:
|
| 807 |
"""
|
| 808 |
+
調用 MCP 工具(帶智慧重試機制 + 統一格式化 + 智能地點查詢)
|
| 809 |
+
2025年最佳實踐:指數退避重試 + 錯誤分類 + AI 格式化 + 自動 geocoding
|
| 810 |
"""
|
| 811 |
if tool_name not in self.mcp_server.tools:
|
| 812 |
return self._generate_tool_not_found_error(tool_name)
|
|
|
|
| 815 |
if not tool.handler:
|
| 816 |
return f"⚠️ 工具 {tool_name} 尚未實作,請稍後再試"
|
| 817 |
|
| 818 |
+
# 智能地點查詢:如果是 forward_geocode,且用戶有位置導航需求,自動串接 directions
|
| 819 |
+
is_navigation_intent = False
|
| 820 |
+
geocode_result = None
|
| 821 |
+
|
| 822 |
+
if tool_name == "forward_geocode":
|
| 823 |
+
# 判斷是否為導航意圖(「怎麼去」「如何去」「到 X」)
|
| 824 |
+
nav_keywords = ["怎麼去", "如何去", "怎麼走", "到哪", "去哪", "要多久", "多遠"]
|
| 825 |
+
is_navigation_intent = any(keyword in original_message for keyword in nav_keywords)
|
| 826 |
+
|
| 827 |
+
if is_navigation_intent:
|
| 828 |
+
logger.info(f"🗺️ 檢測到導航意圖,先執行地點查詢: {arguments.get('query')}")
|
| 829 |
+
|
| 830 |
+
# 執行 geocoding
|
| 831 |
+
geocode_tool = self.mcp_server.tools.get("forward_geocode")
|
| 832 |
+
if geocode_tool and geocode_tool.handler:
|
| 833 |
+
try:
|
| 834 |
+
geocode_result = await asyncio.wait_for(
|
| 835 |
+
geocode_tool.handler(arguments),
|
| 836 |
+
timeout=15.0
|
| 837 |
+
)
|
| 838 |
+
|
| 839 |
+
if geocode_result.get("success"):
|
| 840 |
+
best_match = geocode_result.get("data", {}).get("best_match", {})
|
| 841 |
+
dest_lat = best_match.get("lat")
|
| 842 |
+
dest_lon = best_match.get("lon")
|
| 843 |
+
dest_label = best_match.get("label", arguments.get("query"))
|
| 844 |
+
|
| 845 |
+
# 取得用戶當前位置
|
| 846 |
+
env_ctx = await self._fetch_env_context(user_id)
|
| 847 |
+
origin_lat = env_ctx.get("lat")
|
| 848 |
+
origin_lon = env_ctx.get("lon")
|
| 849 |
+
origin_label = env_ctx.get("label") or env_ctx.get("address_display") or "您的位置"
|
| 850 |
+
|
| 851 |
+
if origin_lat and origin_lon and dest_lat and dest_lon:
|
| 852 |
+
logger.info(f"🚗 自動串接導航: {origin_label} → {dest_label}")
|
| 853 |
+
|
| 854 |
+
# 自動調用 directions
|
| 855 |
+
directions_tool = self.mcp_server.tools.get("directions")
|
| 856 |
+
if directions_tool and directions_tool.handler:
|
| 857 |
+
directions_args = {
|
| 858 |
+
"origin_lat": float(origin_lat),
|
| 859 |
+
"origin_lon": float(origin_lon),
|
| 860 |
+
"dest_lat": float(dest_lat),
|
| 861 |
+
"dest_lon": float(dest_lon),
|
| 862 |
+
"origin_label": origin_label,
|
| 863 |
+
"dest_label": dest_label,
|
| 864 |
+
"mode": "foot-walking" # 預設步行
|
| 865 |
+
}
|
| 866 |
+
|
| 867 |
+
# 遞迴調用 directions(會走下面的正常流程)
|
| 868 |
+
return await self._call_mcp_tool(
|
| 869 |
+
"directions",
|
| 870 |
+
directions_args,
|
| 871 |
+
user_id,
|
| 872 |
+
original_message
|
| 873 |
+
)
|
| 874 |
+
else:
|
| 875 |
+
logger.warning("⚠️ 無法取得完整位置資訊,返回地點查詢結果")
|
| 876 |
+
else:
|
| 877 |
+
logger.warning(f"⚠️ 地點查詢失敗: {geocode_result.get('error')}")
|
| 878 |
+
except Exception as e:
|
| 879 |
+
logger.error(f"❌ 自動地點查詢失敗: {e}", exc_info=True)
|
| 880 |
+
|
| 881 |
arguments = await self._enrich_arguments_with_env(tool_name, arguments, user_id)
|
| 882 |
route_labels: Dict[str, str] = {}
|
| 883 |
if tool_name == "directions":
|
|
|
|
| 1007 |
except Exception as e:
|
| 1008 |
error_msg = str(e)
|
| 1009 |
error_lower = error_msg.lower()
|
| 1010 |
+
|
| 1011 |
+
if tool_name == "directions":
|
| 1012 |
+
logger.error(f"❌ directions 工具失敗,啟用替代回覆: {error_msg}")
|
| 1013 |
+
fallback_result = self._build_directions_failure_response(arguments, route_labels, error_msg)
|
| 1014 |
+
return fallback_result
|
| 1015 |
+
|
| 1016 |
# 判斷是否值得重試
|
| 1017 |
is_retryable = any(keyword in error_lower for keyword in ["timeout", "network", "connection"])
|
| 1018 |
|
features/mcp/tools/directions_tool.py
CHANGED
|
@@ -161,22 +161,65 @@ class DirectionsTool(MCPTool):
|
|
| 161 |
async with aiohttp.ClientSession() as session:
|
| 162 |
async with session.post(url, headers=headers, data=json.dumps(body), timeout=15) as resp:
|
| 163 |
if resp.status != 200:
|
| 164 |
-
|
|
|
|
| 165 |
data = await resp.json()
|
| 166 |
|
| 167 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
feat = data["features"][0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
summary = feat["properties"]["summary"]
|
| 170 |
distance_m = float(summary["distance"]) # meters
|
| 171 |
duration_s = float(summary["duration"]) # seconds
|
| 172 |
polyline = feat["geometry"]["coordinates"] # LineString 座標
|
|
|
|
| 173 |
base_payload = {
|
| 174 |
"distance_m": distance_m,
|
| 175 |
"duration_s": duration_s,
|
| 176 |
"polyline": json.dumps(polyline),
|
| 177 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
except Exception as e:
|
| 179 |
-
|
|
|
|
|
|
|
| 180 |
|
| 181 |
# 回寫快取
|
| 182 |
await db_cache.set_route_cache(key, base_payload)
|
|
|
|
| 161 |
async with aiohttp.ClientSession() as session:
|
| 162 |
async with session.post(url, headers=headers, data=json.dumps(body), timeout=15) as resp:
|
| 163 |
if resp.status != 200:
|
| 164 |
+
text = await resp.text()
|
| 165 |
+
raise ExecutionError(f"OpenRouteService HTTP {resp.status}: {text[:200]}")
|
| 166 |
data = await resp.json()
|
| 167 |
|
| 168 |
try:
|
| 169 |
+
# 檢查 API 回應是否包含錯誤訊息
|
| 170 |
+
if not isinstance(data, dict):
|
| 171 |
+
raise ExecutionError(f"OpenRouteService 回應格式錯誤: 非字典類型")
|
| 172 |
+
|
| 173 |
+
# 優先處理 error 欄位(API 標準錯誤格式)
|
| 174 |
+
if "error" in data:
|
| 175 |
+
error_payload = data["error"]
|
| 176 |
+
if isinstance(error_payload, dict):
|
| 177 |
+
code = error_payload.get("code", "unknown")
|
| 178 |
+
message = error_payload.get("message", "未知錯誤")
|
| 179 |
+
raise ExecutionError(f"路線規劃失敗: {message} (錯誤碼: {code})")
|
| 180 |
+
else:
|
| 181 |
+
raise ExecutionError(f"路線規劃失敗: {error_payload}")
|
| 182 |
+
|
| 183 |
+
# 檢查是否有 features(標準成功格式)
|
| 184 |
+
if "features" not in data or not data["features"]:
|
| 185 |
+
# 提供友善的錯誤訊息
|
| 186 |
+
if o_lat == d_lat and o_lon == d_lon:
|
| 187 |
+
raise ExecutionError("起點與終點相同,無需導航")
|
| 188 |
+
else:
|
| 189 |
+
raise ExecutionError("找不到可行的路線,請確認起點與終點是否在可達範圍內")
|
| 190 |
+
|
| 191 |
feat = data["features"][0]
|
| 192 |
+
|
| 193 |
+
# 檢查必要欄位
|
| 194 |
+
if "properties" not in feat or "summary" not in feat["properties"]:
|
| 195 |
+
raise ExecutionError("路線資料缺少必要欄位(summary)")
|
| 196 |
+
|
| 197 |
+
if "geometry" not in feat or "coordinates" not in feat["geometry"]:
|
| 198 |
+
raise ExecutionError("路線資料缺少必要欄位(geometry)")
|
| 199 |
+
|
| 200 |
summary = feat["properties"]["summary"]
|
| 201 |
distance_m = float(summary["distance"]) # meters
|
| 202 |
duration_s = float(summary["duration"]) # seconds
|
| 203 |
polyline = feat["geometry"]["coordinates"] # LineString 座標
|
| 204 |
+
|
| 205 |
base_payload = {
|
| 206 |
"distance_m": distance_m,
|
| 207 |
"duration_s": duration_s,
|
| 208 |
"polyline": json.dumps(polyline),
|
| 209 |
}
|
| 210 |
+
except ExecutionError:
|
| 211 |
+
# 重新拋出已知錯誤
|
| 212 |
+
raise
|
| 213 |
+
except KeyError as e:
|
| 214 |
+
# 捕捉欄位缺失錯誤,提供更友善的訊息
|
| 215 |
+
raise ExecutionError(f"路線資料格式錯誤: 缺少欄位 {e}")
|
| 216 |
+
except (ValueError, TypeError) as e:
|
| 217 |
+
# 捕捉資料型別轉換錯誤
|
| 218 |
+
raise ExecutionError(f"路線資料解析失敗: {e}")
|
| 219 |
except Exception as e:
|
| 220 |
+
# 捕捉所有其他未預期錯誤
|
| 221 |
+
logger.error(f"❌ ORS 回應解析異常: {e}", exc_info=True)
|
| 222 |
+
raise ExecutionError(f"路線資料處理失敗: {type(e).__name__}: {e}")
|
| 223 |
|
| 224 |
# 回寫快取
|
| 225 |
await db_cache.set_route_cache(key, base_payload)
|
features/mcp/tools/geocode_tool.py
CHANGED
|
@@ -76,8 +76,10 @@ class ReverseGeocodeTool(MCPTool):
|
|
| 76 |
"format": "jsonv2",
|
| 77 |
"lat": lat,
|
| 78 |
"lon": lon,
|
| 79 |
-
"zoom":
|
| 80 |
-
"addressdetails": 1
|
|
|
|
|
|
|
| 81 |
}
|
| 82 |
headers = {
|
| 83 |
"User-Agent": "BloomWare/1.0 (contact@example.com)"
|
|
@@ -88,35 +90,109 @@ class ReverseGeocodeTool(MCPTool):
|
|
| 88 |
if resp.status != 200:
|
| 89 |
raise ExecutionError(f"Nominatim 失敗: {resp.status}")
|
| 90 |
data = await resp.json()
|
|
|
|
| 91 |
addr = data.get("address", {})
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
admin = addr.get("state") or addr.get("county") or ""
|
| 94 |
country_code = (addr.get("country_code") or "").upper()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
display_name = data.get("display_name") or ""
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
suburb = addr.get("suburb") or addr.get("neighbourhood") or ""
|
| 99 |
label_parts = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
if road and house_number:
|
| 101 |
-
label_parts.append(f"{road}{house_number}")
|
| 102 |
elif road:
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
label_parts.append(suburb)
|
| 106 |
-
|
|
|
|
|
|
|
| 107 |
label_parts.append(city)
|
| 108 |
-
|
|
|
|
|
|
|
| 109 |
label_parts.append(admin)
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
payload = {
|
| 112 |
"city": city or "",
|
| 113 |
"admin": admin or "",
|
| 114 |
"country_code": country_code,
|
| 115 |
"display_name": display_name,
|
| 116 |
"label": label or display_name,
|
|
|
|
| 117 |
"road": road,
|
| 118 |
"house_number": house_number,
|
| 119 |
"suburb": suburb,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
}
|
| 121 |
|
| 122 |
# 回寫快取
|
|
|
|
| 76 |
"format": "jsonv2",
|
| 77 |
"lat": lat,
|
| 78 |
"lon": lon,
|
| 79 |
+
"zoom": 18, # 提高 zoom 等級(18 = 建築物級別,可取得門牌號)
|
| 80 |
+
"addressdetails": 1,
|
| 81 |
+
"extratags": 1, # 取得額外標籤(商店名稱、建築物類型等)
|
| 82 |
+
"namedetails": 1 # 取得多語言名稱
|
| 83 |
}
|
| 84 |
headers = {
|
| 85 |
"User-Agent": "BloomWare/1.0 (contact@example.com)"
|
|
|
|
| 90 |
if resp.status != 200:
|
| 91 |
raise ExecutionError(f"Nominatim 失敗: {resp.status}")
|
| 92 |
data = await resp.json()
|
| 93 |
+
|
| 94 |
addr = data.get("address", {})
|
| 95 |
+
extratags = data.get("extratags", {})
|
| 96 |
+
|
| 97 |
+
# 基本地址組件
|
| 98 |
+
road = addr.get("road") or addr.get("pedestrian") or addr.get("footway") or addr.get("cycleway") or ""
|
| 99 |
+
house_number = addr.get("house_number") or ""
|
| 100 |
+
suburb = addr.get("suburb") or addr.get("neighbourhood") or addr.get("quarter") or ""
|
| 101 |
+
city_district = addr.get("city_district") or ""
|
| 102 |
+
city = addr.get("city") or addr.get("town") or addr.get("village") or addr.get("county") or ""
|
| 103 |
admin = addr.get("state") or addr.get("county") or ""
|
| 104 |
country_code = (addr.get("country_code") or "").upper()
|
| 105 |
+
postcode = addr.get("postcode") or ""
|
| 106 |
+
|
| 107 |
+
# POI 資訊(商店、建築物、設施等)
|
| 108 |
+
amenity = addr.get("amenity") or extratags.get("amenity") or ""
|
| 109 |
+
shop = addr.get("shop") or extratags.get("shop") or ""
|
| 110 |
+
building = addr.get("building") or extratags.get("building") or ""
|
| 111 |
+
office = addr.get("office") or extratags.get("office") or ""
|
| 112 |
+
leisure = addr.get("leisure") or extratags.get("leisure") or ""
|
| 113 |
+
tourism = addr.get("tourism") or extratags.get("tourism") or ""
|
| 114 |
+
|
| 115 |
+
# 地點名稱(優先使用繁中)
|
| 116 |
+
name = data.get("name") or ""
|
| 117 |
+
namedetails = data.get("namedetails", {})
|
| 118 |
+
name_zh = namedetails.get("name:zh") or namedetails.get("name:zh-TW") or name
|
| 119 |
+
|
| 120 |
display_name = data.get("display_name") or ""
|
| 121 |
+
|
| 122 |
+
# 組裝精確標籤(優先顯示最精確的資訊)
|
|
|
|
| 123 |
label_parts = []
|
| 124 |
+
|
| 125 |
+
# 1. POI 名稱(如「7-11 明倫門市」「台北101」)
|
| 126 |
+
if name_zh and name_zh != road:
|
| 127 |
+
label_parts.append(name_zh)
|
| 128 |
+
|
| 129 |
+
# 2. 門牌號碼 + 路名(如「中正路123號」)
|
| 130 |
if road and house_number:
|
| 131 |
+
label_parts.append(f"{road}{house_number}號")
|
| 132 |
elif road:
|
| 133 |
+
# 如果沒有門牌,但有路口資訊
|
| 134 |
+
if "路口" in road or "交叉口" in road or "intersection" in road.lower():
|
| 135 |
+
label_parts.append(road)
|
| 136 |
+
else:
|
| 137 |
+
# 嘗試從附近找路口
|
| 138 |
+
label_parts.append(road)
|
| 139 |
+
|
| 140 |
+
# 3. 郵遞區號(如「100」)
|
| 141 |
+
if postcode and len(label_parts) > 0:
|
| 142 |
+
label_parts[0] = f"〒{postcode} {label_parts[0]}"
|
| 143 |
+
|
| 144 |
+
# 4. 區域(如「大安區」)
|
| 145 |
+
if city_district and city_district not in label_parts:
|
| 146 |
+
label_parts.append(city_district)
|
| 147 |
+
elif suburb and suburb not in label_parts:
|
| 148 |
label_parts.append(suburb)
|
| 149 |
+
|
| 150 |
+
# 5. 城市(如「台北市」)
|
| 151 |
+
if city and city not in label_parts:
|
| 152 |
label_parts.append(city)
|
| 153 |
+
|
| 154 |
+
# 6. 省份/州(如「台灣」)
|
| 155 |
+
if admin and admin not in city and admin not in label_parts:
|
| 156 |
label_parts.append(admin)
|
| 157 |
+
|
| 158 |
+
label = ", ".join(filter(None, label_parts))
|
| 159 |
+
|
| 160 |
+
# 組裝詳細地址(用於 AI 顯示)
|
| 161 |
+
detailed_address_parts = []
|
| 162 |
+
if name_zh:
|
| 163 |
+
detailed_address_parts.append(f"地點: {name_zh}")
|
| 164 |
+
if road and house_number:
|
| 165 |
+
detailed_address_parts.append(f"地址: {road}{house_number}號")
|
| 166 |
+
elif road:
|
| 167 |
+
detailed_address_parts.append(f"路段: {road}")
|
| 168 |
+
if suburb:
|
| 169 |
+
detailed_address_parts.append(f"區域: {suburb}")
|
| 170 |
+
if city:
|
| 171 |
+
detailed_address_parts.append(f"城市: {city}")
|
| 172 |
+
if postcode:
|
| 173 |
+
detailed_address_parts.append(f"郵遞區號: {postcode}")
|
| 174 |
+
|
| 175 |
+
detailed_address = " | ".join(detailed_address_parts) if detailed_address_parts else label
|
| 176 |
+
|
| 177 |
payload = {
|
| 178 |
"city": city or "",
|
| 179 |
"admin": admin or "",
|
| 180 |
"country_code": country_code,
|
| 181 |
"display_name": display_name,
|
| 182 |
"label": label or display_name,
|
| 183 |
+
"detailed_address": detailed_address,
|
| 184 |
"road": road,
|
| 185 |
"house_number": house_number,
|
| 186 |
"suburb": suburb,
|
| 187 |
+
"city_district": city_district,
|
| 188 |
+
"postcode": postcode,
|
| 189 |
+
"amenity": amenity,
|
| 190 |
+
"shop": shop,
|
| 191 |
+
"building": building,
|
| 192 |
+
"office": office,
|
| 193 |
+
"leisure": leisure,
|
| 194 |
+
"tourism": tourism,
|
| 195 |
+
"name": name_zh or name,
|
| 196 |
}
|
| 197 |
|
| 198 |
# 回寫快取
|
features/mcp/tools/geocoding_tool.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
地點名稱轉座標工具(Forward Geocoding)
|
| 3 |
+
使用 Nominatim(OSM)將地點名稱轉換為經緯度座標
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import aiohttp
|
| 7 |
+
import asyncio
|
| 8 |
+
import logging
|
| 9 |
+
from typing import Dict, Any, List
|
| 10 |
+
|
| 11 |
+
from .base_tool import MCPTool, StandardToolSchemas, ExecutionError
|
| 12 |
+
from core.database import get_geo_cache, set_geo_cache
|
| 13 |
+
from core.database.cache import db_cache
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger("mcp.tools.geocoding")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class ForwardGeocodeTool(MCPTool):
|
| 19 |
+
NAME = "forward_geocode"
|
| 20 |
+
DESCRIPTION = "將地點名稱(如「銘傳大學」「桃園火車站」)轉換為經緯度座標"
|
| 21 |
+
CATEGORY = "地理"
|
| 22 |
+
TAGS = ["geocode", "forward", "地點", "座標"]
|
| 23 |
+
USAGE_TIPS = [
|
| 24 |
+
"提供地點名稱即可(如「台北101」「淡水捷運站」)",
|
| 25 |
+
"支援地標、車站、學校、商圈等",
|
| 26 |
+
"會返回最相關的座標與詳細地址"
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
@classmethod
|
| 30 |
+
def get_input_schema(cls) -> Dict[str, Any]:
|
| 31 |
+
return StandardToolSchemas.create_input_schema({
|
| 32 |
+
"query": {
|
| 33 |
+
"type": "string",
|
| 34 |
+
"description": "地點名稱或地址(如「銘傳大學桃園校區」「桃園火車站」「台北101」)"
|
| 35 |
+
},
|
| 36 |
+
"limit": {
|
| 37 |
+
"type": "integer",
|
| 38 |
+
"description": "返回結果數量(預設 1,最多 5)",
|
| 39 |
+
"default": 1
|
| 40 |
+
}
|
| 41 |
+
}, required=["query"])
|
| 42 |
+
|
| 43 |
+
@classmethod
|
| 44 |
+
def get_output_schema(cls) -> Dict[str, Any]:
|
| 45 |
+
schema = StandardToolSchemas.create_output_schema()
|
| 46 |
+
schema["properties"].update({
|
| 47 |
+
"results": {
|
| 48 |
+
"type": "array",
|
| 49 |
+
"description": "地點查詢結果列表",
|
| 50 |
+
"items": {
|
| 51 |
+
"type": "object",
|
| 52 |
+
"properties": {
|
| 53 |
+
"lat": {"type": "number", "description": "緯度"},
|
| 54 |
+
"lon": {"type": "number", "description": "經度"},
|
| 55 |
+
"display_name": {"type": "string", "description": "完整地址"},
|
| 56 |
+
"label": {"type": "string", "description": "簡短標籤"},
|
| 57 |
+
"importance": {"type": "number", "description": "重要性評分(0-1)"}
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
},
|
| 61 |
+
"best_match": {
|
| 62 |
+
"type": "object",
|
| 63 |
+
"description": "最佳匹配結果",
|
| 64 |
+
"properties": {
|
| 65 |
+
"lat": {"type": "number"},
|
| 66 |
+
"lon": {"type": "number"},
|
| 67 |
+
"label": {"type": "string"}
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
})
|
| 71 |
+
return schema
|
| 72 |
+
|
| 73 |
+
@classmethod
|
| 74 |
+
async def execute(cls, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
| 75 |
+
query = arguments.get("query", "").strip()
|
| 76 |
+
if not query:
|
| 77 |
+
raise ExecutionError("請提供地點名稱")
|
| 78 |
+
|
| 79 |
+
limit = min(int(arguments.get("limit", 1)), 5)
|
| 80 |
+
|
| 81 |
+
# 生成快取鍵(基於查詢文字)
|
| 82 |
+
import hashlib
|
| 83 |
+
cache_key = hashlib.md5(f"geocode:{query}".encode()).hexdigest()
|
| 84 |
+
|
| 85 |
+
# 記憶體快取
|
| 86 |
+
cached = await db_cache.get_geo_cached(cache_key)
|
| 87 |
+
if cached:
|
| 88 |
+
logger.info(f"📍 Geocoding 快取命中: {query}")
|
| 89 |
+
return cls.create_success_response(
|
| 90 |
+
content=f"找到地點:{cached['best_match']['label']}",
|
| 91 |
+
data=cached
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
# DB 快取
|
| 95 |
+
db_cached = await get_geo_cache(cache_key)
|
| 96 |
+
if db_cached:
|
| 97 |
+
await db_cache.set_geo_cache(cache_key, db_cached)
|
| 98 |
+
return cls.create_success_response(
|
| 99 |
+
content=f"找到地點:{db_cached['best_match']['label']}",
|
| 100 |
+
data=db_cached
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
# 外呼 Nominatim(公共端點,務必節流)
|
| 104 |
+
url = "https://nominatim.openstreetmap.org/search"
|
| 105 |
+
params = {
|
| 106 |
+
"format": "jsonv2",
|
| 107 |
+
"q": query,
|
| 108 |
+
"limit": limit,
|
| 109 |
+
"addressdetails": 1,
|
| 110 |
+
"extratags": 1, # 取得額外標籤
|
| 111 |
+
"namedetails": 1, # 取得多語言名稱
|
| 112 |
+
"accept-language": "zh-TW,zh"
|
| 113 |
+
}
|
| 114 |
+
headers = {
|
| 115 |
+
"User-Agent": "BloomWare/1.0 (contact@example.com)"
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
try:
|
| 119 |
+
async with aiohttp.ClientSession(headers=headers) as session:
|
| 120 |
+
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
| 121 |
+
if resp.status != 200:
|
| 122 |
+
raise ExecutionError(f"Nominatim 查詢失敗: HTTP {resp.status}")
|
| 123 |
+
|
| 124 |
+
data = await resp.json()
|
| 125 |
+
|
| 126 |
+
if not data or len(data) == 0:
|
| 127 |
+
raise ExecutionError(f"找不到地點「{query}」,請確認地點名稱是否正確")
|
| 128 |
+
|
| 129 |
+
except asyncio.TimeoutError:
|
| 130 |
+
raise ExecutionError("地點查詢逾時,請稍後再試")
|
| 131 |
+
except aiohttp.ClientError as e:
|
| 132 |
+
raise ExecutionError(f"網路連接錯誤: {str(e)}")
|
| 133 |
+
|
| 134 |
+
# 解析結果
|
| 135 |
+
results = []
|
| 136 |
+
for item in data:
|
| 137 |
+
lat = float(item.get("lat", 0))
|
| 138 |
+
lon = float(item.get("lon", 0))
|
| 139 |
+
display_name = item.get("display_name", "")
|
| 140 |
+
importance = float(item.get("importance", 0))
|
| 141 |
+
|
| 142 |
+
# 解析地址組件
|
| 143 |
+
addr = item.get("address", {})
|
| 144 |
+
extratags = item.get("extratags", {})
|
| 145 |
+
namedetails = item.get("namedetails", {})
|
| 146 |
+
|
| 147 |
+
name = item.get("name", "")
|
| 148 |
+
name_zh = namedetails.get("name:zh") or namedetails.get("name:zh-TW") or name
|
| 149 |
+
|
| 150 |
+
# 基本地址組件
|
| 151 |
+
road = addr.get("road") or addr.get("pedestrian") or addr.get("footway") or ""
|
| 152 |
+
house_number = addr.get("house_number") or ""
|
| 153 |
+
suburb = addr.get("suburb") or addr.get("neighbourhood") or ""
|
| 154 |
+
city_district = addr.get("city_district") or ""
|
| 155 |
+
city = addr.get("city") or addr.get("town") or addr.get("village") or addr.get("county") or ""
|
| 156 |
+
admin = addr.get("state") or addr.get("county") or ""
|
| 157 |
+
postcode = addr.get("postcode") or ""
|
| 158 |
+
|
| 159 |
+
# POI 資訊
|
| 160 |
+
amenity = addr.get("amenity") or extratags.get("amenity") or ""
|
| 161 |
+
shop = addr.get("shop") or extratags.get("shop") or ""
|
| 162 |
+
building = addr.get("building") or extratags.get("building") or ""
|
| 163 |
+
|
| 164 |
+
# 組裝簡短標籤
|
| 165 |
+
label_parts = []
|
| 166 |
+
if name_zh and name_zh != road:
|
| 167 |
+
label_parts.append(name_zh)
|
| 168 |
+
|
| 169 |
+
if road and house_number:
|
| 170 |
+
label_parts.append(f"{road}{house_number}號")
|
| 171 |
+
elif road:
|
| 172 |
+
label_parts.append(road)
|
| 173 |
+
|
| 174 |
+
if city_district and city_district not in str(label_parts):
|
| 175 |
+
label_parts.append(city_district)
|
| 176 |
+
elif suburb and suburb not in str(label_parts):
|
| 177 |
+
label_parts.append(suburb)
|
| 178 |
+
|
| 179 |
+
# 添加城市/區域資訊
|
| 180 |
+
if city and city not in str(label_parts):
|
| 181 |
+
label_parts.append(city)
|
| 182 |
+
|
| 183 |
+
label = ", ".join(filter(None, label_parts)) if label_parts else display_name
|
| 184 |
+
|
| 185 |
+
# 組裝詳細地址
|
| 186 |
+
detailed_parts = []
|
| 187 |
+
if name_zh:
|
| 188 |
+
detailed_parts.append(f"地點: {name_zh}")
|
| 189 |
+
if road and house_number:
|
| 190 |
+
detailed_parts.append(f"地址: {road}{house_number}號")
|
| 191 |
+
elif road:
|
| 192 |
+
detailed_parts.append(f"路段: {road}")
|
| 193 |
+
if suburb:
|
| 194 |
+
detailed_parts.append(f"區域: {suburb}")
|
| 195 |
+
if city:
|
| 196 |
+
detailed_parts.append(f"城市: {city}")
|
| 197 |
+
if postcode:
|
| 198 |
+
detailed_parts.append(f"郵遞區號: {postcode}")
|
| 199 |
+
|
| 200 |
+
detailed_address = " | ".join(detailed_parts) if detailed_parts else label
|
| 201 |
+
|
| 202 |
+
results.append({
|
| 203 |
+
"lat": lat,
|
| 204 |
+
"lon": lon,
|
| 205 |
+
"display_name": display_name,
|
| 206 |
+
"label": label,
|
| 207 |
+
"detailed_address": detailed_address,
|
| 208 |
+
"importance": importance,
|
| 209 |
+
# 額外欄位供後續使用
|
| 210 |
+
"name": name_zh or name,
|
| 211 |
+
"road": road,
|
| 212 |
+
"house_number": house_number,
|
| 213 |
+
"suburb": suburb,
|
| 214 |
+
"city_district": city_district,
|
| 215 |
+
"city": city,
|
| 216 |
+
"admin": admin,
|
| 217 |
+
"postcode": postcode,
|
| 218 |
+
"amenity": amenity,
|
| 219 |
+
"shop": shop,
|
| 220 |
+
"building": building,
|
| 221 |
+
})
|
| 222 |
+
|
| 223 |
+
# 最佳匹配(重要性最高)
|
| 224 |
+
best_match = max(results, key=lambda x: x["importance"])
|
| 225 |
+
|
| 226 |
+
payload = {
|
| 227 |
+
"results": results,
|
| 228 |
+
"best_match": best_match,
|
| 229 |
+
"query": query
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
# 回寫快取(雙層)
|
| 233 |
+
await db_cache.set_geo_cache(cache_key, payload)
|
| 234 |
+
await set_geo_cache(cache_key, payload)
|
| 235 |
+
|
| 236 |
+
logger.info(f"📍 Geocoding 成功: {query} → {best_match['label']} ({best_match['lat']:.4f}, {best_match['lon']:.4f})")
|
| 237 |
+
|
| 238 |
+
# 組裝友善回覆
|
| 239 |
+
content_parts = [f"找到地點:{best_match['label']}"]
|
| 240 |
+
if len(results) > 1:
|
| 241 |
+
content_parts.append(f"(共 {len(results)} 個結果,已選擇最相關的)")
|
| 242 |
+
|
| 243 |
+
content = "\n".join(content_parts)
|
| 244 |
+
|
| 245 |
+
return cls.create_success_response(content=content, data=payload)
|
services/ai_service.py
CHANGED
|
@@ -145,20 +145,85 @@ def _format_history_for_prompt(history: List[Dict[str, str]]) -> str:
|
|
| 145 |
|
| 146 |
|
| 147 |
def _format_env_context(ctx: Dict[str, Any]) -> str:
|
| 148 |
-
"""將環境資訊整理成可讀文字,確保 AI
|
| 149 |
if not ctx:
|
| 150 |
return ""
|
| 151 |
|
| 152 |
parts: List[str] = []
|
| 153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
city = (ctx.get("city") or "").strip()
|
| 155 |
admin = (ctx.get("admin") or "").strip()
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
tz = (ctx.get("tz") or "").strip()
|
| 164 |
if tz:
|
|
@@ -175,21 +240,6 @@ def _format_env_context(ctx: Dict[str, Any]) -> str:
|
|
| 175 |
except (ValueError, TypeError):
|
| 176 |
pass
|
| 177 |
|
| 178 |
-
lat = ctx.get("lat")
|
| 179 |
-
lon = ctx.get("lon")
|
| 180 |
-
try:
|
| 181 |
-
if lat is not None and lon is not None:
|
| 182 |
-
lat_f = float(lat)
|
| 183 |
-
lon_f = float(lon)
|
| 184 |
-
coord_text = f"{lat_f:.5f}, {lon_f:.5f}"
|
| 185 |
-
geohash = (ctx.get("geohash_7") or "").strip()
|
| 186 |
-
if geohash:
|
| 187 |
-
parts.append(f"座標: {coord_text}(Geohash {geohash})")
|
| 188 |
-
else:
|
| 189 |
-
parts.append(f"座標: {coord_text}")
|
| 190 |
-
except (ValueError, TypeError):
|
| 191 |
-
pass
|
| 192 |
-
|
| 193 |
locale = (ctx.get("locale") or "").strip()
|
| 194 |
if locale:
|
| 195 |
parts.append(f"語系: {locale}")
|
|
@@ -198,10 +248,6 @@ def _format_env_context(ctx: Dict[str, Any]) -> str:
|
|
| 198 |
if device:
|
| 199 |
parts.append(f"裝置: {device}")
|
| 200 |
|
| 201 |
-
address_display = (ctx.get("address_display") or "").strip()
|
| 202 |
-
if address_display:
|
| 203 |
-
parts.append(f"地點: {address_display}")
|
| 204 |
-
|
| 205 |
return "\n".join(parts)
|
| 206 |
|
| 207 |
|
|
|
|
| 145 |
|
| 146 |
|
| 147 |
def _format_env_context(ctx: Dict[str, Any]) -> str:
|
| 148 |
+
"""將環境資訊整理成可讀文字,確保 AI 能掌握使用者所在位置(精確到路口、門牌號)。"""
|
| 149 |
if not ctx:
|
| 150 |
return ""
|
| 151 |
|
| 152 |
parts: List[str] = []
|
| 153 |
|
| 154 |
+
# 優先顯示詳細地址(最重要)
|
| 155 |
+
detailed_address = (ctx.get("detailed_address") or "").strip()
|
| 156 |
+
label = (ctx.get("label") or "").strip()
|
| 157 |
+
address_display = (ctx.get("address_display") or "").strip()
|
| 158 |
+
|
| 159 |
+
if detailed_address:
|
| 160 |
+
parts.append(f"📍 精確位置:\n{detailed_address}")
|
| 161 |
+
elif label:
|
| 162 |
+
parts.append(f"📍 當前位置: {label}")
|
| 163 |
+
elif address_display:
|
| 164 |
+
parts.append(f"📍 當前位置: {address_display}")
|
| 165 |
+
|
| 166 |
+
# 如果有門牌資訊,額外強調
|
| 167 |
+
road = (ctx.get("road") or "").strip()
|
| 168 |
+
house_number = (ctx.get("house_number") or "").strip()
|
| 169 |
+
postcode = (ctx.get("postcode") or "").strip()
|
| 170 |
+
|
| 171 |
+
if road and house_number and not detailed_address:
|
| 172 |
+
address_line = f"{road}{house_number}號"
|
| 173 |
+
if postcode:
|
| 174 |
+
address_line = f"〒{postcode} {address_line}"
|
| 175 |
+
parts.append(f"門牌地址: {address_line}")
|
| 176 |
+
|
| 177 |
+
# 區域資訊(如果沒有在 detailed_address 中顯示)
|
| 178 |
+
city_district = (ctx.get("city_district") or "").strip()
|
| 179 |
+
suburb = (ctx.get("suburb") or "").strip()
|
| 180 |
city = (ctx.get("city") or "").strip()
|
| 181 |
admin = (ctx.get("admin") or "").strip()
|
| 182 |
+
|
| 183 |
+
if not detailed_address:
|
| 184 |
+
if city_district:
|
| 185 |
+
parts.append(f"行政區: {city_district}")
|
| 186 |
+
elif suburb:
|
| 187 |
+
parts.append(f"區域: {suburb}")
|
| 188 |
+
|
| 189 |
+
if city and admin:
|
| 190 |
+
parts.append(f"城市: {city}({admin})")
|
| 191 |
+
elif city:
|
| 192 |
+
parts.append(f"城市: {city}")
|
| 193 |
+
elif admin:
|
| 194 |
+
parts.append(f"省份: {admin}")
|
| 195 |
+
|
| 196 |
+
# 座標資訊(供工具使用)
|
| 197 |
+
lat = ctx.get("lat")
|
| 198 |
+
lon = ctx.get("lon")
|
| 199 |
+
try:
|
| 200 |
+
if lat is not None and lon is not None:
|
| 201 |
+
lat_f = float(lat)
|
| 202 |
+
lon_f = float(lon)
|
| 203 |
+
coord_text = f"緯度 {lat_f:.6f}, 經度 {lon_f:.6f}"
|
| 204 |
+
geohash = (ctx.get("geohash_7") or "").strip()
|
| 205 |
+
if geohash:
|
| 206 |
+
parts.append(f"座標: {coord_text}(Geohash {geohash})")
|
| 207 |
+
else:
|
| 208 |
+
parts.append(f"座標: {coord_text}")
|
| 209 |
+
except (ValueError, TypeError):
|
| 210 |
+
pass
|
| 211 |
+
|
| 212 |
+
# POI 資訊(如果是特殊地點)
|
| 213 |
+
amenity = (ctx.get("amenity") or "").strip()
|
| 214 |
+
shop = (ctx.get("shop") or "").strip()
|
| 215 |
+
building = (ctx.get("building") or "").strip()
|
| 216 |
+
|
| 217 |
+
poi_info = []
|
| 218 |
+
if amenity:
|
| 219 |
+
poi_info.append(f"設施: {amenity}")
|
| 220 |
+
if shop:
|
| 221 |
+
poi_info.append(f"商店: {shop}")
|
| 222 |
+
if building and building not in ["yes", "residential"]:
|
| 223 |
+
poi_info.append(f"建築: {building}")
|
| 224 |
+
|
| 225 |
+
if poi_info:
|
| 226 |
+
parts.append(" | ".join(poi_info))
|
| 227 |
|
| 228 |
tz = (ctx.get("tz") or "").strip()
|
| 229 |
if tz:
|
|
|
|
| 240 |
except (ValueError, TypeError):
|
| 241 |
pass
|
| 242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
locale = (ctx.get("locale") or "").strip()
|
| 244 |
if locale:
|
| 245 |
parts.append(f"語系: {locale}")
|
|
|
|
| 248 |
if device:
|
| 249 |
parts.append(f"裝置: {device}")
|
| 250 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
return "\n".join(parts)
|
| 252 |
|
| 253 |
|
static/frontend/index.html
CHANGED
|
@@ -979,85 +979,89 @@
|
|
| 979 |
.voice-transcript-wrapper {
|
| 980 |
position: relative;
|
| 981 |
width: 100%;
|
|
|
|
|
|
|
|
|
|
| 982 |
display: flex;
|
| 983 |
justify-content: center;
|
| 984 |
}
|
| 985 |
|
| 986 |
.voice-transcript {
|
| 987 |
-
width:
|
| 988 |
flex-shrink: 0;
|
| 989 |
-
padding: 24px 32px
|
| 990 |
-
background: rgba(255, 255, 255, 0.
|
| 991 |
-
border:
|
| 992 |
-
border-radius: 16px
|
| 993 |
-
backdrop-filter: blur(
|
| 994 |
-
font-size: 20px
|
| 995 |
font-weight: 400;
|
| 996 |
color: #1A1A1A;
|
| 997 |
text-align: center;
|
| 998 |
-
line-height: 1.6
|
| 999 |
-
min-height: 80px
|
| 1000 |
-
max-height:
|
| 1001 |
display: flex;
|
| 1002 |
align-items: center;
|
| 1003 |
justify-content: center;
|
| 1004 |
-
transition: all 0.3s;
|
| 1005 |
-
box-shadow: 0
|
| 1006 |
outline: none;
|
| 1007 |
resize: none;
|
| 1008 |
overflow-y: auto;
|
| 1009 |
}
|
| 1010 |
|
| 1011 |
.voice-transcript.provisional {
|
| 1012 |
-
color: rgba(0, 0, 0, 0.
|
| 1013 |
font-style: italic;
|
| 1014 |
}
|
| 1015 |
|
| 1016 |
.voice-transcript.final {
|
| 1017 |
color: #1A1A1A;
|
| 1018 |
-
border-color: rgba(0, 0, 0, 0.
|
| 1019 |
-
box-shadow: 0
|
| 1020 |
}
|
| 1021 |
|
| 1022 |
/* 文字輸入模式 */
|
| 1023 |
.voice-transcript.text-input-mode {
|
| 1024 |
cursor: text;
|
| 1025 |
text-align: left;
|
| 1026 |
-
border-color: rgba(59, 130, 246, 0.
|
| 1027 |
-
box-shadow: 0 0 0
|
| 1028 |
background: rgba(255, 255, 255, 0.98);
|
| 1029 |
white-space: pre-wrap;
|
| 1030 |
word-break: break-word;
|
| 1031 |
-
max-height:
|
| 1032 |
}
|
| 1033 |
|
| 1034 |
.voice-transcript.text-input-mode:focus {
|
| 1035 |
-
border-color: rgba(59, 130, 246, 0.
|
| 1036 |
-
box-shadow: 0 0 0
|
| 1037 |
}
|
| 1038 |
|
| 1039 |
.voice-transcript.text-input-mode:empty:before {
|
| 1040 |
content: attr(data-placeholder);
|
| 1041 |
-
color: rgba(0, 0, 0, 0.
|
| 1042 |
font-style: italic;
|
| 1043 |
}
|
| 1044 |
|
| 1045 |
/* 文字輸入模式下的提示文字 */
|
| 1046 |
.input-mode-hint {
|
| 1047 |
position: absolute;
|
| 1048 |
-
bottom: -40px
|
| 1049 |
left: 50%;
|
| 1050 |
transform: translateX(-50%);
|
| 1051 |
-
font-size: 13px
|
| 1052 |
-
color: rgba(0, 0, 0, 0.
|
| 1053 |
-
background: rgba(255, 255, 255, 0.
|
| 1054 |
-
padding: 8px 16px
|
| 1055 |
-
border-radius: 12px
|
| 1056 |
backdrop-filter: blur(10px);
|
| 1057 |
opacity: 0;
|
| 1058 |
-
transition: opacity 0.3s;
|
| 1059 |
pointer-events: none;
|
| 1060 |
white-space: nowrap;
|
|
|
|
| 1061 |
}
|
| 1062 |
|
| 1063 |
.voice-transcript.text-input-mode + .input-mode-hint {
|
|
@@ -1069,6 +1073,28 @@
|
|
| 1069 |
opacity: 1 !important;
|
| 1070 |
}
|
| 1071 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1072 |
/* === 工具卡片 === */
|
| 1073 |
.voice-tool-card {
|
| 1074 |
position: absolute;
|
|
|
|
| 979 |
.voice-transcript-wrapper {
|
| 980 |
position: relative;
|
| 981 |
width: 100%;
|
| 982 |
+
max-width: 680px; /* 限制最大寬度(從 100% 改為固定上限)*/
|
| 983 |
+
margin: 0 auto; /* 置中對齊 */
|
| 984 |
+
padding: 0 20px; /* 左右留白 */
|
| 985 |
display: flex;
|
| 986 |
justify-content: center;
|
| 987 |
}
|
| 988 |
|
| 989 |
.voice-transcript {
|
| 990 |
+
width: 100%; /* 填滿 wrapper(受 max-width 限制)*/
|
| 991 |
flex-shrink: 0;
|
| 992 |
+
padding: 20px 28px; /* 減少內距(原 24px 32px)*/
|
| 993 |
+
background: rgba(255, 255, 255, 0.96);
|
| 994 |
+
border: 1.5px solid rgba(0, 0, 0, 0.06); /* 更細的邊框 */
|
| 995 |
+
border-radius: 14px; /* 稍微減少圓角(原 16px)*/
|
| 996 |
+
backdrop-filter: blur(24px) saturate(180%);
|
| 997 |
+
font-size: 18px; /* 減小字體(原 20px)*/
|
| 998 |
font-weight: 400;
|
| 999 |
color: #1A1A1A;
|
| 1000 |
text-align: center;
|
| 1001 |
+
line-height: 1.5; /* 稍微緊湊(原 1.6)*/
|
| 1002 |
+
min-height: 72px; /* 減少最小高度(原 80px)*/
|
| 1003 |
+
max-height: 140px; /* 減少最大高度(原 160px)*/
|
| 1004 |
display: flex;
|
| 1005 |
align-items: center;
|
| 1006 |
justify-content: center;
|
| 1007 |
+
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 1008 |
+
box-shadow: 0 3px 16px rgba(0, 0, 0, 0.06), 0 1px 4px rgba(0, 0, 0, 0.04); /* 更柔和的陰影 */
|
| 1009 |
outline: none;
|
| 1010 |
resize: none;
|
| 1011 |
overflow-y: auto;
|
| 1012 |
}
|
| 1013 |
|
| 1014 |
.voice-transcript.provisional {
|
| 1015 |
+
color: rgba(0, 0, 0, 0.35); /* 稍微降低對比(原 0.4)*/
|
| 1016 |
font-style: italic;
|
| 1017 |
}
|
| 1018 |
|
| 1019 |
.voice-transcript.final {
|
| 1020 |
color: #1A1A1A;
|
| 1021 |
+
border-color: rgba(0, 0, 0, 0.10); /* 稍微淡化(原 0.12)*/
|
| 1022 |
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08), 0 2px 6px rgba(0, 0, 0, 0.04);
|
| 1023 |
}
|
| 1024 |
|
| 1025 |
/* 文字輸入模式 */
|
| 1026 |
.voice-transcript.text-input-mode {
|
| 1027 |
cursor: text;
|
| 1028 |
text-align: left;
|
| 1029 |
+
border-color: rgba(59, 130, 246, 0.4); /* 稍微淡化聚焦顏色 */
|
| 1030 |
+
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.08), 0 4px 20px rgba(0, 0, 0, 0.08); /* 減少光暈範圍 */
|
| 1031 |
background: rgba(255, 255, 255, 0.98);
|
| 1032 |
white-space: pre-wrap;
|
| 1033 |
word-break: break-word;
|
| 1034 |
+
max-height: 140px; /* 與基礎樣式保持一致 */
|
| 1035 |
}
|
| 1036 |
|
| 1037 |
.voice-transcript.text-input-mode:focus {
|
| 1038 |
+
border-color: rgba(59, 130, 246, 0.7); /* 稍微降低強度(原 0.8)*/
|
| 1039 |
+
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12), 0 6px 28px rgba(0, 0, 0, 0.12); /* 調整光暈 */
|
| 1040 |
}
|
| 1041 |
|
| 1042 |
.voice-transcript.text-input-mode:empty:before {
|
| 1043 |
content: attr(data-placeholder);
|
| 1044 |
+
color: rgba(0, 0, 0, 0.28); /* 稍微淡化(原 0.3)*/
|
| 1045 |
font-style: italic;
|
| 1046 |
}
|
| 1047 |
|
| 1048 |
/* 文字輸入模式下的提示文字 */
|
| 1049 |
.input-mode-hint {
|
| 1050 |
position: absolute;
|
| 1051 |
+
bottom: -36px; /* 調整位置(原 -40px)*/
|
| 1052 |
left: 50%;
|
| 1053 |
transform: translateX(-50%);
|
| 1054 |
+
font-size: 12px; /* 減小字體(原 13px)*/
|
| 1055 |
+
color: rgba(0, 0, 0, 0.38); /* 稍微提高對比(原 0.4)*/
|
| 1056 |
+
background: rgba(255, 255, 255, 0.92);
|
| 1057 |
+
padding: 6px 14px; /* 減少內距(原 8px 16px)*/
|
| 1058 |
+
border-radius: 10px; /* 減少圓角(原 12px)*/
|
| 1059 |
backdrop-filter: blur(10px);
|
| 1060 |
opacity: 0;
|
| 1061 |
+
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
| 1062 |
pointer-events: none;
|
| 1063 |
white-space: nowrap;
|
| 1064 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); /* 添加微陰影 */
|
| 1065 |
}
|
| 1066 |
|
| 1067 |
.voice-transcript.text-input-mode + .input-mode-hint {
|
|
|
|
| 1073 |
opacity: 1 !important;
|
| 1074 |
}
|
| 1075 |
|
| 1076 |
+
/* 手機版優化 */
|
| 1077 |
+
@media (max-width: 768px) {
|
| 1078 |
+
.voice-transcript-wrapper {
|
| 1079 |
+
max-width: 100%; /* 手機版全寬 */
|
| 1080 |
+
padding: 0 16px; /* 減少左右留白 */
|
| 1081 |
+
}
|
| 1082 |
+
|
| 1083 |
+
.voice-transcript {
|
| 1084 |
+
font-size: 16px; /* 手機版縮小字體 */
|
| 1085 |
+
padding: 16px 20px; /* 減少內距 */
|
| 1086 |
+
min-height: 64px; /* 減少最小高度 */
|
| 1087 |
+
max-height: 120px; /* 減少最大高度 */
|
| 1088 |
+
border-radius: 12px; /* 減少圓角 */
|
| 1089 |
+
}
|
| 1090 |
+
|
| 1091 |
+
.input-mode-hint {
|
| 1092 |
+
font-size: 11px;
|
| 1093 |
+
padding: 5px 12px;
|
| 1094 |
+
bottom: -32px;
|
| 1095 |
+
}
|
| 1096 |
+
}
|
| 1097 |
+
|
| 1098 |
/* === 工具卡片 === */
|
| 1099 |
.voice-tool-card {
|
| 1100 |
position: absolute;
|
static/frontend/js/websocket.js
CHANGED
|
@@ -107,7 +107,11 @@ class WebSocketManager {
|
|
| 107 |
(_err) => {
|
| 108 |
sendSnapshot(undefined, undefined, undefined, heading);
|
| 109 |
},
|
| 110 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
);
|
| 112 |
} else {
|
| 113 |
sendSnapshot(undefined, undefined, undefined, heading);
|
|
|
|
| 107 |
(_err) => {
|
| 108 |
sendSnapshot(undefined, undefined, undefined, heading);
|
| 109 |
},
|
| 110 |
+
{
|
| 111 |
+
enableHighAccuracy: true, // 啟用高精度模式(GPS 優先於 WiFi/Cell)
|
| 112 |
+
maximumAge: 0, // 不使用快取,強制取得最新位置
|
| 113 |
+
timeout: 15000 // 延長超時到 15 秒(GPS 冷啟動需要時間)
|
| 114 |
+
}
|
| 115 |
);
|
| 116 |
} else {
|
| 117 |
sendSnapshot(undefined, undefined, undefined, heading);
|
tests/features/mcp/test_agent_bridge_route_labels.py
CHANGED
|
@@ -1,12 +1,14 @@
|
|
| 1 |
import asyncio
|
| 2 |
from pathlib import Path
|
| 3 |
import sys
|
|
|
|
| 4 |
|
| 5 |
ROOT_DIR = Path(__file__).resolve().parents[4]
|
| 6 |
if str(ROOT_DIR) not in sys.path:
|
| 7 |
sys.path.append(str(ROOT_DIR))
|
| 8 |
|
| 9 |
from features.mcp.agent_bridge import MCPAgentBridge # noqa: E402
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
def test_prepare_route_arguments_injects_labels():
|
|
@@ -51,3 +53,68 @@ def test_build_directions_message_returns_human_friendly_text():
|
|
| 51 |
assert tool_data["duration_readable"].endswith("分") or tool_data["duration_readable"].endswith("分鐘")
|
| 52 |
assert "origin_lat" not in tool_data
|
| 53 |
assert "dest_lon" not in tool_data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import asyncio
|
| 2 |
from pathlib import Path
|
| 3 |
import sys
|
| 4 |
+
import types
|
| 5 |
|
| 6 |
ROOT_DIR = Path(__file__).resolve().parents[4]
|
| 7 |
if str(ROOT_DIR) not in sys.path:
|
| 8 |
sys.path.append(str(ROOT_DIR))
|
| 9 |
|
| 10 |
from features.mcp.agent_bridge import MCPAgentBridge # noqa: E402
|
| 11 |
+
from features.mcp.tools.base_tool import ExecutionError # noqa: E402
|
| 12 |
|
| 13 |
|
| 14 |
def test_prepare_route_arguments_injects_labels():
|
|
|
|
| 53 |
assert tool_data["duration_readable"].endswith("分") or tool_data["duration_readable"].endswith("分鐘")
|
| 54 |
assert "origin_lat" not in tool_data
|
| 55 |
assert "dest_lon" not in tool_data
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_build_directions_failure_response_generates_fallback_message():
|
| 59 |
+
bridge = MCPAgentBridge.__new__(MCPAgentBridge)
|
| 60 |
+
|
| 61 |
+
result = bridge._build_directions_failure_response(
|
| 62 |
+
{
|
| 63 |
+
"origin_lat": 25.045,
|
| 64 |
+
"origin_lon": 121.516,
|
| 65 |
+
"dest_lat": 24.993,
|
| 66 |
+
"dest_lon": 121.324,
|
| 67 |
+
},
|
| 68 |
+
{"origin_label": "測試起點 A", "dest_label": "測試目的地 B"},
|
| 69 |
+
"OpenRouteService 無法提供路線",
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
message = result["message"]
|
| 73 |
+
tool_data = result["tool_data"]
|
| 74 |
+
|
| 75 |
+
assert "測試起點 A" in message
|
| 76 |
+
assert "測試目的地 B" in message
|
| 77 |
+
assert tool_data["fallback"] is True
|
| 78 |
+
assert tool_data["distance_estimated_m"] is not None
|
| 79 |
+
assert "地圖" in message
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_call_mcp_tool_returns_fallback_when_directions_fails():
|
| 83 |
+
bridge = MCPAgentBridge.__new__(MCPAgentBridge)
|
| 84 |
+
|
| 85 |
+
async def fake_enrich(tool_name, arguments, user_id):
|
| 86 |
+
return arguments
|
| 87 |
+
|
| 88 |
+
async def fake_handler(_arguments):
|
| 89 |
+
raise ExecutionError("OpenRouteService 無法提供路線")
|
| 90 |
+
|
| 91 |
+
async def fake_resolve(_self, _lat, _lon):
|
| 92 |
+
return "測試地點"
|
| 93 |
+
|
| 94 |
+
bridge._enrich_arguments_with_env = fake_enrich # type: ignore[attr-defined]
|
| 95 |
+
bridge._resolve_coordinate_label = fake_resolve.__get__(bridge, MCPAgentBridge) # type: ignore[attr-defined]
|
| 96 |
+
|
| 97 |
+
bridge.mcp_server = types.SimpleNamespace(
|
| 98 |
+
tools={
|
| 99 |
+
"directions": types.SimpleNamespace(
|
| 100 |
+
handler=fake_handler,
|
| 101 |
+
description="Route",
|
| 102 |
+
metadata={"category": "地理"},
|
| 103 |
+
inputSchema={"properties": {}, "required": []},
|
| 104 |
+
)
|
| 105 |
+
}
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
result = asyncio.run(
|
| 109 |
+
bridge._call_mcp_tool(
|
| 110 |
+
"directions",
|
| 111 |
+
{"origin_lat": 25.045, "origin_lon": 121.516, "dest_lat": 24.993, "dest_lon": 121.324},
|
| 112 |
+
user_id="u123",
|
| 113 |
+
original_message="測試路線",
|
| 114 |
+
)
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
assert isinstance(result, dict)
|
| 118 |
+
assert result["tool_name"] == "directions"
|
| 119 |
+
assert result["tool_data"]["fallback"] is True
|
| 120 |
+
assert "目前無法向路線服務取得詳細路線" in result["message"]
|
tests/features/mcp/test_navigation_fix.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
測試導航功能修復
|
| 4 |
+
驗證地點查詢與導航是否正常工作
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import asyncio
|
| 8 |
+
import logging
|
| 9 |
+
from features.mcp.agent_bridge import MCPAgentBridge
|
| 10 |
+
|
| 11 |
+
# 設置日誌
|
| 12 |
+
logging.basicConfig(
|
| 13 |
+
level=logging.INFO,
|
| 14 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
async def test_navigation():
|
| 18 |
+
"""測試導航功能"""
|
| 19 |
+
bridge = MCPAgentBridge()
|
| 20 |
+
await bridge.async_initialize()
|
| 21 |
+
|
| 22 |
+
print("\n" + "="*60)
|
| 23 |
+
print("測試場景 1: 單純地點查詢")
|
| 24 |
+
print("="*60)
|
| 25 |
+
|
| 26 |
+
test_messages_1 = [
|
| 27 |
+
"銘傳大學在哪裡",
|
| 28 |
+
"桃園火車站的位置",
|
| 29 |
+
"台北101地址"
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
for msg in test_messages_1:
|
| 33 |
+
print(f"\n用戶: {msg}")
|
| 34 |
+
has_intent, intent_data = await bridge.detect_intent(msg)
|
| 35 |
+
print(f"意圖檢測: has_intent={has_intent}")
|
| 36 |
+
if intent_data:
|
| 37 |
+
print(f"意圖資料: {intent_data}")
|
| 38 |
+
|
| 39 |
+
print("\n" + "="*60)
|
| 40 |
+
print("測試場景 2: 導航需求(應自動串接 directions)")
|
| 41 |
+
print("="*60)
|
| 42 |
+
|
| 43 |
+
test_messages_2 = [
|
| 44 |
+
"怎麼去桃園火車站",
|
| 45 |
+
"如何去銘傳大學",
|
| 46 |
+
"到台北101怎麼走"
|
| 47 |
+
]
|
| 48 |
+
|
| 49 |
+
# 模擬用戶位置(桃園)
|
| 50 |
+
test_user_id = "test_user_123"
|
| 51 |
+
|
| 52 |
+
for msg in test_messages_2:
|
| 53 |
+
print(f"\n用戶: {msg}")
|
| 54 |
+
has_intent, intent_data = await bridge.detect_intent(msg)
|
| 55 |
+
print(f"意圖檢測: has_intent={has_intent}")
|
| 56 |
+
if intent_data:
|
| 57 |
+
print(f"意圖資料: {intent_data}")
|
| 58 |
+
|
| 59 |
+
# 處理意圖
|
| 60 |
+
if intent_data.get("type") == "mcp_tool":
|
| 61 |
+
print(f"\n執行工具: {intent_data.get('tool_name')}")
|
| 62 |
+
print(f"參數: {intent_data.get('arguments')}")
|
| 63 |
+
|
| 64 |
+
print("\n" + "="*60)
|
| 65 |
+
print("測試場景 3: 點到點查詢")
|
| 66 |
+
print("="*60)
|
| 67 |
+
|
| 68 |
+
test_messages_3 = [
|
| 69 |
+
"從銘傳大學到桃園火車站要多久",
|
| 70 |
+
"台北車站到淡水捷運站怎麼走"
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
for msg in test_messages_3:
|
| 74 |
+
print(f"\n用戶: {msg}")
|
| 75 |
+
has_intent, intent_data = await bridge.detect_intent(msg)
|
| 76 |
+
print(f"意圖檢測: has_intent={has_intent}")
|
| 77 |
+
if intent_data:
|
| 78 |
+
print(f"意圖資料: {intent_data}")
|
| 79 |
+
|
| 80 |
+
if __name__ == "__main__":
|
| 81 |
+
asyncio.run(test_navigation())
|
tests/features/mcp/test_precise_location.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
測試精確地址功能
|
| 4 |
+
驗證 reverse_geocode 與 forward_geocode 是否能正確提取門牌、路口資訊
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import asyncio
|
| 8 |
+
import logging
|
| 9 |
+
from features.mcp.tools.geocode_tool import ReverseGeocodeTool
|
| 10 |
+
from features.mcp.tools.geocoding_tool import ForwardGeocodeTool
|
| 11 |
+
|
| 12 |
+
# 設置日誌
|
| 13 |
+
logging.basicConfig(
|
| 14 |
+
level=logging.INFO,
|
| 15 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
async def test_reverse_geocode():
|
| 19 |
+
"""測試反向地理編碼(座標→地址)"""
|
| 20 |
+
print("\n" + "="*60)
|
| 21 |
+
print("測試場景 1: Reverse Geocode(座標 → 精確地址)")
|
| 22 |
+
print("="*60)
|
| 23 |
+
|
| 24 |
+
test_cases = [
|
| 25 |
+
{"lat": 25.0330, "lon": 121.5654, "name": "台北101"},
|
| 26 |
+
{"lat": 25.0478, "lon": 121.5170, "name": "台北車站"},
|
| 27 |
+
{"lat": 24.9932, "lon": 121.3261, "name": "桃園火車站"},
|
| 28 |
+
{"lat": 25.0625, "lon": 121.1876, "name": "銘傳大學桃園校區"},
|
| 29 |
+
{"lat": 25.0853, "lon": 121.5603, "name": "台北市政府"},
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
for case in test_cases:
|
| 33 |
+
print(f"\n📍 測試地點: {case['name']}")
|
| 34 |
+
print(f" 座標: ({case['lat']}, {case['lon']})")
|
| 35 |
+
|
| 36 |
+
try:
|
| 37 |
+
result = await ReverseGeocodeTool.execute({
|
| 38 |
+
"lat": case['lat'],
|
| 39 |
+
"lon": case['lon']
|
| 40 |
+
})
|
| 41 |
+
|
| 42 |
+
if result.get("success"):
|
| 43 |
+
data = result.get("data", {})
|
| 44 |
+
print(f" ✅ 成功取得地址:")
|
| 45 |
+
print(f" 標籤: {data.get('label')}")
|
| 46 |
+
print(f" 詳細地址: {data.get('detailed_address')}")
|
| 47 |
+
if data.get('road'):
|
| 48 |
+
print(f" 路段: {data.get('road')}")
|
| 49 |
+
if data.get('house_number'):
|
| 50 |
+
print(f" 門牌: {data.get('house_number')}")
|
| 51 |
+
if data.get('postcode'):
|
| 52 |
+
print(f" 郵遞區號: {data.get('postcode')}")
|
| 53 |
+
if data.get('suburb'):
|
| 54 |
+
print(f" 區域: {data.get('suburb')}")
|
| 55 |
+
if data.get('city'):
|
| 56 |
+
print(f" 城市: {data.get('city')}")
|
| 57 |
+
else:
|
| 58 |
+
print(f" ❌ 失敗: {result.get('error')}")
|
| 59 |
+
except Exception as e:
|
| 60 |
+
print(f" ❌ 異常: {e}")
|
| 61 |
+
|
| 62 |
+
async def test_forward_geocode():
|
| 63 |
+
"""測試正向地理編碼(地名→座標)"""
|
| 64 |
+
print("\n" + "="*60)
|
| 65 |
+
print("測試場景 2: Forward Geocode(地名 → 座標 + 精確地址)")
|
| 66 |
+
print("="*60)
|
| 67 |
+
|
| 68 |
+
test_queries = [
|
| 69 |
+
"台北101",
|
| 70 |
+
"桃園火車站",
|
| 71 |
+
"銘傳大學桃園校區",
|
| 72 |
+
"台北車站",
|
| 73 |
+
"淡水捷運站",
|
| 74 |
+
"台北市政府",
|
| 75 |
+
"中正紀念堂",
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
+
for query in test_queries:
|
| 79 |
+
print(f"\n🔍 查詢: {query}")
|
| 80 |
+
|
| 81 |
+
try:
|
| 82 |
+
result = await ForwardGeocodeTool.execute({"query": query, "limit": 1})
|
| 83 |
+
|
| 84 |
+
if result.get("success"):
|
| 85 |
+
data = result.get("data", {})
|
| 86 |
+
best = data.get("best_match", {})
|
| 87 |
+
print(f" ✅ 找到地點:")
|
| 88 |
+
print(f" 標籤: {best.get('label')}")
|
| 89 |
+
print(f" 座標: ({best.get('lat')}, {best.get('lon')})")
|
| 90 |
+
print(f" 詳細地址: {best.get('detailed_address')}")
|
| 91 |
+
if best.get('road'):
|
| 92 |
+
print(f" 路段: {best.get('road')}")
|
| 93 |
+
if best.get('house_number'):
|
| 94 |
+
print(f" 門牌: {best.get('house_number')}")
|
| 95 |
+
if best.get('postcode'):
|
| 96 |
+
print(f" 郵遞區號: {best.get('postcode')}")
|
| 97 |
+
else:
|
| 98 |
+
print(f" ❌ 失敗: {result.get('error')}")
|
| 99 |
+
except Exception as e:
|
| 100 |
+
print(f" ❌ 異常: {e}")
|
| 101 |
+
|
| 102 |
+
async def test_precision_comparison():
|
| 103 |
+
"""測試精度對比(舊 vs 新)"""
|
| 104 |
+
print("\n" + "="*60)
|
| 105 |
+
print("測試場景 3: 精度對比(展示改進效果)")
|
| 106 |
+
print("="*60)
|
| 107 |
+
|
| 108 |
+
# 測試一個有明確門牌的地點
|
| 109 |
+
test_lat = 25.0330
|
| 110 |
+
test_lon = 121.5654
|
| 111 |
+
|
| 112 |
+
print(f"\n測試座標: ({test_lat}, {test_lon}) - 台北101附近")
|
| 113 |
+
|
| 114 |
+
result = await ReverseGeocodeTool.execute({"lat": test_lat, "lon": test_lon})
|
| 115 |
+
|
| 116 |
+
if result.get("success"):
|
| 117 |
+
data = result.get("data", {})
|
| 118 |
+
|
| 119 |
+
print("\n📊 解析結果對比:")
|
| 120 |
+
print(f" 舊版輸出(只有城市): {data.get('city')}, {data.get('admin')}")
|
| 121 |
+
print(f" 新版標籤(精確地址): {data.get('label')}")
|
| 122 |
+
print(f" 新版詳細地址: {data.get('detailed_address')}")
|
| 123 |
+
|
| 124 |
+
print("\n🔍 詳細欄位:")
|
| 125 |
+
print(f" 名稱: {data.get('name')}")
|
| 126 |
+
print(f" 路段: {data.get('road')}")
|
| 127 |
+
print(f" 門牌: {data.get('house_number')}")
|
| 128 |
+
print(f" 區域: {data.get('suburb')}")
|
| 129 |
+
print(f" 行政區: {data.get('city_district')}")
|
| 130 |
+
print(f" 城市: {data.get('city')}")
|
| 131 |
+
print(f" 郵遞區號: {data.get('postcode')}")
|
| 132 |
+
print(f" 設施類型: {data.get('amenity')}")
|
| 133 |
+
print(f" 建築類型: {data.get('building')}")
|
| 134 |
+
|
| 135 |
+
async def main():
|
| 136 |
+
"""執行所有測試"""
|
| 137 |
+
await test_reverse_geocode()
|
| 138 |
+
await test_forward_geocode()
|
| 139 |
+
await test_precision_comparison()
|
| 140 |
+
|
| 141 |
+
print("\n" + "="*60)
|
| 142 |
+
print("測試完成!")
|
| 143 |
+
print("="*60)
|
| 144 |
+
|
| 145 |
+
if __name__ == "__main__":
|
| 146 |
+
asyncio.run(main())
|