UNPKG

@siva-sub/mcp-public-transport

Version:

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

270 lines (269 loc) 11.8 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.BusStopSearchTool = void 0; const zod_1 = require("zod"); const base_js_1 = require("../base.js"); const logger_js_1 = require("../../utils/logger.js"); const haversine_distance_1 = __importDefault(require("haversine-distance")); const BusStopSearchInputSchema = zod_1.z.object({ query: zod_1.z.string().min(1, 'Search query cannot be empty'), maxResults: zod_1.z.number().min(1).max(50).default(10), enableFuzzySearch: zod_1.z.boolean().default(true), minScore: zod_1.z.number().min(0).max(1).default(0.3), includeDistance: zod_1.z.boolean().default(true), userLocation: zod_1.z.object({ latitude: zod_1.z.number(), longitude: zod_1.z.number(), }).optional(), }); class BusStopSearchTool extends base_js_1.BaseTool { constructor(ltaService, oneMapService, fuzzySearchService) { super(); this.ltaService = ltaService; this.oneMapService = oneMapService; this.fuzzySearchService = fuzzySearchService; } getDefinitions() { return [ { name: 'search_bus_stops', description: 'Advanced search for bus stops using fuzzy matching, Singapore abbreviations, and intelligent pattern recognition. Supports queries like "Opp Blk 910", "Bef Jurong East MRT", "CP near Orchard".', inputSchema: this.createSchema({ query: { type: 'string', description: 'Search query - supports Singapore abbreviations (Blk, Opp, Bef, Aft, CP, Stn), HDB block numbers, landmarks, and partial descriptions', minLength: 1, }, maxResults: { type: 'number', minimum: 1, maximum: 50, default: 10, description: 'Maximum number of results to return', }, enableFuzzySearch: { type: 'boolean', default: true, description: 'Enable fuzzy matching for typos and variations', }, minScore: { type: 'number', minimum: 0, maximum: 1, default: 0.3, description: 'Minimum similarity score for results (0.0 to 1.0)', }, includeDistance: { type: 'boolean', default: true, description: 'Include distance calculations if user location provided', }, userLocation: { type: 'object', description: 'User location for distance-based ranking', properties: { latitude: { type: 'number' }, longitude: { type: 'number' }, }, }, }, ['query']), }, ]; } canHandle(toolName) { return toolName === 'search_bus_stops'; } async execute(toolName, args) { try { const { query, maxResults, enableFuzzySearch, minScore, includeDistance, userLocation, } = BusStopSearchInputSchema.parse(args); logger_js_1.logger.info(`Searching bus stops with query: "${query}"`, { fuzzyEnabled: enableFuzzySearch, minScore, maxResults, }); // Get all bus stops const allBusStops = await this.ltaService.getAllBusStops(); let searchResults; if (enableFuzzySearch) { // Use fuzzy search searchResults = this.fuzzySearchService.search(query, allBusStops, (busStop) => [ busStop.description, busStop.roadName, `${busStop.description} ${busStop.roadName}`, `${busStop.roadName} ${busStop.description}`, ], minScore, maxResults * 2 // Get more results for distance sorting ); } else { // Use simple substring search searchResults = this.simpleSearch(query, allBusStops, maxResults * 2); } // Enhance results with additional information const enhancedResults = searchResults.map((result) => { const busStop = result.item; const locationPatterns = this.fuzzySearchService.extractLocationPatterns(`${busStop.description} ${busStop.roadName}`); const queryVariations = enableFuzzySearch ? this.fuzzySearchService.expandAbbreviations(query) : [query]; let distanceMeters; let walkingTimeMinutes; if (includeDistance && userLocation) { distanceMeters = (0, haversine_distance_1.default)({ latitude: userLocation.latitude, longitude: userLocation.longitude }, { latitude: busStop.latitude, longitude: busStop.longitude }); walkingTimeMinutes = Math.ceil(distanceMeters / 80); // 80m/min walking speed } return { busStopCode: busStop.busStopCode, description: busStop.description, roadName: busStop.roadName, latitude: busStop.latitude, longitude: busStop.longitude, matchScore: result.score, matchedFields: result.matches, distanceMeters, walkingTimeMinutes, locationPatterns, searchContext: { queryVariations, bestMatch: result.matches[0] || busStop.description, }, }; }); // Sort results by relevance and distance const sortedResults = this.sortResults(enhancedResults, userLocation); const finalResults = sortedResults.slice(0, maxResults); // Generate search suggestions const suggestions = this.generateSearchSuggestions(query, finalResults); return { query, searchType: enableFuzzySearch ? 'fuzzy' : 'simple', totalFound: searchResults.length, results: finalResults, suggestions, searchMetadata: { fuzzySearchEnabled: enableFuzzySearch, minScore, queryVariations: enableFuzzySearch ? this.fuzzySearchService.expandAbbreviations(query) : [query], userLocationProvided: !!userLocation, distanceCalculated: includeDistance && !!userLocation, }, timestamp: new Date().toISOString(), }; } catch (error) { logger_js_1.logger.error(`Bus stop search failed: ${toolName}`, error); return this.formatError(error, toolName); } } simpleSearch(query, busStops, maxResults) { const queryLower = query.toLowerCase(); const results = []; for (const busStop of busStops) { const description = busStop.description.toLowerCase(); const roadName = busStop.roadName.toLowerCase(); let score = 0; const matches = []; // Check exact matches first if (description.includes(queryLower)) { score = Math.max(score, 0.8); matches.push(busStop.description); } if (roadName.includes(queryLower)) { score = Math.max(score, 0.7); matches.push(busStop.roadName); } if (score > 0) { results.push({ item: busStop, score, matches, }); } } return results .sort((a, b) => b.score - a.score) .slice(0, maxResults); } sortResults(results, userLocation) { return results.sort((a, b) => { // Primary sort: match score (higher is better) const scoreDiff = b.matchScore - a.matchScore; if (Math.abs(scoreDiff) > 0.1) return scoreDiff; // Secondary sort: distance (if available, closer is better) if (userLocation && a.distanceMeters !== undefined && b.distanceMeters !== undefined) { return a.distanceMeters - b.distanceMeters; } // Tertiary sort: alphabetical by description return a.description.localeCompare(b.description); }); } generateSearchSuggestions(query, results) { const suggestions = []; if (results.length === 0) { suggestions.push({ text: 'Try using Singapore abbreviations like "Blk" for Block, "Opp" for Opposite', type: 'tip', confidence: 0.9, reason: 'No results found, abbreviations might help', }); suggestions.push({ text: 'Include road name or nearby landmarks in your search', type: 'expansion', confidence: 0.8, reason: 'Broader search terms often yield better results', }); // Check if query contains numbers (might be looking for HDB blocks) if (/\d+/.test(query)) { suggestions.push({ text: 'For HDB blocks, try "Blk [number]" format (e.g., "Blk 123")', type: 'alternative', confidence: 0.7, reason: 'Query contains numbers, might be HDB block search', }); } } else if (results.length < 3) { suggestions.push({ text: 'Try shorter or more general terms for more results', type: 'expansion', confidence: 0.6, reason: 'Few results found, broader search might help', }); } // Analyze common patterns in results for suggestions const commonRoads = this.findCommonPatterns(results, 'roadName'); if (commonRoads.length > 0) { suggestions.push({ text: `Related areas: ${commonRoads.slice(0, 3).join(', ')}`, type: 'alternative', confidence: 0.5, reason: 'Found stops on related roads', }); } return suggestions; } findCommonPatterns(results, field) { const patterns = new Map(); for (const result of results) { const value = result[field]; if (typeof value === 'string') { const words = value.toLowerCase().split(/\s+/); for (const word of words) { if (word.length > 3) { // Only consider meaningful words patterns.set(word, (patterns.get(word) || 0) + 1); } } } } return Array.from(patterns.entries()) .filter(([_, count]) => count > 1) .sort((a, b) => b[1] - a[1]) .map(([word]) => word); } } exports.BusStopSearchTool = BusStopSearchTool;