From f685ab8ff9b48f385b26da3548dce1f350326c26 Mon Sep 17 00:00:00 2001 From: Mayur Virendra Date: Tue, 9 Apr 2024 02:17:50 +0530 Subject: [PATCH 1/3] Added endpoing to trigger an exception on a route --- controllers/ControlCenter.js | 63 ++++++++++++++++---- package.json | 1 + server.js | 4 +- services/DBService.js | 32 ++++++++++ services/MapService.js | 58 ++++++++++++++++++ tests/apis/agent.test.js | 2 +- tests/unit/controllers/controlCenter.test.js | 23 +++++++ tests/unit/services/maps.test.js | 12 ++++ 8 files changed, 182 insertions(+), 13 deletions(-) diff --git a/controllers/ControlCenter.js b/controllers/ControlCenter.js index 66960d7..28e8f76 100644 --- a/controllers/ControlCenter.js +++ b/controllers/ControlCenter.js @@ -9,6 +9,8 @@ import { CANCEL_BOOKING_MESSAGE, TOURISM_STRAPI_URL } from '../utils/constants.js' +import DBService from '../services/DBService.js' +import MapsService from '../services/MapService.js' const action = new Actions() @@ -46,7 +48,7 @@ export const cancelBooking = async (req, res) => { } return res.status(200).send({ message: `Notification ${statusMessage}`, status:true }) } - + return res.status(200).send({ message: 'Cancel Booking Failed', status:false }) } catch (error) { logger.error(error.message) @@ -65,7 +67,7 @@ export const updateCatalog = async (req, res) => { }, },{ Authorization: `Bearer ${process.env.STRAPI_TOURISM_TOKEN}`}) const notifyResponse = await action.send_message(userNo, messageBody) - + if(!notifyResponse || notifyResponse.deliveryStatus === "failed"){ throw new Error('Notification Failed') } @@ -84,14 +86,53 @@ export const notify = async (req, res) => { const sendWhatsappNotificationResponse = await action.send_message( userNo, messageBody - ) - if(sendWhatsappNotificationResponse.deliveryStatus === "failed"){ - return res.status(400).json({...sendWhatsappNotificationResponse, status:false}) + ) + if(sendWhatsappNotificationResponse.deliveryStatus === "failed"){ + return res.status(400).json({...sendWhatsappNotificationResponse, status:false}) + } + sendWhatsappNotificationResponse.deliveryStatus = 'delivered' + return res.status(200).json({...sendWhatsappNotificationResponse, status:true}) + } catch (error) { + logger.error(error.message) + return res.status(400).send({ message: error.message, status:false }) } - sendWhatsappNotificationResponse.deliveryStatus = 'delivered' - return res.status(200).json({...sendWhatsappNotificationResponse, status:true}) - } catch (error) { - logger.error(error.message) - return res.status(400).send({ message: error.message, status:false }) } -} \ No newline at end of file + + export const triggerExceptionOnLocation = async (req, res) => { + const {point, message} = req.body; // needs to be an array with 2 numbers [lat, long] + const db = new DBService(); + const mapService = new MapsService(); + + if(point && message){ + // get all active sessions + const sessions = await db.get_all_sessions(); + logger.info(`Got ${sessions.length} sessions.`) + + // check if point exists on route + for(let session of sessions){ + const selected_route = session.data.selected_route; + if(selected_route?.overview_polyline?.points) { + const status = await mapService.checkGpsOnPolygon(point, selected_route?.overview_polyline?.points) + + logger.info(`Status of gps point ${JSON.stringify(point)} on route ${selected_route.summary} is ${status}`) + // send whatsapp and add to context + if(status){ + try{ + const reply_message = `${message}. Do you want to find alternate routes?` + await action.send_message(session.key, reply_message); + + // update session + if(!session.data.text) session.data.text=[] + session.data.text.push({role: 'assistant', content: reply_message}); + + await db.update_session(session.key, session); + } + catch(e){ + logger.error(e); + } + } + } + } + } + else res.status(400).send('Point and message are required in the body.') + } \ No newline at end of file diff --git a/package.json b/package.json index 5174459..6aad61b 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "license": "ISC", "dependencies": { "@googlemaps/google-maps-services-js": "^3.3.42", + "@mapbox/polyline": "^1.2.1", "axios": "^1.6.7", "body-parser": "^1.20.2", "chai": "^5.0.0", diff --git a/server.js b/server.js index f442e67..d86d442 100644 --- a/server.js +++ b/server.js @@ -9,7 +9,8 @@ import DBService from './services/DBService.js' import { cancelBooking, updateCatalog, - notify + notify, + triggerExceptionOnLocation } from './controllers/ControlCenter.js' const app = express() app.use(cors()) @@ -25,6 +26,7 @@ app.post('/webhook', messageController.process_text) app.post('/notify', notify) app.post('/cancel-booking', cancelBooking) app.post('/update-catalog', updateCatalog) +app.post('/trigger-exception', triggerExceptionOnLocation) // Reset all sessions diff --git a/services/DBService.js b/services/DBService.js index 181ae15..b364c26 100644 --- a/services/DBService.js +++ b/services/DBService.js @@ -117,6 +117,38 @@ class DBService { logger.info(response) return response } + + async get_all_sessions(){ + const sessions = []; + let cursor = '0'; + + try{ + do { + // Use the SCAN command to iteratively retrieve keys that match the "session:*" pattern. + const reply = await this.redisClient.scan(cursor, { + MATCH: '*', + COUNT: 100, // Adjust based on your expected load + }); + + cursor = reply.cursor; + const keys = reply.keys; + + // For each key, get the session data and add it to the sessions array. + for (let key of keys) { + const sessionData = await this.redisClient.get(key); + sessions.push({ + key, + data: JSON.parse(sessionData), + }); + } + } while (cursor !== 0); + } + catch(e){ + logger.error(e); + } + + return sessions; + } } export default DBService; \ No newline at end of file diff --git a/services/MapService.js b/services/MapService.js index aff45e6..2e98be0 100644 --- a/services/MapService.js +++ b/services/MapService.js @@ -2,6 +2,7 @@ import {Client} from "@googlemaps/google-maps-services-js"; import logger from '../utils/logger.js' import AI from './AI.js' const ai = new AI(); +import polyline from '@mapbox/polyline'; class MapsService { @@ -123,6 +124,63 @@ class MapsService { // logger.info(`Generated routes response : ${JSON.stringify(response, null, 2)}`); return response; } + + /** + * Check if a GPS point is on a polyline + * + * @param {Array} point - The GPS point to check, in [latitude, longitude] format. + * @param {String} encodedPolyline - The encoded overview polyline from Google Maps Directions API. + * @param {Number} tolerance - The maximum distance (in meters) for a point to be considered on the polyline. + * @returns {Boolean} true if the point is on the polyline within the specified tolerance, false otherwise. + */ + async checkGpsOnPolygon(point, encodedPolyline, tolerance = 500){ + // Decode the polyline to get the array of points + const polylinePoints = polyline.decode(encodedPolyline); + + // Check each segment of the polyline + for (let i = 0; i < polylinePoints.length - 1; i++) { + const start = polylinePoints[i]; + const end = polylinePoints[i + 1]; + + if (this.isPointNearLineSegment(point, start, end, tolerance)) { + return true; + } + } + + return false; + } + + isPointNearLineSegment(point, start, end, tolerance) { + // Convert degrees to radians + const degToRad = deg => (deg * Math.PI) / 180; + + // Earth radius in meters + const R = 6371000; + + // Point latitude and longitude in radians + const pointLatRad = degToRad(point[0]); + const pointLonRad = degToRad(point[1]); + + // Start point latitude and longitude in radians + const startLatRad = degToRad(start[0]); + const startLonRad = degToRad(start[1]); + + // End point latitude and longitude in radians + const endLatRad = degToRad(end[0]); + const endLonRad = degToRad(end[1]); + + // Using the 'cross-track distance' formula + const delta13 = Math.acos(Math.sin(startLatRad) * Math.sin(pointLatRad) + + Math.cos(startLatRad) * Math.cos(pointLatRad) * Math.cos(pointLonRad - startLonRad)) * R; + const theta13 = Math.atan2(Math.sin(pointLonRad - startLonRad) * Math.cos(pointLatRad), + Math.cos(startLatRad) * Math.sin(pointLatRad) - Math.sin(startLatRad) * Math.cos(pointLatRad) * Math.cos(pointLonRad - startLonRad)); + const theta12 = Math.atan2(Math.sin(endLonRad - startLonRad) * Math.cos(endLatRad), + Math.cos(startLatRad) * Math.sin(endLatRad) - Math.sin(startLatRad) * Math.cos(endLatRad) * Math.cos(endLonRad - startLonRad)); + + const deltaXt = Math.asin(Math.sin(delta13 / R) * Math.sin(theta13 - theta12)) * R; + + return Math.abs(deltaXt) < tolerance; + } } export default MapsService; diff --git a/tests/apis/agent.test.js b/tests/apis/agent.test.js index 1fdac7d..8be8e87 100644 --- a/tests/apis/agent.test.js +++ b/tests/apis/agent.test.js @@ -240,7 +240,7 @@ describe('test cases for generating routes', ()=>{ }) }) -describe.only('test cases for generating routes and selecting a route', ()=>{ +describe('test cases for generating routes and selecting a route', ()=>{ it('Should share routes when asked to share routes.', async () => { const ask = "Can you get routes from Denver to Yellowstone national park?"; diff --git a/tests/unit/controllers/controlCenter.test.js b/tests/unit/controllers/controlCenter.test.js index e45c314..0e8e9b1 100644 --- a/tests/unit/controllers/controlCenter.test.js +++ b/tests/unit/controllers/controlCenter.test.js @@ -92,4 +92,27 @@ describe('API tests for /update-catalog endpoint for an end to end Notify Messag expect(response._body.status).equal(true) expect(response._body.message).equal('Catalog Updated') }) +}) + +describe('API tests for triggering a roadblock', ()=>{ + it.only('Should trigger a roadblock on a selected route', async ()=>{ + const ask1 = "Can you get routes from Denver to Yellowstone national park?"; + await request(app).post('/webhook').send({ + "From": process.env.TEST_RECEPIENT_NUMBER, + "Body": ask1 + }) + + const ask2 = "Lets select the first route."; + await request(app).post('/webhook').send({ + "From": process.env.TEST_RECEPIENT_NUMBER, + "Body": ask2 + }) + + const response = await request(app).post('/trigger-exception').send({ + "point":[39.7408351, -104.9874105], + "message": "Roadblock ahead due to an accident!" + }) + + expect(response.status).equal(200) + }) }) \ No newline at end of file diff --git a/tests/unit/services/maps.test.js b/tests/unit/services/maps.test.js index 26de601..a674306 100644 --- a/tests/unit/services/maps.test.js +++ b/tests/unit/services/maps.test.js @@ -21,6 +21,18 @@ describe('Should test the map service', () => { expect(gpsCoordinates).to.have.property('lat'); expect(gpsCoordinates).to.have.property('lng'); }) + + it('Sould return true if a given gps location falls on a selected polygon', async()=>{ + + const source ='37.422391,-122.084845'; + const destination = '37.411991,-122.079414'; + + const point = [37.422391, -122.084845]; + let routes = await mapService.getRoutes(source, destination); + + const status = await mapService.checkGpsOnPolygon(point, routes[0].overview_polyline.points); + expect(status).to.be.true; + }) }); From c44a05eadb810e5b4f9b4b99b23f3111d8f717bf Mon Sep 17 00:00:00 2001 From: Mayur Virendra Date: Tue, 9 Apr 2024 02:18:22 +0530 Subject: [PATCH 2/3] udpated message --- tests/unit/controllers/controlCenter.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/controllers/controlCenter.test.js b/tests/unit/controllers/controlCenter.test.js index 0e8e9b1..d47ebea 100644 --- a/tests/unit/controllers/controlCenter.test.js +++ b/tests/unit/controllers/controlCenter.test.js @@ -110,7 +110,7 @@ describe('API tests for triggering a roadblock', ()=>{ const response = await request(app).post('/trigger-exception').send({ "point":[39.7408351, -104.9874105], - "message": "Roadblock ahead due to an accident!" + "message": "There is a roadblock on your selected route due to an accident!" }) expect(response.status).equal(200) From c584cb03631add0465b8b0f6a8f6f96f90548bd1 Mon Sep 17 00:00:00 2001 From: Mayur Virendra Date: Tue, 9 Apr 2024 03:28:42 +0530 Subject: [PATCH 3/3] Added re-routing by avoiding certain exxception point --- config/openai.json | 2 +- controllers/Bot.js | 2 +- controllers/ControlCenter.js | 6 ++-- services/MapService.js | 35 +++++++++++++++----- tests/unit/controllers/controlCenter.test.js | 8 ++++- tests/unit/services/maps.test.js | 8 ++++- 6 files changed, 46 insertions(+), 15 deletions(-) diff --git a/config/openai.json b/config/openai.json index 5623c92..720a9ea 100644 --- a/config/openai.json +++ b/config/openai.json @@ -1,6 +1,6 @@ { "SUPPORTED_ACTIONS": [ - { "action": "get_routes", "description": "If the user has requested for routes for a travel plan between two places or asked to plan a trip." }, + { "action": "get_routes", "description": "If the user has requested for routes for a travel plan between two places or asked to plan a trip. If the assistant has suggested to re-route in the last message and asked user to share current location, it should be a get_routes." }, { "action": "select_route", "description": "If the user selects one of the routes from the routes shared by the assistant." }, { "action": "search", "description": "If the user clearly indicates to perform a search for a specific product. Sample instructions : 'find a hotel', 'find an ev charger', 'find tickets'" }, { "action": "select", "description": "If the user likes or selects any item, this action should be used. This action can only be called if a search has been called before." }, diff --git a/controllers/Bot.js b/controllers/Bot.js index 38a7187..eb68ad2 100644 --- a/controllers/Bot.js +++ b/controllers/Bot.js @@ -173,7 +173,7 @@ async function process_text(req, res) { response.formatted = 'Session & profile cleared! You can start a new session now.'; } else if(ai.action?.action === 'get_routes'){ - const routes = await mapService.generate_routes(message, session.text); + const routes = await mapService.generate_routes(message, session.text, session.avoid_point|| []); const formatting_response = await ai.format_response(routes.data?.routes_formatted || routes.errors, [{ role: 'user', content: message },...session.text]); response.formatted = formatting_response.message; session.routes = routes.data?.routes || session.routes; diff --git a/controllers/ControlCenter.js b/controllers/ControlCenter.js index 28e8f76..91dee94 100644 --- a/controllers/ControlCenter.js +++ b/controllers/ControlCenter.js @@ -118,14 +118,15 @@ export const notify = async (req, res) => { // send whatsapp and add to context if(status){ try{ - const reply_message = `${message}. Do you want to find alternate routes?` + const reply_message = `${message}. Please share your current location and I'll try to find some alternate routes?` await action.send_message(session.key, reply_message); // update session + session.data.avoid_point = point; if(!session.data.text) session.data.text=[] session.data.text.push({role: 'assistant', content: reply_message}); - await db.update_session(session.key, session); + await db.update_session(session.key, session.data); } catch(e){ logger.error(e); @@ -133,6 +134,7 @@ export const notify = async (req, res) => { } } } + res.send("Triggered!") } else res.status(400).send('Point and message are required in the body.') } \ No newline at end of file diff --git a/services/MapService.js b/services/MapService.js index 2e98be0..2e555c8 100644 --- a/services/MapService.js +++ b/services/MapService.js @@ -10,7 +10,7 @@ class MapsService { this.client = new Client({}); } - async getRoutes(source, destination) { + async getRoutes(source, destination, avoidPoint=[]) { try { const response = await this.client.directions({ params: { @@ -20,7 +20,17 @@ class MapsService { alternatives: true } }); - return response.data.routes; + let routes= []; + for(const route of response.data.routes){ + const status = await this.checkGpsOnPolygon(avoidPoint, route.overview_polyline.points) + if(!status) routes.push(route) + } + + const path = this.get_static_image_path(routes); + logger.info(`Static image path for routes: ${path}`); + + + return routes; } catch (error) { logger.error(error); return []; @@ -105,14 +115,10 @@ class MapsService { } }) - let polygon_path = ''; - routes.forEach((route, index) => { - polygon_path+=`&path=color:${this.get_random_color()}|weight:${5-index}|enc:${route.overview_polyline.points}`; - }) + // print path + const path = this.get_static_image_path(routes) + logger.info(`Route image path : ${path}`) - const route_image = `https://maps.googleapis.com/maps/api/staticmap?size=300x300${polygon_path}&key=${process.env.GOOGLE_MAPS_API_KEY}`; - logger.info(`Map url :${route_image}`) - response.data.routes_formatted = { "description": `these are the various routes that you can take. Which one would you like to select:`, "routes": response.data.routes.map((route, index) => `Route ${index+1}: ${route.summary}`) @@ -181,6 +187,17 @@ class MapsService { return Math.abs(deltaXt) < tolerance; } + + get_static_image_path(routes){ + let polygon_path = ''; + routes.forEach((route, index) => { + polygon_path+=`&path=color:${this.get_random_color()}|weight:${5-index}|enc:${route.overview_polyline.points}`; + }) + + const route_image = `https://maps.googleapis.com/maps/api/staticmap?size=300x300${polygon_path}&key=${process.env.GOOGLE_MAPS_API_KEY}`; + return route_image; + + } } export default MapsService; diff --git a/tests/unit/controllers/controlCenter.test.js b/tests/unit/controllers/controlCenter.test.js index d47ebea..67f2c4c 100644 --- a/tests/unit/controllers/controlCenter.test.js +++ b/tests/unit/controllers/controlCenter.test.js @@ -95,7 +95,7 @@ describe('API tests for /update-catalog endpoint for an end to end Notify Messag }) describe('API tests for triggering a roadblock', ()=>{ - it.only('Should trigger a roadblock on a selected route', async ()=>{ + it('Should trigger a roadblock on a selected route', async ()=>{ const ask1 = "Can you get routes from Denver to Yellowstone national park?"; await request(app).post('/webhook').send({ "From": process.env.TEST_RECEPIENT_NUMBER, @@ -112,6 +112,12 @@ describe('API tests for triggering a roadblock', ()=>{ "point":[39.7408351, -104.9874105], "message": "There is a roadblock on your selected route due to an accident!" }) + + const ask3 = "I'm near Glendo"; + await request(app).post('/webhook').send({ + "From": process.env.TEST_RECEPIENT_NUMBER, + "Body": ask3 + }) expect(response.status).equal(200) }) diff --git a/tests/unit/services/maps.test.js b/tests/unit/services/maps.test.js index a674306..8a942f2 100644 --- a/tests/unit/services/maps.test.js +++ b/tests/unit/services/maps.test.js @@ -16,7 +16,7 @@ describe('Should test the map service', () => { it('should return GPS coordinates for a valid address', async () => { - const gpsCoordinates = await mapService.lookupGps('1600 Amphitheatre Parkway, Mountain View, CA'); + const gpsCoordinates = await mapService.lookupGps('Yellowstone national park'); expect(gpsCoordinates).to.be.an('object'); expect(gpsCoordinates).to.have.property('lat'); expect(gpsCoordinates).to.have.property('lng'); @@ -34,6 +34,12 @@ describe('Should test the map service', () => { expect(status).to.be.true; }) + it('Should return path avoiding certail points', async ()=>{ + const source ='39.7392358,-104.990251'; + const destination = '44.427963, -110.588455'; + const pointBeforeCasper = [42.839531, -106.136404]; + await mapService.getRoutes(source, destination, pointBeforeCasper); + }) });