File size: 14,124 Bytes
c820838
 
79df050
c820838
 
 
 
 
 
79df050
c820838
79df050
c820838
 
79df050
c820838
 
 
79df050
 
c820838
 
 
bc3bff4
9660a46
c820838
9660a46
c820838
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79df050
 
 
 
 
 
 
c820838
79df050
 
c820838
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79df050
 
c820838
 
79df050
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c820838
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79df050
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
"""
地點名稱轉座標工具(Forward Geocoding)
優先使用 TDX 官方定位服務,失敗時 fallback 到 Nominatim(OSM)
"""

import aiohttp
import asyncio
import logging
from typing import Dict, Any, List
from urllib.parse import quote

from ..base_tool import MCPTool, StandardToolSchemas, ExecutionError
from core.database import get_geo_cache, set_geo_cache
from core.database.cache import db_cache
from ..transportation.tdx_base import TDXBaseAPI

logger = logging.getLogger("mcp.tools.geocoding")

POI_HINTS = ("車站", "捷運站", "站", "大學", "醫院", "百貨", "大樓", "機場", "公園", "學校")


class ForwardGeocodeTool(MCPTool):
    NAME = "forward_geocode"
    DESCRIPTION = "Convert place names (e.g., 'Ming Chuan University', 'Taoyuan Train Station') to coordinates (latitude/longitude)"
    CATEGORY = "地理定位"
    TAGS = ["geocode", "forward", "地點", "座標"]
    KEYWORDS = ["地點", "位置", "座標", "在哪裡", "地址查詢"]
    USAGE_TIPS = [
        "提供地點名稱即可(如「台北101」「淡水捷運站」)",
        "支援地標、車站、學校、商圈等",
        "會返回最相關的座標與詳細地址"
    ]

    @classmethod
    def get_input_schema(cls) -> Dict[str, Any]:
        return StandardToolSchemas.create_input_schema({
            "query": {
                "type": "string",
                "description": "地點名稱或地址(如「銘傳大學桃園校區」「桃園火車站」「台北101」)"
            },
            "limit": {
                "type": "integer",
                "description": "返回結果數量(預設 1,最多 5)",
                "default": 1
            }
        }, required=["query"])

    @classmethod
    def get_output_schema(cls) -> Dict[str, Any]:
        schema = StandardToolSchemas.create_output_schema()
        schema["properties"].update({
            "results": {
                "type": "array",
                "description": "地點查詢結果列表",
                "items": {
                    "type": "object",
                    "properties": {
                        "lat": {"type": "number", "description": "緯度"},
                        "lon": {"type": "number", "description": "經度"},
                        "display_name": {"type": "string", "description": "完整地址"},
                        "label": {"type": "string", "description": "簡短標籤"},
                        "importance": {"type": "number", "description": "重要性評分(0-1)"}
                    }
                }
            },
            "best_match": {
                "type": "object",
                "description": "最佳匹配結果",
                "properties": {
                    "lat": {"type": "number"},
                    "lon": {"type": "number"},
                    "label": {"type": "string"}
                }
            }
        })
        return schema

    @classmethod
    async def execute(cls, arguments: Dict[str, Any]) -> Dict[str, Any]:
        query = arguments.get("query", "").strip()
        if not query:
            raise ExecutionError("請提供地點名稱")

        limit = min(int(arguments.get("limit", 1)), 5)

        # 生成快取鍵(基於查詢文字)
        import hashlib
        cache_key = hashlib.md5(f"geocode:{query}".encode()).hexdigest()

        # 記憶體快取
        cached = await db_cache.get_geo_cached(cache_key)
        if cached:
            logger.info(f"📍 Geocoding 快取命中: {query}")
            return cls.create_success_response(
                content=f"找到地點:{cached['best_match']['label']}",
                data=cached
            )

        # DB 快取
        db_cached = await get_geo_cache(cache_key)
        if db_cached:
            await db_cache.set_geo_cache(cache_key, db_cached)
            return cls.create_success_response(
                content=f"找到地點:{db_cached['best_match']['label']}",
                data=db_cached
            )

        data = await cls._forward_geocode_tdx(query)
        source = "tdx"
        if not data:
            data = await cls._forward_geocode_nominatim(query, limit)
            source = "nominatim"
        if not data or len(data) == 0:
            raise ExecutionError(f"找不到地點「{query}」,請確認地點名稱是否正確")

        prefer_poi = any(hint in query for hint in POI_HINTS)
        prefer_address = any(ch.isdigit() for ch in query) or "號" in query

        # 解析結果
        results = []
        for item in data:
            lat = float(item.get("lat", 0))
            lon = float(item.get("lon", 0))
            display_name = item.get("display_name", "")
            importance = float(item.get("importance", 0))
            
            # 解析地址組件
            addr = item.get("address", {})
            extratags = item.get("extratags", {})
            namedetails = item.get("namedetails", {})
            
            name = item.get("name", "")
            name_zh = namedetails.get("name:zh") or namedetails.get("name:zh-TW") or name
            
            # 基本地址組件
            road = addr.get("road") or addr.get("pedestrian") or addr.get("footway") or ""
            house_number = addr.get("house_number") or ""
            suburb = addr.get("suburb") or addr.get("neighbourhood") 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 ""
            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 ""
            
            # 組裝簡短標籤
            label_parts = []
            if name_zh and name_zh != road:
                label_parts.append(name_zh)
            
            if road and house_number:
                label_parts.append(f"{road}{house_number}號")
            elif road:
                label_parts.append(road)
            
            if city_district and city_district not in str(label_parts):
                label_parts.append(city_district)
            elif suburb and suburb not in str(label_parts):
                label_parts.append(suburb)
            
            # 添加城市/區域資訊
            if city and city not in str(label_parts):
                label_parts.append(city)
            
            label = ", ".join(filter(None, label_parts)) if label_parts else display_name
            
            # 組裝詳細地址
            detailed_parts = []
            if name_zh:
                detailed_parts.append(f"地點: {name_zh}")
            if road and house_number:
                detailed_parts.append(f"地址: {road}{house_number}號")
            elif road:
                detailed_parts.append(f"路段: {road}")
            if suburb:
                detailed_parts.append(f"區域: {suburb}")
            if city:
                detailed_parts.append(f"城市: {city}")
            if postcode:
                detailed_parts.append(f"郵遞區號: {postcode}")
            
            detailed_address = " | ".join(detailed_parts) if detailed_parts else label

            results.append({
                "lat": lat,
                "lon": lon,
                "display_name": display_name,
                "label": label,
                "detailed_address": detailed_address,
                "importance": importance,
                # 額外欄位供後續使用
                "name": name_zh or name,
                "road": road,
                "house_number": house_number,
                "suburb": suburb,
                "city_district": city_district,
                "city": city,
                "admin": admin,
                "postcode": postcode,
                "amenity": amenity,
                "shop": shop,
                "building": building,
                "geocode_source": source,
                "_kind": item.get("_kind", ""),
            })

        for result in results:
            score = float(result.get("importance", 0))
            name = result.get("name", "") or ""
            label = result.get("label", "") or ""
            display_name = result.get("display_name", "") or ""
            kind = result.get("_kind", "")
            text = f"{name} {label} {display_name}"

            if query and query in text:
                score += 5.0
            if prefer_poi and kind == "markname":
                score += 8.0
            if prefer_address and kind == "address":
                score += 8.0
            if "出入口" in text:
                score -= 1.5
            if "交叉口" in display_name and prefer_poi:
                score -= 1.0
            result["_score"] = score

        results.sort(key=lambda x: x.get("_score", 0), reverse=True)
        best_match = results[0]

        payload = {
            "results": results,
            "best_match": best_match,
            "query": query
        }

        # 回寫快取(雙層)
        await db_cache.set_geo_cache(cache_key, payload)
        await set_geo_cache(cache_key, payload)

        logger.info(f"📍 Geocoding 成功: {query}{best_match['label']} ({best_match['lat']:.4f}, {best_match['lon']:.4f})")

        # 組裝友善回覆
        content_parts = [f"找到地點:{best_match['label']}"]
        if len(results) > 1:
            content_parts.append(f"(共 {len(results)} 個結果,已選擇最相關的)")
        
        content = "\n".join(content_parts)

        return cls.create_success_response(content=content, data=payload)

    @staticmethod
    async def _forward_geocode_tdx(query: str) -> List[Dict[str, Any]] | None:
        try:
            address_endpoint = f"V3/Map/GeoCode/Coordinate/Address/{quote(query, safe='')}"
            rows = await TDXBaseAPI.call_api(
                address_endpoint,
                {"$format": "JSON"},
                cache_ttl=86400,
                api_version="",
                api_family="advanced",
            )
            parsed_address = ForwardGeocodeTool._parse_tdx_rows(rows, kind="address")

            mark_endpoint = f"V3/Map/GeoCode/Coordinate/Markname/{quote(query, safe='')}"
            rows = await TDXBaseAPI.call_api(
                mark_endpoint,
                {"$format": "JSON"},
                cache_ttl=86400,
                api_version="",
                api_family="advanced",
            )
            parsed_mark = ForwardGeocodeTool._parse_tdx_rows(rows, kind="markname")
            combined = (parsed_mark or []) + (parsed_address or [])
            return combined
        except Exception as exc:
            logger.warning("TDX forward geocode 失敗,回退 Nominatim: %s", exc)
            return None

    @staticmethod
    def _parse_tdx_rows(rows: Any, *, kind: str) -> List[Dict[str, Any]]:
        results = []
        for row in rows or []:
            lon = row.get("LocationX") or row.get("PositionLon")
            lat = row.get("LocationY") or row.get("PositionLat")
            geometry = row.get("Geometry") or ""
            if (lon is None or lat is None) and isinstance(geometry, str) and geometry.startswith("POINT"):
                try:
                    point_text = geometry.removeprefix("POINT").strip().strip("()")
                    point_lon, point_lat = point_text.split()
                    lon = float(point_lon)
                    lat = float(point_lat)
                except Exception:
                    lon = lon
                    lat = lat
            if lon is None or lat is None:
                continue
            address = row.get("Address") or row.get("RoadName") or row.get("LandMarkName") or ""
            name = row.get("Name") or row.get("LandMarkName") or row.get("LocationDescription") or ""
            city = row.get("City") or ""
            town = row.get("Town") or ""
            label = name or address or ""
            results.append({
                "lat": float(lat),
                "lon": float(lon),
                "display_name": address or label,
                "label": label,
                "importance": 1.0,
                "name": name,
                "road": row.get("RoadName") or "",
                "house_number": str(row.get("AddressNo") or "").strip(),
                "suburb": "",
                "city_district": town,
                "city": city,
                "admin": city,
                "postcode": row.get("ZipCode") or "",
                "amenity": "",
                "shop": "",
                "building": "",
                "detailed_address": address or label,
                "_kind": kind,
            })
        return results

    @staticmethod
    async def _forward_geocode_nominatim(query: str, limit: int) -> List[Dict[str, Any]]:
        url = "https://nominatim.openstreetmap.org/search"
        params = {
            "format": "jsonv2",
            "q": query,
            "limit": limit,
            "addressdetails": 1,
            "extratags": 1,
            "namedetails": 1,
            "accept-language": "zh-TW,zh"
        }
        headers = {
            "User-Agent": "BloomWare/1.0 (contact@example.com)"
        }
        try:
            async with aiohttp.ClientSession(headers=headers) as session:
                async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=10)) as resp:
                    if resp.status != 200:
                        raise ExecutionError(f"Nominatim 查詢失敗: HTTP {resp.status}")
                    return await resp.json()
        except asyncio.TimeoutError:
            raise ExecutionError("地點查詢逾時,請稍後再試")
        except aiohttp.ClientError as e:
            raise ExecutionError(f"網路連接錯誤: {str(e)}")