UNPKG

@siva-sub/mcp-public-transport

Version:

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

683 lines (682 loc) 32.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.JourneyPlanningTool = void 0; const zod_1 = require("zod"); const base_js_1 = require("../base.js"); const validation_js_1 = require("../../utils/validation.js"); const logger_js_1 = require("../../utils/logger.js"); // Enhanced input schema supporting multiple input types const JourneyPlanningInputSchema = zod_1.z.object({ fromLocation: zod_1.z.union([ zod_1.z.string().min(1), zod_1.z.object({ postalCode: zod_1.z.string().regex(/^\d{6}$/, 'Postal code must be 6 digits'), }), zod_1.z.object({ latitude: zod_1.z.number().min(1.0).max(1.5), longitude: zod_1.z.number().min(103.0).max(104.5), name: zod_1.z.string().optional(), }), ]), toLocation: zod_1.z.union([ zod_1.z.string().min(1), zod_1.z.object({ postalCode: zod_1.z.string().regex(/^\d{6}$/, 'Postal code must be 6 digits'), }), zod_1.z.object({ latitude: zod_1.z.number().min(1.0).max(1.5), longitude: zod_1.z.number().min(103.0).max(104.5), name: zod_1.z.string().optional(), }), ]), departureTime: zod_1.z.string().optional(), arrivalTime: zod_1.z.string().optional(), mode: zod_1.z.enum(['PUBLIC_TRANSPORT', 'WALK', 'DRIVE', 'CYCLE', 'AUTO']).default('AUTO'), preferences: zod_1.z.object({ maxWalkDistance: zod_1.z.number().min(100).max(2000).default(1000), avoidCrowds: zod_1.z.boolean().default(false), fastest: zod_1.z.boolean().default(true), cheapest: zod_1.z.boolean().default(false), minimizeTransfers: zod_1.z.boolean().default(true), accessibilityRequired: zod_1.z.boolean().default(false), weatherAware: zod_1.z.boolean().default(true), allowDriving: zod_1.z.boolean().default(true), }).default({}), maxAlternatives: zod_1.z.number().min(1).max(5).default(3), }); class JourneyPlanningTool extends base_js_1.BaseTool { constructor(oneMapService, ltaService) { super(); this.oneMapService = oneMapService; this.ltaService = ltaService; } getDefinitions() { return [ { name: 'plan_comprehensive_journey', description: 'Plan a comprehensive journey using multiple transport modes with real-time data', inputSchema: this.createSchema({ fromLocation: { oneOf: [ { type: 'string', description: 'Starting location as address or landmark (e.g., "Orchard MRT", "Marina Bay Sands")', }, { type: 'object', properties: { postalCode: { type: 'string', pattern: '^\\d{6}$', description: 'Singapore postal code (6 digits)', }, }, required: ['postalCode'], description: 'Starting location as postal code', }, { type: 'object', properties: { latitude: { type: 'number', minimum: 1.0, maximum: 1.5, description: 'Latitude coordinate', }, longitude: { type: 'number', minimum: 103.0, maximum: 104.5, description: 'Longitude coordinate', }, name: { type: 'string', description: 'Optional location name', }, }, required: ['latitude', 'longitude'], description: 'Starting location as coordinates', }, ], description: 'Starting location - provide as address, postal code, or coordinates', }, toLocation: { oneOf: [ { type: 'string', description: 'Destination location as address or landmark (e.g., "Changi Airport", "NUS")', }, { type: 'object', properties: { postalCode: { type: 'string', pattern: '^\\d{6}$', description: 'Singapore postal code (6 digits)', }, }, required: ['postalCode'], description: 'Destination location as postal code', }, { type: 'object', properties: { latitude: { type: 'number', minimum: 1.0, maximum: 1.5, description: 'Latitude coordinate', }, longitude: { type: 'number', minimum: 103.0, maximum: 104.5, description: 'Longitude coordinate', }, name: { type: 'string', description: 'Optional location name', }, }, required: ['latitude', 'longitude'], description: 'Starting location as coordinates', }, ], description: 'Destination location - provide as address, postal code, or coordinates', }, departureTime: { type: 'string', description: 'Departure time in ISO format (optional)', }, arrivalTime: { type: 'string', description: 'Desired arrival time in ISO format (optional)', }, mode: { type: 'string', enum: ['PUBLIC_TRANSPORT', 'WALK', 'DRIVE', 'CYCLE', 'AUTO'], default: 'AUTO', description: 'Transport mode: AUTO (smart selection), PUBLIC_TRANSPORT (bus/train), WALK, DRIVE (taxi/car), CYCLE', }, preferences: { type: 'object', properties: { maxWalkDistance: { type: 'number', minimum: 100, maximum: 2000, default: 1000, description: 'Maximum walking distance in meters', }, avoidCrowds: { type: 'boolean', default: false, description: 'Avoid crowded routes', }, fastest: { type: 'boolean', default: true, description: 'Prioritize fastest route', }, cheapest: { type: 'boolean', default: false, description: 'Prioritize cheapest route', }, minimizeTransfers: { type: 'boolean', default: true, description: 'Minimize number of transfers', }, accessibilityRequired: { type: 'boolean', default: false, description: 'Ensure wheelchair accessibility', }, weatherAware: { type: 'boolean', default: true, description: 'Adjust walking times based on weather', }, allowDriving: { type: 'boolean', default: true, description: 'Include driving/taxi options', }, }, description: 'Journey planning preferences', }, maxAlternatives: { type: 'number', minimum: 1, maximum: 5, default: 3, description: 'Maximum number of alternative routes to return', }, }, ['fromLocation', 'toLocation']), }, ]; } canHandle(toolName) { return toolName === 'plan_comprehensive_journey'; } async execute(toolName, args) { try { const input = (0, validation_js_1.validateInput)(JourneyPlanningInputSchema, args); logger_js_1.logger.info('Planning comprehensive journey', { from: input.fromLocation, to: input.toLocation, mode: input.mode, }); // Resolve locations (handle different input types) const [fromLocation, toLocation] = await Promise.all([ this.resolveLocation(input.fromLocation), this.resolveLocation(input.toLocation), ]); if (!fromLocation) { return { error: `Could not find location: ${JSON.stringify(input.fromLocation)}`, timestamp: new Date().toISOString(), }; } if (!toLocation) { return { error: `Could not find location: ${JSON.stringify(input.toLocation)}`, timestamp: new Date().toISOString(), }; } // Parse time parameters const departureTime = input.departureTime ? new Date(input.departureTime) : undefined; const arrivalTime = input.arrivalTime ? new Date(input.arrivalTime) : undefined; // Determine the best mode if AUTO is selected const resolvedMode = input.mode === 'AUTO' ? this.selectOptimalMode(fromLocation, toLocation) : input.mode || 'PUBLIC_TRANSPORT'; // Ensure preferences have defaults const preferences = { maxWalkDistance: 1000, avoidCrowds: false, fastest: true, cheapest: false, minimizeTransfers: true, accessibilityRequired: false, weatherAware: true, allowDriving: true, ...input.preferences, }; // Plan primary route with retry logic const primaryRoute = await this.planRouteWithRetry(fromLocation, toLocation, { mode: this.mapModeToOneMapMode(resolvedMode), departureTime, arrivalTime, maxWalkDistance: preferences.maxWalkDistance, }); if (!primaryRoute) { return { error: 'No route found between the specified locations', details: 'Tried multiple routing strategies but could not find a viable route', suggestions: [ 'Check if the locations are accessible by the selected transport mode', 'Try increasing maxWalkDistance if using public transport', 'Consider using a different transport mode (AUTO, DRIVE, WALK)', 'Verify that both locations are within Singapore' ], fromLocation: input.fromLocation, toLocation: input.toLocation, resolvedFrom: fromLocation, resolvedTo: toLocation, timestamp: new Date().toISOString(), }; } // Enhance with real-time data const enhancedRoute = await this.enhanceWithRealTimeData(primaryRoute); // Get alternative routes const alternatives = await this.getAlternativeRoutes(fromLocation, toLocation, resolvedMode, preferences); // Get current transport alerts const alerts = await this.getTransportAlerts(); return { primaryRoute: enhancedRoute, alternatives, alerts, summary: this.createJourneySummary(enhancedRoute, alternatives), timestamp: new Date().toISOString(), }; } catch (error) { logger_js_1.logger.error(`Journey planning tool failed: ${toolName}`, error); return this.formatError(error, toolName); } } async resolveLocation(locationInput) { try { let parsedInput = locationInput; // Handle stringified JSON objects (critical fix) if (typeof locationInput === 'string') { try { const parsed = JSON.parse(locationInput); if (parsed && typeof parsed === 'object') { parsedInput = parsed; logger_js_1.logger.debug('Successfully parsed JSON string input', { original: locationInput, parsed }); } } catch (parseError) { // Not JSON, treat as regular address string logger_js_1.logger.debug('Input is not JSON, treating as address string', { input: locationInput }); return await this.oneMapService.geocode(locationInput); } } // Handle coordinate input (from parsed JSON or direct object) if (parsedInput && typeof parsedInput === 'object' && 'latitude' in parsedInput && 'longitude' in parsedInput) { // Validate Singapore coordinate bounds const lat = parseFloat(parsedInput.latitude); const lng = parseFloat(parsedInput.longitude); if (lat < 1.0 || lat > 1.5 || lng < 103.0 || lng > 104.5) { logger_js_1.logger.warn('Coordinates outside Singapore bounds', { lat, lng }); return null; } return { latitude: lat, longitude: lng, name: parsedInput.name || 'Custom Location', address: parsedInput.name || `${lat.toFixed(6)}, ${lng.toFixed(6)}`, }; } // Handle postal code input (from parsed JSON or direct object) if (parsedInput && typeof parsedInput === 'object' && 'postalCode' in parsedInput) { const postalCode = parsedInput.postalCode.toString(); if (!/^\d{6}$/.test(postalCode)) { logger_js_1.logger.warn('Invalid postal code format', { postalCode }); return null; } return await this.oneMapService.geocode(postalCode); } // Handle string input (address/name) - already handled above for JSON case if (typeof parsedInput === 'string') { return await this.oneMapService.geocode(parsedInput); } logger_js_1.logger.warn('Unrecognized location input format', { input: locationInput, parsed: parsedInput }); return null; } catch (error) { logger_js_1.logger.error('Location resolution failed', { error, input: locationInput }); return null; } } selectOptimalMode(from, to) { const distance = this.calculateDistance(from.latitude, from.longitude, to.latitude, to.longitude); // Walking for short distances (under 1km) if (distance < 1000) return 'WALK'; // Driving for long distances (over 15km) if (distance > 15000) return 'DRIVE'; // Public transport for medium distances return 'PUBLIC_TRANSPORT'; } mapModeToOneMapMode(mode) { if (mode === 'CYCLE') return 'WALK'; // OneMap doesn't support cycle, use walk as fallback return mode; } async planRouteWithRetry(from, to, options, retries = 3) { for (let attempt = 1; attempt <= retries; attempt++) { try { logger_js_1.logger.debug(`Route planning attempt ${attempt}/${retries}`, { from: from.name, to: to.name, options }); const result = await this.oneMapService.planRoute(from, to, options); if (result) { logger_js_1.logger.info(`Route planning successful on attempt ${attempt}`, { duration: result.totalDuration, distance: result.totalDistance }); return result; } // If no result but no error, log and continue to next attempt logger_js_1.logger.debug(`No route found on attempt ${attempt}, will retry`); } catch (error) { logger_js_1.logger.warn(`Route planning attempt ${attempt} failed`, { error: error instanceof Error ? error.message : 'Unknown error', attempt, retries }); // If it's an authentication error, don't retry immediately if (error?.response?.status === 401) { logger_js_1.logger.warn('Authentication error detected, will retry after delay'); await new Promise(resolve => setTimeout(resolve, 2000)); } } // Try alternative routing strategies on final attempt if (attempt === retries) { logger_js_1.logger.info('Trying alternative routing strategies'); return await this.tryAlternativeRouting(from, to, options); } // Wait before retry with exponential backoff const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000); logger_js_1.logger.debug(`Waiting ${delay}ms before retry`); await new Promise(resolve => setTimeout(resolve, delay)); } return null; } async tryAlternativeRouting(from, to, originalOptions) { // Try different route types as fallbacks const fallbackModes = ['DRIVE', 'WALK', 'PUBLIC_TRANSPORT']; for (const mode of fallbackModes) { if (mode === originalOptions.mode) continue; // Skip the original mode try { logger_js_1.logger.debug(`Trying alternative routing mode: ${mode}`); const alternativeOptions = { ...originalOptions, mode, }; // Adjust parameters for different modes if (mode === 'WALK') { // For walking, remove PT-specific parameters delete alternativeOptions.maxWalkDistance; } else if (mode === 'DRIVE') { // For driving, remove PT-specific parameters delete alternativeOptions.maxWalkDistance; } const result = await this.oneMapService.planRoute(from, to, alternativeOptions); if (result) { logger_js_1.logger.info(`Alternative routing successful with ${mode}`, { duration: result.totalDuration, distance: result.totalDistance }); return result; } } catch (error) { logger_js_1.logger.warn(`Alternative routing failed for ${mode}`, { error: error instanceof Error ? error.message : 'Unknown error' }); } } // If all alternatives fail, try a simple direct route try { logger_js_1.logger.debug('Trying simple direct route calculation'); return await this.createDirectRoute(from, to); } catch (error) { logger_js_1.logger.error('All routing strategies failed', error); return null; } } async createDirectRoute(from, to) { try { const distance = this.calculateDistance(from.latitude, from.longitude, to.latitude, to.longitude); // Create a simple walking route as last resort const walkingSpeed = 1.4; // m/s (average walking speed) const duration = Math.round(distance / walkingSpeed); const directRoute = { segments: [{ mode: 'WALK', duration, distance, instructions: [ `Walk directly from ${from.name || 'origin'} to ${to.name || 'destination'}`, `Distance: ${(distance / 1000).toFixed(1)}km`, `Estimated time: ${Math.round(duration / 60)} minutes` ], startLocation: from, endLocation: to, }], totalDuration: duration, totalDistance: distance, totalCost: 0, totalWalkDistance: distance, transfers: 0, summary: `${Math.round(duration / 60)} min direct walk (${(distance / 1000).toFixed(1)}km)`, }; logger_js_1.logger.info('Created direct route as fallback', { distance: distance, duration: duration }); return directRoute; } catch (error) { logger_js_1.logger.error('Failed to create direct route', error); return null; } } async enhanceWithRealTimeData(route) { const enhancedSegments = await Promise.all(route.segments.map(async (segment) => { const enhanced = { ...segment }; // Enhance bus segments with real-time arrivals if (segment.mode === 'BUS' && segment.startLocation) { try { // Find nearby bus stops // This is a simplified version - in reality you'd need to map // the segment's start location to actual bus stop codes enhanced.realTimeInfo = { status: 'Real-time data integration would go here', note: 'Bus arrival times would be fetched for actual stops', }; } catch (error) { logger_js_1.logger.warn('Failed to enhance bus segment with real-time data', error); } } return enhanced; })); return { ...route, segments: enhancedSegments, realTimeEnhanced: true, }; } async getAlternativeRoutes(from, to, primaryMode, preferences) { const alternatives = []; try { // Calculate distance between points to determine practical alternatives const distance = this.calculateDistance(from.latitude, from.longitude, to.latitude, to.longitude); // Walking alternative (only if reasonable distance and not primary mode) if (primaryMode !== 'WALK' && distance <= 5000) { // Max 5km for walking const walkingRoute = await this.oneMapService.planRoute(from, to, { mode: 'WALK' }); if (walkingRoute && walkingRoute.totalDuration <= 3600) { // Max 1 hour walk alternatives.push({ ...walkingRoute, summary: `Walking route: ${walkingRoute.summary}`, }); } } // Driving alternative (if not primary mode) if (primaryMode !== 'DRIVE') { const drivingRoute = await this.oneMapService.planRoute(from, to, { mode: 'DRIVE' }); if (drivingRoute) { // Add taxi cost estimate const taxiCost = this.estimateTaxiCost(drivingRoute.totalDistance, drivingRoute.totalDuration); alternatives.push({ ...drivingRoute, totalCost: taxiCost, summary: `Taxi/driving route: ${drivingRoute.summary}`, }); } } // Public transport with different preferences (if not already primary) if (primaryMode === 'PUBLIC_TRANSPORT' && preferences) { const altPreferences = { ...preferences }; if (preferences.fastest) { altPreferences.cheapest = true; altPreferences.fastest = false; const cheaperRoute = await this.oneMapService.planRoute(from, to, { mode: 'PUBLIC_TRANSPORT', maxWalkDistance: altPreferences.maxWalkDistance, }); if (cheaperRoute && this.isDifferentRoute(cheaperRoute, alternatives)) { alternatives.push({ ...cheaperRoute, summary: `Budget-friendly: ${cheaperRoute.summary}`, }); } } } } catch (error) { logger_js_1.logger.warn('Failed to get some alternative routes', error); } // Filter and sort alternatives return this.filterAndSortAlternatives(alternatives); } 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 R * c; } isDifferentRoute(newRoute, existingRoutes) { // Check if this route is significantly different from existing ones for (const existing of existingRoutes) { const timeDiff = Math.abs(newRoute.totalDuration - existing.totalDuration); const costDiff = Math.abs((newRoute.totalCost || 0) - (existing.totalCost || 0)); // If time difference is less than 5 minutes and cost difference is less than $0.50, consider it similar if (timeDiff < 300 && costDiff < 0.5) { return false; } } return true; } filterAndSortAlternatives(alternatives) { // Filter out impractical routes const filtered = alternatives.filter(route => { // Remove routes that are too long (over 4 hours) if (route.totalDuration > 14400) return false; // Remove walking routes over 22km (like the problematic one) if (route.totalWalkDistance > 22000) return false; // Remove routes with excessive instructions (over 50 steps) const totalInstructions = route.segments.reduce((sum, segment) => sum + segment.instructions.length, 0); if (totalInstructions > 50) return false; return true; }); // Sort by practicality (time, then cost) return filtered.sort((a, b) => { // First sort by total time const timeDiff = a.totalDuration - b.totalDuration; if (Math.abs(timeDiff) > 300) return timeDiff; // If time difference > 5 minutes // Then by cost return (a.totalCost || 0) - (b.totalCost || 0); }).slice(0, 3); // Limit to top 3 alternatives } estimateTaxiCost(distanceM, durationSec) { const distanceKm = distanceM / 1000; const durationMin = durationSec / 60; // Singapore taxi fare estimation let cost = 3.90; // Base fare // Distance component if (distanceKm > 1) { cost += (distanceKm - 1) * 0.55; } // Time component (for waiting/slow traffic) if (durationMin > 10) { cost += (durationMin - 10) * 0.55 / 5; // $0.55 per 45-second block } // Peak hour surcharge (simplified) const now = new Date(); const hour = now.getHours(); if ((hour >= 7 && hour <= 9) || (hour >= 17 && hour <= 20)) { cost *= 1.25; } return Math.round(cost * 100) / 100; // Round to nearest cent } async getTransportAlerts() { try { const trainAlerts = await this.ltaService.getTrainServiceAlerts(); return trainAlerts.filter(alert => alert.status === 'Disrupted'); } catch (error) { logger_js_1.logger.warn('Failed to get transport alerts', error); return []; } } createJourneySummary(primaryRoute, alternatives) { const primary = primaryRoute; const duration = Math.round(primary.totalDuration / 60); const cost = primary.totalCost || 0; let summary = `Primary: ${duration} min`; if (cost > 0) summary += `, $${cost.toFixed(2)}`; if (alternatives.length > 0) { const altSummaries = alternatives.slice(0, 2).map(alt => { const altDuration = Math.round(alt.totalDuration / 60); const altCost = alt.totalCost || 0; let altStr = `${altDuration} min`; if (altCost > 0) altStr += `, $${altCost.toFixed(2)}`; return alt.summary || altStr; }); summary += ` | Alternatives: ${altSummaries.join(', ')}`; } return summary; } } exports.JourneyPlanningTool = JourneyPlanningTool;