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

feat: 增強環境感知與導航功能

Browse files

- 新增精確地理位置資訊(門牌號、路口、郵遞區號)
- 優化 reverse_geocode 工具,提取完整地址組件
- 新增 forward_geocode 工具,支援地點名稱查詢
- 增強意圖檢測,自動處理導航需求
- 優化環境上下文格式化,顯示詳細位置資訊
- 修復 directions 工具錯誤處理
- 提升前端地理位置精度設定
- 優化 voice-transcript-wrapper 樣式,縮小寬度並改善視覺效果
- 新增測試腳本驗證功能

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
- raise ExecutionError(f"ORS 失敗: HTTP {resp.status}")
 
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
- raise ExecutionError(f"解析 ORS 回應失敗: {e}")
 
 
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": 10,
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
- city = addr.get("city") or addr.get("town") or addr.get("village") or addr.get("county")
 
 
 
 
 
 
 
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
- road = addr.get("road") or addr.get("pedestrian") or addr.get("footway") or ""
97
- house_number = addr.get("house_number") or ""
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
- label_parts.append(road)
104
- if suburb:
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  label_parts.append(suburb)
106
- if city:
 
 
107
  label_parts.append(city)
108
- if admin:
 
 
109
  label_parts.append(admin)
110
- label = ", ".join(label_parts)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if city and admin:
157
- parts.append(f"城市: {city}({admin})")
158
- elif city:
159
- parts.append(f"城市: {city}")
160
- elif admin:
161
- parts.append(f"行政區: {admin}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: min(100%, 560px);
988
  flex-shrink: 0;
989
- padding: 24px 32px;
990
- background: rgba(255, 255, 255, 0.95);
991
- border: 1px solid rgba(0, 0, 0, 0.08);
992
- border-radius: 16px;
993
- backdrop-filter: blur(20px) saturate(180%);
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: 160px; /* 固定最大高度約 3-4 行文字 */
1001
  display: flex;
1002
  align-items: center;
1003
  justify-content: center;
1004
- transition: all 0.3s;
1005
- box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
1006
  outline: none;
1007
  resize: none;
1008
  overflow-y: auto;
1009
  }
1010
 
1011
  .voice-transcript.provisional {
1012
- color: rgba(0, 0, 0, 0.4);
1013
  font-style: italic;
1014
  }
1015
 
1016
  .voice-transcript.final {
1017
  color: #1A1A1A;
1018
- border-color: rgba(0, 0, 0, 0.12);
1019
- box-shadow: 0 6px 32px rgba(0, 0, 0, 0.12);
1020
  }
1021
 
1022
  /* 文字輸入模式 */
1023
  .voice-transcript.text-input-mode {
1024
  cursor: text;
1025
  text-align: left;
1026
- border-color: rgba(59, 130, 246, 0.5);
1027
- box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1), 0 6px 32px rgba(0, 0, 0, 0.12);
1028
  background: rgba(255, 255, 255, 0.98);
1029
  white-space: pre-wrap;
1030
  word-break: break-word;
1031
- max-height: 160px; /* 與基礎樣式保持一致 */
1032
  }
1033
 
1034
  .voice-transcript.text-input-mode:focus {
1035
- border-color: rgba(59, 130, 246, 0.8);
1036
- box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.15), 0 8px 40px rgba(0, 0, 0, 0.15);
1037
  }
1038
 
1039
  .voice-transcript.text-input-mode:empty:before {
1040
  content: attr(data-placeholder);
1041
- color: rgba(0, 0, 0, 0.3);
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.4);
1053
- background: rgba(255, 255, 255, 0.9);
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
- { enableHighAccuracy: true, maximumAge: 60000, timeout: 5000 }
 
 
 
 
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())