Spaces:
Running
Running
File size: 10,609 Bytes
801ae57 91f3927 e2e01e0 801ae57 e2e01e0 801ae57 69f3746 801ae57 52571d9 801ae57 69f3746 801ae57 52571d9 801ae57 921a78a e8439b4 921a78a 801ae57 921a78a fa7dc28 921a78a 69fb140 921a78a 801ae57 52571d9 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 |
"""
反地理與時區工具(免費 API 優先)
- reverse_geocode: 使用 Nominatim(OSM)反查城市/行政區(先查 DB/記憶體快取)
"""
import aiohttp
import asyncio
import logging
from typing import Dict, Any
from .base_tool import MCPTool, StandardToolSchemas, ExecutionError
from core.database import get_geo_cache, set_geo_cache
from core.database.cache import db_cache
logger = logging.getLogger("mcp.tools.geocode")
class ReverseGeocodeTool(MCPTool):
NAME = "reverse_geocode"
DESCRIPTION = "Convert coordinates (latitude/longitude) to city/district names (uses cache when available)"
CATEGORY = "地理定位"
TAGS = ["geocode", "reverse", "city"]
KEYWORDS = ["座標", "經緯度", "反查", "地址", "我在哪"]
USAGE_TIPS = ["提供 lat/lon 即可"]
@classmethod
def get_input_schema(cls) -> Dict[str, Any]:
return StandardToolSchemas.create_input_schema({
"lat": {"type": "number", "description": "緯度"},
"lon": {"type": "number", "description": "經度"}
}, required=["lat", "lon"])
@classmethod
def get_output_schema(cls) -> Dict[str, Any]:
schema = StandardToolSchemas.create_output_schema()
schema["properties"].update({
"city": {"type": "string"},
"admin": {"type": "string"},
"country_code": {"type": "string"}
})
return schema
@classmethod
async def execute(cls, arguments: Dict[str, Any]) -> Dict[str, Any]:
lat = arguments.get("lat")
lon = arguments.get("lon")
if lat is None or lon is None:
raise ExecutionError("缺少經緯度")
# 先用 geohash7 當鍵查快取
try:
from geohash2 import encode as gh_encode
geokey = gh_encode(lat, lon, precision=7)
except Exception:
geokey = f"{round(lat,4)},{round(lon,4)}"
# 記憶體快取
cached = await db_cache.get_geo_cached(geokey)
if cached:
# 補齊缺失欄位(兼容舊版快取)
cached.setdefault("lat", lat) # ← 補上座標
cached.setdefault("lon", lon) # ← 補上座標
cached.setdefault("name", "")
cached.setdefault("detailed_address", cached.get("label") or cached.get("display_name") or "")
cached.setdefault("postcode", "")
cached.setdefault("city_district", "")
cached.setdefault("amenity", "")
cached.setdefault("shop", "")
cached.setdefault("building", "")
cached.setdefault("office", "")
cached.setdefault("leisure", "")
cached.setdefault("tourism", "")
return cls.create_success_response(
content=cached.get("display_name") or f"{cached.get('city')}, {cached.get('admin')}",
data=cached
)
# DB 快取
db_cached = await get_geo_cache(geokey)
if db_cached:
# 補齊缺失欄位(兼容舊版快取)
db_cached.setdefault("lat", lat) # ← 補上座標
db_cached.setdefault("lon", lon) # ← 補上座標
db_cached.setdefault("name", "")
db_cached.setdefault("detailed_address", db_cached.get("label") or db_cached.get("display_name") or "")
db_cached.setdefault("postcode", "")
db_cached.setdefault("city_district", "")
db_cached.setdefault("amenity", "")
db_cached.setdefault("shop", "")
db_cached.setdefault("building", "")
db_cached.setdefault("office", "")
db_cached.setdefault("leisure", "")
db_cached.setdefault("tourism", "")
await db_cache.set_geo_cache(geokey, db_cached)
return cls.create_success_response(
content=db_cached.get("display_name") or f"{db_cached.get('city')}, {db_cached.get('admin')}",
data=db_cached
)
# 外呼 Nominatim(公共端點,務必節流)
url = "https://nominatim.openstreetmap.org/reverse"
params = {
"format": "jsonv2",
"lat": lat,
"lon": lon,
"zoom": 18,
"addressdetails": 1,
"extratags": 1,
"namedetails": 1
}
headers = {
"User-Agent": "BloomWare/1.0 (contact@example.com)"
}
# 呼叫 Nominatim API
data = None
try:
async with aiohttp.ClientSession(headers=headers) as session:
async with session.get(url, params=params, timeout=30) as resp:
if resp.status != 200:
raise ExecutionError(f"Nominatim 失敗: HTTP {resp.status}")
response_text = await resp.text()
if not response_text or response_text.strip() == "":
raise ExecutionError("Nominatim 回應為空")
import json
try:
data = json.loads(response_text)
except json.JSONDecodeError:
raise ExecutionError(f"Nominatim 回應非 JSON: {response_text[:200]}")
except aiohttp.ClientError as e:
raise ExecutionError(f"Nominatim 網路錯誤: {e}")
except asyncio.TimeoutError:
raise ExecutionError("Nominatim 請求逾時")
# 驗證回應
if data is None:
raise ExecutionError("Nominatim 回應為 null")
if not isinstance(data, dict):
raise ExecutionError(f"Nominatim 回應格式錯誤: {type(data)}")
if "error" in data:
raise ExecutionError(f"Nominatim 錯誤: {data.get('error')}")
# 解析地址資訊
addr = data.get("address") or {}
extratags = data.get("extratags") or {}
# 基本地址組件
road = addr.get("road") or addr.get("pedestrian") or addr.get("footway") or addr.get("cycleway") or ""
house_number = addr.get("house_number") or ""
suburb = addr.get("suburb") or addr.get("neighbourhood") or addr.get("quarter") or ""
city_district = addr.get("city_district") or ""
city = addr.get("city") or addr.get("town") or addr.get("village") or addr.get("county") or ""
admin = addr.get("state") or addr.get("county") or ""
country_code = (addr.get("country_code") or "").upper()
postcode = addr.get("postcode") or ""
# POI 資訊(商店、建築物、設施等)
amenity = addr.get("amenity") or extratags.get("amenity") or ""
shop = addr.get("shop") or extratags.get("shop") or ""
building = addr.get("building") or extratags.get("building") or ""
office = addr.get("office") or extratags.get("office") or ""
leisure = addr.get("leisure") or extratags.get("leisure") or ""
tourism = addr.get("tourism") or extratags.get("tourism") or ""
# 地點名稱(優先使用繁中)
name = data.get("name") or ""
namedetails = data.get("namedetails") or {}
name_zh = namedetails.get("name:zh") or namedetails.get("name:zh-TW") or name
display_name = data.get("display_name") or ""
# 組裝精確標籤(優先顯示最精確的資訊)
label_parts = []
# 1. POI 名稱(如「7-11 明倫門市」「台北101」)
if name_zh and name_zh != road:
label_parts.append(name_zh)
# 2. 門牌號碼 + 路名(如「中正路123號」)
if road and house_number:
label_parts.append(f"{road}{house_number}號")
elif road:
# 如果沒有門牌,但有路口資訊
if "路口" in road or "交叉口" in road or "intersection" in road.lower():
label_parts.append(road)
else:
# 嘗試從附近找路口
label_parts.append(road)
# 3. 郵遞區號(如「100」)
if postcode and len(label_parts) > 0:
label_parts[0] = f"〒{postcode} {label_parts[0]}"
# 4. 區域(如「大安區」)
if city_district and city_district not in label_parts:
label_parts.append(city_district)
elif suburb and suburb not in label_parts:
label_parts.append(suburb)
# 5. 城市(如「台北市」)
if city and city not in label_parts:
label_parts.append(city)
# 6. 省份/州(如「台灣」)
if admin and admin not in city and admin not in label_parts:
label_parts.append(admin)
label = ", ".join(filter(None, label_parts))
# 組裝詳細地址(用於 AI 顯示)
detailed_address_parts = []
if name_zh:
detailed_address_parts.append(f"地點: {name_zh}")
if road and house_number:
detailed_address_parts.append(f"地址: {road}{house_number}號")
elif road:
detailed_address_parts.append(f"路段: {road}")
if suburb:
detailed_address_parts.append(f"區域: {suburb}")
if city:
detailed_address_parts.append(f"城市: {city}")
if postcode:
detailed_address_parts.append(f"郵遞區號: {postcode}")
detailed_address = " | ".join(detailed_address_parts) if detailed_address_parts else label
payload = {
"lat": lat,
"lon": lon,
"city": city or "",
"admin": admin or "",
"country_code": country_code,
"display_name": display_name,
"label": label or display_name,
"detailed_address": detailed_address,
"road": road,
"house_number": house_number,
"suburb": suburb,
"city_district": city_district,
"postcode": postcode,
"amenity": amenity,
"shop": shop,
"building": building,
"office": office,
"leisure": leisure,
"tourism": tourism,
"name": name_zh or name,
}
# 回寫快取
await db_cache.set_geo_cache(geokey, payload)
await set_geo_cache(geokey, payload)
return cls.create_success_response(content=payload.get("label") or f"{payload['city']}, {payload['admin']}", data=payload)
|