Spaces:
Runtime error
Runtime error
| #!/usr/bin/env python3 | |
| """ | |
| Knowledge Graph Data Import Script for Tourism Recommender | |
| Imports CSV data into Neo4j and validates relationships | |
| """ | |
| import pandas as pd | |
| import numpy as np | |
| from neo4j import GraphDatabase | |
| import logging | |
| from typing import Dict, List, Tuple | |
| import os | |
| # Configure logging | |
| logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') | |
| logger = logging.getLogger(__name__) | |
| class TourismKGImporter: | |
| def __init__(self, uri=None, user=None, password=None): | |
| # Đọc từ env (NEO4J_URI/USER/PASSWORD) — cần thiết khi import lên | |
| # AuraDB (neo4j+s://...); mặc định rơi về Neo4j local trong Docker. | |
| uri = uri or os.getenv("NEO4J_URI", "bolt://localhost:7687") | |
| user = user or os.getenv("NEO4J_USER", "neo4j") | |
| password = password or os.getenv("NEO4J_PASSWORD", "tourism123") | |
| logger.info(f"Connecting to Neo4j at: {uri}") | |
| self.driver = GraphDatabase.driver(uri, auth=(user, password)) | |
| def close(self): | |
| self.driver.close() | |
| def clear_database(self): | |
| """Clear all nodes and relationships""" | |
| with self.driver.session() as session: | |
| session.run("MATCH (n) DETACH DELETE n") | |
| logger.info("Database cleared") | |
| def create_constraints_and_indexes(self): | |
| """Create constraints and indexes for better performance""" | |
| with self.driver.session() as session: | |
| constraints = [ | |
| "CREATE CONSTRAINT location_id IF NOT EXISTS FOR (l:Location) REQUIRE l.id IS UNIQUE", | |
| "CREATE CONSTRAINT ward_id IF NOT EXISTS FOR (w:Ward) REQUIRE w.id IS UNIQUE", | |
| "CREATE CONSTRAINT province_id IF NOT EXISTS FOR (p:Province) REQUIRE p.id IS UNIQUE", | |
| "CREATE CONSTRAINT sub_unit_id IF NOT EXISTS FOR (s:SubUnit) REQUIRE s.id IS UNIQUE", | |
| "CREATE INDEX location_name IF NOT EXISTS FOR (l:Location) ON (l.name)", | |
| "CREATE INDEX ward_name IF NOT EXISTS FOR (w:Ward) ON (w.name)", | |
| "CREATE INDEX sub_unit_name IF NOT EXISTS FOR (s:SubUnit) ON (s.name)" | |
| ] | |
| for constraint in constraints: | |
| try: | |
| session.run(constraint) | |
| logger.info(f"Created: {constraint}") | |
| except Exception as e: | |
| logger.warning(f"Constraint/Index already exists or failed: {e}") | |
| def load_provinces(self, provinces_file="Provinces.csv"): | |
| """Load provinces data""" | |
| logger.info("Loading provinces...") | |
| df = pd.read_csv(provinces_file) | |
| with self.driver.session() as session: | |
| for _, row in df.iterrows(): | |
| session.run(""" | |
| MERGE (p:Province {id: $province_id}) | |
| SET p.old_name = $old_name, | |
| p.new_name = $new_name, | |
| p.name = $new_name | |
| """, | |
| province_id=int(row['province_id']), | |
| old_name=row['old_province_name'], | |
| new_name=row['new_province_name'] | |
| ) | |
| logger.info(f"Loaded {len(df)} provinces") | |
| def load_wards(self, wards_file="Wards.csv"): | |
| """Load wards data and create relationships with provinces""" | |
| logger.info("Loading wards...") | |
| df = pd.read_csv(wards_file) | |
| with self.driver.session() as session: | |
| for _, row in df.iterrows(): | |
| # Create ward node | |
| session.run(""" | |
| MERGE (w:Ward {id: $ward_id}) | |
| SET w.old_type = $old_type, | |
| w.old_name = $old_name, | |
| w.new_type = $new_type, | |
| w.new_name = $new_name, | |
| w.name = $new_name, | |
| w.district_city = $district_city | |
| """, | |
| ward_id=int(row['ward_id']), | |
| old_type=row['old_type'], | |
| old_name=row['old_name'], | |
| new_type=row['new_type'], | |
| new_name=row['new_name'], | |
| district_city=row['district_city'] | |
| ) | |
| # Create relationship with province | |
| session.run(""" | |
| MATCH (w:Ward {id: $ward_id}) | |
| MATCH (p:Province {id: $province_id}) | |
| MERGE (w)-[:BELONGS_TO]->(p) | |
| """, | |
| ward_id=int(row['ward_id']), | |
| province_id=int(row['province_id']) | |
| ) | |
| logger.info(f"Loaded {len(df)} wards") | |
| def load_tourism_locations(self, tourism_file="Tourism_FInal.csv"): | |
| """Load tourism locations and create relationships with wards""" | |
| logger.info("Loading tourism locations...") | |
| df = pd.read_csv(tourism_file) | |
| with self.driver.session() as session: | |
| for _, row in df.iterrows(): | |
| # Create location node | |
| session.run(""" | |
| MERGE (l:Location {id: $location_id}) | |
| SET l.name = $name, | |
| l.lat = $lat, | |
| l.lng = $lng, | |
| l.coordinates = point({latitude: $lat, longitude: $lng}) | |
| """, | |
| location_id=f"loc_{row['ward_id']}_{hash(row['name']) % 10000}", | |
| name=row['name'], | |
| lat=float(row['lat']), | |
| lng=float(row['lng']) | |
| ) | |
| # Create relationship with ward | |
| session.run(""" | |
| MATCH (l:Location {id: $location_id}) | |
| MATCH (w:Ward {id: $ward_id}) | |
| MERGE (l)-[:LOCATED_IN]->(w) | |
| """, | |
| location_id=f"loc_{row['ward_id']}_{hash(row['name']) % 10000}", | |
| ward_id=int(row['ward_id']) | |
| ) | |
| logger.info(f"Loaded {len(df)} tourism locations") | |
| def load_tourism_locations_xlsx(self, tourism_file="datasets/dulich.xlsx"): | |
| """Load tourism POIs from dulich.xlsx (the file actually shipped in the | |
| repo) and link them to wards. | |
| Column mapping (Vietnamese headers in dulich.xlsx): | |
| 'Tên địa điểm' -> name | |
| 'Mô tả ngắn' -> description | |
| 'Địa chỉ' -> address | |
| 'Số điện thoại' -> phone | |
| 'Giờ mở cửa' -> opening_hours | |
| 'Id_ xã phường cũ' -> ward_id (links to Ward.id) | |
| 'Lat' / 'Lng' -> coordinates | |
| Uses a stable, collision-resistant id (sha1 of ward_id + name) instead of | |
| the original ``hash(name) % 10000`` which could silently overwrite POIs. | |
| """ | |
| import hashlib | |
| logger.info("Loading tourism locations from xlsx...") | |
| df = pd.read_excel(tourism_file) | |
| def col(row, *names, default=None): | |
| for n in names: | |
| if n in row and pd.notna(row[n]): | |
| return row[n] | |
| return default | |
| import re as _re | |
| def fix_coord(value, limit): | |
| """Best-effort coordinate parser for messy xlsx cells. | |
| Handles: plain numbers; numbers that lost the decimal separator | |
| (106993595 -> 106.993595); free text like '~11.1848° N' or | |
| '10°57′53.28″N ≈ 10.9648' (takes the LAST decimal number found); | |
| trailing dots ('106.7930.'). Returns None when no usable number. | |
| """ | |
| if value is None: | |
| return None | |
| s = str(value) | |
| nums = _re.findall(r"[-+]?\d+(?:[.,]\d+)?", s) | |
| if not nums: | |
| return None | |
| try: | |
| f = float(nums[-1].replace(",", ".")) | |
| except ValueError: | |
| return None | |
| guard = 0 | |
| while abs(f) > limit and guard < 12: | |
| f /= 10.0 | |
| guard += 1 | |
| return f if abs(f) <= limit else None | |
| loaded_xy, loaded_noxy, skipped = 0, 0, 0 | |
| with self.driver.session() as session: | |
| for _, row in df.iterrows(): | |
| name = col(row, "Tên địa điểm", "name") | |
| ward_raw = col(row, "Id_ xã phường cũ", "ward_id") | |
| lat = col(row, "Lat", "lat") | |
| lng = col(row, "Lng", "lng") | |
| if not name or ward_raw is None: | |
| skipped += 1 | |
| continue | |
| try: | |
| ward_id = int(ward_raw) | |
| except (ValueError, TypeError): | |
| skipped += 1 | |
| continue | |
| # Coordinates are OPTIONAL: most dulich.xlsx rows lack them or | |
| # carry free text. POIs without coordinates are still imported | |
| # (they just won't take part in NEAR relationships). | |
| lat_f = fix_coord(lat, 90.0) | |
| lng_f = fix_coord(lng, 180.0) | |
| has_xy = (lat_f is not None and lng_f is not None | |
| and 0 < lat_f < 30 and 100 < lng_f < 115) # Vietnam bounds | |
| loc_hash = hashlib.sha1(f"{ward_id}|{name}".encode("utf-8")).hexdigest()[:12] | |
| location_id = f"loc_{ward_id}_{loc_hash}" | |
| props = dict( | |
| location_id=location_id, | |
| name=str(name), | |
| description=str(col(row, "Mô tả ngắn", default="")), | |
| address=str(col(row, "Địa chỉ", default="")), | |
| phone=str(col(row, "Số điện thoại", default="")), | |
| opening_hours=str(col(row, "Giờ mở cửa", default="")), | |
| ) | |
| if has_xy: | |
| session.run( | |
| """ | |
| MERGE (l:Location {id: $location_id}) | |
| SET l.name = $name, | |
| l.description = $description, | |
| l.address = $address, | |
| l.phone = $phone, | |
| l.opening_hours = $opening_hours, | |
| l.lat = $lat, | |
| l.lng = $lng, | |
| l.coordinates = point({latitude: $lat, longitude: $lng}) | |
| """, | |
| lat=lat_f, lng=lng_f, **props, | |
| ) | |
| loaded_xy += 1 | |
| else: | |
| session.run( | |
| """ | |
| MERGE (l:Location {id: $location_id}) | |
| SET l.name = $name, | |
| l.description = $description, | |
| l.address = $address, | |
| l.phone = $phone, | |
| l.opening_hours = $opening_hours | |
| """, | |
| **props, | |
| ) | |
| loaded_noxy += 1 | |
| session.run( | |
| """ | |
| MATCH (l:Location {id: $location_id}) | |
| MATCH (w:Ward {id: $ward_id}) | |
| MERGE (l)-[:LOCATED_IN]->(w) | |
| """, | |
| location_id=location_id, | |
| ward_id=ward_id, | |
| ) | |
| logger.info( | |
| f"Loaded {loaded_xy + loaded_noxy} tourism locations from xlsx " | |
| f"({loaded_xy} with coordinates, {loaded_noxy} without, {skipped} skipped)" | |
| ) | |
| def load_sub_units(self, sub_units_file="mapped_sub_units.csv"): | |
| """Load sub-units data and create relationships with wards""" | |
| logger.info("Loading sub-units...") | |
| df = pd.read_csv(sub_units_file) | |
| with self.driver.session() as session: | |
| skipped_rows = 0 | |
| for _, row in df.iterrows(): | |
| try: | |
| ward_id = int(row['ward_id']) | |
| # Create sub-unit node | |
| session.run(""" | |
| MERGE (s:SubUnit {id: $sub_unit_id}) | |
| SET s.old_type = $old_type, | |
| s.old_name = $old_name, | |
| s.new_type = $new_type, | |
| s.new_name = $new_name, | |
| s.name = $new_name | |
| """, | |
| sub_unit_id=f"sub_{ward_id}_{hash(row['new_name']) % 10000}", | |
| old_type=row['old_type'], | |
| old_name=row['old_name'], | |
| new_type=row['new_type'], | |
| new_name=row['new_name'] | |
| ) | |
| # Create relationship with ward | |
| session.run(""" | |
| MATCH (s:SubUnit {id: $sub_unit_id}) | |
| MATCH (w:Ward {id: $ward_id}) | |
| MERGE (s)-[:PART_OF]->(w) | |
| """, | |
| sub_unit_id=f"sub_{ward_id}_{hash(row['new_name']) % 10000}", | |
| ward_id=ward_id | |
| ) | |
| except (ValueError, TypeError) as e: | |
| logger.warning(f"Skipping row with invalid ward_id: {row['ward_id']} - {e}") | |
| skipped_rows += 1 | |
| continue | |
| logger.info(f"Loaded {len(df) - skipped_rows} sub-units (skipped {skipped_rows} invalid rows)") | |
| def create_proximity_relationships(self, distance_km=5.0): | |
| """Create NEAR relationships between locations within specified distance""" | |
| logger.info(f"Creating proximity relationships (within {distance_km}km)...") | |
| with self.driver.session() as session: | |
| # Use Neo4j's spatial functions to find nearby locations | |
| session.run(""" | |
| MATCH (l1:Location), (l2:Location) | |
| WHERE l1 <> l2 | |
| AND point.distance(l1.coordinates, l2.coordinates) < $distance_meters | |
| MERGE (l1)-[:NEAR {distance: point.distance(l1.coordinates, l2.coordinates)}]->(l2) | |
| """, distance_meters=distance_km * 1000) | |
| logger.info("Proximity relationships created") | |
| def validate_and_clean_orphaned_nodes(self): | |
| """Find and optionally remove nodes without relationships""" | |
| logger.info("Validating node relationships...") | |
| with self.driver.session() as session: | |
| # Find orphaned locations (no relationships) | |
| result = session.run(""" | |
| MATCH (l:Location) | |
| WHERE NOT (l)-[]-() | |
| RETURN l.id as location_id, l.name as name | |
| """) | |
| orphaned_locations = [record for record in result] | |
| if orphaned_locations: | |
| logger.warning(f"Found {len(orphaned_locations)} orphaned locations:") | |
| for loc in orphaned_locations[:10]: # Show first 10 | |
| logger.warning(f" - {loc['name']} (ID: {loc['location_id']})") | |
| # Find orphaned wards | |
| result = session.run(""" | |
| MATCH (w:Ward) | |
| WHERE NOT (w)-[]-() | |
| RETURN w.id as ward_id, w.name as name | |
| """) | |
| orphaned_wards = [record for record in result] | |
| if orphaned_wards: | |
| logger.warning(f"Found {len(orphaned_wards)} orphaned wards:") | |
| for ward in orphaned_wards[:10]: | |
| logger.warning(f" - {ward['name']} (ID: {ward['ward_id']})") | |
| return len(orphaned_locations), len(orphaned_wards) | |
| def get_database_stats(self): | |
| """Get database statistics""" | |
| with self.driver.session() as session: | |
| stats = {} | |
| # Count nodes by type | |
| result = session.run("MATCH (n:Location) RETURN count(n) as count") | |
| stats['locations'] = result.single()['count'] | |
| result = session.run("MATCH (n:Ward) RETURN count(n) as count") | |
| stats['wards'] = result.single()['count'] | |
| result = session.run("MATCH (n:Province) RETURN count(n) as count") | |
| stats['provinces'] = result.single()['count'] | |
| result = session.run("MATCH (n:SubUnit) RETURN count(n) as count") | |
| stats['sub_units'] = result.single()['count'] | |
| # Count relationships by type | |
| result = session.run("MATCH ()-[r:LOCATED_IN]->() RETURN count(r) as count") | |
| stats['located_in_rels'] = result.single()['count'] | |
| result = session.run("MATCH ()-[r:BELONGS_TO]->() RETURN count(r) as count") | |
| stats['belongs_to_rels'] = result.single()['count'] | |
| result = session.run("MATCH ()-[r:NEAR]->() RETURN count(r) as count") | |
| stats['near_rels'] = result.single()['count'] | |
| result = session.run("MATCH ()-[r:PART_OF]->() RETURN count(r) as count") | |
| stats['part_of_rels'] = result.single()['count'] | |
| return stats | |
| def run_full_import(self, clear_existing=True): | |
| """Run complete data import process""" | |
| logger.info("Starting full knowledge graph import...") | |
| if clear_existing: | |
| self.clear_database() | |
| # Create constraints and indexes | |
| self.create_constraints_and_indexes() | |
| # Import data in order | |
| self.load_provinces("datasets/Provinces.csv") | |
| self.load_wards("datasets/Wards.csv") | |
| # Prefer the legacy Tourism_FInal.csv if present, otherwise fall back to | |
| # the dulich.xlsx file actually shipped in the repo. | |
| if os.path.exists("datasets/Tourism_FInal.csv"): | |
| self.load_tourism_locations("datasets/Tourism_FInal.csv") | |
| else: | |
| logger.info("Tourism_FInal.csv not found; loading POIs from datasets/dulich.xlsx") | |
| self.load_tourism_locations_xlsx("datasets/dulich.xlsx") | |
| self.load_sub_units("datasets/mapped_sub_units.csv") | |
| # Create proximity relationships | |
| self.create_proximity_relationships() | |
| # Validate data | |
| orphaned_locs, orphaned_wards = self.validate_and_clean_orphaned_nodes() | |
| # Get final stats | |
| stats = self.get_database_stats() | |
| logger.info("Import completed!") | |
| logger.info(f"Database statistics:") | |
| for key, value in stats.items(): | |
| logger.info(f" {key}: {value}") | |
| if orphaned_locs > 0 or orphaned_wards > 0: | |
| logger.warning(f"Found {orphaned_locs} orphaned locations and {orphaned_wards} orphaned wards") | |
| logger.warning("Consider reviewing data quality or relationship logic") | |
| return stats | |
| def main(): | |
| """Main execution function""" | |
| importer = TourismKGImporter() | |
| try: | |
| # Test connection | |
| with importer.driver.session() as session: | |
| result = session.run("RETURN 'Connection successful' as message") | |
| logger.info(result.single()['message']) | |
| # Run import | |
| stats = importer.run_full_import(clear_existing=True) | |
| logger.info("Knowledge graph import completed successfully!") | |
| except Exception as e: | |
| logger.error(f"Import failed: {e}") | |
| raise | |
| finally: | |
| importer.close() | |
| if __name__ == "__main__": | |
| main() | |