Add personal information and tourist places support to concierge pipeline
This commit is contained in:
parent
347005322a
commit
71d8685685
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
var bubble = el('div', { class: 'ebc-msg' + (role === 'user' ? ' ebc-user' : '') });
|
||||
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' : '') });
|
||||
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
|
||||
|
|
|
|||
|
|
@ -388,37 +388,220 @@ 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 });
|
||||
const eventId = bot.event._id || bot.event;
|
||||
|
||||
let source = await KnowledgeSource.findOne({
|
||||
eventId,
|
||||
type: 'event',
|
||||
});
|
||||
|
||||
if (!source) {
|
||||
source = await KnowledgeSource.create({
|
||||
eventId,
|
||||
type,
|
||||
type: 'event',
|
||||
sourcePath: String(eventId),
|
||||
displayName,
|
||||
displayName: 'Event programme & links',
|
||||
status: 'PENDING',
|
||||
});
|
||||
} else {
|
||||
source.status = 'PENDING';
|
||||
source.errorMessage = null;
|
||||
source.displayName = displayName;
|
||||
source.displayName = 'Event programme & links';
|
||||
await source.save();
|
||||
}
|
||||
|
||||
|
|
@ -426,38 +609,37 @@ const syncEventKnowledge = async (request, h) => {
|
|||
sourceId: String(source._id),
|
||||
botId: String(bot._id),
|
||||
eventId: String(eventId),
|
||||
type,
|
||||
type: 'event',
|
||||
});
|
||||
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();
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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} }
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue