UNPKG

@siva-sub/mcp-public-transport

Version:

A Model Context Protocol server for Singapore transport data with real-time information and routing

409 lines (408 loc) 18.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.LocationSearchTool = void 0; const base_js_1 = require("../base.js"); const errors_js_1 = require("../../utils/errors.js"); const logger_js_1 = require("../../utils/logger.js"); class LocationSearchTool extends base_js_1.BaseTool { constructor(oneMapService, postalCodeService, timeService, fuzzySearchService) { super(); this.oneMapService = oneMapService; this.postalCodeService = postalCodeService; this.timeService = timeService; this.fuzzySearchService = fuzzySearchService; } getDefinitions() { return [ { name: 'search_location', description: 'Search for locations in Singapore using addresses, postal codes, landmarks, or building names. Supports fuzzy search and intelligent query analysis.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query - can be address, postal code, MRT station, landmark, or building name', }, maxResults: { type: 'number', description: 'Maximum number of results to return (1-20)', minimum: 1, maximum: 20, default: 5, }, enableFuzzySearch: { type: 'boolean', description: 'Enable fuzzy matching for typos and variations', default: true, }, includeNearbyInfo: { type: 'boolean', description: 'Include nearby amenities and transport information', default: false, }, userLocation: { type: 'object', description: 'User location for proximity-based ranking', properties: { latitude: { type: 'number' }, longitude: { type: 'number' }, }, }, }, required: ['query'], }, }, { name: 'resolve_postal_code', description: 'Resolve a Singapore postal code to detailed location information with high accuracy.', inputSchema: { type: 'object', properties: { postalCode: { type: 'string', description: 'Singapore 6-digit postal code to resolve', pattern: '^\\d{6}$', }, includeNearbyInfo: { type: 'boolean', description: 'Include nearby transport and amenities', default: false, }, }, required: ['postalCode'], }, }, { name: 'reverse_geocode', description: 'Get address and location information from latitude/longitude coordinates.', inputSchema: { type: 'object', properties: { latitude: { type: 'number', description: 'Latitude coordinate (WGS84)', minimum: 1.0, maximum: 1.5, }, longitude: { type: 'number', description: 'Longitude coordinate (WGS84)', minimum: 103.0, maximum: 104.5, }, radius: { type: 'number', description: 'Search radius in meters (0-500)', minimum: 0, maximum: 500, default: 100, }, includeNearbyAmenities: { type: 'boolean', description: 'Include nearby amenities and transport', default: false, }, }, required: ['latitude', 'longitude'], }, }, ]; } canHandle(toolName) { return ['search_location', 'resolve_postal_code', 'reverse_geocode'].includes(toolName); } async execute(toolName, args) { try { switch (toolName) { case 'search_location': return await this.searchLocation(args); case 'resolve_postal_code': return await this.resolvePostalCode(args); case 'reverse_geocode': return await this.reverseGeocode(args); default: throw new errors_js_1.ValidationError(`Unknown tool: ${toolName}`); } } catch (error) { logger_js_1.logger.error(`Location tool execution failed: ${toolName}`, error); return this.formatError(error, toolName); } } async searchLocation(args) { const { query, maxResults = 5, enableFuzzySearch = true, includeNearbyInfo = false, userLocation, } = args; if (!query?.trim()) { throw new errors_js_1.ValidationError('Query cannot be empty'); } logger_js_1.logger.info(`Searching for location: ${query}`); const results = []; const searchStrategiesUsed = []; const timeInfo = this.timeService.getCurrentTime(); try { // Strategy 1: Check if it's a postal code const postalCode = this.postalCodeService.extractPostalCodeFromText(query); if (postalCode) { logger_js_1.logger.debug(`Detected postal code: ${postalCode}`); const postalResult = await this.postalCodeService.getLocationFromPostalCode(postalCode); if (postalResult) { results.push(postalResult); searchStrategiesUsed.push('postal_code'); } } // Strategy 2: OneMap search with authentication if (results.length < maxResults) { logger_js_1.logger.debug('Performing OneMap search'); const oneMapResults = await this.oneMapService.geocodeWithAuth(query); for (const omResult of oneMapResults.slice(0, maxResults - results.length)) { const locationResult = this.convertOneMapToLocationResult(omResult); // Avoid duplicates (especially postal code results) const isDuplicate = results.some(existing => Math.abs(existing.coordinates.wgs84.lat - locationResult.coordinates.wgs84.lat) < 0.0001 && Math.abs(existing.coordinates.wgs84.lng - locationResult.coordinates.wgs84.lng) < 0.0001); if (!isDuplicate) { results.push(locationResult); } } if (oneMapResults.length > 0) { searchStrategiesUsed.push('onemap_geocoding'); } } // Strategy 3: Fuzzy search and suggestions (if enabled and few results) if (enableFuzzySearch && results.length < 2) { searchStrategiesUsed.push('fuzzy_search'); // Additional fuzzy logic could be implemented here } // Sort results by relevance (postal codes first, then by confidence) results.sort((a, b) => { // Postal codes get highest priority if (a.locationTypes.includes('POSTAL_CODE') && !b.locationTypes.includes('POSTAL_CODE')) { return -1; } if (b.locationTypes.includes('POSTAL_CODE') && !a.locationTypes.includes('POSTAL_CODE')) { return 1; } // Then by confidence return b.confidence - a.confidence; }); // Generate suggestions for improvement const suggestions = this.generateSearchSuggestions(query, results); // Calculate overall confidence const confidence = results.length > 0 ? Math.max(...results.map(r => r.confidence)) : 0; const response = { query, results: results.slice(0, maxResults), suggestions, confidence, searchStrategiesUsed, contextInfo: { singaporeTime: timeInfo.formatted.full, isBusinessHours: timeInfo.businessContext.isBusinessHours, isRushHour: timeInfo.businessContext.isRushHour, }, timestamp: new Date().toISOString(), }; logger_js_1.logger.info(`Location search completed: ${results.length} results found`); return response; } catch (error) { logger_js_1.logger.error('Location search failed', error); throw new errors_js_1.APIError(`Location search failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 'SEARCH_FAILED'); } } async resolvePostalCode(args) { const { postalCode, includeNearbyInfo = false } = args; if (!this.postalCodeService.isValidSingaporePostalCode(postalCode)) { throw new errors_js_1.ValidationError(`Invalid Singapore postal code: ${postalCode}. Must be exactly 6 digits.`); } logger_js_1.logger.info(`Resolving postal code: ${postalCode}`); try { const location = await this.postalCodeService.getLocationFromPostalCode(postalCode); if (!location) { return { success: false, error: `Postal code ${postalCode} not found`, suggestions: [ 'Verify the postal code is correct', 'Try searching for the building name or address instead', ], }; } const timeInfo = this.timeService.getCurrentTime(); const district = this.postalCodeService.getDistrictFromPostalCode(postalCode); const areaType = this.postalCodeService.getAreaType(postalCode); return { success: true, location, metadata: { district, areaType, confidence: location.confidence, lastUpdated: location.metadata.lastUpdated, }, contextInfo: { singaporeTime: timeInfo.formatted.full, isBusinessHours: timeInfo.businessContext.isBusinessHours, isRushHour: timeInfo.businessContext.isRushHour, }, timestamp: new Date().toISOString(), }; } catch (error) { logger_js_1.logger.error(`Postal code resolution failed: ${postalCode}`, error); throw new errors_js_1.APIError(`Failed to resolve postal code: ${error instanceof Error ? error.message : 'Unknown error'}`, 'POSTAL_CODE_FAILED'); } } async reverseGeocode(args) { const { latitude, longitude, radius = 100, includeNearbyAmenities = false } = args; // Validate Singapore coordinates if (latitude < 1.0 || latitude > 1.5 || longitude < 103.0 || longitude > 104.5) { throw new errors_js_1.ValidationError('Coordinates must be within Singapore bounds'); } logger_js_1.logger.info(`Reverse geocoding: ${latitude}, ${longitude}`); try { // Use OneMap reverse geocoding API const response = await this.oneMapService.client.get('/public/revgeocode', { params: { location: `${latitude},${longitude}`, buffer: radius.toString(), addressType: 'All', }, headers: { 'Authorization': await this.oneMapService.ensureValidToken(), }, }); const geocodeInfo = response.data.GeocodeInfo || []; const timeInfo = this.timeService.getCurrentTime(); if (geocodeInfo.length === 0) { return { success: false, message: 'No address found at the specified coordinates', coordinates: { latitude, longitude }, searchRadius: radius, }; } const results = geocodeInfo.map((info) => ({ buildingName: info.BUILDINGNAME !== 'null' ? info.BUILDINGNAME : null, block: info.BLOCK, road: info.ROAD, postalCode: info.POSTALCODE, coordinates: { latitude: parseFloat(info.LATITUDE), longitude: parseFloat(info.LONGITUDE), }, distance: this.calculateDistance(latitude, longitude, parseFloat(info.LATITUDE), parseFloat(info.LONGITUDE)), })); return { success: true, results, searchLocation: { latitude, longitude }, searchRadius: radius, contextInfo: { singaporeTime: timeInfo.formatted.full, isBusinessHours: timeInfo.businessContext.isBusinessHours, isRushHour: timeInfo.businessContext.isRushHour, }, timestamp: new Date().toISOString(), }; } catch (error) { logger_js_1.logger.error('Reverse geocoding failed', error); throw new errors_js_1.APIError(`Reverse geocoding failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 'REVERSE_GEOCODE_FAILED'); } } convertOneMapToLocationResult(oneMapResult) { const lat = parseFloat(oneMapResult.LATITUDE); const lng = parseFloat(oneMapResult.LONGITUDE); const x = parseFloat(oneMapResult.X); const y = parseFloat(oneMapResult.Y); const buildingName = oneMapResult.BUILDING && oneMapResult.BUILDING !== 'NIL' ? oneMapResult.BUILDING : undefined; const blockNumber = oneMapResult.BLK_NO && oneMapResult.BLK_NO !== 'NIL' ? oneMapResult.BLK_NO : undefined; const name = buildingName || oneMapResult.ROAD_NAME || 'Unknown Location'; const displayName = oneMapResult.ADDRESS || `${name}, Singapore`; return { id: `onemap_${oneMapResult.SEARCHVAL?.replace(/\s+/g, '_').toLowerCase()}`, name, displayName, coordinates: { wgs84: { lat, lng }, svy21: { x, y }, }, address: { buildingName, blockNumber, streetName: oneMapResult.ROAD_NAME, postalCode: oneMapResult.POSTAL, formattedAddress: oneMapResult.ADDRESS, }, locationTypes: this.determineLocationTypes(oneMapResult), confidence: 0.85, // Good confidence for OneMap results metadata: { source: 'onemap', lastUpdated: new Date().toISOString(), isLandmark: !!buildingName, searchStrategy: 'onemap_geocoding', }, }; } determineLocationTypes(oneMapResult) { const types = []; if (oneMapResult.POSTAL) types.push('POSTAL_CODE'); if (oneMapResult.BUILDING && oneMapResult.BUILDING !== 'NIL') types.push('BUILDING'); if (oneMapResult.ROAD_NAME) types.push('STREET'); // Check for MRT/LRT stations const searchVal = oneMapResult.SEARCHVAL?.toLowerCase() || ''; const building = oneMapResult.BUILDING?.toLowerCase() || ''; if (searchVal.includes('mrt') || building.includes('mrt')) types.push('MRT_STATION'); if (searchVal.includes('lrt') || building.includes('lrt')) types.push('LRT_STATION'); if (searchVal.includes('bus') || building.includes('interchange')) types.push('BUS_INTERCHANGE'); return types.length > 0 ? types : ['GENERAL']; } generateSearchSuggestions(query, results) { const suggestions = []; if (results.length === 0) { suggestions.push({ text: 'Try searching with a postal code (6 digits)', type: 'expansion', confidence: 0.8, reason: 'Postal codes provide the most accurate results', }); suggestions.push({ text: 'Include "MRT" or "LRT" for train stations', type: 'expansion', confidence: 0.7, reason: 'Transport stations are common search targets', }); } else if (results.length === 1) { suggestions.push({ text: 'Add more specific terms for additional results', type: 'expansion', confidence: 0.6, reason: 'Broader search might find related locations', }); } return suggestions; } calculateDistance(lat1, lon1, lat2, lon2) { const R = 6371e3; // Earth's radius in metres const φ1 = lat1 * Math.PI / 180; const φ2 = lat2 * Math.PI / 180; const Δφ = (lat2 - lat1) * Math.PI / 180; const Δλ = (lon2 - lon1) * Math.PI / 180; const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + Math.cos1) * Math.cos2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return Math.round(R * c); // Distance in metres } } exports.LocationSearchTool = LocationSearchTool;