@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
JavaScript
"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;