"""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