From 71d8685685ce16a0c8ef8d49861d06d43512ce9b Mon Sep 17 00:00:00 2001 From: Tanuja Pansare Date: Wed, 1 Jul 2026 16:45:54 +0530 Subject: [PATCH] Add personal information and tourist places support to concierge pipeline --- public/embed/concierge-widget.css | 65 ++ public/embed/concierge-widget.js | 167 +++- src/controllers/conciergeBot.controller.js | 343 ++++++- src/controllers/publicConcierge.controller.js | 45 +- src/models/ConciergeBot.js | 6 +- src/models/KnowledgeSource.js | 2 +- src/models/WebChatSession.js | 5 + src/routes/conciergeBot.routes.js | 5 +- src/routes/publicConcierge.routes.js | 13 + src/services/conciergePipeline.service.js | 936 ++++++++++++++---- src/services/conciergeRag.service.js | 58 +- src/services/foodDb.service.js | 2 + src/services/foodRecommendationService.js | 74 +- src/services/personalInformation.service.js | 220 ++++ .../personalInformation.service.test.js | 119 +++ src/services/placesDb.service.js | 59 ++ src/services/placesRecommendationService.js | 396 ++++++++ src/utils/conciergeIngest.js | 28 + src/utils/conciergeIntent.js | 68 +- src/utils/conciergeIntent.test.js | 18 + 20 files changed, 2349 insertions(+), 280 deletions(-) create mode 100644 src/services/personalInformation.service.js create mode 100644 src/services/personalInformation.service.test.js create mode 100644 src/services/placesDb.service.js create mode 100644 src/services/placesRecommendationService.js create mode 100644 src/utils/conciergeIntent.test.js diff --git a/public/embed/concierge-widget.css b/public/embed/concierge-widget.css index 7499859..8b9c487 100644 --- a/public/embed/concierge-widget.css +++ b/public/embed/concierge-widget.css @@ -30,11 +30,76 @@ display:flex; align-items:center; justify-content:space-between; } .ebc-title{ font-weight:700; font-size:13px; } +.ebc-header-actions{ display:flex; align-items:center; gap:6px; } +.ebc-end{ border:0; background:transparent; cursor:pointer; color:var(--ebc-muted); font-size:12px; } .ebc-close{ border:0; background:transparent; cursor:pointer; color:var(--ebc-muted); font-size:18px; } .ebc-messages{ padding:12px; gap:8px; display:flex; flex-direction:column; overflow:auto; flex:1; } .ebc-msg{ max-width:85%; padding:9px 10px; border-radius:12px; border:1px solid var(--ebc-border); background:#f9fafb; } .ebc-msg.ebc-user{ margin-left:auto; background:#eff6ff; border-color:#dbeafe; } +.ebc-msg.ebc-rich{ max-width:94%; padding:10px; background:#ffffff; } +.ebc-intro{ margin:0 0 9px; color:var(--ebc-fg); } +.ebc-card-list{ display:flex; flex-direction:column; gap:8px; } +.ebc-card{ + display:grid; grid-template-columns:24px 1fr; gap:8px; + padding:9px; border:1px solid var(--ebc-border); + border-radius:8px; background:#f9fafb; +} +.ebc-card-number{ + width:22px; height:22px; border-radius:999px; + display:flex; align-items:center; justify-content:center; + background:var(--ebc-primary); color:var(--ebc-primary-fg); + font-size:11px; font-weight:700; line-height:1; +} +.ebc-card-text{ min-width:0; overflow-wrap:anywhere; } .ebc-meta{ font-size:11px; color:var(--ebc-muted); margin-top:2px; } +.ebc-msg.ebc-places{ max-width:96%; background:#ffffff; } +.ebc-place-list{ display:flex; flex-direction:column; gap:9px; } +.ebc-place-card{ + display:flex; flex-direction:column; gap:8px; + padding:10px; border:1px solid var(--ebc-border); + border-radius:8px; background:#ffffff; + box-shadow:0 6px 16px rgba(17,24,39,.06); +} +.ebc-place-head{ display:flex; gap:9px; align-items:flex-start; } +.ebc-place-pin{ + width:26px; height:26px; border-radius:8px; + display:flex; align-items:center; justify-content:center; + background:#ecfdf5; color:#047857; font-weight:800; flex:0 0 auto; +} +.ebc-place-title-wrap{ min-width:0; } +.ebc-place-name{ + margin:0; font-size:13px; line-height:1.25; font-weight:750; + overflow-wrap:anywhere; +} +.ebc-place-rating{ margin-top:2px; color:#92400e; font-size:11px; font-weight:650; } +.ebc-place-address{ margin:0; color:var(--ebc-muted); font-size:12px; overflow-wrap:anywhere; } +.ebc-place-tags{ display:flex; flex-wrap:wrap; gap:5px; } +.ebc-place-tag{ + padding:3px 7px; border-radius:999px; + background:#f3f4f6; color:#374151; font-size:11px; +} +.ebc-place-map{ + align-self:flex-start; color:var(--ebc-primary); + font-size:12px; font-weight:700; text-decoration:none; +} +.ebc-place-map:hover{ text-decoration:underline; } +.ebc-msg.ebc-foods{ max-width:96%; background:#ffffff; } +.ebc-food-list{ display:flex; flex-direction:column; gap:9px; } +.ebc-food-card{ + display:flex; flex-direction:column; gap:8px; + padding:10px; border:1px solid var(--ebc-border); + border-radius:8px; background:#ffffff; + box-shadow:0 6px 16px rgba(17,24,39,.06); +} +.ebc-food-icon{ + width:26px; height:26px; border-radius:8px; + display:flex; align-items:center; justify-content:center; + background:#fff7ed; color:#c2410c; font-size:14px; flex:0 0 auto; +} +.ebc-food-tag{ + padding:3px 7px; border-radius:999px; + background:#fff7ed; color:#9a3412; font-size:11px; +} .ebc-inputbar{ padding:10px; border-top:1px solid var(--ebc-border); display:flex; gap:8px; align-items:center; diff --git a/public/embed/concierge-widget.js b/public/embed/concierge-widget.js index 69d38e1..7ce9c7b 100644 --- a/public/embed/concierge-widget.js +++ b/public/embed/concierge-widget.js @@ -10,7 +10,9 @@ var storageKey = 'ebc_session_' + botId; var sessionId = null; - try { sessionId = localStorage.getItem(storageKey); } catch {} + // A page refresh starts a new chat. Remove IDs saved by older widget builds so + // participant identity is never silently restored from localStorage. + try { localStorage.removeItem(storageKey); } catch {} function el(tag, attrs, children) { var n = document.createElement(tag); @@ -31,9 +33,13 @@ var panel = el('div', { class: 'ebc-panel', role: 'dialog', 'aria-label': 'Event concierge chat' }); var header = el('div', { class: 'ebc-header' }); var title = el('div', { class: 'ebc-title' }, ['Event Concierge']); + var headerActions = el('div', { class: 'ebc-header-actions' }); + var endBtn = el('button', { class: 'ebc-end', type: 'button', 'aria-label': 'End chat' }, ['End chat']); var closeBtn = el('button', { class: 'ebc-close', type: 'button', 'aria-label': 'Close' }, ['×']); header.appendChild(title); - header.appendChild(closeBtn); + headerActions.appendChild(endBtn); + headerActions.appendChild(closeBtn); + header.appendChild(headerActions); var messages = el('div', { class: 'ebc-messages' }); var error = el('div', { class: 'ebc-error' }, ['']); @@ -60,9 +66,142 @@ error.textContent = msg; } - function addMessage(role, text) { + function parseNumberedRecommendations(text) { + var source = String(text || '').trim(); + var markerPattern = /(?:^|\s)(\d{1,2})[.)]\s+/g; + var markers = []; + var match; + + while ((match = markerPattern.exec(source)) !== null) { + markers.push({ + number: match[1], + markerStart: match.index, + contentStart: markerPattern.lastIndex + }); + } + + if (markers.length < 2) return null; + + var intro = source.slice(0, markers[0].markerStart).trim(); + var items = markers.map(function (marker, index) { + var nextMarker = markers[index + 1]; + return { + number: marker.number, + text: source.slice(marker.contentStart, nextMarker ? nextMarker.markerStart : source.length).trim() + }; + }).filter(function (item) { return item.text; }); + + return items.length >= 2 ? { intro: intro, items: items } : null; + } + + function appendAssistantContent(bubble, text) { + var parsed = parseNumberedRecommendations(text); + if (!parsed) { + bubble.textContent = text; + return; + } + + bubble.classList.add('ebc-rich'); + if (parsed.intro) { + bubble.appendChild(el('p', { class: 'ebc-intro' }, [parsed.intro])); + } + + var list = el('div', { class: 'ebc-card-list' }); + parsed.items.forEach(function (item) { + var card = el('div', { class: 'ebc-card' }); + card.appendChild(el('div', { class: 'ebc-card-number' }, [item.number])); + card.appendChild(el('div', { class: 'ebc-card-text' }, [item.text])); + list.appendChild(card); + }); + bubble.appendChild(list); + } + + function formatRating(place) { + if (place.rating === null || place.rating === undefined || place.rating === '') return 'Rating not available'; + var label = String(place.rating); + if (place.userRatingsTotal !== null && place.userRatingsTotal !== undefined) { + label += ' (' + String(place.userRatingsTotal) + ' reviews)'; + } + return label; + } + + function mapsUrl(place) { + if (place.location && isFinite(Number(place.location.lat)) && isFinite(Number(place.location.lng))) { + return 'https://www.google.com/maps/search/?api=1&query=' + + encodeURIComponent(String(place.location.lat) + ',' + String(place.location.lng)) + + '&query_place_id=' + encodeURIComponent(place.placeId || place.foodId || ''); + } + return 'https://www.google.com/maps/search/?api=1&query=' + encodeURIComponent((place.name || '') + ' ' + (place.address || '')); + } + + function appendPlaceCards(bubble, text, places) { + bubble.classList.add('ebc-rich', 'ebc-places'); + if (text) bubble.appendChild(el('p', { class: 'ebc-intro' }, [text])); + + var list = el('div', { class: 'ebc-place-list' }); + places.forEach(function (place) { + var card = el('article', { class: 'ebc-place-card' }); + var head = el('div', { class: 'ebc-place-head' }); + head.appendChild(el('div', { class: 'ebc-place-pin', 'aria-hidden': 'true' }, ['⌖'])); + var titleWrap = el('div', { class: 'ebc-place-title-wrap' }); + titleWrap.appendChild(el('h3', { class: 'ebc-place-name' }, [place.name || 'Unnamed place'])); + titleWrap.appendChild(el('div', { class: 'ebc-place-rating' }, [formatRating(place)])); + head.appendChild(titleWrap); + card.appendChild(head); + + if (place.address) card.appendChild(el('p', { class: 'ebc-place-address' }, [place.address])); + + if (Array.isArray(place.tags) && place.tags.length) { + var tags = el('div', { class: 'ebc-place-tags' }); + place.tags.slice(0, 3).forEach(function (tag) { + tags.appendChild(el('span', { class: 'ebc-place-tag' }, [String(tag)])); + }); + card.appendChild(tags); + } + + card.appendChild(el('a', { class: 'ebc-place-map', href: mapsUrl(place), target: '_blank', rel: 'noopener noreferrer' }, ['Open map'])); + list.appendChild(card); + }); + bubble.appendChild(list); + } + + function appendFoodCards(bubble, text, foods) { + bubble.classList.add('ebc-rich', 'ebc-foods'); + if (text) bubble.appendChild(el('p', { class: 'ebc-intro' }, [text])); + + var list = el('div', { class: 'ebc-food-list' }); + foods.forEach(function (food) { + var card = el('article', { class: 'ebc-food-card' }); + var head = el('div', { class: 'ebc-place-head' }); + head.appendChild(el('div', { class: 'ebc-food-icon', 'aria-hidden': 'true' }, ['🍽'])); + var titleWrap = el('div', { class: 'ebc-place-title-wrap' }); + titleWrap.appendChild(el('h3', { class: 'ebc-place-name' }, [food.name || 'Unnamed restaurant'])); + titleWrap.appendChild(el('div', { class: 'ebc-place-rating' }, [formatRating(food)])); + head.appendChild(titleWrap); + card.appendChild(head); + + if (food.address) card.appendChild(el('p', { class: 'ebc-place-address' }, [food.address])); + + if (Array.isArray(food.cuisines) && food.cuisines.length) { + var cuisines = el('div', { class: 'ebc-place-tags' }); + food.cuisines.slice(0, 3).forEach(function (cuisine) { + cuisines.appendChild(el('span', { class: 'ebc-food-tag' }, [String(cuisine).replace(/_/g, ' ')])); + }); + card.appendChild(cuisines); + } + + card.appendChild(el('a', { class: 'ebc-place-map', href: mapsUrl(food), target: '_blank', rel: 'noopener noreferrer' }, ['Open map'])); + list.appendChild(card); + }); + bubble.appendChild(list); + } + + function addMessage(role, text, places, foods) { var bubble = el('div', { class: 'ebc-msg' + (role === 'user' ? ' ebc-user' : '') }); - bubble.textContent = text; + if (role === 'assistant' && Array.isArray(foods) && foods.length) appendFoodCards(bubble, text, foods); + else if (role === 'assistant' && Array.isArray(places) && places.length) appendPlaceCards(bubble, text, places); + else if (role === 'assistant') appendAssistantContent(bubble, text); + else bubble.textContent = text; messages.appendChild(bubble); messages.scrollTop = messages.scrollHeight; } @@ -80,6 +219,18 @@ else openPanel(); }); closeBtn.addEventListener('click', closePanel); + endBtn.addEventListener('click', function () { + var endingSessionId = sessionId; + sessionId = null; + try { localStorage.removeItem(storageKey); } catch {} + messages.textContent = ''; + addMessage('assistant', 'This chat has ended. Ask a question to start a new session.'); + if (endingSessionId) { + http('/public/concierge/' + encodeURIComponent(botId) + '/session/end', { + sessionId: endingSessionId + }).catch(function () {}); + } + }); function http(path, body) { return fetch(apiBase + path, { @@ -121,9 +272,13 @@ } if (res.json.data && res.json.data.sessionId) { sessionId = res.json.data.sessionId; - try { localStorage.setItem(storageKey, sessionId); } catch {} } - addMessage('assistant', (res.json.data && res.json.data.reply) ? res.json.data.reply : '…'); + addMessage( + 'assistant', + (res.json.data && res.json.data.reply) ? res.json.data.reply : '…', + res.json.data && Array.isArray(res.json.data.places) ? res.json.data.places : [], + res.json.data && Array.isArray(res.json.data.foods) ? res.json.data.foods : [] + ); // Log detected intent to browser console when backend returns it if (res.json.data && res.json.data.intent) { // eslint-disable-next-line no-console diff --git a/src/controllers/conciergeBot.controller.js b/src/controllers/conciergeBot.controller.js index e43873e..12cc94a 100644 --- a/src/controllers/conciergeBot.controller.js +++ b/src/controllers/conciergeBot.controller.js @@ -388,76 +388,258 @@ const clearKnowledge = async (request, h) => { return h.response(success({ cleared: true })); }; +// const syncEventKnowledge = async (request, h) => { +// console.log('[concierge] syncEventKnowledge called', { params: request.params }); +// const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user); +// if (err) return h.response(error(err.code, err.message)).code(err.status); + +// if (bot.knowledge?.includeEventDetails === false) { +// return h +// .response( +// error( +// 'VALIDATION_ERROR', +// 'Event programme indexing is disabled for this bot. Enable "Include event programme in knowledge base" in settings first.', +// ), +// ) +// .code(400); +// } + +// const eventId = bot.event._id || bot.event; +// const programmeDisplayName = 'Event programme & links'; + +// async function ensureSource(type, displayName) { +// let source = await KnowledgeSource.findOne({ eventId, type }); +// if (!source) { +// source = await KnowledgeSource.create({ +// eventId, +// type, +// sourcePath: String(eventId), +// displayName, +// status: 'PENDING', +// }); +// } else { +// source.status = 'PENDING'; +// source.errorMessage = null; +// source.displayName = displayName; +// await source.save(); +// } + +// await enqueueConciergeIngest({ +// sourceId: String(source._id), +// botId: String(bot._id), +// eventId: String(eventId), +// type, +// }); +// return source; +// } + +// const eventSource = await ensureSource('event', programmeDisplayName); + +// bot.knowledge = bot.knowledge || {}; +// bot.knowledge.lastEventSnapshotAt = new Date(); + +// if (bot.knowledge?.includeFoodRecommendations !== false) { +// await ensureSource('food', 'Food Recommendations'); +// bot.knowledge.lastFoodSnapshotAt = new Date(); +// } + +// if (bot.knowledge?.includePlacesRecommendations !== false) { +// await ensureSource('places', 'Tourist Places Recommendation'); +// bot.knowledge.lastPlacesSnapshotAt = new Date(); +// } + +// await bot.save(); +// return h.response(success(eventSource, 'Event programme sync queued')); +// }; + + + + +// const eventId = bot.event._id || bot.event; +// const programmeDisplayName = 'Event programme & links'; + +// async function ensureSource(type, displayName) { +// let source = await KnowledgeSource.findOne({ eventId, type }); +// if (!source) { +// source = await KnowledgeSource.create({ +// eventId, +// type, +// sourcePath: String(eventId), +// displayName, +// status: 'PENDING', +// }); +// } else { +// source.status = 'PENDING'; +// source.errorMessage = null; +// source.displayName = displayName; +// await source.save(); +// } + +// await enqueueConciergeIngest({ +// sourceId: String(source._id), +// botId: String(bot._id), +// eventId: String(eventId), +// type, +// }); +// return source; +// } + +// const eventSource = await ensureSource('event', programmeDisplayName); + +// bot.knowledge = bot.knowledge || {}; +// bot.knowledge.lastEventSnapshotAt = new Date(); + +// if (bot.knowledge?.includeFoodRecommendations !== false) { +// await ensureSource('food', 'Food Recommendations'); +// bot.knowledge.lastFoodSnapshotAt = new Date(); +// } + +// if (bot.knowledge?.includePlacesRecommendations !== false) { +// await ensureSource('places', 'Tourist Places Recommendation'); +// bot.knowledge.lastPlacesSnapshotAt = new Date(); +// } + + +// await bot.save(); +// return h.response(success(eventSource, 'Event programme sync queued')); +// }; + +// const syncPlacesKnowledge = async (request, h) => { +// console.log('[concierge] syncPlacesKnowledge called', { params: request.params }); +// const { bot, err } = await getBotForUser( +// request.params.botId, +// request.auth.credentials.user +// ); +// if (err) { +// return h.response(error(err.code, err.message)).code(err.status); +// } + +// if (bot.knowledge?.includePlacesRecommendations === false) { +// return h.response( +// error('VALIDATION_ERROR', 'Places recommendations are disabled') +// ).code(400); +// } + +// const eventId = bot.event._id || bot.event; + +// let source = await KnowledgeSource.findOne({ +// eventId, +// type: 'places', +// }); + +// if (!source) { +// source = await KnowledgeSource.create({ +// eventId, +// type: 'places', +// sourcePath: String(eventId), +// displayName: 'Tourist Places Recommendation', +// status: 'PENDING', +// }); +// } else { +// source.status = 'PENDING'; +// source.errorMessage = null; +// source.displayName = 'Tourist Places Recommendation'; +// await source.save(); +// } + +// await enqueueConciergeIngest({ +// sourceId: String(source._id), +// botId: String(bot._id), +// eventId: String(eventId), +// type: 'places', +// }); + +// // Also attempt immediate ingestion for places so the UI doesn't stay PENDING +// try { +// const { runConciergeIngest } = require('../utils/conciergeIngest'); +// await runConciergeIngest(String(source._id)); +// const refreshed = await KnowledgeSource.findById(source._id).lean(); +// return h.response(success(refreshed, 'Places sync completed')); +// } catch (e) { +// return h.response(success(source, 'Places sync queued')); +// } +// }; + const syncEventKnowledge = async (request, h) => { console.log('[concierge] syncEventKnowledge called', { params: request.params }); - const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user); - if (err) return h.response(error(err.code, err.message)).code(err.status); + + const { bot, err } = await getBotForUser( + request.params.botId, + request.auth.credentials.user + ); + + if (err) { + return h.response(error(err.code, err.message)).code(err.status); + } + if (bot.knowledge?.includeEventDetails === false) { return h .response( error( 'VALIDATION_ERROR', - 'Event programme indexing is disabled for this bot. Enable "Include event programme in knowledge base" in settings first.', - ), + 'Event programme indexing is disabled for this bot. Enable "Include event programme in knowledge base" in settings first.' + ) ) .code(400); } + const eventId = bot.event._id || bot.event; - const programmeDisplayName = 'Event programme & links'; - async function ensureSource(type, displayName) { - let source = await KnowledgeSource.findOne({ eventId, type }); - if (!source) { - source = await KnowledgeSource.create({ - eventId, - type, - sourcePath: String(eventId), - displayName, - status: 'PENDING', - }); - } else { - source.status = 'PENDING'; - source.errorMessage = null; - source.displayName = displayName; - await source.save(); - } + let source = await KnowledgeSource.findOne({ + eventId, + type: 'event', + }); - await enqueueConciergeIngest({ - sourceId: String(source._id), - botId: String(bot._id), - eventId: String(eventId), - type, + if (!source) { + source = await KnowledgeSource.create({ + eventId, + type: 'event', + sourcePath: String(eventId), + displayName: 'Event programme & links', + status: 'PENDING', }); - return source; + } else { + source.status = 'PENDING'; + source.errorMessage = null; + source.displayName = 'Event programme & links'; + await source.save(); } - const eventSource = await ensureSource('event', programmeDisplayName); + await enqueueConciergeIngest({ + sourceId: String(source._id), + botId: String(bot._id), + eventId: String(eventId), + type: 'event', + }); bot.knowledge = bot.knowledge || {}; bot.knowledge.lastEventSnapshotAt = new Date(); - - if (bot.knowledge?.includeFoodRecommendations !== false) { - await ensureSource('food', 'Food Recommendations'); - bot.knowledge.lastFoodSnapshotAt = new Date(); - } - await bot.save(); - return h.response(success(eventSource, 'Event programme sync queued')); + + return h.response(success(source, 'Event programme sync queued')); }; const syncFoodKnowledge = async (request, h) => { console.log('[concierge] syncFoodKnowledge called', { params: request.params }); + const { bot, err } = await getBotForUser( request.params.botId, request.auth.credentials.user ); + if (err) { return h.response(error(err.code, err.message)).code(err.status); } + if (bot.knowledge?.includeFoodRecommendations === false) { - return h.response( - error('VALIDATION_ERROR', 'Food recommendations are disabled'), - ).code(400); + return h + .response( + error( + 'VALIDATION_ERROR', + 'Food recommendations indexing is disabled.' + ) + ) + .code(400); } const eventId = bot.event._id || bot.event; @@ -481,7 +663,6 @@ const syncFoodKnowledge = async (request, h) => { source.displayName = 'Food Recommendations'; await source.save(); } - console.log('[concierge] syncFoodKnowledge resolved source', { _id: source._id, type: source.type, displayName: source.displayName }); await enqueueConciergeIngest({ sourceId: String(source._id), @@ -490,15 +671,88 @@ const syncFoodKnowledge = async (request, h) => { type: 'food', }); - // Also attempt immediate ingestion for food so the UI doesn't stay PENDING + bot.knowledge = bot.knowledge || {}; + bot.knowledge.lastFoodSnapshotAt = new Date(); + await bot.save(); + + return h.response(success(source, 'Food sync queued')); +}; + +const syncPlacesKnowledge = async (request, h) => { + console.log('[concierge] syncPlacesKnowledge called', { + params: request.params, + }); + + const { bot, err } = await getBotForUser( + request.params.botId, + request.auth.credentials.user + ); + + if (err) { + return h.response(error(err.code, err.message)).code(err.status); + } + + if (bot.knowledge?.includePlacesRecommendations === false) { + console.warn('[concierge][places] blocked by knowledge gate', { + botId: request.params?.botId, + eventId, + includePlacesRecommendations: bot.knowledge?.includePlacesRecommendations, + knowledge: bot.knowledge, + }); + return h + .response( + error( + 'VALIDATION_ERROR', + 'Places recommendations are disabled' + ) + ) + .code(400); + } + + + const eventId = bot.event._id || bot.event; + + let source = await KnowledgeSource.findOne({ + eventId, + type: 'places', + }); + + if (!source) { + source = await KnowledgeSource.create({ + eventId, + type: 'places', + sourcePath: String(eventId), + displayName: 'Tourist Places Recommendation', + status: 'PENDING', + }); + } else { + source.status = 'PENDING'; + source.errorMessage = null; + source.displayName = 'Tourist Places Recommendation'; + await source.save(); + } + + await enqueueConciergeIngest({ + sourceId: String(source._id), + botId: String(bot._id), + eventId: String(eventId), + type: 'places', + }); + try { const { runConciergeIngest } = require('../utils/conciergeIngest'); + await runConciergeIngest(String(source._id)); + const refreshed = await KnowledgeSource.findById(source._id).lean(); - return h.response(success(refreshed, 'Food sync completed')); + + bot.knowledge = bot.knowledge || {}; + bot.knowledge.lastPlacesSnapshotAt = new Date(); + await bot.save(); + + return h.response(success(refreshed, 'Places sync completed')); } catch (e) { - // Ingest failed or worker not running; return queued response - return h.response(success(source, 'Food sync queued')); + return h.response(success(source, 'Places sync queued')); } }; @@ -520,4 +774,5 @@ module.exports = { clearKnowledge, syncEventKnowledge, syncFoodKnowledge, -}; + syncPlacesKnowledge, +}; \ No newline at end of file diff --git a/src/controllers/publicConcierge.controller.js b/src/controllers/publicConcierge.controller.js index c2af2b4..c954ee8 100644 --- a/src/controllers/publicConcierge.controller.js +++ b/src/controllers/publicConcierge.controller.js @@ -9,6 +9,7 @@ const { interpolateConciergeGreeting } = require('../utils/conciergeGreeting'); const MAX_MESSAGE_LENGTH = 800; const MAX_HISTORY_MESSAGES = 40; +const SESSION_IDLE_MS = 30 * 60 * 1000; // Basic in-memory rate limit (best-effort). const ipBuckets = new Map(); @@ -77,6 +78,14 @@ const chat = async (request, h) => { if (!sessionId) sessionId = crypto.randomBytes(16).toString('hex'); let session = await WebChatSession.findOne({ botId: bot._id, sessionId }); + const sessionExpired = session && ( + session.endedAt || + (session.lastSeenAt && Date.now() - new Date(session.lastSeenAt).getTime() > SESSION_IDLE_MS) + ); + if (sessionExpired) { + sessionId = crypto.randomBytes(16).toString('hex'); + session = null; + } if (!session) { session = await WebChatSession.create({ botId: bot._id, @@ -112,7 +121,9 @@ const chat = async (request, h) => { text: m.text, })); - const intentResult = await determineIntentCategory(last6ForIntent); + const intentResult = session.awaitingPersonalEmail + ? { category: 'Personal Information', databaseSearchNeeded: true } + : await determineIntentCategory(last6ForIntent); const intentCategory = intentResult?.category || 'unrecognized'; if (intentResult?.databaseSearchNeeded) { @@ -134,9 +145,14 @@ const chat = async (request, h) => { eventName: event?.name || 'Event', bot, sessionHistory: session.history.map((m) => ({ role: m.role, text: m.text })), + intentResult, + chatSession: session, + organizationId: String(bot.organization), }); const answerText = pipelineResult?.text || "I don't have that in my knowledge base yet. Please check with event staff or try another question."; + const places = Array.isArray(pipelineResult?.places) ? pipelineResult.places.slice(0, 5) : []; + const foods = Array.isArray(pipelineResult?.foods) ? pipelineResult.foods.slice(0, 5) : []; const reply = String(answerText || '').trim().slice(0, 1200); @@ -158,12 +174,37 @@ const chat = async (request, h) => { } await session.save(); - return h.response(success({ sessionId, reply, usage: pipelineResult?.usage || null, intent: intentCategory })); + return h.response(success({ sessionId, reply, places, foods, usage: pipelineResult?.usage || null, intent: intentCategory })); +}; + +const end = async (request, h) => { + const { botId } = request.params; + const { bot, err } = await loadBotOr404(botId); + if (err) return h.response(err).code(err.error?.code === 'NOT_FOUND' ? 404 : 400); + + const sessionId = String(request.payload?.sessionId || '').trim(); + if (sessionId) { + await WebChatSession.updateOne( + { botId: bot._id, sessionId }, + { + $set: { + participant: null, + awaitingPersonalEmail: false, + identityVerifiedAt: null, + identityMethod: null, + endedAt: new Date(), + lastSeenAt: new Date(), + }, + }, + ); + } + return h.response(success({ ended: true })); }; module.exports = { config, chat, + end, }; diff --git a/src/models/ConciergeBot.js b/src/models/ConciergeBot.js index faf34ff..a4cf0c6 100644 --- a/src/models/ConciergeBot.js +++ b/src/models/ConciergeBot.js @@ -34,9 +34,13 @@ const conciergeBotSchema = new mongoose.Schema( }, knowledge: { includeEventDetails: { type: Boolean, default: true }, - includeFoodRecommendations: { type: Boolean, default: true}, + includeFoodRecommendations: { type: Boolean, default: true }, + includePlacesRecommendations: { type: Boolean, default: true }, lastEventSnapshotAt: Date, + lastFoodSnapshotAt: Date, + lastPlacesSnapshotAt: Date, }, + lastKnowledgeClearedAt: Date, lastPurgedAt: Date, }, diff --git a/src/models/KnowledgeSource.js b/src/models/KnowledgeSource.js index 7003ac2..529a238 100644 --- a/src/models/KnowledgeSource.js +++ b/src/models/KnowledgeSource.js @@ -10,7 +10,7 @@ const knowledgeSourceSchema = new mongoose.Schema( }, type: { type: String, - enum: ['url', 'pdf', 'event','food'], + enum: ['url', 'pdf', 'event', 'food', 'places'], required: true, }, sourcePath: { type: String, required: true }, diff --git a/src/models/WebChatSession.js b/src/models/WebChatSession.js index 6c367a6..c3f111f 100644 --- a/src/models/WebChatSession.js +++ b/src/models/WebChatSession.js @@ -18,6 +18,11 @@ const webChatSessionSchema = new mongoose.Schema( eventId: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true, index: true }, organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', required: true, index: true }, sessionId: { type: String, required: true, index: true }, + participant: { type: mongoose.Schema.Types.ObjectId, ref: 'Participant', default: null, index: true }, + awaitingPersonalEmail: { type: Boolean, default: false }, + identityVerifiedAt: { type: Date, default: null }, + identityMethod: { type: String, enum: ['email'], default: null }, + endedAt: { type: Date, default: null, index: true }, history: { type: [messageSchema], default: [] }, lastSeenAt: { type: Date, default: Date.now, index: true }, inputTokensTotal: { type: Number, default: 0 }, diff --git a/src/routes/conciergeBot.routes.js b/src/routes/conciergeBot.routes.js index 43f8ce2..8223c8b 100644 --- a/src/routes/conciergeBot.routes.js +++ b/src/routes/conciergeBot.routes.js @@ -14,8 +14,10 @@ const registrationSchema = Joi.object({ const knowledgeSchema = Joi.object({ includeEventDetails: Joi.boolean(), includeFoodRecommendations: Joi.boolean(), + includePlacesRecommendations: Joi.boolean(), }); + const createPayload = Joi.object({ eventId: Joi.string().required(), name: Joi.string().allow(''), @@ -66,5 +68,6 @@ module.exports = [ { method: 'POST', path: '/api/concierge-bots/{botId}/sources/{sourceId}/approve', options: { handler: controller.approveSource } }, { method: 'POST', path: '/api/concierge-bots/{botId}/knowledge/clear', options: { handler: controller.clearKnowledge } }, { method: 'POST', path: '/api/concierge-bots/{botId}/knowledge/sync-event', options: { handler: controller.syncEventKnowledge } }, - { method: "POST", path: "/api/concierge-bots/{botId}/knowledge/food-sync", options: { handler: controller.syncFoodKnowledge} } + { method: "POST", path: "/api/concierge-bots/{botId}/knowledge/food-sync", options: { handler: controller.syncFoodKnowledge} }, + { method: "POST", path: "/api/concierge-bots/{botId}/knowledge/places-sync", options: { handler: controller.syncPlacesKnowledge} } ]; diff --git a/src/routes/publicConcierge.routes.js b/src/routes/publicConcierge.routes.js index db51c0e..37d778e 100644 --- a/src/routes/publicConcierge.routes.js +++ b/src/routes/publicConcierge.routes.js @@ -6,6 +6,10 @@ const chatPayload = Joi.object({ message: Joi.string().min(1).max(2000).required(), }); +const endPayload = Joi.object({ + sessionId: Joi.string().max(80).required(), +}); + module.exports = [ { method: 'GET', @@ -25,5 +29,14 @@ module.exports = [ handler: controller.chat, }, }, + { + method: 'POST', + path: '/api/public/concierge/{botId}/session/end', + options: { + auth: false, + validate: { params: Joi.object({ botId: Joi.string().required() }), payload: endPayload }, + handler: controller.end, + }, + }, ]; diff --git a/src/services/conciergePipeline.service.js b/src/services/conciergePipeline.service.js index e0829ce..5cc650c 100644 --- a/src/services/conciergePipeline.service.js +++ b/src/services/conciergePipeline.service.js @@ -2,6 +2,374 @@ const { executeConciergeRag } = require('./conciergeRag.service'); const { determineIntentCategory } = require('../utils/conciergeIntent'); const { getFoodRecommendations } = require('./foodRecommendationService'); const { fetchFoodRecordForEvent } = require('./foodDb.service'); +const { fetchPlacesRecordForEvent } = require('./placesDb.service'); +const { getPlacesRecommendations } = require('./placesRecommendationService'); +const { runPersonalInformation } = require('./personalInformation.service'); + +function normalizePlaceId(value) { + return String(value || '').trim(); +} + +function publicPlaceFromRecord(place) { + if (!place) return null; + const placeId = normalizePlaceId(place.place_id || place.placeId || place.id); + if (!placeId) return null; + + const location = place.location || place.geometry?.location || null; + const lat = Number(location?.lat); + const lng = Number(location?.lng); + + return { + placeId, + name: place.name || 'Unnamed place', + address: place.address || place.formatted_address || '', + rating: place.rating != null ? place.rating : null, + userRatingsTotal: place.user_ratings_total != null ? place.user_ratings_total : null, + location: Number.isFinite(lat) && Number.isFinite(lng) ? { lat, lng } : null, + tags: Array.isArray(place.tags) + ? place.tags.slice(0, 4) + : Array.isArray(place.types) + ? place.types.slice(0, 4).map((t) => String(t).replace(/_/g, ' ')) + : [], + }; +} + +function selectPlacesByIds(places, selectedIds, limit = 5) { + if (!Array.isArray(places) || !Array.isArray(selectedIds)) return []; + const byId = new Map(); + places.forEach((place) => { + const id = normalizePlaceId(place?.place_id || place?.placeId || place?.id); + if (id && !byId.has(id)) byId.set(id, place); + }); + + const picked = []; + const seen = new Set(); + selectedIds.forEach((rawId) => { + const id = normalizePlaceId(rawId); + if (!id || seen.has(id) || !byId.has(id)) return; + const normalized = publicPlaceFromRecord(byId.get(id)); + if (normalized) { + picked.push(normalized); + seen.add(id); + } + }); + + return picked.slice(0, limit); +} + +function parsePlaceIdsFromLlmText(text) { + const raw = String(text || '').trim(); + if (!raw) return []; + + const candidates = [raw]; + const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fenced?.[1]) candidates.push(fenced[1].trim()); + + const arrayMatch = raw.match(/\[[\s\S]*\]/); + if (arrayMatch?.[0]) candidates.push(arrayMatch[0]); + + for (const candidate of candidates) { + try { + const parsed = JSON.parse(candidate); + if (Array.isArray(parsed)) { + return parsed + .map((item) => (typeof item === 'string' ? item : item?.place_id || item?.placeId || item?.id)) + .map(normalizePlaceId) + .filter(Boolean); + } + if (Array.isArray(parsed?.place_ids)) return parsed.place_ids.map(normalizePlaceId).filter(Boolean); + if (Array.isArray(parsed?.placeIds)) return parsed.placeIds.map(normalizePlaceId).filter(Boolean); + } catch {} + } + + return []; +} + +function publicFoodFromRecord(record) { + if (!record) return null; + const foodId = normalizePlaceId(record.place_id || record.foodId || record.placeId || record.id); + if (!foodId) return null; + + const location = record.location || record.geometry?.location || null; + const lat = Number(location?.lat); + const lng = Number(location?.lng); + + return { + foodId, + name: record.name || 'Unnamed restaurant', + address: record.address || record.formatted_address || '', + rating: record.rating != null ? record.rating : null, + userRatingsTotal: record.user_ratings_total != null ? record.user_ratings_total : null, + location: Number.isFinite(lat) && Number.isFinite(lng) ? { lat, lng } : null, + cuisines: (Array.isArray(record.cuisines) + ? record.cuisines + : record.cuisines + ? [record.cuisines] + : []) + .map((cuisine) => String(cuisine).trim()) + .filter((cuisine) => cuisine && cuisine.toLowerCase() !== 'n/a') + .slice(0, 4), + }; +} + +function normalizeFoodWord(word) { + const value = String(word || '') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase(); + const aliases = { + biriyani: 'biryani', + briyani: 'biryani', + cafes: 'cafe', + veg: 'vegetarian', + }; + if (aliases[value]) return aliases[value]; + if (value.endsWith('ies') && value.length > 4) return `${value.slice(0, -3)}y`; + if (/(ches|shes|xes|zes|ses)$/.test(value) && value.length > 4) return value.slice(0, -2); + if (value.endsWith('s') && !value.endsWith('ss') && value.length > 3) return value.slice(0, -1); + return value; +} + +function normalizedFoodWords(value) { + return (String(value || '') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .match(/[a-z0-9]+/g) || []) + .map(normalizeFoodWord) + .filter(Boolean); +} + +function inferFoodQueryMode(question) { + const words = normalizedFoodWords(question); + const genericWords = new Set([ + 'a', 'an', 'and', 'any', 'at', 'best', 'can', 'could', 'eat', 'food', 'foods', + 'for', 'get', 'good', 'have', 'i', 'in', 'is', 'lunch', 'dinner', 'breakfast', + 'find', 'give', 'looking', 'me', 'meal', 'meals', 'near', 'nearby', 'place', + 'places', 'please', 'recommend', 'show', 'some', 'suggest', 'shop', 'shops', + 'recommendation', 'recommendations', 'restaurant', 'restaurants', 'the', 'to', + 'try', 'venue', 'want', 'where', 'which', 'would', 'you', 'around', + ]); + const specificTerms = [...new Set(words.filter((word) => !genericWords.has(word)))]; + + return { + mode: specificTerms.length ? 'specific' : 'general', + specificTerms, + }; +} + +function foodRecordEvidenceWords(record) { + const reviews = Array.isArray(record?.reviews) + ? record.reviews.flatMap((review) => [review?.summary, review?.text]) + : []; + const values = [ + record?.name, + record?.reviewsSummary, + ...(Array.isArray(record?.cuisines) ? record.cuisines : [record?.cuisines]), + ...(Array.isArray(record?.types) ? record.types : []), + ...(Array.isArray(record?.matched_queries) ? record.matched_queries : []), + ...reviews, + ]; + return new Set(values.flatMap(normalizedFoodWords)); +} + +function filterFoodRecordsByTerms(records, terms) { + if (!Array.isArray(records) || !Array.isArray(terms) || !terms.length) return []; + return records.filter((record) => { + const evidence = foodRecordEvidenceWords(record); + return terms.every((term) => evidence.has(normalizeFoodWord(term))); + }); +} + +function selectFoodsByIds(records, selectedIds, limit = 5) { + if (!Array.isArray(records) || !Array.isArray(selectedIds)) return []; + const byId = new Map(); + records.forEach((record) => { + const id = normalizePlaceId(record?.place_id || record?.foodId || record?.placeId || record?.id); + if (id && !byId.has(id)) byId.set(id, record); + }); + + const selected = []; + const seen = new Set(); + selectedIds.forEach((rawId) => { + const id = normalizePlaceId(rawId); + if (!id || seen.has(id) || !byId.has(id)) return; + const food = publicFoodFromRecord(byId.get(id)); + if (food) { + selected.push(food); + seen.add(id); + } + }); + + return selected.slice(0, limit); +} + +function parseFoodIdsFromLlmText(text) { + const raw = String(text || '').trim(); + if (!raw) return []; + + const candidates = [raw]; + const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fenced?.[1]) candidates.push(fenced[1].trim()); + const arrayMatch = raw.match(/\[[\s\S]*\]/); + if (arrayMatch?.[0]) candidates.push(arrayMatch[0]); + + for (const candidate of candidates) { + try { + const parsed = JSON.parse(candidate); + if (Array.isArray(parsed)) { + return parsed + .map((item) => (typeof item === 'string' ? item : item?.food_id || item?.foodId || item?.place_id || item?.id)) + .map(normalizePlaceId) + .filter(Boolean); + } + const ids = parsed?.food_ids || parsed?.foodIds; + if (Array.isArray(ids)) return ids.map(normalizePlaceId).filter(Boolean); + } catch {} + } + + return []; +} + +function rankFoodRecords(records, limit = 5) { + return (Array.isArray(records) ? records : []) + .slice() + .sort((a, b) => { + const score = (record) => { + const rating = Number(record?.rating || 0); + const count = Math.max(0, Number(record?.user_ratings_total || 0)); + const priorRating = 4; + const priorCount = 100; + return (rating * count + priorRating * priorCount) / (count + priorCount); + }; + const scoreDiff = score(b) - score(a); + if (scoreDiff) return scoreDiff; + return Number(b?.user_ratings_total || 0) - Number(a?.user_ratings_total || 0); + }) + .slice(0, limit) + .map(publicFoodFromRecord) + .filter(Boolean); +} + +function placeSearchText(place) { + const bits = [ + place?.name, + place?.address, + place?.formatted_address, + ...(Array.isArray(place?.tags) ? place.tags : []), + ...(Array.isArray(place?.types) ? place.types : []), + ]; + + return bits + .filter(Boolean) + .map((value) => String(value).replace(/_/g, ' ').toLowerCase()) + .join(' '); +} + +function hasAnyPlaceTerm(place, terms) { + const text = placeSearchText(place); + return terms.some((term) => text.includes(term)); +} + +function isTouristAttractionLike(place) { + return hasAnyPlaceTerm(place, [ + 'tourist attraction', + 'museum', + 'art gallery', + 'park', + 'garden', + 'viewpoint', + 'view point', + 'tekdi', + 'hill', + 'fort', + 'lake', + 'palace', + 'zoo', + 'temple', + 'church', + 'mosque', + 'gurudwara', + 'monument', + 'memorial', + 'smark', + 'smarak', + ]); +} + +function inferRequestedPlaceCategory(question) { + const q = String(question || '').toLowerCase(); + + const categories = [ + { + label: 'shopping malls', + noMatch: "I couldn't find any shopping malls in the available recommendations for this event.", + regex: /\b(malls?|shopping|shopping\s*(centers?|centres?))\b/i, + include: (place) => hasAnyPlaceTerm(place, ['shopping mall', 'shopping center', 'shopping centre', ' mall']), + }, + { + label: 'museums', + noMatch: "I couldn't find any museums in the available recommendations for this event.", + regex: /\b(museums?)\b/i, + include: (place) => hasAnyPlaceTerm(place, ['museum']), + }, + { + label: 'parks or gardens', + noMatch: "I couldn't find any parks or gardens in the available recommendations for this event.", + regex: /\b(parks?|gardens?|udyan)\b/i, + include: (place) => hasAnyPlaceTerm(place, ['park', 'garden', 'udyan']), + }, + { + label: 'viewpoints', + noMatch: "I couldn't find any viewpoints in the available recommendations for this event.", + regex: /\b(view\s*points?|viewpoints?|sunset|tekdi|hill)\b/i, + include: (place) => hasAnyPlaceTerm(place, ['viewpoint', 'view point', 'sunset', 'tekdi', 'hill']), + }, + { + label: 'cultural attractions', + noMatch: "I couldn't find any cultural attractions in the available recommendations for this event.", + regex: /\b(cultural|culture|heritage|monuments?|memorials?)\b/i, + include: (place) => + hasAnyPlaceTerm(place, ['museum', 'art gallery', 'monument', 'memorial', 'smarak', 'temple', 'church', 'mosque', 'gurudwara', 'palace', 'fort']), + }, + { + label: 'temples', + noMatch: "I couldn't find any temples in the available recommendations for this event.", + regex: /\b(temples?|mandirs?)\b/i, + include: (place) => hasAnyPlaceTerm(place, ['temple', 'mandir', 'hindu temple']), + }, + { + label: 'tourist attractions', + noMatch: "I couldn't find any tourist attractions in the available recommendations for this event.", + regex: /\b(tourists?|attractions?|sightseeing|places to visit|things to do)\b/i, + include: isTouristAttractionLike, + }, + ]; + + return categories.find((category) => category.regex.test(q)) || null; +} + +function filterPlacesForQuestion(places, question) { + const requestedCategory = inferRequestedPlaceCategory(question); + const source = Array.isArray(places) ? places : []; + if (!requestedCategory) return { requestedCategory: null, places: source }; + + return { + requestedCategory, + places: source.filter((place) => requestedCategory.include(place)), + }; +} + +function rankPlaces(places, limit = 5) { + return (Array.isArray(places) ? places : []) + .slice() + .sort((a, b) => { + const ratingDiff = Number(b?.rating || 0) - Number(a?.rating || 0); + if (ratingDiff) return ratingDiff; + return Number(b?.user_ratings_total || 0) - Number(a?.user_ratings_total || 0); + }) + .slice(0, limit); +} /** * Pipeline: classify intent -> fetch data -> generate response via LLM. @@ -14,13 +382,21 @@ const { fetchFoodRecordForEvent } = require('./foodDb.service'); * - unrecognized */ -async function runConciergePipeline({ eventId, eventName, bot, sessionHistory }) { +async function runConciergePipeline({ + eventId, + eventName, + bot, + sessionHistory, + intentResult: suppliedIntentResult, + chatSession, + organizationId, +}) { const historyForIntent = Array.isArray(sessionHistory) ? sessionHistory.slice(-10) : []; // IMPORTANT: Always use the latest user message in the current request. const userQuestion = historyForIntent.slice(-1)?.[0]?.text || ''; - const intentResult = await determineIntentCategory( + const intentResult = suppliedIntentResult || await determineIntentCategory( historyForIntent.map((m) => ({ role: m.role, text: m.text })), ); @@ -31,220 +407,182 @@ async function runConciergePipeline({ eventId, eventName, bot, sessionHistory }) ? sessionHistory.slice(-12).map((m) => ({ role: m.role, text: m.text })) : []; - // 1) Fetch data for the intent (Food uses external DB, others are blank for now) - let dataBlock = ''; - + // Food records are selected by the LLM using IDs, then hydrated from MongoDB. + let foodRecord = null; if (intentCategory === 'Food') { - const existing = await fetchFoodRecordForEvent(eventId); + foodRecord = await fetchFoodRecordForEvent(eventId); + const generatedAtMs = foodRecord?.generatedAt ? new Date(foodRecord.generatedAt).getTime() : 0; + const isExpired = generatedAtMs > 0 && Date.now() - generatedAtMs > 24 * 60 * 60 * 1000; - const isExpired = - existing?.generatedAt && - Date.now() - new Date(existing.generatedAt).getTime() > 24 * 60 * 60 * 1000; + const hasStructuredRestaurants = Array.isArray(foodRecord?.restaurants) && foodRecord.restaurants.length > 0; + const isLegacyFoodRecord = hasStructuredRestaurants && Number(foodRecord?.schemaVersion || 0) < 4; - if (existing?.recommendations && !isExpired) { - dataBlock = `Food knowledge (ground truth / fetched):\n${existing.recommendations}`; - - // Add structured restaurant/place records (names + cuisines + review snippets) - // so the matching logic can reference persisted dataset entries directly. - if (Array.isArray(existing.restaurants) && existing.restaurants.length) { - const placesBlock = existing.restaurants - .map((p, i) => { - const cuisines = Array.isArray(p.cuisines) - ? p.cuisines.join(', ') - : p.cuisines || ''; - const topReviews = Array.isArray(p.reviews) ? p.reviews.slice(0, 2) : []; - - const reviewsText = topReviews - .map((r) => `- ${r.author_name} (${r.rating || 'N/A'}): ${r.summary || r.text || ''}`) - .join('\n'); - - return `\n${i + 1}) ${p.name}\n Cuisines: ${cuisines || 'N/A'}\n Rating: ${p.rating || 'N/A'} (${p.user_ratings_total || 0} reviews)\n Address: ${p.address || 'N/A'}\n Reviews (up to 2):\n${reviewsText || '- N/A'}\n`; - }) - .join(''); - - dataBlock += `\n\nFood places records (use ONLY these names when recommending):\n${placesBlock}`; - } - } else { - // Best-effort location string; foodRecommendationService will geocode & persist. + if (!foodRecord || isExpired || !hasStructuredRestaurants || isLegacyFoodRecord) { const location = bot?.eventLocation || bot?.location || ''; - const foodText = location - ? await getFoodRecommendations(location, eventId) - : await getFoodRecommendations('', eventId); - dataBlock = `Food knowledge (ground truth / fetched):\n${foodText}`; + await getFoodRecommendations(location, eventId); + foodRecord = await fetchFoodRecordForEvent(eventId); } } - // 2) Generate response if (intentCategory === 'Food') { - const questionLower = String(userQuestion || '').toLowerCase(); + const restaurants = Array.isArray(foodRecord?.restaurants) + ? foodRecord.restaurants.filter((record) => normalizePlaceId(record?.place_id || record?.foodId || record?.placeId || record?.id)) + : []; - // Minimal dish-key extraction for deterministic matching. - // This is intentionally conservative: only match dishes we can reliably ground from DB context. - // For broad queries like "South Indian breakfast", we map to common dish keywords. - const dishKeys = []; - - const pushUnique = (k) => { - if (!k) return; - if (!dishKeys.includes(k)) dishKeys.push(k); - }; - - // Misal - if (questionLower.includes('misal')) pushUnique('misal'); - - // Pav bhaji / bhaji - if (questionLower.includes('pav bhaji') || questionLower.includes('bhaji')) pushUnique('pav bhaji'); - - // Biryani - if (questionLower.includes('biryani')) pushUnique('biryani'); - - // South Indian breakfast / common South Indian dishes - if (questionLower.includes('idli')) pushUnique('idli'); - if (questionLower.includes('dosa')) pushUnique('dosa'); - if (questionLower.includes('vada') || questionLower.includes('vada pav')) pushUnique('vada'); - if (questionLower.includes('vada pav')) pushUnique('vada pav'); - if (questionLower.includes('uttapam')) pushUnique('uttapam'); - if (questionLower.includes('sambar')) pushUnique('sambar'); - if (questionLower.includes('chutney')) pushUnique('chutney'); - if (questionLower.includes('upma')) pushUnique('upma'); - if (questionLower.includes('pongal')) pushUnique('pongal'); - - // If explicitly asked for South Indian breakfast but no specific dish keywords are present, - // still attempt a deterministic search using a small set of very common keywords. - if (dishKeys.length === 0 && /south\s*indian/.test(questionLower)) { - ['idli', 'dosa', 'vada', 'uttapam', 'sambar'].forEach(pushUnique); + if (!restaurants.length) { + return { + intent: intentCategory, + text: 'Food suggestions are currently unavailable for this event.', + foods: [], + usage: null, + modelId: null, + }; } - const dishKey = dishKeys.length === 1 ? dishKeys[0] : null; + const { mode: foodQueryMode, specificTerms } = inferFoodQueryMode(userQuestion); + const evidenceMatches = foodQueryMode === 'specific' + ? filterFoodRecordsByTerms(restaurants, specificTerms) + : []; + const foodContext = restaurants.slice(0, 40).map((record, index) => { + const foodId = normalizePlaceId(record.place_id || record.foodId || record.placeId || record.id); + const cuisineValues = (Array.isArray(record.cuisines) ? record.cuisines : [record.cuisines]) + .map((cuisine) => String(cuisine || '').trim()) + .filter((cuisine) => cuisine && cuisine.toLowerCase() !== 'n/a'); + const cuisines = cuisineValues.length ? cuisineValues.join(', ') : 'Not specified'; + const reviews = Array.isArray(record.reviews) + ? record.reviews + .slice(0, 2) + .map((review) => String(review?.summary || review?.text || '').replace(/\s+/g, ' ').trim().slice(0, 240)) + .filter(Boolean) + .join(' | ') + : record.reviewsSummary || 'N/A'; - // Deterministic dish->place+review matching using the persisted DB-context text. - // IMPORTANT: Create human-like review bullets by removing the reviewer name. - // Example input stored format: - // "- Questraveler Questraveler (4): Perfect place ..." - // Output bullet should be: - // "- (4): Perfect place ..." - if (dishKey) { - const matches = []; + const directEvidence = evidenceMatches.includes(record) ? 'yes' : 'no'; + return `${index + 1}) Food ID: ${foodId}\nName: ${record.name || 'Unnamed restaurant'}\nCuisines: ${cuisines}\nRating: ${record.rating ?? 'N/A'} (${record.user_ratings_total ?? 0} reviews)\nAddress: ${record.address || record.formatted_address || 'N/A'}\nDirect query evidence: ${directEvidence}\nReview evidence: ${reviews || 'N/A'}`; + }).join('\n\n'); - const blockStart = String(dataBlock || '').indexOf('Food places records'); - if (blockStart !== -1) { - const placesSection = String(dataBlock || '').slice(blockStart); - const placeChunks = placesSection.split(/\n\s*\d+\)\s+/).slice(1); - - const dishRegex = new RegExp( - `\\b${dishKey.replace(/[-/\\^$*+?.()|[\\]{}]/g, '\\$&')}\\b`, - 'i', - ); - - for (const chunk of placeChunks) { - const chunkText = String(chunk || ''); - const nameLine = chunkText.split('\n')[0] || ''; - const placeName = nameLine.trim(); - if (!placeName) continue; - - if (dishRegex.test(chunkText)) { - const reviewLines = chunkText - .split('\n') - .filter((l) => dishRegex.test(l)) - .slice(0, 2); - - matches.push({ placeName, reviewSnippets: reviewLines }); - } - } - } - - if (matches.length) { - const parts = matches.map((m) => { - const reviews = !m.reviewSnippets.length - ? '- (review snippet not available)' - : m.reviewSnippets - .map((s) => String(s || '').trim()) - .filter(Boolean) - .map((line) => { - // strip leading "- (): " - // keep "- (): " or similar. - // Works for common patterns: "- Name (4): text" - const cleaned = line - .replace(/^[-\s]*[^:]+\(([^)]*)\):\s*/g, '($1): ') - .replace(/^[-\s]*[^:]+:\s*/g, ''); - - // Ensure bullet format - return `- ${cleaned.replace(/^[-\s]*/, '').trim()}`; - }) - .join('\n'); - - return `• ${m.placeName}\n${reviews}`; - }); - - return { - intent: intentCategory, - text: `🍽 Best match for ${userQuestion.trim()}\n\n${parts.join('\n\n')}`, - usage: null, - modelId: null, - }; - } - } - - // If dish-specific matching didn’t find anything, fall back to LLM using only context. - // (LLM fallback prompt should avoid refusal phrasing.) const responseModelId = 'meta.llama3-8b-instruct-v1:0'; - const { createBedrockClient, getBedrockConfig } = require('../utils/bedrockClient'); const { converse, responseText } = require('../utils/bedrockConverse'); - const { region } = getBedrockConfig(); const client = createBedrockClient(region); - const foodSystemPrompt = `You are a food concierge for an event assistant. + const foodSystemPrompt = `You are the EventBuzz food recommendation selector. -You will be given a food recommendations context. -Your job is to answer the CURRENT USER QUESTION using ONLY information from the context. +Select restaurants that best answer the current user question using ONLY the available food records. -GROUNDING RULES (MUST FOLLOW): -1) Do NOT invent restaurants, dishes, or addresses. -2) Only recommend dishes/foods or restaurants that appear in the provided context. +STRICT RULES: +1. Never invent a restaurant, dish, food ID, address, rating, or fact. +2. Select only Food IDs that appear exactly in the available records. +3. The backend supplies a QUERY MODE. Follow the rules for that mode exactly. +4. GENERAL mode means the user wants restaurants or a meal such as lunch/dinner without naming a dish, cuisine, or dietary restriction. In GENERAL mode, select 3 to 5 of the best available restaurants. Exact dish evidence is not required. Rank using rating and review count. +5. SPECIFIC mode means the user named a dish, cuisine, or dietary restriction. In SPECIFIC mode, select only records with clear evidence for that request in the restaurant name, cuisines, review evidence, or supplemental context. Never assume every restaurant serves the requested item. +6. In SPECIFIC mode, return only the strong matches (normally 1 to 3). Do not add weak or unrelated records merely to reach a count. +7. If SPECIFIC mode has no evidence-backed match, return an empty array. +8. Return ONLY a valid JSON array of Food ID strings, such as ["id_1","id_2"]. +9. Do not return markdown, names, explanations, or any text outside the JSON array. -If the exact dish/restaurant requested is NOT clearly present in the context, do NOT say a refusal sentence. Instead: -- Suggest the closest dishes/foods that ARE present in the provided context. -- If you can't find any relevant dish from the context, say you don't have relevant recommendations from the provided list. +Examples: +- "Recommend restaurants near the venue" is GENERAL: return 3 to 5 highly rated IDs. +- "Where can I have lunch?" is GENERAL: return 3 to 5 highly rated IDs. +- "Best biryani near the venue" is SPECIFIC: return only IDs explicitly supported for biryani. +- "Where can I get pizza?" is SPECIFIC: return only IDs explicitly supported for pizza, or [] if none are supported.`; -Return ONLY plain text (no JSON).`; - - const userText = `CURRENT USER QUESTION:\n${userQuestion}\n\nFOOD RECOMMENDATIONS CONTEXT (do not invent beyond this):\n${dataBlock}`; + const userText = `CURRENT USER QUESTION:\n${userQuestion}\n\nQUERY MODE: ${foodQueryMode.toUpperCase()}\nSPECIFIC TERMS: ${specificTerms.length ? specificTerms.join(', ') : 'none'}\n\nAVAILABLE FOOD RECORDS:\n${foodContext}${foodRecord?.recommendations ? `\n\nSUPPLEMENTAL FOOD CONTEXT:\n${String(foodRecord.recommendations).slice(0, 5000)}` : ''}\n\nReturn only the selected Food IDs as a JSON array.`; try { const response = await converse(client, responseModelId, { system: [{ text: foodSystemPrompt }], messages: [{ role: 'user', content: [{ text: userText }] }], - inferenceConfig: { - temperature: 0.4, - topP: 0.9, - maxTokens: 500, - }, + inferenceConfig: { temperature: 0, topP: 0.1, maxTokens: 150 }, + }); + + const rawIds = responseText(response).trim(); + const selectedIds = parseFoodIdsFromLlmText(rawIds); + const selectableRecords = foodQueryMode === 'specific' && evidenceMatches.length + ? evidenceMatches + : restaurants; + let foods = selectFoodsByIds(selectableRecords, selectedIds, 5); + let usedDatabaseFallback = false; + let usedEvidenceFallback = false; + + // Obvious lexical matches (for example cafe/cafes or Pizza Hut/pizza) + // remain grounded even when the model returns an empty array. + if (!foods.length && foodQueryMode === 'specific' && evidenceMatches.length) { + foods = rankFoodRecords(evidenceMatches, 3); + usedEvidenceFallback = foods.length > 0; + } + + // A broad request does not require semantic dish matching. If the model + // returns an empty/malformed array, return grounded, DB-ranked cards. + if (!foods.length && foodQueryMode === 'general') { + foods = rankFoodRecords(restaurants, 5); + usedDatabaseFallback = foods.length > 0; + } + + console.log('[pipeline][food][llm]', { + queryMode: foodQueryMode, + specificTerms, + evidenceMatches: evidenceMatches.length, + rawOutput: rawIds.slice(0, 300), + selectedIds: selectedIds.length, + validatedFoods: foods.length, + usedDatabaseFallback, + usedEvidenceFallback, }); return { intent: intentCategory, - text: responseText(response).trim(), + text: foods.length + ? foodQueryMode === 'general' + ? 'Here are some of the best food places near the venue.' + : 'Here are the best matching food places I found for you.' + : "I couldn't find a matching food place in the available recommendations for this event.", + foods, usage: null, modelId: responseModelId, }; } catch (e) { - console.warn('[pipeline][food] Bedrock failed; falling back to deterministic formatting:', e?.message || e); + console.warn('[pipeline][food][llm] failed:', e?.message || e); + const foods = foodQueryMode === 'general' ? rankFoodRecords(restaurants, 5) : []; + return { + intent: intentCategory, + text: foods.length + ? 'Here are some of the best food places near the venue.' + : 'Food suggestions are currently unavailable for this event.', + foods, + usage: null, + modelId: null, + }; + } + } + + if (intentCategory === 'Personal Information') { + if (!chatSession || !organizationId) { + return { + intent: intentCategory, + text: 'Please start a new chat and try your personal-information question again.', + usage: null, + modelId: null, + }; } + const personalResult = await runPersonalInformation({ + eventId, + organizationId, + session: chatSession, + sessionHistory, + userQuestion, + }); return { intent: intentCategory, - text: `🍽 Food suggestions are currently unavailable for this event.`, + text: personalResult.text, usage: null, modelId: null, }; } - if ( - intentCategory === 'Event Information' || - intentCategory === 'Personal Information' || - intentCategory === 'Places' - ) { + if (intentCategory === 'Event Information') { const includeEventDetails = bot?.knowledge?.includeEventDetails !== false; const ragResult = await executeConciergeRag(eventId, userQuestion, eventName, { @@ -264,6 +602,262 @@ Return ONLY plain text (no JSON).`; }; } + if (intentCategory === 'Places') { + const questionLower = String(userQuestion || '').toLowerCase(); + + // Instead return a short explanation modeled after the Food pipeline. + // This prevents Places flow from responding with unrelated tourist/temple/mall content. + const isAskingFoodRecommendationHowWorks = + /food\s*recommend(ation)?/.test(questionLower) && + /(how|works|explain|explanation)/.test(questionLower); + + if (isAskingFoodRecommendationHowWorks) { + return { + intent: intentCategory, + text: + '🍽 Food recommendations work by: (1) classifying your request as food, (2) fetching the saved food recommendations/places for this event from the database, and (3) using the LLM to answer ONLY from that stored context (so it won\'t invent restaurants or dishes).', + usage: null, + modelId: null, + }; + } + + + const touristPlacesRegex = + /\b(tourists?|attractions?|sightseeing|landmarks?|monuments?|museums?|galleries?|temples?|parks?|viewpoints?|malls?|shopping|gardens?|forts?|lakes?|zoos?|churches?|mosques?|gurudwaras?|palaces?|beaches?|waterfalls?|places to visit|things to do)\b/i; + + const shouldUseTouristPlaces = touristPlacesRegex.test(questionLower); + if (shouldUseTouristPlaces) { + const existing = await fetchPlacesRecordForEvent(eventId); + + const isExpired = + existing?.generatedAt && + Date.now() - new Date(existing.generatedAt).getTime() > 24 * 60 * 60 * 1000; + + // If not expired and we have DB data, use it. + if (existing && !isExpired) { + const responseModelId = 'meta.llama3-8b-instruct-v1:0'; + + const { createBedrockClient, getBedrockConfig } = require('../utils/bedrockClient'); + const { converse, responseText } = require('../utils/bedrockConverse'); + + const { region } = getBedrockConfig(); + const client = createBedrockClient(region); + + const recommendationsText = existing?.recommendations || ''; + const structuredPlaces = Array.isArray(existing?.places) ? existing.places : []; + const { requestedCategory, places: candidatePlaces } = filterPlacesForQuestion(structuredPlaces, userQuestion); + + if (requestedCategory && candidatePlaces.length === 0) { + return { + intent: intentCategory, + text: requestedCategory.noMatch, + places: [], + usage: null, + modelId: null, + }; + } + + // Structured, question-aware grounding from `existing.places[]` including reviewsSummary. + const placesContextBlock = candidatePlaces.length + ? candidatePlaces + .slice(0, 40) + .map((p, i) => { + const name = p?.name || 'Unnamed place'; + const placeId = p?.place_id || p?.placeId || p?.id || ''; + const tags = Array.isArray(p?.tags) ? p.tags.join(', ') : (p?.tags || ''); + const rating = p?.rating != null ? String(p.rating) : 'N/A'; + const address = p?.address || 'N/A'; + const reviewsSummary = p?.reviewsSummary || p?.reviewSummary || 'N/A'; + + return `\n${i + 1}) ${name}\n Place ID: ${placeId || 'N/A'}\n Tags: ${tags || 'N/A'}\n Rating: ${rating}\n Address: ${address}\n Reviews summary: ${reviewsSummary}`; + }) + .join('') + : ''; + + const dataBlockTourist = placesContextBlock + ? `Tourist places (structured with reviews summary):\n${placesContextBlock}${recommendationsText ? `\n\nSupplemental recommendations text:\n${recommendationsText}` : ''}` + : `Tourist places recommendations (ground truth / fetched):\n${recommendationsText}`; + + const placesSystemPrompt = ` +You are an EventBuzz Tourist Places Concierge. + +Your job is to answer the user's question ONLY using the tourist places provided in the context. + +======================== +STRICT RULES +======================== + +1. NEVER invent any place, address, review, rating or fact. + +2. Recommend ONLY places that exist in the provided context. + +3. If the user asks for a specific category such as: +- temple +- museum +- shopping mall +- park +- garden +- viewpoint +- zoo +- church +- mosque +- gurudwara +- fort +- lake +- palace +- beach +- waterfall + +then ONLY recommend places belonging to that category. + +4. DO NOT substitute another category. + +Example: +User: Nearby temples? +❌ Wrong: Recommend a memorial. +✅ Correct: If no temples exist, say: +"I couldn't find any temples in the available recommendations." + +5. If multiple matching places exist: +- Rank them using rating and review quality. +- Mention 2-5 places. +- Mention why they are recommended using the review summary. +- Do not invent additional information. + +6. If the user asks: +"What tourist attractions are nearby?" + +then recommend the highest-rated tourist attractions from the context regardless of category. + +7. If no relevant place exists in the provided context, reply: + +"I couldn't find any matching places in the available recommendations for this event." + +8. Return ONLY a JSON array of selected place IDs, for example: +["place_id_1","place_id_2"] + +9. If no relevant place exists, return an empty JSON array: [] + +10. Never include markdown, explanations, names, addresses, or any text outside the JSON array. +`; + + const userText = ` + USER QUESTION: + ${userQuestion} + + AVAILABLE TOURIST PLACES: + + ${dataBlockTourist} + + Answer ONLY with a JSON array of place IDs from the places above. + `; + + console.log('[pipeline][places][llm] modelId=', responseModelId, { + hasStructuredPlaces: candidatePlaces.length > 0, + structuredCount: candidatePlaces.length, + requestedCategory: requestedCategory?.label || null, + }); + + try { + const response = await converse(client, responseModelId, { + system: [{ text: placesSystemPrompt }], + messages: [{ role: 'user', content: [{ text: userText }] }], + inferenceConfig: { temperature: 0.4, topP: 0.9, maxTokens: 500 }, + }); + + const text = responseText(response).trim(); + const selectedPlaceIds = parsePlaceIdsFromLlmText(text); + const selectedPlaces = selectPlacesByIds(candidatePlaces, selectedPlaceIds, 5); + console.log('[pipeline][places][llm] success:', text.slice(0, 120), { + selectedPlaceIds: selectedPlaceIds.length, + selectedPlaces: selectedPlaces.length, + }); + + if (selectedPlaces.length) { + return { + intent: intentCategory, + text: 'Here are the best matching places I found for you.', + places: selectedPlaces, + usage: null, + modelId: responseModelId, + }; + } + + const deterministicPlaces = rankPlaces(candidatePlaces, 5) + .map(publicPlaceFromRecord) + .filter(Boolean); + + if (deterministicPlaces.length) { + return { + intent: intentCategory, + text: 'Here are the best matching places I found for you.', + places: deterministicPlaces, + usage: null, + modelId: null, + }; + } + + return { + intent: intentCategory, + text: requestedCategory?.noMatch || "I couldn't find any matching places in the available recommendations for this event.", + places: [], + usage: null, + modelId: responseModelId, + }; + } catch (e) { + console.warn('[pipeline][places][llm] failed; falling back:', e?.message || e); + + const fallback = recommendationsText + ? recommendationsText + : 'Tourist place suggestions are currently unavailable for this event.'; + + return { + intent: intentCategory, + text: String(fallback).trim(), + places: [], + usage: null, + modelId: null, + }; + } + } + + // Expired or missing DB: fetch fresh recommendations and return. + const location = bot?.eventLocation || bot?.location || ''; + const placesText = location ? await getPlacesRecommendations(location, eventId) : await getPlacesRecommendations('', eventId); + const refreshed = await fetchPlacesRecordForEvent(eventId); + const { requestedCategory, places: refreshedCandidates } = filterPlacesForQuestion( + Array.isArray(refreshed?.places) ? refreshed.places : [], + userQuestion, + ); + const refreshedPlaces = refreshedCandidates.length + ? rankPlaces(refreshedCandidates, 5) + .map(publicPlaceFromRecord) + .filter(Boolean) + : []; + + return { + intent: intentCategory, + text: refreshedPlaces.length + ? 'Here are the best matching places I found for you.' + : requestedCategory?.noMatch || placesText, + places: refreshedPlaces, + usage: null, + modelId: null, + }; + } + } + + // Fallback: if intent was correctly classified as Places, don't overwrite with the generic greeting. + if (intentCategory === 'Places') { + return { + intent: intentCategory, + text: 'No places available nearby right now.', + places: [], + usage: null, + modelId: null, + }; + } + return { intent: intentCategory, text: 'Hi! Tell me what you’re looking for (food, directions, schedule, or your ticket/check-in).', diff --git a/src/services/conciergeRag.service.js b/src/services/conciergeRag.service.js index c6a58f5..d1043ca 100644 --- a/src/services/conciergeRag.service.js +++ b/src/services/conciergeRag.service.js @@ -96,16 +96,68 @@ async function executeConciergeRag(eventId, userQuery, eventName, options = {}) console.log('[conciergeRag][debug] RAG chunk gating', { eventId, isActive: true, + includeEventDetails, + includeFoodRecommendations, + promptIntent: options?.promptIntent, embedModelId, totalActiveForEvent, matchingChunksCount, - }); + excludedSourceIds: chunkFilter?.sourceId?.$nin, + chunkFilterKeys: Object.keys(chunkFilter), + excludedSourceIds: excludedSourceIds.length ? excludedSourceIds : undefined, + }); } catch (e) { console.warn('[conciergeRag][debug] chunk gating log failed:', e?.message || e); } - const activeChunks = await ContextChunk.find(chunkFilter).lean(); - if (!activeChunks.length) return null; + // Note: we intentionally only want active chunks, but if ingestion marked + // chunks as inactive (isActive=false) while embeddings exist, RAG would + // return null. Keep behavior for normal cases, but log a hint. + let activeChunks = await ContextChunk.find(chunkFilter).lean(); + if (!activeChunks.length) { + // Targeted fallback: if no active chunks exist, allow using inactive chunks. + // This unblocks the Event Information pipeline when ingestion hasn't activated chunks yet. + const fallbackFilter = { + ...chunkFilter, + isActive: false, + }; + try { + activeChunks = await ContextChunk.find(fallbackFilter).lean(); + } catch (e) { + // keep activeChunks empty; existing fallback to null will happen below + } + if (activeChunks.length) { + console.log('[conciergeRag][debug] Using inactive chunks as fallback (no active chunks found)', { + eventId, + embedModelId, + inactiveUsed: true, + count: activeChunks.length, + }); + } else { + try { + const inactiveCount = await ContextChunk.countDocuments({ + eventId, + isActive: false, + embeddingModel: embedModelId, + }); + console.log( + '[conciergeRag][debug] no active chunks; inactive chunks with embedModel exist?', + { eventId, embedModelId, inactiveCount }, + ); + } catch {} + + console.log('[conciergeRag][debug] RAG returned no active chunks', { + eventId, + isActive: true, + includeEventDetails, + embedModelId, + excludedSourceIds: chunkFilter?.sourceId?.$nin, + chunkFilterKeys: Object.keys(chunkFilter), + }); + return null; + } + } + const ranked = activeChunks diff --git a/src/services/foodDb.service.js b/src/services/foodDb.service.js index 3b984ab..1215720 100644 --- a/src/services/foodDb.service.js +++ b/src/services/foodDb.service.js @@ -42,6 +42,8 @@ async function fetchFoodRecordForEvent(eventId) { location: doc.location, recommendations: doc.recommendations, restaurants: doc.restaurants, + schemaVersion: doc.schemaVersion, + generatedAt: doc.generatedAt || doc.createdAt, raw: doc, }; } finally { diff --git a/src/services/foodRecommendationService.js b/src/services/foodRecommendationService.js index a5345d8..962787c 100644 --- a/src/services/foodRecommendationService.js +++ b/src/services/foodRecommendationService.js @@ -18,14 +18,20 @@ const BASE_DETAILS_URL = const DEFAULT_RADIUS_METERS = 2000; const DEFAULT_RESULT_COUNT = 20; +const MAX_RESULTS_PER_QUERY = 2; const SEARCH_QUERY_TEMPLATES = [ + "top restaurants in {location}", + "breakfast restaurants in {location}", + "best cafes in {location}", + "South Indian restaurants in {location}", + "vegetarian restaurants in {location}", + "pizza restaurants in {location}", + "biryani restaurants in {location}", "best local food in {location}", "traditional food in {location}", "street food in {location}", - "top restaurants in {location}", "famous dishes in {location}", - "best cafes in {location}", "local cuisine near {location}", ]; @@ -134,7 +140,7 @@ function parseCuisine(types = []) { .replace(/\b\w/g, (c) => c.toUpperCase()) ); - return cuisines.length ? cuisines : ["N/A"]; + return cuisines; } async function getPlaceDetails(placeId, apiKey) { @@ -174,6 +180,10 @@ async function searchFoodPlaces( ) { const seen = new Set(); const places = []; + const hasUsableOrigin = + Number.isFinite(Number(originLat)) && + Number.isFinite(Number(originLng)) && + !(Number(originLat) === 0 && Number(originLng) === 0); for (const template of SEARCH_QUERY_TEMPLATES) { if (places.length >= limit) break; @@ -203,12 +213,34 @@ async function searchFoodPlaces( continue; } + let addedForQuery = 0; for (const place of result.results || []) { - if (places.length >= limit) break; + if (places.length >= limit || addedForQuery >= MAX_RESULTS_PER_QUERY) break; const placeId = place.place_id; - if (!placeId || seen.has(placeId)) { + const lat = Number(place.geometry?.location?.lat); + const lng = Number(place.geometry?.location?.lng); + const hasCoordinates = Number.isFinite(lat) && Number.isFinite(lng); + const distanceMeters = hasCoordinates && hasUsableOrigin + ? haversineDistance(originLat, originLng, lat, lng) + : null; + + // Text Search treats radius as a bias, so enforce it ourselves. + if (distanceMeters != null && distanceMeters > radius) continue; + + if (!placeId) { + continue; + } + + if (seen.has(placeId)) { + const existingPlace = places.find((candidate) => candidate.place_id === placeId); + if (existingPlace && !Array.isArray(existingPlace.matched_queries)) { + existingPlace.matched_queries = []; + } + if (existingPlace && !existingPlace.matched_queries.includes(queryText)) { + existingPlace.matched_queries.push(queryText); + } continue; } @@ -223,23 +255,22 @@ async function searchFoodPlaces( ); } catch {} - const lat = - Number(place.geometry?.location?.lat || 0); - - const lng = - Number(place.geometry?.location?.lng || 0); - const types = details.types || place.types || []; places.push({ name: place.name, address: place.formatted_address, + location: + hasCoordinates + ? { lat, lng } + : null, rating: place.rating, user_ratings_total: place.user_ratings_total, types, place_id: placeId, + matched_queries: [queryText], business_status: place.business_status, opening_hours: @@ -271,14 +302,9 @@ async function searchFoodPlaces( types.includes("meal_delivery"), takeaway: types.includes("meal_takeaway"), - distance_meters: - haversineDistance( - originLat, - originLng, - lat, - lng - ), + distance_meters: distanceMeters, }); + addedForQuery += 1; } } @@ -518,7 +544,16 @@ async function getFoodRecommendations(location, eventId = null) { await client.connect(); const db = client.db('reallives_serverless_eb'); const coll = db.collection('Food'); - const doc = { eventId: eventId ? String(eventId) : null, location, origin: geo, recommendations: fallbackText, places: [], note: 'no-api-key', createdAt: new Date() }; + const doc = { + eventId: eventId ? String(eventId) : null, + location, + origin: geo, + recommendations: fallbackText, + restaurants: [], + schemaVersion: 4, + note: 'no-api-key', + createdAt: new Date(), + }; if (doc.eventId) await coll.updateOne({ eventId: doc.eventId }, { $set: doc }, { upsert: true }); else await coll.insertOne(doc); await client.close(); } catch (dbErr) { @@ -593,6 +628,7 @@ async function getFoodRecommendations(location, eventId = null) { origin: { lat: geo.lat, lng: geo.lng }, recommendations: recommendationText, restaurants: places, + schemaVersion: 4, createdAt: new Date(), }; diff --git a/src/services/personalInformation.service.js b/src/services/personalInformation.service.js new file mode 100644 index 0000000..8671e8d --- /dev/null +++ b/src/services/personalInformation.service.js @@ -0,0 +1,220 @@ +const Participant = require('../models/Participant'); +const Event = require('../models/Event'); +const Session = require('../models/Session'); +const SessionBookmark = require('../models/SessionBookmark'); +const { getMembership, membershipEmailQuery } = require('../utils/participantMembership'); + +const TIME_ZONE = process.env.EVENT_TIME_ZONE || 'Asia/Kolkata'; +const EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i; + +function extractEmail(value) { + const match = String(value || '').match(EMAIL_RE); + return match ? match[0].trim().toLowerCase() : ''; +} + +function attendeeName(participant) { + return String(participant?.displayName || '').trim() + || [participant?.firstName, participant?.lastName].filter(Boolean).join(' ').trim() + || 'Attendee'; +} + +function formatDateTime(value) { + if (!value) return 'time not specified'; + return new Intl.DateTimeFormat('en-IN', { + timeZone: TIME_ZONE, + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(value)); +} + +function dateKey(value) { + return new Intl.DateTimeFormat('en-CA', { + timeZone: TIME_ZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(new Date(value)); +} + +function statusLabel(status) { + return ({ + pending: 'pending approval', + confirmed: 'confirmed', + checked_in: 'checked in', + cancelled: 'cancelled', + })[status] || status || 'registered'; +} + +function priorQuestion(history, currentQuestion) { + const current = String(currentQuestion || '').trim(); + const rows = Array.isArray(history) ? history : []; + for (let i = rows.length - 1; i >= 0; i -= 1) { + const text = String(rows[i]?.text || '').trim(); + if (rows[i]?.role === 'user' && text && text !== current && !extractEmail(text)) return text; + } + return ''; +} + +async function getSavedSchedule(participantId, eventId, question, membership) { + const bookmarks = await SessionBookmark.find({ participant: participantId, event: eventId }) + .select('session') + .lean(); + const sessionIds = bookmarks.map((row) => row.session).filter(Boolean); + + if (!sessionIds.length) { + const isRegisteredOrCheckedIn = + membership?.checkedIn || membership?.status === 'checked_in' || membership?.status; + + if (isRegisteredOrCheckedIn) { + return 'I can’t find any saved/bookmarked sessions for you in this event yet. If you registered for specific sessions under your track/agenda, that information isn’t available in the concierge schedule endpoint right now—please use the session/agenda area in the event website or ask event staff.'; + } + + return 'You do not have any saved sessions for this event yet.'; + } + + let sessions = await Session.find({ + _id: { $in: sessionIds }, + event: eventId, + status: { $ne: 'cancelled' }, + }) + .select('title startTime endTime location status') + .sort({ startTime: 1 }) + .lean(); + + if (/\btoday\b/i.test(question)) { + const today = dateKey(new Date()); + sessions = sessions.filter((row) => dateKey(row.startTime) === today); + if (!sessions.length) return 'You do not have any saved sessions scheduled for today.'; + } + + const shown = sessions.slice(0, 10); + const lines = shown.map((row) => + `• ${row.title} — ${formatDateTime(row.startTime)}${row.location ? ` · ${row.location}` : ''}`); + const more = sessions.length > shown.length ? `\n…and ${sessions.length - shown.length} more.` : ''; + return `Your saved session schedule:\n${lines.join('\n')}${more}`; +} + +async function getRegisteredEvents(participant, organizationId) { + const memberships = Array.isArray(participant.eventMemberships) ? participant.eventMemberships : []; + const byEvent = new Map(memberships.map((row) => [String(row.event), row])); + const eventIds = [...byEvent.keys()]; + if (!eventIds.length) return 'I could not find any registered events for this email address.'; + + const events = await Event.find({ _id: { $in: eventIds }, organization: organizationId }) + .select('name startDate') + .sort({ startDate: 1 }) + .lean(); + if (!events.length) return 'I could not find any registered events for this email address.'; + + const lines = events.slice(0, 10).map((event) => + `• ${event.name} — ${statusLabel(byEvent.get(String(event._id))?.status)} (${formatDateTime(event.startDate)})`); + return `Your registered events:\n${lines.join('\n')}`; +} + +async function answerPersonalQuestion({ participant, eventId, organizationId, question }) { + const q = String(question || '').toLowerCase(); + const membership = getMembership(participant, eventId); + + if (/\bevents?\b/.test(q) && /\b(my|registered|registrations?)\b/.test(q)) { + return getRegisteredEvents(participant, organizationId); + } + if (/\b(sessions?|schedule|agenda|itinerary)\b/.test(q)) { + return getSavedSchedule(participant._id, eventId, question, membership); + } + if (/\b(check[ -]?in|checked[ -]?in)\b/.test(q)) { + if (membership?.checkedIn || membership?.status === 'checked_in') { + const when = membership.checkedInAt ? ` as of ${formatDateTime(membership.checkedInAt)}` : ''; + return `Yes, ${attendeeName(participant)}, you are checked in for this event${when}.`; + } + return `You are registered for this event, but you are not checked in yet. Your registration status is ${statusLabel(membership?.status)}.`; + } + if (/\b(payment|paid|transaction)\b/.test(q)) { + const payment = participant.payment || {}; + const amount = payment.amount != null ? ` (${payment.currency || 'INR'} ${payment.amount})` : ''; + return payment.status + ? `Your payment status is ${payment.status}${amount}.` + : 'I do not have a payment status recorded for your registration.'; + } + if (/\b(phone|mobile|contact number)\b/.test(q)) { + return participant.phone + ? `The phone number on your profile is ${participant.phone}.` + : 'I do not have a phone number recorded on your profile.'; + } + if (/\b(profile|name|email|designation|company|organization)\b/.test(q)) { + return [ + `Name: ${attendeeName(participant)}`, + `Email: ${participant.email}`, + participant.phone ? `Phone: ${participant.phone}` : '', + participant.designation ? `Designation: ${participant.designation}` : '', + participant.organization ? `Organization: ${participant.organization}` : '', + `Event registration: ${statusLabel(membership?.status)}`, + ].filter(Boolean).join('\n'); + } + if (/\b(ticket|badge|qr|registration|registered|status)\b/.test(q)) { + const registeredAt = membership?.registrationDate + ? `, registered on ${formatDateTime(membership.registrationDate)}` + : ''; + return `Your registration for this event is ${statusLabel(membership?.status)}${registeredAt}.`; + } + return `${attendeeName(participant)}, your registration status for this event is ${statusLabel(membership?.status)}. You can ask about your saved schedule, registered events, check-in, payment, or profile.`; +} + +async function runPersonalInformation({ eventId, organizationId, session, sessionHistory, userQuestion }) { + let participant = null; + let effectiveQuestion = userQuestion; + const suppliedEmail = extractEmail(userQuestion); + + // An email entered at any point is an explicit request to identify/switch the + // attendee. Clear the previous binding before lookup to avoid stale PII. + if (suppliedEmail) { + session.participant = null; + session.identityVerifiedAt = null; + session.identityMethod = null; + participant = await Participant.findOne( + membershipEmailQuery(organizationId, eventId, suppliedEmail), + ).lean(); + + if (!participant) { + session.awaitingPersonalEmail = true; + return { text: 'I could not find an event registration for that email address. Please check the address and try again.' }; + } + + session.participant = participant._id; + session.awaitingPersonalEmail = false; + session.identityVerifiedAt = new Date(); + session.identityMethod = 'email'; + effectiveQuestion = priorQuestion(sessionHistory, userQuestion) || 'registration status'; + + // Persist the identity binding immediately so subsequent questions in the + // same chat session reliably use the correct participant. + await session.save(); + } else if (session.participant) { + participant = await Participant.findOne({ + _id: session.participant, + tenantOrganization: organizationId, + eventMemberships: { $elemMatch: { event: eventId } }, + }).lean(); + if (!participant) { + session.participant = null; + session.identityVerifiedAt = null; + session.identityMethod = null; + } + } + + if (!participant) { + session.awaitingPersonalEmail = true; + return { text: 'Please share the email address you used to register for this event.' }; + } + + session.awaitingPersonalEmail = false; + return { + text: await answerPersonalQuestion({ + participant, + eventId, + organizationId, + question: effectiveQuestion, + }), + }; +} + +module.exports = { runPersonalInformation, extractEmail, answerPersonalQuestion }; diff --git a/src/services/personalInformation.service.test.js b/src/services/personalInformation.service.test.js new file mode 100644 index 0000000..32f81a5 --- /dev/null +++ b/src/services/personalInformation.service.test.js @@ -0,0 +1,119 @@ +jest.mock('../models/Participant', () => ({ findOne: jest.fn() })); + +const Participant = require('../models/Participant'); +const { runPersonalInformation, extractEmail, answerPersonalQuestion } = require('./personalInformation.service'); + +const eventId = '64b000000000000000000001'; +const organizationId = '64b000000000000000000002'; + +function participant(overrides = {}) { + return { + _id: '64b000000000000000000003', + tenantOrganization: organizationId, + email: 'person@example.com', + firstName: 'Asha', + eventMemberships: [{ event: eventId, status: 'confirmed' }], + ...overrides, + }; +} + +describe('personalInformation service', () => { + beforeEach(() => jest.clearAllMocks()); + + it('normalizes an email embedded in a reply', () => { + expect(extractEmail('My email is Person+Event@Example.COM.')).toBe('person+event@example.com'); + }); + + it('asks for the registered email and records the pending state', async () => { + const session = {}; + const result = await runPersonalInformation({ + eventId, + organizationId, + session, + sessionHistory: [{ role: 'user', text: 'What is my schedule?' }], + userQuestion: 'What is my schedule?', + }); + + expect(result.text).toMatch(/email address/i); + expect(session.awaitingPersonalEmail).toBe(true); + expect(Participant.findOne).not.toHaveBeenCalled(); + }); + + it('binds a matching event participant to only the current chat session', async () => { + Participant.findOne.mockReturnValue({ lean: jest.fn().mockResolvedValue(participant()) }); + const session = {}; + const result = await runPersonalInformation({ + eventId, + organizationId, + session, + sessionHistory: [ + { role: 'user', text: 'Am I registered?' }, + { role: 'assistant', text: 'Please share your registered email.' }, + { role: 'user', text: 'person@example.com' }, + ], + userQuestion: 'person@example.com', + }); + + expect(result.text).toMatch(/confirmed/i); + expect(session.participant).toBe('64b000000000000000000003'); + expect(session.awaitingPersonalEmail).toBe(false); + expect(session.identityMethod).toBe('email'); + expect(session.identityVerifiedAt).toBeInstanceOf(Date); + expect(Participant.findOne).toHaveBeenCalledWith(expect.objectContaining({ + tenantOrganization: organizationId, + email: 'person@example.com', + })); + }); + + it('does not bind an email that is not registered for the current event', async () => { + Participant.findOne.mockReturnValue({ lean: jest.fn().mockResolvedValue(null) }); + const session = {}; + const result = await runPersonalInformation({ + eventId, + organizationId, + session, + sessionHistory: [{ role: 'user', text: 'person@example.com' }], + userQuestion: 'person@example.com', + }); + + expect(result.text).toMatch(/could not find/i); + expect(session.participant).toBeUndefined(); + expect(session.awaitingPersonalEmail).toBe(true); + }); + + it('switches the session binding when a different registered email is supplied', async () => { + const replacement = participant({ + _id: '64b000000000000000000004', + email: 'second@example.com', + firstName: 'Second', + eventMemberships: [{ event: eventId, status: 'pending' }], + }); + Participant.findOne.mockReturnValue({ lean: jest.fn().mockResolvedValue(replacement) }); + const session = { participant: '64b000000000000000000003' }; + + const result = await runPersonalInformation({ + eventId, + organizationId, + session, + sessionHistory: [ + { role: 'user', text: 'Am I registered?' }, + { role: 'user', text: 'person@example.com' }, + { role: 'user', text: 'second@example.com' }, + ], + userQuestion: 'second@example.com', + }); + + expect(result.text).toMatch(/pending approval/i); + expect(session.participant).toBe('64b000000000000000000004'); + }); + + it('returns the phone number stored on the participant profile', async () => { + const text = await answerPersonalQuestion({ + participant: participant({ phone: '+91 98765 43210' }), + eventId, + organizationId, + question: 'Tell me my phone number', + }); + expect(text).toContain('+91 98765 43210'); + }); +}); diff --git a/src/services/placesDb.service.js b/src/services/placesDb.service.js new file mode 100644 index 0000000..4bd6b19 --- /dev/null +++ b/src/services/placesDb.service.js @@ -0,0 +1,59 @@ +const { MongoClient } = require('mongodb'); + +const DEFAULT_EXTERNAL_DB_NAME = 'reallives_serverless_eb'; +const DEFAULT_EXTERNAL_COLLECTION = 'places'; + +function getExternalMongoUri() { + return process.env.MONGODB_URI || ''; +} + +function getCollection() { + return process.env.PLACES_EXTERNAL_COLLECTION || DEFAULT_EXTERNAL_COLLECTION; +} + +function getDbName() { + return process.env.PLACES_EXTERNAL_DB || DEFAULT_EXTERNAL_DB_NAME; +} + +/** + * Fetch existing tourist-places record for an event from external DB. + * + * @param {string} eventId + * @returns {Promise<{ location?: string, recommendations?: string, places?: any[], raw?: any } | null>} + */ +async function fetchPlacesRecordForEvent(eventId) { + const eventIdStr = eventId != null ? String(eventId) : ''; + if (!eventIdStr) return null; + + const mongoUri = getExternalMongoUri(); + if (!mongoUri) return null; + + const client = new MongoClient(mongoUri, { + useNewUrlParser: true, + useUnifiedTopology: true, + }); + + await client.connect(); + try { + const db = client.db(getDbName()); + const coll = db.collection(getCollection()); + + // The external doc created in placesRecommendationService uses { eventId: } + const doc = await coll.findOne({ eventId: eventIdStr }); + if (!doc) return null; + + return { + location: doc.location, + recommendations: doc.recommendations, + places: doc.places, + raw: doc, + }; + } finally { + await client.close(); + } +} + +module.exports = { + fetchPlacesRecordForEvent, +}; + diff --git a/src/services/placesRecommendationService.js b/src/services/placesRecommendationService.js new file mode 100644 index 0000000..61c877f --- /dev/null +++ b/src/services/placesRecommendationService.js @@ -0,0 +1,396 @@ +const axios = require("axios"); +const { spawn } = require("child_process"); +const { MongoClient } = require('mongodb'); + +const API_KEY_ENV = 'GOOGLE_PLACES_API_KEY'; +const GOOGLE_PLACES_API_KEY = process.env[API_KEY_ENV]; +const OLLAMA_MODEL_ENV = "OLLAMA_MODEL"; + +const DEFAULT_OLLAMA_MODEL = "phi3"; + +const BASE_GEOCODE_URL = + "https://maps.googleapis.com/maps/api/geocode/json"; +const BASE_PLACES_URL = + "https://maps.googleapis.com/maps/api/place/textsearch/json"; +const BASE_DETAILS_URL = + "https://maps.googleapis.com/maps/api/place/details/json"; + +const DEFAULT_RADIUS_METERS = 2500; +const DEFAULT_RESULT_COUNT = 20; + +const SEARCH_QUERY_TEMPLATES = [ + "tourist attractions in {location}", + "best landmarks near {location}", + "places to visit in {location}", + "museums and galleries near {location}", + "historical sites in {location}", + "monuments and viewpoints near {location}", + "parks and gardens near {location}", +]; + +function getApiKey() { + const apiKey = process.env[API_KEY_ENV]; + + if (!apiKey) { + console.error(`Error: environment variable ${API_KEY_ENV} is not set.`); + process.exit(1); + } + + return apiKey; +} + +async function fetchJson(url, params) { + const response = await axios.get(url, { + params, + timeout: 30000, + }); + + return response.data; +} + +async function geocodeAddress(address, apiKey) { + const result = await fetchJson(BASE_GEOCODE_URL, { + address, + key: apiKey, + }); + + if ( + result.status !== "OK" || + !result.results || + !result.results.length + ) { + throw new Error(`Geocoding failed: ${result.status}`); + } + + const location = result.results[0].geometry.location; + + return { + formattedAddress: + result.results[0].formatted_address || address, + lat: Number(location.lat), + lng: Number(location.lng), + }; +} + +function haversineDistance(lat1, lng1, lat2, lng2) { + const R = 6371000; + + const toRad = (deg) => (deg * Math.PI) / 180; + + const dLat = toRad(lat2 - lat1); + const dLng = toRad(lng2 - lng1); + + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * + Math.cos(toRad(lat2)) * + Math.sin(dLng / 2) ** 2; + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return R * c; +} + +function formatDistance(meters) { + if (meters >= 1000) return `${(meters / 1000).toFixed(1)} km`; + return `${Math.round(meters)} m`; +} + +async function getPlaceDetails(placeId, apiKey) { + const result = await fetchJson(BASE_DETAILS_URL, { + place_id: placeId, + key: apiKey, + fields: [ + "formatted_phone_number", + "international_phone_number", + "price_level", + "reviews", + "types", + "opening_hours", + "geometry", + "website", + ].join(","), + }); + + if (result.status !== "OK" || !result.result) { + throw new Error(`Place details failed: ${result.status}`); + } + + return result.result; +} + +async function searchTouristPlaces( + originLat, + originLng, + apiKey, + locationName = "", + radius = DEFAULT_RADIUS_METERS, + limit = DEFAULT_RESULT_COUNT +) { + const seen = new Set(); + const places = []; + + for (const template of SEARCH_QUERY_TEMPLATES) { + if (places.length >= limit) break; + + const queryText = template.replace("{location}", locationName); + + let result; + try { + result = await fetchJson(BASE_PLACES_URL, { + query: queryText, + location: `${originLat},${originLng}`, + radius, + key: apiKey, + }); + } catch { + continue; + } + + if (result.status !== "OK" && result.status !== "ZERO_RESULTS") { + continue; + } + + for (const place of result.results || []) { + if (places.length >= limit) break; + + const placeId = place.place_id; + if (!placeId || seen.has(placeId)) continue; + + seen.add(placeId); + + let details = {}; + try { + details = await getPlaceDetails(placeId, apiKey); + } catch {} + + const lat = Number(place.geometry?.location?.lat || 0); + const lng = Number(place.geometry?.location?.lng || 0); + + const types = details.types || place.types || []; + + const reviews = Array.isArray(details.reviews) ? details.reviews : []; + const reviewsSummary = reviews + .map((r) => { + const text = r.text; + const summary = String(text || '') + .replace(/\s+/g, ' ') + .trim() + .split(/[.!?]/)[0] + .trim(); + const s = summary || String(text || '').slice(0, 120); + const rating = r.rating != null ? ` (${r.rating})` : ''; + return `${s}${rating}`; + }) + .filter(Boolean) + .slice(0, 8) + .join(' • '); + + places.push({ + name: place.name, + address: place.formatted_address, + rating: place.rating, + location: { lat, lng }, + user_ratings_total: place.user_ratings_total, + types, + place_id: placeId, + business_status: place.business_status, + opening_hours: + details.opening_hours?.open_now, + price_level: details.price_level, + phone_number: + details.formatted_phone_number || + details.international_phone_number, + website: details.website, + reviewsSummary, + tags: types + .filter(Boolean) + .slice(0, 8) + .map((t) => String(t).replace(/_/g, ' ')), + distance_meters: haversineDistance(originLat, originLng, lat, lng), + }); + } + } + + return places; +} + +function getOllamaModelName() { + return process.env[OLLAMA_MODEL_ENV] || DEFAULT_OLLAMA_MODEL; +} + +function runOllamaModel(prompt, modelName) { + return new Promise((resolve, reject) => { + const child = spawn("ollama", ["run", modelName, "--nowordwrap"]); + + let output = ""; + let errorOutput = ""; + + child.stdout.on("data", (data) => { + output += data.toString(); + }); + + child.stderr.on("data", (data) => { + errorOutput += data.toString(); + }); + + child.on("close", (code) => { + if (code !== 0) return reject(new Error(errorOutput)); + resolve(output.trim()); + }); + + child.stdin.write(prompt); + child.stdin.end(); + }); +} + +function buildTouristRecommendationPrompt(places, location) { + const placeSummary = places + .slice(0, 12) + .map((p, index) => { + return `\n${index + 1}. ${p.name}\nTags: ${p.tags.join(', ') || 'N/A'}\nRating: ${p.rating || 'N/A'}\nDistance: ${formatDistance(p.distance_meters)}\nAddress: ${p.address || 'N/A'}\nWhy others like it: ${p.reviewsSummary || 'N/A'}\n`; + }) + .join('\n'); + + return ` +You are a local tourist places recommendation assistant. + +The user is looking for tourist places / attractions / things to do near: +${location} + +Nearby candidate places: +${placeSummary} + +Your task: +1) Recommend 5 to 7 tourist places that a visitor should visit in this area. +2) For each place, include a brief explanation of why it is worth visiting. +3) Use ONLY the places listed above (do NOT invent places). +4) Do NOT assume the location is in a specific country. +5) Start your response with a heading like "🗺️ Top Tourist Places in ${location}". +6) Return ONLY plain text recommendation. +`; +} + +async function getPlacesRecommendationsWithLLM(places, location) { + try { + const model = getOllamaModelName(); + const prompt = buildTouristRecommendationPrompt(places, location); + return await runOllamaModel(prompt, model); + } catch (err) { + console.log('Unable to generate tourist-place recommendations:', err?.message || err); + return ''; + } +} + +async function getPlacesRecommendations(location, eventId = null) { + + const apiKey = process.env[API_KEY_ENV]; + + let geo = { formattedAddress: location, lat: 0, lng: 0 }; + let places = []; + + if (!apiKey) { + const fallbackText = `Tourist place recommendations unavailable: missing Google Places API key for ${location}.`; + + try { + const MONGO_URL = process.env.MONGODB_URI || ''; + const client = new MongoClient(MONGO_URL, { useNewUrlParser: true, useUnifiedTopology: true }); + await client.connect(); + const db = client.db('reallives_serverless_eb'); + const coll = db.collection('places'); + + const doc = { + eventId: eventId ? String(eventId) : null, + location, + origin: geo, + recommendations: fallbackText, + places: [], + note: 'no-api-key', + createdAt: new Date(), + }; + + if (doc.eventId) await coll.updateOne({ eventId: doc.eventId }, { $set: doc }, { upsert: true }); + else await coll.insertOne(doc); + + await client.close(); + } catch (dbErr) { + console.warn('Failed to save tourist places to external DB:', dbErr.message || dbErr); + } + + return fallbackText + ' Set GOOGLE_PLACES_API_KEY to enable real recommendations.'; + } + + try { + geo = await geocodeAddress(location, apiKey); + } catch (e) { + console.warn('[getPlacesRecommendations] Geocoding failed:', e.message); + geo.formattedAddress = location; + } + + try { + places = await searchTouristPlaces(geo.lat, geo.lng, apiKey, geo.formattedAddress); + } catch (e) { + console.warn('[getPlacesRecommendations] Tourist places search failed:', e.message); + } + + let recommendationText = ''; + + try { + recommendationText = await getPlacesRecommendationsWithLLM(places, geo.formattedAddress); + } catch (e) { + console.warn('[getPlacesRecommendations] LLM failed; fallback formatting:', e.message || e); + } + + if (!recommendationText) { + if (places.length === 0) { + recommendationText = `No tourist places found near ${geo.formattedAddress}.`; + } else { + const top = places.slice(0, 6) + .map((p) => `${p.name} (rating: ${p.rating || 'N/A'})`) + .join('\n'); + recommendationText = `Tourist places near ${geo.formattedAddress}\n\nNearby places:\n${top}`; + } + } + + // Save structured places and generated recommendation into external MongoDB + try { + const MONGO_URL = process.env.MONGODB_URI || ''; + const client = new MongoClient(MONGO_URL, { useNewUrlParser: true, useUnifiedTopology: true }); + await client.connect(); + + const db = client.db('reallives_serverless_eb'); + const coll = db.collection('places'); + + const doc = { + eventId: eventId ? String(eventId) : null, + location: geo.formattedAddress, + origin: { lat: geo.lat, lng: geo.lng }, + recommendations: recommendationText, + places, + createdAt: new Date(), + }; + + if (doc.eventId) await coll.updateOne({ eventId: doc.eventId }, { $set: doc }, { upsert: true }); + else await coll.insertOne(doc); + + await client.close(); + } catch (dbErr) { + console.warn('Failed to save tourist places to external DB:', dbErr.message || dbErr); + } + + const placesSummary = places + .slice(0, 10) + .map((p, index) => + `\n${index + 1}. ${p.name}\nTags: ${p.tags.join(', ') || 'N/A'}\nRating: ${p.rating || 'N/A'}\nDistance: ${formatDistance(p.distance_meters)}\nAddress: ${p.address || 'N/A'}\n`, + ) + .join('\n'); + + return `\nTOURIST PLACES RECOMMENDATIONS FOR ${geo.formattedAddress}\n\n${recommendationText}\n\nPLACES TO TRY\n\n${placesSummary}\n`; +} + +module.exports = { + // used by conciergeIngest.js + getPlacesRecommendations, +}; + + diff --git a/src/utils/conciergeIngest.js b/src/utils/conciergeIngest.js index 12886e0..8d2edb4 100644 --- a/src/utils/conciergeIngest.js +++ b/src/utils/conciergeIngest.js @@ -7,6 +7,7 @@ const { getBufferFromS3Key } = require('./s3Media'); const { extractTextFromPdfBuffer } = require('./conciergeDocumentExtract'); const { crawlUrlSource, extractTextFromUrl } = require('./conciergeUrlCrawl'); const { getFoodRecommendations } = require('../services/foodRecommendationService'); +const { getPlacesRecommendations } = require('../services/placesRecommendationService'); async function extractTextFromPdf(sourcePath) { const buffer = await getBufferFromS3Key(sourcePath); @@ -40,6 +41,29 @@ async function extractTextFromFood(eventId) { return await getFoodRecommendations(location, eventId); } +async function extractTextFromPlaces(eventId) { + const Event = require('../models/Event'); + + const event = await Event.findById(eventId); + + if (!event) { + throw new Error('Event not found'); + } + + const location = + event.city || + event.venue || + event.location || + event.address; + + if (!location) { + throw new Error('No event location found'); + } + + return await getPlacesRecommendations(location, eventId); +} + + /** * @param {import('../models/KnowledgeSource')} source */ @@ -91,6 +115,8 @@ async function runConciergeIngest(sourceId) { text = await extractTextFromEvent(source.sourcePath || source.eventId); } else if (source.type === 'food') { text = await extractTextFromFood(source.sourcePath || source.eventId); + } else if (source.type === 'places') { + text = await extractTextFromPlaces(source.sourcePath || source.eventId); } else { throw new Error(`Unknown source type: ${source.type}`); } @@ -135,4 +161,6 @@ module.exports = { extractTextFromPdf, extractTextFromEvent, extractTextFromFood, + extractTextFromPlaces, }; + diff --git a/src/utils/conciergeIntent.js b/src/utils/conciergeIntent.js index d2689b3..35b3a0a 100644 --- a/src/utils/conciergeIntent.js +++ b/src/utils/conciergeIntent.js @@ -43,42 +43,44 @@ function tryParse(s) { function classifyByRules(message) { const text = String(message || '').toLowerCase(); + const foodRegex = + /\b(food|eat|restaurant|restaurants|cafe|cafes|vendor|menu|breakfast|lunch|dinner|coffee|tea|vegetarian|vegan|pizza|burger|sandwich|snack|snacks|meal|meals|drink|drinks|juice|dessert|biryani|misal|dosa|idli|pav\s*bhaji|vada\s*pav)\b/; + const placesRegex = + /\b(hall|room|booth|parking|restroom|bathroom|gate|entrance|exit|directions|map|stage|tourist|tourists|attraction|attractions|sightseeing|landmark|landmarks|monument|monuments|museum|museums|temple|temples|park|parks|viewpoint|viewpoints|mall|malls|shopping|shopping\s*mall|shopping\s*malls|shopping\s*center|shopping\s*centers|market|markets|places\s*to\s*visit|things\s*to\s*do)\b/; + const personalRegex = + /\b(my|mine|me|i|am i)\b.*\b(ticket|badge|registration|registered|qr|check.?in|seat|profile|sessions?|schedule|agenda|itinerary|payment|paid|transaction|email|phone|mobile|contact|name|designation|company|organization)\b|\b(ticket|badge|registration|registered|qr|check.?in|seat|profile|sessions?|schedule|agenda|itinerary|payment|paid|transaction|email|phone|mobile|contact|name|designation|company|organization)\b.*\b(my|mine|me|i|am i)\b/; + const eventAgendaRegex = + /\b(event(?:'s)?\s+(?:agenda|schedule|program|programme)|(?:agenda|schedule|program|programme)\s+(?:(?:of|for)\s+)?(?:the\s+)?event)\b/; + const eventRegex = + /\b(event|about|session|sessions|schedule|agenda|speaker|speakers|workshop|workshops|keynote|program|programme|entry\s*fee|fee|fees|price|pricing|cost|ticket\s*price|venue|date|time|timing|location)\b/; - // PERSONAL - if ( - /\b(my|am i|me)\b/.test(text) && - /\b(ticket|badge|registration|registered|qr|check.?in|seat|profile)\b/.test(text) - ) { - return { category: 'Personal Information', databaseSearchNeeded: true }; - } - - // EVENT - if ( - /\b(session|sessions|schedule|agenda|speaker|speakers|workshop|workshops|keynote|program)\b/.test( - text, - ) - ) { - return { category: 'Event Information', databaseSearchNeeded: true }; - } - - // FOOD - if ( - /\b(food|eat|restaurant|cafe|vendor|menu|breakfast|lunch|dinner|coffee|tea|vegetarian|vegan)\b/.test( - text, - ) - ) { + // FOOD: food words should win even when the query also mentions sessions, venue, or "my". + if (foodRegex.test(text)) { return { category: 'Food', databaseSearchNeeded: true }; } // PLACES - if ( - /\b(hall|room|booth|parking|restroom|bathroom|gate|entrance|exit|directions|map|stage)\b/.test( - text, - ) - ) { + if (placesRegex.test(text)) { return { category: 'Places', databaseSearchNeeded: true }; } + // An explicit event agenda/schedule is event information. This must run before + // the personal rule so polite phrasing such as "show me the event agenda" + // does not treat "me" as a sign of personal ownership. + if (eventAgendaRegex.test(text)) { + return { category: 'Event Information', databaseSearchNeeded: true }; + } + + // PERSONAL + if (personalRegex.test(text)) { + return { category: 'Personal Information', databaseSearchNeeded: true }; + } + + // EVENT + if (eventRegex.test(text)) { + return { category: 'Event Information', databaseSearchNeeded: true }; + } + // GREETINGS if (/\b(hello|hi|hey|good morning|good afternoon)\b/.test(text)) { return { category: 'unrecognized', databaseSearchNeeded: false }; @@ -99,16 +101,18 @@ Classify the CURRENT USER MESSAGE into exactly ONE category from: Categories: - Event Information: schedules, sessions, workshops, speakers, agendas, programs, event details -- Personal Information: my registration, my ticket, badge, QR code, check-in, attendee profile +- Personal Information: my registration, ticket, badge, QR code, check-in, attendee profile, saved sessions, payment, registered email, or phone number - Food: food, meals, restaurants, cafes, vendors, dietary preferences - Places: halls, rooms, parking, restrooms, directions, booths - unrecognized: greetings, compliments, or unrelated requests Rules: -- If the message is about the user themselves (ticket/badge/QR/check-in/profile), use Personal Information. -- If the message is about what/when/where inside the event (schedule, speakers, sessions, workshops, agenda, program), use Event Information. -- If the message asks what to eat or where to eat, use Food. +- If the message asks what to eat or where to eat, use Food even if it also mentions schedule, session, venue, or "my". +- If the message asks for restaurants, cafes, lunch, dinner, pizza, vegetarian food, coffee, or any meal, use Food. - If the message asks for navigation inside the venue (directions/maps/where is X room/stage/booth), use Places. +- If the message asks for tourist attractions, sightseeing, malls, markets, or places to visit, use Places. +- If the message is about the user themselves (my ticket/badge/QR/check-in/profile/registered sessions/personal schedule/payment/email/phone), use Personal Information. +- If the message is about what/when/where inside the event (schedule, speakers, sessions, workshops, agenda, program), use Event Information. Also decide whether the backend should perform a knowledge base lookup: - databaseSearchNeeded: true when the answer depends on event knowledge or places/food knowledge. diff --git a/src/utils/conciergeIntent.test.js b/src/utils/conciergeIntent.test.js new file mode 100644 index 0000000..0223869 --- /dev/null +++ b/src/utils/conciergeIntent.test.js @@ -0,0 +1,18 @@ +const { determineIntentCategory } = require('./conciergeIntent'); + +describe('concierge personal intent classification', () => { + test.each([ + 'Which sessions have I registered for?', + 'Show my registered events.', + 'What is my schedule today?', + 'What is my payment status?', + 'Which email is on my profile?', + 'Tell me my phone number', + ])('classifies %s as personal information', async (message) => { + const result = await determineIntentCategory([{ role: 'user', text: message }]); + expect(result).toEqual({ + category: 'Personal Information', + databaseSearchNeeded: true, + }); + }); +});