Download app/mcp/tools/graph_tool.py from Cuong2004/LocalMate: direct link, hf CLI and curl.
- Browser
- Download file 10.9 kB
-
https://huggingface.co/spaces/Cuong2004/LocalMate/resolve/main/app/mcp/tools/graph_tool.py
- Command line
-
hf download hf://spaces/Cuong2004/LocalMate/app/mcp/tools/graph_tool.py
-
curl -L -o graph_tool.py https://huggingface.co/spaces/Cuong2004/LocalMate/resolve/main/app/mcp/tools/graph_tool.py
10.9 kB
| """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 | |
| 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 | |
| class NearbyPlace: | |
| """Nearby place with distance.""" | |
| place_id: str | |
| name: str | |
| category: str | |
| rating: float | |
| distance_km: float | |
| class Review: | |
| """Place review.""" | |
| text: str | |
| rating: int | |
| reviewer: str | |
| 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 | |