Add personal information and tourist places support to concierge pipeline

This commit is contained in:
Tanuja Pansare 2026-07-01 16:45:54 +05:30
parent 347005322a
commit 71d8685685
20 changed files with 2349 additions and 280 deletions

View File

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

View File

@ -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

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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,
},

View File

@ -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 },

View File

@ -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 },

View File

@ -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} }
];

View File

@ -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,
},
},
];

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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 {

View File

@ -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(),
};

View File

@ -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 };

View File

@ -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');
});
});

View File

@ -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: <string> }
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,
};

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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.

View File

@ -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,
});
});
});