File size: 10,866 Bytes
ca7a2c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9e98b5a
 
 
 
 
 
ca7a2c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
"""Graph Tool - Neo4j spatial search with place details, relationships, and OSM geocoding.

Features:
- Spatial search (find nearby places by coordinates)
- Place details with photos and reviews
- Same category relationships
- OpenStreetMap geocoding fallback
"""

from dataclasses import dataclass, field
from typing import Optional, Any

import httpx

from app.shared.integrations.neo4j_client import neo4j_client


@dataclass
class PlaceResult:
    """Result from nearby places search."""

    place_id: str
    name: str
    category: str
    lat: float
    lng: float
    distance_km: float | None = None
    rating: float | None = None
    description: str | None = None


@dataclass
class NearbyPlace:
    """Nearby place with distance."""

    place_id: str
    name: str
    category: str
    rating: float
    distance_km: float


@dataclass
class Review:
    """Place review."""

    text: str
    rating: int
    reviewer: str


@dataclass
class PlaceDetails:
    """Complete place details from Neo4j."""

    place_id: str
    name: str
    category: str
    rating: float
    address: str
    phone: str | None = None
    website: str | None = None
    google_maps_url: str | None = None
    description: str | None = None
    specialty: str | None = None
    price_range: str | None = None
    coordinates: dict[str, float] = field(default_factory=dict)
    photos_count: int = 0
    reviews_count: int = 0
    photos: list[str] = field(default_factory=list)
    reviews: list[Review] = field(default_factory=list)
    nearby_places: list[NearbyPlace] = field(default_factory=list)
    same_category: list[dict[str, Any]] = field(default_factory=list)


# Available categories in Neo4j - imported from centralized prompts
from app.shared.prompts import AVAILABLE_CATEGORIES


# Tool definition for agent - imported from centralized prompts
from app.shared.prompts import FIND_NEARBY_PLACES_TOOL as TOOL_DEFINITION


async def find_nearby_places(
    lat: float,
    lng: float,
    max_distance_km: float = 5.0,
    category: str | None = None,
    limit: int = 10,
) -> list[PlaceResult]:
    """
    Find nearby places using Neo4j spatial query.

    Args:
        lat: Center latitude
        lng: Center longitude
        max_distance_km: Maximum distance in kilometers
        category: Optional category filter
        limit: Maximum results

    Returns:
        List of nearby places ordered by distance
    """
    category_filter = ""
    if category:
        category_filter = "AND toLower(p.category) CONTAINS toLower($category)"

    query = f"""
    MATCH (p:Place)
    WITH p, point.distance(
        point({{latitude: p.latitude, longitude: p.longitude}}),
        point({{latitude: $lat, longitude: $lng}})
    ) / 1000 as distance_km
    WHERE distance_km <= $max_distance {category_filter}
    RETURN 
        p.id as place_id,
        p.name as name,
        p.category as category,
        p.latitude as lat,
        p.longitude as lng,
        distance_km,
        p.rating as rating,
        p.description as description
    ORDER BY distance_km
    LIMIT $limit
    """

    params = {
        "lat": lat,
        "lng": lng,
        "max_distance": max_distance_km,
        "limit": limit,
    }
    if category:
        params["category"] = category

    results = await neo4j_client.run_cypher(query, params)

    return [
        PlaceResult(
            place_id=r["place_id"],
            name=r["name"],
            category=r["category"] or '',
            lat=r["lat"] or 0.0,
            lng=r["lng"] or 0.0,
            distance_km=r.get("distance_km"),
            rating=r.get("rating"),
            description=r.get("description"),
        )
        for r in results
    ]


async def get_place_details(
    place_id: str,
    include_nearby: bool = True,
    include_same_category: bool = True,
    nearby_limit: int = 5,
) -> PlaceDetails | None:
    """
    Get complete place details including photos, reviews, and relationships.

    Args:
        place_id: The place identifier
        include_nearby: Whether to include nearby places
        include_same_category: Whether to include same category places
        nearby_limit: Limit for nearby/related results

    Returns:
        PlaceDetails or None if not found
    """
    # Main place query with photos and reviews
    query = """
    MATCH (p:Place {id: $place_id})
    OPTIONAL MATCH (p)-[:HAS_PHOTO]->(photo:Photo)
    OPTIONAL MATCH (p)-[:HAS_REVIEW]->(review:Review)
    RETURN p,
           collect(DISTINCT photo.path) as photos,
           collect(DISTINCT {
               text: review.text,
               rating: review.rating,
               reviewer: review.reviewer
           }) as reviews
    """

    results = await neo4j_client.run_cypher(query, {"place_id": place_id})

    if not results or not results[0].get('p'):
        return None

    record = results[0]
    place = record['p']

    details = PlaceDetails(
        place_id=place.get('id', place_id),
        name=place.get('name', 'Unknown'),
        category=place.get('category', ''),
        rating=float(place.get('rating', 0) or 0),
        address=place.get('address', ''),
        phone=place.get('phone'),
        website=place.get('website'),
        google_maps_url=place.get('google_maps_url'),
        description=place.get('description'),
        specialty=place.get('specialty'),
        price_range=place.get('price_range'),
        coordinates={
            'lat': float(place.get('latitude', 0) or 0),
            'lng': float(place.get('longitude', 0) or 0)
        },
        photos_count=int(place.get('photos_count', 0) or 0),
        reviews_count=int(place.get('reviews_count', 0) or 0),
        photos=record.get('photos', [])[:10],
        reviews=[
            Review(
                text=r['text'] or '',
                rating=int(r['rating'] or 0),
                reviewer=r['reviewer'] or ''
            )
            for r in record.get('reviews', [])[:5]
            if r.get('text')
        ]
    )

    # Get nearby places
    if include_nearby:
        details.nearby_places = await get_nearby_by_relationship(place_id, nearby_limit)

    # Get same category places
    if include_same_category:
        details.same_category = await get_same_category_places(place_id, nearby_limit)

    return details


async def get_nearby_by_relationship(
    place_id: str,
    limit: int = 5,
    max_distance_km: float = 2.0
) -> list[NearbyPlace]:
    """
    Get places near a given place using NEAR relationship.

    Args:
        place_id: The source place identifier
        limit: Maximum number of results
        max_distance_km: Maximum distance in km

    Returns:
        List of NearbyPlace objects
    """
    query = """
    MATCH (p:Place {id: $place_id})-[n:NEAR]-(other:Place)
    WHERE n.distance_km <= $max_distance
    RETURN other.id as place_id,
           other.name as name,
           other.category as category,
           other.rating as rating,
           n.distance_km as distance_km
    ORDER BY n.distance_km
    LIMIT $limit
    """

    results = await neo4j_client.run_cypher(query, {
        "place_id": place_id,
        "max_distance": max_distance_km,
        "limit": limit
    })

    return [
        NearbyPlace(
            place_id=r['place_id'],
            name=r['name'],
            category=r['category'] or '',
            rating=float(r['rating'] or 0),
            distance_km=round(float(r['distance_km'] or 0), 2)
        )
        for r in results
    ]


async def get_same_category_places(
    place_id: str,
    limit: int = 5
) -> list[dict[str, Any]]:
    """
    Get other places in the same category.

    Args:
        place_id: The source place identifier
        limit: Maximum number of results

    Returns:
        List of places in same category, ordered by rating
    """
    query = """
    MATCH (p:Place {id: $place_id})-[:IN_CATEGORY]->(c:Category)<-[:IN_CATEGORY]-(other:Place)
    WHERE other.id <> $place_id
    RETURN other.id as place_id,
           other.name as name,
           other.category as category,
           other.rating as rating,
           other.address as address
    ORDER BY other.rating DESC
    LIMIT $limit
    """

    results = await neo4j_client.run_cypher(query, {
        "place_id": place_id,
        "limit": limit
    })

    return [
        {
            'place_id': r['place_id'],
            'name': r['name'],
            'category': r['category'] or '',
            'rating': float(r['rating'] or 0),
            'address': r['address'] or ''
        }
        for r in results
    ]


async def geocode_location(location_name: str, country: str = "Vietnam") -> tuple[float, float] | None:
    """
    Geocode a location name using OpenStreetMap Nominatim API.

    Args:
        location_name: Name of the place (e.g., "Cầu Rồng", "Bãi biển Mỹ Khê")
        country: Country to bias search (default: Vietnam)

    Returns:
        (lat, lng) tuple or None if not found
    """
    search_query = f"{location_name}, Da Nang, {country}"

    try:
        async with httpx.AsyncClient(timeout=10.0) as client:
            response = await client.get(
                "https://nominatim.openstreetmap.org/search",
                params={
                    "q": search_query,
                    "format": "json",
                    "limit": 1,
                    "addressdetails": 0,
                },
                headers={
                    "User-Agent": "LocalMate-DaNang/1.0 (travel assistant app)",
                },
            )
            response.raise_for_status()
            data = response.json()

            if data and len(data) > 0:
                lat = float(data[0]["lat"])
                lng = float(data[0]["lon"])
                return (lat, lng)

    except Exception:
        pass

    return None


async def get_location_coordinates(location_name: str) -> tuple[float, float] | None:
    """
    Get coordinates for a location name.

    First tries Neo4j, then falls back to OpenStreetMap Nominatim.

    Args:
        location_name: Name of the place (e.g., "Khách sạn Rex", "Cầu Rồng")

    Returns:
        (lat, lng) tuple or None if not found
    """
    # Try Neo4j first
    try:
        query = """
        MATCH (p:Place)
        WHERE toLower(p.name) CONTAINS toLower($name)
        RETURN p.latitude as lat, p.longitude as lng
        LIMIT 1
        """
        results = await neo4j_client.run_cypher(query, {"name": location_name})
        if results and results[0].get("lat") and results[0].get("lng"):
            return (results[0]["lat"], results[0]["lng"])
    except Exception:
        pass

    # Fallback to OpenStreetMap Nominatim
    osm_result = await geocode_location(location_name)
    if osm_result:
        return osm_result

    return None