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)